mirror of
https://github.com/Teach2Breach/Tempest
synced 2026-06-08 12:38:56 +00:00
initial gui dev
This commit is contained in:
+2
-1
@@ -1 +1,2 @@
|
||||
.vscode
|
||||
.vscode
|
||||
*./target
|
||||
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "conduit_gui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
dioxus = "0.5"
|
||||
dioxus-desktop = "0.5"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.11.4", features = ["json", "native-tls", "tokio-native-tls"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
config = "0.10.1"
|
||||
base64 = "0.21.2"
|
||||
rfd = "0.14"
|
||||
chrono = "0.4"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
use dioxus::prelude::*;
|
||||
use crate::{AppState};
|
||||
use crate::services::api;
|
||||
use crate::models::ImpInfo;
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
|
||||
#[component]
|
||||
pub fn Dashboard(state: AppState) -> Element {
|
||||
let imps = use_signal(|| Vec::<ImpInfo>::new());
|
||||
let connection_msg = use_signal(|| None as Option<String>);
|
||||
|
||||
// polling
|
||||
{
|
||||
let state_clone = state.clone();
|
||||
let mut imps_sig = imps.clone();
|
||||
let mut conn_sig = connection_msg.clone();
|
||||
use_future(move || async move {
|
||||
loop {
|
||||
if let (Some(tok), url) = (state_clone.token.read().clone(), state_clone.base_url.read().clone()) {
|
||||
match api::fetch_imps(&url, tok.as_str()).await {
|
||||
Ok(list) => {
|
||||
*imps_sig.write() = list;
|
||||
if conn_sig.read().is_some() {
|
||||
*conn_sig.write() = Some("Connection re-established".to_string());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
*conn_sig.write() = Some(format!("Error fetching imp info: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let open_session = { move |_session: String, _sleep: String, _os: String| { /* placeholder */ } };
|
||||
|
||||
let rows_vec: Vec<ImpInfo> = imps.read().iter().cloned().collect();
|
||||
let status_msg = connection_msg.read().as_ref().cloned();
|
||||
let rows_iter = rows_vec.into_iter().map(|imp| {
|
||||
let sleep_secs = imp.sleep.parse::<u64>().unwrap_or(0);
|
||||
let last_dt: DateTime<Utc> = NaiveDateTime::parse_from_str(&imp.last_check_in, "%Y-%m-%d %H:%M:%S")
|
||||
.map(|naive| DateTime::from_naive_utc_and_offset(naive, Utc))
|
||||
.unwrap_or_else(|_| Utc::now());
|
||||
let diff = Utc::now().signed_duration_since(last_dt);
|
||||
let is_stale = diff.num_seconds() as u64 > sleep_secs + 60;
|
||||
let color_class = if is_stale { "stale" } else { "fresh" };
|
||||
|
||||
let session = imp.session.clone();
|
||||
let sleep_s = imp.sleep.clone();
|
||||
let os = imp.os.clone();
|
||||
|
||||
rsx!(
|
||||
tr { onclick: move |_| open_session(session.clone(), sleep_s.clone(), os.clone()),
|
||||
td { class: "{color_class}", "{imp.short_session()}" }
|
||||
td { class: "{color_class}", "{imp.ip}" }
|
||||
td { class: "{color_class}", "{imp.username}" }
|
||||
td { class: "{color_class}", "{imp.domain}" }
|
||||
td { class: "{color_class}", "{imp.os}" }
|
||||
td { class: "{color_class}", "{imp.imp_pid}" }
|
||||
td { class: "{color_class}", "{imp.process_name}" }
|
||||
td { class: "{color_class}", "{imp.sleep}" }
|
||||
td { class: "{color_class}", "{imp.last_check_in}" }
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
rsx! {
|
||||
div { class: "dashboard",
|
||||
if let Some(msg) = status_msg {
|
||||
div { class: "status", "{msg}" }
|
||||
}
|
||||
table { class: "imp_table",
|
||||
thead { tr {
|
||||
th { "Session" }
|
||||
th { "IP" }
|
||||
th { "Username" }
|
||||
th { "Domain" }
|
||||
th { "OS" }
|
||||
th { "PID" }
|
||||
th { "Process" }
|
||||
th { "Sleep" }
|
||||
th { "Last Check In" }
|
||||
}}
|
||||
tbody { {rows_iter} }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
use dioxus::prelude::*;
|
||||
use crate::services::{api, cfg};
|
||||
|
||||
use crate::Route;
|
||||
use crate::AppState;
|
||||
|
||||
#[component]
|
||||
pub fn Login(state: AppState) -> Element {
|
||||
let url = use_signal(|| String::new());
|
||||
let username = use_signal(|| String::new());
|
||||
let password = use_signal(|| String::new());
|
||||
let error = use_signal(|| None as Option<String>);
|
||||
|
||||
let on_submit = {
|
||||
let url = url.clone();
|
||||
let username = username.clone();
|
||||
let password = password.clone();
|
||||
let state_clone = state.clone();
|
||||
let error = error.clone();
|
||||
move |_| {
|
||||
let url_val = url.read().clone();
|
||||
let username_val = username.read().clone();
|
||||
let password_val = password.read().clone();
|
||||
|
||||
spawn(async move {
|
||||
let mut base = url_val;
|
||||
if let Some(port) = cfg::load_server_port() {
|
||||
base = cfg::append_port_if_missing(base, port);
|
||||
}
|
||||
|
||||
match api::authenticate(&base, &username_val, &password_val).await {
|
||||
Ok(tok) => {
|
||||
*state_clone.base_url.write() = base;
|
||||
*state_clone.token.write() = Some(tok);
|
||||
*state_clone.route.write() = Route::Dashboard;
|
||||
}
|
||||
Err(e) => {
|
||||
*error.write() = Some(format!("Login failed: {}", e));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "login",
|
||||
div { class: "field",
|
||||
label { "Server URL" }
|
||||
input { r#type: "text", value: "{url}", oninput: move |e| url.set(e.value()) }
|
||||
}
|
||||
div { class: "field",
|
||||
label { "Username" }
|
||||
input { r#type: "text", value: "{username}", oninput: move |e| username.set(e.value()) }
|
||||
}
|
||||
div { class: "field",
|
||||
label { "Password" }
|
||||
input { r#type: "password", value: "{password}", oninput: move |e| password.set(e.value()) }
|
||||
}
|
||||
div { class: "actions",
|
||||
button { onclick: on_submit, "Login" }
|
||||
}
|
||||
if let Some(err) = &*error.read() {
|
||||
div { class: "error", "{err}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_desktop::launch::launch;
|
||||
use dioxus_desktop::Config as DesktopConfig;
|
||||
|
||||
mod models;
|
||||
mod services;
|
||||
mod components {
|
||||
pub mod dashboard;
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum Route {
|
||||
Login,
|
||||
Dashboard,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct AppState {
|
||||
base_url: Signal<String>,
|
||||
token: Signal<Option<String>>,
|
||||
route: Signal<Route>,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
launch(App, vec![], DesktopConfig::default());
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn App() -> Element {
|
||||
// global signals
|
||||
let base_url = use_signal(|| String::new());
|
||||
let token = use_signal(|| None as Option<String>);
|
||||
let route = use_signal(|| Route::Login);
|
||||
|
||||
let state = AppState { base_url, token, route };
|
||||
|
||||
// local login signals
|
||||
let mut url = use_signal(|| String::new());
|
||||
let mut username = use_signal(|| String::new());
|
||||
let mut password = use_signal(|| String::new());
|
||||
let mut error = use_signal(|| None as Option<String>);
|
||||
|
||||
let on_submit = {
|
||||
let mut state_clone = state.clone();
|
||||
move |_| {
|
||||
let url_val = url.read().clone();
|
||||
let username_val = username.read().clone();
|
||||
let password_val = password.read().clone();
|
||||
spawn(async move {
|
||||
let mut base = url_val;
|
||||
if let Some(port) = services::cfg::load_server_port() {
|
||||
base = services::cfg::append_port_if_missing(base, port);
|
||||
}
|
||||
match services::api::authenticate(&base, &username_val, &password_val).await {
|
||||
Ok(tok) => {
|
||||
*state_clone.base_url.write() = base;
|
||||
*state_clone.token.write() = Some(tok);
|
||||
*state_clone.route.write() = Route::Dashboard;
|
||||
}
|
||||
Err(e) => {
|
||||
*error.write() = Some(format!("Login failed: {}", e));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
rsx! {
|
||||
style { "{include_str!(\"./styles.css\")}", }
|
||||
div { class: "root",
|
||||
header { class: "app_header", h1 { "Tempest Conduit GUI" } }
|
||||
main { class: "app_main",
|
||||
match *state.route.read() {
|
||||
Route::Login => rsx!(
|
||||
div { class: "login",
|
||||
div { class: "field",
|
||||
label { "Server URL" }
|
||||
input { r#type: "text", value: "{url}", oninput: move |e| url.set(e.value()) }
|
||||
}
|
||||
div { class: "field",
|
||||
label { "Username" }
|
||||
input { r#type: "text", value: "{username}", oninput: move |e| username.set(e.value()) }
|
||||
}
|
||||
div { class: "field",
|
||||
label { "Password" }
|
||||
input { r#type: "password", value: "{password}", oninput: move |e| password.set(e.value()) }
|
||||
}
|
||||
div { class: "actions",
|
||||
button { onclick: on_submit, "Login" }
|
||||
}
|
||||
if let Some(err) = &*error.read() {
|
||||
div { class: "error", "{err}" }
|
||||
}
|
||||
}
|
||||
),
|
||||
Route::Dashboard => rsx!(components::dashboard::Dashboard { state: state.clone() }),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct ImpInfo {
|
||||
pub session: String,
|
||||
pub ip: String,
|
||||
pub username: String,
|
||||
pub domain: String,
|
||||
pub os: String,
|
||||
pub imp_pid: String,
|
||||
pub process_name: String,
|
||||
pub sleep: String,
|
||||
pub last_check_in: String,
|
||||
}
|
||||
|
||||
impl ImpInfo {
|
||||
pub fn short_session(&self) -> String {
|
||||
let s = self.session.clone();
|
||||
s.chars()
|
||||
.rev()
|
||||
.take(8)
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect::<String>()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
use reqwest::{header::{HeaderMap, HeaderName, HeaderValue}, ClientBuilder};
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use crate::models::ImpInfo;
|
||||
|
||||
pub async fn authenticate(url: &str, username: &str, password: &str) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("https://{}/authenticate", url);
|
||||
let client = ClientBuilder::new()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()?;
|
||||
let up64 = general_purpose::STANDARD.encode(format!("{}:{}", username, password));
|
||||
let auth = format!("Basic {}", up64);
|
||||
let res = client.post(&url).header("Authorization", auth).send().await?;
|
||||
if res.status().is_success() {
|
||||
Ok(res.text().await?.trim().to_string())
|
||||
} else {
|
||||
Err(format!("Login failed: {}", res.status()).into())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch_imps(url: &str, token: &str) -> Result<Vec<ImpInfo>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("https://{}/imps", url);
|
||||
let client = ClientBuilder::new().danger_accept_invalid_certs(true).build()?;
|
||||
let res = client.get(url).header("X-Token", token).send().await?;
|
||||
if res.status().is_success() {
|
||||
let body = res.text().await?;
|
||||
// First try direct object-based deserialization (expected form)
|
||||
if let Ok(imps) = serde_json::from_str::<Vec<ImpInfo>>(&body) {
|
||||
return Ok(imps);
|
||||
}
|
||||
// Fallback: tolerate array-form rows with 8 or 9 elements
|
||||
if let Ok(rows) = serde_json::from_str::<Vec<Vec<serde_json::Value>>>(&body) {
|
||||
let mapped: Vec<ImpInfo> = rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let get = |i: usize| -> String {
|
||||
r.get(i)
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
match r.len() {
|
||||
// 9-element layout: [session, ip, username, domain, os, pid, process, sleep, last_check_in]
|
||||
9 | 10 => ImpInfo {
|
||||
session: get(0),
|
||||
ip: get(1),
|
||||
username: get(2),
|
||||
domain: get(3),
|
||||
os: get(4),
|
||||
imp_pid: get(5),
|
||||
process_name: get(6),
|
||||
sleep: get(7),
|
||||
last_check_in: get(8),
|
||||
},
|
||||
// 8-element layout (observed): [session, username, domain, os, pid, process, sleep, last_check_in]
|
||||
8 => ImpInfo {
|
||||
session: get(0),
|
||||
ip: String::new(),
|
||||
username: get(1),
|
||||
domain: get(2),
|
||||
os: get(3),
|
||||
imp_pid: get(4),
|
||||
process_name: get(5),
|
||||
sleep: get(6),
|
||||
last_check_in: get(7),
|
||||
},
|
||||
// Fallback: best-effort mapping
|
||||
_ => ImpInfo {
|
||||
session: get(0),
|
||||
ip: get(1),
|
||||
username: get(2),
|
||||
domain: get(3),
|
||||
os: get(4),
|
||||
imp_pid: get(5),
|
||||
process_name: get(6),
|
||||
sleep: get(7),
|
||||
last_check_in: get(8),
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
return Ok(mapped);
|
||||
}
|
||||
// If both fail, return original error
|
||||
return Err("Failed to parse imp info response".into());
|
||||
} else {
|
||||
Err(format!("Failed to retrieve imp info: {}", res.status()).into())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn issue_task(url: &str, token: &str, session_id: &str, task: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("https://{}/issue_task", url);
|
||||
let client = ClientBuilder::new().danger_accept_invalid_certs(true).build()?;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(HeaderName::from_static("x-token"), HeaderValue::from_str(token)?);
|
||||
headers.insert(HeaderName::from_static("x-session"), HeaderValue::from_str(session_id)?);
|
||||
headers.insert(HeaderName::from_static("x-task"), HeaderValue::from_str(task)?);
|
||||
let res = client.post(&url).headers(headers).send().await?;
|
||||
if res.status().is_success() { Ok(()) } else { Err("Failed to issue task".into()) }
|
||||
}
|
||||
|
||||
pub async fn retrieve_all_out(url: &str, token: &str) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("https://{}/retrieve_all_out", url);
|
||||
let client = ClientBuilder::new().danger_accept_invalid_certs(true).build()?;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(HeaderName::from_static("x-token"), HeaderValue::from_str(token)?);
|
||||
let res = client.get(&url).headers(headers).send().await?;
|
||||
Ok(res.text().await?)
|
||||
}
|
||||
|
||||
pub async fn build_imp(url: &str, token: &str, target: &str, target_ip: &str, target_port: &str, tsleep: &str, format: &str, jitter: &str) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("https://{}/build_imp", url);
|
||||
let client = ClientBuilder::new().danger_accept_invalid_certs(true).build()?;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(HeaderName::from_static("x-token"), HeaderValue::from_str(token)?);
|
||||
headers.insert(HeaderName::from_static("x-target"), HeaderValue::from_str(target)?);
|
||||
headers.insert(HeaderName::from_static("x-target-ip"), HeaderValue::from_str(target_ip)?);
|
||||
headers.insert(HeaderName::from_static("x-target-port"), HeaderValue::from_str(target_port)?);
|
||||
headers.insert(HeaderName::from_static("x-tsleep"), HeaderValue::from_str(tsleep)?);
|
||||
headers.insert(HeaderName::from_static("x-format"), HeaderValue::from_str(format)?);
|
||||
headers.insert(HeaderName::from_static("x-jitter"), HeaderValue::from_str(jitter)?);
|
||||
let res = client.post(&url).headers(headers).send().await?;
|
||||
if !res.status().is_success() { return Err(format!("Server returned error: {}", res.status()).into()); }
|
||||
let bytes = res.bytes().await?;
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
pub async fn bofload(url: &str, token: &str, filename: &str, bytes: Vec<u8>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
use reqwest::header::{CONTENT_TYPE, USER_AGENT};
|
||||
let url = format!("https://{}/bofload", url);
|
||||
let client = ClientBuilder::new().danger_accept_invalid_certs(true).build()?;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("X-Filename", HeaderValue::from_str(filename)?);
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/octet-stream"));
|
||||
headers.insert(USER_AGENT, HeaderValue::from_static("reqwest"));
|
||||
headers.insert("X-Token", HeaderValue::from_str(token)?);
|
||||
let res = client.post(url).headers(headers).body(bytes).send().await?;
|
||||
if res.status().is_success() { Ok(()) } else { Err(format!("Upload failed: {}", res.status()).into()) }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
use config::{Config, File};
|
||||
|
||||
pub fn load_server_port() -> Option<u16> {
|
||||
let mut settings = Config::default();
|
||||
if settings.merge(File::with_name("conduit/config")).is_ok() {
|
||||
if let Ok(port) = settings.get::<u16>("server.port") {
|
||||
return Some(port);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn append_port_if_missing(mut url: String, port: u16) -> String {
|
||||
if !url.contains(':') {
|
||||
url = format!("{}:{}", url, port);
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod api;
|
||||
pub mod cfg;
|
||||
pub mod output;
|
||||
pub mod upload;
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
use base64::{alphabet, engine, Engine as _};
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
|
||||
const CUSTOM_ENGINE: engine::GeneralPurpose = engine::GeneralPurpose::new(&alphabet::URL_SAFE, engine::general_purpose::NO_PAD);
|
||||
|
||||
pub fn decode_base64_urlsafe_no_pad(s: &str) -> Option<String> {
|
||||
if let Ok(bytes) = CUSTOM_ENGINE.decode(s) {
|
||||
return String::from_utf8(bytes).ok();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn process_retrieved_output(raw: &str) -> Vec<String> {
|
||||
// raw may contain concatenated outputs; split into lines for UI consumption
|
||||
raw.lines().map(|l| l.to_string()).collect()
|
||||
}
|
||||
|
||||
// Very similar to TUI logic: detect getfile, decode trailing blob, save to loot/<filename>
|
||||
pub fn try_handle_getfile(decoded_output: &str) -> Option<String> {
|
||||
if decoded_output.contains("getfile") {
|
||||
// trailing token is base64 file data
|
||||
let b64 = decoded_output.split_whitespace().last()?;
|
||||
if let Ok(content) = CUSTOM_ENGINE.decode(b64) {
|
||||
// derive filename from 3rd token (full remote path), then basename, then save to loot/
|
||||
let remote = decoded_output.split_whitespace().nth(2)?;
|
||||
let filename = remote.split(['\\', '/']).last()?;
|
||||
let filename = filename.trim_end_matches(':');
|
||||
let path = format!("loot/{}", filename);
|
||||
if let Ok(mut f) = File::create(&path) {
|
||||
let _ = f.write_all(&content);
|
||||
return Some(format!("File saved to: {}", path));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
use rfd::FileDialog;
|
||||
use std::fs;
|
||||
|
||||
pub fn pick_file() -> Option<(String, Vec<u8>)> {
|
||||
if let Some(path) = FileDialog::new().pick_file() {
|
||||
let filename = path.file_name()?.to_string_lossy().to_string();
|
||||
let bytes = fs::read(&path).ok()?;
|
||||
Some((filename, bytes))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.root { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; color: #eaeaea; background: #0f0f12; height: 100vh; }
|
||||
.app_header { padding: 12px 16px; border-bottom: 1px solid #2a2a34; }
|
||||
.app_main { padding: 16px; }
|
||||
.login .field { margin-bottom: 12px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.login input { padding: 8px; border: 1px solid #3a3a45; background: #141419; color: #eaeaea; border-radius: 6px; }
|
||||
.actions button { padding: 8px 14px; background: #2f6fed; border: none; color: #fff; border-radius: 6px; cursor: pointer; }
|
||||
.error { color: #ff6b6b; margin-top: 8px; }
|
||||
.status { color: #a0a0ff; margin-bottom: 8px; }
|
||||
.imp_table { border-collapse: collapse; width: 100%; }
|
||||
.imp_table th, .imp_table td { border: 1px solid #2a2a34; padding: 6px 8px; }
|
||||
.imp_table thead { background: #171722; }
|
||||
.imp_table tbody tr { cursor: pointer; }
|
||||
.imp_table tbody tr:hover { background: #1b1b28; }
|
||||
.imp_table .fresh { color: #ffffff; }
|
||||
.imp_table .stale { color: #ff5a5a; }
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
## Tempest GUI (Dioxus) — Technical Implementation Plan
|
||||
|
||||
This document defines a step-by-step plan to build a Rust GUI client that achieves feature parity with the existing `conduit` TUI client, using Dioxus for the UI. Steps are intentionally small and LLM-friendly so we can implement them incrementally and refer back as we go.
|
||||
|
||||
### Goals
|
||||
- Reproduce all `conduit` capabilities in a GUI:
|
||||
- Secure auth against Anvil and token handling
|
||||
- Live dashboard of imps with status and last check-in
|
||||
- Session interaction (issue tasks, view output)
|
||||
- Build new implants
|
||||
- File operations: sendfile/getfile, bof upload, inject/runpe upload flows
|
||||
- SOCKS command issuance
|
||||
- Persistent and scrollable output view with continuous polling
|
||||
- Config-driven server port (read `conduit/config.toml`), append port to URL if missing
|
||||
|
||||
### High-Level Architecture
|
||||
- Crate: `conduit_gui` at repo root (sibling to `conduit/` and `Anvil/`).
|
||||
- Tech stack:
|
||||
- UI: `dioxus`, `dioxus-desktop` (Wry-based; no need for separate Tauri unless later desired)
|
||||
- Async/runtime: `tokio`
|
||||
- HTTP: `reqwest` (TLS with `danger_accept_invalid_certs(true)` to match TUI behavior)
|
||||
- Data: `serde`, `serde_json`
|
||||
- Config: `config` crate to read `conduit/config.toml` for server port
|
||||
- Base64: `base64`
|
||||
- File dialogs: `rfd` (open/save file picker)
|
||||
- State mgmt: Dioxus hooks (`use_signal`/`use_state`, `use_coroutine`, `use_context`) and small service layer
|
||||
|
||||
### API Contract (parity with TUI)
|
||||
- POST `https://{url}/authenticate`
|
||||
- Header: `Authorization: Basic <base64(username:password)>`
|
||||
- Returns: token string body
|
||||
- GET `https://{url}/imps`
|
||||
- Header: `X-Token: <token>`
|
||||
- Returns: JSON `Vec<ImpInfo>`
|
||||
- POST `https://{url}/issue_task`
|
||||
- Headers: `x-token`, `x-session`, `x-task`
|
||||
- Returns: 2xx on success
|
||||
- GET `https://{url}/retrieve_all_out`
|
||||
- Header: `x-token`
|
||||
- Returns: base64 (URL_SAFE, NO_PAD) encoded concatenated output string
|
||||
- POST `https://{url}/build_imp`
|
||||
- Headers: `x-token`, `x-target`, `x-target-ip`, `x-target-port`, `x-tsleep`, `x-format`, `x-jitter`
|
||||
- Returns: binary bytes (write file with appropriate extension as TUI does)
|
||||
- POST `https://{url}/bofload`
|
||||
- Headers: `X-Filename`, `Content-Type: application/octet-stream`, `X-Token`
|
||||
- Body: raw binary
|
||||
|
||||
### Data Models (mirror TUI)
|
||||
- `ImpInfo` fields: `session`, `ip`, `username`, `domain`, `os`, `imp_pid`, `process_name`, `sleep`, `last_check_in`.
|
||||
- Output stream: decoded string lines; save file artifacts under `loot/` as in TUI.
|
||||
|
||||
### UI Flow
|
||||
1. Login Screen
|
||||
- Inputs: URL, Username, Password
|
||||
- Automatically append port from `conduit/config.toml` if URL lacks port
|
||||
- On submit → authenticate → store token → navigate to Dashboard
|
||||
|
||||
2. Dashboard Screen
|
||||
- Table of imps (scrollable): columns as TUI; session shown as last 8 chars
|
||||
- Row color red if last-check-in is older than sleep + 60s (match TUI logic)
|
||||
- Buttons/actions:
|
||||
- Open Session (select an imp)
|
||||
- Build Implant (opens modal/dialog)
|
||||
- Refresh (manual)
|
||||
- Background task: poll `/imps` every second, update list and connection status messages
|
||||
|
||||
3. Session Screen
|
||||
- Header with imp identity (session short, ip, user, os)
|
||||
- Large Output pane (scrollable, preserves up to N lines, e.g. 1000)
|
||||
- Command input with help hint; Enter sends
|
||||
- Background task: periodic `retrieve_all_out` and distribute new lines; handle getfile special-case decoding and save to `loot/` (same logic as TUI)
|
||||
- Commands: same set as TUI; special flows for `sendfile`, `bof`, `inject`, `runpe` to upload file first, then issue task with filename
|
||||
|
||||
4. Build Implant Dialog
|
||||
- Inputs: target_os, format, target_ip, target_port, sleep, jitter
|
||||
- Calls `/build_imp` and saves file following TUI extension rules
|
||||
|
||||
### Directory Layout
|
||||
```
|
||||
conduit_gui/
|
||||
Cargo.toml
|
||||
src/
|
||||
main.rs // Dioxus app entry
|
||||
app.rs // Router and global state
|
||||
models.rs // ImpInfo and DTOs
|
||||
services/
|
||||
mod.rs
|
||||
api.rs // Reqwest calls, header building
|
||||
output.rs // decode logic, loot saving
|
||||
build.rs // build_imp, file save helper
|
||||
upload.rs // bofload and file helpers
|
||||
components/
|
||||
login.rs
|
||||
dashboard.rs
|
||||
session.rs
|
||||
build_dialog.rs
|
||||
ui/
|
||||
table.rs // table helpers/styles if needed
|
||||
```
|
||||
|
||||
### Step-by-Step Implementation (LLM-friendly tasks)
|
||||
|
||||
1) Bootstrap crate [DONE]
|
||||
- Created `conduit_gui/` with Dioxus Desktop, Tokio, Reqwest, Serde, Config, Base64, RFD; added release profile similar to `conduit`.
|
||||
- Implemented runnable Desktop entry using `dioxus_desktop::launch(App, vec![], Config::default())` with a minimal UI shell and CSS.
|
||||
|
||||
2) Shared models
|
||||
- Create `models.rs` with `ImpInfo` struct matching TUI (serde derive).
|
||||
- Add helper to compute short-session (last 8 chars).
|
||||
|
||||
3) Config loader
|
||||
- Implement `services::config` that loads `conduit/config.toml` (port) and offers `append_port_if_missing(url, port)`.
|
||||
|
||||
4) API service layer [DONE - scaffold]
|
||||
- `services::api` functions:
|
||||
- `authenticate(url, username, password) -> Result<String, Error>`
|
||||
- `fetch_imps(url, token) -> Result<Vec<ImpInfo>, Error>`
|
||||
- `issue_task(url, token, session_id, task) -> Result<(), Error>`
|
||||
- `retrieve_all_out(url, token) -> Result<String, Error>` (raw base64)
|
||||
- `build_imp(url, token, params) -> Result<Vec<u8>, Error>`
|
||||
- `bofload(url, token, filename, bytes) -> Result<(), Error>`
|
||||
- All requests: `danger_accept_invalid_certs(true)` to mirror TUI
|
||||
Notes: Implemented per TUI semantics; not yet wired into UI beyond login.
|
||||
|
||||
5) Output handling [DONE - scaffold]
|
||||
- `services::output`:
|
||||
- `decode_output(raw: String) -> String` using URL_SAFE NO_PAD
|
||||
- Special-case `getfile` results: detect path and final base64 blob, decode and save to `loot/`, return friendly message path
|
||||
- Utility to split into lines and dedupe last-seen entry (match TUI’s previous_output check)
|
||||
Notes: Added helpers; dedupe logic to be added when wiring session view.
|
||||
|
||||
6) File ops helpers [DONE - scaffold]
|
||||
- `services::upload`:
|
||||
- `open_file_dialog() -> Option<(filename, bytes)>` via `rfd`
|
||||
- For `sendfile`, `inject`, `runpe`, `bof` upload flow: return server filename (basename) for task string
|
||||
|
||||
7) Global app state and routing
|
||||
- `app.rs` with a shared `AppState` context:
|
||||
- `token: Option<String>`
|
||||
- `base_url: String`
|
||||
- `imps: Vec<ImpInfo>`
|
||||
- `output_lines: VecDeque<String>` (session page)
|
||||
- `connection_msg: Option<String>`
|
||||
- Simple enum route: `Route::Login | Route::Dashboard | Route::Session { session_id, sleep, os }`
|
||||
- Provide context and navigation helpers
|
||||
|
||||
8) Login component [DONE]
|
||||
- Fields: URL, Username, Password (masked), submit button
|
||||
- On submit: load port from config, append if needed; call `authenticate`, store token + url, navigate to Dashboard
|
||||
- Handle error display
|
||||
Notes: Implemented inline within `App` for now to reduce boilerplate. Will refactor into `components::login` later if needed.
|
||||
|
||||
9) Dashboard component [DONE - basic]
|
||||
- Implemented polling each second using `use_future(move || async move { ... })` and updating a signal-backed list.
|
||||
- Rendered table with columns matching TUI and a stale/fresh color rule (red after `sleep + 60s`).
|
||||
- Row click handler stubbed for now; will route to Session in next step.
|
||||
Notes: RSX requires owned rows for iteration; used `map(..)` to build row nodes and inserted iterator directly in `tbody`.
|
||||
|
||||
10) Session component
|
||||
- Show imp header and a scrollable output area; cap to 1000 lines
|
||||
- Command input with help hints (contextual to OS: windows/linux/generic as in TUI)
|
||||
- Background poller: every N seconds (see logic below) call `retrieve_all_out`, decode, split, push lines into output (skip dupes)
|
||||
- Command handling:
|
||||
- Direct tasks: `whoami`, `ipconfig`, `ps`, `cd`, `pwd`, `ls`, `catfile`, `cmd`, `pwsh`, `sh`, `wmi`, `socks`, `sleep`, `kill`, `q|quit`
|
||||
- `sendfile`/`bof`/`inject`/`runpe`: open file dialog, upload file via `bofload`, then issue task text with just filename (and any args)
|
||||
- On `q|quit`: navigate back to Dashboard
|
||||
|
||||
11) Poll timing logic (match TUI behavior)
|
||||
- For dashboard imps polling: fixed 1-second interval
|
||||
- For session output polling: choose interval equal to min(imp.sleep) from dashboard data or a default (3s) if unknown; add jitter behavior later if needed
|
||||
|
||||
12) Build dialog
|
||||
- Fields: `target_os`, `format`, `target_ip`, `target_port`, `sleep`, `jitter`
|
||||
- Submit calls `build_imp`, then save bytes to a file:
|
||||
- Windows: `.exe` for `exe`, `.dll` for `dll`, `.bin` for `raw`
|
||||
- Linux: no extension (match TUI)
|
||||
- Show completion toast with file path
|
||||
|
||||
13) Loot folder
|
||||
- Ensure `loot/` exists at app start; write downloads there as TUI does
|
||||
|
||||
14) Error handling & UX
|
||||
- Show inline errors (auth, network) and a reconnect message when connectivity resumes (replicate TUI’s "Connection re-established")
|
||||
- Prevent duplicate output lines; keep last N
|
||||
|
||||
15) Testing strategy
|
||||
- Unit tests for `services::output::decode_output` and getfile parsing/save
|
||||
- Smoke-tests for API layer behind a feature flag or mock server
|
||||
- Manual E2E against Anvil
|
||||
|
||||
16) Packaging & Run
|
||||
- `cargo run -p conduit_gui`
|
||||
- Later: produce single-file binaries for Windows/Linux/macOS
|
||||
|
||||
### Feature Parity Checklist
|
||||
- [ ] Auth flow with Basic auth → token persisted in memory
|
||||
- [ ] Dashboard imps list with coloring rule and scrolling
|
||||
- [ ] Session view with command input and output scrolling (1000-line cap)
|
||||
- [ ] Continuous output polling and dedupe
|
||||
- [ ] All tasks supported; special upload flows for `sendfile`/`bof`/`inject`/`runpe`
|
||||
- [ ] Build implant workflow and file save rules
|
||||
- [ ] Config port append behavior
|
||||
- [ ] Loot directory writes for getfile
|
||||
- [ ] Connection status messages
|
||||
|
||||
### Initial Task Breakdown for Implementation
|
||||
1. Create crate, deps, and hello-world Dioxus app
|
||||
2. Add models and config loader
|
||||
3. Implement API service functions (auth, fetch_imps)
|
||||
4. Build Login component → wire auth
|
||||
5. Build Dashboard with polling → render table
|
||||
6. Implement Session view scaffolding → issue_task
|
||||
7. Add retrieve_all_out polling and output decoding
|
||||
8. Implement upload flows (`bofload`) + command wiring for `bof`/`inject`/`runpe`/`sendfile`
|
||||
9. Implement Build dialog and saving rules
|
||||
10. Polish UX: scrolling, toasts, errors, connection messages
|
||||
11. Add tests for decode and file save helpers
|
||||
|
||||
### Notes
|
||||
- Dioxus Desktop is chosen for simplicity and native-feel. If we later want Tauri packaging or a web build (Dioxus Web), the architecture isolates services and components to make that feasible.
|
||||
- We mirror TUI’s permissive TLS handling for parity; consider hardening later.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user