netget/server/reverse_shell/mod.rs
1//! Reverse-shell listener server.
2//!
3//! A raw-TCP listener that **emulates** the operator side of a reverse shell for authorized
4//! security testing, CTF and lab use. An operator connects back to this listener with a plain
5//! TCP client (`nc`, `socat`, `ncat`), and the LLM role-plays the shell on the far end: it
6//! decides the banner, each command's output and the prompt.
7//!
8//! Safety: NetGet does **not** execute the operator's commands on this host. Every byte the
9//! operator sees is fictional output supplied by the model — the same premise as every other
10//! NetGet protocol (impersonating a service without being one). Real command execution is only
11//! reachable through the separate, opt-in, unsandboxed scripting layer documented in the
12//! top-level `CLAUDE.md`; this protocol never touches it. See `src/server/reverse_shell/CLAUDE.md`.
13//!
14//! Wire model: there is no framing. The listener reads raw bytes, buffers them into
15//! newline-terminated lines, and raises one `reverse_shell_command` event per line. One
16//! connection is handled strictly sequentially — the model call for one line finishes before the
17//! next line is read — so there is never a concurrent LLM call on a single connection, and input
18//! that arrives mid-call sits in the socket buffer until the call returns.
19
20pub mod actions;
21
22use crate::llm::action_helper::call_llm;
23use crate::llm::actions::protocol_trait::ActionResult;
24use crate::llm::ollama_client::OllamaClient;
25use crate::protocol::Event;
26use crate::server::connection::ConnectionId;
27use crate::state::app_state::AppState;
28use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
29use crate::{console_debug, console_info, console_warn};
30use actions::{
31 ReverseShellProtocol, REVERSE_SHELL_COMMAND_EVENT, REVERSE_SHELL_SESSION_OPENED_EVENT,
32};
33use anyhow::Result;
34use std::net::SocketAddr;
35use std::sync::Arc;
36use std::time::Duration;
37use tokio::io::{AsyncReadExt, AsyncWriteExt};
38use tokio::net::TcpStream;
39use tokio::sync::{mpsc, Mutex};
40use tracing::{debug, error, info, warn};
41
42/// Largest single line accepted from the operator, in bytes.
43///
44/// The line accumulator grows until a newline arrives; without a cap a peer that never sends one
45/// could grow it without bound. A command line far longer than this is not a real shell command.
46pub const MAX_LINE_LEN: usize = 64 * 1024;
47
48/// Reverse-shell listener.
49/// How long to wait for the first command from an operator who has only connected.
50///
51/// Two minutes rather than a machine protocol's thirty seconds, because the peer here is a
52/// person at `nc`: the session opens with a model-written banner and prompt, and they read it
53/// before typing. The banner sits outside this deadline by construction — it is generated and
54/// written before the read loop starts — so this bound measures only the time from a prompt
55/// being on their screen to the first key.
56const FIRST_COMMAND_READ_TIMEOUT: Duration = Duration::from_secs(120);
57
58/// How long to wait for a *further* command once one has been answered.
59///
60/// Fifteen minutes. A shell's own idle convention is `TMOUT`, which hardening baselines set to
61/// 900 seconds, so this is the number an operator already expects a shell session to be
62/// reclaimed at — and long enough that reading a long listing, or stepping away mid-engagement,
63/// does not cost the session.
64///
65/// The LLM round-trip and a `manual` rule parking a command for a human
66/// (`src/state/intercepts.rs`, 300s by default) both happen after a line has already been read,
67/// so neither can be timed out from under itself.
68const IDLE_BETWEEN_COMMANDS_TIMEOUT: Duration = Duration::from_secs(900);
69
70/// Concurrent connections this listener admits.
71///
72/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`]. A reverse-shell listener in real
73/// use holds one or two sessions, so the generous shared default is far above anything
74/// legitimate while still bounding a peer that simply opens sockets.
75const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
76
77/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
78///
79/// There is no framing to refuse in — this is a raw byte stream — but there *is* a terminal on
80/// the other end, so a plain line is read by the one audience that exists. It is deliberately
81/// netget's own voice rather than a shell-looking error: the emulated shell never got as far as
82/// existing, and a fabricated shell message would be a positive assertion about a session that
83/// was declined. Fixed text, so there is no placeholder an internal error could reach.
84const CONNECTION_CAP_REFUSAL: &[u8] = b"\r\n[netget] too many connections\r\n";
85
86pub struct ReverseShellServer;
87
88impl ReverseShellServer {
89 /// Bind the listener and spawn the accept loop.
90 ///
91 /// Awaits the bind so a failure (address in use, permission) is returned as `Err` and the
92 /// server is marked `Error` rather than lying about being `Running`. The accept-loop
93 /// `JoinHandle` is registered so `stop_server` releases the socket.
94 pub async fn spawn_with_llm_actions(
95 listen_addr: SocketAddr,
96 llm_client: OllamaClient,
97 app_state: Arc<AppState>,
98 status_tx: mpsc::UnboundedSender<String>,
99 server_id: crate::state::ServerId,
100 ) -> Result<SocketAddr> {
101 let listener =
102 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
103 let local_addr = listener.local_addr()?;
104
105 // Keep the address last on the line: the E2E harness parses the port from after "on ".
106 console_info!(
107 status_tx,
108 "Reverse-shell listener (emulation) listening on {}",
109 local_addr
110 );
111
112 let task_registrar = app_state.clone();
113 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
114 let accept_handle = tokio::spawn(async move {
115 loop {
116 match crate::server::accept_bounded::accept_bounded(
117 &listener,
118 &limiter,
119 CONNECTION_CAP_REFUSAL,
120 "Reverse-shell",
121 Some(&status_tx),
122 )
123 .await
124 {
125 Ok((stream, remote_addr, permit)) => {
126 let connection_id =
127 ConnectionId::new(app_state.get_next_unified_id().await);
128 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
129
130 info!("Reverse-shell operator connected from {}", remote_addr);
131 let _ = status_tx.send(format!(
132 "[INFO] Reverse-shell operator connected from {}",
133 remote_addr
134 ));
135
136 let llm_clone = llm_client.clone();
137 let state_clone = app_state.clone();
138 let status_clone = status_tx.clone();
139 // Tracked, not detached: stop_server must abort this task too.
140 let task_owner = app_state.clone();
141 task_owner
142 .spawn_server_task(server_id, async move {
143 // Held for the life of the connection: releasing it here would
144 // cap the accept rate rather than the number of live
145 // connections.
146 let _permit = permit;
147 if let Err(e) = Self::handle_connection(
148 stream,
149 connection_id,
150 remote_addr,
151 local_addr_conn,
152 server_id,
153 state_clone,
154 status_clone,
155 llm_clone,
156 )
157 .await
158 {
159 error!("Reverse-shell connection error: {}", e);
160 }
161 })
162 .await;
163 }
164 Err(e) => {
165 // Break rather than spin: a persistent accept error (EMFILE, socket
166 // closed under us) would otherwise loop at full CPU.
167 error!("Reverse-shell accept failed: {}", e);
168 let _ = status_tx
169 .send(format!("✗ Reverse-shell accept failed, stopping: {}", e));
170 break;
171 }
172 }
173 }
174 });
175
176 task_registrar
177 .register_server_task(server_id, accept_handle)
178 .await;
179
180 Ok(local_addr)
181 }
182
183 /// Handle one operator connection: session-open event, then a line loop.
184 #[allow(clippy::too_many_arguments)]
185 async fn handle_connection(
186 stream: TcpStream,
187 connection_id: ConnectionId,
188 remote_addr: SocketAddr,
189 local_addr: SocketAddr,
190 server_id: crate::state::ServerId,
191 app_state: Arc<AppState>,
192 status_tx: mpsc::UnboundedSender<String>,
193 llm_client: OllamaClient,
194 ) -> Result<()> {
195 let (mut read_half, write_half) = tokio::io::split(stream);
196 let write_half = Arc::new(Mutex::new(write_half));
197
198 let now = crate::utils::clock::Instant::now();
199 let conn_state = ConnectionState {
200 id: connection_id,
201 remote_addr,
202 local_addr,
203 bytes_sent: 0,
204 bytes_received: 0,
205 packets_sent: 0,
206 packets_received: 0,
207 last_activity: now,
208 status: ConnectionStatus::Active,
209 status_changed_at: now,
210 protocol_info: ProtocolConnectionInfo::empty(),
211 };
212 app_state
213 .add_connection_to_server(server_id, conn_state)
214 .await;
215 let _ = status_tx.send("__UPDATE_UI__".to_string());
216
217 // Register a peer handle so the dashboard can [ message this peer ] /
218 // [ disconnect this peer ] this live connection. The command task shares the same
219 // Arc<Mutex<WriteHalf>> the reader writes through, so injected output is serialized
220 // against the model's. Every wire verb (send_shell_output/prompt, end_shell_session)
221 // returns Output/CloseConnection, so the generic peer task covers the whole vocabulary.
222 let protocol: Arc<ReverseShellProtocol> = Arc::new(ReverseShellProtocol::new());
223 let peer_rx = crate::server::peer_support::register_peer_channel(
224 &app_state,
225 server_id,
226 connection_id.as_u32(),
227 )
228 .await;
229 crate::server::peer_support::spawn_peer_command_task(
230 peer_rx,
231 protocol.clone(),
232 app_state.clone(),
233 server_id,
234 connection_id.as_u32(),
235 write_half.clone(),
236 status_tx.clone(),
237 );
238
239 let mut conn = ShellConnection {
240 connection_id,
241 server_id,
242 app_state: app_state.clone(),
243 llm_client,
244 protocol: ReverseShellProtocol::new(),
245 status_tx: status_tx.clone(),
246 write_half,
247 };
248
249 let result = conn.run(&mut read_half).await;
250
251 // Remove the peer handle on every exit path (EOF, read error, oversize line,
252 // end_shell_session, no-answer fail-closed) — this single cleanup runs after run()
253 // returns however it returned, so the rail never keeps offering a dead peer.
254 app_state
255 .remove_peer_handle(server_id, connection_id.as_u32())
256 .await;
257 app_state
258 .remove_connection_from_server(server_id, connection_id)
259 .await;
260 let _ = status_tx.send("__UPDATE_UI__".to_string());
261
262 result
263 }
264}
265
266/// Per-connection state and behaviour for one operator session.
267struct ShellConnection {
268 connection_id: ConnectionId,
269 server_id: crate::state::ServerId,
270 app_state: Arc<AppState>,
271 llm_client: OllamaClient,
272 protocol: ReverseShellProtocol,
273 status_tx: mpsc::UnboundedSender<String>,
274 write_half: Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
275}
276
277/// Why a session is being failed closed, kept distinct from a deliberate model decision.
278///
279/// The two variants exist so the log can tell an operator which happened. Collapsing them —
280/// which this protocol used to do behind a single `no_answer` flag — makes a backend outage
281/// indistinguishable from a model that answered with an unusable action list, and those want
282/// completely different responses from whoever is watching.
283#[derive(Clone, Copy, Debug)]
284enum FailClosed {
285 /// The LLM call itself returned `Err`. Carries only the *category* of the failure — the
286 /// error text is logged, never carried towards the wire (`crate::utils::wire_failure`).
287 LlmError(crate::utils::WireFailure),
288 /// The call succeeded but produced nothing usable: an empty action list, or a batch where
289 /// every action failed. The backend is up; the answer was not usable.
290 NoUsableAnswer,
291}
292
293impl FailClosed {
294 /// The peer-visible category. `&'static str` by construction, so nothing derived from an
295 /// error can reach the socket.
296 fn wire_text(self) -> &'static str {
297 match self {
298 Self::LlmError(failure) => failure.text(),
299 // The backend answered; it is this request that could not be served.
300 Self::NoUsableAnswer => crate::utils::WireFailure::Unavailable.text(),
301 }
302 }
303
304 /// The `decision=` tag written to the log and the status stream. Three cases stay
305 /// distinguishable there: `model_end_session` (a deliberate close), `fail_closed_no_answer`
306 /// (the model said nothing usable) and `fail_closed_llm_error` (the call errored), the last
307 /// split further by overload so a capacity problem is not read as a permanent fault.
308 fn decision_tag(self) -> &'static str {
309 match self {
310 Self::LlmError(crate::utils::WireFailure::Overloaded) => {
311 "fail_closed_llm_error_overloaded"
312 }
313 Self::LlmError(crate::utils::WireFailure::Unavailable) => {
314 "fail_closed_llm_error_unavailable"
315 }
316 Self::NoUsableAnswer => "fail_closed_no_answer",
317 }
318 }
319}
320
321/// What the model decided in answer to one event.
322#[derive(Default)]
323struct Outcome {
324 /// Bytes to write to the operator, in order.
325 output: Vec<u8>,
326 /// The session should close after writing `output`.
327 close: bool,
328 /// Set when nothing usable came back, with the reason kept separate from a model decision.
329 fail_closed: Option<FailClosed>,
330}
331
332impl ShellConnection {
333 /// The session: greet on open, then one event per operator command line.
334 async fn run(&mut self, read_half: &mut tokio::io::ReadHalf<TcpStream>) -> Result<()> {
335 // Session-opened event.
336 let open_event = Event::new(&REVERSE_SHELL_SESSION_OPENED_EVENT, serde_json::json!({}));
337 let outcome = self.consult(&open_event).await;
338 if self.apply(outcome).await? {
339 return Ok(());
340 }
341
342 let mut buffer = vec![0u8; 8192];
343 let mut line: Vec<u8> = Vec::new();
344 let mut first_command = true;
345
346 loop {
347 // The deadline wraps this read and nothing else. Everything that can legitimately
348 // take minutes — the LLM round-trip, and a `manual` rule parking the command for a
349 // human to answer — happens below, after bytes have already been read.
350 let read_timeout = if first_command {
351 FIRST_COMMAND_READ_TIMEOUT
352 } else {
353 IDLE_BETWEEN_COMMANDS_TIMEOUT
354 };
355 let read = match tokio::time::timeout(read_timeout, read_half.read(&mut buffer)).await {
356 Ok(read) => read,
357 Err(_) => {
358 info!(
359 "Reverse-shell operator {} sent nothing for {}s; closing idle session",
360 self.connection_id,
361 read_timeout.as_secs()
362 );
363 break;
364 }
365 };
366 let n = match read {
367 Ok(0) => {
368 debug!("Reverse-shell operator {} disconnected", self.connection_id);
369 break;
370 }
371 Ok(n) => {
372 // Refresh byte/packet counters and last_activity so the rail's ↓ moves and
373 // connection-scoped task prompts see a fresh timestamp.
374 self.app_state
375 .update_connection_stats(
376 self.server_id,
377 self.connection_id,
378 Some(n as u64),
379 None,
380 Some(1),
381 None,
382 )
383 .await;
384 n
385 }
386 Err(e) => {
387 error!("Reverse-shell read error on {}: {}", self.connection_id, e);
388 break;
389 }
390 };
391
392 for &byte in &buffer[..n] {
393 if byte == b'\n' {
394 // Strip a trailing CR so CRLF and LF clients look identical to the model.
395 if line.last() == Some(&b'\r') {
396 line.pop();
397 }
398 let command = String::from_utf8_lossy(&line).to_string();
399 let empty = command.trim().is_empty();
400 line.clear();
401
402 console_debug!(
403 self.status_tx,
404 "Reverse-shell command from {}: {:?}",
405 self.connection_id,
406 command
407 );
408
409 let event = Event::new(
410 &REVERSE_SHELL_COMMAND_EVENT,
411 serde_json::json!({
412 "command": command,
413 "first_command": first_command,
414 "empty": empty,
415 }),
416 );
417 first_command = false;
418
419 let outcome = self.consult(&event).await;
420 if self.apply(outcome).await? {
421 return Ok(());
422 }
423 } else {
424 line.push(byte);
425 if line.len() > MAX_LINE_LEN {
426 console_warn!(
427 self.status_tx,
428 "Reverse-shell line from {} exceeded {} bytes, closing",
429 self.connection_id,
430 MAX_LINE_LEN
431 );
432 self.shutdown().await;
433 return Ok(());
434 }
435 }
436 }
437 }
438
439 Ok(())
440 }
441
442 /// Ask the event handlers (script → static → LLM) what to do.
443 ///
444 /// Never returns an error: a failure has to fail *closed* on the caller side, so the reason
445 /// is carried back in `Outcome::no_answer` and the caller closes the socket. Silence is never
446 /// treated as approval — there is nothing to approve here, but the same discipline keeps the
447 /// "no answer" path distinct from a deliberate "no output" (`no_shell_output`).
448 async fn consult(&self, event: &Event) -> Outcome {
449 let mut outcome = Outcome::default();
450
451 let execution = match call_llm(
452 &self.llm_client,
453 &self.app_state,
454 self.server_id,
455 Some(self.connection_id),
456 event,
457 &self.protocol,
458 )
459 .await
460 {
461 Ok(execution) => execution,
462 Err(e) => {
463 // The full error goes to the log and the status stream, where an operator
464 // looks. Only its category is allowed anywhere near the socket.
465 let failure = crate::utils::WireFailure::classify(&e);
466 error!(
467 "Reverse-shell event {} decision={} error={}",
468 event.event_type.id,
469 FailClosed::LlmError(failure).decision_tag(),
470 e
471 );
472 let _ = self.status_tx.send(format!(
473 "[ERROR] Reverse-shell {} decision={}: {}",
474 event.event_type.id,
475 FailClosed::LlmError(failure).decision_tag(),
476 e
477 ));
478 outcome.fail_closed = Some(FailClosed::LlmError(failure));
479 return outcome;
480 }
481 };
482
483 for message in execution.messages {
484 let _ = self.status_tx.send(message);
485 }
486
487 let mut saw_result = false;
488 for result in execution.protocol_results {
489 match result {
490 ActionResult::Output(data) => {
491 saw_result = true;
492 outcome.output.extend_from_slice(&data);
493 }
494 ActionResult::CloseConnection => {
495 saw_result = true;
496 outcome.close = true;
497 }
498 // no_shell_output: a deliberate decision to print nothing. Structurally distinct
499 // from a missing answer — the session stays open and no placeholder is invented.
500 ActionResult::WaitForMore | ActionResult::NoAction => {
501 saw_result = true;
502 }
503 other => {
504 warn!(
505 "Reverse-shell ignoring unexpected action result: {:?}",
506 other
507 );
508 }
509 }
510 }
511
512 if !saw_result {
513 warn!(
514 "Reverse-shell event {} decision={} (model returned no usable action)",
515 event.event_type.id,
516 FailClosed::NoUsableAnswer.decision_tag()
517 );
518 outcome.fail_closed = Some(FailClosed::NoUsableAnswer);
519 }
520
521 outcome
522 }
523
524 /// Write the decided output, then close if asked or if no usable answer came back.
525 ///
526 /// Returns true when the connection has been closed and the caller must stop.
527 ///
528 /// Fail-closed: a missing answer (LLM error, empty/failed action list) shuts the socket down
529 /// with a FIN rather than falling through to any permissive default — there is no fabricated
530 /// output and no fake prompt, so an LLM outage is visible to the operator as a dropped
531 /// session, not as a silently-working shell.
532 async fn apply(&mut self, outcome: Outcome) -> Result<bool> {
533 if !outcome.output.is_empty() {
534 let mut writer = self.write_half.lock().await;
535 writer.write_all(&outcome.output).await?;
536 writer.flush().await?;
537 drop(writer);
538 self.app_state
539 .update_connection_stats(
540 self.server_id,
541 self.connection_id,
542 None,
543 Some(outcome.output.len() as u64),
544 None,
545 Some(1),
546 )
547 .await;
548 console_debug!(
549 self.status_tx,
550 "Reverse-shell sent {} bytes to {}",
551 outcome.output.len(),
552 self.connection_id
553 );
554 }
555
556 if let Some(reason) = outcome.fail_closed {
557 let _ = self.status_tx.send(format!(
558 "✗ Reverse-shell closing {} decision={}",
559 self.connection_id,
560 reason.decision_tag()
561 ));
562 self.write_failure_notice(reason).await;
563 self.shutdown().await;
564 return Ok(true);
565 }
566
567 if outcome.close {
568 let _ = self.status_tx.send(format!(
569 "✗ Reverse-shell session {} decision=model_end_session",
570 self.connection_id
571 ));
572 self.shutdown().await;
573 return Ok(true);
574 }
575
576 Ok(false)
577 }
578
579 /// Tell the operator, on its own line, that the server could not answer — then the caller
580 /// half-closes.
581 ///
582 /// A bare FIN is honest but ambiguous: on a reverse-shell transcript it looks exactly like
583 /// the implant on the far end dying, which sends the operator hunting the wrong problem.
584 /// One line naming the *category* removes that ambiguity while inventing no shell output.
585 ///
586 /// The text is `WireFailure`'s `&'static str` and nothing else. The error itself has
587 /// already been logged; interpolating it here is the defect this whole path exists to
588 /// avoid (see `crate::utils::wire_failure`). The two categories carry different words —
589 /// "retry later" for a saturated backend, "could not be processed" otherwise — so an
590 /// operator (or a script watching the transcript) can tell a transient outage from a
591 /// permanent one even though a raw shell stream has no status code.
592 ///
593 /// CRLF and a leading newline so the notice cannot be mistaken for the output of whatever
594 /// the operator last typed, and so a cooked-mode client does not stair-step it.
595 async fn write_failure_notice(&self, reason: FailClosed) {
596 let notice = format!("\r\n[netget] {}\r\n", reason.wire_text());
597 let mut writer = self.write_half.lock().await;
598 if writer.write_all(notice.as_bytes()).await.is_ok() {
599 let _ = writer.flush().await;
600 drop(writer);
601 self.app_state
602 .update_connection_stats(
603 self.server_id,
604 self.connection_id,
605 None,
606 Some(notice.len() as u64),
607 None,
608 Some(1),
609 )
610 .await;
611 }
612 }
613
614 /// Half-close the write direction so the operator's next read returns EOF.
615 async fn shutdown(&self) {
616 let mut writer = self.write_half.lock().await;
617 let _ = writer.shutdown().await;
618 }
619}