diff --git a/.gitignore b/.gitignore index 600d2d3..0b5834c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.vscode \ No newline at end of file +.vscode +*./target \ No newline at end of file diff --git a/conduit_gui/.gitignore b/conduit_gui/.gitignore new file mode 100644 index 0000000..869df07 --- /dev/null +++ b/conduit_gui/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock \ No newline at end of file diff --git a/conduit_gui/Cargo.toml b/conduit_gui/Cargo.toml new file mode 100644 index 0000000..11bd7d6 --- /dev/null +++ b/conduit_gui/Cargo.toml @@ -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" + + diff --git a/conduit_gui/src/components/dashboard.rs b/conduit_gui/src/components/dashboard.rs new file mode 100644 index 0000000..1a569aa --- /dev/null +++ b/conduit_gui/src/components/dashboard.rs @@ -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::::new()); + let connection_msg = use_signal(|| None as Option); + + // 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 = 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::().unwrap_or(0); + let last_dt: DateTime = 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} } + } + } + } +} + + diff --git a/conduit_gui/src/components/login.rs b/conduit_gui/src/components/login.rs new file mode 100644 index 0000000..aee0e59 --- /dev/null +++ b/conduit_gui/src/components/login.rs @@ -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); + + 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}" } + } + } + } +} + + diff --git a/conduit_gui/src/main.rs b/conduit_gui/src/main.rs new file mode 100644 index 0000000..e8c0a3c --- /dev/null +++ b/conduit_gui/src/main.rs @@ -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, + token: Signal>, + route: Signal, +} + +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); + 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); + + 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() }), + } + } + } + } +} + + + diff --git a/conduit_gui/src/models.rs b/conduit_gui/src/models.rs new file mode 100644 index 0000000..38e9cb1 --- /dev/null +++ b/conduit_gui/src/models.rs @@ -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::() + .chars() + .rev() + .collect::() + } +} + + diff --git a/conduit_gui/src/services/api.rs b/conduit_gui/src/services/api.rs new file mode 100644 index 0000000..8811320 --- /dev/null +++ b/conduit_gui/src/services/api.rs @@ -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> { + 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, Box> { + 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::>(&body) { + return Ok(imps); + } + // Fallback: tolerate array-form rows with 8 or 9 elements + if let Ok(rows) = serde_json::from_str::>>(&body) { + let mapped: Vec = 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> { + 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> { + 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, Box> { + 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) -> Result<(), Box> { + 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()) } +} + + diff --git a/conduit_gui/src/services/cfg.rs b/conduit_gui/src/services/cfg.rs new file mode 100644 index 0000000..a652d24 --- /dev/null +++ b/conduit_gui/src/services/cfg.rs @@ -0,0 +1,20 @@ +use config::{Config, File}; + +pub fn load_server_port() -> Option { + let mut settings = Config::default(); + if settings.merge(File::with_name("conduit/config")).is_ok() { + if let Ok(port) = settings.get::("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 +} + + diff --git a/conduit_gui/src/services/mod.rs b/conduit_gui/src/services/mod.rs new file mode 100644 index 0000000..bf346a9 --- /dev/null +++ b/conduit_gui/src/services/mod.rs @@ -0,0 +1,6 @@ +pub mod api; +pub mod cfg; +pub mod output; +pub mod upload; + + diff --git a/conduit_gui/src/services/output.rs b/conduit_gui/src/services/output.rs new file mode 100644 index 0000000..6f7be56 --- /dev/null +++ b/conduit_gui/src/services/output.rs @@ -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 { + if let Ok(bytes) = CUSTOM_ENGINE.decode(s) { + return String::from_utf8(bytes).ok(); + } + None +} + +pub fn process_retrieved_output(raw: &str) -> Vec { + // 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/ +pub fn try_handle_getfile(decoded_output: &str) -> Option { + 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 +} + + diff --git a/conduit_gui/src/services/upload.rs b/conduit_gui/src/services/upload.rs new file mode 100644 index 0000000..6977b9b --- /dev/null +++ b/conduit_gui/src/services/upload.rs @@ -0,0 +1,14 @@ +use rfd::FileDialog; +use std::fs; + +pub fn pick_file() -> Option<(String, Vec)> { + 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 + } +} + + diff --git a/conduit_gui/src/styles.css b/conduit_gui/src/styles.css new file mode 100644 index 0000000..61e2df8 --- /dev/null +++ b/conduit_gui/src/styles.css @@ -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; } + diff --git a/techplan.md b/techplan.md new file mode 100644 index 0000000..f9f2597 --- /dev/null +++ b/techplan.md @@ -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 ` + - Returns: token string body +- GET `https://{url}/imps` + - Header: `X-Token: ` + - Returns: JSON `Vec` +- 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` + - `fetch_imps(url, token) -> Result, Error>` + - `issue_task(url, token, session_id, task) -> Result<(), Error>` + - `retrieve_all_out(url, token) -> Result` (raw base64) + - `build_imp(url, token, params) -> Result, 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` + - `base_url: String` + - `imps: Vec` + - `output_lines: VecDeque` (session page) + - `connection_msg: Option` +- 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. + +