Skip to content

Commit 508a7da

Browse files
maxholmanclaude
andcommitted
fix: UAT-driven fixes — neli ACK parsing, MCP error codes, disconnect log
Three bugs found during 2026-03-20 UAT session: 1. neli 0.7 ACK parsing: recv_netlink_ack matched NlPayload::Payload which never matches for NLMSG_ERROR — neli 0.7 parses these into NlPayload::Ack/Err variants. OS route add/remove silently failed. 2. MCP error codes: all daemon errors (peer not found, invalid CIDR) were wrapped as internal_error (-32603). Now uses invalid_params (-32602) since these are user-input validation failures. 3. API-initiated peer disconnect produced no log line. Added info-level log in do_disconnect so the event appears in the ring buffer. Also: route display uses "via" instead of Unicode arrow, stale subcommand references removed from help text. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent de973a6 commit 508a7da

7 files changed

Lines changed: 110 additions & 29 deletions

File tree

crates/cli/src/bin/wallhack.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@
77
//! Invoked as `wallhack` (no args, slim build): starts daemon engine directly.
88
//! Invoked as `wallhack --connect HOST [...]`: daemon in auto-negotiated mode.
99
//! Invoked as `wallhack --listen ADDR [...]`: daemon in auto-negotiated mode.
10-
//! Invoked as `wallhack --role ROLE [...]`: daemon with fixed role hint.
11-
//! Invoked as `wallhack entry/exit/relay [...]`: daemon with explicit role override.
10+
//! Invoked as `wallhack --role ROLE [...]`: daemon with fixed role override.
1211
//! Invoked as `wallhackd` (or `wallhack daemon`): daemon engine directly.
1312
//! Invoked as `wallhackctl`: IPC control client only; fails if daemon not running.
1413
//! Invoked as `wallhack <control-subcommand>`: IPC control client.

crates/cli/src/daemon_cli.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ use wallhackd::{
2424

2525
/// Network pivoting and tunneling tool.
2626
///
27-
/// Auto-negotiates role from `--connect` / `--listen` flags. Use a subcommand
28-
/// (`entry`, `exit`, `relay`) to override with an explicit role.
27+
/// Auto-negotiates role from `--connect` / `--listen` flags. Use `--role` to
28+
/// override with an explicit role.
2929
#[allow(clippy::struct_excessive_bools)] // Independent CLI flags, not related state
3030
#[derive(FromArgs, Debug, Clone)]
3131
pub struct WallhackCli {

crates/core/src/control/handler.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,7 @@ impl Handler {
363363
fn do_disconnect(&self, id: &str) {
364364
self.peers.send_disconnect(id, "disconnected by API");
365365
let _ = self.peers.unregister(id);
366+
tracing::info!("Peer disconnected: {id} (via API)");
366367
}
367368
}
368369

crates/daemon/src/netlink.rs

Lines changed: 15 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,10 @@ pub(crate) fn add_os_route(cidr: &str, dev: &str) -> Result<(), String> {
144144

145145
/// Receive and check the Netlink ACK/error response.
146146
///
147-
/// `NLMSG_ERROR` (type 2) carries a 4-byte `i32` error code at the start of its
148-
/// payload. Error 0 = success (pure ACK), negative = errno.
149-
/// `-3` (ESRCH) after route delete and `-17` (EEXIST) after route add are
150-
/// treated as success (idempotent operations).
147+
/// neli 0.7 parses `NLMSG_ERROR` into `NlPayload::Ack` (error=0) or
148+
/// `NlPayload::Err` (error<0). We check the error code and treat
149+
/// `-3` (ESRCH, already gone) and `-17` (EEXIST, already present) as
150+
/// success for idempotent route operations.
151151
fn recv_netlink_ack(socket: &mut NlSocketHandle, op: &str) -> Result<(), String> {
152152
let (mut iter, _groups) = socket
153153
.recv::<u16, neli::types::Buffer>()
@@ -158,28 +158,20 @@ fn recv_netlink_ack(socket: &mut NlSocketHandle, op: &str) -> Result<(), String>
158158
};
159159
let msg = msg_result.map_err(|e| format!("Netlink recv error: {e}"))?;
160160

161-
// NLMSG_ERROR = 2
162-
if *msg.nl_type() == 2 {
163-
if let NlPayload::Payload(buf) = msg.nl_payload() {
164-
let bytes: &[u8] = buf.as_ref();
165-
if bytes.len() >= 4 {
166-
let error = i32::from_ne_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
167-
// 0 = success, -3 = ESRCH (already gone), -17 = EEXIST (already present)
168-
if error == 0 || error == -3 || error == -17 {
169-
Ok(())
170-
} else {
171-
let err_msg = format!("Netlink error: {error}");
172-
tracing::warn!("Failed to {op}: {err_msg}");
173-
Err(err_msg)
174-
}
161+
match msg.nl_payload() {
162+
NlPayload::Ack(_) | NlPayload::Empty => Ok(()),
163+
NlPayload::Err(err) => {
164+
let code = *err.error();
165+
// -3 = ESRCH (route already gone), -17 = EEXIST (route already present)
166+
if code == -3 || code == -17 {
167+
Ok(())
175168
} else {
176-
Err("Netlink ACK payload too short".into())
169+
let err_msg = format!("Netlink error: {code}");
170+
tracing::warn!("Failed to {op}: {err_msg}");
171+
Err(err_msg)
177172
}
178-
} else {
179-
Err("Unexpected payload in ACK".into())
180173
}
181-
} else {
182-
Err(format!("Unexpected message type: {}", msg.nl_type()))
174+
_ => Err(format!("Unexpected Netlink response for {op}")),
183175
}
184176
}
185177

crates/mcp/src/convert.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ pub fn format_response(resp: &ManagementResponse) -> Result<String, String> {
6767
let mut out = String::new();
6868
for route in &r.routes {
6969
let tag = if route.auto_managed { " (auto)" } else { "" };
70-
let _ = writeln!(out, "{} {}{tag}", route.cidr, route.peer);
70+
let _ = writeln!(out, "{} via {}{tag}", route.cidr, route.peer);
7171
}
7272
Ok(out)
7373
}

crates/mcp/src/tools.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ async fn ipc_call(request: management_request::Request) -> Result<String, rmcp::
6464
.await
6565
.map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
6666

67-
convert::format_response(&resp).map_err(|msg| rmcp::ErrorData::internal_error(msg, None))
67+
convert::format_response(&resp).map_err(|msg| rmcp::ErrorData::invalid_params(msg, None))
6868
}
6969

7070
#[rmcp::tool_router(vis = "pub")]

uat/2026-03-20.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# UAT Report — 2026-03-20
2+
3+
## Session Summary
4+
- **Range**: Full 27-VM cyberrange (6 networks, perimeter→office→datacenter→management→vault)
5+
- **Persona**: CTF Player transitioning to Pentester critique
6+
- **Objective**: Validate logs feature (v0.13.0), tunnel connectivity, auto-routing, and MCP tool UX
7+
- **Outcome**: Logs feature works end-to-end. Single-hop pivot to office network confirmed. Found 2 bugs (neli ACK parsing, missing disconnect log) and 1 MCP error code issue.
8+
9+
## Pontoon MCP — Findings
10+
11+
### Tool Completeness
12+
Sufficient for range lifecycle and VM interaction. `range_up`, `vm_exec`, `vm_cp`, `vm_exec_bg`, `vm_tail` covered all needs.
13+
14+
### UX & Discoverability
15+
Tool names are clear and consistent. `vm_port_probe` runs from the host, not inside the VM — this caused initial confusion (probed office network from host, got "closed" because the host isn't routed through the tunnel). Not a bug, but worth documenting.
16+
17+
### Specific Issues
18+
| Severity | Finding | Suggested Fix |
19+
|----------|---------|---------------|
20+
| 🟢 Suggestion | `vm_port_probe` description doesn't clarify it runs from the host perspective, not inside the VM | Add "(from the host)" to description |
21+
22+
## Wallhack MCP — Findings
23+
24+
### Tool Completeness
25+
All management operations covered: info, peers, routes, stats, logs, connect, listen, disconnect, route_add, route_del, hint_set, hint_set_auto, shutdown. The new `logs` tool fills the observability gap — can now diagnose peer connections, route installation, and errors without SSH access.
26+
27+
### Logging Quality
28+
- **Startup**: clear and informative (version, capabilities, listen addr, PSK warning)
29+
- **Peer lifecycle**: connect events logged with name, addr, TUN interface, route count
30+
- **Missing**: peer disconnect events not logged at info level — only connects appear in the ring buffer
31+
- **Verbosity**: right level for info — no data-plane noise, just lifecycle and errors
32+
33+
### Terminology Consistency
34+
No issues found in this session. Role names (entry/exit), peer/route terminology all consistent.
35+
36+
### UX & Workflow
37+
- Auto-routing on connect is excellent — office network was immediately reachable after gateway-perimeter connected
38+
- Route warning when adding a CIDR not advertised by the peer is helpful and actionable
39+
- `hint_set` tool name is confusing for first-time users — "hint" is protocol jargon. The REPL uses `role` which is much clearer. MCP should match.
40+
41+
### Error Messages
42+
- "peer not found: nonexistent" — clear message, but wrapped as MCP internal_error (-32603) instead of invalid_params (-32602). All daemon `ErrorResponse` messages are mapped to internal_error regardless of cause.
43+
44+
### Specific Issues
45+
| Severity | Finding | Suggested Fix |
46+
|----------|---------|---------------|
47+
| 🔴 Critical | neli 0.7 ACK parsing broken — `recv_netlink_ack` returns "Unexpected payload in ACK" for route add/remove OS operations. Kernel routes silently fail to install for manually added routes. Auto-routes may also be affected. | Fix the raw buffer parsing in `recv_netlink_ack` — the 0.7 `Buffer` payload may include the full nlmsghdr, not just the error code. Compare with neli 0.6 behavior. |
48+
| 🟡 Minor | Peer disconnect events not captured in log ring buffer — connects show "Peer connected: ..." but disconnects produce no corresponding info-level log line | Add `info!("Peer disconnected: name={name}")` in the disconnect path |
49+
| 🟡 Minor | MCP error codes: all daemon errors mapped to `-32603` (internal_error) regardless of cause. "peer not found" and "invalid CIDR" should be `-32602` (invalid_params) | Map `ErrorResponse` codes to appropriate MCP error types in `convert::format_response` |
50+
| 🟡 Minor | `hint_set` / `hint_set_auto` MCP tool names don't match REPL (`role` command). Per interface parity, MCP should expose `role` not `hint` | Rename MCP tools or add `role` as the primary name |
51+
| 🟢 Suggestion | `logs` output could include timestamps for each line (currently just `level: message`) | Prepend compact timestamp to each buffered line |
52+
53+
## Cross-Cutting Observations
54+
55+
- **Hot-patching workflow works but is manual** — kill old process, cp binary, restart with correct flags. The initrd binary is v0.11.0 while we're testing v0.13.0. A `pontoon build` + `range_up` cycle would be cleaner but slower.
56+
- **CLI changed between initrd (subcommand: `wallhack entry`) and current (flag: `wallhack --role entry`)** — the clusterfuck merge changed the CLI surface. Help text says "Use a subcommand" but they're actually flags now. This will confuse users who read old docs.
57+
- **The logs feature delivered exactly what was needed** — during this session, `logs` was the primary diagnostic tool for understanding peer connections, route installation, and error conditions. Without it, debugging would have required SSH + reading stderr.
58+
59+
## Session Transcript (Condensed)
60+
61+
▶ Brought range up (27 VMs)
62+
→ All VMs ready. Wallhack daemon auto-started on attacker (entry) and gateway-perimeter (exit).
63+
64+
`wallhack info` via MCP
65+
→ Connected to attacker daemon (v0.11.0 — stale initrd binary)
66+
67+
`wallhack logs` on stale binary
68+
→ "empty request" — logs feature doesn't exist in v0.11.0. Expected.
69+
70+
▶ Hot-patched attacker and gateway-perimeter with v0.13.0 binary
71+
→ Restarted both daemons with `--role entry/exit` flags
72+
73+
`wallhack logs` on v0.13.0
74+
→ Returns startup lines, peer connect events, route installation. Feature works.
75+
76+
▶ Curled intranet (10.99.2.80) from attacker through tunnel
77+
→ Page returned with DB creds. Full tunnel chain working.
78+
79+
`route_add` with nonexistent peer
80+
→ "peer not found: nonexistent" — clear message, wrong MCP error code (-32603 vs -32602)
81+
82+
`route_add` for CIDR not advertised by peer
83+
→ "OK" with warning about unreachable destination. Excellent UX.
84+
→ Bug: OS route operations fail with "Unexpected payload in ACK" (neli 0.7 parsing)
85+
86+
▶ Disconnected web-filter peer
87+
→ Disconnect succeeded, peer removed from list, but no log line captured for the event
88+
89+
▶ Tunnel still functional after disconnect — office network still reachable

0 commit comments

Comments
 (0)