diff --git a/crates/cli/src/bin/wallhack.rs b/crates/cli/src/bin/wallhack.rs index 3a45eb4..7fc4daa 100644 --- a/crates/cli/src/bin/wallhack.rs +++ b/crates/cli/src/bin/wallhack.rs @@ -7,8 +7,7 @@ //! Invoked as `wallhack` (no args, slim build): starts daemon engine directly. //! Invoked as `wallhack --connect HOST [...]`: daemon in auto-negotiated mode. //! Invoked as `wallhack --listen ADDR [...]`: daemon in auto-negotiated mode. -//! Invoked as `wallhack --role ROLE [...]`: daemon with fixed role hint. -//! Invoked as `wallhack entry/exit/relay [...]`: daemon with explicit role override. +//! Invoked as `wallhack --role ROLE [...]`: daemon with fixed role override. //! Invoked as `wallhackd` (or `wallhack daemon`): daemon engine directly. //! Invoked as `wallhackctl`: IPC control client only; fails if daemon not running. //! Invoked as `wallhack `: IPC control client. diff --git a/crates/cli/src/daemon_cli.rs b/crates/cli/src/daemon_cli.rs index d974707..cc7bd00 100644 --- a/crates/cli/src/daemon_cli.rs +++ b/crates/cli/src/daemon_cli.rs @@ -24,8 +24,8 @@ use wallhackd::{ /// Network pivoting and tunneling tool. /// -/// Auto-negotiates role from `--connect` / `--listen` flags. Use a subcommand -/// (`entry`, `exit`, `relay`) to override with an explicit role. +/// Auto-negotiates role from `--connect` / `--listen` flags. Use `--role` to +/// override with an explicit role. #[allow(clippy::struct_excessive_bools)] // Independent CLI flags, not related state #[derive(FromArgs, Debug, Clone)] pub struct WallhackCli { diff --git a/crates/core/src/control/handler.rs b/crates/core/src/control/handler.rs index 6bd8a49..f35de82 100644 --- a/crates/core/src/control/handler.rs +++ b/crates/core/src/control/handler.rs @@ -363,6 +363,7 @@ impl Handler { fn do_disconnect(&self, id: &str) { self.peers.send_disconnect(id, "disconnected by API"); let _ = self.peers.unregister(id); + tracing::info!("Peer disconnected: {id} (via API)"); } } diff --git a/crates/daemon/src/mode/auto.rs b/crates/daemon/src/mode/auto.rs index 5d2e208..0f7eb83 100644 --- a/crates/daemon/src/mode/auto.rs +++ b/crates/daemon/src/mode/auto.rs @@ -412,18 +412,17 @@ async fn run_auto_connect_session_dispatch( NegotiationResult::Indeterminate { .. } => NodeRole::Indeterminate, }; node_state.update_role(negotiated_role); + let peer_role = super::peer_role_from_capabilities(peer_hs.capabilities.unwrap_or_default()); + tracing::info!( + "Role resolved: peer={} addr={peer_addr} local_role={negotiated_role} peer_role={peer_role}", + peer_hs.name, + ); match result { NegotiationResult::Resolved { role: NodeRole::Entry, .. } => { - tracing::info!( - "Role resolved: name={} addr={peer_addr} role=entry", - peer_hs.name, - ); - node_state.update_role(NodeRole::Entry); - // Install routes advertised by the exit peer. The inner function // applies routes from the table when it creates the TUN, so they // must be in the table before we call it. @@ -477,11 +476,6 @@ async fn run_auto_connect_session_dispatch( role: NodeRole::Exit, .. } => { - tracing::info!( - "Role resolved: name={} addr={peer_addr} role=exit", - peer_hs.name, - ); - node_state.update_role(NodeRole::Exit); let peer_caps = peer_hs.capabilities.unwrap_or_default(); let peer_role = super::peer_role_from_capabilities(peer_caps); let peer_name = if peer_hs.name.is_empty() { @@ -870,18 +864,17 @@ async fn run_auto_accept_session_inner( NegotiationResult::Indeterminate { .. } => NodeRole::Indeterminate, }; node_state.update_role(negotiated_role); + let peer_role = super::peer_role_from_capabilities(peer_hs.capabilities.unwrap_or_default()); + tracing::info!( + "Role resolved: peer={} addr={peer_addr} local_role={negotiated_role} peer_role={peer_role}", + peer_hs.name, + ); match result { NegotiationResult::Resolved { role: NodeRole::Entry, .. } => { - tracing::info!( - "Role resolved: name={} addr={peer_addr} role=entry", - peer_hs.name, - ); - node_state.update_role(NodeRole::Entry); - // Spawn data tasks: incoming (peer→instructions/responses) + outgoing (instructions→peer). super::entry::spawn_data_tasks( &transport, @@ -1040,12 +1033,6 @@ async fn run_auto_accept_session_inner( role: NodeRole::Exit, .. } => { - tracing::info!( - "Role resolved: name={} addr={peer_addr} role=exit", - peer_hs.name, - ); - node_state.update_role(NodeRole::Exit); - // Spawn data tasks for exit: incoming (peer→broadcasts) + outgoing (responses→peer). { let transport = Arc::clone(&transport); diff --git a/crates/daemon/src/netlink.rs b/crates/daemon/src/netlink.rs index f8536ad..f73b21f 100644 --- a/crates/daemon/src/netlink.rs +++ b/crates/daemon/src/netlink.rs @@ -144,10 +144,10 @@ pub(crate) fn add_os_route(cidr: &str, dev: &str) -> Result<(), String> { /// Receive and check the Netlink ACK/error response. /// -/// `NLMSG_ERROR` (type 2) carries a 4-byte `i32` error code at the start of its -/// payload. Error 0 = success (pure ACK), negative = errno. -/// `-3` (ESRCH) after route delete and `-17` (EEXIST) after route add are -/// treated as success (idempotent operations). +/// neli 0.7 parses `NLMSG_ERROR` into `NlPayload::Ack` (error=0) or +/// `NlPayload::Err` (error<0). We check the error code and treat +/// `-3` (ESRCH, already gone) and `-17` (EEXIST, already present) as +/// success for idempotent route operations. fn recv_netlink_ack(socket: &mut NlSocketHandle, op: &str) -> Result<(), String> { let (mut iter, _groups) = socket .recv::() @@ -158,28 +158,20 @@ fn recv_netlink_ack(socket: &mut NlSocketHandle, op: &str) -> Result<(), String> }; let msg = msg_result.map_err(|e| format!("Netlink recv error: {e}"))?; - // NLMSG_ERROR = 2 - if *msg.nl_type() == 2 { - if let NlPayload::Payload(buf) = msg.nl_payload() { - let bytes: &[u8] = buf.as_ref(); - if bytes.len() >= 4 { - let error = i32::from_ne_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); - // 0 = success, -3 = ESRCH (already gone), -17 = EEXIST (already present) - if error == 0 || error == -3 || error == -17 { - Ok(()) - } else { - let err_msg = format!("Netlink error: {error}"); - tracing::warn!("Failed to {op}: {err_msg}"); - Err(err_msg) - } + match msg.nl_payload() { + NlPayload::Ack(_) | NlPayload::Empty => Ok(()), + NlPayload::Err(err) => { + let code = *err.error(); + // -3 = ESRCH (route already gone), -17 = EEXIST (route already present) + if code == -3 || code == -17 { + Ok(()) } else { - Err("Netlink ACK payload too short".into()) + let err_msg = format!("Netlink error: {code}"); + tracing::warn!("Failed to {op}: {err_msg}"); + Err(err_msg) } - } else { - Err("Unexpected payload in ACK".into()) } - } else { - Err(format!("Unexpected message type: {}", msg.nl_type())) + _ => Err(format!("Unexpected Netlink response for {op}")), } } diff --git a/crates/mcp/src/convert.rs b/crates/mcp/src/convert.rs index 3ec68ff..ec87c12 100644 --- a/crates/mcp/src/convert.rs +++ b/crates/mcp/src/convert.rs @@ -67,7 +67,7 @@ pub fn format_response(resp: &ManagementResponse) -> Result { let mut out = String::new(); for route in &r.routes { let tag = if route.auto_managed { " (auto)" } else { "" }; - let _ = writeln!(out, "{} → {}{tag}", route.cidr, route.peer); + let _ = writeln!(out, "{} via {}{tag}", route.cidr, route.peer); } Ok(out) } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index c4ad653..f178804 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -64,7 +64,7 @@ async fn ipc_call(request: management_request::Request) -> Result role=entry" log is misleading — displays attacker's resolved role next to peer's name, contradicting `peers` output | Fix log format to clearly separate my_role and peer_role | +| 🟡 Minor | `stats` shows 0 connections/flows even after confirmed traffic flows through tunnel | Fix stats tracking to count TUN flows and connections | +| 🟡 Minor | `hint_set` / `hint_set_auto` return "OK" with no state reflection | Return current hint state in the response, e.g. "role hint set: entry=fixed" | +| 🟡 Minor | Exit peer (web-filter) did not auto-reconnect after being forcibly disconnected by the entry | Implement reconnect backoff on exit peers, or document expected behaviour clearly | +| 🟢 Suggestion | `wallhack --help` has no examples — a CTF player from Ligolo-ng muscle memory would benefit from a one-liner showing the connect/listen/relay workflow | Add examples section to help output | +| 🟢 Suggestion | `info` output shows `capabilities: tun=true listen=true connect=false` — `connect=false` on the entry node is counterintuitive (it's the one initiating connections) | Clarify capabilities semantics in docs or rename to reflect actual meaning | + +--- + +## Cross-Cutting Observations + +- **Pontoon and Wallhack complement each other well for the first hop.** `range_up` → auto-peer-connect → auto-routes is a smooth first experience. Reaching the office network required zero manual configuration on the attacker side. This is the best part of the current experience. +- **The range topology is well-constructed** — realistic with gateways, masquerading, and deny rules. `deny_cp` and `deny_root` enforcement is solid. The loot → credentials → deeper chain is exactly how a real CTF progression works. +- **The MCP's effective coverage ends at the office network.** Everything past 10.99.2.0/24 requires capabilities (dynamic connect/listen, relay config) that don't yet exist in the MCP. If a CTF player is expected to reach gold, the relay workflow must be MCP-accessible. +- **Log visibility gap**: Pontoon can only see kernel serial output via `vm_logs`; Wallhack MCP can only see the attacker daemon's ring buffer via `logs`. There is no MCP path to view VM-side wallhack logs for peer nodes. This is a debugging gap that will frustrate both players and developers. + +--- + +## Session Transcript (Condensed) + +``` +▶ wallhack info (MCP) before range_up + → Error: "cannot connect to daemon via vsock 3:4434: No such device" + 💬 Clear error. I know the range isn't running. Good. + +▶ range_up + → 27 VMs launched and ready. PID list, no names. + 💬 "launched (pid=3758486)" tells me nothing about which VM that is. + +▶ wallhack info (MCP) after range_up + → name=attacker role=entry listen=[::]:443 caps=tun/listen version=0.14.0+508a7da uptime=6s + 💬 Clean. Answered all my first questions. Version with build metadata is excellent. + +▶ wallhack --help (on attacker VM) + → Full flag list. No examples. --prefer-role / --exclude-role / --role flags listed. + 💬 As a Ligolo user I'd expect "agent" and "proxy" — "entry", "exit", "relay" map okay though. + No examples is a miss for first-timers. + +▶ wallhack peers + → gateway-perimeter (role=exit, connected, 0.6ms), web-filter (role=exit, connected, 0.5ms) + 💬 Two auto-connections already. Nice. But logs say "Role resolved: name=gateway-perimeter role=entry" + — that's the opposite of what peers shows. Bug. + +▶ wallhack routes + → 10.99.1.0/24 (auto) and 10.99.2.0/24 (auto) via gateway-perimeter + 💬 Auto-routing is excellent. "(auto)" label is a great UX touch. + +▶ curl http://10.99.2.80/ from attacker VM through tunnel + → "Acme Corp Internal Portal" — works! + 💬 Zero config required to reach the internal network. Very smooth. + +▶ wallhack stats + → bytes in: 659, bytes out: 242, connections: 0, flows: 0 + 💬 Traffic clearly flowed but connections=0, flows=0. Stats are broken or not counting TUN traffic. + +▶ wallhack route_add 10.99.3.0/24 via gateway-perimeter + → OK + warning: "peer gateway-perimeter does not advertise a route covering 10.99.3.0/24" + 💬 Warning is perfect. I immediately know this won't work and why. Best error in the session. + +▶ ping 10.99.3.20 from attacker (with manual route) + → Network unreachable + 💬 Confirmed. gateway-perimeter only knows 10.99.2.0/24. Dead end with no relay chain. + +▶ wallhack listen 0.0.0.0:53 (to let udp-only VM connect) + → Error: "dynamic listen not yet implemented — specify --listen at startup" + 💬 udp-only is waiting to connect to :53. Can't open a new listener. Completely stuck. + +▶ wallhack connect 10.99.9.99:4433 (testing error quality) + → Error: "dynamic connect not yet implemented — specify --connect at startup" + 💬 Same error format as listen — at least consistent. But both are stubs. Two tools, zero utility. + +▶ disconnect web-filter + → OK. Log: "Peer disconnected: web-filter (via API)" + 💬 Clean. "(via API)" attribution in logs is a nice touch. + → Checked 90s later — web-filter never reconnected + 💬 Exit peers should retry. No auto-reconnect is surprising and frustrating. + +▶ disconnect (no peer — testing transport-level disconnect) + → Error: "dynamic disconnect not yet implemented" + 💬 Named peer disconnect works but transport disconnect doesn't. Inconsistent partial implementation. + +▶ hint_set role=entry level=fixed + → OK + 💬 No state feedback. Did it do anything? Can't tell. + +▶ hint_set_auto + → OK + 💬 Same — just "OK". No indication of restored state. +```