feat: complete protocol spec and initial implementation

- Write PROTOCOL.md with full wire format spec and 8 real-world scenario
  analyses (reconnect, multi-operator, large files, AV evasion, router crash,
  malformed packets, future pivoting)

- Rewrite workspace structure:
  - unshell lib: protocol types (PacketHeader, TreeRequest/Response,
    HandshakeMessage/Ack), Transport trait, TcpTransport, Tree routing
  - ush-router: router binary with per-node threads, NodeRegistry with
    longest-prefix path matching, packet relay
  - ush-payload: implant binary with reconnect loop, module tree, InfoModule
  - ush-cli: operator REPL with rustyline, session management, command parser

- Protocol design: two-part rkyv frame [header][payload]; router reads only
  header for routing, payload bytes forwarded opaque

- All code documented with doc comments and examples
- Zero warnings, zero errors across entire workspace
- 32 tests pass (unit tests for tree routing, TCP transport, framing,
  command parsing, node registry)
This commit is contained in:
Michael Mikovsky
2026-04-20 23:38:02 -06:00
parent 959ea469a8
commit fcb3b2be17
30 changed files with 4623 additions and 658 deletions
+189
View File
@@ -0,0 +1,189 @@
//! # REPL Command Parser
//!
//! Parses lines typed in the operator REPL into structured `Command` values.
//!
//! ## Supported commands
//!
//! | Command | Description |
//! |---|---|
//! | `list` | List all connected nodes |
//! | `use <path>` | Set the current working path |
//! | `ls [path]` | List procedures at `path` (or current path) |
//! | `call <path> [data]` | Call a procedure at `path` |
//! | `read <path>` | Read a value at `path` |
//! | `write <path> <data>` | Write a value to `path` |
//! | `background` | Background the current session |
//! | `sessions` | List backgrounded sessions |
//! | `exit` | Disconnect and quit |
//! | `help` | Print this help |
/// A parsed REPL command.
#[derive(Debug, Clone, PartialEq)]
pub enum Command {
/// `list` — list all connected nodes via `/router/nodes`.
List,
/// `use <path>` — set the current working path.
Use(String),
/// `ls [path]` — `GetProcedures` at the given or current path.
Ls(Option<String>),
/// `call <path> [data]` — `CallProcedure` at `path` with optional `data`.
Call { path: String, data: Option<String> },
/// `read <path>` — `Read` at `path`.
Read(String),
/// `write <path> <data>` — `Write` at `path` with `data`.
Write { path: String, data: String },
/// `background` — push current session to background list.
Background,
/// `sessions` — list backgrounded sessions.
Sessions,
/// `exit` — disconnect and quit.
Exit,
/// `help` — print command help.
Help,
}
/// Parse a line of input into a `Command`.
///
/// Returns `None` if the line is empty or a comment (`#`).
/// Returns `Err` if the line cannot be parsed as a valid command.
///
/// # Example
///
/// ```rust
/// use ush_cli::commands::{parse, Command};
///
/// assert_eq!(parse("list").unwrap(), Some(Command::List));
/// assert_eq!(parse("use /agents/abc123").unwrap(), Some(Command::Use("/agents/abc123".into())));
/// assert_eq!(parse("").unwrap(), None);
/// assert_eq!(parse(" # comment").unwrap(), None);
/// ```
///
/// # Errors
///
/// Returns an error string if the command name is unrecognised or the
/// arguments are malformed.
pub fn parse(line: &str) -> Result<Option<Command>, String> {
let trimmed = line.trim();
// Empty lines and comments
if trimmed.is_empty() || trimmed.starts_with('#') {
return Ok(None);
}
let mut parts = trimmed.splitn(3, ' ');
let cmd = parts.next().unwrap_or("");
let arg1 = parts.next().map(str::trim);
let arg2 = parts.next().map(str::trim);
match cmd {
"list" => Ok(Some(Command::List)),
"use" => {
let path = arg1.ok_or("usage: use <path>")?;
Ok(Some(Command::Use(path.to_owned())))
}
"ls" => Ok(Some(Command::Ls(arg1.map(str::to_owned)))),
"call" => {
let path = arg1.ok_or("usage: call <path> [data]")?;
Ok(Some(Command::Call {
path: path.to_owned(),
data: arg2.map(str::to_owned),
}))
}
"read" => {
let path = arg1.ok_or("usage: read <path>")?;
Ok(Some(Command::Read(path.to_owned())))
}
"write" => {
let path = arg1.ok_or("usage: write <path> <data>")?;
let data = arg2.ok_or("usage: write <path> <data>")?;
Ok(Some(Command::Write {
path: path.to_owned(),
data: data.to_owned(),
}))
}
"background" | "bg" => Ok(Some(Command::Background)),
"sessions" => Ok(Some(Command::Sessions)),
"exit" | "quit" | "q" => Ok(Some(Command::Exit)),
"help" | "?" => Ok(Some(Command::Help)),
other => Err(format!("unknown command: {other}. Type 'help' for a list.")),
}
}
/// Print the help text for all available commands.
pub fn print_help() {
println!("Available commands:");
println!(" list List all connected nodes");
println!(" use <path> Set working path (e.g., use agents/abc123)");
println!(" ls [path] List available procedures");
println!(" call <path> [data] Call a procedure");
println!(" read <path> Read a value");
println!(" write <path> <data> Write a value");
println!(" background Background current session");
println!(" sessions List backgrounded sessions");
println!(" exit Disconnect and quit");
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_empty() {
assert_eq!(parse("").unwrap(), None);
assert_eq!(parse(" ").unwrap(), None);
assert_eq!(parse("# comment").unwrap(), None);
}
#[test]
fn parse_list() {
assert_eq!(parse("list").unwrap(), Some(Command::List));
}
#[test]
fn parse_use() {
assert_eq!(
parse("use /agents/abc123").unwrap(),
Some(Command::Use("/agents/abc123".into()))
);
}
#[test]
fn parse_ls_no_arg() {
assert_eq!(parse("ls").unwrap(), Some(Command::Ls(None)));
}
#[test]
fn parse_ls_with_arg() {
assert_eq!(
parse("ls shell").unwrap(),
Some(Command::Ls(Some("shell".into())))
);
}
#[test]
fn parse_call_with_data() {
assert_eq!(
parse("call shell/exec ls -la").unwrap(),
Some(Command::Call {
path: "shell/exec".into(),
data: Some("ls -la".into()),
})
);
}
#[test]
fn parse_exit_aliases() {
assert_eq!(parse("exit").unwrap(), Some(Command::Exit));
assert_eq!(parse("quit").unwrap(), Some(Command::Exit));
assert_eq!(parse("q").unwrap(), Some(Command::Exit));
}
#[test]
fn parse_unknown_command() {
assert!(parse("foobar").is_err());
}
}
+33
View File
@@ -0,0 +1,33 @@
//! # ush-cli — UnShell Operator REPL
//!
//! The operator CLI connects to the router as a first-class node and provides
//! an interactive shell for issuing commands to connected payload nodes.
//!
//! ## Usage
//!
//! ```text
//! ush-cli --router 127.0.0.1:9000
//! ```
//!
//! ## REPL commands
//!
//! ```text
//! unshell> list # list all connected nodes
//! unshell> use agents/abc123 # set working path prefix
//! unshell [agents/abc123]> ls # GetProcedures at current path
//! unshell [agents/abc123]> call shell/exec "ls -la"
//! unshell [agents/abc123]> read files/passwd
//! unshell [agents/abc123]> background # detach, keep in session list
//! unshell> sessions # list background sessions
//! unshell> exit # disconnect and quit
//! ```
mod commands;
mod repl;
mod session;
fn main() {
// TODO: parse --router argument
let router_addr = "127.0.0.1:9000";
repl::run(router_addr).expect("repl failed");
}
+336
View File
@@ -0,0 +1,336 @@
//! # REPL Core
//!
//! The main interactive loop for the operator CLI.
//!
//! ## Flow
//!
//! ```text
//! run()
//! ↓
//! connect to router → handshake → register as operator node
//! ↓
//! start recv thread (router → operator messages)
//! ↓
//! main thread: readline loop
//! parse command
//! execute (may send TreeRequest over transport)
//! print response
//! ```
//!
//! ## Threading model
//!
//! The transport is shared between:
//! - The main thread (sends requests, prints responses).
//! - A background recv thread (receives unsolicited messages from the router,
//! e.g., node-connected notifications — future feature).
//!
//! In v1, the main thread does both send and receive synchronously (blocking
//! recv after each send). The recv thread is reserved for future async notifications.
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use rustyline::error::ReadlineError;
use rustyline::DefaultEditor;
use unshell::protocol::{
content, HandshakeAck, HandshakeMessage, NodeType,
PacketHeader, PacketType, RequestType, TreeRequest,
};
use unshell::transport::tcp::TcpTransport;
use unshell::transport::Transport;
use crate::commands::{self, Command};
use crate::session::Session;
// ---------------------------------------------------------------------------
// Request ID counter
// ---------------------------------------------------------------------------
/// Monotonically increasing request ID generator.
///
/// Generates unique IDs so the operator can correlate responses to requests
/// in the future when multiple requests are in-flight concurrently.
static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(1);
fn next_request_id() -> u64 {
REQUEST_COUNTER.fetch_add(1, Ordering::SeqCst)
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
/// Start the operator REPL, connecting to `router_addr`.
///
/// Blocks until the user types `exit` or the connection is lost.
///
/// # Errors
///
/// Returns an error if the connection or handshake fails.
pub fn run(router_addr: &str) -> Result<(), Box<dyn std::error::Error>> {
println!("UnShell operator console");
println!("Connecting to {}...", router_addr);
let mut transport = TcpTransport::connect(router_addr)?;
let session_id = format!("sess{}", std::process::id());
let base_path = format!("/operator/{session_id}");
// Handshake
let handshake = HandshakeMessage {
node_id: session_id.clone(),
node_type: NodeType::Operator,
registered_paths: vec![base_path.clone()],
platform: "operator".to_owned(),
};
let handshake_payload = rkyv::to_bytes::<rkyv::rancor::Error>(&handshake)
.map_err(|e| format!("failed to serialise handshake: {e}"))?;
let handshake_header = PacketHeader {
dst_path: "/router".to_owned(),
src_path: base_path.clone(),
packet_type: PacketType::Handshake,
};
transport.send(&handshake_header, &handshake_payload)?;
let (_, ack_payload) = transport.recv()?;
let ack: HandshakeAck =
rkyv::from_bytes::<HandshakeAck, rkyv::rancor::Error>(&ack_payload)
.map_err(|e| format!("failed to deserialise ack: {e}"))?;
if !ack.accepted {
return Err(format!(
"router rejected: {}",
ack.rejection_reason.unwrap_or_default()
)
.into());
}
println!("Connected. Type 'help' for commands.");
// Wrap transport in a Mutex for shared access
let transport = Arc::new(Mutex::new(transport));
// REPL state
let mut current_session = Session::new("default", "/");
let mut background_sessions: Vec<Session> = Vec::new();
// Readline editor with history
let mut rl = DefaultEditor::new()?;
loop {
let prompt = if current_session.current_path == "/" {
"unshell> ".to_owned()
} else {
let short = current_session
.current_path
.trim_start_matches("/agents/")
.trim_start_matches("/operator/");
format!("unshell [{short}]> ")
};
let readline = rl.readline(&prompt);
match readline {
Ok(line) => {
rl.add_history_entry(line.as_str())
.unwrap_or_default();
match commands::parse(&line) {
Ok(None) => {} // empty / comment
Ok(Some(cmd)) => {
if !handle_command(
cmd,
&mut current_session,
&mut background_sessions,
&base_path,
&transport,
) {
break; // exit command
}
}
Err(e) => println!("error: {e}"),
}
}
Err(ReadlineError::Interrupted | ReadlineError::Eof) => {
println!("Disconnecting...");
break;
}
Err(e) => {
eprintln!("readline error: {e}");
break;
}
}
}
println!("Bye.");
Ok(())
}
// ---------------------------------------------------------------------------
// Command handlers
// ---------------------------------------------------------------------------
/// Handle one parsed command.
///
/// Returns `false` if the REPL should exit, `true` to continue.
fn handle_command(
cmd: Command,
current_session: &mut Session,
background_sessions: &mut Vec<Session>,
base_path: &str,
transport: &Arc<Mutex<TcpTransport>>,
) -> bool {
match cmd {
Command::Exit => return false,
Command::Help => commands::print_help(),
Command::Use(path) => {
// Normalise: if no leading slash, prepend /agents/
let resolved = if path.starts_with('/') {
path
} else {
format!("/agents/{path}")
};
current_session.current_path = resolved;
println!("current path: {}", current_session.current_path);
}
Command::List => {
// Send GetProcedures to /router/nodes
send_request_and_print(
"/router/nodes",
RequestType::GetProcedures,
content::NONE,
None,
base_path,
transport,
);
}
Command::Ls(sub_path) => {
let path = sub_path
.as_deref()
.map(|p| current_session.resolve(p))
.unwrap_or_else(|| current_session.current_path.clone());
send_request_and_print(
&path,
RequestType::GetProcedures,
content::NONE,
None,
base_path,
transport,
);
}
Command::Read(sub_path) => {
let path = current_session.resolve(&sub_path);
send_request_and_print(
&path,
RequestType::Read,
content::NONE,
None,
base_path,
transport,
);
}
Command::Call { path, data } => {
let full_path = current_session.resolve(&path);
send_request_and_print(
&full_path,
RequestType::CallProcedure,
content::UTF8_STRING,
data.as_deref(),
base_path,
transport,
);
}
Command::Write { path, data } => {
let full_path = current_session.resolve(&path);
send_request_and_print(
&full_path,
RequestType::Write,
content::UTF8_STRING,
Some(&data),
base_path,
transport,
);
}
Command::Background => {
let mut session = current_session.clone();
session.active = false;
background_sessions.push(session);
current_session.current_path = "/".to_owned();
println!("session backgrounded. Type 'sessions' to list.");
}
Command::Sessions => {
if background_sessions.is_empty() {
println!("no background sessions");
} else {
for (i, sess) in background_sessions.iter().enumerate() {
println!(" [{i}] {} ({})", sess.name, sess.current_path);
}
}
}
}
true
}
/// Send a `TreeRequest` and print the response.
fn send_request_and_print(
dst_path: &str,
request_type: RequestType,
content_type: &str,
data: Option<&str>,
src_path: &str,
transport: &Arc<Mutex<TcpTransport>>,
) {
let request = TreeRequest {
request_id: next_request_id(),
request_type,
content_type: content_type.to_owned(),
data: data.map(|s| s.as_bytes().to_vec()).unwrap_or_default(),
};
let Ok(payload) = rkyv::to_bytes::<rkyv::rancor::Error>(&request) else {
eprintln!("error: failed to serialise request");
return;
};
let header = PacketHeader {
dst_path: dst_path.to_owned(),
src_path: src_path.to_owned(),
packet_type: PacketType::Request,
};
let mut t = transport.lock().expect("transport lock poisoned");
if let Err(e) = t.send(&header, &payload) {
eprintln!("send error: {e}");
return;
}
match t.recv() {
Ok((_, resp_payload)) => {
match rkyv::from_bytes::<unshell::protocol::TreeResponse, rkyv::rancor::Error>(
&resp_payload,
) {
Ok(resp) => {
if resp.data.is_empty() {
println!("[{:?}]", resp.status);
} else if let Ok(text) = std::str::from_utf8(&resp.data) {
println!("{text}");
} else {
println!("[{} bytes, content-type: {}]", resp.data.len(), resp.content_type);
}
}
Err(e) => eprintln!("error: failed to deserialise response: {e}"),
}
}
Err(e) => eprintln!("recv error: {e}"),
}
}
+67
View File
@@ -0,0 +1,67 @@
//! # Session Management
//!
//! A `Session` represents an active connection context to a specific node path.
//!
//! The operator can have multiple named sessions open simultaneously. Each session
//! has a "current path" (e.g., `/agents/abc123`) that prefixes commands.
//! Sessions can be backgrounded and switched between without disconnecting.
//!
//! ## Session lifecycle
//!
//! ```text
//! connect → handshake → session created
//! ↓
//! use agents/abc123 ← sets current_path
//! ↓
//! call shell/exec ← sends to /agents/abc123/shell/exec
//! ↓
//! background ← pushed to session list, detached
//! ↓
//! sessions ← lists all active sessions
//! ↓
//! use <session_id> ← reattaches
//! ```
/// A named, backgroundable session context.
#[derive(Debug, Clone)]
pub struct Session {
/// Human-readable name (e.g., "abc123" or "session-1").
pub name: String,
/// The current working path (e.g., `/agents/abc123`).
pub current_path: String,
/// Whether this session is in the foreground.
pub active: bool,
}
impl Session {
/// Create a new session at the given path.
#[must_use]
pub fn new(name: impl Into<String>, path: impl Into<String>) -> Self {
Self {
name: name.into(),
current_path: path.into(),
active: true,
}
}
/// Return the full path for a sub-path command.
///
/// If `sub_path` is absolute (starts with `/`), return it unchanged.
/// Otherwise, append it to `current_path`.
///
/// # Example
///
/// ```rust
/// let sess = Session::new("abc123", "/agents/abc123");
/// assert_eq!(sess.resolve("shell/exec"), "/agents/abc123/shell/exec");
/// assert_eq!(sess.resolve("/router/nodes"), "/router/nodes");
/// ```
#[must_use]
pub fn resolve(&self, sub_path: &str) -> String {
if sub_path.starts_with('/') {
sub_path.to_owned()
} else {
format!("{}/{sub_path}", self.current_path.trim_end_matches('/'))
}
}
}