pub mod actions;
use crate::llm::action_helper::call_llm;
use crate::llm::actions::protocol_trait::ActionResult;
use crate::llm::ollama_client::OllamaClient;
use crate::protocol::Event;
use crate::server::connection::ConnectionId;
use crate::state::app_state::AppState;
use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
use crate::{console_debug, console_info, console_warn};
use actions::{
ReverseShellProtocol, REVERSE_SHELL_COMMAND_EVENT, REVERSE_SHELL_SESSION_OPENED_EVENT,
};
use anyhow::Result;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::sync::{mpsc, Mutex};
use tracing::{debug, error, info, warn};
pub const MAX_LINE_LEN: usize = 64 * 1024;
const FIRST_COMMAND_READ_TIMEOUT: Duration = Duration::from_secs(120);
const IDLE_BETWEEN_COMMANDS_TIMEOUT: Duration = Duration::from_secs(900);
const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
const CONNECTION_CAP_REFUSAL: &[u8] = b"\r\n[netget] too many connections\r\n";
pub struct ReverseShellServer;
impl ReverseShellServer {
pub async fn spawn_with_llm_actions(
listen_addr: SocketAddr,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
server_id: crate::state::ServerId,
) -> Result<SocketAddr> {
let listener =
crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
let local_addr = listener.local_addr()?;
console_info!(
status_tx,
"Reverse-shell listener (emulation) listening on {}",
local_addr
);
let task_registrar = app_state.clone();
let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
let accept_handle = tokio::spawn(async move {
loop {
match crate::server::accept_bounded::accept_bounded(
&listener,
&limiter,
CONNECTION_CAP_REFUSAL,
"Reverse-shell",
Some(&status_tx),
)
.await
{
Ok((stream, remote_addr, permit)) => {
let connection_id =
ConnectionId::new(app_state.get_next_unified_id().await);
let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
info!("Reverse-shell operator connected from {}", remote_addr);
let _ = status_tx.send(format!(
"[INFO] Reverse-shell operator connected from {}",
remote_addr
));
let llm_clone = llm_client.clone();
let state_clone = app_state.clone();
let status_clone = status_tx.clone();
let task_owner = app_state.clone();
task_owner
.spawn_server_task(server_id, async move {
let _permit = permit;
if let Err(e) = Self::handle_connection(
stream,
connection_id,
remote_addr,
local_addr_conn,
server_id,
state_clone,
status_clone,
llm_clone,
)
.await
{
error!("Reverse-shell connection error: {}", e);
}
})
.await;
}
Err(e) => {
error!("Reverse-shell accept failed: {}", e);
let _ = status_tx
.send(format!("✗ Reverse-shell accept failed, stopping: {}", e));
break;
}
}
}
});
task_registrar
.register_server_task(server_id, accept_handle)
.await;
Ok(local_addr)
}
#[allow(clippy::too_many_arguments)]
async fn handle_connection(
stream: TcpStream,
connection_id: ConnectionId,
remote_addr: SocketAddr,
local_addr: SocketAddr,
server_id: crate::state::ServerId,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
llm_client: OllamaClient,
) -> Result<()> {
let (mut read_half, write_half) = tokio::io::split(stream);
let write_half = Arc::new(Mutex::new(write_half));
let now = crate::utils::clock::Instant::now();
let conn_state = ConnectionState {
id: connection_id,
remote_addr,
local_addr,
bytes_sent: 0,
bytes_received: 0,
packets_sent: 0,
packets_received: 0,
last_activity: now,
status: ConnectionStatus::Active,
status_changed_at: now,
protocol_info: ProtocolConnectionInfo::empty(),
};
app_state
.add_connection_to_server(server_id, conn_state)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
let protocol: Arc<ReverseShellProtocol> = Arc::new(ReverseShellProtocol::new());
let peer_rx = crate::server::peer_support::register_peer_channel(
&app_state,
server_id,
connection_id.as_u32(),
)
.await;
crate::server::peer_support::spawn_peer_command_task(
peer_rx,
protocol.clone(),
app_state.clone(),
server_id,
connection_id.as_u32(),
write_half.clone(),
status_tx.clone(),
);
let mut conn = ShellConnection {
connection_id,
server_id,
app_state: app_state.clone(),
llm_client,
protocol: ReverseShellProtocol::new(),
status_tx: status_tx.clone(),
write_half,
};
let result = conn.run(&mut read_half).await;
app_state
.remove_peer_handle(server_id, connection_id.as_u32())
.await;
app_state
.remove_connection_from_server(server_id, connection_id)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
result
}
}
struct ShellConnection {
connection_id: ConnectionId,
server_id: crate::state::ServerId,
app_state: Arc<AppState>,
llm_client: OllamaClient,
protocol: ReverseShellProtocol,
status_tx: mpsc::UnboundedSender<String>,
write_half: Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
}
#[derive(Clone, Copy, Debug)]
enum FailClosed {
LlmError(crate::utils::WireFailure),
NoUsableAnswer,
}
impl FailClosed {
fn wire_text(self) -> &'static str {
match self {
Self::LlmError(failure) => failure.text(),
Self::NoUsableAnswer => crate::utils::WireFailure::Unavailable.text(),
}
}
fn decision_tag(self) -> &'static str {
match self {
Self::LlmError(crate::utils::WireFailure::Overloaded) => {
"fail_closed_llm_error_overloaded"
}
Self::LlmError(crate::utils::WireFailure::Unavailable) => {
"fail_closed_llm_error_unavailable"
}
Self::NoUsableAnswer => "fail_closed_no_answer",
}
}
}
#[derive(Default)]
struct Outcome {
output: Vec<u8>,
close: bool,
fail_closed: Option<FailClosed>,
}
impl ShellConnection {
async fn run(&mut self, read_half: &mut tokio::io::ReadHalf<TcpStream>) -> Result<()> {
let open_event = Event::new(&REVERSE_SHELL_SESSION_OPENED_EVENT, serde_json::json!({}));
let outcome = self.consult(&open_event).await;
if self.apply(outcome).await? {
return Ok(());
}
let mut buffer = vec![0u8; 8192];
let mut line: Vec<u8> = Vec::new();
let mut first_command = true;
loop {
let read_timeout = if first_command {
FIRST_COMMAND_READ_TIMEOUT
} else {
IDLE_BETWEEN_COMMANDS_TIMEOUT
};
let read = match tokio::time::timeout(read_timeout, read_half.read(&mut buffer)).await {
Ok(read) => read,
Err(_) => {
info!(
"Reverse-shell operator {} sent nothing for {}s; closing idle session",
self.connection_id,
read_timeout.as_secs()
);
break;
}
};
let n = match read {
Ok(0) => {
debug!("Reverse-shell operator {} disconnected", self.connection_id);
break;
}
Ok(n) => {
self.app_state
.update_connection_stats(
self.server_id,
self.connection_id,
Some(n as u64),
None,
Some(1),
None,
)
.await;
n
}
Err(e) => {
error!("Reverse-shell read error on {}: {}", self.connection_id, e);
break;
}
};
for &byte in &buffer[..n] {
if byte == b'\n' {
if line.last() == Some(&b'\r') {
line.pop();
}
let command = String::from_utf8_lossy(&line).to_string();
let empty = command.trim().is_empty();
line.clear();
console_debug!(
self.status_tx,
"Reverse-shell command from {}: {:?}",
self.connection_id,
command
);
let event = Event::new(
&REVERSE_SHELL_COMMAND_EVENT,
serde_json::json!({
"command": command,
"first_command": first_command,
"empty": empty,
}),
);
first_command = false;
let outcome = self.consult(&event).await;
if self.apply(outcome).await? {
return Ok(());
}
} else {
line.push(byte);
if line.len() > MAX_LINE_LEN {
console_warn!(
self.status_tx,
"Reverse-shell line from {} exceeded {} bytes, closing",
self.connection_id,
MAX_LINE_LEN
);
self.shutdown().await;
return Ok(());
}
}
}
}
Ok(())
}
async fn consult(&self, event: &Event) -> Outcome {
let mut outcome = Outcome::default();
let execution = match call_llm(
&self.llm_client,
&self.app_state,
self.server_id,
Some(self.connection_id),
event,
&self.protocol,
)
.await
{
Ok(execution) => execution,
Err(e) => {
let failure = crate::utils::WireFailure::classify(&e);
error!(
"Reverse-shell event {} decision={} error={}",
event.event_type.id,
FailClosed::LlmError(failure).decision_tag(),
e
);
let _ = self.status_tx.send(format!(
"[ERROR] Reverse-shell {} decision={}: {}",
event.event_type.id,
FailClosed::LlmError(failure).decision_tag(),
e
));
outcome.fail_closed = Some(FailClosed::LlmError(failure));
return outcome;
}
};
for message in execution.messages {
let _ = self.status_tx.send(message);
}
let mut saw_result = false;
for result in execution.protocol_results {
match result {
ActionResult::Output(data) => {
saw_result = true;
outcome.output.extend_from_slice(&data);
}
ActionResult::CloseConnection => {
saw_result = true;
outcome.close = true;
}
ActionResult::WaitForMore | ActionResult::NoAction => {
saw_result = true;
}
other => {
warn!(
"Reverse-shell ignoring unexpected action result: {:?}",
other
);
}
}
}
if !saw_result {
warn!(
"Reverse-shell event {} decision={} (model returned no usable action)",
event.event_type.id,
FailClosed::NoUsableAnswer.decision_tag()
);
outcome.fail_closed = Some(FailClosed::NoUsableAnswer);
}
outcome
}
async fn apply(&mut self, outcome: Outcome) -> Result<bool> {
if !outcome.output.is_empty() {
let mut writer = self.write_half.lock().await;
writer.write_all(&outcome.output).await?;
writer.flush().await?;
drop(writer);
self.app_state
.update_connection_stats(
self.server_id,
self.connection_id,
None,
Some(outcome.output.len() as u64),
None,
Some(1),
)
.await;
console_debug!(
self.status_tx,
"Reverse-shell sent {} bytes to {}",
outcome.output.len(),
self.connection_id
);
}
if let Some(reason) = outcome.fail_closed {
let _ = self.status_tx.send(format!(
"✗ Reverse-shell closing {} decision={}",
self.connection_id,
reason.decision_tag()
));
self.write_failure_notice(reason).await;
self.shutdown().await;
return Ok(true);
}
if outcome.close {
let _ = self.status_tx.send(format!(
"✗ Reverse-shell session {} decision=model_end_session",
self.connection_id
));
self.shutdown().await;
return Ok(true);
}
Ok(false)
}
async fn write_failure_notice(&self, reason: FailClosed) {
let notice = format!("\r\n[netget] {}\r\n", reason.wire_text());
let mut writer = self.write_half.lock().await;
if writer.write_all(notice.as_bytes()).await.is_ok() {
let _ = writer.flush().await;
drop(writer);
self.app_state
.update_connection_stats(
self.server_id,
self.connection_id,
None,
Some(notice.len() as u64),
None,
Some(1),
)
.await;
}
}
async fn shutdown(&self) {
let mut writer = self.write_half.lock().await;
let _ = writer.shutdown().await;
}
}