From 8f7f799044c95af2c0a1bc7021e9d2a714afd7f8 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Tue, 17 Mar 2026 21:06:08 +0700 Subject: [PATCH 01/41] feat(wire): support interactive capability for human-in-the-loop tiebreaker Auto-negotiation between two TUN-capable nodes currently deadlocks at Indeterminate. The new `interactive` field in `Capabilities` lets a node signal "human at terminal", which the negotiation logic (next commit) uses as a deterministic tiebreaker. All existing Capabilities struct literals default to `interactive: false`. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/core/src/client/quic/mod.rs | 1 + crates/core/src/client/ws/mod.rs | 1 + crates/core/src/control/handler.rs | 1 + crates/core/src/negotiate.rs | 1 + crates/core/src/psk.rs | 1 + crates/daemon/src/mode/auto.rs | 2 ++ crates/daemon/src/mode/entry.rs | 3 +++ crates/daemon/src/mode/exit.rs | 2 ++ crates/daemon/src/mode/relay.rs | 3 +++ crates/wire/proto/data.proto | 1 + 10 files changed, 16 insertions(+) diff --git a/crates/core/src/client/quic/mod.rs b/crates/core/src/client/quic/mod.rs index c9bce9c5..0b0be5bd 100644 --- a/crates/core/src/client/quic/mod.rs +++ b/crates/core/src/client/quic/mod.rs @@ -138,6 +138,7 @@ impl Client for QuicClient { tun_capable: false, listening: false, connecting: true, + interactive: false, }), name: self.name.clone().unwrap_or_default(), version: env!("CARGO_PKG_VERSION").to_string(), diff --git a/crates/core/src/client/ws/mod.rs b/crates/core/src/client/ws/mod.rs index 03ba27d0..ebd18b19 100644 --- a/crates/core/src/client/ws/mod.rs +++ b/crates/core/src/client/ws/mod.rs @@ -358,6 +358,7 @@ impl WsClient { tun_capable: false, listening: false, connecting: true, + interactive: false, }), name: self.config.base.name.clone().unwrap_or_default(), version: env!("CARGO_PKG_VERSION").to_string(), diff --git a/crates/core/src/control/handler.rs b/crates/core/src/control/handler.rs index 1dc59790..fa9048ed 100644 --- a/crates/core/src/control/handler.rs +++ b/crates/core/src/control/handler.rs @@ -825,6 +825,7 @@ mod tests { tun_capable: true, listening: false, connecting: false, + interactive: false, }); let status = crate::node_api::NodeApi::status(&handler); diff --git a/crates/core/src/negotiate.rs b/crates/core/src/negotiate.rs index a6f65ef8..8ada6301 100644 --- a/crates/core/src/negotiate.rs +++ b/crates/core/src/negotiate.rs @@ -266,6 +266,7 @@ mod tests { tun_capable, listening, connecting, + interactive: false, }), name: String::new(), version: String::new(), diff --git a/crates/core/src/psk.rs b/crates/core/src/psk.rs index c724e66e..76747723 100644 --- a/crates/core/src/psk.rs +++ b/crates/core/src/psk.rs @@ -83,6 +83,7 @@ mod tests { tun_capable: true, listening: true, connecting: false, + interactive: false, }), name: "test-node".to_string(), version: "0.1.0".to_string(), diff --git a/crates/daemon/src/mode/auto.rs b/crates/daemon/src/mode/auto.rs index 9ca8c41f..b2ba4611 100644 --- a/crates/daemon/src/mode/auto.rs +++ b/crates/daemon/src/mode/auto.rs @@ -86,6 +86,7 @@ pub(crate) async fn run( tun_capable, listening: cfg.listen.is_some(), connecting: cfg.connect.is_some(), + interactive: false, }); match (&cfg.connect, &cfg.listen) { @@ -152,6 +153,7 @@ fn build_local_handshake( tun_capable, listening, connecting, + interactive: false, }), name: cfg.name.clone(), version: version.to_string(), diff --git a/crates/daemon/src/mode/entry.rs b/crates/daemon/src/mode/entry.rs index 7d759ddb..c2b4faef 100644 --- a/crates/daemon/src/mode/entry.rs +++ b/crates/daemon/src/mode/entry.rs @@ -170,6 +170,7 @@ pub async fn run( tun_capable: crate::tun_cap::detect_tun_capable(), listening: false, connecting: false, + interactive: false, }); let res = EntryResources { @@ -215,6 +216,7 @@ async fn run_entry_listen( tun_capable: crate::tun_cap::detect_tun_capable(), listening: true, connecting: false, + interactive: false, }), name: cfg.name.clone(), version: global.version.clone(), @@ -438,6 +440,7 @@ fn entry_local_handshake(name: &str, version: &str) -> wallhack_wire::data::Hand tun_capable: crate::tun_cap::detect_tun_capable(), listening: false, connecting: true, + interactive: false, }), name: name.to_string(), version: version.to_string(), diff --git a/crates/daemon/src/mode/exit.rs b/crates/daemon/src/mode/exit.rs index 255477ec..53701620 100644 --- a/crates/daemon/src/mode/exit.rs +++ b/crates/daemon/src/mode/exit.rs @@ -101,6 +101,7 @@ async fn run_exit_connector( tun_capable: false, listening: false, connecting: true, + interactive: false, }), name: name.to_string(), version: global.version.clone(), @@ -241,6 +242,7 @@ async fn run_exit_listener( tun_capable: false, listening: true, connecting: false, + interactive: false, }), name: node_name.to_string(), version: global.version.clone(), diff --git a/crates/daemon/src/mode/relay.rs b/crates/daemon/src/mode/relay.rs index 8dec4e55..b556db48 100644 --- a/crates/daemon/src/mode/relay.rs +++ b/crates/daemon/src/mode/relay.rs @@ -51,6 +51,7 @@ fn build_server_options(cfg: &RelayConfig, version: &str, metrics: Arc) tun_capable: false, listening: true, connecting: true, + interactive: false, }), name: cfg.name.clone(), version: version.to_string(), @@ -82,6 +83,7 @@ pub async fn run( tun_capable: false, listening: true, connecting: true, + interactive: false, }); let addr: std::net::SocketAddr = cfg.listen.addr.parse::()?.into(); let server_options = build_server_options(cfg, &global.version, metrics); @@ -101,6 +103,7 @@ pub async fn run( tun_capable: false, listening: true, connecting: true, + interactive: false, }), name: cfg.name.clone(), version: global.version.clone(), diff --git a/crates/wire/proto/data.proto b/crates/wire/proto/data.proto index 4ea8269c..bb95ec63 100644 --- a/crates/wire/proto/data.proto +++ b/crates/wire/proto/data.proto @@ -222,6 +222,7 @@ message Capabilities { bool tun_capable = 1; // Whether this node can create TUN interfaces bool listening = 2; // Can accept incoming connections bool connecting = 3; // Can initiate outgoing connections + bool interactive = 4; // Human at terminal — tiebreaker for auto-negotiation } // Handshake message exchanged bidirectionally after transport connects. From a9ae95c4ecadb39a64a8c48b2f5eb9550d73f126 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Tue, 17 Mar 2026 21:31:12 +0700 Subject: [PATCH 02/41] feat(negotiate): resolve TUN-capable ambiguity via interactive tiebreaker When both peers are TUN-capable, the interactive flag (human at terminal) breaks the deadlock deterministically. Adds reason field to Resolved variant for traceability. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/core/src/negotiate.rs | 220 +++++++++++++++++++++++++++-------- crates/wire/src/data.rs | 8 +- 2 files changed, 176 insertions(+), 52 deletions(-) diff --git a/crates/core/src/negotiate.rs b/crates/core/src/negotiate.rs index 8ada6301..27e26cab 100644 --- a/crates/core/src/negotiate.rs +++ b/crates/core/src/negotiate.rs @@ -12,7 +12,12 @@ use crate::NodeRole; #[derive(Debug, PartialEq, Eq, Clone)] pub enum NegotiationResult { /// Role unambiguously determined. - Resolved(NodeRole), + Resolved { + /// The negotiated role. + role: NodeRole, + /// Human-readable explanation of why this role was selected. + reason: &'static str, + }, /// Cannot determine role from current inputs alone. /// The node stays in `NodeRole::Indeterminate` and waits for a topology /// change or an operator hint (Phase 13d). @@ -22,7 +27,7 @@ pub enum NegotiationResult { impl std::fmt::Display for NegotiationResult { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::Resolved(role) => write!(f, "resolved({role:?})"), + Self::Resolved { role, reason } => write!(f, "resolved({role:?}, {reason})"), Self::Indeterminate { reason } => write!(f, "indeterminate({reason})"), } } @@ -81,14 +86,17 @@ pub fn negotiate(local: &Handshake, peer: &Handshake) -> NegotiationResult { reason: "both peers have fixed hints for the same role", }; } - return NegotiationResult::Resolved(target); + return NegotiationResult::Resolved { + role: target, + reason: "local has a fixed role hint", + }; } // 2. Capability-based rules. let cap_result = negotiate_from_capabilities(local, peer); // If capabilities gave a clear answer, return it. - if let NegotiationResult::Resolved(_) = &cap_result { + if let NegotiationResult::Resolved { .. } = &cap_result { return cap_result; } @@ -132,26 +140,55 @@ fn negotiate_from_capabilities(local: &Handshake, peer: &Handshake) -> Negotiati // Relay is unambiguous: if local is both listening and connecting, it is // always relay regardless of peer capabilities or TUN status. if local_relay { - return NegotiationResult::Resolved(NodeRole::Relay); + return NegotiationResult::Resolved { + role: NodeRole::Relay, + reason: "local is relay (both listening and connecting)", + }; } // Peer of a relay: resolve from local TUN capability alone. // The relay's presence signals the chain continues through it. if peer_relay { return if local_tun { - NegotiationResult::Resolved(NodeRole::Entry) + NegotiationResult::Resolved { + role: NodeRole::Entry, + reason: "peer is relay; local has TUN capability", + } } else { - NegotiationResult::Resolved(NodeRole::Exit) + NegotiationResult::Resolved { + role: NodeRole::Exit, + reason: "peer is relay; local lacks TUN", + } }; } // Two non-relay nodes: entry/exit determined by TUN capability. match (local_tun, peer_tun) { - (true, false) => NegotiationResult::Resolved(NodeRole::Entry), - (false, true) => NegotiationResult::Resolved(NodeRole::Exit), - (true, true) => NegotiationResult::Indeterminate { - reason: "both peers are TUN-capable; set a preferred or fixed role to resolve", + (true, false) => NegotiationResult::Resolved { + role: NodeRole::Entry, + reason: "TUN capability asymmetry: local has TUN, peer does not", + }, + (false, true) => NegotiationResult::Resolved { + role: NodeRole::Exit, + reason: "TUN capability asymmetry: peer has TUN, local does not", }, + (true, true) => { + let local_interactive = local_caps.is_some_and(|c| c.interactive); + let peer_interactive = peer_caps.is_some_and(|c| c.interactive); + match (local_interactive, peer_interactive) { + (true, false) => NegotiationResult::Resolved { + role: NodeRole::Entry, + reason: "interactive terminal (human-in-the-loop)", + }, + (false, true) => NegotiationResult::Resolved { + role: NodeRole::Exit, + reason: "peer has interactive terminal", + }, + _ => NegotiationResult::Indeterminate { + reason: "both peers are TUN-capable; set a preferred or fixed role to resolve", + }, + } + } (false, false) => NegotiationResult::Indeterminate { reason: "neither peer has TUN capability", }, @@ -198,7 +235,10 @@ fn negotiate_with_exclude( reason: "excluded entry for local, but peer also lacks TUN capability", }; } - return NegotiationResult::Resolved(remaining); + return NegotiationResult::Resolved { + role: remaining, + reason: "exclude hint narrows to complement role", + }; } // Excluding relay when local isn't a relay is a no-op for two-node @@ -218,7 +258,10 @@ fn negotiate_with_prefer( // Peer prefers relay → signals the chain continues through them, so a // TUN-capable local resolves to entry. if peer_prefer == Some(NodeRole::Relay) && local_tun { - return NegotiationResult::Resolved(NodeRole::Entry); + return NegotiationResult::Resolved { + role: NodeRole::Entry, + reason: "peer prefers relay; local has TUN capability", + }; } match (local_prefer, peer_prefer) { @@ -227,13 +270,22 @@ fn negotiate_with_prefer( reason: "both peers prefer the same role", }, // They prefer different roles → each gets what they want. - (Some(l), Some(_)) => NegotiationResult::Resolved(l), + (Some(role), Some(_)) => NegotiationResult::Resolved { + role, + reason: "local prefer hint resolved (peers prefer different roles)", + }, // Only local prefers → local gets it. - (Some(target), None) => NegotiationResult::Resolved(target), + (Some(role), None) => NegotiationResult::Resolved { + role, + reason: "local prefer hint resolved (uncontested)", + }, // Only peer prefers → local gets the complement. (None, Some(peer_target)) => { - if let Some(c) = complement(peer_target) { - NegotiationResult::Resolved(c) + if let Some(role) = complement(peer_target) { + NegotiationResult::Resolved { + role, + reason: "peer prefer hint; local takes complement role", + } } else { negotiate_from_capabilities(local, peer) } @@ -283,14 +335,46 @@ mod tests { } } - fn resolved(role: NodeRole) -> NegotiationResult { - NegotiationResult::Resolved(role) + fn assert_resolved(result: &NegotiationResult, expected_role: NodeRole) { + match result { + NegotiationResult::Resolved { role, .. } => assert_eq!( + *role, expected_role, + "expected {expected_role:?}, got {role:?}" + ), + NegotiationResult::Indeterminate { reason } => { + panic!("expected Resolved({expected_role:?}), got indeterminate({reason})"); + } + } } fn is_indeterminate(r: &NegotiationResult) -> bool { matches!(r, NegotiationResult::Indeterminate { .. }) } + // REASON: This is a test helper that mirrors the Capabilities wire format; the + // bools are distinct flags, not a state machine. Only used in tests. + #[allow(clippy::fn_params_excessive_bools)] + fn hs_interactive( + tun_capable: bool, + listening: bool, + connecting: bool, + interactive: bool, + ) -> Handshake { + Handshake { + capabilities: Some(Capabilities { + tun_capable, + listening, + connecting, + interactive, + }), + name: String::new(), + version: String::new(), + psk_proof: vec![], + routes: vec![], + hint: None, + } + } + // ------------------------------------------------------------------------- // Topology table from 13c task doc // (L = listening, C = connecting, T = tun_capable) @@ -301,9 +385,9 @@ mod tests { fn tun_listen_vs_nontun_connect() { let local = hs(true, true, false); let peer = hs(false, false, true); - assert_eq!(negotiate(&local, &peer), resolved(NodeRole::Entry)); + assert_resolved(&negotiate(&local, &peer), NodeRole::Entry); // symmetry: peer sees exit - assert_eq!(negotiate(&peer, &local), resolved(NodeRole::Exit)); + assert_resolved(&negotiate(&peer, &local), NodeRole::Exit); } /// non-TUN connector ↔ TUN-capable listener → exit / entry @@ -311,8 +395,8 @@ mod tests { fn nontun_connect_vs_tun_listen() { let local = hs(false, false, true); let peer = hs(true, true, false); - assert_eq!(negotiate(&local, &peer), resolved(NodeRole::Exit)); - assert_eq!(negotiate(&peer, &local), resolved(NodeRole::Entry)); + assert_resolved(&negotiate(&local, &peer), NodeRole::Exit); + assert_resolved(&negotiate(&peer, &local), NodeRole::Entry); } /// TUN-capable connector ↔ non-TUN listener → entry / exit @@ -320,8 +404,8 @@ mod tests { fn tun_connect_vs_nontun_listen() { let local = hs(true, false, true); let peer = hs(false, true, false); - assert_eq!(negotiate(&local, &peer), resolved(NodeRole::Entry)); - assert_eq!(negotiate(&peer, &local), resolved(NodeRole::Exit)); + assert_resolved(&negotiate(&local, &peer), NodeRole::Entry); + assert_resolved(&negotiate(&peer, &local), NodeRole::Exit); } /// non-TUN listener ↔ TUN-capable connector → exit / entry @@ -329,8 +413,8 @@ mod tests { fn nontun_listen_vs_tun_connect() { let local = hs(false, true, false); let peer = hs(true, false, true); - assert_eq!(negotiate(&local, &peer), resolved(NodeRole::Exit)); - assert_eq!(negotiate(&peer, &local), resolved(NodeRole::Entry)); + assert_resolved(&negotiate(&local, &peer), NodeRole::Exit); + assert_resolved(&negotiate(&peer, &local), NodeRole::Entry); } /// TUN-capable listener ↔ TUN-capable connector → indeterminate (symmetric) @@ -374,22 +458,22 @@ mod tests { fn relay_vs_nontun_connector() { let local = hs(false, true, true); // relay let peer = hs(false, false, true); - assert_eq!(negotiate(&local, &peer), resolved(NodeRole::Relay)); + assert_resolved(&negotiate(&local, &peer), NodeRole::Relay); } #[test] fn relay_vs_tun_listener() { let local = hs(false, true, true); // relay let peer = hs(true, true, false); - assert_eq!(negotiate(&local, &peer), resolved(NodeRole::Relay)); + assert_resolved(&negotiate(&local, &peer), NodeRole::Relay); } #[test] fn relay_vs_relay() { let local = hs(false, true, true); // relay let peer = hs(false, true, true); // also relay - assert_eq!(negotiate(&local, &peer), resolved(NodeRole::Relay)); - assert_eq!(negotiate(&peer, &local), resolved(NodeRole::Relay)); + assert_resolved(&negotiate(&local, &peer), NodeRole::Relay); + assert_resolved(&negotiate(&peer, &local), NodeRole::Relay); } /// TUN-capable node ↔ relay → entry @@ -397,14 +481,14 @@ mod tests { fn tun_listen_vs_relay() { let local = hs(true, true, false); let peer = hs(false, true, true); // relay - assert_eq!(negotiate(&local, &peer), resolved(NodeRole::Entry)); + assert_resolved(&negotiate(&local, &peer), NodeRole::Entry); } #[test] fn tun_connect_vs_relay() { let local = hs(true, false, true); let peer = hs(false, true, true); // relay - assert_eq!(negotiate(&local, &peer), resolved(NodeRole::Entry)); + assert_resolved(&negotiate(&local, &peer), NodeRole::Entry); } /// non-TUN node ↔ relay → exit @@ -412,14 +496,14 @@ mod tests { fn nontun_listen_vs_relay() { let local = hs(false, true, false); let peer = hs(false, true, true); // relay - assert_eq!(negotiate(&local, &peer), resolved(NodeRole::Exit)); + assert_resolved(&negotiate(&local, &peer), NodeRole::Exit); } #[test] fn nontun_connect_vs_relay() { let local = hs(false, false, true); let peer = hs(false, true, true); // relay - assert_eq!(negotiate(&local, &peer), resolved(NodeRole::Exit)); + assert_resolved(&negotiate(&local, &peer), NodeRole::Exit); } // ------------------------------------------------------------------------- @@ -441,8 +525,8 @@ mod tests { fn symmetry_entry_exit() { let a = hs(true, true, false); let b = hs(false, false, true); - assert_eq!(negotiate(&a, &b), resolved(NodeRole::Entry)); - assert_eq!(negotiate(&b, &a), resolved(NodeRole::Exit)); + assert_resolved(&negotiate(&a, &b), NodeRole::Entry); + assert_resolved(&negotiate(&b, &a), NodeRole::Exit); } #[test] @@ -466,7 +550,7 @@ mod tests { }; let peer = hs(true, true, false); // local has no capabilities → non-TUN, not relay - assert_eq!(negotiate(&local, &peer), resolved(NodeRole::Exit)); + assert_resolved(&negotiate(&local, &peer), NodeRole::Exit); } // ------------------------------------------------------------------------- @@ -483,9 +567,9 @@ mod tests { Some(role_hint(HintLevel::Prefer, ProtoNodeRole::RoleEntry)), ); let peer = hs(true, false, true); - assert_eq!(negotiate(&local, &peer), resolved(NodeRole::Entry)); + assert_resolved(&negotiate(&local, &peer), NodeRole::Entry); // Symmetry: peer sees exit. - assert_eq!(negotiate(&peer, &local), resolved(NodeRole::Exit)); + assert_resolved(&negotiate(&peer, &local), NodeRole::Exit); } /// PREFER ignored when topology is unambiguous. @@ -499,7 +583,7 @@ mod tests { Some(role_hint(HintLevel::Prefer, ProtoNodeRole::RoleExit)), ); let peer = hs(false, false, true); - assert_eq!(negotiate(&local, &peer), resolved(NodeRole::Entry)); + assert_resolved(&negotiate(&local, &peer), NodeRole::Entry); } /// Conflicting PREFER: both prefer entry → Indeterminate. @@ -536,8 +620,8 @@ mod tests { true, Some(role_hint(HintLevel::Prefer, ProtoNodeRole::RoleExit)), ); - assert_eq!(negotiate(&local, &peer), resolved(NodeRole::Entry)); - assert_eq!(negotiate(&peer, &local), resolved(NodeRole::Exit)); + assert_resolved(&negotiate(&local, &peer), NodeRole::Entry); + assert_resolved(&negotiate(&peer, &local), NodeRole::Exit); } /// EXCLUDE removes a role from consideration. @@ -551,7 +635,7 @@ mod tests { Some(role_hint(HintLevel::Exclude, ProtoNodeRole::RoleEntry)), ); let peer = hs(true, false, true); - assert_eq!(negotiate(&local, &peer), resolved(NodeRole::Exit)); + assert_resolved(&negotiate(&local, &peer), NodeRole::Exit); } /// EXCLUDE leaves no valid role → Indeterminate. @@ -579,7 +663,7 @@ mod tests { Some(role_hint(HintLevel::Fixed, ProtoNodeRole::RoleExit)), ); let peer = hs(false, false, true); - assert_eq!(negotiate(&local, &peer), resolved(NodeRole::Exit)); + assert_resolved(&negotiate(&local, &peer), NodeRole::Exit); } /// FIXED + both fixed to same role → Indeterminate. @@ -611,9 +695,9 @@ mod tests { true, Some(role_hint(HintLevel::Prefer, ProtoNodeRole::RoleRelay)), ); - assert_eq!(negotiate(&local, &peer), resolved(NodeRole::Entry)); + assert_resolved(&negotiate(&local, &peer), NodeRole::Entry); // Symmetry: peer prefers relay, so peer resolves to relay. - assert_eq!(negotiate(&peer, &local), resolved(NodeRole::Relay)); + assert_resolved(&negotiate(&peer, &local), NodeRole::Relay); } /// Symmetry verified for all hint scenarios. @@ -626,8 +710,8 @@ mod tests { Some(role_hint(HintLevel::Prefer, ProtoNodeRole::RoleEntry)), ); let b = hs(true, false, true); - assert_eq!(negotiate(&a, &b), resolved(NodeRole::Entry)); - assert_eq!(negotiate(&b, &a), resolved(NodeRole::Exit)); + assert_resolved(&negotiate(&a, &b), NodeRole::Entry); + assert_resolved(&negotiate(&b, &a), NodeRole::Exit); } #[test] @@ -644,7 +728,41 @@ mod tests { true, Some(role_hint(HintLevel::Fixed, ProtoNodeRole::RoleExit)), ); - assert_eq!(negotiate(&a, &b), resolved(NodeRole::Entry)); - assert_eq!(negotiate(&b, &a), resolved(NodeRole::Exit)); + assert_resolved(&negotiate(&a, &b), NodeRole::Entry); + assert_resolved(&negotiate(&b, &a), NodeRole::Exit); + } + + // ------------------------------------------------------------------------- + // Interactive tiebreaker tests + // ------------------------------------------------------------------------- + + /// One peer is interactive, the other is not; both are TUN-capable → resolves. + #[test] + fn interactive_breaks_tun_ambiguity() { + let local = hs_interactive(true, true, false, true); // interactive + let peer = hs_interactive(true, false, true, false); // not interactive + assert_resolved(&negotiate(&local, &peer), NodeRole::Entry); + // Symmetry: peer sees exit. + assert_resolved(&negotiate(&peer, &local), NodeRole::Exit); + } + + /// Both peers are interactive and TUN-capable → still indeterminate. + #[test] + fn both_interactive_still_indeterminate() { + let local = hs_interactive(true, true, false, true); + let peer = hs_interactive(true, false, true, true); + assert!(is_indeterminate(&negotiate(&local, &peer))); + assert!(is_indeterminate(&negotiate(&peer, &local))); + } + + /// Interactive flag has no effect when peers are not both TUN-capable. + #[test] + fn interactive_irrelevant_without_tun() { + // Local is interactive but not TUN-capable; peer is TUN-capable. + // TUN asymmetry should determine the role, not interactive. + let local = hs_interactive(false, true, false, true); + let peer = hs_interactive(true, false, true, false); + assert_resolved(&negotiate(&local, &peer), NodeRole::Exit); + assert_resolved(&negotiate(&peer, &local), NodeRole::Entry); } } diff --git a/crates/wire/src/data.rs b/crates/wire/src/data.rs index 51c5ce50..50a067e2 100644 --- a/crates/wire/src/data.rs +++ b/crates/wire/src/data.rs @@ -5,7 +5,13 @@ use icmp_send_instruction::IcmpMessage; use crate::helpers::{ConversionError, vec_to_sized_array}; // Suppress clippy warnings from auto-generated prost code -#[allow(clippy::doc_markdown, clippy::must_use_candidate)] +#[allow( + clippy::doc_markdown, + clippy::must_use_candidate, + // REASON: Capabilities is a generated prost struct with four bool fields that + // directly mirror the proto definition; restructuring would diverge from the wire format. + clippy::struct_excessive_bools +)] mod generated { include!(concat!(env!("OUT_DIR"), "/wallhack.data.rs")); } From c8814362d1db3301a348a6a9432bfcdfd1592c96 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Tue, 17 Mar 2026 21:31:23 +0700 Subject: [PATCH 03/41] feat(daemon): advertise interactive capability when stdin is a terminal Auto-mode detects whether stdin is a terminal and advertises it in the handshake. Explicit modes always set interactive=false since their role is predetermined. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/daemon/src/mode/auto.rs | 68 +++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 13 deletions(-) diff --git a/crates/daemon/src/mode/auto.rs b/crates/daemon/src/mode/auto.rs index b2ba4611..95661e6e 100644 --- a/crates/daemon/src/mode/auto.rs +++ b/crates/daemon/src/mode/auto.rs @@ -141,19 +141,23 @@ pub(crate) async fn run( /// /// Always populates `routes` with locally-routable CIDRs so that a peer /// resolving to Entry can install OS routes automatically. +// REASON: these are distinct capability flags from the wire format; wrapping +// them in an enum would add indirection without clarity at the call sites. +#[allow(clippy::fn_params_excessive_bools)] fn build_local_handshake( cfg: &AutoConfig, version: &str, tun_capable: bool, listening: bool, connecting: bool, + interactive: bool, ) -> Handshake { Handshake { capabilities: Some(Capabilities { tun_capable, listening, connecting, - interactive: false, + interactive, }), name: cfg.name.clone(), version: version.to_string(), @@ -226,7 +230,14 @@ async fn run_auto_connector( route_updates: tokio::sync::broadcast::Receiver, node_state: SharedNodeState, ) -> Result<(), NodeError> { - let local_hs = build_local_handshake(cfg, &global.version, tun_capable, false, true); + let local_hs = build_local_handshake( + cfg, + &global.version, + tun_capable, + false, + true, + std::io::IsTerminal::is_terminal(&std::io::stdin()), + ); tracing::info!("Auto connector: connecting to {}...", spec.addr); let endpoint = @@ -398,13 +409,16 @@ async fn run_auto_connect_session_dispatch( let result = negotiate(local_hs, &peer_hs); let negotiated_role = match &result { - NegotiationResult::Resolved(role) => *role, + NegotiationResult::Resolved { role, .. } => *role, NegotiationResult::Indeterminate { .. } => NodeRole::Indeterminate, }; node_state.update_role(negotiated_role); match result { - NegotiationResult::Resolved(NodeRole::Entry) => { + NegotiationResult::Resolved { + role: NodeRole::Entry, + .. + } => { tracing::info!( "Role resolved: name={} addr={peer_addr} role=entry", peer_hs.name, @@ -461,7 +475,10 @@ async fn run_auto_connect_session_dispatch( result } - NegotiationResult::Resolved(NodeRole::Exit) => { + NegotiationResult::Resolved { + role: NodeRole::Exit, + .. + } => { tracing::info!( "Role resolved: name={} addr={peer_addr} role=exit", peer_hs.name, @@ -508,13 +525,19 @@ async fn run_auto_connect_session_dispatch( ) .await } - NegotiationResult::Resolved(NodeRole::Relay) => { + NegotiationResult::Resolved { + role: NodeRole::Relay, + .. + } => { tracing::warn!("Unexpected relay negotiation for connector-only mode; holding"); let _keep_alive = control_tx; hold_until_disconnect(tasks).await; Ok(()) } - NegotiationResult::Resolved(NodeRole::Indeterminate) + NegotiationResult::Resolved { + role: NodeRole::Indeterminate, + .. + } | NegotiationResult::Indeterminate { .. } => { tracing::warn!("Role negotiated: {result}"); let name = if peer_hs.name.is_empty() { @@ -615,7 +638,14 @@ async fn run_auto_listener( route_updates_tx: tokio::sync::broadcast::Sender, node_state: SharedNodeState, ) -> Result<(), NodeError> { - let local_hs = build_local_handshake(cfg, &global.version, tun_capable, true, false); + let local_hs = build_local_handshake( + cfg, + &global.version, + tun_capable, + true, + false, + std::io::IsTerminal::is_terminal(&std::io::stdin()), + ); let addr: std::net::SocketAddr = spec.addr.parse::()?.into(); let server_options = ServerOptions { @@ -835,13 +865,16 @@ async fn run_auto_accept_session_inner( let result = negotiate(&local_hs, &peer_hs); let negotiated_role = match &result { - NegotiationResult::Resolved(role) => *role, + NegotiationResult::Resolved { role, .. } => *role, NegotiationResult::Indeterminate { .. } => NodeRole::Indeterminate, }; node_state.update_role(negotiated_role); match result { - NegotiationResult::Resolved(NodeRole::Entry) => { + NegotiationResult::Resolved { + role: NodeRole::Entry, + .. + } => { tracing::info!( "Role resolved: name={} addr={peer_addr} role=entry", peer_hs.name, @@ -1002,7 +1035,10 @@ async fn run_auto_accept_session_inner( } } } - NegotiationResult::Resolved(NodeRole::Exit) => { + NegotiationResult::Resolved { + role: NodeRole::Exit, + .. + } => { tracing::info!( "Role resolved: name={} addr={peer_addr} role=exit", peer_hs.name, @@ -1087,11 +1123,17 @@ async fn run_auto_accept_session_inner( peers.unregister(&peer_name); tracing::info!("Peer disconnected: {peer_name}"); } - NegotiationResult::Resolved(NodeRole::Relay) => { + NegotiationResult::Resolved { + role: NodeRole::Relay, + .. + } => { tracing::warn!("Unexpected relay negotiation for listener-only mode; holding"); let _keep_alive = control_tx; } - NegotiationResult::Resolved(NodeRole::Indeterminate) + NegotiationResult::Resolved { + role: NodeRole::Indeterminate, + .. + } | NegotiationResult::Indeterminate { .. } => { tracing::warn!("Role negotiated: {result}"); let name = if peer_hs.name.is_empty() { From 088b07ef11de1250b92f00dbec577308d651075b Mon Sep 17 00:00:00 2001 From: Max Holman Date: Tue, 17 Mar 2026 21:43:37 +0700 Subject: [PATCH 04/41] fix(relay): identify accepted peers by handshake name instead of raw address Previously all accepted relay peers were registered with their socket address as name and NodeRole::Relay hardcoded. Now extracts the peer's configured name and derives role from advertised capabilities, matching how entry/exit modes register peers. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/daemon/src/mode/relay.rs | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/daemon/src/mode/relay.rs b/crates/daemon/src/mode/relay.rs index b556db48..404ebb3f 100644 --- a/crates/daemon/src/mode/relay.rs +++ b/crates/daemon/src/mode/relay.rs @@ -571,6 +571,7 @@ where } /// Non-generic handler for erased relay connection results. +#[allow(clippy::too_many_lines)] // REASON: symmetric uni/bidi stream setup and heartbeat per accepted peer fn handle_relay_connection( erased: wallhack_core::server::server::ErasedAcceptResult, source_resp_tx: tokio::sync::mpsc::Sender, @@ -591,6 +592,7 @@ fn handle_relay_connection( let peer_addr = erased.peer_addr; let transport = erased.transport; + let peer_handshake = erased.peer_handshake; let (channels, control_tx) = (erased.channels, erased.control_tx); let DataChannels { instructions_tx, @@ -599,11 +601,20 @@ fn handle_relay_connection( responses_rx, } = channels; + let peer_name = peer_handshake + .as_ref() + .filter(|h| !h.name.is_empty()) + .map_or_else(|| peer_addr.clone(), |h| h.name.clone()); + let peer_role = peer_handshake + .as_ref() + .and_then(|h| h.capabilities) + .map_or(NodeRole::Exit, super::peer_role_from_capabilities); + // Register the bridged peer so it appears in `wallhack peers`. peers.register( + peer_name.clone(), peer_addr.clone(), - peer_addr.clone(), - NodeRole::Relay, + peer_role, ConnectionSide::Accept, ); @@ -627,7 +638,7 @@ fn handle_relay_connection( // Outgoing: open uni stream to exit peer, send instructions from the entry. // instructions_rx receives instructions distributed by the fan-out task. let peer_transport_instr = std::sync::Arc::clone(&transport); - let peer_addr_cleanup = peer_addr.clone(); + let peer_name_cleanup = peer_name.clone(); let peers_cleanup = Arc::clone(peers); tokio::spawn(async move { match peer_transport_instr.open_uni_erased().await { @@ -639,7 +650,7 @@ fn handle_relay_connection( Err(e) => tracing::debug!("Relay peer failed to open send stream: {e}"), } // Unregister peer when the outgoing stream closes (connection gone). - peers_cleanup.unregister(&peer_addr_cleanup); + peers_cleanup.unregister(&peer_name_cleanup); }); // Register this peer's transport for bidi bridging. @@ -684,7 +695,7 @@ fn handle_relay_connection( }); crate::transport::relay_bridge_channels( - &peer_addr, + &peer_name, instructions_tx, responses_rx, control_tx, From 4a2c3d38cf72491b5ad84b7ce14153db1be75196 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Tue, 17 Mar 2026 21:45:52 +0700 Subject: [PATCH 05/41] fix(relay): enable heartbeat and latency tracking for all relay peers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source and accepted peers had no heartbeat — no latency tracking, no liveness checks, and disconnect_peer IPC couldn't reach them. Spawns heartbeat for both connection paths. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/daemon/src/mode/relay.rs | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/daemon/src/mode/relay.rs b/crates/daemon/src/mode/relay.rs index 404ebb3f..e70972fb 100644 --- a/crates/daemon/src/mode/relay.rs +++ b/crates/daemon/src/mode/relay.rs @@ -149,6 +149,7 @@ pub async fn run( e.tasks, e.control_tx, e.peer_handshake_rx, + e.latency_rx, &global, &listen_spec, addr, @@ -202,6 +203,7 @@ pub async fn run( e.tasks, e.control_tx, e.peer_handshake_rx, + e.latency_rx, &global, &listen_spec, addr, @@ -234,10 +236,9 @@ async fn run_relay_loop_inner( transport: std::sync::Arc, channels: wallhack_core::server::server::DataChannels, mut tasks: wallhack_core::client::client::ConnectionTasks, - // Retain control_tx for the full session lifetime — dropping it kills the - // control stream and causes the source to see the relay as disconnected. - _source_control_tx: tokio::sync::mpsc::Sender, + source_control_tx: tokio::sync::mpsc::Sender, peer_handshake_rx: Option>, + latency_rx: Option>, global: &GlobalConfig, listen_spec: &AddressSpec, addr: std::net::SocketAddr, @@ -276,6 +277,13 @@ async fn run_relay_loop_inner( ConnectionSide::Connect, ); + let _source_heartbeat = super::spawn_heartbeat( + source_control_tx, + latency_rx, + peer_name.clone(), + Arc::clone(&peers), + ); + let DataChannels { instructions_tx: _source_instr_tx, instructions_rx: source_instr_rx, @@ -593,6 +601,7 @@ fn handle_relay_connection( let peer_addr = erased.peer_addr; let transport = erased.transport; let peer_handshake = erased.peer_handshake; + let latency_rx = erased.latency_rx; let (channels, control_tx) = (erased.channels, erased.control_tx); let DataChannels { instructions_tx, @@ -618,6 +627,14 @@ fn handle_relay_connection( ConnectionSide::Accept, ); + let heartbeat_control_tx = control_tx.clone(); + let _accepted_heartbeat = super::spawn_heartbeat( + heartbeat_control_tx, + latency_rx, + peer_name.clone(), + Arc::clone(peers), + ); + // Incoming: accept uni stream from exit peer, dispatch data messages. // Exit peers send ExitNodeResponses which are dispatched via responses_tx. let peer_transport_uni = std::sync::Arc::clone(&transport); From b2aa40a1fc30bc1fa66c6c9e00605049ae1823b2 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Tue, 17 Mar 2026 21:40:46 +0700 Subject: [PATCH 06/41] fix(ipc): per-instance socket paths prevent collision between concurrent daemons Multiple daemon instances on the same host (e.g. entry + relay) previously fought over a single wallhackd.sock. The daemon now derives a socket filename from the mode name (wallhackd-entry.sock, wallhackd-relay.sock, etc.). The WALLHACK_HOST env var and -H flag still override for explicit addressing. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/cli/src/bin/wallhack.rs | 2 +- crates/core/src/ipc.rs | 29 ++++++++++++++++++++--------- crates/daemon/src/lib.rs | 3 ++- crates/ipc/src/client.rs | 28 ++++++++++++++++++---------- 4 files changed, 41 insertions(+), 21 deletions(-) diff --git a/crates/cli/src/bin/wallhack.rs b/crates/cli/src/bin/wallhack.rs index 4d557966..261b9fc1 100644 --- a/crates/cli/src/bin/wallhack.rs +++ b/crates/cli/src/bin/wallhack.rs @@ -196,7 +196,7 @@ fn run_daemon_repl( // Start IPC listener so wallhackctl can still connect. let socket_path = cli.host.as_deref().map_or_else( - wallhack_core::ipc::socket_path, + || wallhack_core::ipc::socket_path(None), wallhack_cli::ipc::resolve_host, ); let ipc_api = handle.api_arc(); diff --git a/crates/core/src/ipc.rs b/crates/core/src/ipc.rs index b7e4bc9c..338d475d 100644 --- a/crates/core/src/ipc.rs +++ b/crates/core/src/ipc.rs @@ -39,25 +39,36 @@ const HOST_ENV: &str = "WALLHACK_HOST"; /// Resolve the IPC socket path. /// +/// When `name` is `Some(n)`, the socket filename is `wallhackd-{n}.sock`, +/// allowing multiple daemon instances (e.g. entry + relay) to coexist on +/// the same host without colliding on `wallhackd.sock`. +/// When `name` is `None`, the default `wallhackd.sock` filename is used. +/// +/// `WALLHACK_HOST` overrides everything and is checked first. +/// /// Checks (in order): /// 1. `WALLHACK_HOST` environment variable -/// 2. `$XDG_RUNTIME_DIR/wallhack/wallhackd.sock` -/// 3. `/tmp/wallhack-/wallhackd.sock` -/// 4. `$HOME/.wallhack/wallhackd.sock` -/// 5. `/tmp/wallhack-shared/wallhackd.sock` +/// 2. `$XDG_RUNTIME_DIR/wallhack/wallhackd[-{name}].sock` +/// 3. `/tmp/wallhack-/wallhackd[-{name}].sock` +/// 4. `$HOME/.wallhack/wallhackd[-{name}].sock` +/// 5. `/tmp/wallhack-shared/wallhackd[-{name}].sock` #[must_use] -pub fn socket_path() -> PathBuf { +pub fn socket_path(name: Option<&str>) -> PathBuf { if let Ok(host) = std::env::var(HOST_ENV) { return PathBuf::from(host.strip_prefix("unix://").unwrap_or(&host)); } + let filename = match name { + Some(n) => format!("wallhackd-{n}.sock"), + None => SOCKET_NAME.to_string(), + }; if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { - Path::new(&runtime_dir).join("wallhack").join(SOCKET_NAME) + Path::new(&runtime_dir).join("wallhack").join(&filename) } else if let Ok(user) = std::env::var("USER") { - PathBuf::from(format!("/tmp/wallhack-{user}")).join(SOCKET_NAME) + PathBuf::from(format!("/tmp/wallhack-{user}")).join(&filename) } else if let Ok(home) = std::env::var("HOME") { - Path::new(&home).join(".wallhack").join(SOCKET_NAME) + Path::new(&home).join(".wallhack").join(&filename) } else { - PathBuf::from("/tmp/wallhack-shared").join(SOCKET_NAME) + PathBuf::from("/tmp/wallhack-shared").join(&filename) } } diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs index a0901846..4562137a 100644 --- a/crates/daemon/src/lib.rs +++ b/crates/daemon/src/lib.rs @@ -58,7 +58,8 @@ pub async fn run_daemon_engine( let handle = start_node(&config)?; // Start IPC listener for the management protocol. - let socket_path = socket_path_override.unwrap_or_else(wallhack_core::ipc::socket_path); + let socket_path = socket_path_override + .unwrap_or_else(|| wallhack_core::ipc::socket_path(Some(config.mode.name()))); let api = handle.api_arc(); let peer_events = handle.peer_events_sender(); let shutdown_rx = handle.shutdown_rx(); diff --git a/crates/ipc/src/client.rs b/crates/ipc/src/client.rs index 35612611..ddebca2f 100644 --- a/crates/ipc/src/client.rs +++ b/crates/ipc/src/client.rs @@ -92,28 +92,36 @@ pub fn resolve_host(host: &str) -> PathBuf { /// Resolve the default IPC socket path (ignores `vsock://` `WALLHACK_HOST` values). /// +/// When `name` is `Some(n)`, the socket filename is `wallhackd-{n}.sock`. +/// When `name` is `None`, the default `wallhackd.sock` filename is used. +/// Named instances are typically addressed via `-H` or `WALLHACK_HOST` instead. +/// /// Checks (in order): /// 1. `WALLHACK_HOST` environment variable (unix paths only) -/// 2. `$XDG_RUNTIME_DIR/wallhack/wallhackd.sock` -/// 3. `/tmp/wallhack-/wallhackd.sock` -/// 4. `$HOME/.wallhack/wallhackd.sock` -/// 5. `/tmp/wallhack-shared/wallhackd.sock` +/// 2. `$XDG_RUNTIME_DIR/wallhack/wallhackd[-{name}].sock` +/// 3. `/tmp/wallhack-/wallhackd[-{name}].sock` +/// 4. `$HOME/.wallhack/wallhackd[-{name}].sock` +/// 5. `/tmp/wallhack-shared/wallhackd[-{name}].sock` #[must_use] -pub fn socket_path() -> PathBuf { +pub fn socket_path(name: Option<&str>) -> PathBuf { #[allow(clippy::collapsible_if)] if let Ok(host) = std::env::var(HOST_ENV) { if !host.starts_with("vsock://") { return PathBuf::from(host.strip_prefix("unix://").unwrap_or(&host)); } } + let filename = match name { + Some(n) => format!("wallhackd-{n}.sock"), + None => SOCKET_NAME.to_string(), + }; if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { - Path::new(&runtime_dir).join("wallhack").join(SOCKET_NAME) + Path::new(&runtime_dir).join("wallhack").join(&filename) } else if let Ok(user) = std::env::var("USER") { - PathBuf::from(format!("/tmp/wallhack-{user}")).join(SOCKET_NAME) + PathBuf::from(format!("/tmp/wallhack-{user}")).join(&filename) } else if let Ok(home) = std::env::var("HOME") { - Path::new(&home).join(".wallhack").join(SOCKET_NAME) + Path::new(&home).join(".wallhack").join(&filename) } else { - PathBuf::from("/tmp/wallhack-shared").join(SOCKET_NAME) + PathBuf::from("/tmp/wallhack-shared").join(&filename) } } @@ -151,7 +159,7 @@ pub async fn connect() -> io::Result { return connect_vsock_str(addr).await; } } - connect_to(&socket_path()).await + connect_to(&socket_path(None)).await } #[cfg(feature = "vsock")] From ae3430e5a57c41fafb4133427699f1c8fb68b4c5 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Tue, 17 Mar 2026 22:05:46 +0700 Subject: [PATCH 07/41] fix: align interactive capability and socket path with review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit node_state.update_capabilities in auto mode now reflects the actual interactive flag instead of hardcoding false — status API no longer misreports what was advertised in the handshake. REPL path uses the mode-derived socket name, matching the headless path so concurrent REPL-attached daemons no longer collide. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/cli/src/bin/wallhack.rs | 2 +- crates/daemon/src/mode/auto.rs | 3 ++- standards | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/cli/src/bin/wallhack.rs b/crates/cli/src/bin/wallhack.rs index 261b9fc1..b848cc2e 100644 --- a/crates/cli/src/bin/wallhack.rs +++ b/crates/cli/src/bin/wallhack.rs @@ -196,7 +196,7 @@ fn run_daemon_repl( // Start IPC listener so wallhackctl can still connect. let socket_path = cli.host.as_deref().map_or_else( - || wallhack_core::ipc::socket_path(None), + || wallhack_core::ipc::socket_path(Some(config.mode.name())), wallhack_cli::ipc::resolve_host, ); let ipc_api = handle.api_arc(); diff --git a/crates/daemon/src/mode/auto.rs b/crates/daemon/src/mode/auto.rs index 95661e6e..da8b86a5 100644 --- a/crates/daemon/src/mode/auto.rs +++ b/crates/daemon/src/mode/auto.rs @@ -82,11 +82,12 @@ pub(crate) async fn run( tracing::info!("Eligible roles: {}", eligible.join(", ")); // Set initial capabilities; role stays Indeterminate until negotiation. + let interactive = std::io::IsTerminal::is_terminal(&std::io::stdin()); node_state.update_capabilities(Capabilities { tun_capable, listening: cfg.listen.is_some(), connecting: cfg.connect.is_some(), - interactive: false, + interactive, }); match (&cfg.connect, &cfg.listen) { diff --git a/standards b/standards index 1050c06f..0c5d4544 160000 --- a/standards +++ b/standards @@ -1 +1 @@ -Subproject commit 1050c06fb6f178af0186f6c00f403f94af0e25fb +Subproject commit 0c5d45442a94aafa240ee7bd6cca746991c45761 From 799d458fb34be2fb991e14fe8c6f30898b41a0dc Mon Sep 17 00:00:00 2001 From: Max Holman Date: Tue, 17 Mar 2026 22:10:32 +0700 Subject: [PATCH 08/41] feat(cli): disconnect a specific peer without tearing down the transport The existing `disconnect` command kills the entire transport session. `disconnect-peer ` surgically drops a single peer connection, which is the intended operation when managing multi-peer topologies. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/cli/src/bin/wallhack.rs | 13 ++++++++++--- crates/cli/src/cli.rs | 10 ++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/cli/src/bin/wallhack.rs b/crates/cli/src/bin/wallhack.rs index b848cc2e..288e340c 100644 --- a/crates/cli/src/bin/wallhack.rs +++ b/crates/cli/src/bin/wallhack.rs @@ -22,9 +22,10 @@ use wallhack_cli::{ ipc, output, }; use wallhack_wire::management::{ - AddRouteRequest, ClearHintsRequest, ConnectRequest, DisconnectRequest, HintLevel, - ListenRequest, NodeRole, PeersRequest, PingRequest, RemoveRouteRequest, RoutesRequest, - SetHintRequest, ShutdownRequest, StatsRequest, StatusRequest, management_request, + AddRouteRequest, ClearHintsRequest, ConnectRequest, DisconnectPeerRequest, DisconnectRequest, + HintLevel, ListenRequest, NodeRole, PeersRequest, PingRequest, RemoveRouteRequest, + RoutesRequest, SetHintRequest, ShutdownRequest, StatsRequest, StatusRequest, + management_request, }; const DAEMON_BIN_NAME: &str = "wallhackd"; @@ -349,6 +350,12 @@ async fn run_ctl_async(cli: wallhack_cli::cli::Cli) -> Result<(), output::CtlErr management_request::Request::Listen(ListenRequest { addr: cmd.addr }) } CtlCommand::Disconnect(_) => management_request::Request::Disconnect(DisconnectRequest {}), + CtlCommand::DisconnectPeer(cmd) => { + management_request::Request::DisconnectPeer(DisconnectPeerRequest { + peer: cmd.peer, + exact: false, + }) + } CtlCommand::Role(cmd) => { if let Some(target) = cmd.target { let role = parse_ctl_role(&target); diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 4875f88b..1d14ee6a 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -29,6 +29,7 @@ pub enum CtlCommand { Connect(ConnectCmd), Listen(ListenCmd), Disconnect(DisconnectCmd), + DisconnectPeer(DisconnectPeerCmd), Role(RoleCmd), Hint(HintCmd), Shutdown(ShutdownCmd), @@ -129,6 +130,15 @@ pub struct ListenCmd { #[argh(subcommand, name = "disconnect")] pub struct DisconnectCmd {} +/// Disconnect a specific peer by name. +#[derive(FromArgs, Debug)] +#[argh(subcommand, name = "disconnect-peer")] +pub struct DisconnectPeerCmd { + /// peer name (or unambiguous prefix) + #[argh(positional)] + pub peer: String, +} + /// Show or set the node role. #[derive(FromArgs, Debug)] #[argh(subcommand, name = "role")] From d15574787521ef8a02b9bcf4082a594d5b8827c8 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Tue, 17 Mar 2026 22:16:01 +0700 Subject: [PATCH 09/41] feat(mcp): expose role negotiation controls and rename status to info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP agents can now steer auto-negotiation without CLI access via set_hint (prefer/exclude/fixed) and clear_hints tools. The status→info rename aligns MCP with the CLI naming convention: info = node identity, stats = traffic counters. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/mcp/src/tools.rs | 63 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index ff7ac93b..a36a0db6 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -2,9 +2,10 @@ use rmcp::{handler::server::wrapper::Parameters, schemars, tool}; use wallhack_wire::management::{ - AddRouteRequest, ConnectRequest, DisconnectPeerRequest, DisconnectRequest, ListenRequest, - PeersRequest, PingRequest, RemoveRouteRequest, RoutesRequest, ShutdownRequest, StatsRequest, - StatusRequest, management_request, + AddRouteRequest, ClearHintsRequest, ConnectRequest, DisconnectPeerRequest, DisconnectRequest, + HintLevel, ListenRequest, NodeRole, PeersRequest, PingRequest, RemoveRouteRequest, + RoutesRequest, SetHintRequest, ShutdownRequest, StatsRequest, StatusRequest, + management_request, }; use crate::convert; @@ -41,6 +42,14 @@ pub struct AddrParams { pub addr: String, } +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct SetHintParams { + /// Hint level: "prefer", "exclude", or "fixed" + pub level: String, + /// Target role: "entry", "exit", or "relay" + pub role: String, +} + /// Wallhack MCP server — exposes daemon management as MCP tools. #[derive(Debug, Clone)] pub struct WallhackServer; @@ -61,9 +70,9 @@ async fn ipc_call(request: management_request::Request) -> Result Result { + async fn info(&self) -> Result { ipc_call(management_request::Request::Status(StatusRequest {})).await } @@ -168,6 +177,50 @@ impl WallhackServer { async fn shutdown(&self) -> Result { ipc_call(management_request::Request::Shutdown(ShutdownRequest {})).await } + + #[tool( + description = "Set a role hint to influence auto-negotiation (prefer/exclude/fixed + entry/exit/relay)" + )] + async fn set_hint( + &self, + Parameters(params): Parameters, + ) -> Result { + let level = match params.level.as_str() { + "prefer" => HintLevel::Prefer, + "exclude" => HintLevel::Exclude, + "fixed" => HintLevel::Fixed, + other => { + return Err(rmcp::ErrorData::invalid_params( + format!("invalid hint level '{other}' (expected: prefer, exclude, fixed)"), + None, + )); + } + }; + let role = match params.role.as_str() { + "entry" => NodeRole::Entry, + "exit" => NodeRole::Exit, + "relay" => NodeRole::Relay, + other => { + return Err(rmcp::ErrorData::invalid_params( + format!("invalid role '{other}' (expected: entry, exit, relay)"), + None, + )); + } + }; + ipc_call(management_request::Request::SetHint(SetHintRequest { + level: level.into(), + role: role.into(), + })) + .await + } + + #[tool(description = "Clear all role hints, returning to pure capability-based negotiation")] + async fn clear_hints(&self) -> Result { + ipc_call(management_request::Request::ClearHints( + ClearHintsRequest {}, + )) + .await + } } impl rmcp::ServerHandler for WallhackServer { From 99352504e57491bee88f0eba7beaeb2c1a821e58 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Tue, 17 Mar 2026 22:24:39 +0700 Subject: [PATCH 10/41] feat(api): full command parity for REST API and rename /status to /info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headless orchestration (CI, monitoring, MCP fallback) previously required IPC for connect/listen/disconnect/ping/shutdown/hints. All operations are now available over HTTPS with the same auth model. The /status→/info rename aligns with CLI and MCP naming: info = identity, stats = traffic. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/api/src/handlers.rs | 353 +++++++++++++++++++++++++++++++++- crates/api/src/lib.rs | 14 +- website/src/data/openapi.json | 285 ++++++++++++++++++++++++++- 3 files changed, 643 insertions(+), 9 deletions(-) diff --git a/crates/api/src/handlers.rs b/crates/api/src/handlers.rs index 2533736c..6c7b0553 100644 --- a/crates/api/src/handlers.rs +++ b/crates/api/src/handlers.rs @@ -10,8 +10,10 @@ use axum::{ }; use serde::{Deserialize, Serialize}; use wallhack_wire::management::{ - AddRouteRequest, DisconnectPeerRequest, PeersRequest, RemoveRouteRequest, RoutesRequest, - StatsRequest, StatusRequest, management_request, management_response, + AddRouteRequest, ClearHintsRequest, ConnectRequest, DisconnectPeerRequest, DisconnectRequest, + HintLevel, ListenRequest, NodeRole, PeersRequest, PingRequest, RemoveRouteRequest, + RoutesRequest, SetHintRequest, ShutdownRequest, StatsRequest, StatusRequest, + management_request, management_response, }; use super::{state::State as ApiState, validation}; @@ -96,6 +98,48 @@ pub struct SuccessResponse { pub message: Option, } +/// Connect request body. +#[derive(Debug, Deserialize)] +pub struct ConnectRequestBody { + pub addr: String, +} + +/// Connect response. +#[derive(Debug, Serialize)] +pub struct ConnectResponse { + pub peer_addr: String, + pub protocol: String, +} + +/// Listen request body. +#[derive(Debug, Deserialize)] +pub struct ListenRequestBody { + pub addr: String, +} + +/// Listen response. +#[derive(Debug, Serialize)] +pub struct ListenResponse { + pub listen_addr: String, + pub protocol: String, + pub fingerprint: String, +} + +/// Ping response. +#[derive(Debug, Serialize)] +pub struct PingResponseBody { + pub uptime_ms: u64, + pub version: String, + pub role: String, +} + +/// Set hint request body. +#[derive(Debug, Deserialize)] +pub struct SetHintRequestBody { + pub level: String, + pub role: String, +} + pub async fn health() -> &'static str { "ok" } @@ -475,6 +519,311 @@ pub async fn list_routes( } } +pub async fn connect( + State(state): State, + Json(req): Json, +) -> Result, StatusCode> { + let resp = state + .ipc + .lock() + .await + .request(management_request::Request::Connect(ConnectRequest { + addr: req.addr, + })) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + match resp.response { + Some(management_response::Response::Connect(c)) => Ok(Json(ConnectResponse { + peer_addr: c.peer_addr, + protocol: c.protocol, + })), + Some(management_response::Response::Error(e)) => { + tracing::warn!("Connect failed: {}", e.message); + Err(StatusCode::BAD_REQUEST) + } + _ => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +pub async fn listen( + State(state): State, + Json(req): Json, +) -> Result, StatusCode> { + let resp = state + .ipc + .lock() + .await + .request(management_request::Request::Listen(ListenRequest { + addr: req.addr, + })) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + match resp.response { + Some(management_response::Response::Listen(l)) => Ok(Json(ListenResponse { + listen_addr: l.listen_addr, + protocol: l.protocol, + fingerprint: l.fingerprint, + })), + Some(management_response::Response::Error(e)) => { + tracing::warn!("Listen failed: {}", e.message); + Err(StatusCode::BAD_REQUEST) + } + _ => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +pub async fn disconnect(State(state): State) -> (StatusCode, Json) { + let resp = state + .ipc + .lock() + .await + .request(management_request::Request::Disconnect( + DisconnectRequest {}, + )) + .await; + + match resp { + Ok(r) => match r.response { + Some(management_response::Response::Ok(_)) => ( + StatusCode::OK, + Json(SuccessResponse { + success: true, + message: None, + }), + ), + Some(management_response::Response::Error(e)) => ( + StatusCode::BAD_REQUEST, + Json(SuccessResponse { + success: false, + message: Some(e.message), + }), + ), + _ => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(SuccessResponse { + success: false, + message: None, + }), + ), + }, + Err(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(SuccessResponse { + success: false, + message: None, + }), + ), + } +} + +pub async fn ping(State(state): State) -> Result, StatusCode> { + let resp = state + .ipc + .lock() + .await + .request(management_request::Request::Ping(PingRequest { + peer: String::new(), + })) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + match resp.response { + Some(management_response::Response::Ping(p)) => { + let role = NodeRole::try_from(p.node_role).unwrap_or(NodeRole::Unspecified); + Ok(Json(PingResponseBody { + uptime_ms: p.uptime_ms, + version: p.version, + role: role.to_string(), + })) + } + _ => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +pub async fn ping_peer( + State(state): State, + Path(peer): Path, +) -> Result, StatusCode> { + let resp = state + .ipc + .lock() + .await + .request(management_request::Request::Ping(PingRequest { peer })) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + match resp.response { + Some(management_response::Response::Ping(p)) => { + let role = NodeRole::try_from(p.node_role).unwrap_or(NodeRole::Unspecified); + Ok(Json(PingResponseBody { + uptime_ms: p.uptime_ms, + version: p.version, + role: role.to_string(), + })) + } + Some(management_response::Response::Error(e)) => { + tracing::warn!("Ping peer failed: {}", e.message); + Err(StatusCode::NOT_FOUND) + } + _ => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +pub async fn shutdown(State(state): State) -> (StatusCode, Json) { + let resp = state + .ipc + .lock() + .await + .request(management_request::Request::Shutdown(ShutdownRequest {})) + .await; + + match resp { + Ok(_) => ( + StatusCode::OK, + Json(SuccessResponse { + success: true, + message: None, + }), + ), + Err(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(SuccessResponse { + success: false, + message: None, + }), + ), + } +} + +pub async fn set_hint( + State(state): State, + Json(req): Json, +) -> (StatusCode, Json) { + let level = match req.level.as_str() { + "prefer" => HintLevel::Prefer, + "exclude" => HintLevel::Exclude, + "fixed" => HintLevel::Fixed, + _ => { + return ( + StatusCode::BAD_REQUEST, + Json(SuccessResponse { + success: false, + message: Some(format!( + "invalid hint level '{}' (expected: prefer, exclude, fixed)", + req.level + )), + }), + ); + } + }; + let role = match req.role.as_str() { + "entry" => NodeRole::Entry, + "exit" => NodeRole::Exit, + "relay" => NodeRole::Relay, + _ => { + return ( + StatusCode::BAD_REQUEST, + Json(SuccessResponse { + success: false, + message: Some(format!( + "invalid role '{}' (expected: entry, exit, relay)", + req.role + )), + }), + ); + } + }; + + let resp = state + .ipc + .lock() + .await + .request(management_request::Request::SetHint(SetHintRequest { + level: level.into(), + role: role.into(), + })) + .await; + + match resp { + Ok(r) => match r.response { + Some(management_response::Response::Ok(_)) => ( + StatusCode::OK, + Json(SuccessResponse { + success: true, + message: None, + }), + ), + Some(management_response::Response::Error(e)) => ( + StatusCode::BAD_REQUEST, + Json(SuccessResponse { + success: false, + message: Some(e.message), + }), + ), + _ => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(SuccessResponse { + success: false, + message: None, + }), + ), + }, + Err(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(SuccessResponse { + success: false, + message: None, + }), + ), + } +} + +pub async fn clear_hints(State(state): State) -> (StatusCode, Json) { + let resp = state + .ipc + .lock() + .await + .request(management_request::Request::ClearHints( + ClearHintsRequest {}, + )) + .await; + + match resp { + Ok(r) => match r.response { + Some(management_response::Response::Ok(_)) => ( + StatusCode::OK, + Json(SuccessResponse { + success: true, + message: None, + }), + ), + Some(management_response::Response::Error(e)) => ( + StatusCode::BAD_REQUEST, + Json(SuccessResponse { + success: false, + message: Some(e.message), + }), + ), + _ => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(SuccessResponse { + success: false, + message: None, + }), + ), + }, + Err(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(SuccessResponse { + success: false, + message: None, + }), + ), + } +} + /// Convert epoch seconds to ISO 8601 UTC string. fn epoch_to_iso8601(epoch_secs: u64) -> String { #[allow(clippy::cast_possible_wrap)] // REASON: epoch seconds fits i64 for millennia diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 1a6f4d44..2dbb33b0 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -21,7 +21,7 @@ use axum::{ http::{HeaderValue, Request, header}, middleware::{self, Next}, response::Response, - routing::{delete, get}, + routing::{delete, get, post, put}, }; pub use auth::Auth; @@ -62,7 +62,7 @@ pub fn router(state: State) -> Router { let auth = state.auth.clone(); let protected_routes = Router::new() - .route("/status", get(handlers::status)) + .route("/info", get(handlers::status)) .route("/stats", get(handlers::stats)) .route("/peers", get(handlers::peers)) .route("/peers/{name}", delete(handlers::disconnect_peer)) @@ -72,6 +72,16 @@ pub fn router(state: State) -> Router { ) .route("/routes/{cidr}", delete(handlers::delete_route)) .route("/events", get(handlers::events)) + .route("/connect", post(handlers::connect)) + .route("/listen", post(handlers::listen)) + .route("/disconnect", post(handlers::disconnect)) + .route("/ping", get(handlers::ping)) + .route("/ping/{peer}", get(handlers::ping_peer)) + .route("/shutdown", post(handlers::shutdown)) + .route( + "/hints", + put(handlers::set_hint).delete(handlers::clear_hints), + ) .layer(middleware::from_fn(move |req, next| { let auth = auth.clone(); auth::middleware(auth, req, next) diff --git a/website/src/data/openapi.json b/website/src/data/openapi.json index 9156861f..a75e62c1 100644 --- a/website/src/data/openapi.json +++ b/website/src/data/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "wallhack REST API", - "version": "0.3.0", + "version": "0.4.0", "description": "Headless control plane for wallhack nodes. All endpoints except /health require Basic authentication when --api-user and --api-secret are configured." }, "servers": [ @@ -224,6 +224,93 @@ "description": "Optional detail providing context for success or failure." } } + }, + "ConnectRequest": { + "type": "object", + "required": ["addr"], + "properties": { + "addr": { + "type": "string", + "description": "Remote peer address to connect to (e.g., quic://1.2.3.4:4433)." + } + } + }, + "ConnectResponse": { + "type": "object", + "required": ["peer_addr", "protocol"], + "properties": { + "peer_addr": { + "type": "string", + "description": "Resolved remote peer address." + }, + "protocol": { + "type": "string", + "description": "Transport protocol used (QUIC or WebSocket)." + } + } + }, + "ListenRequest": { + "type": "object", + "required": ["addr"], + "properties": { + "addr": { + "type": "string", + "description": "Address to listen on (e.g., quic://0.0.0.0:4433)." + } + } + }, + "ListenResponse": { + "type": "object", + "required": ["listen_addr", "protocol", "fingerprint"], + "properties": { + "listen_addr": { + "type": "string", + "description": "Actual bound address (important if port was 0)." + }, + "protocol": { + "type": "string", + "description": "Transport protocol used (QUIC or WebSocket)." + }, + "fingerprint": { + "type": "string", + "description": "Certificate fingerprint (SHA-256) for peer verification." + } + } + }, + "PingResponse": { + "type": "object", + "required": ["uptime_ms", "version", "role"], + "properties": { + "uptime_ms": { + "type": "integer", + "description": "Node uptime in milliseconds." + }, + "version": { + "type": "string", + "description": "Binary version string." + }, + "role": { + "type": "string", + "enum": ["entry", "exit", "relay", "indeterminate", "unknown"], + "description": "Current operational role of the node or peer." + } + } + }, + "SetHintRequest": { + "type": "object", + "required": ["level", "role"], + "properties": { + "level": { + "type": "string", + "enum": ["prefer", "exclude", "fixed"], + "description": "Hint strength: prefer (soft), exclude (medium), fixed (hard)." + }, + "role": { + "type": "string", + "enum": ["entry", "exit", "relay"], + "description": "Role to apply the hint to." + } + } } } }, @@ -244,15 +331,15 @@ } } }, - "/status": { + "/info": { "get": { - "summary": "Node status", + "summary": "Node info", "description": "Retrieves node identity, role, capabilities, and uptime.", - "operationId": "status", + "operationId": "getInfo", "security": [{ "basicAuth": [] }], "responses": { "200": { - "description": "Status retrieved.", + "description": "Info retrieved.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StatusResponse" } @@ -426,6 +513,194 @@ "404": { "description": "Specified route does not exist." } } } + }, + "/connect": { + "post": { + "summary": "Connect to peer", + "description": "Initiates an outbound connection to a remote peer.", + "operationId": "connect", + "security": [{ "basicAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ConnectRequest" } + } + } + }, + "responses": { + "200": { + "description": "Connection initiated.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ConnectResponse" } + } + } + }, + "400": { "description": "Invalid address or connection refused." }, + "401": { "description": "Unauthorized." } + } + } + }, + "/listen": { + "post": { + "summary": "Start listener", + "description": "Begins accepting inbound peer connections on the given address.", + "operationId": "listen", + "security": [{ "basicAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ListenRequest" } + } + } + }, + "responses": { + "200": { + "description": "Listener started.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ListenResponse" } + } + } + }, + "400": { "description": "Invalid address or already listening." }, + "401": { "description": "Unauthorized." } + } + } + }, + "/disconnect": { + "post": { + "summary": "Disconnect transport", + "description": "Tears down the active transport session (both connect and listen).", + "operationId": "disconnect", + "security": [{ "basicAuth": [] }], + "responses": { + "200": { + "description": "Transport disconnected.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SuccessResponse" } + } + } + }, + "400": { "description": "Not connected." }, + "401": { "description": "Unauthorized." } + } + } + }, + "/ping": { + "get": { + "summary": "Ping daemon", + "description": "Retrieves daemon liveness info: uptime, version, and role.", + "operationId": "ping", + "security": [{ "basicAuth": [] }], + "responses": { + "200": { + "description": "Daemon is alive.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/PingResponse" } + } + } + }, + "401": { "description": "Unauthorized." } + } + } + }, + "/ping/{peer}": { + "get": { + "summary": "Ping peer", + "description": "Pings a specific connected peer by name or prefix.", + "operationId": "pingPeer", + "security": [{ "basicAuth": [] }], + "parameters": [ + { + "name": "peer", + "in": "path", + "required": true, + "schema": { "type": "string" }, + "description": "Peer name or unambiguous prefix." + } + ], + "responses": { + "200": { + "description": "Peer responded.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/PingResponse" } + } + } + }, + "401": { "description": "Unauthorized." }, + "404": { "description": "Peer not found." } + } + } + }, + "/shutdown": { + "post": { + "summary": "Shutdown daemon", + "description": "Initiates graceful shutdown of the daemon process.", + "operationId": "shutdown", + "security": [{ "basicAuth": [] }], + "responses": { + "200": { + "description": "Shutdown initiated.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SuccessResponse" } + } + } + }, + "401": { "description": "Unauthorized." } + } + } + }, + "/hints": { + "put": { + "summary": "Set role hint", + "description": "Configures a negotiation hint to influence auto-role resolution.", + "operationId": "setHint", + "security": [{ "basicAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SetHintRequest" } + } + } + }, + "responses": { + "200": { + "description": "Hint applied.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SuccessResponse" } + } + } + }, + "400": { "description": "Invalid hint level or role." }, + "401": { "description": "Unauthorized." } + } + }, + "delete": { + "summary": "Clear hints", + "description": "Removes all role hints, returning to pure capability-based negotiation.", + "operationId": "clearHints", + "security": [{ "basicAuth": [] }], + "responses": { + "200": { + "description": "Hints cleared.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SuccessResponse" } + } + } + }, + "401": { "description": "Unauthorized." } + } + } } } } From ff3c49283248541f534699a6ae3fd5968c0faed2 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Tue, 17 Mar 2026 22:34:48 +0700 Subject: [PATCH 11/41] style: fix prohibited terminology and opaque variable names from review Replace "upstream" in DisconnectCmd doc with neutral terminology. Rename single-char match bindings (c, l, p) to descriptive names (connect, listen, ping) in new API handlers per naming standard. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/api/src/handlers.rs | 30 +++++++++++++++--------------- crates/cli/src/cli.rs | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/crates/api/src/handlers.rs b/crates/api/src/handlers.rs index 6c7b0553..e5a62e39 100644 --- a/crates/api/src/handlers.rs +++ b/crates/api/src/handlers.rs @@ -534,9 +534,9 @@ pub async fn connect( .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; match resp.response { - Some(management_response::Response::Connect(c)) => Ok(Json(ConnectResponse { - peer_addr: c.peer_addr, - protocol: c.protocol, + Some(management_response::Response::Connect(connect)) => Ok(Json(ConnectResponse { + peer_addr: connect.peer_addr, + protocol: connect.protocol, })), Some(management_response::Response::Error(e)) => { tracing::warn!("Connect failed: {}", e.message); @@ -561,10 +561,10 @@ pub async fn listen( .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; match resp.response { - Some(management_response::Response::Listen(l)) => Ok(Json(ListenResponse { - listen_addr: l.listen_addr, - protocol: l.protocol, - fingerprint: l.fingerprint, + Some(management_response::Response::Listen(listen)) => Ok(Json(ListenResponse { + listen_addr: listen.listen_addr, + protocol: listen.protocol, + fingerprint: listen.fingerprint, })), Some(management_response::Response::Error(e)) => { tracing::warn!("Listen failed: {}", e.message); @@ -630,11 +630,11 @@ pub async fn ping(State(state): State) -> Result { - let role = NodeRole::try_from(p.node_role).unwrap_or(NodeRole::Unspecified); + Some(management_response::Response::Ping(ping)) => { + let role = NodeRole::try_from(ping.node_role).unwrap_or(NodeRole::Unspecified); Ok(Json(PingResponseBody { - uptime_ms: p.uptime_ms, - version: p.version, + uptime_ms: ping.uptime_ms, + version: ping.version, role: role.to_string(), })) } @@ -655,11 +655,11 @@ pub async fn ping_peer( .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; match resp.response { - Some(management_response::Response::Ping(p)) => { - let role = NodeRole::try_from(p.node_role).unwrap_or(NodeRole::Unspecified); + Some(management_response::Response::Ping(ping)) => { + let role = NodeRole::try_from(ping.node_role).unwrap_or(NodeRole::Unspecified); Ok(Json(PingResponseBody { - uptime_ms: p.uptime_ms, - version: p.version, + uptime_ms: ping.uptime_ms, + version: ping.version, role: role.to_string(), })) } diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 1d14ee6a..8a7ab2af 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -125,7 +125,7 @@ pub struct ListenCmd { pub addr: String, } -/// Disconnect from upstream peer. +/// Disconnect the active transport session. #[derive(FromArgs, Debug)] #[argh(subcommand, name = "disconnect")] pub struct DisconnectCmd {} From f8c300c42868b96f301c2ae938067da063b1483c Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 08:18:43 +0700 Subject: [PATCH 12/41] fix(negotiate): relay forces accepted peers to exit via peer-FIXED hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A TUN-capable node connecting to the listen side of a relay incorrectly resolved to Entry — the negotiate() function couldn't distinguish which side of the relay chain the connector was on since the relay's handshake was identical in both directions. The relay now sends Fixed(Entry) in its accept-side handshake, declaring "I represent the entry side." negotiate() respects peer FIXED hints before capability rules, so the accepted peer takes the complement and resolves to Exit. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/core/src/negotiate.rs | 155 ++++++++++++++++++++++++++++++-- crates/daemon/src/mode/relay.rs | 9 +- 2 files changed, 158 insertions(+), 6 deletions(-) diff --git a/crates/core/src/negotiate.rs b/crates/core/src/negotiate.rs index 27e26cab..bb19f152 100644 --- a/crates/core/src/negotiate.rs +++ b/crates/core/src/negotiate.rs @@ -59,12 +59,17 @@ fn parse_hint(hint: Option<&RoleHint>) -> Option<(HintLevel, NodeRole)> { /// /// # Rules (in priority order) /// -/// 1. **FIXED hint** — checked first. If local has a FIXED hint, return -/// the target role immediately. If both sides are FIXED to the same role, -/// return Indeterminate (conflict detected at runtime, not startup). +/// 1a. **Local FIXED hint** — if local has a FIXED hint, return the target +/// role immediately. If both sides are FIXED to the same role, return +/// Indeterminate (conflict). /// -/// 2. **Capability-based rules** — relay, TUN asymmetry. If these produce an -/// unambiguous result, hints don't override it. +/// 1b. **Peer FIXED hint** — if the peer declares a FIXED role, local takes +/// the complement. This lets a relay proxy the entry side of the chain: +/// the relay sends `Fixed(Entry)` to accepted peers, forcing them to Exit. +/// +/// 2. **Capability-based rules** — relay, TUN asymmetry, interactive +/// tiebreaker. If these produce an unambiguous result, soft hints don't +/// override it. /// /// 3. **EXCLUDE hint** — removes a role from the local candidate set, then /// re-evaluates. @@ -92,6 +97,19 @@ pub fn negotiate(local: &Handshake, peer: &Handshake) -> NegotiationResult { }; } + // 1b. Peer's FIXED hint — the peer is declaring its role (e.g. a relay + // proxying the entry side of the chain). Local takes the complement. + if let Some((HintLevel::Fixed, peer_target)) = peer_hint { + return match complement(peer_target) { + Some(role) => NegotiationResult::Resolved { + role, + reason: "complement of peer's declared role", + }, + // No complement (e.g. peer fixed as relay) → fall through to capabilities. + None => negotiate_from_capabilities(local, peer), + }; + } + // 2. Capability-based rules. let cap_result = negotiate_from_capabilities(local, peer); @@ -765,4 +783,131 @@ mod tests { assert_resolved(&negotiate(&local, &peer), NodeRole::Exit); assert_resolved(&negotiate(&peer, &local), NodeRole::Entry); } + + // ------------------------------------------------------------------------- + // Peer FIXED hint (relay topology) + // ------------------------------------------------------------------------- + + /// Peer declares Fixed(Entry) → local takes the complement (Exit). + /// This is how a relay forces accepted peers to resolve as exit nodes. + #[test] + fn peer_fixed_entry_forces_exit() { + // TUN-capable connector would normally resolve to Entry against a + // relay, but the relay's Fixed(Entry) hint overrides. + let local = hs(true, false, true); // TUN-capable connector + let peer = hs_hint( + false, + true, + true, + Some(role_hint(HintLevel::Fixed, ProtoNodeRole::RoleEntry)), + ); + assert_resolved(&negotiate(&local, &peer), NodeRole::Exit); + } + + /// Non-TUN peer also respects Fixed(Entry) → Exit. + #[test] + fn peer_fixed_entry_nontun_also_exit() { + let local = hs(false, false, true); // non-TUN connector + let peer = hs_hint( + false, + true, + true, + Some(role_hint(HintLevel::Fixed, ProtoNodeRole::RoleEntry)), + ); + assert_resolved(&negotiate(&local, &peer), NodeRole::Exit); + } + + /// Peer declares Fixed(Exit) → local takes the complement (Entry). + #[test] + fn peer_fixed_exit_forces_entry() { + let local = hs(false, false, true); + let peer = hs_hint( + false, + true, + false, + Some(role_hint(HintLevel::Fixed, ProtoNodeRole::RoleExit)), + ); + assert_resolved(&negotiate(&local, &peer), NodeRole::Entry); + } + + /// Peer declares Fixed(Relay) → no complement, falls through to capabilities. + #[test] + fn peer_fixed_relay_no_complement() { + let local = hs(true, false, true); // TUN-capable + let peer = hs_hint( + false, + true, + true, // relay caps + Some(role_hint(HintLevel::Fixed, ProtoNodeRole::RoleRelay)), + ); + // complement(Relay) = None → falls through to capability-based. + // peer_relay = true, local_tun = true → Entry. + assert_resolved(&negotiate(&local, &peer), NodeRole::Entry); + } + + /// Local FIXED takes precedence over peer FIXED. + #[test] + fn local_fixed_overrides_peer_fixed() { + let local = hs_hint( + true, + false, + true, + Some(role_hint(HintLevel::Fixed, ProtoNodeRole::RoleEntry)), + ); + let peer = hs_hint( + false, + true, + true, + Some(role_hint(HintLevel::Fixed, ProtoNodeRole::RoleEntry)), + ); + // Both Fixed(Entry) → Indeterminate (conflict). + assert!(is_indeterminate(&negotiate(&local, &peer))); + } + + // ------------------------------------------------------------------------- + // Three-node relay chain scenario + // ------------------------------------------------------------------------- + + /// Full three-node chain: entry ← relay ← exit. + /// The relay sends Fixed(Entry) to accepted peers, forcing them to Exit + /// even if they have TUN capability. + #[test] + fn three_node_chain_all_roles_correct() { + // ENTRY: TUN-capable, listening. + let entry_hs = hs(true, true, false); + // RELAY connector handshake (sent to entry): no hint. + let relay_connector_hs = hs(false, true, true); + // RELAY accept handshake (sent to exit peers): Fixed(Entry). + let relay_accept_hs = hs_hint( + false, + true, + true, + Some(role_hint(HintLevel::Fixed, ProtoNodeRole::RoleEntry)), + ); + // EXIT: TUN-capable (root), connecting. + let exit_hs = hs(true, false, true); + + // Entry ↔ Relay(connector): entry resolves Entry. + assert_resolved(&negotiate(&entry_hs, &relay_connector_hs), NodeRole::Entry); + + // Exit ↔ Relay(accept): exit resolves Exit despite having TUN. + assert_resolved(&negotiate(&exit_hs, &relay_accept_hs), NodeRole::Exit); + } + + /// Three-node chain with non-TUN exit: same correct result. + #[test] + fn three_node_chain_nontun_exit() { + let entry_hs = hs(true, true, false); + let relay_connector_hs = hs(false, true, true); + let relay_accept_hs = hs_hint( + false, + true, + true, + Some(role_hint(HintLevel::Fixed, ProtoNodeRole::RoleEntry)), + ); + let exit_hs = hs(false, false, true); // non-TUN + + assert_resolved(&negotiate(&entry_hs, &relay_connector_hs), NodeRole::Entry); + assert_resolved(&negotiate(&exit_hs, &relay_accept_hs), NodeRole::Exit); + } } diff --git a/crates/daemon/src/mode/relay.rs b/crates/daemon/src/mode/relay.rs index e70972fb..5fec2a97 100644 --- a/crates/daemon/src/mode/relay.rs +++ b/crates/daemon/src/mode/relay.rs @@ -46,6 +46,10 @@ fn build_server_options(cfg: &RelayConfig, version: &str, metrics: Arc) peers: None, routes: None, route_updates: None, + // Accept-side handshake: declare Fixed(Entry) so accepted peers + // resolve to Exit via complement. Without this, a TUN-capable peer + // connecting to the relay's listen port would resolve to Entry + // (wrong — it's on the exit side of the chain). local_handshake: Some(wallhack_wire::data::Handshake { capabilities: Some(wallhack_wire::data::Capabilities { tun_capable: false, @@ -57,7 +61,10 @@ fn build_server_options(cfg: &RelayConfig, version: &str, metrics: Arc) version: version.to_string(), psk_proof: Vec::new(), routes: Vec::new(), - hint: None, + hint: Some(wallhack_wire::data::RoleHint { + level: wallhack_wire::data::HintLevel::Fixed.into(), + target: wallhack_wire::data::NodeRole::RoleEntry.into(), + }), }), } } From 02e20c2f0194492bdd0afb15a938452be3958b59 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 08:38:06 +0700 Subject: [PATCH 13/41] chore(lint): format openapi.json with biome Co-Authored-By: Claude Opus 4.6 (1M context) --- website/src/data/openapi.json | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/website/src/data/openapi.json b/website/src/data/openapi.json index a75e62c1..8a86c911 100644 --- a/website/src/data/openapi.json +++ b/website/src/data/openapi.json @@ -22,13 +22,7 @@ "schemas": { "StatusResponse": { "type": "object", - "required": [ - "name", - "version", - "role", - "uptime_ms", - "capabilities" - ], + "required": ["name", "version", "role", "uptime_ms", "capabilities"], "properties": { "name": { "type": "string", From a85ef85c2b8cd610c46329cf49322ff9643d3d0c Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 10:33:16 +0700 Subject: [PATCH 14/41] refactor(api): rename status handler to info to match route path Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/api/src/handlers.rs | 2 +- crates/api/src/lib.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/api/src/handlers.rs b/crates/api/src/handlers.rs index e5a62e39..34365d88 100644 --- a/crates/api/src/handlers.rs +++ b/crates/api/src/handlers.rs @@ -183,7 +183,7 @@ pub async fn events( ) } -pub async fn status(State(state): State) -> Result, StatusCode> { +pub async fn info(State(state): State) -> Result, StatusCode> { let resp = state .ipc .lock() diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 2dbb33b0..4c603f99 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -62,7 +62,7 @@ pub fn router(state: State) -> Router { let auth = state.auth.clone(); let protected_routes = Router::new() - .route("/info", get(handlers::status)) + .route("/info", get(handlers::info)) .route("/stats", get(handlers::stats)) .route("/peers", get(handlers::peers)) .route("/peers/{name}", delete(handlers::disconnect_peer)) From 4d92964534d40951219f53971e3c2481e8c4f057 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 10:37:45 +0700 Subject: [PATCH 15/41] refactor: unify disconnect interface across CLI, MCP, and REPL All three interfaces now use the same pattern: disconnect with an optional peer argument. Omitting the peer disconnects the transport; providing one disconnects that specific peer. Removes the separate disconnect-peer subcommand/tool that broke parity with the REPL. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/cli/src/bin/wallhack.rs | 12 ++++----- crates/cli/src/cli.rs | 14 +++------- crates/mcp/src/tools.rs | 47 +++++++++++++++++----------------- 3 files changed, 34 insertions(+), 39 deletions(-) diff --git a/crates/cli/src/bin/wallhack.rs b/crates/cli/src/bin/wallhack.rs index 288e340c..60887593 100644 --- a/crates/cli/src/bin/wallhack.rs +++ b/crates/cli/src/bin/wallhack.rs @@ -349,13 +349,13 @@ async fn run_ctl_async(cli: wallhack_cli::cli::Cli) -> Result<(), output::CtlErr CtlCommand::Listen(cmd) => { management_request::Request::Listen(ListenRequest { addr: cmd.addr }) } - CtlCommand::Disconnect(_) => management_request::Request::Disconnect(DisconnectRequest {}), - CtlCommand::DisconnectPeer(cmd) => { - management_request::Request::DisconnectPeer(DisconnectPeerRequest { - peer: cmd.peer, + CtlCommand::Disconnect(cmd) => match cmd.peer { + Some(peer) => management_request::Request::DisconnectPeer(DisconnectPeerRequest { + peer, exact: false, - }) - } + }), + None => management_request::Request::Disconnect(DisconnectRequest {}), + }, CtlCommand::Role(cmd) => { if let Some(target) = cmd.target { let role = parse_ctl_role(&target); diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 8a7ab2af..cb82e52c 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -29,7 +29,6 @@ pub enum CtlCommand { Connect(ConnectCmd), Listen(ListenCmd), Disconnect(DisconnectCmd), - DisconnectPeer(DisconnectPeerCmd), Role(RoleCmd), Hint(HintCmd), Shutdown(ShutdownCmd), @@ -125,18 +124,13 @@ pub struct ListenCmd { pub addr: String, } -/// Disconnect the active transport session. +/// Disconnect a peer or the transport session. #[derive(FromArgs, Debug)] #[argh(subcommand, name = "disconnect")] -pub struct DisconnectCmd {} - -/// Disconnect a specific peer by name. -#[derive(FromArgs, Debug)] -#[argh(subcommand, name = "disconnect-peer")] -pub struct DisconnectPeerCmd { - /// peer name (or unambiguous prefix) +pub struct DisconnectCmd { + /// peer name (or unambiguous prefix). Omit to disconnect the transport. #[argh(positional)] - pub peer: String, + pub peer: Option, } /// Show or set the node role. diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index a36a0db6..36a5baa3 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -31,9 +31,9 @@ pub struct RemoveRouteParams { } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct PeerParams { - /// Peer name (or unambiguous prefix) - pub peer: String, +pub struct DisconnectParams { + /// Peer name (or unambiguous prefix). Omit to disconnect the transport. + pub peer: Option, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] @@ -129,20 +129,6 @@ impl WallhackServer { .await } - #[tool(description = "Disconnect a peer by name (or unambiguous prefix) or by remote address")] - async fn disconnect_peer( - &self, - Parameters(params): Parameters, - ) -> Result { - ipc_call(management_request::Request::DisconnectPeer( - DisconnectPeerRequest { - peer: params.peer, - exact: false, - }, - )) - .await - } - #[tool(description = "Connect to a remote peer by address")] async fn connect( &self, @@ -165,12 +151,27 @@ impl WallhackServer { .await } - #[tool(description = "Disconnect from the current transport (stop connecting/listening)")] - async fn disconnect(&self) -> Result { - ipc_call(management_request::Request::Disconnect( - DisconnectRequest {}, - )) - .await + #[tool( + description = "Disconnect a peer by name, or disconnect the transport if no peer specified" + )] + async fn disconnect( + &self, + Parameters(params): Parameters, + ) -> Result { + match params.peer { + Some(peer) => { + ipc_call(management_request::Request::DisconnectPeer( + DisconnectPeerRequest { peer, exact: false }, + )) + .await + } + None => { + ipc_call(management_request::Request::Disconnect( + DisconnectRequest {}, + )) + .await + } + } } #[tool(description = "Gracefully shut down the wallhack daemon")] From 68bc2c5019dd7b713776d3952b5729a9d0593e95 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 10:39:29 +0700 Subject: [PATCH 16/41] refactor(mcp): align tool names with REPL command structure route_add/route_remove and hint_set/hint_clear now mirror the REPL's "route add" / "route remove" and "hint set" / "hint clear" verb order instead of the inverted add_route/set_hint form. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/mcp/src/tools.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 36a5baa3..737e6fa9 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -107,7 +107,7 @@ impl WallhackServer { } #[tool(description = "Add a route: forward traffic for a CIDR range to a peer")] - async fn add_route( + async fn route_add( &self, Parameters(params): Parameters, ) -> Result { @@ -119,7 +119,7 @@ impl WallhackServer { } #[tool(description = "Remove a route by CIDR")] - async fn remove_route( + async fn route_remove( &self, Parameters(params): Parameters, ) -> Result { @@ -182,7 +182,7 @@ impl WallhackServer { #[tool( description = "Set a role hint to influence auto-negotiation (prefer/exclude/fixed + entry/exit/relay)" )] - async fn set_hint( + async fn hint_set( &self, Parameters(params): Parameters, ) -> Result { @@ -216,7 +216,7 @@ impl WallhackServer { } #[tool(description = "Clear all role hints, returning to pure capability-based negotiation")] - async fn clear_hints(&self) -> Result { + async fn hint_clear(&self) -> Result { ipc_call(management_request::Request::ClearHints( ClearHintsRequest {}, )) From 0f9a5577c7e75879e8be8dc1e21f300837bab1e3 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 10:42:28 +0700 Subject: [PATCH 17/41] refactor: rename hint clear to hint auto across all interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "auto" communicates intent — return to capability-based negotiation — rather than the mechanism (clearing a data structure). REPL keeps "clear" as a hidden alias for muscle memory. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/cli/src/bin/wallhack.rs | 2 +- crates/cli/src/cli.rs | 8 ++++---- crates/cli/src/repl.rs | 6 +++--- crates/mcp/src/tools.rs | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/cli/src/bin/wallhack.rs b/crates/cli/src/bin/wallhack.rs index 60887593..e503aad4 100644 --- a/crates/cli/src/bin/wallhack.rs +++ b/crates/cli/src/bin/wallhack.rs @@ -386,7 +386,7 @@ async fn run_ctl_async(cli: wallhack_cli::cli::Cli) -> Result<(), output::CtlErr role: parse_ctl_role(&h.role).into(), }) } - wallhack_cli::cli::HintAction::Clear(_) => { + wallhack_cli::cli::HintAction::Auto(_) => { management_request::Request::ClearHints(ClearHintsRequest {}) } }, diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index cb82e52c..0f376cf0 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -157,7 +157,7 @@ pub enum HintAction { Prefer(HintPreferCmd), Exclude(HintExcludeCmd), Fixed(HintFixedCmd), - Clear(HintClearCmd), + Auto(HintAutoCmd), } /// Set a prefer hint. @@ -187,10 +187,10 @@ pub struct HintFixedCmd { pub role: String, } -/// Clear all role hints. +/// Return to capability-based negotiation. #[derive(FromArgs, Debug)] -#[argh(subcommand, name = "clear")] -pub struct HintClearCmd {} +#[argh(subcommand, name = "auto")] +pub struct HintAutoCmd {} /// Shut down the daemon. #[derive(FromArgs, Debug)] diff --git a/crates/cli/src/repl.rs b/crates/cli/src/repl.rs index d4d47d59..065dad90 100644 --- a/crates/cli/src/repl.rs +++ b/crates/cli/src/repl.rs @@ -212,11 +212,11 @@ fn parse_role_command(parts: &[&str]) -> Option { } } -/// Parse `hint` command: `hint clear` or `hint `. +/// Parse `hint` command: `hint auto` or `hint `. fn parse_hint_command(parts: &[&str]) -> Option { let sub = parts.get(1).copied()?; match sub { - "clear" => Some(management_request::Request::ClearHints( + "auto" | "clear" => Some(management_request::Request::ClearHints( management::ClearHintsRequest {}, )), "prefer" | "exclude" | "fixed" => { @@ -272,7 +272,7 @@ fn print_help() { tw, " hint \tApply a role hint" ); - let _ = writeln!(tw, " hint clear\tClear all role hints"); + let _ = writeln!(tw, " hint auto\tReturn to capability-based negotiation"); let _ = writeln!(tw, " shutdown\tShut down the daemon"); let _ = writeln!(tw, " help / ?\tShow this help"); let _ = writeln!(tw, " quit \tQuit the REPL"); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 737e6fa9..4004ef78 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -215,8 +215,8 @@ impl WallhackServer { .await } - #[tool(description = "Clear all role hints, returning to pure capability-based negotiation")] - async fn hint_clear(&self) -> Result { + #[tool(description = "Return to capability-based negotiation by removing all role hints")] + async fn hint_auto(&self) -> Result { ipc_call(management_request::Request::ClearHints( ClearHintsRequest {}, )) From a7761c5e272b007443cffd363d8aa8ffe8f59ef6 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 10:54:03 +0700 Subject: [PATCH 18/41] refactor(wire): align proto message names with interface naming convention Pre-1.0 rename to establish noun_verb ordering (RouteAddRequest, HintSetRequest, PeerDisconnectRequest) matching the CLI/REPL/MCP command structure. StatusRequest/Response renamed to Info to match the endpoint rename. ClearHints renamed to HintAuto. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/api/src/handlers.rs | 36 ++++++++++++++-------------- crates/cli/src/bin/wallhack.rs | 27 ++++++++++----------- crates/cli/src/output.rs | 2 +- crates/cli/src/repl.rs | 38 +++++++++++++++--------------- crates/core/src/ipc.rs | 20 ++++++++-------- crates/mcp/src/convert.rs | 2 +- crates/mcp/src/tools.rs | 26 +++++++++----------- crates/wire/proto/management.proto | 30 +++++++++++------------ 8 files changed, 88 insertions(+), 93 deletions(-) diff --git a/crates/api/src/handlers.rs b/crates/api/src/handlers.rs index 34365d88..51935369 100644 --- a/crates/api/src/handlers.rs +++ b/crates/api/src/handlers.rs @@ -10,10 +10,10 @@ use axum::{ }; use serde::{Deserialize, Serialize}; use wallhack_wire::management::{ - AddRouteRequest, ClearHintsRequest, ConnectRequest, DisconnectPeerRequest, DisconnectRequest, - HintLevel, ListenRequest, NodeRole, PeersRequest, PingRequest, RemoveRouteRequest, - RoutesRequest, SetHintRequest, ShutdownRequest, StatsRequest, StatusRequest, - management_request, management_response, + ConnectRequest, DisconnectRequest, HintAutoRequest, HintLevel, HintSetRequest, InfoRequest, + ListenRequest, NodeRole, PeerDisconnectRequest, PeersRequest, PingRequest, + RouteAddRequest as ProtoRouteAddRequest, RouteRemoveRequest, RoutesRequest, ShutdownRequest, + StatsRequest, management_request, management_response, }; use super::{state::State as ApiState, validation}; @@ -188,12 +188,12 @@ pub async fn info(State(state): State) -> Result, .ipc .lock() .await - .request(management_request::Request::Status(StatusRequest {})) + .request(management_request::Request::Info(InfoRequest {})) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; match resp.response { - Some(management_response::Response::Status(s)) => { + Some(management_response::Response::Info(s)) => { let role = s.role().to_string(); Ok(Json(StatusResponse { name: s.package_name, @@ -299,8 +299,8 @@ pub async fn disconnect_peer( .ipc .lock() .await - .request(management_request::Request::DisconnectPeer( - DisconnectPeerRequest { + .request(management_request::Request::PeerDisconnect( + PeerDisconnectRequest { peer: name, exact: true, }, @@ -382,10 +382,12 @@ pub async fn add_route( .ipc .lock() .await - .request(management_request::Request::AddRoute(AddRouteRequest { - cidr: req.cidr, - peer: req.peer, - })) + .request(management_request::Request::RouteAdd( + ProtoRouteAddRequest { + cidr: req.cidr, + peer: req.peer, + }, + )) .await; match resp { @@ -445,8 +447,8 @@ pub async fn delete_route( .ipc .lock() .await - .request(management_request::Request::RemoveRoute( - RemoveRouteRequest { cidr: cidr_str }, + .request(management_request::Request::RouteRemove( + RouteRemoveRequest { cidr: cidr_str }, )) .await; @@ -740,7 +742,7 @@ pub async fn set_hint( .ipc .lock() .await - .request(management_request::Request::SetHint(SetHintRequest { + .request(management_request::Request::HintSet(HintSetRequest { level: level.into(), role: role.into(), })) @@ -785,9 +787,7 @@ pub async fn clear_hints(State(state): State) -> (StatusCode, Json Result<(), output::CtlErr CtlCommand::Ping(cmd) => management_request::Request::Ping(PingRequest { peer: cmd.peer.unwrap_or_default(), }), - CtlCommand::Info(_) => management_request::Request::Status(StatusRequest {}), + CtlCommand::Info(_) => management_request::Request::Info(InfoRequest {}), CtlCommand::Stats(_) => management_request::Request::Stats(StatsRequest {}), #[cfg(feature = "json")] CtlCommand::Peers(ref cmd) if cmd.json => { @@ -335,12 +334,12 @@ async fn run_ctl_async(cli: wallhack_cli::cli::Cli) -> Result<(), output::CtlErr CtlCommand::Peers(_) => management_request::Request::Peers(PeersRequest {}), CtlCommand::Route(cmd) => match cmd.action { RouteAction::List(_) => management_request::Request::Routes(RoutesRequest {}), - RouteAction::Add(add) => management_request::Request::AddRoute(AddRouteRequest { + RouteAction::Add(add) => management_request::Request::RouteAdd(RouteAddRequest { cidr: add.cidr, peer: add.peer, }), RouteAction::Remove(rm) => { - management_request::Request::RemoveRoute(RemoveRouteRequest { cidr: rm.cidr }) + management_request::Request::RouteRemove(RouteRemoveRequest { cidr: rm.cidr }) } }, CtlCommand::Connect(cmd) => { @@ -350,7 +349,7 @@ async fn run_ctl_async(cli: wallhack_cli::cli::Cli) -> Result<(), output::CtlErr management_request::Request::Listen(ListenRequest { addr: cmd.addr }) } CtlCommand::Disconnect(cmd) => match cmd.peer { - Some(peer) => management_request::Request::DisconnectPeer(DisconnectPeerRequest { + Some(peer) => management_request::Request::PeerDisconnect(PeerDisconnectRequest { peer, exact: false, }), @@ -359,35 +358,35 @@ async fn run_ctl_async(cli: wallhack_cli::cli::Cli) -> Result<(), output::CtlErr CtlCommand::Role(cmd) => { if let Some(target) = cmd.target { let role = parse_ctl_role(&target); - management_request::Request::SetHint(SetHintRequest { + management_request::Request::HintSet(HintSetRequest { level: HintLevel::Fixed.into(), role: role.into(), }) } else { - management_request::Request::Status(StatusRequest {}) + management_request::Request::Info(InfoRequest {}) } } CtlCommand::Hint(cmd) => match cmd.action { wallhack_cli::cli::HintAction::Prefer(h) => { - management_request::Request::SetHint(SetHintRequest { + management_request::Request::HintSet(HintSetRequest { level: HintLevel::Prefer.into(), role: parse_ctl_role(&h.role).into(), }) } wallhack_cli::cli::HintAction::Exclude(h) => { - management_request::Request::SetHint(SetHintRequest { + management_request::Request::HintSet(HintSetRequest { level: HintLevel::Exclude.into(), role: parse_ctl_role(&h.role).into(), }) } wallhack_cli::cli::HintAction::Fixed(h) => { - management_request::Request::SetHint(SetHintRequest { + management_request::Request::HintSet(HintSetRequest { level: HintLevel::Fixed.into(), role: parse_ctl_role(&h.role).into(), }) } wallhack_cli::cli::HintAction::Auto(_) => { - management_request::Request::ClearHints(ClearHintsRequest {}) + management_request::Request::HintAuto(HintAutoRequest {}) } }, CtlCommand::Shutdown(_) => management_request::Request::Shutdown(ShutdownRequest {}), diff --git a/crates/cli/src/output.rs b/crates/cli/src/output.rs index e70b2819..129a8a2c 100644 --- a/crates/cli/src/output.rs +++ b/crates/cli/src/output.rs @@ -103,7 +103,7 @@ fn print_peers_table(peers: &[wallhack_wire::management::PeerInfo]) { /// Returns an error if the response contains an error from the daemon. pub fn print_response(resp: &ManagementResponse) -> Result<(), CtlError> { match &resp.response { - Some(management_response::Response::Status(s)) => { + Some(management_response::Response::Info(s)) => { let role = s.role(); let uptime = format_uptime(s.uptime_ms); diff --git a/crates/cli/src/repl.rs b/crates/cli/src/repl.rs index 065dad90..5d101bb5 100644 --- a/crates/cli/src/repl.rs +++ b/crates/cli/src/repl.rs @@ -115,8 +115,8 @@ fn parse_command(line: &str) -> Option { wallhack_wire::management::PingRequest { peer }, )) } - "info" => Some(management_request::Request::Status( - wallhack_wire::management::StatusRequest {}, + "info" => Some(management_request::Request::Info( + wallhack_wire::management::InfoRequest {}, )), "stats" => Some(management_request::Request::Stats( wallhack_wire::management::StatsRequest {}, @@ -143,8 +143,8 @@ fn parse_command(line: &str) -> Option { } "disconnect" => { if let Some(peer) = parts.get(1) { - Some(management_request::Request::DisconnectPeer( - wallhack_wire::management::DisconnectPeerRequest { + Some(management_request::Request::PeerDisconnect( + wallhack_wire::management::PeerDisconnectRequest { peer: (*peer).to_string(), exact: false, }, @@ -173,14 +173,14 @@ fn parse_route_command(parts: &[&str]) -> Option { // Support: `route add ` or `route add via ` let peer_idx = if parts.get(3) == Some(&"via") { 4 } else { 3 }; let peer = (*parts.get(peer_idx)?).to_string(); - Some(management_request::Request::AddRoute( - wallhack_wire::management::AddRouteRequest { cidr, peer }, + Some(management_request::Request::RouteAdd( + wallhack_wire::management::RouteAddRequest { cidr, peer }, )) } - "del" | "remove" => { + "remove" => { let cidr = (*parts.get(2)?).to_string(); - Some(management_request::Request::RemoveRoute( - wallhack_wire::management::RemoveRouteRequest { cidr }, + Some(management_request::Request::RouteRemove( + wallhack_wire::management::RouteRemoveRequest { cidr }, )) } "list" | "" => Some(management_request::Request::Routes( @@ -194,16 +194,16 @@ fn parse_route_command(parts: &[&str]) -> Option { fn parse_role_command(parts: &[&str]) -> Option { match parts.get(1).copied() { None => { - // `role` alone → show current role via status. - Some(management_request::Request::Status( - wallhack_wire::management::StatusRequest {}, + // `role` alone → show current role via info. + Some(management_request::Request::Info( + wallhack_wire::management::InfoRequest {}, )) } Some(target) => { // `role ` → shorthand for `hint fixed `. let role = parse_role_name(target)?; - Some(management_request::Request::SetHint( - management::SetHintRequest { + Some(management_request::Request::HintSet( + management::HintSetRequest { level: management::HintLevel::Fixed.into(), role: role.into(), }, @@ -216,8 +216,8 @@ fn parse_role_command(parts: &[&str]) -> Option { fn parse_hint_command(parts: &[&str]) -> Option { let sub = parts.get(1).copied()?; match sub { - "auto" | "clear" => Some(management_request::Request::ClearHints( - management::ClearHintsRequest {}, + "auto" | "clear" => Some(management_request::Request::HintAuto( + management::HintAutoRequest {}, )), "prefer" | "exclude" | "fixed" => { let role_name = parts.get(2).copied()?; @@ -228,8 +228,8 @@ fn parse_hint_command(parts: &[&str]) -> Option { "fixed" => management::HintLevel::Fixed, _ => unreachable!(), }; - Some(management_request::Request::SetHint( - management::SetHintRequest { + Some(management_request::Request::HintSet( + management::HintSetRequest { level: level.into(), role: role.into(), }, @@ -262,7 +262,7 @@ fn print_help() { let _ = writeln!(tw, " peers\tList connected peers"); let _ = writeln!(tw, " route\tList configured routes"); let _ = writeln!(tw, " route add \tAdd a route"); - let _ = writeln!(tw, " route del \tRemove a route"); + let _ = writeln!(tw, " route remove \tRemove a route"); let _ = writeln!(tw, " connect \tConnect to a peer"); let _ = writeln!(tw, " listen \tStart listening for connections"); let _ = writeln!(tw, " disconnect [peer]\tDisconnect peer"); diff --git a/crates/core/src/ipc.rs b/crates/core/src/ipc.rs index 338d475d..c315698a 100644 --- a/crates/core/src/ipc.rs +++ b/crates/core/src/ipc.rs @@ -17,9 +17,9 @@ use tokio::{ use wallhack_transport::TransportError; use wallhack_wire::management::{ self, ConnectResponse, DaemonMessage, DaemonNotification, ErrorCode, ErrorResponse, - ListenResponse, ManagementRequest, ManagementResponse, OkResponse, PeerConnected, - PeerDisconnected, PeersResponse, PingResponse, RoutesResponse, StatsResponse, StatusResponse, - daemon_message, daemon_notification, management_request, management_response, + InfoResponse, ListenResponse, ManagementRequest, ManagementResponse, OkResponse, PeerConnected, + PeerDisconnected, PeersResponse, PingResponse, RoutesResponse, StatsResponse, daemon_message, + daemon_notification, management_request, management_response, }; use crate::{ @@ -307,9 +307,9 @@ fn dispatch_request(request: &ManagementRequest, api: &dyn NodeApi) -> Managemen } } - Some(management_request::Request::Status(_)) => { + Some(management_request::Request::Info(_)) => { let s = api.status(); - management_response::Response::Status(StatusResponse { + management_response::Response::Info(InfoResponse { role: management::NodeRole::from(s.role).into(), connected: false, // deprecated — derive from peer count instead peer_addr: s.peer_addr.unwrap_or_default(), @@ -353,7 +353,7 @@ fn dispatch_request(request: &ManagementRequest, api: &dyn NodeApi) -> Managemen Err(e) => error_response(&e), }, - Some(management_request::Request::AddRoute(req)) => match req.cidr.parse() { + Some(management_request::Request::RouteAdd(req)) => match req.cidr.parse() { Ok(cidr) => match api.add_route(cidr, req.peer.clone()) { Ok(()) => management_response::Response::Ok(OkResponse {}), Err(e) => error_response(&e), @@ -364,7 +364,7 @@ fn dispatch_request(request: &ManagementRequest, api: &dyn NodeApi) -> Managemen }), }, - Some(management_request::Request::RemoveRoute(req)) => match req.cidr.parse() { + Some(management_request::Request::RouteRemove(req)) => match req.cidr.parse() { Ok(cidr) => match api.remove_route(&cidr) { Ok(()) => management_response::Response::Ok(OkResponse {}), Err(e) => error_response(&e), @@ -375,7 +375,7 @@ fn dispatch_request(request: &ManagementRequest, api: &dyn NodeApi) -> Managemen }), }, - Some(management_request::Request::DisconnectPeer(req)) => { + Some(management_request::Request::PeerDisconnect(req)) => { let result = if req.exact { api.disconnect_peer_by_id(req.peer.clone()) } else { @@ -422,7 +422,7 @@ fn dispatch_request(request: &ManagementRequest, api: &dyn NodeApi) -> Managemen management_response::Response::Ok(OkResponse {}) } - Some(management_request::Request::SetHint(req)) => { + Some(management_request::Request::HintSet(req)) => { let level = wallhack_wire::data::HintLevel::try_from(req.level).unwrap_or_default(); let target = wallhack_wire::data::NodeRole::try_from(req.role).unwrap_or_default(); let hint = wallhack_wire::data::RoleHint { @@ -435,7 +435,7 @@ fn dispatch_request(request: &ManagementRequest, api: &dyn NodeApi) -> Managemen } } - Some(management_request::Request::ClearHints(_)) => match api.clear_hints() { + Some(management_request::Request::HintAuto(_)) => match api.clear_hints() { Ok(()) => management_response::Response::Ok(OkResponse {}), Err(e) => error_response(&e), }, diff --git a/crates/mcp/src/convert.rs b/crates/mcp/src/convert.rs index 6b41d766..4e9a8137 100644 --- a/crates/mcp/src/convert.rs +++ b/crates/mcp/src/convert.rs @@ -7,7 +7,7 @@ use wallhack_wire::management::{ManagementResponse, management_response}; /// Format a management response as human-readable text for MCP tool output. pub fn format_response(resp: &ManagementResponse) -> Result { match &resp.response { - Some(management_response::Response::Status(s)) => { + Some(management_response::Response::Info(s)) => { let role = s.role().to_string(); let mut out = String::new(); let _ = writeln!(out, "name: {}", s.package_name); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 4004ef78..96fcac7b 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -2,10 +2,9 @@ use rmcp::{handler::server::wrapper::Parameters, schemars, tool}; use wallhack_wire::management::{ - AddRouteRequest, ClearHintsRequest, ConnectRequest, DisconnectPeerRequest, DisconnectRequest, - HintLevel, ListenRequest, NodeRole, PeersRequest, PingRequest, RemoveRouteRequest, - RoutesRequest, SetHintRequest, ShutdownRequest, StatsRequest, StatusRequest, - management_request, + ConnectRequest, DisconnectRequest, HintAutoRequest, HintLevel, HintSetRequest, InfoRequest, + ListenRequest, NodeRole, PeerDisconnectRequest, PeersRequest, PingRequest, RouteAddRequest, + RouteRemoveRequest, RoutesRequest, ShutdownRequest, StatsRequest, management_request, }; use crate::convert; @@ -73,7 +72,7 @@ impl WallhackServer { description = "Get node info: role, version, uptime, listen/peer addresses, capabilities" )] async fn info(&self) -> Result { - ipc_call(management_request::Request::Status(StatusRequest {})).await + ipc_call(management_request::Request::Info(InfoRequest {})).await } #[tool(description = "Ping the daemon (or a specific peer by name prefix)")] @@ -111,7 +110,7 @@ impl WallhackServer { &self, Parameters(params): Parameters, ) -> Result { - ipc_call(management_request::Request::AddRoute(AddRouteRequest { + ipc_call(management_request::Request::RouteAdd(RouteAddRequest { cidr: params.cidr, peer: params.peer, })) @@ -123,8 +122,8 @@ impl WallhackServer { &self, Parameters(params): Parameters, ) -> Result { - ipc_call(management_request::Request::RemoveRoute( - RemoveRouteRequest { cidr: params.cidr }, + ipc_call(management_request::Request::RouteRemove( + RouteRemoveRequest { cidr: params.cidr }, )) .await } @@ -160,8 +159,8 @@ impl WallhackServer { ) -> Result { match params.peer { Some(peer) => { - ipc_call(management_request::Request::DisconnectPeer( - DisconnectPeerRequest { peer, exact: false }, + ipc_call(management_request::Request::PeerDisconnect( + PeerDisconnectRequest { peer, exact: false }, )) .await } @@ -208,7 +207,7 @@ impl WallhackServer { )); } }; - ipc_call(management_request::Request::SetHint(SetHintRequest { + ipc_call(management_request::Request::HintSet(HintSetRequest { level: level.into(), role: role.into(), })) @@ -217,10 +216,7 @@ impl WallhackServer { #[tool(description = "Return to capability-based negotiation by removing all role hints")] async fn hint_auto(&self) -> Result { - ipc_call(management_request::Request::ClearHints( - ClearHintsRequest {}, - )) - .await + ipc_call(management_request::Request::HintAuto(HintAutoRequest {})).await } } diff --git a/crates/wire/proto/management.proto b/crates/wire/proto/management.proto index 104cdbeb..313c6c38 100644 --- a/crates/wire/proto/management.proto +++ b/crates/wire/proto/management.proto @@ -11,19 +11,19 @@ message ManagementRequest { uint64 request_id = 1; // assigned by sender, echoed in response oneof request { PingRequest ping = 2; - StatusRequest status = 3; + InfoRequest info = 3; StatsRequest stats = 4; PeersRequest peers = 5; RoutesRequest routes = 6; - AddRouteRequest add_route = 7; - RemoveRouteRequest remove_route = 8; - DisconnectPeerRequest disconnect_peer = 9; + RouteAddRequest route_add = 7; + RouteRemoveRequest route_remove = 8; + PeerDisconnectRequest peer_disconnect = 9; ConnectRequest connect = 10; ListenRequest listen = 11; DisconnectRequest disconnect = 12; ShutdownRequest shutdown = 13; - SetHintRequest set_hint = 14; - ClearHintsRequest clear_hints = 15; + HintSetRequest hint_set = 14; + HintAutoRequest hint_auto = 15; } } @@ -39,14 +39,14 @@ message ManagementResponse { uint64 request_id = 1; // echoes the request oneof response { PingResponse ping = 2; - StatusResponse status = 3; + InfoResponse info = 3; StatsResponse stats = 4; PeersResponse peers = 5; RoutesResponse routes = 6; ConnectResponse connect = 7; ListenResponse listen = 8; ErrorResponse error = 20; - OkResponse ok = 21; // for void operations (add_route, disconnect, etc.) + OkResponse ok = 21; // for void operations (route_add, disconnect, etc.) } } @@ -68,7 +68,7 @@ message PingRequest { string peer = 1; // prefix to match; empty = auto-select sole peer } -message StatusRequest {} +message InfoRequest {} message StatsRequest {} @@ -76,16 +76,16 @@ message PeersRequest {} message RoutesRequest {} -message AddRouteRequest { +message RouteAddRequest { string cidr = 1; // e.g., "10.0.0.0/8" string peer = 2; // target peer name } -message RemoveRouteRequest { +message RouteRemoveRequest { string cidr = 1; } -message DisconnectPeerRequest { +message PeerDisconnectRequest { string peer = 1; bool exact = 2; // true = exact match on registry id; false = prefix match (REPL/CLI) } @@ -110,12 +110,12 @@ enum HintLevel { HINT_LEVEL_FIXED = 3; // Hard — this role and no other } -message SetHintRequest { +message HintSetRequest { HintLevel level = 1; NodeRole role = 2; } -message ClearHintsRequest {} +message HintAutoRequest {} // ── Response messages ─────────────────────────────────────────────── @@ -140,7 +140,7 @@ message PingResponse { NodeRole node_role = 3; } -message StatusResponse { +message InfoResponse { NodeRole role = 1; bool connected = 2; string peer_addr = 3; From 906ec94b4d212140a59de2da4820ee96993313dc Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 12:22:42 +0700 Subject: [PATCH 19/41] =?UTF-8?q?refactor(wire):=20RouteRemove=E2=86=92Rou?= =?UTF-8?q?teDel,=20ClearHints=E2=86=92HintSetAuto=20across=20proto=20and?= =?UTF-8?q?=20all=20consumers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iproute2-style `del` instead of `remove`. HintSetAuto sits in the HintSet family — clearing hints is setting the negotiation mode to auto. No aliases — one name per operation across REPL, CLI, MCP, and REST. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/api/src/handlers.rs | 14 ++++++++------ crates/cli/src/bin/wallhack.rs | 10 +++++----- crates/cli/src/cli.rs | 10 +++++----- crates/cli/src/repl.rs | 12 ++++++------ crates/core/src/control/handler.rs | 31 ++++++++++++++---------------- crates/core/src/ipc.rs | 4 ++-- crates/mcp/src/tools.rs | 23 ++++++++++++---------- crates/wire/proto/control.proto | 8 ++++---- crates/wire/proto/management.proto | 8 ++++---- 9 files changed, 61 insertions(+), 59 deletions(-) diff --git a/crates/api/src/handlers.rs b/crates/api/src/handlers.rs index 51935369..e3f2e111 100644 --- a/crates/api/src/handlers.rs +++ b/crates/api/src/handlers.rs @@ -10,9 +10,9 @@ use axum::{ }; use serde::{Deserialize, Serialize}; use wallhack_wire::management::{ - ConnectRequest, DisconnectRequest, HintAutoRequest, HintLevel, HintSetRequest, InfoRequest, + ConnectRequest, DisconnectRequest, HintLevel, HintSetAutoRequest, HintSetRequest, InfoRequest, ListenRequest, NodeRole, PeerDisconnectRequest, PeersRequest, PingRequest, - RouteAddRequest as ProtoRouteAddRequest, RouteRemoveRequest, RoutesRequest, ShutdownRequest, + RouteAddRequest as ProtoRouteAddRequest, RouteDelRequest, RoutesRequest, ShutdownRequest, StatsRequest, management_request, management_response, }; @@ -447,9 +447,9 @@ pub async fn delete_route( .ipc .lock() .await - .request(management_request::Request::RouteRemove( - RouteRemoveRequest { cidr: cidr_str }, - )) + .request(management_request::Request::RouteDel(RouteDelRequest { + cidr: cidr_str, + })) .await; match resp { @@ -787,7 +787,9 @@ pub async fn clear_hints(State(state): State) -> (StatusCode, Json Result<(), output::CtlErr cidr: add.cidr, peer: add.peer, }), - RouteAction::Remove(rm) => { - management_request::Request::RouteRemove(RouteRemoveRequest { cidr: rm.cidr }) + RouteAction::Del(del) => { + management_request::Request::RouteDel(RouteDelRequest { cidr: del.cidr }) } }, CtlCommand::Connect(cmd) => { @@ -386,7 +386,7 @@ async fn run_ctl_async(cli: wallhack_cli::cli::Cli) -> Result<(), output::CtlErr }) } wallhack_cli::cli::HintAction::Auto(_) => { - management_request::Request::HintAuto(HintAutoRequest {}) + management_request::Request::HintSetAuto(HintSetAutoRequest {}) } }, CtlCommand::Shutdown(_) => management_request::Request::Shutdown(ShutdownRequest {}), diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 0f376cf0..30f5d0b9 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -76,7 +76,7 @@ pub struct RouteCmd { pub enum RouteAction { List(RouteListCmd), Add(RouteAddCmd), - Remove(RouteRemoveCmd), + Del(RouteDelCmd), } /// List routes. @@ -97,11 +97,11 @@ pub struct RouteAddCmd { pub peer: String, } -/// Remove a route. +/// Delete a route. #[derive(FromArgs, Debug)] -#[argh(subcommand, name = "remove")] -pub struct RouteRemoveCmd { - /// CIDR to remove (e.g. "10.0.0.0/8") +#[argh(subcommand, name = "del")] +pub struct RouteDelCmd { + /// CIDR to delete (e.g. "10.0.0.0/8") #[argh(positional)] pub cidr: String, } diff --git a/crates/cli/src/repl.rs b/crates/cli/src/repl.rs index 5d101bb5..9c488200 100644 --- a/crates/cli/src/repl.rs +++ b/crates/cli/src/repl.rs @@ -177,10 +177,10 @@ fn parse_route_command(parts: &[&str]) -> Option { wallhack_wire::management::RouteAddRequest { cidr, peer }, )) } - "remove" => { + "del" => { let cidr = (*parts.get(2)?).to_string(); - Some(management_request::Request::RouteRemove( - wallhack_wire::management::RouteRemoveRequest { cidr }, + Some(management_request::Request::RouteDel( + wallhack_wire::management::RouteDelRequest { cidr }, )) } "list" | "" => Some(management_request::Request::Routes( @@ -216,8 +216,8 @@ fn parse_role_command(parts: &[&str]) -> Option { fn parse_hint_command(parts: &[&str]) -> Option { let sub = parts.get(1).copied()?; match sub { - "auto" | "clear" => Some(management_request::Request::HintAuto( - management::HintAutoRequest {}, + "auto" => Some(management_request::Request::HintSetAuto( + management::HintSetAutoRequest {}, )), "prefer" | "exclude" | "fixed" => { let role_name = parts.get(2).copied()?; @@ -262,7 +262,7 @@ fn print_help() { let _ = writeln!(tw, " peers\tList connected peers"); let _ = writeln!(tw, " route\tList configured routes"); let _ = writeln!(tw, " route add \tAdd a route"); - let _ = writeln!(tw, " route remove \tRemove a route"); + let _ = writeln!(tw, " route del \tRemove a route"); let _ = writeln!(tw, " connect \tConnect to a peer"); let _ = writeln!(tw, " listen \tStart listening for connections"); let _ = writeln!(tw, " disconnect [peer]\tDisconnect peer"); diff --git a/crates/core/src/control/handler.rs b/crates/core/src/control/handler.rs index fa9048ed..46054c85 100644 --- a/crates/core/src/control/handler.rs +++ b/crates/core/src/control/handler.rs @@ -175,7 +175,7 @@ impl Handler { Some(control_request::Request::Peers(_)) => self.handle_peers(), Some(control_request::Request::Disconnect(req)) => self.handle_disconnect(&req), Some(control_request::Request::RouteAdd(req)) => self.handle_route_add(req), - Some(control_request::Request::RouteRemove(req)) => self.handle_route_remove(&req), + Some(control_request::Request::RouteDel(req)) => self.handle_route_del(&req), Some(control_request::Request::RouteList(_)) => self.handle_route_list(), None => Self::error_response("Empty request"), } @@ -286,16 +286,13 @@ impl Handler { } } - fn handle_route_remove( - &self, - req: &wallhack_wire::control::RouteRemoveRequest, - ) -> ControlResponse { + fn handle_route_del(&self, req: &wallhack_wire::control::RouteDelRequest) -> ControlResponse { let cidr = match req.cidr.parse() { Ok(c) => c, Err(e) => { return ControlResponse { - response: Some(control_response::Response::RouteRemove( - wallhack_wire::control::RouteRemoveResponse { + response: Some(control_response::Response::RouteDel( + wallhack_wire::control::RouteDelResponse { success: false, message: format!("invalid CIDR: {e}"), }, @@ -312,8 +309,8 @@ impl Handler { .send(super::routes::RouteUpdate::Remove(entry)); } ControlResponse { - response: Some(control_response::Response::RouteRemove( - wallhack_wire::control::RouteRemoveResponse { + response: Some(control_response::Response::RouteDel( + wallhack_wire::control::RouteDelResponse { success, message: if success { String::new() @@ -654,7 +651,7 @@ mod tests { } #[test] - fn test_route_remove() { + fn test_route_del() { let handler = test_handler(); // Add first @@ -669,15 +666,15 @@ mod tests { // Remove let response = handler.handle(ControlRequest { - request: Some(control_request::Request::RouteRemove( - wallhack_wire::control::RouteRemoveRequest { + request: Some(control_request::Request::RouteDel( + wallhack_wire::control::RouteDelRequest { cidr: "10.0.0.0/8".to_string(), }, )), }); match response.response { - Some(control_response::Response::RouteRemove(r)) => { + Some(control_response::Response::RouteDel(r)) => { assert!(r.success); } _ => panic!("Expected route remove response"), @@ -685,18 +682,18 @@ mod tests { } #[test] - fn test_route_remove_not_found() { + fn test_route_del_not_found() { let handler = test_handler(); let response = handler.handle(ControlRequest { - request: Some(control_request::Request::RouteRemove( - wallhack_wire::control::RouteRemoveRequest { + request: Some(control_request::Request::RouteDel( + wallhack_wire::control::RouteDelRequest { cidr: "10.0.0.0/8".to_string(), }, )), }); match response.response { - Some(control_response::Response::RouteRemove(r)) => { + Some(control_response::Response::RouteDel(r)) => { assert!(!r.success); assert_eq!(r.message, "route not found"); } diff --git a/crates/core/src/ipc.rs b/crates/core/src/ipc.rs index c315698a..675b29a3 100644 --- a/crates/core/src/ipc.rs +++ b/crates/core/src/ipc.rs @@ -364,7 +364,7 @@ fn dispatch_request(request: &ManagementRequest, api: &dyn NodeApi) -> Managemen }), }, - Some(management_request::Request::RouteRemove(req)) => match req.cidr.parse() { + Some(management_request::Request::RouteDel(req)) => match req.cidr.parse() { Ok(cidr) => match api.remove_route(&cidr) { Ok(()) => management_response::Response::Ok(OkResponse {}), Err(e) => error_response(&e), @@ -435,7 +435,7 @@ fn dispatch_request(request: &ManagementRequest, api: &dyn NodeApi) -> Managemen } } - Some(management_request::Request::HintAuto(_)) => match api.clear_hints() { + Some(management_request::Request::HintSetAuto(_)) => match api.clear_hints() { Ok(()) => management_response::Response::Ok(OkResponse {}), Err(e) => error_response(&e), }, diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 96fcac7b..ecaaab9b 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -2,9 +2,9 @@ use rmcp::{handler::server::wrapper::Parameters, schemars, tool}; use wallhack_wire::management::{ - ConnectRequest, DisconnectRequest, HintAutoRequest, HintLevel, HintSetRequest, InfoRequest, + ConnectRequest, DisconnectRequest, HintLevel, HintSetAutoRequest, HintSetRequest, InfoRequest, ListenRequest, NodeRole, PeerDisconnectRequest, PeersRequest, PingRequest, RouteAddRequest, - RouteRemoveRequest, RoutesRequest, ShutdownRequest, StatsRequest, management_request, + RouteDelRequest, RoutesRequest, ShutdownRequest, StatsRequest, management_request, }; use crate::convert; @@ -24,7 +24,7 @@ pub struct AddRouteParams { } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct RemoveRouteParams { +pub struct RouteDelParams { /// CIDR range to remove, e.g. "10.0.0.0/8" pub cidr: String, } @@ -118,13 +118,13 @@ impl WallhackServer { } #[tool(description = "Remove a route by CIDR")] - async fn route_remove( + async fn route_del( &self, - Parameters(params): Parameters, + Parameters(params): Parameters, ) -> Result { - ipc_call(management_request::Request::RouteRemove( - RouteRemoveRequest { cidr: params.cidr }, - )) + ipc_call(management_request::Request::RouteDel(RouteDelRequest { + cidr: params.cidr, + })) .await } @@ -215,8 +215,11 @@ impl WallhackServer { } #[tool(description = "Return to capability-based negotiation by removing all role hints")] - async fn hint_auto(&self) -> Result { - ipc_call(management_request::Request::HintAuto(HintAutoRequest {})).await + async fn hint_set_auto(&self) -> Result { + ipc_call(management_request::Request::HintSetAuto( + HintSetAutoRequest {}, + )) + .await } } diff --git a/crates/wire/proto/control.proto b/crates/wire/proto/control.proto index 1c59381c..99344c58 100644 --- a/crates/wire/proto/control.proto +++ b/crates/wire/proto/control.proto @@ -13,7 +13,7 @@ message ControlRequest { PeersRequest peers = 3; DisconnectRequest disconnect = 4; RouteAddRequest route_add = 5; - RouteRemoveRequest route_remove = 6; + RouteDelRequest route_del = 6; RouteListRequest route_list = 7; } } @@ -25,7 +25,7 @@ message ControlResponse { PeersResponse peers = 3; DisconnectResponse disconnect = 4; RouteAddResponse route_add = 5; - RouteRemoveResponse route_remove = 6; + RouteDelResponse route_del = 6; RouteListResponse route_list = 7; ErrorResponse error = 100; } @@ -79,10 +79,10 @@ message RouteAddResponse { string message = 2; } -message RouteRemoveRequest { +message RouteDelRequest { string cidr = 1; } -message RouteRemoveResponse { +message RouteDelResponse { bool success = 1; string message = 2; } diff --git a/crates/wire/proto/management.proto b/crates/wire/proto/management.proto index 313c6c38..1cebe9cf 100644 --- a/crates/wire/proto/management.proto +++ b/crates/wire/proto/management.proto @@ -16,14 +16,14 @@ message ManagementRequest { PeersRequest peers = 5; RoutesRequest routes = 6; RouteAddRequest route_add = 7; - RouteRemoveRequest route_remove = 8; + RouteDelRequest route_del = 8; PeerDisconnectRequest peer_disconnect = 9; ConnectRequest connect = 10; ListenRequest listen = 11; DisconnectRequest disconnect = 12; ShutdownRequest shutdown = 13; HintSetRequest hint_set = 14; - HintAutoRequest hint_auto = 15; + HintSetAutoRequest hint_set_auto = 15; } } @@ -81,7 +81,7 @@ message RouteAddRequest { string peer = 2; // target peer name } -message RouteRemoveRequest { +message RouteDelRequest { string cidr = 1; } @@ -115,7 +115,7 @@ message HintSetRequest { NodeRole role = 2; } -message HintAutoRequest {} +message HintSetAutoRequest {} // ── Response messages ─────────────────────────────────────────────── From eb7834c91684c3fa9b9dfb3bc93801a35579055b Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 14:29:54 +0700 Subject: [PATCH 20/41] refactor(daemon): pass Capabilities struct and derive interactive from terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_local_handshake now takes a Capabilities struct instead of positional bools — call sites are self-documenting. All modes (entry/exit/relay/auto) derive interactive from stdin terminal detection instead of hardcoding false. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/daemon/src/mode/auto.rs | 40 +++++++++++++-------------------- crates/daemon/src/mode/entry.rs | 6 ++--- crates/daemon/src/mode/exit.rs | 4 ++-- crates/daemon/src/mode/relay.rs | 6 ++--- 4 files changed, 23 insertions(+), 33 deletions(-) diff --git a/crates/daemon/src/mode/auto.rs b/crates/daemon/src/mode/auto.rs index da8b86a5..096e6203 100644 --- a/crates/daemon/src/mode/auto.rs +++ b/crates/daemon/src/mode/auto.rs @@ -142,24 +142,10 @@ pub(crate) async fn run( /// /// Always populates `routes` with locally-routable CIDRs so that a peer /// resolving to Entry can install OS routes automatically. -// REASON: these are distinct capability flags from the wire format; wrapping -// them in an enum would add indirection without clarity at the call sites. -#[allow(clippy::fn_params_excessive_bools)] -fn build_local_handshake( - cfg: &AutoConfig, - version: &str, - tun_capable: bool, - listening: bool, - connecting: bool, - interactive: bool, -) -> Handshake { +/// Build a local `Handshake` from config and process-wide capabilities. +fn build_local_handshake(cfg: &AutoConfig, version: &str, caps: Capabilities) -> Handshake { Handshake { - capabilities: Some(Capabilities { - tun_capable, - listening, - connecting, - interactive, - }), + capabilities: Some(caps), name: cfg.name.clone(), version: version.to_string(), psk_proof: Vec::new(), @@ -234,10 +220,12 @@ async fn run_auto_connector( let local_hs = build_local_handshake( cfg, &global.version, - tun_capable, - false, - true, - std::io::IsTerminal::is_terminal(&std::io::stdin()), + Capabilities { + tun_capable, + listening: false, + connecting: true, + interactive: std::io::IsTerminal::is_terminal(&std::io::stdin()), + }, ); tracing::info!("Auto connector: connecting to {}...", spec.addr); @@ -642,10 +630,12 @@ async fn run_auto_listener( let local_hs = build_local_handshake( cfg, &global.version, - tun_capable, - true, - false, - std::io::IsTerminal::is_terminal(&std::io::stdin()), + Capabilities { + tun_capable, + listening: true, + connecting: false, + interactive: std::io::IsTerminal::is_terminal(&std::io::stdin()), + }, ); let addr: std::net::SocketAddr = spec.addr.parse::()?.into(); diff --git a/crates/daemon/src/mode/entry.rs b/crates/daemon/src/mode/entry.rs index c2b4faef..fa3f38e7 100644 --- a/crates/daemon/src/mode/entry.rs +++ b/crates/daemon/src/mode/entry.rs @@ -170,7 +170,7 @@ pub async fn run( tun_capable: crate::tun_cap::detect_tun_capable(), listening: false, connecting: false, - interactive: false, + interactive: std::io::IsTerminal::is_terminal(&std::io::stdin()), }); let res = EntryResources { @@ -216,7 +216,7 @@ async fn run_entry_listen( tun_capable: crate::tun_cap::detect_tun_capable(), listening: true, connecting: false, - interactive: false, + interactive: std::io::IsTerminal::is_terminal(&std::io::stdin()), }), name: cfg.name.clone(), version: global.version.clone(), @@ -440,7 +440,7 @@ fn entry_local_handshake(name: &str, version: &str) -> wallhack_wire::data::Hand tun_capable: crate::tun_cap::detect_tun_capable(), listening: false, connecting: true, - interactive: false, + interactive: std::io::IsTerminal::is_terminal(&std::io::stdin()), }), name: name.to_string(), version: version.to_string(), diff --git a/crates/daemon/src/mode/exit.rs b/crates/daemon/src/mode/exit.rs index 53701620..ea266e95 100644 --- a/crates/daemon/src/mode/exit.rs +++ b/crates/daemon/src/mode/exit.rs @@ -101,7 +101,7 @@ async fn run_exit_connector( tun_capable: false, listening: false, connecting: true, - interactive: false, + interactive: std::io::IsTerminal::is_terminal(&std::io::stdin()), }), name: name.to_string(), version: global.version.clone(), @@ -242,7 +242,7 @@ async fn run_exit_listener( tun_capable: false, listening: true, connecting: false, - interactive: false, + interactive: std::io::IsTerminal::is_terminal(&std::io::stdin()), }), name: node_name.to_string(), version: global.version.clone(), diff --git a/crates/daemon/src/mode/relay.rs b/crates/daemon/src/mode/relay.rs index 5fec2a97..29917029 100644 --- a/crates/daemon/src/mode/relay.rs +++ b/crates/daemon/src/mode/relay.rs @@ -55,7 +55,7 @@ fn build_server_options(cfg: &RelayConfig, version: &str, metrics: Arc) tun_capable: false, listening: true, connecting: true, - interactive: false, + interactive: std::io::IsTerminal::is_terminal(&std::io::stdin()), }), name: cfg.name.clone(), version: version.to_string(), @@ -90,7 +90,7 @@ pub async fn run( tun_capable: false, listening: true, connecting: true, - interactive: false, + interactive: std::io::IsTerminal::is_terminal(&std::io::stdin()), }); let addr: std::net::SocketAddr = cfg.listen.addr.parse::()?.into(); let server_options = build_server_options(cfg, &global.version, metrics); @@ -110,7 +110,7 @@ pub async fn run( tun_capable: false, listening: true, connecting: true, - interactive: false, + interactive: std::io::IsTerminal::is_terminal(&std::io::stdin()), }), name: cfg.name.clone(), version: global.version.clone(), From dc86139ed28c0abf1d94cf2c485c34196b3f8d9f Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 14:34:44 +0700 Subject: [PATCH 21/41] refactor(relay): extract resolve_peer helper, shadow clones for spawned tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deduplicates handshake→name/role extraction into resolve_peer(). Spawned task clones use block-scoped shadowing instead of _cleanup suffixes per naming standard. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/daemon/src/mode/relay.rs | 62 ++++++++++++++++----------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/crates/daemon/src/mode/relay.rs b/crates/daemon/src/mode/relay.rs index 29917029..5716dba7 100644 --- a/crates/daemon/src/mode/relay.rs +++ b/crates/daemon/src/mode/relay.rs @@ -30,6 +30,21 @@ use crate::{ /// Delay before reconnecting after the source peer connection drops. const RECONNECT_DELAY: Duration = Duration::from_millis(500); +/// Extract peer name and role from an optional handshake, falling back to the +/// transport address and `Exit` role when the handshake is missing or empty. +fn resolve_peer( + handshake: Option<&wallhack_wire::data::Handshake>, + peer_addr: &str, +) -> (String, NodeRole) { + let name = handshake + .filter(|h| !h.name.is_empty()) + .map_or_else(|| peer_addr.to_owned(), |h| h.name.clone()); + let role = handshake + .and_then(|h| h.capabilities) + .map_or(NodeRole::Exit, super::peer_role_from_capabilities); + (name, role) +} + /// Lightweight shutdown signal: dropping the sender wakes all receivers. /// /// Avoids a `tokio-util` dependency for `CancellationToken`. @@ -267,16 +282,8 @@ async fn run_relay_loop_inner( } else { None }; - let peer_name = peer_handshake - .as_ref() - .filter(|h| !h.name.is_empty()) - .map_or_else(|| peer_addr.clone(), |h| h.name.clone()); - let peer_role = peer_handshake - .as_ref() - .and_then(|h| h.capabilities) - .map_or(NodeRole::Exit, super::peer_role_from_capabilities); + let (peer_name, peer_role) = resolve_peer(peer_handshake.as_ref(), &peer_addr); - // Register the source peer so it appears in `wallhack peers`. peers.register( peer_name.clone(), peer_addr.clone(), @@ -617,19 +624,11 @@ fn handle_relay_connection( responses_rx, } = channels; - let peer_name = peer_handshake - .as_ref() - .filter(|h| !h.name.is_empty()) - .map_or_else(|| peer_addr.clone(), |h| h.name.clone()); - let peer_role = peer_handshake - .as_ref() - .and_then(|h| h.capabilities) - .map_or(NodeRole::Exit, super::peer_role_from_capabilities); + let (peer_name, peer_role) = resolve_peer(peer_handshake.as_ref(), &peer_addr); - // Register the bridged peer so it appears in `wallhack peers`. peers.register( peer_name.clone(), - peer_addr.clone(), + peer_addr, peer_role, ConnectionSide::Accept, ); @@ -662,20 +661,21 @@ fn handle_relay_connection( // Outgoing: open uni stream to exit peer, send instructions from the entry. // instructions_rx receives instructions distributed by the fan-out task. let peer_transport_instr = std::sync::Arc::clone(&transport); - let peer_name_cleanup = peer_name.clone(); - let peers_cleanup = Arc::clone(peers); - tokio::spawn(async move { - match peer_transport_instr.open_uni_erased().await { - Ok(mut send) => { - if let Err(e) = run_send_instructions(&mut send, instructions_rx).await { - tracing::debug!("Relay peer send-instructions finished: {e}"); + { + let peer_name = peer_name.clone(); + let peers = Arc::clone(peers); + tokio::spawn(async move { + match peer_transport_instr.open_uni_erased().await { + Ok(mut send) => { + if let Err(e) = run_send_instructions(&mut send, instructions_rx).await { + tracing::debug!("Relay peer send-instructions finished: {e}"); + } } + Err(e) => tracing::debug!("Relay peer failed to open send stream: {e}"), } - Err(e) => tracing::debug!("Relay peer failed to open send stream: {e}"), - } - // Unregister peer when the outgoing stream closes (connection gone). - peers_cleanup.unregister(&peer_name_cleanup); - }); + peers.unregister(&peer_name); + }); + } // Register this peer's transport for bidi bridging. // The source→peer accept loop (spawned in run_relay_loop_inner) reads From f149e1da023f5250991cfed147329075eba76b71 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 14:57:48 +0700 Subject: [PATCH 22/41] chore(api): align OpenAPI operationIds with noun_verb naming convention routeAdd/routeDel/hintSet/hintSetAuto match the proto and interface naming established in the wire rename. Co-Authored-By: Claude Opus 4.6 (1M context) --- website/src/data/openapi.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/website/src/data/openapi.json b/website/src/data/openapi.json index 8a86c911..f908582c 100644 --- a/website/src/data/openapi.json +++ b/website/src/data/openapi.json @@ -433,7 +433,7 @@ "post": { "summary": "Add route", "description": "Configures a new route to a target network through an active peer.", - "operationId": "addRoute", + "operationId": "routeAdd", "security": [{ "basicAuth": [] }], "requestBody": { "required": true, @@ -480,9 +480,9 @@ }, "/routes/{cidr}": { "delete": { - "summary": "Remove route", + "summary": "Delete route", "description": "Deletes a routing entry by its network specification.", - "operationId": "deleteRoute", + "operationId": "routeDel", "security": [{ "basicAuth": [] }], "parameters": [ { @@ -655,7 +655,7 @@ "put": { "summary": "Set role hint", "description": "Configures a negotiation hint to influence auto-role resolution.", - "operationId": "setHint", + "operationId": "hintSet", "security": [{ "basicAuth": [] }], "requestBody": { "required": true, @@ -679,9 +679,9 @@ } }, "delete": { - "summary": "Clear hints", - "description": "Removes all role hints, returning to pure capability-based negotiation.", - "operationId": "clearHints", + "summary": "Auto-negotiate", + "description": "Returns to pure capability-based negotiation by removing all role hints.", + "operationId": "hintSetAuto", "security": [{ "basicAuth": [] }], "responses": { "200": { From 27ab3235062dd6b840c8bb7b4eb403e402140063 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 14:58:51 +0700 Subject: [PATCH 23/41] chore(api): rename OpenAPI schema types to match generated web UI types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StatusResponse→InfoResponse, RouteAddRequest→RouteAddBody, SetHintRequest→HintSetBody. Web UI generates TypeScript types from these schema names, so they need to be consistent with the interface naming convention. Co-Authored-By: Claude Opus 4.6 (1M context) --- website/src/data/openapi.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/data/openapi.json b/website/src/data/openapi.json index f908582c..077657eb 100644 --- a/website/src/data/openapi.json +++ b/website/src/data/openapi.json @@ -20,7 +20,7 @@ } }, "schemas": { - "StatusResponse": { + "InfoResponse": { "type": "object", "required": ["name", "version", "role", "uptime_ms", "capabilities"], "properties": { @@ -290,7 +290,7 @@ } } }, - "SetHintRequest": { + "HintSetRequest": { "type": "object", "required": ["level", "role"], "properties": { From 2dd760b7993119a0bd10a6c5dfc1e75fb289af73 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 15:13:00 +0700 Subject: [PATCH 24/41] style: shadow clones and rename opaque abbreviations per naming standard All Arc::clone bindings now shadow the original name in block scope instead of using _suffix or single-char names. Opaque abbreviations (pa, ns, ru, r, m, p, e, cc, lhs) replaced with descriptive names. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/core/src/client/quic/mod.rs | 1 + crates/core/src/client/ws/mod.rs | 1 + crates/core/src/ipc.rs | 8 +- crates/core/src/server/quic/mod.rs | 48 ++++---- crates/core/src/server/ws/mod.rs | 48 ++++---- crates/daemon/src/lib.rs | 9 +- crates/daemon/src/mode/auto.rs | 182 ++++++++++++++++------------- crates/daemon/src/mode/entry.rs | 102 ++++++++++------ crates/daemon/src/mode/exit.rs | 117 ++++++++++--------- crates/daemon/src/mode/relay.rs | 98 ++++++++-------- crates/daemon/src/transport.rs | 5 +- 11 files changed, 345 insertions(+), 274 deletions(-) diff --git a/crates/core/src/client/quic/mod.rs b/crates/core/src/client/quic/mod.rs index 0b0be5bd..9f468072 100644 --- a/crates/core/src/client/quic/mod.rs +++ b/crates/core/src/client/quic/mod.rs @@ -181,6 +181,7 @@ impl Client for QuicClient { latency_tx: Some(latency_tx), control_response_tx: None, role_transition_tx: None, + peer_announcement_tx: None, }; match protocol::run_control_stream_initiator( &*transport_ctrl, diff --git a/crates/core/src/client/ws/mod.rs b/crates/core/src/client/ws/mod.rs index ebd18b19..669c5b1c 100644 --- a/crates/core/src/client/ws/mod.rs +++ b/crates/core/src/client/ws/mod.rs @@ -398,6 +398,7 @@ impl WsClient { latency_tx: Some(latency_tx), control_response_tx: None, role_transition_tx: None, + peer_announcement_tx: None, }; match protocol::run_control_stream_initiator( &*transport_ctrl, diff --git a/crates/core/src/ipc.rs b/crates/core/src/ipc.rs index 675b29a3..6d033c7c 100644 --- a/crates/core/src/ipc.rs +++ b/crates/core/src/ipc.rs @@ -115,10 +115,10 @@ pub async fn run_ipc_listener( } result = listener.accept() => { let (stream, _addr) = result.context("accepting IPC connection")?; - let api = Arc::clone(&node_api); + let node_api = Arc::clone(&node_api); let events_rx = peer_events.subscribe(); tokio::spawn(async move { - if let Err(e) = handle_connection(stream, api, Some(events_rx)).await { + if let Err(e) = handle_connection(stream, node_api, Some(events_rx)).await { tracing::debug!(error = %e, "IPC connection ended"); } }); @@ -167,10 +167,10 @@ pub async fn run_vsock_listener( match result { Ok((stream, addr)) => { tracing::debug!(cid = addr.cid(), port = addr.port(), "vsock IPC connection"); - let api = Arc::clone(&node_api); + let node_api = Arc::clone(&node_api); let events_rx = peer_events.subscribe(); tokio::spawn(async move { - if let Err(e) = handle_connection(stream, api, Some(events_rx)).await { + if let Err(e) = handle_connection(stream, node_api, Some(events_rx)).await { tracing::debug!(error = %e, "vsock IPC connection ended"); } }); diff --git a/crates/core/src/server/quic/mod.rs b/crates/core/src/server/quic/mod.rs index e74d1656..263b6209 100644 --- a/crates/core/src/server/quic/mod.rs +++ b/crates/core/src/server/quic/mod.rs @@ -199,7 +199,6 @@ impl Server for QuicServer { // Spawn control stream task with handler let handler_config = self.options.handler_config.clone(); - let metrics_ctrl = Arc::clone(&metrics); let peers_ctrl = self .options .peers @@ -219,27 +218,32 @@ impl Server for QuicServer { tx }); - tokio::spawn(async move { - let handler = Handler::new( - handler_config, - metrics_ctrl, - peers_ctrl, - routes_ctrl, - route_updates, - ); - let mut channels = protocol::ControlChannels { - outgoing_rx: control_rx, - handshake_tx: None, // Handshake already read above - latency_tx: Some(latency_tx), - control_response_tx: None, // server doesn't issue ControlRequests - role_transition_tx: None, - }; - let mut control_stream = wallhack_transport::erased::BoxBiStream::new(control_stream); - let exit = channels - .run(&mut control_stream, Some(&handler), Duration::from_secs(30)) - .await; - tracing::debug!("Control stream finished: {exit:?}"); - }); + { + let metrics = Arc::clone(&metrics); + tokio::spawn(async move { + let handler = Handler::new( + handler_config, + metrics, + peers_ctrl, + routes_ctrl, + route_updates, + ); + let mut channels = protocol::ControlChannels { + outgoing_rx: control_rx, + handshake_tx: None, // Handshake already read above + latency_tx: Some(latency_tx), + control_response_tx: None, // server doesn't issue ControlRequests + role_transition_tx: None, + peer_announcement_tx: None, + }; + let mut control_stream = + wallhack_transport::erased::BoxBiStream::new(control_stream); + let exit = channels + .run(&mut control_stream, Some(&handler), Duration::from_secs(30)) + .await; + tracing::debug!("Control stream finished: {exit:?}"); + }); + } // Data tasks are NOT spawned here — the caller does that after PSK validation. Ok(Some(AcceptResult::with_handshake( diff --git a/crates/core/src/server/ws/mod.rs b/crates/core/src/server/ws/mod.rs index 86ceaf2d..6277f54a 100644 --- a/crates/core/src/server/ws/mod.rs +++ b/crates/core/src/server/ws/mod.rs @@ -280,7 +280,6 @@ impl Server for WebSocketServer { // Spawn control stream task with handler let handler_config = self.options.handler_config.clone(); - let metrics_ctrl = Arc::clone(&metrics); let peers_ctrl = self .options .peers @@ -300,27 +299,32 @@ impl Server for WebSocketServer { tx }); - tokio::spawn(async move { - let handler = Handler::new( - handler_config, - metrics_ctrl, - peers_ctrl, - routes_ctrl, - route_updates, - ); - let mut channels = protocol::ControlChannels { - outgoing_rx: control_rx, - handshake_tx: None, // Handshake already read above - latency_tx: Some(latency_tx), - control_response_tx: None, // server doesn't issue ControlRequests - role_transition_tx: None, - }; - let mut control_stream = wallhack_transport::erased::BoxBiStream::new(control_stream); - let exit = channels - .run(&mut control_stream, Some(&handler), Duration::from_secs(30)) - .await; - tracing::debug!("Control stream finished: {exit:?}"); - }); + { + let metrics = Arc::clone(&metrics); + tokio::spawn(async move { + let handler = Handler::new( + handler_config, + metrics, + peers_ctrl, + routes_ctrl, + route_updates, + ); + let mut channels = protocol::ControlChannels { + outgoing_rx: control_rx, + handshake_tx: None, // Handshake already read above + latency_tx: Some(latency_tx), + control_response_tx: None, // server doesn't issue ControlRequests + role_transition_tx: None, + peer_announcement_tx: None, + }; + let mut control_stream = + wallhack_transport::erased::BoxBiStream::new(control_stream); + let exit = channels + .run(&mut control_stream, Some(&handler), Duration::from_secs(30)) + .await; + tracing::debug!("Control stream finished: {exit:?}"); + }); + } // Data tasks are NOT spawned here — the caller does that after PSK validation. Ok(Some(AcceptResult::with_handshake( diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs index 4562137a..1843478f 100644 --- a/crates/daemon/src/lib.rs +++ b/crates/daemon/src/lib.rs @@ -156,7 +156,7 @@ pub fn start_node(config: &DaemonConfig) -> Result { let (shutdown_tx, _shutdown_rx) = watch::channel(()); - let handle_peers = Arc::clone(&peers); + let peers_for_handle = Arc::clone(&peers); let config = config.clone(); let resources = mode::NodeResources { metrics, @@ -168,5 +168,10 @@ pub fn start_node(config: &DaemonConfig) -> Result { }; let task = tokio::spawn(async move { mode::run(&config, resources).await.map_err(Into::into) }); - Ok(DaemonHandle::new(node_api, handle_peers, shutdown_tx, task)) + Ok(DaemonHandle::new( + node_api, + peers_for_handle, + shutdown_tx, + task, + )) } diff --git a/crates/daemon/src/mode/auto.rs b/crates/daemon/src/mode/auto.rs index 096e6203..e86af180 100644 --- a/crates/daemon/src/mode/auto.rs +++ b/crates/daemon/src/mode/auto.rs @@ -45,6 +45,7 @@ const RECONNECT_DELAY: Duration = Duration::from_millis(500); /// # Errors /// /// Returns error if the connection setup fails non-retryably. +// REASON: threading metrics, peers, routes, route_updates, route_updates_tx, node_state through mode dispatch #[allow(clippy::too_many_arguments)] pub(crate) async fn run( global: &GlobalConfig, @@ -165,9 +166,9 @@ fn is_routable_cidr(cidr: &wallhack_core::Cidr) -> bool { && !addr.is_loopback() && !match addr { IpAddr::V4(a) => a.is_link_local(), - IpAddr::V6(a) => { - let o = a.octets(); - o[0] == 0xfe && (o[1] & 0xc0) == 0x80 + IpAddr::V6(addr) => { + let octets = addr.octets(); + octets[0] == 0xfe && (octets[1] & 0xc0) == 0x80 } } && !addr.is_unspecified() @@ -205,6 +206,7 @@ fn install_advertised_routes( // ============================================================================ /// Auto connector: connect to a peer, negotiate role, run the session. +// REASON: threading transport, metrics, peers, routes, route_updates through protocol-specific quic/ws arms #[allow(clippy::too_many_lines, clippy::too_many_arguments)] async fn run_auto_connector( global: &GlobalConfig, @@ -251,8 +253,7 @@ async fn run_auto_connector( &security, Some(local_hs.clone()), ); - let lhs = local_hs; - let ru = route_updates.resubscribe(); + let route_updates = route_updates.resubscribe(); crate::transport::connect_loop( || { let cfg = client_config.clone(); @@ -264,24 +265,24 @@ async fn run_auto_connector( }, move |connect_result| { // erase() is sync — runs before async move captures anything generic - let e = connect_result.erase(); + let connect_result = connect_result.erase(); let metrics = Arc::clone(&metrics); let peers = Arc::clone(&peers); - let pa = peer_addr.clone(); - let lhs = lhs.clone(); - let ns = node_state.clone(); - let r = Arc::clone(&routes); - let ru = ru.resubscribe(); + let peer_addr = peer_addr.clone(); + let local_hs = local_hs.clone(); + let node_state = node_state.clone(); + let routes = Arc::clone(&routes); + let route_updates = route_updates.resubscribe(); async move { run_auto_connect_session_dispatch( - e, - &lhs, - &pa, + connect_result, + &local_hs, + &peer_addr, metrics, peers, - ns, - Some(r), - Some(ru), + node_state, + Some(routes), + Some(route_updates), ) .await } @@ -303,8 +304,7 @@ async fn run_auto_connector( &security, Some(local_hs.clone()), ); - let lhs = local_hs; - let ru = route_updates.resubscribe(); + let route_updates = route_updates.resubscribe(); crate::transport::connect_loop( || { let cfg = client_config.clone(); @@ -315,24 +315,24 @@ async fn run_auto_connector( }, move |connect_result| { // erase() is sync — runs before async move captures anything generic - let e = connect_result.erase(); + let connect_result = connect_result.erase(); let metrics = Arc::clone(&metrics); let peers = Arc::clone(&peers); - let pa = peer_addr.clone(); - let lhs = lhs.clone(); - let ns = node_state.clone(); - let r = Arc::clone(&routes); - let ru = ru.resubscribe(); + let peer_addr = peer_addr.clone(); + let local_hs = local_hs.clone(); + let node_state = node_state.clone(); + let routes = Arc::clone(&routes); + let route_updates = route_updates.resubscribe(); async move { run_auto_connect_session_dispatch( - e, - &lhs, - &pa, + connect_result, + &local_hs, + &peer_addr, metrics, peers, - ns, - Some(r), - Some(ru), + node_state, + Some(routes), + Some(route_updates), ) .await } @@ -348,6 +348,7 @@ async fn run_auto_connector( } /// Non-generic auto-connector dispatch: negotiates role and runs the session. +// REASON: symmetric entry/exit/relay/indeterminate negotiation arms, each with distinct session logic #[allow(clippy::too_many_arguments, clippy::too_many_lines)] async fn run_auto_connect_session_dispatch( connect_result: wallhack_core::client::client::ErasedConnectResult, @@ -418,8 +419,8 @@ async fn run_auto_connect_session_dispatch( // applies routes from the table when it creates the TUN, so they // must be in the table before we call it. let peer_name = peer_hs.name.as_str(); - let routes_for_cleanup = routes.as_ref().map(Arc::clone); - let tun_name_for_cleanup = if peer_name.is_empty() { + let routes = routes.as_ref().map(Arc::clone); + let tun_name = if peer_name.is_empty() { None } else { Some(super::entry::peer_name_to_iface(peer_name)) @@ -442,14 +443,14 @@ async fn run_auto_connect_session_dispatch( Some(peer_name), Some(Arc::clone(&peers)), latency_rx, - routes, + routes.clone(), route_updates, ) .await; // Remove auto-managed routes and their OS entries now that the // session has ended. - if let (Some(r), Some(tun)) = (routes_for_cleanup, tun_name_for_cleanup) { + if let (Some(r), Some(tun)) = (routes, tun_name) { let removed = r.remove_auto_by_peer(peer_name); if !removed.is_empty() { tracing::info!( @@ -482,18 +483,21 @@ async fn run_auto_connect_session_dispatch( peer_hs.name }; // Spawn the outgoing data task (send responses to entry peer). - let transport_out = Arc::clone(&transport); - tokio::spawn(async move { - match transport_out.open_uni_erased().await { - Ok(mut send) => { - if let Err(e) = protocol::run_send_responses(&mut send, responses_rx).await - { - tracing::debug!("Auto exit send-responses finished: {e}"); + { + let transport = Arc::clone(&transport); + tokio::spawn(async move { + match transport.open_uni_erased().await { + Ok(mut send) => { + if let Err(e) = + protocol::run_send_responses(&mut send, responses_rx).await + { + tracing::debug!("Auto exit send-responses finished: {e}"); + } } + Err(e) => tracing::debug!("Auto exit failed to open send stream: {e}"), } - Err(e) => tracing::debug!("Auto exit failed to open send stream: {e}"), - } - }); + }); + } drop(tasks); let heartbeat = super::spawn_heartbeat( control_tx, @@ -563,6 +567,7 @@ async fn hold_until_disconnect(mut tasks: wallhack_core::client::client::Connect } /// Non-generic exit session handler for the auto-connector path. +// REASON: threading transport, instructions, responses, heartbeat, role, peer info, metrics, peers #[allow(clippy::too_many_arguments)] async fn run_auto_exit_session_inner( transport: Arc, @@ -614,6 +619,7 @@ async fn run_auto_exit_session_inner( // ============================================================================ /// Auto listener: accept connections, negotiate role, dispatch. +// REASON: threading metrics, peers, routes, route_updates, route_updates_tx, node_state through listener #[allow(clippy::too_many_arguments)] async fn run_auto_listener( global: &GlobalConfig, @@ -665,16 +671,16 @@ async fn run_auto_listener( .map_err(|e| NodeError::Transport(Box::new(e)))?; let bound = server.local_addr()?; node_state.set_listen_addr(bound); - let ru = route_updates.resubscribe(); - let r = Arc::clone(&routes); + let route_updates = route_updates.resubscribe(); + let routes = Arc::clone(&routes); run_auto_accept_loop( server, local_hs, global.psk.clone(), metrics, peers, - r, - ru, + routes, + route_updates, node_state, ) .await @@ -691,16 +697,16 @@ async fn run_auto_listener( )?; let bound = server.local_addr()?; node_state.set_listen_addr(bound); - let ru = route_updates.resubscribe(); - let r = Arc::clone(&routes); + let route_updates = route_updates.resubscribe(); + let routes = Arc::clone(&routes); run_auto_accept_loop( server, local_hs, global.psk.clone(), metrics, peers, - r, - ru, + routes, + route_updates, node_state, ) .await @@ -712,6 +718,7 @@ async fn run_auto_listener( } /// Accept loop for auto-negotiation listener. +// REASON: threading local_hs, psk, metrics, peers, routes, route_updates, node_state through generic accept loop #[allow(clippy::too_many_arguments)] async fn run_auto_accept_loop( mut server: S, @@ -784,8 +791,8 @@ where let metrics = Arc::clone(&metrics); let peers = Arc::clone(&peers); let routes = Arc::clone(&routes); - let ru = route_updates.resubscribe(); - let ns = node_state.clone(); + let route_updates = route_updates.resubscribe(); + let node_state = node_state.clone(); tokio::spawn(async move { if let Err(e) = run_auto_accept_session_inner( @@ -800,9 +807,9 @@ where metrics, peers, Some(routes), - Some(ru), + Some(route_updates), peer_addr, - ns, + node_state, latency_rx, ) .await @@ -828,6 +835,7 @@ where /// /// All generic extraction (transport, channels, handshake) happens in the /// caller before spawning, so this function is monomorphized only once. +// REASON: symmetric entry/exit/relay/indeterminate negotiation arms, each with distinct session setup #[allow(clippy::too_many_arguments, clippy::too_many_lines)] async fn run_auto_accept_session_inner( transport: Arc, @@ -901,6 +909,7 @@ async fn run_auto_accept_session_inner( } // Apply all routes (user-configured and newly-advertised) to the TUN. + // REASON: outer guard is an option, inner guard is a separate semantic check on peer identity #[allow(clippy::collapsible_if)] if let Some(r) = &routes { if !peer_hs.name.is_empty() { @@ -930,6 +939,7 @@ async fn run_auto_accept_session_inner( loop { match updates.recv().await { Ok(wallhack_core::control::routes::RouteUpdate::Add(entry)) => { + // REASON: peer match is a route filter; OS call error is a separate concern #[allow(clippy::collapsible_if)] if Some(entry.peer.as_str()) == peer.as_deref() { if let Err(e) = @@ -939,8 +949,8 @@ async fn run_auto_accept_session_inner( } } } - Ok(wallhack_core::control::routes::RouteUpdate::Remove(entry)) => - { + Ok(wallhack_core::control::routes::RouteUpdate::Remove(entry)) => { + // REASON: peer match is a route filter; OS call error is a separate concern #[allow(clippy::collapsible_if)] if Some(entry.peer.as_str()) == peer.as_deref() { if let Err(e) = crate::netlink::remove_os_route( @@ -1037,34 +1047,40 @@ async fn run_auto_accept_session_inner( node_state.update_role(NodeRole::Exit); // Spawn data tasks for exit: incoming (peer→broadcasts) + outgoing (responses→peer). - let transport_in = Arc::clone(&transport); - let instructions_in = instructions_tx.clone(); - let responses_in = responses_tx.clone(); - tokio::spawn(async move { - match transport_in.accept_uni_erased().await { - Ok(Some(mut recv)) => { - if let Err(e) = - protocol::run_data_in(&mut recv, &instructions_in, &responses_in).await - { - tracing::debug!("Auto exit data-in finished: {e}"); + { + let transport = Arc::clone(&transport); + let instructions_in = instructions_tx.clone(); + let responses_in = responses_tx.clone(); + tokio::spawn(async move { + match transport.accept_uni_erased().await { + Ok(Some(mut recv)) => { + if let Err(e) = + protocol::run_data_in(&mut recv, &instructions_in, &responses_in) + .await + { + tracing::debug!("Auto exit data-in finished: {e}"); + } } + Ok(None) => tracing::debug!("Transport closed before data-in"), + Err(e) => tracing::debug!("Failed to accept data-in stream: {e}"), } - Ok(None) => tracing::debug!("Transport closed before data-in"), - Err(e) => tracing::debug!("Failed to accept data-in stream: {e}"), - } - }); - let transport_out = Arc::clone(&transport); - tokio::spawn(async move { - match transport_out.open_uni_erased().await { - Ok(mut send) => { - if let Err(e) = protocol::run_send_responses(&mut send, responses_rx).await - { - tracing::debug!("Auto exit send-responses finished: {e}"); + }); + } + { + let transport = Arc::clone(&transport); + tokio::spawn(async move { + match transport.open_uni_erased().await { + Ok(mut send) => { + if let Err(e) = + protocol::run_send_responses(&mut send, responses_rx).await + { + tracing::debug!("Auto exit send-responses finished: {e}"); + } } + Err(e) => tracing::debug!("Failed to open send stream: {e}"), } - Err(e) => tracing::debug!("Failed to open send stream: {e}"), - } - }); + }); + } let peer_name = if peer_hs.name.is_empty() { peer_addr.clone() diff --git a/crates/daemon/src/mode/entry.rs b/crates/daemon/src/mode/entry.rs index fa3f38e7..1e2f5212 100644 --- a/crates/daemon/src/mode/entry.rs +++ b/crates/daemon/src/mode/entry.rs @@ -154,6 +154,7 @@ const RECONNECT_DELAY: std::time::Duration = std::time::Duration::from_millis(50 /// # Errors /// /// Returns error if server or connection setup fails. +// REASON: threading metrics, peers, routes, route_updates, route_updates_tx, node_state through mode dispatch #[allow(clippy::too_many_arguments)] pub async fn run( global: &GlobalConfig, @@ -311,6 +312,7 @@ where /// Run entry node in connect mode. /// /// DNS resolve once, then retry loop with exponential backoff. +// REASON: symmetric quic/ws connect arms each with API start, session loop, and route cleanup #[allow(clippy::too_many_lines)] pub(crate) async fn run_entry_connect( global: &GlobalConfig, @@ -365,7 +367,7 @@ pub(crate) async fn run_entry_connect( &security, Some(entry_handshake), ); - let r_updates = res.route_updates.resubscribe(); + let route_updates = res.route_updates.resubscribe(); crate::transport::connect_loop( || { let cfg = client_config.clone(); @@ -376,14 +378,22 @@ pub(crate) async fn run_entry_connect( } }, move |connect_result| { - let e = connect_result.erase(); - let m = Arc::clone(&metrics); - let p = Arc::clone(&peers); - let r = Arc::clone(&routes); - let ru = r_updates.resubscribe(); - let pa = peer_addr.clone(); + let erased = connect_result.erase(); + let metrics = Arc::clone(&metrics); + let peers = Arc::clone(&peers); + let routes = Arc::clone(&routes); + let route_updates = route_updates.resubscribe(); + let peer_addr = peer_addr.clone(); async move { - run_entry_connected_erased(e, &m, &pa, Some(p), Some(r), Some(ru)).await + run_entry_connected_erased( + erased, + &metrics, + &peer_addr, + Some(peers), + Some(routes), + Some(route_updates), + ) + .await } }, RECONNECT_DELAY, @@ -403,7 +413,7 @@ pub(crate) async fn run_entry_connect( &security, Some(entry_handshake), ); - let r_updates = res.route_updates.resubscribe(); + let route_updates = res.route_updates.resubscribe(); crate::transport::connect_loop( || { let cfg = client_config.clone(); @@ -413,14 +423,22 @@ pub(crate) async fn run_entry_connect( } }, move |connect_result| { - let e = connect_result.erase(); - let m = Arc::clone(&metrics); - let p = Arc::clone(&peers); - let r = Arc::clone(&routes); - let ru = r_updates.resubscribe(); - let pa = peer_addr.clone(); + let erased = connect_result.erase(); + let metrics = Arc::clone(&metrics); + let peers = Arc::clone(&peers); + let routes = Arc::clone(&routes); + let route_updates = route_updates.resubscribe(); + let peer_addr = peer_addr.clone(); async move { - run_entry_connected_erased(e, &m, &pa, Some(p), Some(r), Some(ru)).await + run_entry_connected_erased( + erased, + &metrics, + &peer_addr, + Some(peers), + Some(routes), + Some(route_updates), + ) + .await } }, RECONNECT_DELAY, @@ -478,6 +496,7 @@ pub(crate) async fn run_entry_connected_erased( // Wait for the server's handshake to get the peer name let peer_name = if let Some(rx) = peer_handshake_rx { + // REASON: the else arm logs a warning and returns None — collapsing loses the diagnostic #[allow(clippy::single_match_else)] match rx.await { Ok(h) => Some(h.name), @@ -518,6 +537,7 @@ pub(crate) async fn run_entry_connected_erased( } /// Non-generic inner: monomorphized once regardless of transport type. +// REASON: threading transport, channels, control, metrics, peer info, peers, routes, route_updates #[allow(clippy::too_many_arguments, clippy::too_many_lines)] pub(crate) async fn run_entry_connected_inner( transport: Arc, @@ -538,22 +558,24 @@ pub(crate) async fn run_entry_connected_inner( tracing::info!("Connected to {peer_addr}"); // Spawn outgoing data task: open uni stream, send instructions to peer. - let transport_out = Arc::clone(&transport); - tokio::spawn(async move { - match transport_out.open_uni_erased().await { - Ok(mut send) => { - if let Err(e) = wallhack_core::transport::protocol::run_send_instructions( - &mut send, - instructions_rx, - ) - .await - { - tracing::debug!("Send-instructions handler finished: {e}"); + { + let transport = Arc::clone(&transport); + tokio::spawn(async move { + match transport.open_uni_erased().await { + Ok(mut send) => { + if let Err(e) = wallhack_core::transport::protocol::run_send_instructions( + &mut send, + instructions_rx, + ) + .await + { + tracing::debug!("Send-instructions handler finished: {e}"); + } } + Err(e) => tracing::debug!("Failed to open send stream: {e}"), } - Err(e) => tracing::debug!("Failed to open send stream: {e}"), - } - }); + }); + } let name = peer_name .filter(|n| !n.is_empty()) @@ -561,6 +583,7 @@ pub(crate) async fn run_entry_connected_inner( let actor = create_tun_with_retry(name.clone()).await?; // Apply existing routes + // REASON: outer guard is optional routes; inner guard is a distinct peer identity check #[allow(clippy::collapsible_if)] if let Some(r) = &routes { if let Some(pn) = peer_name { @@ -639,6 +662,7 @@ struct EntryListenOptions { } /// Generic entry server loop that works with any `Server` implementation. +// REASON: per-connection setup spans PSK validation, peer registration, transport extraction, and spawn #[allow(clippy::too_many_lines)] async fn run_entry_server( mut server: S, @@ -684,8 +708,8 @@ where let conn_metrics = accept_result.metrics(); let conn_sessions = sessions.clone(); - let conn_peers = Arc::clone(&peers); - let conn_routes = Arc::clone(&routes); + let peers = Arc::clone(&peers); + let routes = Arc::clone(&routes); let peer_route_updates = route_updates.resubscribe(); let peer_addr = accept_result.peer_addr().to_string(); @@ -708,13 +732,13 @@ where let peer_name = identity.name.as_deref().unwrap_or(&peer_addr).to_string(); // Register peer in the registry and apply handshake capabilities. - let (peer_id, connection_id) = conn_peers.register( + let (peer_id, connection_id) = peers.register( peer_name.clone(), peer_addr.clone(), identity.role, wallhack_core::control::peers::ConnectionSide::Accept, ); - conn_peers.update_capabilities(&peer_id, &identity.capabilities); + peers.update_capabilities(&peer_id, &identity.capabilities); // Extract transport and channels from the generic AcceptResult before // spawning so the spawned future is non-generic. @@ -734,8 +758,8 @@ where channels, control_tx, sessions: conn_sessions.clone(), - peers: Arc::clone(&conn_peers), - routes: Arc::clone(&conn_routes), + peers: Arc::clone(&peers), + routes: Arc::clone(&routes), route_updates: peer_route_updates, peer: identity.name, peer_addr: peer_addr.clone(), @@ -744,11 +768,11 @@ where // Unregister peer — connection ID check prevents evicting a // newer connection that re-registered under the same name. - conn_peers.unregister_if_current(&peer_name, connection_id); + peers.unregister_if_current(&peer_name, connection_id); // Clean up routes BEFORE deleting the TUN so that // remove_os_route can still resolve the interface index. - let removed_routes = conn_routes.remove_by_peer(&peer_name); + let removed_routes = routes.remove_by_peer(&peer_name); for entry in &removed_routes { if let Some(tun) = conn_sessions.get_tun_for_peer(&peer_name) { let _ = remove_os_route(&entry.cidr.to_string(), &tun); @@ -914,6 +938,7 @@ pub(crate) fn spawn_data_tasks( /// On exit (normal or error), the manager task is aborted and joined so /// the `ConnectionManager` (and its TUN fd Arcs) are dropped before the /// caller runs `delete_tun`. +// REASON: threading manager_handle, control, latency, route_updates, peer info, peers, tun_name #[allow(clippy::too_many_arguments)] async fn run_connection_loop( mut manager_handle: tokio::task::JoinHandle>, @@ -1082,6 +1107,7 @@ impl ConnectionParams { } #[cfg(feature = "http-api")] +// REASON: threading api_addr, metrics, peers, routes, tls_config, username, secret, version into API state #[allow(clippy::too_many_arguments)] fn start_api( api_addr: std::net::SocketAddr, diff --git a/crates/daemon/src/mode/exit.rs b/crates/daemon/src/mode/exit.rs index ea266e95..931107da 100644 --- a/crates/daemon/src/mode/exit.rs +++ b/crates/daemon/src/mode/exit.rs @@ -125,7 +125,7 @@ async fn run_exit_connector( Some(local_handshake.clone()), ); let ctx = Arc::clone(ctx); - let pa = peer_addr.clone(); + let peer_addr = peer_addr.clone(); crate::transport::connect_loop( || { let cfg = client_config.clone(); @@ -136,20 +136,20 @@ async fn run_exit_connector( } }, |connect_result| { - let e = connect_result.erase(); + let erased = connect_result.erase(); let ctx = Arc::clone(&ctx); - let pa = pa.clone(); + let peer_addr = peer_addr.clone(); async move { run_exit_loop_inner( - e.transport, - e.channels.instructions_rx, - e.channels.responses_tx, - e.channels.responses_rx, - e.control_tx, - e.tasks, - e.peer_handshake_rx, - e.latency_rx, - &pa, + erased.transport, + erased.channels.instructions_rx, + erased.channels.responses_tx, + erased.channels.responses_rx, + erased.control_tx, + erased.tasks, + erased.peer_handshake_rx, + erased.latency_rx, + &peer_addr, &ctx, ) .await @@ -175,7 +175,7 @@ async fn run_exit_connector( Some(local_handshake), ); let ctx = Arc::clone(ctx); - let pa = peer_addr.clone(); + let peer_addr = peer_addr.clone(); crate::transport::connect_loop( || { let cfg = client_config.clone(); @@ -185,20 +185,20 @@ async fn run_exit_connector( } }, |connect_result| { - let e = connect_result.erase(); + let erased = connect_result.erase(); let ctx = Arc::clone(&ctx); - let pa = pa.clone(); + let peer_addr = peer_addr.clone(); async move { run_exit_loop_inner( - e.transport, - e.channels.instructions_rx, - e.channels.responses_tx, - e.channels.responses_rx, - e.control_tx, - e.tasks, - e.peer_handshake_rx, - e.latency_rx, - &pa, + erased.transport, + erased.channels.instructions_rx, + erased.channels.responses_tx, + erased.channels.responses_rx, + erased.control_tx, + erased.tasks, + erased.peer_handshake_rx, + erased.latency_rx, + &peer_addr, &ctx, ) .await @@ -292,6 +292,7 @@ async fn run_exit_listener( } /// Server accept loop for listen-only mode. +// REASON: spawns transport, orchestrator, and stream listener tasks per connection; inherently broad #[allow(clippy::too_many_lines)] async fn run_accept_loop(mut server: S, ctx: &Arc) -> Result<(), NodeError> where @@ -351,33 +352,37 @@ where ) = accept_result.into_channels(); // Incoming: accept uni stream from entry peer, dispatch instructions. - let transport_in = Arc::clone(&transport); - let instr_in = instructions_tx.clone(); - let resp_in = responses_tx.clone(); - tokio::spawn(async move { - match transport_in.accept_uni_erased().await { - Ok(Some(mut recv)) => { - if let Err(e) = run_data_in(&mut recv, &instr_in, &resp_in).await { - tracing::debug!("Data-in handler finished: {e}"); + { + let transport = Arc::clone(&transport); + let instr_in = instructions_tx.clone(); + let resp_in = responses_tx.clone(); + tokio::spawn(async move { + match transport.accept_uni_erased().await { + Ok(Some(mut recv)) => { + if let Err(e) = run_data_in(&mut recv, &instr_in, &resp_in).await { + tracing::debug!("Data-in handler finished: {e}"); + } } + Ok(None) => tracing::debug!("Transport closed before data-in stream"), + Err(e) => tracing::debug!("Failed to accept data-in stream: {e}"), } - Ok(None) => tracing::debug!("Transport closed before data-in stream"), - Err(e) => tracing::debug!("Failed to accept data-in stream: {e}"), - } - }); + }); + } // Outgoing: open uni stream to entry peer, send responses. - let transport_out = Arc::clone(&transport); - tokio::spawn(async move { - match transport_out.open_uni_erased().await { - Ok(mut send) => { - if let Err(e) = run_send_responses(&mut send, responses_rx).await { - tracing::debug!("Send-responses handler finished: {e}"); + { + let transport = Arc::clone(&transport); + tokio::spawn(async move { + match transport.open_uni_erased().await { + Ok(mut send) => { + if let Err(e) = run_send_responses(&mut send, responses_rx).await { + tracing::debug!("Send-responses handler finished: {e}"); + } } + Err(e) => tracing::debug!("Failed to open send stream: {e}"), } - Err(e) => tracing::debug!("Failed to open send stream: {e}"), - } - }); + }); + } let stream_fut = run_stream_listener(transport); let ctx = Arc::clone(ctx); @@ -421,6 +426,7 @@ where } /// Non-generic exit loop: monomorphized once regardless of transport type. +// REASON: threading transport, instructions, responses, control, tasks, handshake, latency, peer_addr, ctx #[allow(clippy::too_many_arguments)] async fn run_exit_loop_inner( transport: Arc, @@ -462,17 +468,19 @@ async fn run_exit_loop_inner( .update_capabilities(&peer_name, &peer_capabilities); // Outgoing: open uni stream to entry peer, send responses. - let transport_out = Arc::clone(&transport); - tokio::spawn(async move { - match transport_out.open_uni_erased().await { - Ok(mut send) => { - if let Err(e) = run_send_responses(&mut send, responses_rx).await { - tracing::debug!("Send-responses handler finished: {e}"); + { + let transport = Arc::clone(&transport); + tokio::spawn(async move { + match transport.open_uni_erased().await { + Ok(mut send) => { + if let Err(e) = run_send_responses(&mut send, responses_rx).await { + tracing::debug!("Send-responses handler finished: {e}"); + } } + Err(e) => tracing::debug!("Failed to open send stream: {e}"), } - Err(e) => tracing::debug!("Failed to open send stream: {e}"), - } - }); + }); + } let adapter = SyscallExitAdapter::new(); let _reaper = adapter.start_reaper( @@ -589,6 +597,7 @@ async fn tcp_connect_with_retry( Err(last_err.unwrap_or_else(|| std::io::Error::other("retry exhausted"))) } +// REASON: symmetric TCP and UDP session protocol arms each with connect, status, and data relay logic #[allow(clippy::too_many_lines)] async fn handle_stream(stream: &mut S) -> Result<(), NodeError> { let header: wallhack_wire::data::TcpStreamHeader = stream diff --git a/crates/daemon/src/mode/relay.rs b/crates/daemon/src/mode/relay.rs index 5716dba7..6c363ba3 100644 --- a/crates/daemon/src/mode/relay.rs +++ b/crates/daemon/src/mode/relay.rs @@ -92,7 +92,8 @@ fn build_server_options(cfg: &RelayConfig, version: &str, metrics: Arc) /// # Errors /// /// Returns error if a non-retryable connection error occurs. -#[allow(clippy::too_many_lines)] // symmetric quic/ws dispatch arms +// REASON: symmetric quic/ws dispatch arms +#[allow(clippy::too_many_lines)] pub async fn run( global: &GlobalConfig, cfg: &RelayConfig, @@ -147,7 +148,7 @@ pub async fn run( ); let listen_spec = cfg.listen.clone(); let global = global.clone(); - let peers_quic = Arc::clone(&peers); + let peers = Arc::clone(&peers); crate::transport::connect_loop( || { let cfg = client_config.clone(); @@ -158,20 +159,20 @@ pub async fn run( } }, |connect_result| { - let e = connect_result.erase(); + let erased = connect_result.erase(); let global = global.clone(); let listen_spec = listen_spec.clone(); let server_options = server_options.clone(); - let peers = Arc::clone(&peers_quic); + let peers = Arc::clone(&peers); async move { run_relay_loop_inner( - e.peer_addr, - e.transport, - e.channels, - e.tasks, - e.control_tx, - e.peer_handshake_rx, - e.latency_rx, + erased.peer_addr, + erased.transport, + erased.channels, + erased.tasks, + erased.control_tx, + erased.peer_handshake_rx, + erased.latency_rx, &global, &listen_spec, addr, @@ -202,7 +203,7 @@ pub async fn run( ); let listen_spec = cfg.listen.clone(); let global = global.clone(); - let peers_ws = Arc::clone(&peers); + let peers = Arc::clone(&peers); crate::transport::connect_loop( || { let cfg = client_config.clone(); @@ -212,20 +213,20 @@ pub async fn run( } }, |connect_result| { - let e = connect_result.erase(); + let erased = connect_result.erase(); let global = global.clone(); let listen_spec = listen_spec.clone(); let server_options = server_options.clone(); - let peers = Arc::clone(&peers_ws); + let peers = Arc::clone(&peers); async move { run_relay_loop_inner( - e.peer_addr, - e.transport, - e.channels, - e.tasks, - e.control_tx, - e.peer_handshake_rx, - e.latency_rx, + erased.peer_addr, + erased.transport, + erased.channels, + erased.tasks, + erased.control_tx, + erased.peer_handshake_rx, + erased.latency_rx, &global, &listen_spec, addr, @@ -252,6 +253,7 @@ pub async fn run( /// Starts the listener, bridges channels, and returns `Ok(())` when the /// source peer disconnects so `connect_loop` reconnects. /// Non-generic relay loop: monomorphized once regardless of transport type. +// REASON: threading source transport, channels, and shutdown signal; body spans source peer setup + listener spawn #[allow(clippy::too_many_arguments, clippy::too_many_lines)] async fn run_relay_loop_inner( peer_addr: String, @@ -308,17 +310,19 @@ async fn run_relay_loop_inner( // Outgoing: open uni stream to source, send exit-peer responses (relay → entry). // The connect() incoming task already handles entry→relay instructions via // source_instr_tx; here we send the collected exit responses back to the entry. - let transport_resp = std::sync::Arc::clone(&transport); - tokio::spawn(async move { - match transport_resp.open_uni_erased().await { - Ok(mut send) => { - if let Err(e) = run_send_responses(&mut send, source_resp_rx).await { - tracing::debug!("Send-responses to source finished: {e}"); + { + let transport = std::sync::Arc::clone(&transport); + tokio::spawn(async move { + match transport.open_uni_erased().await { + Ok(mut send) => { + if let Err(e) = run_send_responses(&mut send, source_resp_rx).await { + tracing::debug!("Send-responses to source finished: {e}"); + } } + Err(e) => tracing::debug!("Failed to open send stream to source: {e}"), } - Err(e) => tracing::debug!("Failed to open send stream to source: {e}"), - } - }); + }); + } // Spawn the instruction fan-out task: reads from source_instr_rx and // forwards each instruction to all connected exit peers. @@ -333,12 +337,12 @@ async fn run_relay_loop_inner( // Source→peer bidi bridge: single accept loop on the source transport. // When a bidi stream arrives from the source, opens a matching bidi to // the current peer and splices them together. - let source_transport_bi = Arc::clone(&transport); + let transport_for_bidi = Arc::clone(&transport); let mut shutdown_bidi = shutdown_rx.clone(); tokio::spawn(async move { loop { tokio::select! { - result = source_transport_bi.accept_bi_erased() => { + result = transport_for_bidi.accept_bi_erased() => { match result { Ok(Some(source_stream)) => { let current_peer = peer_transport_rx.borrow().clone(); @@ -643,29 +647,31 @@ fn handle_relay_connection( // Incoming: accept uni stream from exit peer, dispatch data messages. // Exit peers send ExitNodeResponses which are dispatched via responses_tx. - let peer_transport_uni = std::sync::Arc::clone(&transport); - let instr_tx = instructions_tx.clone(); - let resp_tx = responses_tx.clone(); - tokio::spawn(async move { - match peer_transport_uni.accept_uni_erased().await { - Ok(Some(mut recv)) => { - if let Err(e) = run_data_in(&mut recv, &instr_tx, &resp_tx).await { - tracing::debug!("Relay peer data-in finished: {e}"); + { + let transport = std::sync::Arc::clone(&transport); + let instr_tx = instructions_tx.clone(); + let resp_tx = responses_tx.clone(); + tokio::spawn(async move { + match transport.accept_uni_erased().await { + Ok(Some(mut recv)) => { + if let Err(e) = run_data_in(&mut recv, &instr_tx, &resp_tx).await { + tracing::debug!("Relay peer data-in finished: {e}"); + } } + Ok(None) => tracing::debug!("Relay peer transport closed before data-in"), + Err(e) => tracing::debug!("Relay peer failed to accept data-in: {e}"), } - Ok(None) => tracing::debug!("Relay peer transport closed before data-in"), - Err(e) => tracing::debug!("Relay peer failed to accept data-in: {e}"), - } - }); + }); + } // Outgoing: open uni stream to exit peer, send instructions from the entry. // instructions_rx receives instructions distributed by the fan-out task. - let peer_transport_instr = std::sync::Arc::clone(&transport); { + let transport = std::sync::Arc::clone(&transport); let peer_name = peer_name.clone(); let peers = Arc::clone(peers); tokio::spawn(async move { - match peer_transport_instr.open_uni_erased().await { + match transport.open_uni_erased().await { Ok(mut send) => { if let Err(e) = run_send_instructions(&mut send, instructions_rx).await { tracing::debug!("Relay peer send-instructions finished: {e}"); diff --git a/crates/daemon/src/transport.rs b/crates/daemon/src/transport.rs index 3a64409a..b8fb2d61 100644 --- a/crates/daemon/src/transport.rs +++ b/crates/daemon/src/transport.rs @@ -213,7 +213,6 @@ mod tests { #[tokio::test] async fn connect_loop_reconnects_after_session_ends() { let connect_count = Arc::new(AtomicUsize::new(0)); - let cc = Arc::clone(&connect_count); // connect_loop runs forever, so we timeout after it has reconnected // multiple times. @@ -221,9 +220,9 @@ mod tests { Duration::from_millis(200), connect_loop( || { - let cc = Arc::clone(&cc); + let connect_count = Arc::clone(&connect_count); async move { - cc.fetch_add(1, Ordering::SeqCst); + connect_count.fetch_add(1, Ordering::SeqCst); Ok::<_, std::io::Error>(()) } }, From 5b74bce06551775dddb35ad810925a20e24ba78a Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 15:13:10 +0700 Subject: [PATCH 25/41] feat(wire): PeerAnnouncement control message for relay topology visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relays can now announce accepted peers to their source peer via a new PeerAnnouncement message on the control stream. The entry sees peers behind the relay without direct transport connectivity. Control loop forwards announcements through a new peer_announcement_tx channel — wiring in the daemon modes is the next commit. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/core/src/transport/protocol.rs | 20 ++++++++++++++++++++ crates/wire/proto/control.proto | 16 ++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/crates/core/src/transport/protocol.rs b/crates/core/src/transport/protocol.rs index 9c4e643e..ebecf59c 100644 --- a/crates/core/src/transport/protocol.rs +++ b/crates/core/src/transport/protocol.rs @@ -143,6 +143,8 @@ pub struct ControlChannels { pub control_response_tx: Option>, /// `RoleTransition` forwarding to the mode task for re-evaluation. pub role_transition_tx: Option>, + /// `PeerAnnouncement` forwarding — relays announce accepted peers to the source. + pub peer_announcement_tx: Option>, } impl ControlChannels { @@ -291,6 +293,17 @@ impl ControlChannels { let _ = tx.send(rt).await; } } + Some(control_message::Message::PeerAnnouncement(announcement)) => { + tracing::info!( + "Control: peer announcement: {:?} {} ({})", + announcement.event(), + announcement.name, + announcement.addr, + ); + if let Some(ref tx) = self.peer_announcement_tx { + let _ = tx.send(announcement).await; + } + } None => { tracing::warn!("Control: received empty ControlMessage"); } @@ -671,6 +684,7 @@ mod tests { latency_tx: None, control_response_tx: None, role_transition_tx: None, + peer_announcement_tx: None, }; let mut stream_a = BoxBiStream::new(stream_a); channels @@ -685,6 +699,7 @@ mod tests { latency_tx: None, control_response_tx: None, role_transition_tx: None, + peer_announcement_tx: None, }; let mut stream_b = BoxBiStream::new(stream_b); channels @@ -739,6 +754,7 @@ mod tests { latency_tx: None, control_response_tx: None, role_transition_tx: None, + peer_announcement_tx: None, }; let mut stream_b = BoxBiStream::new(stream_b); @@ -766,6 +782,7 @@ mod tests { latency_tx: Some(latency_tx), control_response_tx: None, role_transition_tx: None, + peer_announcement_tx: None, }; // Spawn the control loop on side B (will read from stream_b). @@ -847,6 +864,7 @@ mod tests { latency_tx: None, control_response_tx: None, role_transition_tx: None, + peer_announcement_tx: None, }; // Control loop with 1-second ping interval. @@ -920,6 +938,7 @@ mod tests { latency_tx: None, control_response_tx: None, role_transition_tx: None, + peer_announcement_tx: None, }; let server_handle = tokio::spawn(async move { @@ -1051,6 +1070,7 @@ mod tests { latency_tx: None, control_response_tx: None, role_transition_tx: None, + peer_announcement_tx: None, }; let server_handle = tokio::spawn(async move { diff --git a/crates/wire/proto/control.proto b/crates/wire/proto/control.proto index 99344c58..d1ff7d9d 100644 --- a/crates/wire/proto/control.proto +++ b/crates/wire/proto/control.proto @@ -114,6 +114,7 @@ message ControlMessage { ControlResponse control_response = 5; Disconnect disconnect = 6; RoleTransition role_transition = 7; + PeerAnnouncement peer_announcement = 8; } } @@ -128,3 +129,18 @@ message Disconnect { message RoleTransition { wallhack.data.NodeRole new_role = 1; } + +// Announces a peer behind this node (relay → source). The source can register +// the peer for visibility and install routes advertised in the handshake. +message PeerAnnouncement { + enum Event { + EVENT_UNSPECIFIED = 0; + CONNECTED = 1; + DISCONNECTED = 2; + } + Event event = 1; + string name = 2; + string addr = 3; + wallhack.data.NodeRole role = 4; + repeated string routes = 5; // CIDR notation, from the announced peer's handshake +} From 72877fe58c96b29fb0eed62955c70ca0cf34f094 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 15:24:49 +0700 Subject: [PATCH 26/41] feat(relay): announce accepted peers to source for topology visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay subscribes to its own peer registry events and forwards PeerAnnouncement messages over the existing control stream to the source peer. The server-side control loop registers announced peers directly in the entry's registry — no new channels or transport needed. Entry nodes now see peers behind relays in their peers list. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/core/src/server/quic/mod.rs | 3 +- crates/core/src/server/ws/mod.rs | 3 +- crates/core/src/transport/protocol.rs | 52 +++++++++++++++++-------- crates/daemon/src/mode/relay.rs | 56 +++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 19 deletions(-) diff --git a/crates/core/src/server/quic/mod.rs b/crates/core/src/server/quic/mod.rs index 263b6209..b1addaa1 100644 --- a/crates/core/src/server/quic/mod.rs +++ b/crates/core/src/server/quic/mod.rs @@ -221,6 +221,7 @@ impl Server for QuicServer { { let metrics = Arc::clone(&metrics); tokio::spawn(async move { + let peer_registry = Arc::clone(&peers_ctrl); let handler = Handler::new( handler_config, metrics, @@ -234,7 +235,7 @@ impl Server for QuicServer { latency_tx: Some(latency_tx), control_response_tx: None, // server doesn't issue ControlRequests role_transition_tx: None, - peer_announcement_tx: None, + peer_registry: Some(peer_registry), }; let mut control_stream = wallhack_transport::erased::BoxBiStream::new(control_stream); diff --git a/crates/core/src/server/ws/mod.rs b/crates/core/src/server/ws/mod.rs index 6277f54a..08b37f60 100644 --- a/crates/core/src/server/ws/mod.rs +++ b/crates/core/src/server/ws/mod.rs @@ -302,6 +302,7 @@ impl Server for WebSocketServer { { let metrics = Arc::clone(&metrics); tokio::spawn(async move { + let peer_registry = Arc::clone(&peers_ctrl); let handler = Handler::new( handler_config, metrics, @@ -315,7 +316,7 @@ impl Server for WebSocketServer { latency_tx: Some(latency_tx), control_response_tx: None, // server doesn't issue ControlRequests role_transition_tx: None, - peer_announcement_tx: None, + peer_registry: Some(peer_registry), }; let mut control_stream = wallhack_transport::erased::BoxBiStream::new(control_stream); diff --git a/crates/core/src/transport/protocol.rs b/crates/core/src/transport/protocol.rs index ebecf59c..1266d14d 100644 --- a/crates/core/src/transport/protocol.rs +++ b/crates/core/src/transport/protocol.rs @@ -143,8 +143,9 @@ pub struct ControlChannels { pub control_response_tx: Option>, /// `RoleTransition` forwarding to the mode task for re-evaluation. pub role_transition_tx: Option>, - /// `PeerAnnouncement` forwarding — relays announce accepted peers to the source. - pub peer_announcement_tx: Option>, + /// Peer registry for handling relay `PeerAnnouncement` messages. + /// Announced peers are registered/unregistered directly in the registry. + pub peer_registry: Option>, } impl ControlChannels { @@ -294,14 +295,31 @@ impl ControlChannels { } } Some(control_message::Message::PeerAnnouncement(announcement)) => { - tracing::info!( - "Control: peer announcement: {:?} {} ({})", - announcement.event(), - announcement.name, - announcement.addr, - ); - if let Some(ref tx) = self.peer_announcement_tx { - let _ = tx.send(announcement).await; + use wallhack_wire::control::peer_announcement; + if let Some(ref registry) = self.peer_registry { + match announcement.event() { + peer_announcement::Event::Connected => { + let role = wallhack_wire::data::NodeRole::try_from(announcement.role) + .unwrap_or_default() + .into(); + tracing::info!( + "Peer announced: {} ({}) role={role:?}", + announcement.name, + announcement.addr, + ); + registry.register( + announcement.name, + announcement.addr, + role, + crate::control::peers::ConnectionSide::Accept, + ); + } + peer_announcement::Event::Disconnected => { + tracing::info!("Peer departed: {}", announcement.name); + registry.unregister(&announcement.name); + } + _ => {} + } } } None => { @@ -684,7 +702,7 @@ mod tests { latency_tx: None, control_response_tx: None, role_transition_tx: None, - peer_announcement_tx: None, + peer_registry: None, }; let mut stream_a = BoxBiStream::new(stream_a); channels @@ -699,7 +717,7 @@ mod tests { latency_tx: None, control_response_tx: None, role_transition_tx: None, - peer_announcement_tx: None, + peer_registry: None, }; let mut stream_b = BoxBiStream::new(stream_b); channels @@ -754,7 +772,7 @@ mod tests { latency_tx: None, control_response_tx: None, role_transition_tx: None, - peer_announcement_tx: None, + peer_registry: None, }; let mut stream_b = BoxBiStream::new(stream_b); @@ -782,7 +800,7 @@ mod tests { latency_tx: Some(latency_tx), control_response_tx: None, role_transition_tx: None, - peer_announcement_tx: None, + peer_registry: None, }; // Spawn the control loop on side B (will read from stream_b). @@ -864,7 +882,7 @@ mod tests { latency_tx: None, control_response_tx: None, role_transition_tx: None, - peer_announcement_tx: None, + peer_registry: None, }; // Control loop with 1-second ping interval. @@ -938,7 +956,7 @@ mod tests { latency_tx: None, control_response_tx: None, role_transition_tx: None, - peer_announcement_tx: None, + peer_registry: None, }; let server_handle = tokio::spawn(async move { @@ -1070,7 +1088,7 @@ mod tests { latency_tx: None, control_response_tx: None, role_transition_tx: None, - peer_announcement_tx: None, + peer_registry: None, }; let server_handle = tokio::spawn(async move { diff --git a/crates/daemon/src/mode/relay.rs b/crates/daemon/src/mode/relay.rs index 6c363ba3..7dbf67dd 100644 --- a/crates/daemon/src/mode/relay.rs +++ b/crates/daemon/src/mode/relay.rs @@ -286,6 +286,9 @@ async fn run_relay_loop_inner( }; let (peer_name, peer_role) = resolve_peer(peer_handshake.as_ref(), &peer_addr); + // Clone before heartbeat takes ownership — used for peer announcements. + let announce_tx = source_control_tx.clone(); + peers.register( peer_name.clone(), peer_addr.clone(), @@ -375,6 +378,59 @@ async fn run_relay_loop_inner( } }); + // Forward accepted peer events to the source peer as PeerAnnouncements. + // The source (entry) registers these peers for topology visibility. + { + let mut peer_events = peers.subscribe(); + let source_peer_name = peer_name.clone(); + tokio::spawn(async move { + use wallhack_core::control::peers::PeerEvent; + use wallhack_wire::control::{ + ControlMessage, PeerAnnouncement, control_message, peer_announcement, + }; + + loop { + match peer_events.recv().await { + Ok(PeerEvent::Connected { name, addr, role }) if name != source_peer_name => { + let announcement = PeerAnnouncement { + event: peer_announcement::Event::Connected.into(), + name, + addr, + role: wallhack_wire::data::NodeRole::from(role).into(), + routes: Vec::new(), + }; + let msg = ControlMessage { + message: Some(control_message::Message::PeerAnnouncement(announcement)), + }; + if announce_tx.send(msg).await.is_err() { + break; + } + } + Ok(PeerEvent::Disconnected { name }) if name != source_peer_name => { + let announcement = PeerAnnouncement { + event: peer_announcement::Event::Disconnected.into(), + name, + addr: String::new(), + role: 0, + routes: Vec::new(), + }; + let msg = ControlMessage { + message: Some(control_message::Message::PeerAnnouncement(announcement)), + }; + if announce_tx.send(msg).await.is_err() { + break; + } + } + Ok(_) => {} // skip source peer events + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + tracing::debug!("Peer announcement forwarder lagged {n} events"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + }); + } + let listener_fut = run_listener( global, listen_spec, From 5d58e5b0f4ae22e7dfb5ac3e5532bcdc7f956976 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 15:29:51 +0700 Subject: [PATCH 27/41] =?UTF-8?q?style:=20remove=20=5Fctrl=20suffixes=20?= =?UTF-8?q?=E2=80=94=20shadow=20original=20names=20per=20naming=20standard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/core/src/client/quic/mod.rs | 72 ++++++++++++++++-------------- crates/core/src/client/ws/mod.rs | 72 ++++++++++++++++-------------- crates/core/src/server/quic/mod.rs | 14 ++---- crates/core/src/server/ws/mod.rs | 14 ++---- 4 files changed, 84 insertions(+), 88 deletions(-) diff --git a/crates/core/src/client/quic/mod.rs b/crates/core/src/client/quic/mod.rs index 9f468072..793dd19f 100644 --- a/crates/core/src/client/quic/mod.rs +++ b/crates/core/src/client/quic/mod.rs @@ -173,49 +173,53 @@ impl Client for QuicClient { let (latency_tx, latency_rx) = tokio::sync::mpsc::channel::(4); // Spawn control stream task - let transport_ctrl = Arc::clone(&transport); - let control_handle = tokio::spawn(async move { - let mut channels = protocol::ControlChannels { - outgoing_rx: control_rx, - handshake_tx: Some(handshake_tx), // receive server's Handshake - latency_tx: Some(latency_tx), - control_response_tx: None, - role_transition_tx: None, - peer_announcement_tx: None, - }; - match protocol::run_control_stream_initiator( - &*transport_ctrl, - &mut channels, - None, // client doesn't handle ControlRequests - std::time::Duration::from_secs(30), - ) - .await - { - Ok(exit) => tracing::debug!("Control stream finished: {exit:?}"), - Err(e) => tracing::debug!("Control stream error: {e}"), - } - }); + let control_handle = { + let transport = Arc::clone(&transport); + tokio::spawn(async move { + let mut channels = protocol::ControlChannels { + outgoing_rx: control_rx, + handshake_tx: Some(handshake_tx), + latency_tx: Some(latency_tx), + control_response_tx: None, + role_transition_tx: None, + peer_registry: None, + }; + match protocol::run_control_stream_initiator( + &*transport, + &mut channels, + None, // client doesn't handle ControlRequests + std::time::Duration::from_secs(30), + ) + .await + { + Ok(exit) => tracing::debug!("Control stream finished: {exit:?}"), + Err(e) => tracing::debug!("Control stream error: {e}"), + } + }) + }; let channels = DataChannels::new(); // Incoming data task: accept uni stream from peer, dispatch messages. - let transport_data = Arc::clone(&transport); let instructions_in = channels.instructions_tx.clone(); let responses_in = channels.responses_tx.clone(); - let incoming_handle = tokio::spawn(async move { - match transport_data.accept_uni().await { - Ok(Some(mut recv)) => { - if let Err(e) = - protocol::run_data_in(&mut recv, &instructions_in, &responses_in).await - { - tracing::debug!("Data-in handler finished: {e}"); + let incoming_handle = { + let transport = Arc::clone(&transport); + tokio::spawn(async move { + match transport.accept_uni().await { + Ok(Some(mut recv)) => { + if let Err(e) = + protocol::run_data_in(&mut recv, &instructions_in, &responses_in).await + { + tracing::debug!("Data-in handler finished: {e}"); + } } + Ok(None) => tracing::debug!("Transport closed before data-in stream accepted"), + Err(e) => tracing::debug!("Failed to accept data-in stream: {e}"), } - Ok(None) => tracing::debug!("Transport closed before data-in stream accepted"), - Err(e) => tracing::debug!("Failed to accept data-in stream: {e}"), - } - }); + }) + }; // Outgoing data task is NOT spawned here; the caller opens the uni stream // and drives run_send_instructions / run_send_responses as appropriate for diff --git a/crates/core/src/client/ws/mod.rs b/crates/core/src/client/ws/mod.rs index 669c5b1c..de007f72 100644 --- a/crates/core/src/client/ws/mod.rs +++ b/crates/core/src/client/ws/mod.rs @@ -390,49 +390,53 @@ impl WsClient { let (latency_tx, latency_rx) = tokio::sync::mpsc::channel::(4); // Spawn control stream task - let transport_ctrl = Arc::clone(&transport); - let control_handle = tokio::spawn(async move { - let mut channels = protocol::ControlChannels { - outgoing_rx: control_rx, - handshake_tx: Some(handshake_tx), // receive server's Handshake - latency_tx: Some(latency_tx), - control_response_tx: None, - role_transition_tx: None, - peer_announcement_tx: None, - }; - match protocol::run_control_stream_initiator( - &*transport_ctrl, - &mut channels, - None, // client doesn't handle ControlRequests - std::time::Duration::from_secs(30), - ) - .await - { - Ok(exit) => tracing::debug!("Control stream finished: {exit:?}"), - Err(e) => tracing::debug!("Control stream error: {e}"), - } - }); + let control_handle = { + let transport = Arc::clone(&transport); + tokio::spawn(async move { + let mut channels = protocol::ControlChannels { + outgoing_rx: control_rx, + handshake_tx: Some(handshake_tx), // receive server's Handshake + latency_tx: Some(latency_tx), + control_response_tx: None, + role_transition_tx: None, + peer_registry: None, + }; + match protocol::run_control_stream_initiator( + &*transport, + &mut channels, + None, // client doesn't handle ControlRequests + std::time::Duration::from_secs(30), + ) + .await + { + Ok(exit) => tracing::debug!("Control stream finished: {exit:?}"), + Err(e) => tracing::debug!("Control stream error: {e}"), + } + }) + }; let channels = DataChannels::new(); // Incoming data task: accept uni stream from peer, dispatch messages. - let transport_data = Arc::clone(&transport); let instructions_in = channels.instructions_tx.clone(); let responses_in = channels.responses_tx.clone(); - let incoming_handle = tokio::spawn(async move { - match transport_data.accept_uni().await { - Ok(Some(mut recv)) => { - if let Err(e) = - protocol::run_data_in(&mut recv, &instructions_in, &responses_in).await - { - tracing::debug!("Data-in handler finished: {e}"); + let incoming_handle = { + let transport = Arc::clone(&transport); + tokio::spawn(async move { + match transport.accept_uni().await { + Ok(Some(mut recv)) => { + if let Err(e) = + protocol::run_data_in(&mut recv, &instructions_in, &responses_in).await + { + tracing::debug!("Data-in handler finished: {e}"); + } } + Ok(None) => tracing::debug!("Transport closed before data-in stream accepted"), + Err(e) => tracing::debug!("Failed to accept data-in stream: {e}"), } - Ok(None) => tracing::debug!("Transport closed before data-in stream accepted"), - Err(e) => tracing::debug!("Failed to accept data-in stream: {e}"), - } - }); + }) + }; // Outgoing data task is NOT spawned here; the caller opens the uni stream // and drives run_send_instructions / run_send_responses as appropriate for diff --git a/crates/core/src/server/quic/mod.rs b/crates/core/src/server/quic/mod.rs index b1addaa1..0dde26b0 100644 --- a/crates/core/src/server/quic/mod.rs +++ b/crates/core/src/server/quic/mod.rs @@ -199,12 +199,12 @@ impl Server for QuicServer { // Spawn control stream task with handler let handler_config = self.options.handler_config.clone(); - let peers_ctrl = self + let peers = self .options .peers .clone() .unwrap_or_else(|| Arc::new(Registry::new())); - let routes_ctrl = self + let routes = self .options .routes .clone() @@ -220,15 +220,9 @@ impl Server for QuicServer { { let metrics = Arc::clone(&metrics); + let peer_registry = Arc::clone(&peers); tokio::spawn(async move { - let peer_registry = Arc::clone(&peers_ctrl); - let handler = Handler::new( - handler_config, - metrics, - peers_ctrl, - routes_ctrl, - route_updates, - ); + let handler = Handler::new(handler_config, metrics, peers, routes, route_updates); let mut channels = protocol::ControlChannels { outgoing_rx: control_rx, handshake_tx: None, // Handshake already read above diff --git a/crates/core/src/server/ws/mod.rs b/crates/core/src/server/ws/mod.rs index 08b37f60..290a8307 100644 --- a/crates/core/src/server/ws/mod.rs +++ b/crates/core/src/server/ws/mod.rs @@ -280,12 +280,12 @@ impl Server for WebSocketServer { // Spawn control stream task with handler let handler_config = self.options.handler_config.clone(); - let peers_ctrl = self + let peers = self .options .peers .clone() .unwrap_or_else(|| Arc::new(Registry::new())); - let routes_ctrl = self + let routes = self .options .routes .clone() @@ -301,15 +301,9 @@ impl Server for WebSocketServer { { let metrics = Arc::clone(&metrics); + let peer_registry = Arc::clone(&peers); tokio::spawn(async move { - let peer_registry = Arc::clone(&peers_ctrl); - let handler = Handler::new( - handler_config, - metrics, - peers_ctrl, - routes_ctrl, - route_updates, - ); + let handler = Handler::new(handler_config, metrics, peers, routes, route_updates); let mut channels = protocol::ControlChannels { outgoing_rx: control_rx, handshake_tx: None, // Handshake already read above From 552ff23a7b45b55394de0d58055d49433e245738 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 15:34:38 +0700 Subject: [PATCH 28/41] style: shadow remaining non-shadowed clone bindings Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/core/src/client/quic/mod.rs | 7 ++- crates/core/src/client/ws/mod.rs | 7 ++- crates/daemon/src/mode/auto.rs | 6 +-- crates/daemon/src/mode/entry.rs | 68 +++++++++++++++------------- crates/daemon/src/mode/exit.rs | 8 ++-- crates/daemon/src/mode/relay.rs | 73 +++++++++++++++--------------- 6 files changed, 87 insertions(+), 82 deletions(-) diff --git a/crates/core/src/client/quic/mod.rs b/crates/core/src/client/quic/mod.rs index 793dd19f..65daa74d 100644 --- a/crates/core/src/client/quic/mod.rs +++ b/crates/core/src/client/quic/mod.rs @@ -201,16 +201,15 @@ impl Client for QuicClient { let channels = DataChannels::new(); // Incoming data task: accept uni stream from peer, dispatch messages. - let instructions_in = channels.instructions_tx.clone(); - let responses_in = channels.responses_tx.clone(); - let incoming_handle = { let transport = Arc::clone(&transport); + let instructions_tx = channels.instructions_tx.clone(); + let responses_tx = channels.responses_tx.clone(); tokio::spawn(async move { match transport.accept_uni().await { Ok(Some(mut recv)) => { if let Err(e) = - protocol::run_data_in(&mut recv, &instructions_in, &responses_in).await + protocol::run_data_in(&mut recv, &instructions_tx, &responses_tx).await { tracing::debug!("Data-in handler finished: {e}"); } diff --git a/crates/core/src/client/ws/mod.rs b/crates/core/src/client/ws/mod.rs index de007f72..630adb95 100644 --- a/crates/core/src/client/ws/mod.rs +++ b/crates/core/src/client/ws/mod.rs @@ -418,16 +418,15 @@ impl WsClient { let channels = DataChannels::new(); // Incoming data task: accept uni stream from peer, dispatch messages. - let instructions_in = channels.instructions_tx.clone(); - let responses_in = channels.responses_tx.clone(); - let incoming_handle = { let transport = Arc::clone(&transport); + let instructions_tx = channels.instructions_tx.clone(); + let responses_tx = channels.responses_tx.clone(); tokio::spawn(async move { match transport.accept_uni().await { Ok(Some(mut recv)) => { if let Err(e) = - protocol::run_data_in(&mut recv, &instructions_in, &responses_in).await + protocol::run_data_in(&mut recv, &instructions_tx, &responses_tx).await { tracing::debug!("Data-in handler finished: {e}"); } diff --git a/crates/daemon/src/mode/auto.rs b/crates/daemon/src/mode/auto.rs index e86af180..923c9711 100644 --- a/crates/daemon/src/mode/auto.rs +++ b/crates/daemon/src/mode/auto.rs @@ -1049,13 +1049,13 @@ async fn run_auto_accept_session_inner( // Spawn data tasks for exit: incoming (peer→broadcasts) + outgoing (responses→peer). { let transport = Arc::clone(&transport); - let instructions_in = instructions_tx.clone(); - let responses_in = responses_tx.clone(); + let instructions_tx = instructions_tx.clone(); + let responses_tx = responses_tx.clone(); tokio::spawn(async move { match transport.accept_uni_erased().await { Ok(Some(mut recv)) => { if let Err(e) = - protocol::run_data_in(&mut recv, &instructions_in, &responses_in) + protocol::run_data_in(&mut recv, &instructions_tx, &responses_tx) .await { tracing::debug!("Auto exit data-in finished: {e}"); diff --git a/crates/daemon/src/mode/entry.rs b/crates/daemon/src/mode/entry.rs index 1e2f5212..e97fc984 100644 --- a/crates/daemon/src/mode/entry.rs +++ b/crates/daemon/src/mode/entry.rs @@ -893,44 +893,48 @@ pub(crate) fn spawn_data_tasks( instructions_rx: tokio::sync::mpsc::Receiver, ) { // Incoming data: accept uni stream from exit peer, dispatch data messages. - let transport_data = Arc::clone(transport); - let instructions_in = instructions_tx.clone(); - let responses_in = responses_tx.clone(); - tokio::spawn(async move { - match transport_data.accept_uni_erased().await { - Ok(Some(mut recv)) => { - if let Err(e) = wallhack_core::transport::protocol::run_data_in( - &mut recv, - &instructions_in, - &responses_in, - ) - .await - { - tracing::debug!("Data-in handler finished: {e}"); + { + let transport = Arc::clone(transport); + let instructions_tx = instructions_tx.clone(); + let responses_tx = responses_tx.clone(); + tokio::spawn(async move { + match transport.accept_uni_erased().await { + Ok(Some(mut recv)) => { + if let Err(e) = wallhack_core::transport::protocol::run_data_in( + &mut recv, + &instructions_tx, + &responses_tx, + ) + .await + { + tracing::debug!("Data-in handler finished: {e}"); + } } + Ok(None) => tracing::debug!("Transport closed before data-in stream accepted"), + Err(e) => tracing::debug!("Failed to accept data-in stream: {e}"), } - Ok(None) => tracing::debug!("Transport closed before data-in stream accepted"), - Err(e) => tracing::debug!("Failed to accept data-in stream: {e}"), - } - }); + }); + } // Outgoing data: open uni stream to exit peer, write instructions. - let transport_out = Arc::clone(transport); - tokio::spawn(async move { - match transport_out.open_uni_erased().await { - Ok(mut send) => { - if let Err(e) = wallhack_core::transport::protocol::run_send_instructions( - &mut send, - instructions_rx, - ) - .await - { - tracing::debug!("Send-instructions handler finished: {e}"); + { + let transport = Arc::clone(transport); + tokio::spawn(async move { + match transport.open_uni_erased().await { + Ok(mut send) => { + if let Err(e) = wallhack_core::transport::protocol::run_send_instructions( + &mut send, + instructions_rx, + ) + .await + { + tracing::debug!("Send-instructions handler finished: {e}"); + } } + Err(e) => tracing::debug!("Failed to open send stream: {e}"), } - Err(e) => tracing::debug!("Failed to open send stream: {e}"), - } - }); + }); + } } /// Run the connection manager alongside ping/latency handling. diff --git a/crates/daemon/src/mode/exit.rs b/crates/daemon/src/mode/exit.rs index 931107da..d2f94222 100644 --- a/crates/daemon/src/mode/exit.rs +++ b/crates/daemon/src/mode/exit.rs @@ -354,12 +354,14 @@ where // Incoming: accept uni stream from entry peer, dispatch instructions. { let transport = Arc::clone(&transport); - let instr_in = instructions_tx.clone(); - let resp_in = responses_tx.clone(); + let instructions_tx = instructions_tx.clone(); + let responses_tx = responses_tx.clone(); tokio::spawn(async move { match transport.accept_uni_erased().await { Ok(Some(mut recv)) => { - if let Err(e) = run_data_in(&mut recv, &instr_in, &resp_in).await { + if let Err(e) = + run_data_in(&mut recv, &instructions_tx, &responses_tx).await + { tracing::debug!("Data-in handler finished: {e}"); } } diff --git a/crates/daemon/src/mode/relay.rs b/crates/daemon/src/mode/relay.rs index 7dbf67dd..1ded1b40 100644 --- a/crates/daemon/src/mode/relay.rs +++ b/crates/daemon/src/mode/relay.rs @@ -340,43 +340,45 @@ async fn run_relay_loop_inner( // Source→peer bidi bridge: single accept loop on the source transport. // When a bidi stream arrives from the source, opens a matching bidi to // the current peer and splices them together. - let transport_for_bidi = Arc::clone(&transport); - let mut shutdown_bidi = shutdown_rx.clone(); - tokio::spawn(async move { - loop { - tokio::select! { - result = transport_for_bidi.accept_bi_erased() => { - match result { - Ok(Some(source_stream)) => { - let current_peer = peer_transport_rx.borrow().clone(); - let Some(peer) = current_peer else { - tracing::debug!("bidi bridge: no peer connected, dropping stream"); - continue; - }; - tokio::spawn(async move { - match peer.open_bi_erased().await { - Ok(peer_stream) => { - if let Err(e) = wallhack_core::transport::splice_bi( - source_stream, - peer_stream, - ).await { - tracing::debug!("bidi bridge (source→peer) ended: {e}"); + { + let transport = Arc::clone(&transport); + let mut shutdown_bidi = shutdown_rx.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + result = transport.accept_bi_erased() => { + match result { + Ok(Some(source_stream)) => { + let current_peer = peer_transport_rx.borrow().clone(); + let Some(peer) = current_peer else { + tracing::debug!("bidi bridge: no peer connected, dropping stream"); + continue; + }; + tokio::spawn(async move { + match peer.open_bi_erased().await { + Ok(peer_stream) => { + if let Err(e) = wallhack_core::transport::splice_bi( + source_stream, + peer_stream, + ).await { + tracing::debug!("bidi bridge (source→peer) ended: {e}"); + } } + Err(e) => tracing::debug!("bidi bridge: failed to open peer stream: {e}"), } - Err(e) => tracing::debug!("bidi bridge: failed to open peer stream: {e}"), - } - }); - } - Ok(None) => break, - Err(e) => { - tracing::debug!("bidi bridge: source accept_bi error: {e}"); + }); + } + Ok(None) => break, + Err(e) => { + tracing::debug!("bidi bridge: source accept_bi error: {e}"); + } } } + _ = shutdown_bidi.changed() => break, } - _ = shutdown_bidi.changed() => break, } - } - }); + }); + } // Forward accepted peer events to the source peer as PeerAnnouncements. // The source (entry) registers these peers for topology visibility. @@ -745,18 +747,17 @@ fn handle_relay_connection( let _ = peer_transport_tx.send(Some(Arc::clone(&transport))); // Peer→source bidi bridge: accept bidi from this peer, open bidi to source, splice. - let peer_transport_bidi = transport; - let source_transport_bidi = Arc::clone(source_transport); + let source_transport = Arc::clone(source_transport); let mut shutdown_bidi = shutdown.clone(); tokio::spawn(async move { loop { tokio::select! { - result = peer_transport_bidi.accept_bi_erased() => { + result = transport.accept_bi_erased() => { match result { Ok(Some(peer_stream)) => { - let source = Arc::clone(&source_transport_bidi); + let source_transport = Arc::clone(&source_transport); tokio::spawn(async move { - match source.open_bi_erased().await { + match source_transport.open_bi_erased().await { Ok(source_stream) => { if let Err(e) = wallhack_core::transport::splice_bi( peer_stream, From 4644084d8347c618362c2babe2b3c6228aa25aad Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 17:46:54 +0700 Subject: [PATCH 29/41] fix(relay): register accepted peers as Exit instead of capability-derived role The relay's accept-side handshake forces all accepted peers to Exit via Fixed(Entry) hint. The peer registry and announcement must reflect the negotiated role, not the raw capability guess (which incorrectly shows TUN-capable peers as Entry). Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/daemon/src/mode/relay.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/daemon/src/mode/relay.rs b/crates/daemon/src/mode/relay.rs index 1ded1b40..64bc7cb5 100644 --- a/crates/daemon/src/mode/relay.rs +++ b/crates/daemon/src/mode/relay.rs @@ -686,12 +686,12 @@ fn handle_relay_connection( responses_rx, } = channels; - let (peer_name, peer_role) = resolve_peer(peer_handshake.as_ref(), &peer_addr); + let (peer_name, _) = resolve_peer(peer_handshake.as_ref(), &peer_addr); peers.register( peer_name.clone(), peer_addr, - peer_role, + NodeRole::Exit, // relay's Fixed(Entry) hint forces accepted peers to Exit ConnectionSide::Accept, ); From 4c6c35045f83887d3602a7650ac34d93518df369 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 17:56:42 +0700 Subject: [PATCH 30/41] fix(relay): detect accepted peer disconnect via transport liveness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The send-instructions task never exited because the fan-out channel stayed alive — peers were never unregistered. Move cleanup to the data-in task which exits when the peer's transport dies. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/daemon/src/mode/relay.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/daemon/src/mode/relay.rs b/crates/daemon/src/mode/relay.rs index 64bc7cb5..62c68881 100644 --- a/crates/daemon/src/mode/relay.rs +++ b/crates/daemon/src/mode/relay.rs @@ -705,10 +705,15 @@ fn handle_relay_connection( // Incoming: accept uni stream from exit peer, dispatch data messages. // Exit peers send ExitNodeResponses which are dispatched via responses_tx. + // This task exits when the peer's transport dies, making it the canonical + // place to unregister — the send-instructions task never exits on its own + // because the fan-out channel outlives the peer's transport. { let transport = std::sync::Arc::clone(&transport); let instr_tx = instructions_tx.clone(); let resp_tx = responses_tx.clone(); + let peer_name = peer_name.clone(); + let peers = Arc::clone(peers); tokio::spawn(async move { match transport.accept_uni_erased().await { Ok(Some(mut recv)) => { @@ -719,6 +724,9 @@ fn handle_relay_connection( Ok(None) => tracing::debug!("Relay peer transport closed before data-in"), Err(e) => tracing::debug!("Relay peer failed to accept data-in: {e}"), } + // Peer transport is dead — unregister so the registry stays clean + // and PeerEvent::Disconnected fires to the source peer. + peers.unregister(&peer_name); }); } @@ -726,8 +734,6 @@ fn handle_relay_connection( // instructions_rx receives instructions distributed by the fan-out task. { let transport = std::sync::Arc::clone(&transport); - let peer_name = peer_name.clone(); - let peers = Arc::clone(peers); tokio::spawn(async move { match transport.open_uni_erased().await { Ok(mut send) => { @@ -737,7 +743,9 @@ fn handle_relay_connection( } Err(e) => tracing::debug!("Relay peer failed to open send stream: {e}"), } - peers.unregister(&peer_name); + // Unregistration is handled by the data-in task above, which exits + // when the transport dies. This task does not unregister because + // the fan-out channel keeps instructions_rx alive past peer death. }); } From 644beb78c68f8ce5f24ad7edf43dee057b9517d9 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 17:59:47 +0700 Subject: [PATCH 31/41] fix(relay): detect accepted peer disconnect and consistent log wording Move peer unregistration from the dead send-instructions path to the data-in task which actually exits when the transport dies. Rename "Peer departed" log to "Peer disconnected (via relay)" for consistency. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/core/src/transport/protocol.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/core/src/transport/protocol.rs b/crates/core/src/transport/protocol.rs index 1266d14d..d8ffdfb9 100644 --- a/crates/core/src/transport/protocol.rs +++ b/crates/core/src/transport/protocol.rs @@ -315,7 +315,7 @@ impl ControlChannels { ); } peer_announcement::Event::Disconnected => { - tracing::info!("Peer departed: {}", announcement.name); + tracing::info!("Peer disconnected (via relay): {}", announcement.name); registry.unregister(&announcement.name); } _ => {} From 87ec1fbfddf6898351ea721436e3a2275f96c383 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 18:03:47 +0700 Subject: [PATCH 32/41] style: direction-neutral peer announcement log wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "connected with" / "disconnected from" instead of "Peer announced" / "Peer disconnected" — the receiving node didn't initiate the connection. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/core/src/transport/protocol.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/core/src/transport/protocol.rs b/crates/core/src/transport/protocol.rs index d8ffdfb9..4137c2ec 100644 --- a/crates/core/src/transport/protocol.rs +++ b/crates/core/src/transport/protocol.rs @@ -303,7 +303,7 @@ impl ControlChannels { .unwrap_or_default() .into(); tracing::info!( - "Peer announced: {} ({}) role={role:?}", + "connected with {} ({}) via relay, role={role:?}", announcement.name, announcement.addr, ); @@ -315,7 +315,11 @@ impl ControlChannels { ); } peer_announcement::Event::Disconnected => { - tracing::info!("Peer disconnected (via relay): {}", announcement.name); + tracing::info!( + "disconnected from {} ({}) via relay", + announcement.name, + announcement.addr, + ); registry.unregister(&announcement.name); } _ => {} From e3d79c333f6df17e444eab81510864efa1d135d6 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 18:33:15 +0700 Subject: [PATCH 33/41] fix(api): return 501 for unimplemented peer ping instead of misleading 404 Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/api/src/handlers.rs | 9 +++++++-- website/src/data/openapi.json | 3 ++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/api/src/handlers.rs b/crates/api/src/handlers.rs index e3f2e111..c6181400 100644 --- a/crates/api/src/handlers.rs +++ b/crates/api/src/handlers.rs @@ -666,8 +666,13 @@ pub async fn ping_peer( })) } Some(management_response::Response::Error(e)) => { - tracing::warn!("Ping peer failed: {}", e.message); - Err(StatusCode::NOT_FOUND) + let not_supported: i32 = wallhack_wire::management::ErrorCode::NotSupported.into(); + if e.code == not_supported { + Err(StatusCode::NOT_IMPLEMENTED) + } else { + tracing::warn!("Ping peer failed: {}", e.message); + Err(StatusCode::NOT_FOUND) + } } _ => Err(StatusCode::INTERNAL_SERVER_ERROR), } diff --git a/website/src/data/openapi.json b/website/src/data/openapi.json index 077657eb..cd0f723c 100644 --- a/website/src/data/openapi.json +++ b/website/src/data/openapi.json @@ -628,7 +628,8 @@ } }, "401": { "description": "Unauthorized." }, - "404": { "description": "Peer not found." } + "404": { "description": "Peer not found." }, + "501": { "description": "Peer ping not yet implemented." } } } }, From 77adf0afc13e9e682a70e9736a4f5e058e0f98c1 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 18:35:00 +0700 Subject: [PATCH 34/41] fix(ipc): skip empty env vars in socket path fallback chain Empty HOME/USER/XDG_RUNTIME_DIR (common in minimal VMs) produced paths like /.wallhack/ instead of falling through to /tmp/wallhack-shared/. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/core/src/ipc.rs | 7 ++++--- crates/ipc/src/client.rs | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/core/src/ipc.rs b/crates/core/src/ipc.rs index 6d033c7c..d4aeac05 100644 --- a/crates/core/src/ipc.rs +++ b/crates/core/src/ipc.rs @@ -61,11 +61,12 @@ pub fn socket_path(name: Option<&str>) -> PathBuf { Some(n) => format!("wallhackd-{n}.sock"), None => SOCKET_NAME.to_string(), }; - if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { + let non_empty = |key| std::env::var(key).ok().filter(|v| !v.is_empty()); + if let Some(runtime_dir) = non_empty("XDG_RUNTIME_DIR") { Path::new(&runtime_dir).join("wallhack").join(&filename) - } else if let Ok(user) = std::env::var("USER") { + } else if let Some(user) = non_empty("USER") { PathBuf::from(format!("/tmp/wallhack-{user}")).join(&filename) - } else if let Ok(home) = std::env::var("HOME") { + } else if let Some(home) = non_empty("HOME") { Path::new(&home).join(".wallhack").join(&filename) } else { PathBuf::from("/tmp/wallhack-shared").join(&filename) diff --git a/crates/ipc/src/client.rs b/crates/ipc/src/client.rs index ddebca2f..5e304096 100644 --- a/crates/ipc/src/client.rs +++ b/crates/ipc/src/client.rs @@ -114,11 +114,12 @@ pub fn socket_path(name: Option<&str>) -> PathBuf { Some(n) => format!("wallhackd-{n}.sock"), None => SOCKET_NAME.to_string(), }; - if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { + let non_empty = |key| std::env::var(key).ok().filter(|v| !v.is_empty()); + if let Some(runtime_dir) = non_empty("XDG_RUNTIME_DIR") { Path::new(&runtime_dir).join("wallhack").join(&filename) - } else if let Ok(user) = std::env::var("USER") { + } else if let Some(user) = non_empty("USER") { PathBuf::from(format!("/tmp/wallhack-{user}")).join(&filename) - } else if let Ok(home) = std::env::var("HOME") { + } else if let Some(home) = non_empty("HOME") { Path::new(&home).join(".wallhack").join(&filename) } else { PathBuf::from("/tmp/wallhack-shared").join(&filename) From 08241c00a7f61764587602b448710f4eb432f819 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 19:16:18 +0700 Subject: [PATCH 35/41] fix(auto): update peer capabilities from handshake after registration Auto mode registered peers with default (all-false) capabilities. Now extracts capabilities from the peer's handshake and updates the registry. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/daemon/src/mode/auto.rs | 37 +++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/crates/daemon/src/mode/auto.rs b/crates/daemon/src/mode/auto.rs index 923c9711..f121052b 100644 --- a/crates/daemon/src/mode/auto.rs +++ b/crates/daemon/src/mode/auto.rs @@ -474,9 +474,8 @@ async fn run_auto_connect_session_dispatch( peer_hs.name, ); node_state.update_role(NodeRole::Exit); - let peer_role = peer_hs - .capabilities - .map_or(NodeRole::Exit, super::peer_role_from_capabilities); + 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() { peer_addr.to_string() } else { @@ -511,6 +510,7 @@ async fn run_auto_connect_session_dispatch( responses_tx, heartbeat, peer_role, + peer_caps, &peer_name, peer_addr, &metrics, @@ -538,12 +538,14 @@ async fn run_auto_connect_session_dispatch( } else { peer_hs.name.clone() }; - peers.register( + let (peer_id, _) = peers.register( name.clone(), peer_addr.to_string(), NodeRole::Indeterminate, wallhack_core::control::peers::ConnectionSide::Connect, ); + let peer_caps = peer_hs.capabilities.unwrap_or_default(); + peers.update_capabilities(&peer_id, &peer_caps); let _heartbeat = super::spawn_heartbeat(control_tx, latency_rx, name.clone(), Arc::clone(&peers)); hold_until_disconnect(tasks).await; @@ -567,7 +569,7 @@ async fn hold_until_disconnect(mut tasks: wallhack_core::client::client::Connect } /// Non-generic exit session handler for the auto-connector path. -// REASON: threading transport, instructions, responses, heartbeat, role, peer info, metrics, peers +// REASON: threading transport, instructions, responses, heartbeat, role, caps, peer info, metrics, peers #[allow(clippy::too_many_arguments)] async fn run_auto_exit_session_inner( transport: Arc, @@ -575,17 +577,19 @@ async fn run_auto_exit_session_inner( responses_tx: tokio::sync::mpsc::Sender, _heartbeat: tokio::task::JoinHandle<()>, peer_role: NodeRole, + peer_caps: Capabilities, peer_name: &str, peer_addr: &str, metrics: &Arc, peers: &Arc, ) -> Result<(), NodeError> { - peers.register( + let (peer_id, _) = peers.register( peer_name.to_string(), peer_addr.to_string(), peer_role, ConnectionSide::Connect, ); + peers.update_capabilities(&peer_id, &peer_caps); let adapter = SyscallExitAdapter::new(); let _reaper = adapter.start_reaper( @@ -990,18 +994,19 @@ async fn run_auto_accept_session_inner( }; // The peer connected to us (we accepted), so side=Accept. // The peer is an exit/relay node — use role from their handshake capabilities. - let peer_caps = peer_hs.capabilities.as_ref(); - let peer_role = if peer_caps.is_some_and(|c| c.listening && c.connecting) { + let peer_caps = peer_hs.capabilities.unwrap_or_default(); + let peer_role = if peer_caps.listening && peer_caps.connecting { NodeRole::Relay } else { NodeRole::Exit }; - peers.register( + let (peer_id, _) = peers.register( peer_name.clone(), peer_addr.clone(), peer_role, ConnectionSide::Accept, ); + peers.update_capabilities(&peer_id, &peer_caps); let _heartbeat = super::spawn_heartbeat( control_tx, @@ -1087,17 +1092,15 @@ async fn run_auto_accept_session_inner( } else { peer_hs.name.clone() }; - let peer_role = peer_hs - .capabilities - .as_ref() - .copied() - .map_or(NodeRole::Exit, super::peer_role_from_capabilities); - peers.register( + let peer_caps = peer_hs.capabilities.unwrap_or_default(); + let peer_role = super::peer_role_from_capabilities(peer_caps); + let (peer_id, _) = peers.register( peer_name.clone(), peer_addr.clone(), peer_role, ConnectionSide::Accept, ); + peers.update_capabilities(&peer_id, &peer_caps); let _heartbeat = super::spawn_heartbeat( control_tx, @@ -1148,12 +1151,14 @@ async fn run_auto_accept_session_inner( } else { peer_hs.name.clone() }; - peers.register( + let (peer_id, _) = peers.register( name.clone(), peer_addr.clone(), NodeRole::Indeterminate, wallhack_core::control::peers::ConnectionSide::Accept, ); + let peer_caps = peer_hs.capabilities.unwrap_or_default(); + peers.update_capabilities(&peer_id, &peer_caps); let _heartbeat = super::spawn_heartbeat(control_tx, latency_rx, name.clone(), Arc::clone(&peers)); // Hold transport alive; wait for the peer to disconnect From c99a5802e183e283acc19dc38f62c46bd6bd97ce Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 19:16:23 +0700 Subject: [PATCH 36/41] fix(heartbeat): unregister peer when heartbeat exits on connection death MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The heartbeat loop exits when send_ping fails (control channel closed). This is the most reliable connection-death signal — unregister the peer here instead of relying on transport stream tasks that may not exit promptly. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/daemon/src/mode/mod.rs | 5 +++++ crates/daemon/src/mode/relay.rs | 10 ++-------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/daemon/src/mode/mod.rs b/crates/daemon/src/mode/mod.rs index 6b7953f9..7a8e100f 100644 --- a/crates/daemon/src/mode/mod.rs +++ b/crates/daemon/src/mode/mod.rs @@ -143,6 +143,11 @@ pub(crate) fn spawn_heartbeat( } } } + + // Heartbeat exits when the control channel is closed (peer gone). + // Unregister here so the registry stays clean regardless of which + // transport task exits first. + peers.unregister(&peer_name); }) } diff --git a/crates/daemon/src/mode/relay.rs b/crates/daemon/src/mode/relay.rs index 62c68881..86aad87e 100644 --- a/crates/daemon/src/mode/relay.rs +++ b/crates/daemon/src/mode/relay.rs @@ -705,15 +705,10 @@ fn handle_relay_connection( // Incoming: accept uni stream from exit peer, dispatch data messages. // Exit peers send ExitNodeResponses which are dispatched via responses_tx. - // This task exits when the peer's transport dies, making it the canonical - // place to unregister — the send-instructions task never exits on its own - // because the fan-out channel outlives the peer's transport. { let transport = std::sync::Arc::clone(&transport); let instr_tx = instructions_tx.clone(); let resp_tx = responses_tx.clone(); - let peer_name = peer_name.clone(); - let peers = Arc::clone(peers); tokio::spawn(async move { match transport.accept_uni_erased().await { Ok(Some(mut recv)) => { @@ -724,9 +719,8 @@ fn handle_relay_connection( Ok(None) => tracing::debug!("Relay peer transport closed before data-in"), Err(e) => tracing::debug!("Relay peer failed to accept data-in: {e}"), } - // Peer transport is dead — unregister so the registry stays clean - // and PeerEvent::Disconnected fires to the source peer. - peers.unregister(&peer_name); + // Unregistration is handled by spawn_heartbeat, which detects + // connection death via a failed ping and unregisters the peer. }); } From d95e6fdef17ff88a88907a1ed22035cbae37684c Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 19:23:55 +0700 Subject: [PATCH 37/41] refactor(peers): require capabilities at registration instead of two-step default+update Previously callers would register with default capabilities then immediately call update_capabilities to set the real values from the handshake. Consolidate into a single atomic operation by requiring capabilities at register() time. Remove the now-dead update_capabilities method from Registry. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/core/src/control/peers.rs | 23 +++++++++++------------ crates/daemon/src/mode/auto.rs | 24 ++++++++++++------------ crates/daemon/src/mode/entry.rs | 6 +++--- crates/daemon/src/mode/exit.rs | 9 ++++----- crates/daemon/src/mode/relay.rs | 10 ++++++++++ 5 files changed, 40 insertions(+), 32 deletions(-) diff --git a/crates/core/src/control/peers.rs b/crates/core/src/control/peers.rs index e6a75765..615ffc36 100644 --- a/crates/core/src/control/peers.rs +++ b/crates/core/src/control/peers.rs @@ -145,6 +145,7 @@ impl Registry { id: String, addr: String, role: NodeRole, + capabilities: Capabilities, side: ConnectionSide, ) -> (String, u64) { let connection_id = self.next_connection_id.fetch_add(1, Ordering::Relaxed) + 1; @@ -166,7 +167,7 @@ impl Registry { name: id, addr, role, - capabilities: Capabilities::default(), + capabilities, side, connect_time: Instant::now(), connect_time_epoch: std::time::SystemTime::now() @@ -207,17 +208,6 @@ impl Registry { }); } - /// Update capability fields for a peer from a received `Handshake` message. - pub fn update_capabilities(&self, id: &str, capabilities: &Capabilities) { - self.peers.rcu(|old| { - let mut new = (**old).clone(); - if let Some(peer) = new.get_mut(id) { - peer.capabilities = *capabilities; - } - new - }); - } - /// Unregister a peer unconditionally. pub fn unregister(&self, id: &str) -> Option { self.control_channels.rcu(|old| { @@ -416,6 +406,7 @@ mod tests { "peer1".into(), "1.2.3.4:5678".into(), NodeRole::Exit, + Capabilities::default(), ConnectionSide::Accept, ); @@ -434,6 +425,7 @@ mod tests { "peer1".into(), "1.2.3.4:5678".into(), NodeRole::Exit, + Capabilities::default(), ConnectionSide::Accept, ); @@ -451,6 +443,7 @@ mod tests { "peer1".into(), "1.2.3.4:5678".into(), NodeRole::Exit, + Capabilities::default(), ConnectionSide::Accept, ); @@ -470,6 +463,7 @@ mod tests { "peer1".into(), "1.2.3.4:5678".into(), NodeRole::Exit, + Capabilities::default(), ConnectionSide::Accept, ); @@ -484,6 +478,7 @@ mod tests { "peer1".into(), "1.2.3.4:5678".into(), NodeRole::Exit, + Capabilities::default(), ConnectionSide::Accept, ); @@ -501,6 +496,7 @@ mod tests { "peer1".into(), "1.2.3.4:5678".into(), NodeRole::Exit, + Capabilities::default(), ConnectionSide::Accept, ); assert_eq!(id1, "peer1"); @@ -510,6 +506,7 @@ mod tests { "peer1".into(), "1.2.3.4:9999".into(), NodeRole::Exit, + Capabilities::default(), ConnectionSide::Accept, ); assert_eq!(id2, "peer1", "reconnect should reuse the name"); @@ -536,6 +533,7 @@ mod tests { "peer1".into(), "1.2.3.4:5678".into(), NodeRole::Exit, + Capabilities::default(), ConnectionSide::Accept, ); assert_eq!(peer_id1, "peer1"); @@ -544,6 +542,7 @@ mod tests { "peer1".into(), "1.2.3.4:9999".into(), NodeRole::Exit, + Capabilities::default(), ConnectionSide::Accept, ); assert_eq!(peer_id2, "peer1"); diff --git a/crates/daemon/src/mode/auto.rs b/crates/daemon/src/mode/auto.rs index f121052b..ceca84b4 100644 --- a/crates/daemon/src/mode/auto.rs +++ b/crates/daemon/src/mode/auto.rs @@ -538,14 +538,14 @@ async fn run_auto_connect_session_dispatch( } else { peer_hs.name.clone() }; - let (peer_id, _) = peers.register( + let peer_caps = peer_hs.capabilities.unwrap_or_default(); + peers.register( name.clone(), peer_addr.to_string(), NodeRole::Indeterminate, + peer_caps, wallhack_core::control::peers::ConnectionSide::Connect, ); - let peer_caps = peer_hs.capabilities.unwrap_or_default(); - peers.update_capabilities(&peer_id, &peer_caps); let _heartbeat = super::spawn_heartbeat(control_tx, latency_rx, name.clone(), Arc::clone(&peers)); hold_until_disconnect(tasks).await; @@ -583,13 +583,13 @@ async fn run_auto_exit_session_inner( metrics: &Arc, peers: &Arc, ) -> Result<(), NodeError> { - let (peer_id, _) = peers.register( + peers.register( peer_name.to_string(), peer_addr.to_string(), peer_role, + peer_caps, ConnectionSide::Connect, ); - peers.update_capabilities(&peer_id, &peer_caps); let adapter = SyscallExitAdapter::new(); let _reaper = adapter.start_reaper( @@ -1000,13 +1000,13 @@ async fn run_auto_accept_session_inner( } else { NodeRole::Exit }; - let (peer_id, _) = peers.register( + peers.register( peer_name.clone(), peer_addr.clone(), peer_role, + peer_caps, ConnectionSide::Accept, ); - peers.update_capabilities(&peer_id, &peer_caps); let _heartbeat = super::spawn_heartbeat( control_tx, @@ -1094,13 +1094,13 @@ async fn run_auto_accept_session_inner( }; let peer_caps = peer_hs.capabilities.unwrap_or_default(); let peer_role = super::peer_role_from_capabilities(peer_caps); - let (peer_id, _) = peers.register( + peers.register( peer_name.clone(), peer_addr.clone(), peer_role, + peer_caps, ConnectionSide::Accept, ); - peers.update_capabilities(&peer_id, &peer_caps); let _heartbeat = super::spawn_heartbeat( control_tx, @@ -1151,14 +1151,14 @@ async fn run_auto_accept_session_inner( } else { peer_hs.name.clone() }; - let (peer_id, _) = peers.register( + let peer_caps = peer_hs.capabilities.unwrap_or_default(); + peers.register( name.clone(), peer_addr.clone(), NodeRole::Indeterminate, + peer_caps, wallhack_core::control::peers::ConnectionSide::Accept, ); - let peer_caps = peer_hs.capabilities.unwrap_or_default(); - peers.update_capabilities(&peer_id, &peer_caps); let _heartbeat = super::spawn_heartbeat(control_tx, latency_rx, name.clone(), Arc::clone(&peers)); // Hold transport alive; wait for the peer to disconnect diff --git a/crates/daemon/src/mode/entry.rs b/crates/daemon/src/mode/entry.rs index e97fc984..66918dcf 100644 --- a/crates/daemon/src/mode/entry.rs +++ b/crates/daemon/src/mode/entry.rs @@ -731,14 +731,14 @@ where let peer_name = identity.name.as_deref().unwrap_or(&peer_addr).to_string(); - // Register peer in the registry and apply handshake capabilities. - let (peer_id, connection_id) = peers.register( + // Register peer in the registry with handshake capabilities. + let (_, connection_id) = peers.register( peer_name.clone(), peer_addr.clone(), identity.role, + identity.capabilities, wallhack_core::control::peers::ConnectionSide::Accept, ); - peers.update_capabilities(&peer_id, &identity.capabilities); // Extract transport and channels from the generic AcceptResult before // spawning so the spawned future is non-generic. diff --git a/crates/daemon/src/mode/exit.rs b/crates/daemon/src/mode/exit.rs index d2f94222..ad0172c3 100644 --- a/crates/daemon/src/mode/exit.rs +++ b/crates/daemon/src/mode/exit.rs @@ -320,14 +320,14 @@ where let peer_name = peer_hs .filter(|h| !h.name.is_empty()) .map_or_else(|| peer_addr.clone(), |h| h.name.clone()); - let peer_role = peer_hs - .and_then(|h| h.capabilities) - .map_or(NodeRole::Exit, super::peer_role_from_capabilities); + let peer_caps = peer_hs.and_then(|h| h.capabilities).unwrap_or_default(); + let peer_role = super::peer_role_from_capabilities(peer_caps); tracing::info!("Peer connected: name={peer_name} addr={peer_addr}"); ctx.peers.register( peer_name.clone(), peer_addr, peer_role, + peer_caps, ConnectionSide::Accept, ); @@ -464,10 +464,9 @@ async fn run_exit_loop_inner( peer_name.clone(), peer_addr.to_string(), peer_role, + peer_capabilities, ConnectionSide::Connect, ); - ctx.peers - .update_capabilities(&peer_name, &peer_capabilities); // Outgoing: open uni stream to entry peer, send responses. { diff --git a/crates/daemon/src/mode/relay.rs b/crates/daemon/src/mode/relay.rs index 86aad87e..d9835f4b 100644 --- a/crates/daemon/src/mode/relay.rs +++ b/crates/daemon/src/mode/relay.rs @@ -285,6 +285,10 @@ async fn run_relay_loop_inner( None }; let (peer_name, peer_role) = resolve_peer(peer_handshake.as_ref(), &peer_addr); + let peer_caps = peer_handshake + .as_ref() + .and_then(|h| h.capabilities) + .unwrap_or_default(); // Clone before heartbeat takes ownership — used for peer announcements. let announce_tx = source_control_tx.clone(); @@ -293,6 +297,7 @@ async fn run_relay_loop_inner( peer_name.clone(), peer_addr.clone(), peer_role, + peer_caps, ConnectionSide::Connect, ); @@ -687,11 +692,16 @@ fn handle_relay_connection( } = channels; let (peer_name, _) = resolve_peer(peer_handshake.as_ref(), &peer_addr); + let peer_caps = peer_handshake + .as_ref() + .and_then(|h| h.capabilities) + .unwrap_or_default(); peers.register( peer_name.clone(), peer_addr, NodeRole::Exit, // relay's Fixed(Entry) hint forces accepted peers to Exit + peer_caps, ConnectionSide::Accept, ); From 5197977b56c535f918671ff12b0fa3830384e069 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 19:40:50 +0700 Subject: [PATCH 38/41] fix(lint): add REASON to handle_message allow, use explicit match variant Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/core/src/transport/protocol.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/core/src/transport/protocol.rs b/crates/core/src/transport/protocol.rs index 4137c2ec..f7303ddb 100644 --- a/crates/core/src/transport/protocol.rs +++ b/crates/core/src/transport/protocol.rs @@ -225,6 +225,8 @@ impl ControlChannels { /// Process a single incoming `ControlMessage`. /// /// Returns `Some(exit_reason)` if the loop should terminate. + // REASON: dispatches all control message variants with inline handling + #[allow(clippy::too_many_lines)] async fn handle_message( &mut self, stream: &mut BoxBiStream, @@ -311,6 +313,7 @@ impl ControlChannels { announcement.name, announcement.addr, role, + wallhack_wire::data::Capabilities::default(), crate::control::peers::ConnectionSide::Accept, ); } @@ -322,7 +325,7 @@ impl ControlChannels { ); registry.unregister(&announcement.name); } - _ => {} + peer_announcement::Event::Unspecified => {} } } } From f9cba80e7045229dc27479b356971c5b01886b11 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 20:00:30 +0700 Subject: [PATCH 39/41] =?UTF-8?q?refactor:=20rename=20set=5Fhint=20?= =?UTF-8?q?=E2=86=92=20hint=5Fset=20across=20all=20consumer=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/api/src/handlers.rs | 8 ++++---- crates/api/src/lib.rs | 2 +- crates/core/src/control/handler.rs | 4 ++-- crates/core/src/ipc.rs | 4 ++-- crates/core/src/node_api.rs | 6 +++--- crates/mcp/src/tools.rs | 4 ++-- website/src/data/openapi.json | 2 +- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/crates/api/src/handlers.rs b/crates/api/src/handlers.rs index c6181400..ca6a9b49 100644 --- a/crates/api/src/handlers.rs +++ b/crates/api/src/handlers.rs @@ -135,7 +135,7 @@ pub struct PingResponseBody { /// Set hint request body. #[derive(Debug, Deserialize)] -pub struct SetHintRequestBody { +pub struct HintSetRequestBody { pub level: String, pub role: String, } @@ -704,9 +704,9 @@ pub async fn shutdown(State(state): State) -> (StatusCode, Json, - Json(req): Json, + Json(req): Json, ) -> (StatusCode, Json) { let level = match req.level.as_str() { "prefer" => HintLevel::Prefer, @@ -787,7 +787,7 @@ pub async fn set_hint( } } -pub async fn clear_hints(State(state): State) -> (StatusCode, Json) { +pub async fn hint_set_auto(State(state): State) -> (StatusCode, Json) { let resp = state .ipc .lock() diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 4c603f99..465f0792 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -80,7 +80,7 @@ pub fn router(state: State) -> Router { .route("/shutdown", post(handlers::shutdown)) .route( "/hints", - put(handlers::set_hint).delete(handlers::clear_hints), + put(handlers::hint_set).delete(handlers::hint_set_auto), ) .layer(middleware::from_fn(move |req, next| { let auth = auth.clone(); diff --git a/crates/core/src/control/handler.rs b/crates/core/src/control/handler.rs index 46054c85..d9c02368 100644 --- a/crates/core/src/control/handler.rs +++ b/crates/core/src/control/handler.rs @@ -481,12 +481,12 @@ impl crate::node_api::NodeApi for Handler { self.state.load().role } - fn set_hint(&self, hint: RoleHint) -> crate::node_api::Result<()> { + fn hint_set(&self, hint: RoleHint) -> crate::node_api::Result<()> { self.hint_tx.send_replace(Some(hint)); Ok(()) } - fn clear_hints(&self) -> crate::node_api::Result<()> { + fn hint_set_auto(&self) -> crate::node_api::Result<()> { self.hint_tx.send_replace(None); Ok(()) } diff --git a/crates/core/src/ipc.rs b/crates/core/src/ipc.rs index d4aeac05..2f7f1b7c 100644 --- a/crates/core/src/ipc.rs +++ b/crates/core/src/ipc.rs @@ -430,13 +430,13 @@ fn dispatch_request(request: &ManagementRequest, api: &dyn NodeApi) -> Managemen level: level.into(), target: target.into(), }; - match api.set_hint(hint) { + match api.hint_set(hint) { Ok(()) => management_response::Response::Ok(OkResponse {}), Err(e) => error_response(&e), } } - Some(management_request::Request::HintSetAuto(_)) => match api.clear_hints() { + Some(management_request::Request::HintSetAuto(_)) => match api.hint_set_auto() { Ok(()) => management_response::Response::Ok(OkResponse {}), Err(e) => error_response(&e), }, diff --git a/crates/core/src/node_api.rs b/crates/core/src/node_api.rs index 29f22ebe..328ea712 100644 --- a/crates/core/src/node_api.rs +++ b/crates/core/src/node_api.rs @@ -218,9 +218,9 @@ pub trait NodeApi: Send + Sync { /// Apply a role hint at runtime. /// /// Triggers re-negotiation if the node is in auto mode. - /// `role ` in the REPL is shorthand for `set_hint(Fixed, target)`. - fn set_hint(&self, hint: RoleHint) -> Result<()>; + /// `role ` in the REPL is shorthand for `hint_set(Fixed, target)`. + fn hint_set(&self, hint: RoleHint) -> Result<()>; /// Remove all hints (both startup and runtime). - fn clear_hints(&self) -> Result<()>; + fn hint_set_auto(&self) -> Result<()>; } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index ecaaab9b..1e65f6f5 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -42,7 +42,7 @@ pub struct AddrParams { } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct SetHintParams { +pub struct HintSetParams { /// Hint level: "prefer", "exclude", or "fixed" pub level: String, /// Target role: "entry", "exit", or "relay" @@ -183,7 +183,7 @@ impl WallhackServer { )] async fn hint_set( &self, - Parameters(params): Parameters, + Parameters(params): Parameters, ) -> Result { let level = match params.level.as_str() { "prefer" => HintLevel::Prefer, diff --git a/website/src/data/openapi.json b/website/src/data/openapi.json index cd0f723c..c6e21812 100644 --- a/website/src/data/openapi.json +++ b/website/src/data/openapi.json @@ -662,7 +662,7 @@ "required": true, "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/SetHintRequest" } + "schema": { "$ref": "#/components/schemas/HintSetRequest" } } } }, From 2f9c7f38048960e47349a78bbba131dddb77204e Mon Sep 17 00:00:00 2001 From: Max Holman Date: Wed, 18 Mar 2026 20:15:51 +0700 Subject: [PATCH 40/41] refactor: unify role command, eliminate hint from user interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hint command was protocol jargon. All role operations now go through a single `role` command: `role entry` (hard set), `role prefer entry` (soft), `role exclude entry`, `role auto` (clear). Also renames ping_peer → peer_ping for noun_verb consistency. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/api/src/handlers.rs | 2 +- crates/api/src/lib.rs | 2 +- crates/cli/src/bin/wallhack.rs | 62 ++++++++++++++++--------------- crates/cli/src/cli.rs | 58 +++-------------------------- crates/cli/src/repl.rs | 67 +++++++++++++++------------------- crates/mcp/src/tools.rs | 8 ++-- 6 files changed, 72 insertions(+), 127 deletions(-) diff --git a/crates/api/src/handlers.rs b/crates/api/src/handlers.rs index ca6a9b49..b111a9b5 100644 --- a/crates/api/src/handlers.rs +++ b/crates/api/src/handlers.rs @@ -644,7 +644,7 @@ pub async fn ping(State(state): State) -> Result, Path(peer): Path, ) -> Result, StatusCode> { diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 465f0792..065ebe8c 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -76,7 +76,7 @@ pub fn router(state: State) -> Router { .route("/listen", post(handlers::listen)) .route("/disconnect", post(handlers::disconnect)) .route("/ping", get(handlers::ping)) - .route("/ping/{peer}", get(handlers::ping_peer)) + .route("/ping/{peer}", get(handlers::peer_ping)) .route("/shutdown", post(handlers::shutdown)) .route( "/hints", diff --git a/crates/cli/src/bin/wallhack.rs b/crates/cli/src/bin/wallhack.rs index 30390e5b..866fa607 100644 --- a/crates/cli/src/bin/wallhack.rs +++ b/crates/cli/src/bin/wallhack.rs @@ -356,39 +356,41 @@ async fn run_ctl_async(cli: wallhack_cli::cli::Cli) -> Result<(), output::CtlErr None => management_request::Request::Disconnect(DisconnectRequest {}), }, CtlCommand::Role(cmd) => { - if let Some(target) = cmd.target { - let role = parse_ctl_role(&target); - management_request::Request::HintSet(HintSetRequest { + let first = cmd.args.first().map(String::as_str); + let second = cmd.args.get(1).map(String::as_str); + match (first, second) { + (None, _) => management_request::Request::Info(InfoRequest {}), + (Some("auto"), None) => { + management_request::Request::HintSetAuto(HintSetAutoRequest {}) + } + (Some("prefer"), Some(role)) => { + management_request::Request::HintSet(HintSetRequest { + level: HintLevel::Prefer.into(), + role: parse_ctl_role(role).into(), + }) + } + (Some("exclude"), Some(role)) => { + management_request::Request::HintSet(HintSetRequest { + level: HintLevel::Exclude.into(), + role: parse_ctl_role(role).into(), + }) + } + (Some(level @ ("prefer" | "exclude")), None) => { + eprintln!("error: 'role {level}' requires a target role (entry, exit, relay)"); + std::process::exit(1); + } + (Some(role), None) => management_request::Request::HintSet(HintSetRequest { level: HintLevel::Fixed.into(), - role: role.into(), - }) - } else { - management_request::Request::Info(InfoRequest {}) + role: parse_ctl_role(role).into(), + }), + (Some(_), Some(_)) => { + eprintln!( + "error: invalid syntax. Usage: role [auto|entry|exit|relay|prefer |exclude ]" + ); + std::process::exit(1); + } } } - CtlCommand::Hint(cmd) => match cmd.action { - wallhack_cli::cli::HintAction::Prefer(h) => { - management_request::Request::HintSet(HintSetRequest { - level: HintLevel::Prefer.into(), - role: parse_ctl_role(&h.role).into(), - }) - } - wallhack_cli::cli::HintAction::Exclude(h) => { - management_request::Request::HintSet(HintSetRequest { - level: HintLevel::Exclude.into(), - role: parse_ctl_role(&h.role).into(), - }) - } - wallhack_cli::cli::HintAction::Fixed(h) => { - management_request::Request::HintSet(HintSetRequest { - level: HintLevel::Fixed.into(), - role: parse_ctl_role(&h.role).into(), - }) - } - wallhack_cli::cli::HintAction::Auto(_) => { - management_request::Request::HintSetAuto(HintSetAutoRequest {}) - } - }, CtlCommand::Shutdown(_) => management_request::Request::Shutdown(ShutdownRequest {}), }; diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 30f5d0b9..3103ec3d 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -30,7 +30,6 @@ pub enum CtlCommand { Listen(ListenCmd), Disconnect(DisconnectCmd), Role(RoleCmd), - Hint(HintCmd), Shutdown(ShutdownCmd), } @@ -134,64 +133,17 @@ pub struct DisconnectCmd { } /// Show or set the node role. +/// +/// Usage: role [auto|entry|exit|relay|prefer |exclude ] #[derive(FromArgs, Debug)] #[argh(subcommand, name = "role")] pub struct RoleCmd { - /// target role (entry, exit, relay). Omit to show current role. + /// arguments: a role (entry/exit/relay) for hard set, "auto", + /// or "prefer"/"exclude" followed by a role #[argh(positional)] - pub target: Option, + pub args: Vec, } -/// Manage role hints. -#[derive(FromArgs, Debug)] -#[argh(subcommand, name = "hint")] -pub struct HintCmd { - #[argh(subcommand)] - pub action: HintAction, -} - -/// Hint sub-commands. -#[derive(FromArgs, Debug)] -#[argh(subcommand)] -pub enum HintAction { - Prefer(HintPreferCmd), - Exclude(HintExcludeCmd), - Fixed(HintFixedCmd), - Auto(HintAutoCmd), -} - -/// Set a prefer hint. -#[derive(FromArgs, Debug)] -#[argh(subcommand, name = "prefer")] -pub struct HintPreferCmd { - /// target role (entry, exit, relay) - #[argh(positional)] - pub role: String, -} - -/// Set an exclude hint. -#[derive(FromArgs, Debug)] -#[argh(subcommand, name = "exclude")] -pub struct HintExcludeCmd { - /// target role (entry, exit, relay) - #[argh(positional)] - pub role: String, -} - -/// Set a fixed hint. -#[derive(FromArgs, Debug)] -#[argh(subcommand, name = "fixed")] -pub struct HintFixedCmd { - /// target role (entry, exit, relay) - #[argh(positional)] - pub role: String, -} - -/// Return to capability-based negotiation. -#[derive(FromArgs, Debug)] -#[argh(subcommand, name = "auto")] -pub struct HintAutoCmd {} - /// Shut down the daemon. #[derive(FromArgs, Debug)] #[argh(subcommand, name = "shutdown")] diff --git a/crates/cli/src/repl.rs b/crates/cli/src/repl.rs index 9c488200..c4de797d 100644 --- a/crates/cli/src/repl.rs +++ b/crates/cli/src/repl.rs @@ -159,7 +159,6 @@ fn parse_command(line: &str) -> Option { wallhack_wire::management::ShutdownRequest {}, )), "role" => parse_role_command(&parts), - "hint" => parse_hint_command(&parts), _ => None, } } @@ -190,42 +189,27 @@ fn parse_route_command(parts: &[&str]) -> Option { } } -/// Parse `role` command: `role` (show) or `role ` (set fixed). +/// Parse `role` command. +/// +/// Forms: +/// - `role` — show current role via info +/// - `role auto` — return to capability-based negotiation +/// - `role prefer ` — soft prefer a role +/// - `role exclude ` — exclude a role +/// - `role ` — hard set role fn parse_role_command(parts: &[&str]) -> Option { - match parts.get(1).copied() { - None => { - // `role` alone → show current role via info. - Some(management_request::Request::Info( - wallhack_wire::management::InfoRequest {}, - )) - } - Some(target) => { - // `role ` → shorthand for `hint fixed `. - let role = parse_role_name(target)?; - Some(management_request::Request::HintSet( - management::HintSetRequest { - level: management::HintLevel::Fixed.into(), - role: role.into(), - }, - )) - } - } -} - -/// Parse `hint` command: `hint auto` or `hint `. -fn parse_hint_command(parts: &[&str]) -> Option { - let sub = parts.get(1).copied()?; - match sub { - "auto" => Some(management_request::Request::HintSetAuto( + match (parts.get(1).copied(), parts.get(2).copied()) { + (None, _) => Some(management_request::Request::Info( + wallhack_wire::management::InfoRequest {}, + )), + (Some("auto"), None) => Some(management_request::Request::HintSetAuto( management::HintSetAutoRequest {}, )), - "prefer" | "exclude" | "fixed" => { - let role_name = parts.get(2).copied()?; - let role = parse_role_name(role_name)?; - let level = match sub { + (Some("prefer" | "exclude"), Some(role)) => { + let role = parse_role_name(role)?; + let level = match parts[1] { "prefer" => management::HintLevel::Prefer, "exclude" => management::HintLevel::Exclude, - "fixed" => management::HintLevel::Fixed, _ => unreachable!(), }; Some(management_request::Request::HintSet( @@ -235,6 +219,15 @@ fn parse_hint_command(parts: &[&str]) -> Option { }, )) } + (Some(target), None) => { + let role = parse_role_name(target)?; + Some(management_request::Request::HintSet( + management::HintSetRequest { + level: management::HintLevel::Fixed.into(), + role: role.into(), + }, + )) + } _ => None, } } @@ -267,12 +260,10 @@ fn print_help() { let _ = writeln!(tw, " listen \tStart listening for connections"); let _ = writeln!(tw, " disconnect [peer]\tDisconnect peer"); let _ = writeln!(tw, " role\tShow current role"); - let _ = writeln!(tw, " role \tSet role hint"); - let _ = writeln!( - tw, - " hint \tApply a role hint" - ); - let _ = writeln!(tw, " hint auto\tReturn to capability-based negotiation"); + let _ = writeln!(tw, " role \tSet role (hard)"); + let _ = writeln!(tw, " role prefer \tPrefer a role (soft)"); + let _ = writeln!(tw, " role exclude \tExclude a role"); + let _ = writeln!(tw, " role auto\tAuto-negotiate"); let _ = writeln!(tw, " shutdown\tShut down the daemon"); let _ = writeln!(tw, " help / ?\tShow this help"); let _ = writeln!(tw, " quit \tQuit the REPL"); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 1e65f6f5..e66805fb 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -179,9 +179,9 @@ impl WallhackServer { } #[tool( - description = "Set a role hint to influence auto-negotiation (prefer/exclude/fixed + entry/exit/relay)" + description = "Set the node role with a level (prefer/exclude/fixed) and target (entry/exit/relay)" )] - async fn hint_set( + async fn role_set( &self, Parameters(params): Parameters, ) -> Result { @@ -214,8 +214,8 @@ impl WallhackServer { .await } - #[tool(description = "Return to capability-based negotiation by removing all role hints")] - async fn hint_set_auto(&self) -> Result { + #[tool(description = "Return to capability-based auto-negotiation")] + async fn role_auto(&self) -> Result { ipc_call(management_request::Request::HintSetAuto( HintSetAutoRequest {}, )) From 3c7ac1d8865d38ee03e5c14f5f8af19a1d8ca592 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Fri, 20 Mar 2026 18:13:30 +0700 Subject: [PATCH 41/41] fix: remove ping handlers reintroduced by clusterfuck merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clusterfuck branch added /ping and /ping/{peer} REST endpoints. Ping was removed in v0.12.0 — drop the handlers, routes, and PingResponseBody struct. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/api/src/handlers.rs | 39 -------------------------------------- crates/api/src/lib.rs | 2 -- 2 files changed, 41 deletions(-) diff --git a/crates/api/src/handlers.rs b/crates/api/src/handlers.rs index fc73686d..586d0f43 100644 --- a/crates/api/src/handlers.rs +++ b/crates/api/src/handlers.rs @@ -125,14 +125,6 @@ pub struct ListenResponse { pub fingerprint: String, } -/// Ping response. -#[derive(Debug, Serialize)] -pub struct PingResponseBody { - pub uptime_ms: u64, - pub version: String, - pub role: String, -} - /// Set hint request body. #[derive(Debug, Deserialize)] pub struct HintSetRequestBody { @@ -637,37 +629,6 @@ pub async fn disconnect(State(state): State) -> (StatusCode, Json) -> Result, StatusCode> { - let resp = state - .ipc - .lock() - .await - .request(management_request::Request::Info( - wallhack_wire::management::InfoRequest {}, - )) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - match resp.response { - Some(management_response::Response::Info(info)) => { - let role = NodeRole::try_from(info.role).unwrap_or(NodeRole::Unspecified); - Ok(Json(PingResponseBody { - uptime_ms: info.uptime_ms, - version: info.version, - role: role.to_string(), - })) - } - _ => Err(StatusCode::INTERNAL_SERVER_ERROR), - } -} - -pub async fn peer_ping( - State(_state): State, - Path(_peer): Path, -) -> Result, StatusCode> { - Err(StatusCode::NOT_IMPLEMENTED) -} - pub async fn shutdown(State(state): State) -> (StatusCode, Json) { let resp = state .ipc diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 198b7b54..72c419f1 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -76,8 +76,6 @@ pub fn router(state: State) -> Router { .route("/connect", post(handlers::connect)) .route("/listen", post(handlers::listen)) .route("/disconnect", post(handlers::disconnect)) - .route("/ping", get(handlers::ping)) - .route("/ping/{peer}", get(handlers::peer_ping)) .route("/shutdown", post(handlers::shutdown)) .route( "/hints",