From 0d5777668ba27a153518d3629376aea096a3d889 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Fri, 20 Mar 2026 11:24:15 +0700 Subject: [PATCH 01/10] chore: build-range recipe also builds wallhack-mcp Reduces the manual step of remembering to rebuild the MCP binary after code changes during range development. Co-Authored-By: Claude Opus 4.6 (1M context) --- justfile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/justfile b/justfile index 8644944c..0fa37bea 100644 --- a/justfile +++ b/justfile @@ -28,6 +28,11 @@ test: build-release: cargo build --quiet --release --features full +# Build musl binary for range VMs (slim + vsock for IPC) and glibc MCP binary for host +build-range: + cargo build --quiet --release --target x86_64-unknown-linux-musl -p wallhack-cli --no-default-features --features slim,vsock + cargo build --quiet --release -p wallhack-mcp + # Delete local branches that have been merged and deleted on origin clean-branches: git fetch -p From ac89f99269a093cecf7c8903ffc1f8a3642145f4 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Fri, 20 Mar 2026 11:24:25 +0700 Subject: [PATCH 02/10] ci: include wallhack-mcp in release pipeline MCP binary was previously only buildable from source. Now produced alongside wallhack-cli for x64 glibc and arm64 targets. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/build-and-publish.yml | 57 ++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index e96ce173..1cccebad 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -131,8 +131,63 @@ jobs: cp "target/${{ matrix.target }}/release/wallhack" "$ARTIFACT" gh release upload --clobber "$TAG_NAME" "$ARTIFACT" + build-mcp: + if: startsWith(github.event.inputs.tag_name, 'wallhack-cli-v') + permissions: + contents: write + strategy: + fail-fast: false + matrix: + target: + - x86_64-unknown-linux-gnu + - aarch64-unknown-linux-gnu + include: + - target: x86_64-unknown-linux-gnu + artifact: wallhack-mcp-linux-x64 + - target: aarch64-unknown-linux-gnu + artifact: wallhack-mcp-linux-arm64 + cross: true + + runs-on: self-hosted + + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.inputs.tag_name }} + + - name: Install Rust toolchain + run: | + rustup show + rustup target add ${{ matrix.target }} + + - name: Install cross + if: matrix.cross + env: + GH_TOKEN: ${{ github.token }} + run: | + if ! command -v cross &>/dev/null; then + gh release download "$CROSS_VERSION" --repo cross-rs/cross --pattern 'cross-x86_64-unknown-linux-musl.tar.gz' -D /tmp --clobber + tar xz -C ~/.cargo/bin cross < /tmp/cross-x86_64-unknown-linux-musl.tar.gz + fi + + - name: Build (native) + if: ${{ !matrix.cross }} + run: cargo build --release --target ${{ matrix.target }} -p wallhack-mcp + + - name: Build (cross) + if: matrix.cross + run: cross build --release --target ${{ matrix.target }} -p wallhack-mcp + + - name: Upload binary + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ github.event.inputs.tag_name }} + run: | + cp "target/${{ matrix.target }}/release/wallhack-mcp" "${{ matrix.artifact }}" + gh release upload --clobber "$TAG_NAME" "${{ matrix.artifact }}" + publish: - needs: build + needs: [build, build-mcp] runs-on: self-hosted permissions: contents: write From ab6ec4f926cc81f06c1bf79d5d11956b453d1daa Mon Sep 17 00:00:00 2001 From: Max Holman Date: Fri, 20 Mar 2026 11:24:47 +0700 Subject: [PATCH 03/10] feat(daemon): surface recent daemon logs via IPC for MCP/CLI/REST observability When something goes wrong (latency missing, peer disconnected, route failing), there was no way to diagnose through the control plane without SSH access to the daemon host. This adds a bounded ring buffer (last 200 lines) that captures all tracing output and exposes it through every control interface: - Proto: LogsRequest/LogsResponse - NodeApi: logs(count) method - IPC dispatch - MCP tool: logs (with lines parameter) - REPL: logs [N] - CLI: wallhack logs [-n N] - REST: GET /logs?lines=N - OpenAPI spec updated Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 20 ++++++ crates/api/Cargo.toml | 1 + crates/api/src/handlers.rs | 100 +++++++++----------------- crates/api/src/lib.rs | 3 +- crates/cli/src/bin/wallhack.rs | 30 ++++---- crates/cli/src/cli.rs | 20 +++--- crates/cli/src/output.rs | 15 ++-- crates/cli/src/repl.rs | 20 +++--- crates/cli/src/subscriber.rs | 17 ++++- crates/core/src/control/handler.rs | 21 +++++- crates/core/src/control/log_buffer.rs | 96 +++++++++++++++++++++++++ crates/core/src/control/mod.rs | 1 + crates/core/src/control/server.rs | 1 + crates/core/src/ipc.rs | 30 ++------ crates/core/src/node_api.rs | 6 ++ crates/core/src/server/server.rs | 2 +- crates/core/src/transport/protocol.rs | 2 + crates/daemon/src/lib.rs | 10 ++- crates/daemon/src/mode/entry.rs | 1 + crates/mcp/src/convert.rs | 15 ++-- crates/mcp/src/tools.rs | 39 +++++----- crates/wire/proto/management.proto | 22 +++--- website/src/data/openapi.json | 39 ++++++++++ 23 files changed, 337 insertions(+), 174 deletions(-) create mode 100644 crates/core/src/control/log_buffer.rs diff --git a/Cargo.lock b/Cargo.lock index 29575367..725b18d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -268,6 +268,7 @@ checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core 0.5.6", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", @@ -283,6 +284,7 @@ dependencies = [ "serde_core", "serde_json", "serde_path_to_error", + "serde_urlencoded", "sync_wrapper", "tokio", "tower 0.5.3", @@ -2766,6 +2768,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -2878,6 +2886,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index 9e873dfe..d5fe481a 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -10,6 +10,7 @@ wallhack-wire = { path = "../wire" } axum = { version = "0.8", default-features = false, features = [ "tokio", "json", + "query", "http1", ] } tower-http = { version = "0.6", default-features = false, features = ["cors"] } diff --git a/crates/api/src/handlers.rs b/crates/api/src/handlers.rs index 3d16feb5..5115552c 100644 --- a/crates/api/src/handlers.rs +++ b/crates/api/src/handlers.rs @@ -11,7 +11,7 @@ use axum::{ use serde::{Deserialize, Serialize}; use wallhack_wire::management::{ ConnectRequest, DisconnectRequest, HintLevel, HintSetAutoRequest, HintSetRequest, InfoRequest, - ListenRequest, NodeRole, PeerDisconnectRequest, PeersRequest, PingRequest, + ListenRequest, LogsRequest, NodeRole, PeerDisconnectRequest, PeersRequest, RouteAddRequest as ProtoRouteAddRequest, RouteDelRequest, RoutesRequest, ShutdownRequest, StatsRequest, management_request, management_response, }; @@ -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, -} - /// Hint set request body. #[derive(Debug, Deserialize)] pub struct HintSetRequestBody { @@ -140,6 +132,19 @@ pub struct HintSetRequestBody { pub role: String, } +/// Logs query parameters. +#[derive(Debug, Deserialize)] +pub struct LogsQuery { + /// Number of recent lines to return (default: all buffered). + pub lines: Option, +} + +/// Logs response. +#[derive(Debug, Serialize)] +pub struct LogsResponse { + pub lines: Vec, +} + pub async fn health() -> &'static str { "ok" } @@ -624,64 +629,6 @@ pub async fn disconnect(State(state): State) -> (StatusCode, Json) -> 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(ping)) => { - let role = NodeRole::try_from(ping.node_role).unwrap_or(NodeRole::Unspecified); - Ok(Json(PingResponseBody { - uptime_ms: ping.uptime_ms, - version: ping.version, - role: role.to_string(), - })) - } - _ => Err(StatusCode::INTERNAL_SERVER_ERROR), - } -} - -pub async fn peer_ping( - 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(ping)) => { - let role = NodeRole::try_from(ping.node_role).unwrap_or(NodeRole::Unspecified); - Ok(Json(PingResponseBody { - uptime_ms: ping.uptime_ms, - version: ping.version, - role: role.to_string(), - })) - } - Some(management_response::Response::Error(e)) => { - 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), - } -} - pub async fn shutdown(State(state): State) -> (StatusCode, Json) { let resp = state .ipc @@ -835,6 +782,25 @@ pub async fn hint_set_auto(State(state): State) -> (StatusCode, Json, + axum::extract::Query(query): axum::extract::Query, +) -> Result, StatusCode> { + let lines = query.lines.unwrap_or(0); + let resp = state + .ipc + .lock() + .await + .request(management_request::Request::Logs(LogsRequest { lines })) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + match resp.response { + Some(management_response::Response::Logs(l)) => Ok(Json(LogsResponse { lines: l.lines })), + _ => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + /// 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 bfa7323f..72c419f1 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -64,6 +64,7 @@ pub fn router(state: State) -> Router { let protected_routes = Router::new() .route("/info", get(handlers::info)) .route("/stats", get(handlers::stats)) + .route("/logs", get(handlers::logs)) .route("/peers", get(handlers::peers)) .route("/peers/{name}", delete(handlers::peer_disconnect)) .route( @@ -75,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", diff --git a/crates/cli/src/bin/wallhack.rs b/crates/cli/src/bin/wallhack.rs index 30390e5b..54693fb9 100644 --- a/crates/cli/src/bin/wallhack.rs +++ b/crates/cli/src/bin/wallhack.rs @@ -15,7 +15,7 @@ //! //! The dispatch heuristic: if the first argument starts with `-` it is a flag //! destined for the daemon CLI (auto-negotiation or global options). Control -//! client subcommands are always bare words (`route`, `peers`, `ping`, etc.). +//! client subcommands are always bare words (`route`, `peers`, `info`, etc.). use wallhack_cli::{ cli::{CtlCommand, RouteAction}, @@ -23,7 +23,7 @@ use wallhack_cli::{ }; use wallhack_wire::management::{ ConnectRequest, DisconnectRequest, HintLevel, HintSetAutoRequest, HintSetRequest, InfoRequest, - ListenRequest, NodeRole, PeerDisconnectRequest, PeersRequest, PingRequest, RouteAddRequest, + ListenRequest, LogsRequest, NodeRole, PeerDisconnectRequest, PeersRequest, RouteAddRequest, RouteDelRequest, RoutesRequest, ShutdownRequest, StatsRequest, management_request, }; @@ -122,8 +122,10 @@ fn run_daemon(args: Vec, bin_name: &str) -> ! { } // Headless path: no REPL, just run the daemon engine. - tracing::subscriber::set_global_default(wallhack_cli::subscriber::SimpleSubscriber::from(&cli)) - .expect("setting default subscriber"); + let log_buffer = wallhack_core::control::log_buffer::LogBuffer::new(); + let mut subscriber = wallhack_cli::subscriber::SimpleSubscriber::from(&cli); + subscriber.set_log_buffer(log_buffer.clone()); + tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber"); let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -132,7 +134,7 @@ fn run_daemon(args: Vec, bin_name: &str) -> ! { let socket_override = cli.host.as_deref().map(wallhack_cli::ipc::resolve_host); let exit_code = rt.block_on(async { - match wallhackd::run_daemon_engine(config, socket_override).await { + match wallhackd::run_daemon_engine(config, socket_override, Some(log_buffer)).await { Ok(()) => 0, Err(e) => { eprintln!("error: {e}"); @@ -150,7 +152,8 @@ fn run_daemon_repl( cli: &wallhack_cli::daemon_cli::WallhackCli, config: &wallhackd::daemon_config::DaemonConfig, ) -> ! { - let subscriber = if cli.trace || cli.trace_filter.is_some() { + let log_buffer = wallhack_core::control::log_buffer::LogBuffer::new(); + let mut subscriber = if cli.trace || cli.trace_filter.is_some() { wallhack_cli::subscriber::SimpleSubscriber::new( tracing::level_filters::LevelFilter::TRACE, cli.trace_filter.as_deref().unwrap_or(""), @@ -171,6 +174,7 @@ fn run_daemon_repl( "", ) }; + subscriber.set_log_buffer(log_buffer.clone()); let writer = subscriber.writer(); tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber"); @@ -186,7 +190,7 @@ fn run_daemon_repl( .expect("failed to build tokio runtime"); let exit_code = rt.block_on(async { - let handle = match wallhackd::start_node(config) { + let handle = match wallhackd::start_node(config, Some(log_buffer)) { Ok(h) => h, Err(e) => { eprintln!("error: {e}"); @@ -312,11 +316,11 @@ async fn run_ctl_async(cli: wallhack_cli::cli::Cli) -> Result<(), output::CtlErr std::process::exit(1); }; let request = match command { - CtlCommand::Ping(cmd) => management_request::Request::Ping(PingRequest { - peer: cmd.peer.unwrap_or_default(), - }), CtlCommand::Info(_) => management_request::Request::Info(InfoRequest {}), CtlCommand::Stats(_) => management_request::Request::Stats(StatsRequest {}), + CtlCommand::Logs(ref cmd) => { + management_request::Request::Logs(LogsRequest { lines: cmd.lines }) + } #[cfg(feature = "json")] CtlCommand::Peers(ref cmd) if cmd.json => { // JSON output: make the request and short-circuit the standard response path. @@ -412,7 +416,9 @@ fn parse_ctl_role(s: &str) -> NodeRole { fn run_repl() -> ! { use tracing::level_filters::LevelFilter; - let subscriber = wallhack_cli::subscriber::SimpleSubscriber::new(LevelFilter::WARN, ""); + let log_buffer = wallhack_core::control::log_buffer::LogBuffer::new(); + let mut subscriber = wallhack_cli::subscriber::SimpleSubscriber::new(LevelFilter::WARN, ""); + subscriber.set_log_buffer(log_buffer.clone()); let writer = subscriber.writer(); tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber"); @@ -433,7 +439,7 @@ fn run_repl() -> ! { .expect("failed to build tokio runtime"); let exit_code = rt.block_on(async { - let handle = match wallhackd::start_node(&config) { + let handle = match wallhackd::start_node(&config, Some(log_buffer)) { Ok(h) => h, Err(e) => { eprintln!("error: {e}"); diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 30f5d0b9..602b85e2 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -21,9 +21,9 @@ pub struct Cli { #[derive(FromArgs, Debug)] #[argh(subcommand)] pub enum CtlCommand { - Ping(PingCmd), Info(InfoCmd), Stats(StatsCmd), + Logs(LogsCmd), Peers(PeersCmd), Route(RouteCmd), Connect(ConnectCmd), @@ -34,15 +34,6 @@ pub enum CtlCommand { Shutdown(ShutdownCmd), } -/// Ping a peer. -#[derive(FromArgs, Debug)] -#[argh(subcommand, name = "ping")] -pub struct PingCmd { - /// peer name prefix to ping (auto-selects sole peer if omitted) - #[argh(positional)] - pub peer: Option, -} - /// Show daemon info. #[derive(FromArgs, Debug)] #[argh(subcommand, name = "info")] @@ -53,6 +44,15 @@ pub struct InfoCmd {} #[argh(subcommand, name = "stats")] pub struct StatsCmd {} +/// Show recent daemon log lines. +#[derive(FromArgs, Debug)] +#[argh(subcommand, name = "logs")] +pub struct LogsCmd { + /// number of recent lines to show (default: all buffered) + #[argh(option, short = 'n', default = "0")] + pub lines: u32, +} + /// List connected peers. #[derive(FromArgs, Debug)] #[argh(subcommand, name = "peers")] diff --git a/crates/cli/src/output.rs b/crates/cli/src/output.rs index d2195449..3633ac7c 100644 --- a/crates/cli/src/output.rs +++ b/crates/cli/src/output.rs @@ -148,6 +148,15 @@ pub fn print_response(resp: &ManagementResponse) -> Result<(), CtlError> { let _ = tw.flush(); } } + Some(management_response::Response::Logs(l)) => { + if l.lines.is_empty() { + println!("No log lines available."); + } else { + for line in &l.lines { + println!("{line}"); + } + } + } Some(management_response::Response::Connect(c)) => { println!("Connected to {} ({})", c.peer_addr, c.protocol); } @@ -166,12 +175,6 @@ pub fn print_response(resp: &ManagementResponse) -> Result<(), CtlError> { Some(management_response::Response::Error(e)) => { return Err(CtlError::Daemon(e.message.clone())); } - Some(management_response::Response::Ping(_)) => { - // Ping response is handled by daemon; not used by CLI currently. - return Err(CtlError::Daemon( - "unexpected ping response from daemon".to_string(), - )); - } None => { return Err(CtlError::EmptyResponse); } diff --git a/crates/cli/src/repl.rs b/crates/cli/src/repl.rs index dbe024cc..10562f0b 100644 --- a/crates/cli/src/repl.rs +++ b/crates/cli/src/repl.rs @@ -106,15 +106,6 @@ fn parse_command(line: &str) -> Option { let cmd = *parts.first()?; match cmd { - "ping" => { - let peer = parts - .get(1) - .map(std::string::ToString::to_string) - .unwrap_or_default(); - Some(management_request::Request::Ping( - wallhack_wire::management::PingRequest { peer }, - )) - } "info" => Some(management_request::Request::Info( wallhack_wire::management::InfoRequest {}, )), @@ -124,6 +115,15 @@ fn parse_command(line: &str) -> Option { "peers" => Some(management_request::Request::Peers( wallhack_wire::management::PeersRequest {}, )), + "logs" => { + let lines = parts + .get(1) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + Some(management_request::Request::Logs( + wallhack_wire::management::LogsRequest { lines }, + )) + } "route" => parse_route_command(&parts), "connect" => { let addr = parts.get(1)?; @@ -255,10 +255,10 @@ fn print_help() { let mut tw = TabWriter::new(std::io::stdout()); let _ = writeln!(tw, "Commands:"); - let _ = writeln!(tw, " ping\tPing the daemon (peer ping not yet supported)"); let _ = writeln!(tw, " info\tShow daemon info"); let _ = writeln!(tw, " version\tShow version"); let _ = writeln!(tw, " stats\tShow traffic statistics"); + let _ = writeln!(tw, " logs [N]\tShow recent daemon log lines"); let _ = writeln!(tw, " peers\tList connected peers"); let _ = writeln!(tw, " route\tList configured routes"); let _ = writeln!(tw, " route add \tAdd a route"); diff --git a/crates/cli/src/subscriber.rs b/crates/cli/src/subscriber.rs index 7c01c499..08aaf1b9 100644 --- a/crates/cli/src/subscriber.rs +++ b/crates/cli/src/subscriber.rs @@ -14,6 +14,7 @@ use std::{ }; use tracing::{Event, Level, Metadata, Subscriber, level_filters::LevelFilter}; +use wallhack_core::control::log_buffer::LogBuffer; pub type LogWriter = Arc>>; @@ -28,6 +29,7 @@ pub struct SimpleSubscriber { max_level: LevelFilter, filters: Vec, writer: LogWriter, + log_buffer: Option, dedup: Mutex, } @@ -49,6 +51,7 @@ impl SimpleSubscriber { max_level, filters, writer: Arc::new(RwLock::new(Box::new(|tag, msg| eprintln!("{tag}: {msg}")))), + log_buffer: None, dedup: Mutex::new(DedupeState { last_hash: 0, last_tag: "info", @@ -62,6 +65,11 @@ impl SimpleSubscriber { pub fn writer(&self) -> LogWriter { Arc::clone(&self.writer) } + + /// Attach a [`LogBuffer`] so every emitted line is also stored in memory. + pub fn set_log_buffer(&mut self, buffer: LogBuffer) { + self.log_buffer = Some(buffer); + } } impl From<&crate::daemon_cli::WallhackCli> for SimpleSubscriber { @@ -142,9 +150,16 @@ impl Subscriber for SimpleSubscriber { if let Ok(writer) = self.writer.read() { if flush_count > 0 { - writer(flush_tag, &format!("↑ repeated {flush_count}×")); + let repeat_line = format!("↑ repeated {flush_count}×"); + writer(flush_tag, &repeat_line); + if let Some(ref buf) = self.log_buffer { + buf.push(format!("{flush_tag}: {repeat_line}")); + } } writer(tag, &visitor.0); + if let Some(ref buf) = self.log_buffer { + buf.push(format!("{tag}: {}", visitor.0)); + } } } diff --git a/crates/core/src/control/handler.rs b/crates/core/src/control/handler.rs index 2bec4ef2..cce970f8 100644 --- a/crates/core/src/control/handler.rs +++ b/crates/core/src/control/handler.rs @@ -17,7 +17,9 @@ use wallhack_wire::{ use crate::NodeRole; -use super::{metrics::SharedMetrics, peers::SharedRegistry, routes::SharedRouteTable}; +use super::{ + log_buffer::LogBuffer, metrics::SharedMetrics, peers::SharedRegistry, routes::SharedRouteTable, +}; /// Mutable runtime state that can change after construction. /// @@ -113,6 +115,7 @@ pub struct Handler { /// Sender for hint changes. The mode task watches the receiver and /// re-evaluates when a new hint arrives. `None` means no hint is active. hint_tx: watch::Sender>, + log_buffer: LogBuffer, metrics: SharedMetrics, peers: SharedRegistry, routes: SharedRouteTable, @@ -123,6 +126,11 @@ pub struct Handler { impl Handler { /// Creates a new control handler. + /// Creates a new control handler. + /// + /// `log_buffer`, when provided, is the shared ring buffer that the tracing + /// subscriber also writes into — enabling the `logs` API to return recent + /// daemon output. #[must_use] pub fn new( config: HandlerConfig, @@ -130,12 +138,14 @@ impl Handler { peers: SharedRegistry, routes: SharedRouteTable, route_updates: tokio::sync::broadcast::Sender, + log_buffer: Option, ) -> Self { let state = SharedNodeState::new(config.node_role); let (hint_tx, _) = watch::channel(None); Self { config, hint_tx, + log_buffer: log_buffer.unwrap_or_default(), metrics, peers, routes, @@ -507,6 +517,10 @@ impl crate::node_api::NodeApi for Handler { self.hint_tx.send_replace(None); Ok(()) } + + fn logs(&self, count: u32) -> Vec { + self.log_buffer.tail(count) + } } #[cfg(test)] @@ -530,6 +544,7 @@ mod tests { peers, routes, tokio::sync::broadcast::channel(16).0, + None, ) } @@ -570,6 +585,7 @@ mod tests { peers, routes, tokio::sync::broadcast::channel(16).0, + None, ); let request = ControlRequest { request: Some(control_request::Request::Stats( @@ -733,6 +749,7 @@ mod tests { peers, routes, tokio::sync::broadcast::channel(16).0, + None, ); let request = ControlRequest { @@ -769,6 +786,7 @@ mod tests { peers, routes, tokio::sync::broadcast::channel(16).0, + None, ); let status = crate::node_api::NodeApi::info(&handler); @@ -823,6 +841,7 @@ mod tests { Arc::new(Registry::new()), RouteTable::shared(), tokio::sync::broadcast::channel(16).0, + None, ); // Initially indeterminate with no capabilities. diff --git a/crates/core/src/control/log_buffer.rs b/crates/core/src/control/log_buffer.rs new file mode 100644 index 00000000..37502cfd --- /dev/null +++ b/crates/core/src/control/log_buffer.rs @@ -0,0 +1,96 @@ +//! Bounded ring buffer for recent daemon log lines. +//! +//! Stores the last `capacity` formatted log lines in a `VecDeque` behind +//! `Arc>`. Writers (the tracing layer) push lines; readers +//! (IPC/REST/MCP) snapshot the tail. + +use std::{ + collections::VecDeque, + sync::{Arc, Mutex}, +}; + +/// Default number of log lines to retain. +const DEFAULT_CAPACITY: usize = 200; + +/// Thread-safe handle to a bounded log ring buffer. +#[derive(Debug, Clone)] +pub struct LogBuffer(Arc>>); + +impl LogBuffer { + /// Create a new buffer that retains at most `DEFAULT_CAPACITY` lines. + #[must_use] + pub fn new() -> Self { + Self(Arc::new(Mutex::new(VecDeque::with_capacity( + DEFAULT_CAPACITY, + )))) + } + + /// Append a formatted log line, evicting the oldest if at capacity. + pub fn push(&self, line: String) { + let Ok(mut buf) = self.0.lock() else { + return; + }; + if buf.len() >= DEFAULT_CAPACITY { + buf.pop_front(); + } + buf.push_back(line); + } + + /// Return the most recent `count` lines (or all if `count` is 0). + #[must_use] + pub fn tail(&self, count: u32) -> Vec { + let Ok(buf) = self.0.lock() else { + return Vec::new(); + }; + if count == 0 || count as usize >= buf.len() { + buf.iter().cloned().collect() + } else { + buf.iter() + .skip(buf.len() - count as usize) + .cloned() + .collect() + } + } +} + +impl Default for LogBuffer { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tail_returns_most_recent_lines() { + let buf = LogBuffer::new(); + for i in 0..10 { + buf.push(format!("line {i}")); + } + let tail = buf.tail(3); + assert_eq!(tail, vec!["line 7", "line 8", "line 9"]); + } + + #[test] + fn tail_zero_returns_all() { + let buf = LogBuffer::new(); + for i in 0..5 { + buf.push(format!("line {i}")); + } + assert_eq!(buf.tail(0).len(), 5); + } + + #[test] + fn capacity_evicts_oldest() { + let buf = LogBuffer::new(); + for i in 0..250 { + buf.push(format!("line {i}")); + } + let all = buf.tail(0); + assert_eq!(all.len(), DEFAULT_CAPACITY); + assert_eq!(all[0], "line 50"); + assert_eq!(all[DEFAULT_CAPACITY - 1], "line 249"); + } +} diff --git a/crates/core/src/control/mod.rs b/crates/core/src/control/mod.rs index 9a88da05..cdc3cc18 100644 --- a/crates/core/src/control/mod.rs +++ b/crates/core/src/control/mod.rs @@ -1,6 +1,7 @@ #[cfg(feature = "quic")] pub mod client; pub mod handler; +pub mod log_buffer; pub mod metrics; pub mod peers; pub mod routes; diff --git a/crates/core/src/control/server.rs b/crates/core/src/control/server.rs index 5cba3ba3..e894099e 100644 --- a/crates/core/src/control/server.rs +++ b/crates/core/src/control/server.rs @@ -100,6 +100,7 @@ impl ControlServer { Arc::new(Registry::new()), RouteTable::shared(), route_updates, + None, )); Ok(Self { endpoint, handler }) diff --git a/crates/core/src/ipc.rs b/crates/core/src/ipc.rs index 98d7fefd..e3a10f85 100644 --- a/crates/core/src/ipc.rs +++ b/crates/core/src/ipc.rs @@ -17,8 +17,8 @@ use tokio::{ use wallhack_transport::TransportError; use wallhack_wire::management::{ self, ConnectResponse, DaemonMessage, DaemonNotification, ErrorCode, ErrorResponse, - InfoResponse, ListenResponse, ManagementRequest, ManagementResponse, OkResponse, PeerConnected, - PeerDisconnected, PeersResponse, PingResponse, RoutesResponse, StatsResponse, daemon_message, + InfoResponse, ListenResponse, LogsResponse, ManagementRequest, ManagementResponse, OkResponse, + PeerConnected, PeerDisconnected, PeersResponse, RoutesResponse, StatsResponse, daemon_message, daemon_notification, management_request, management_response, }; @@ -287,27 +287,6 @@ fn dispatch_request(request: &ManagementRequest, api: &dyn NodeApi) -> Managemen let request_id = request.request_id; let response = match &request.request { - Some(management_request::Request::Ping(req)) => { - if req.peer.is_empty() { - // Ping the daemon itself - let status = api.info(); - management_response::Response::Ping(PingResponse { - uptime_ms: status.uptime_ms, - version: status.version, - node_role: management::NodeRole::from(status.role).into(), - }) - } else { - // Peer pinging is not yet implemented - return ManagementResponse { - request_id, - response: Some(management_response::Response::Error(ErrorResponse { - code: ErrorCode::NotSupported.into(), - message: "peer ping not yet implemented".to_string(), - })), - }; - } - } - Some(management_request::Request::Info(_)) => { let s = api.info(); management_response::Response::Info(InfoResponse { @@ -460,6 +439,11 @@ fn dispatch_request(request: &ManagementRequest, api: &dyn NodeApi) -> Managemen Err(e) => error_response(&e), }, + Some(management_request::Request::Logs(req)) => { + let lines = api.logs(req.lines); + management_response::Response::Logs(LogsResponse { lines }) + } + None => management_response::Response::Error(ErrorResponse { code: ErrorCode::Internal.into(), message: "empty request".to_string(), diff --git a/crates/core/src/node_api.rs b/crates/core/src/node_api.rs index e5665f08..7d294728 100644 --- a/crates/core/src/node_api.rs +++ b/crates/core/src/node_api.rs @@ -227,4 +227,10 @@ pub trait NodeApi: Send + Sync { /// Remove all hints (both startup and runtime). fn hint_set_auto(&self) -> Result<()>; + + /// Retrieve recent daemon log lines. + /// + /// Returns the most recent `count` lines from the in-memory log buffer. + /// If `count` is 0, returns all buffered lines. + fn logs(&self, count: u32) -> Vec; } diff --git a/crates/core/src/server/server.rs b/crates/core/src/server/server.rs index 93bfc6c4..fc925209 100644 --- a/crates/core/src/server/server.rs +++ b/crates/core/src/server/server.rs @@ -242,7 +242,7 @@ where let metrics = Arc::clone(&metrics); let peer_registry = Arc::clone(&peers); tokio::spawn(async move { - let handler = Handler::new(handler_config, metrics, peers, routes, route_updates); + let handler = Handler::new(handler_config, metrics, peers, routes, route_updates, None); let mut channels = protocol::ControlChannels { outgoing_rx: control_rx, handshake_tx: None, diff --git a/crates/core/src/transport/protocol.rs b/crates/core/src/transport/protocol.rs index 8478ba71..4716ca12 100644 --- a/crates/core/src/transport/protocol.rs +++ b/crates/core/src/transport/protocol.rs @@ -960,6 +960,7 @@ mod tests { std::sync::Arc::new(Registry::new()), RouteTable::shared(), tokio::sync::broadcast::channel(16).0, + None, ); let (_ctrl_tx, ctrl_rx) = tokio::sync::mpsc::channel::(16); @@ -1091,6 +1092,7 @@ mod tests { std::sync::Arc::new(Registry::new()), RouteTable::shared(), tokio::sync::broadcast::channel(16).0, + None, ); let (_ctrl_tx, ctrl_rx) = tokio::sync::mpsc::channel::(16); diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs index 1843478f..a9984d26 100644 --- a/crates/daemon/src/lib.rs +++ b/crates/daemon/src/lib.rs @@ -24,6 +24,7 @@ use wallhack_core::{ NodeRole, control::{ handler::{Handler, HandlerConfig}, + log_buffer::LogBuffer, metrics::Metrics, peers::Registry, routes::RouteTable, @@ -50,12 +51,13 @@ use wallhack_core::{ pub async fn run_daemon_engine( config: DaemonConfig, socket_path_override: Option, + log_buffer: Option, ) -> Result<(), NodeError> { tracing::info!("wallhack {} {}", config.global.version, config.mode.name()); sys::check_entropy_ready(); - let handle = start_node(&config)?; + let handle = start_node(&config, log_buffer)?; // Start IPC listener for the management protocol. let socket_path = socket_path_override @@ -127,7 +129,10 @@ pub async fn run_daemon_engine( /// # Errors /// /// Returns error if node setup fails. -pub fn start_node(config: &DaemonConfig) -> Result { +pub fn start_node( + config: &DaemonConfig, + log_buffer: Option, +) -> Result { let role = match &config.mode { ModeConfig::Entry(_) => NodeRole::Entry, ModeConfig::Exit(_) => NodeRole::Exit, @@ -150,6 +155,7 @@ pub fn start_node(config: &DaemonConfig) -> Result { Arc::clone(&peers), Arc::clone(&routes), route_update_tx.clone(), + log_buffer, ); let node_state = handler.node_state(); let node_api: Arc = Arc::new(handler); diff --git a/crates/daemon/src/mode/entry.rs b/crates/daemon/src/mode/entry.rs index cb53bcef..8190d39b 100644 --- a/crates/daemon/src/mode/entry.rs +++ b/crates/daemon/src/mode/entry.rs @@ -1120,6 +1120,7 @@ fn start_api( Arc::clone(peers), Arc::clone(routes), route_updates, + None, ); tracing::info!("REST API username: {username}"); tracing::info!("REST API secret: {secret}"); diff --git a/crates/mcp/src/convert.rs b/crates/mcp/src/convert.rs index a72c0c31..3ec68ff1 100644 --- a/crates/mcp/src/convert.rs +++ b/crates/mcp/src/convert.rs @@ -27,14 +27,6 @@ pub fn format_response(resp: &ManagementResponse) -> Result { let _ = writeln!(out, "uptime: {}", format_uptime(s.uptime_ms)); Ok(out) } - Some(management_response::Response::Ping(p)) => { - let role = p.node_role().to_string(); - Ok(format!( - "pong — role: {role}, version: {}, uptime: {}", - p.version, - format_uptime(p.uptime_ms), - )) - } Some(management_response::Response::Stats(s)) => Ok(format!( "bytes in: {}\nbytes out: {}\npackets in: {}\npackets out: {}\n\ connections: {}\nflows: {}\ndropped: {}", @@ -79,6 +71,13 @@ pub fn format_response(resp: &ManagementResponse) -> Result { } Ok(out) } + Some(management_response::Response::Logs(l)) => { + if l.lines.is_empty() { + Ok("No log lines available.".to_string()) + } else { + Ok(l.lines.join("\n")) + } + } Some(management_response::Response::Connect(c)) => { Ok(format!("Connected to {} ({})", c.peer_addr, c.protocol)) } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 581b355a..c4ad6535 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -3,18 +3,12 @@ use rmcp::{handler::server::wrapper::Parameters, schemars, tool}; use wallhack_wire::management::{ ConnectRequest, DisconnectRequest, HintLevel, HintSetAutoRequest, HintSetRequest, InfoRequest, - ListenRequest, NodeRole, PeerDisconnectRequest, PeersRequest, PingRequest, RouteAddRequest, + ListenRequest, LogsRequest, NodeRole, PeerDisconnectRequest, PeersRequest, RouteAddRequest, RouteDelRequest, RoutesRequest, ShutdownRequest, StatsRequest, management_request, }; use crate::convert; -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct PingParams { - /// Reserved — peer-specific ping is not yet supported. Leave empty. - pub peer: Option, -} - #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct AddRouteParams { /// CIDR range, e.g. "10.0.0.0/8" @@ -41,6 +35,13 @@ pub struct AddrParams { pub addr: String, } +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct LogsParams { + /// Number of recent log lines to retrieve (0 or omit for all buffered) + #[serde(default)] + pub lines: u32, +} + #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct HintSetParams { /// How strongly to apply the hint: "prefer" (soft), "exclude" (avoid), or "fixed" (force) @@ -76,23 +77,23 @@ impl WallhackServer { } #[tool( - description = "Ping the daemon to check liveness. Returns role, version, and uptime. (Peer-specific ping is not yet supported.)" + description = "Get traffic statistics: bytes/packets in/out, active connections and flows" )] - async fn ping( - &self, - Parameters(params): Parameters, - ) -> Result { - ipc_call(management_request::Request::Ping(PingRequest { - peer: params.peer.unwrap_or_default(), - })) - .await + async fn stats(&self) -> Result { + ipc_call(management_request::Request::Stats(StatsRequest {})).await } #[tool( - description = "Get traffic statistics: bytes/packets in/out, active connections and flows" + description = "Retrieve recent daemon log lines for diagnostics (ring buffer, last 200 lines max)" )] - async fn stats(&self) -> Result { - ipc_call(management_request::Request::Stats(StatsRequest {})).await + async fn logs( + &self, + Parameters(params): Parameters, + ) -> Result { + ipc_call(management_request::Request::Logs(LogsRequest { + lines: params.lines, + })) + .await } #[tool( diff --git a/crates/wire/proto/management.proto b/crates/wire/proto/management.proto index 8ef5f229..460c52b8 100644 --- a/crates/wire/proto/management.proto +++ b/crates/wire/proto/management.proto @@ -10,7 +10,6 @@ package wallhack.management; message ManagementRequest { uint64 request_id = 1; // assigned by sender, echoed in response oneof request { - PingRequest ping = 2; InfoRequest info = 3; StatsRequest stats = 4; PeersRequest peers = 5; @@ -24,6 +23,7 @@ message ManagementRequest { ShutdownRequest shutdown = 13; HintSetRequest hint_set = 14; HintSetAutoRequest hint_set_auto = 15; + LogsRequest logs = 16; } } @@ -38,13 +38,13 @@ message DaemonMessage { message ManagementResponse { uint64 request_id = 1; // echoes the request oneof response { - PingResponse ping = 2; InfoResponse info = 3; StatsResponse stats = 4; PeersResponse peers = 5; RoutesResponse routes = 6; ConnectResponse connect = 7; ListenResponse listen = 8; + LogsResponse logs = 9; ErrorResponse error = 20; OkResponse ok = 21; // for void operations (route_add, disconnect, etc.) } @@ -64,10 +64,6 @@ message DaemonNotification { // ── Request messages ──────────────────────────────────────────────── -message PingRequest { - string peer = 1; // prefix to match; empty = auto-select sole peer -} - message InfoRequest {} message StatsRequest {} @@ -117,6 +113,14 @@ message HintSetRequest { message HintSetAutoRequest {} +message LogsRequest { + uint32 lines = 1; // number of recent lines to retrieve (0 = all buffered) +} + +message LogsResponse { + repeated string lines = 1; +} + // ── Response messages ─────────────────────────────────────────────── enum NodeRole { @@ -134,12 +138,6 @@ enum PeerStatus { } -message PingResponse { - uint64 uptime_ms = 1; - string version = 2; - NodeRole node_role = 3; -} - message InfoResponse { NodeRole role = 1; bool connected = 2; diff --git a/website/src/data/openapi.json b/website/src/data/openapi.json index 2c85c03c..1c548b1b 100644 --- a/website/src/data/openapi.json +++ b/website/src/data/openapi.json @@ -271,6 +271,17 @@ } } }, + "LogsResponse": { + "type": "object", + "required": ["lines"], + "properties": { + "lines": { + "type": "array", + "items": { "type": "string" }, + "description": "Recent daemon log lines, oldest first." + } + } + }, "HintSetRequest": { "type": "object", "required": ["level", "role"], @@ -325,6 +336,34 @@ } } }, + "/logs": { + "get": { + "summary": "Recent daemon logs", + "description": "Retrieves recent log lines from the daemon's in-memory ring buffer (last 200 lines max).", + "operationId": "logs", + "security": [{ "basicAuth": [] }], + "parameters": [ + { + "name": "lines", + "in": "query", + "required": false, + "schema": { "type": "integer", "default": 0 }, + "description": "Number of recent lines to return. 0 or omit for all buffered lines." + } + ], + "responses": { + "200": { + "description": "Log lines retrieved.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/LogsResponse" } + } + } + }, + "401": { "description": "Unauthorized." } + } + } + }, "/stats": { "get": { "summary": "Traffic metrics", From 5d4367b3b83f4bdda9fe33ead81d932836908c7d Mon Sep 17 00:00:00 2001 From: Max Holman Date: Fri, 20 Mar 2026 11:24:59 +0700 Subject: [PATCH 04/10] =?UTF-8?q?chore:=20mark=20completed=20TODO=20items?= =?UTF-8?q?=20from=20v0.11=E2=80=93v0.12=20cycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-routing, ping removal, latency fix, channel sprawl dedup, and all stale terminology renames verified and marked done. Co-Authored-By: Claude Opus 4.6 (1M context) --- TODO.md | 80 +++++++++++++++++++++++++++++++++++---------------------- 1 file changed, 50 insertions(+), 30 deletions(-) diff --git a/TODO.md b/TODO.md index 01718597..60e31014 100644 --- a/TODO.md +++ b/TODO.md @@ -18,12 +18,9 @@ streams`). Relay now bridges bidi streams between source and exit peers using `copy_bidirectional`. SYN probes and TCP data sessions work through relay chains. -- [ ] **Topology visibility** — entry has no visibility into peers behind a - relay. `wallhack peers` shows the relay but not the exit nodes connected - to it. Relay should forward downstream peer identity (name, capabilities, - routes) upstream via a peer announcement control message or augmented - handshake. Needed for topology observability, debugging, and multi-hop - route selection. +- [x] ~~**Topology visibility**~~ — done: relay sends `PeerAnnouncement` over + control stream; entry registers announced peers in its registry. + `wallhack peers` on entry shows exit nodes behind relays. ## Transports @@ -53,14 +50,10 @@ ## Auto-negotiation -- [ ] **Tiebreaker for symmetric capabilities** — when both peers are - TUN-capable with identical connectivity (both connect-only or both - listen-only), the `negotiate()` function returns `Indeterminate`. Needs a - deterministic tiebreaker rule (e.g. lexicographic name ordering, or a - "prefer" hint with Fixed level). Current workaround: set `--prefer-role` - on one side. Design decision: should the tiebreaker be purely local (each - side picks independently but deterministically) or negotiated (extra - round-trip)? +- [x] ~~**Tiebreaker for symmetric capabilities**~~ — done: interactive flag + (human at terminal) breaks TUN-capable ambiguity. Relay accept-side + Fixed(Entry) hint forces exit role on accepted peers. Both-interactive + still Indeterminate — use `--prefer-role`. ## REPL @@ -100,11 +93,8 @@ ## Bugs -- [ ] **Auto-routing not implemented** — entry node does not inject kernel - routes for the exit peer's announced networks when a TUN is created. User - must run `ip route add dev ` manually after every connect. - This should be automatic and verified by the smoke test suite (add a smoke - test that checks routes exist after connect). +- [x] ~~**Auto-routing not implemented**~~ — done: `auto.rs` auto-installs + kernel routes for peer-advertised CIDRs when TUN is created. - [ ] **`disconnect_peer` by address or connection ID** — `wallhack_disconnect_peer` only accepts a peer name. Unnamed peers (relays that don't propagate names) are impossible to disconnect. Need either @@ -117,11 +107,8 @@ `update_capabilities()` now called in exit connect mode. - [x] ~~**Latency not measured on connect**~~ — done: initial ping after handshake + 30s heartbeat on all connection paths (entry, exit, auto). -- [ ] **Relay peer name not propagated to entry** — when a relay node connects, - the entry node sees it as an unnamed address (e.g. `10.99.1.10:48535`) - rather than the relay's declared name. This breaks deterministic TUN - naming (`peer_name_to_iface`) so TUN gets a random name instead of - `wh{hash}`. +- [x] ~~**Relay peer name not propagated to entry**~~ — fixed: relay extracts + peer name from handshake instead of using raw socket address. - [x] ~~**Relay peer role reported as `exit`**~~ — fixed: relay data plane wiring and `update_capabilities()` now correct. - [x] ~~**Stale TUN interfaces not cleaned up on disconnect**~~ — fixed in PR @@ -133,6 +120,10 @@ `TunDropGuard` for panic safety. - [x] ~~No color in `[+]` notification messages~~ — done, uses `nu-ansi-term` behind `repl` feature gate. +- [x] ~~**`ping` returns status info, not RTT**~~ — moot: `ping` command removed + in v0.12.0. +- [x] ~~**Initial heartbeat latency delayed ~30s**~~ — fixed: microsecond + timestamp resolution in v0.11.1. - [ ] Log prefix inconsistency in REPL — mix of `warn:` prefix (from `tracing::warn!`) and `[+]`/`[-]`/`[!]` prefixes (from notifications). Consolidate into a consistent style. Broader fix: unified logging format — @@ -150,10 +141,10 @@ ## UX -- [ ] **`--fixed-role` naming** — `--fixed-role relay` is confusing; "fixed" - implies overriding something. Prefer `--role relay` (or just make role a - positional subcommand). `--fixed-role` is used in static range setups as - the normal way to set a role. +- [x] ~~**`--fixed-role` naming**~~ — done: `hint` command eliminated, unified + into `role` command. `role entry` (hard), `role prefer entry` (soft), + `role exclude entry`, `role auto` (clear). Daemon flags: `--role`, + `--prefer-role`, `--exclude-role`. - [ ] **Relay `--listen` address underdocumented** — relay mode accepts both `--connect` (upstream) and `--listen` (for downstream peers) but neither the help text nor any docs explain the relay topology model, which @@ -408,12 +399,41 @@ `daemon/src/mode/mod.rs`, used in `auto.rs` and `entry.rs`. The subscriber's consecutive-dedup handles the common case (single attacker hammering from one IP) just as well. -- [ ] single character variable names anti-pattern: its pointless and confusing. - see the coding standards rules. shadow the original variable when cloning +- [x] ~~single character variable names anti-pattern~~ — done: full codebase + sweep shadowed all non-shadowed clones and renamed opaque abbreviations. - [ ] `neli` pinned at `0.6` (`crates/daemon/Cargo.toml`) — 0.7.4 available. Likely a breaking API change; needs migration of `crates/daemon/src/netlink.rs`. +### Channel sprawl refactor +- [ ] `ControlChannels` — 6-field struct, most `None`. Replace with + Handler/Registry direct references. Control loop already has + `Option<&Handler>` on server side; extend to client side. +- [x] ~~Eliminate `latency_tx`~~ — done in channel sprawl refactor. +- [x] ~~Eliminate `role_transition_tx`~~ — done in channel sprawl refactor. +- [x] ~~Deduplicate QUIC/WS client connect~~ — done (commit `624bc9c`). +- [x] ~~Deduplicate QUIC/WS server accept~~ — done (commit `afc7671`). +- [ ] IPC client: 3 channels → `IpcConnection` object with `request()` method +- [ ] Source/sink naming: replace `_tx`/`_rx` convention with `_source`/`_sink` +- [ ] `outgoing_rx` → `control_sink` or similar (oxymoron: receiving end of + outgoing messages) + +### Stale terminology (audit 2026-03-18) +- [x] ~~`StatusResponse` → `InfoResponse`~~ — done. +- [x] ~~`NodeStatus` → `NodeInfo`~~ — done. +- [x] ~~`fn status()` → `fn info()`~~ — done. +- [x] ~~`fn set_hint()` → `fn hint_set()`~~ — done. +- [x] ~~`fn clear_hints()` → `fn hint_set_auto()`~~ — done. +- [x] ~~`fn remove_route()` → `fn route_del()`~~ — done. +- [x] ~~`fn disconnect_peer()` → `fn peer_disconnect()`~~ — done. +- [x] ~~`fn ping_peer()` → `fn peer_ping()`~~ — moot: ping removed in v0.12.0. +- [x] ~~`SetHintParams` → `HintSetParams`~~ — done. +- [x] ~~`SetHintRequestBody` → `HintSetRequestBody`~~ — done. +- [x] ~~MCP "Remove a route" → "Delete a route"~~ — done. +- [x] ~~`downstream` in node_api.rs doc~~ — done. +- [ ] `client` variable in entry/session.rs, icmp.rs → `source` +- [x] ~~OpenAPI operationId consistency~~ — done (peerPing moot: ping removed). + ## Next batch: Phase 13f — Security Posture - [ ] When any auth flag (`--psk`, `--cert`, etc.) is provided, automatically harden config: suppress auto-negotiation and auto-routing. See From 16feb910b3cdef8a89f8d84311f5e498aaded1bb Mon Sep 17 00:00:00 2001 From: Max Holman Date: Fri, 20 Mar 2026 14:17:56 +0700 Subject: [PATCH 05/10] =?UTF-8?q?refactor(entry):=20rename=20client=20?= =?UTF-8?q?=E2=86=92=20source=20in=20ICMP=20and=20UDP=20domain=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per naming conventions: "client" is prohibited in domain logic. Renamed client_ip → source_ip, client_endpoint → source_endpoint, client_port → source_port across icmp.rs, manager.rs, session.rs. Also fixes duplicated doc comment in handler.rs. Co-Authored-By: Claude Opus 4.6 (1M context) --- TODO.md | 6 ++-- crates/core/src/control/handler.rs | 1 - crates/core/src/entry/icmp.rs | 44 +++++++++++++++--------------- crates/core/src/entry/manager.rs | 32 +++++++++++----------- crates/core/src/entry/session.rs | 6 ++-- 5 files changed, 43 insertions(+), 46 deletions(-) diff --git a/TODO.md b/TODO.md index 60e31014..7eaf887b 100644 --- a/TODO.md +++ b/TODO.md @@ -185,10 +185,8 @@ intent at the type level. - [x] ~~`Metrics` field visibility~~ — fields now private with `snapshot()` accessor returning `node_api::Metrics`. -- [ ] Redundant role conversion helper — `crates/core/src/negotiate.rs` has - `proto_to_core_role()` even though `impl From for NodeRole` - already exists in `crates/core/src/types.rs`. Replace the free helper with - `.into()` and remove the duplicate conversion logic. +- [x] ~~Redundant role conversion helper~~ — done: `proto_to_core_role()` + already removed; `.into()` used everywhere. - [ ] **Field-threading anti-pattern** — six call sites thread individual fields from `ErasedConnectResult`/`ErasedAcceptResult` instead of passing the struct whole. The existing `ExitContext` in `exit.rs` is the correct diff --git a/crates/core/src/control/handler.rs b/crates/core/src/control/handler.rs index cce970f8..6bd8a49f 100644 --- a/crates/core/src/control/handler.rs +++ b/crates/core/src/control/handler.rs @@ -125,7 +125,6 @@ pub struct Handler { } impl Handler { - /// Creates a new control handler. /// Creates a new control handler. /// /// `log_buffer`, when provided, is the shared ring buffer that the tracing diff --git a/crates/core/src/entry/icmp.rs b/crates/core/src/entry/icmp.rs index f5500545..eea139ee 100644 --- a/crates/core/src/entry/icmp.rs +++ b/crates/core/src/entry/icmp.rs @@ -13,27 +13,27 @@ use smoltcp::wire::{ #[must_use] pub fn build_icmp_dest_unreachable( reason: IcmpUnreachableReason, - client_ip: IpAddress, + source_ip: IpAddress, target_ip: IpAddress, target_port: u16, - client_port: u16, + source_port: u16, original_payload: &[u8], ) -> Option> { - match (client_ip, target_ip) { - (IpAddress::Ipv4(client), IpAddress::Ipv4(target)) => Some(build_icmpv4( + match (source_ip, target_ip) { + (IpAddress::Ipv4(source), IpAddress::Ipv4(target)) => Some(build_icmpv4( reason, - client, + source, target, target_port, - client_port, + source_port, original_payload, )), - (IpAddress::Ipv6(client), IpAddress::Ipv6(target)) => Some(build_icmpv6( + (IpAddress::Ipv6(source), IpAddress::Ipv6(target)) => Some(build_icmpv6( reason, - client, + source, target, target_port, - client_port, + source_port, original_payload, )), _ => None, @@ -79,10 +79,10 @@ fn build_udp_header_bytes(src_port: u16, dst_port: u16, payload_len: usize) -> [ fn build_icmpv4( reason: IcmpUnreachableReason, - client: smoltcp::wire::Ipv4Address, + source: smoltcp::wire::Ipv4Address, target: smoltcp::wire::Ipv4Address, target_port: u16, - client_port: u16, + source_port: u16, original_payload: &[u8], ) -> Vec { let icmp_reason = match reason { @@ -91,11 +91,11 @@ fn build_icmpv4( IcmpUnreachableReason::Net => Icmpv4DstUnreachable::NetUnreachable, }; - let udp_header = build_udp_header_bytes(client_port, target_port, original_payload.len()); + let udp_header = build_udp_header_bytes(source_port, target_port, original_payload.len()); // The "original" IP header that was in the triggering packet let inner_ip = Ipv4Repr { - src_addr: client, + src_addr: source, dst_addr: target, next_header: IpProtocol::Udp, payload_len: 8 + original_payload.len(), @@ -108,11 +108,11 @@ fn build_icmpv4( data: &udp_header, }; - // Outer IP header: from the target back to the client + // Outer IP header: from the target back to the source let icmp_len = icmp_repr.buffer_len(); let outer_ip = Ipv4Repr { src_addr: target, - dst_addr: client, + dst_addr: source, next_header: IpProtocol::Icmp, payload_len: icmp_len, hop_limit: 64, @@ -140,10 +140,10 @@ fn build_icmpv4( fn build_icmpv6( reason: IcmpUnreachableReason, - client: smoltcp::wire::Ipv6Address, + source: smoltcp::wire::Ipv6Address, target: smoltcp::wire::Ipv6Address, target_port: u16, - client_port: u16, + source_port: u16, original_payload: &[u8], ) -> Vec { let icmp_reason = match reason { @@ -152,11 +152,11 @@ fn build_icmpv6( IcmpUnreachableReason::Net => Icmpv6DstUnreachable::NoRoute, }; - let udp_header = build_udp_header_bytes(client_port, target_port, original_payload.len()); + let udp_header = build_udp_header_bytes(source_port, target_port, original_payload.len()); // The "original" IP header that was in the triggering packet let inner_ip = Ipv6Repr { - src_addr: client, + src_addr: source, dst_addr: target, next_header: IpProtocol::Udp, payload_len: 8 + original_payload.len(), @@ -169,11 +169,11 @@ fn build_icmpv6( data: &udp_header, }; - // Outer IP header: from the target back to the client + // Outer IP header: from the target back to the source let icmp_len = icmp_repr.buffer_len(); let outer_ip = Ipv6Repr { src_addr: target, - dst_addr: client, + dst_addr: source, next_header: IpProtocol::Icmpv6, payload_len: icmp_len, hop_limit: 64, @@ -190,7 +190,7 @@ fn build_icmpv6( let mut icmp_packet = Icmpv6Packet::new_unchecked(&mut buf[outer_ip.buffer_len()..]); icmp_repr.emit( &target, - &client, + &source, &mut icmp_packet, &smoltcp::phy::ChecksumCapabilities::default(), ); diff --git a/crates/core/src/entry/manager.rs b/crates/core/src/entry/manager.rs index 59dc0b91..79a623c5 100644 --- a/crates/core/src/entry/manager.rs +++ b/crates/core/src/entry/manager.rs @@ -311,7 +311,7 @@ impl ConnectionManager { let (src_std, dst_std): (std::net::SocketAddr, std::net::SocketAddr) = socket_set.into(); - let client_endpoint = smoltcp::wire::IpEndpoint { + let source_endpoint = smoltcp::wire::IpEndpoint { addr: src_std.ip().into(), port: src_std.port(), }; @@ -324,22 +324,22 @@ impl ConnectionManager { { // Update session last_seen if let Some(session) = - self.udp_sessions.get_mut(&(client_endpoint, local_port)) + self.udp_sessions.get_mut(&(source_endpoint, local_port)) { session.last_seen = Instant::now(); } let meta = smoltcp::socket::udp::UdpMetadata { - endpoint: client_endpoint, + endpoint: source_endpoint, local_address: local_ip, meta: smoltcp::phy::PacketMeta::default(), }; if let Err(e) = udp.send_to(local_port, &data_recv.data, meta) { - tracing::warn!("Failed to send UDP response to client: {e}"); + tracing::warn!("Failed to send UDP response to source: {e}"); } else { tracing::debug!( local_port, - client = %client_endpoint, - "UDP response sent to client" + source = %source_endpoint, + "UDP response sent to source" ); self.metrics.inc_packets_in(1); self.metrics.inc_bytes_in(data_recv.data.len() as u64); @@ -363,7 +363,7 @@ impl ConnectionManager { }; let (src_std, dst_std): (std::net::SocketAddr, std::net::SocketAddr) = socket_set.into(); - let client_endpoint = smoltcp::wire::IpEndpoint { + let source_endpoint = smoltcp::wire::IpEndpoint { addr: src_std.ip().into(), port: src_std.port(), }; @@ -374,7 +374,7 @@ impl ConnectionManager { tracing::debug!( reason = %err.reason, icmp_reason = ?reason, - client = %client_endpoint, + source = %source_endpoint, target = %target_ip, "UDP runtime error from exit, injecting ICMP unreachable" ); @@ -386,10 +386,10 @@ impl ConnectionManager { // are correctly reconstructed from the socket set below. if let Some(packet) = build_icmp_dest_unreachable( reason, - client_endpoint.addr, + source_endpoint.addr, target_ip, local_port, - client_endpoint.port, + source_endpoint.port, &[], ) && let Err(e) = self.tun_writer.get_ref().send(&packet) { @@ -605,11 +605,11 @@ fn build_icmpv4_echo_reply( // IP: src = target (where the ping went), dst = originator let target_ip: smoltcp::wire::Ipv4Address = *dst_v4.ip(); - let client_ip: smoltcp::wire::Ipv4Address = *src_v4.ip(); + let source_ip: smoltcp::wire::Ipv4Address = *src_v4.ip(); let ip_repr = Ipv4Repr { src_addr: target_ip, - dst_addr: client_ip, + dst_addr: source_ip, next_header: IpProtocol::Icmp, payload_len: reply.buffer_len(), hop_limit: 64, @@ -637,10 +637,10 @@ fn build_icmpv6_echo_reply( use smoltcp::wire::{Icmpv6Packet, Icmpv6Repr, IpProtocol, Ipv6Packet, Ipv6Repr}; let target_ip: smoltcp::wire::Ipv6Address = *dst_v6.ip(); - let client_ip: smoltcp::wire::Ipv6Address = *src_v6.ip(); + let source_ip: smoltcp::wire::Ipv6Address = *src_v6.ip(); let icmp_pkt = Icmpv6Packet::new_checked(raw_icmp).ok()?; - let repr = Icmpv6Repr::parse(&target_ip, &client_ip, &icmp_pkt, caps).ok()?; + let repr = Icmpv6Repr::parse(&target_ip, &source_ip, &icmp_pkt, caps).ok()?; let Icmpv6Repr::EchoReply { seq_no, data, .. } = repr else { return None; @@ -654,7 +654,7 @@ fn build_icmpv6_echo_reply( let ip_repr = Ipv6Repr { src_addr: target_ip, - dst_addr: client_ip, + dst_addr: source_ip, next_header: IpProtocol::Icmpv6, payload_len: reply.buffer_len(), hop_limit: 64, @@ -667,7 +667,7 @@ fn build_icmpv6_echo_reply( ip_repr.emit(&mut ip_pkt); let mut icmp_out = Icmpv6Packet::new_unchecked(&mut buf[ip_repr.buffer_len()..]); - reply.emit(&target_ip, &client_ip, &mut icmp_out, caps); + reply.emit(&target_ip, &source_ip, &mut icmp_out, caps); Some(buf) } diff --git a/crates/core/src/entry/session.rs b/crates/core/src/entry/session.rs index 5eaf5abf..8b3f1627 100644 --- a/crates/core/src/entry/session.rs +++ b/crates/core/src/entry/session.rs @@ -28,8 +28,8 @@ where D: smoltcp::phy::Device + Send + 'static, { // In AnyIP mode, smoltcp accepts connections destined for any IP. - // local_endpoint = the destination the client wanted (e.g., 10.200.2.10:9999) - // remote_endpoint = the client's source address (e.g., 10.200.1.10:54016) + // local_endpoint = the destination the source wanted (e.g., 10.200.2.10:9999) + // remote_endpoint = the source's address (e.g., 10.200.1.10:54016) let target = local .local_endpoint() .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotConnected, "missing local"))?; @@ -47,7 +47,7 @@ where remote.write_proto(&header).await?; // Wait for exit node to confirm the connection succeeded before copying data. - // Without this, smoltcp has already SYN-ACKed the client but we don't know + // Without this, smoltcp has already SYN-ACKed the source but we don't know // if the real target is reachable. On failure, dropping `local` sends RST. let status: TcpStreamStatus = remote .read_proto(crate::transport::protocol::TCP_STREAM_HEADER_MTU) From 51ec8e13962a378237f516f47f5034585eed53cc Mon Sep 17 00:00:00 2001 From: Max Holman Date: Fri, 20 Mar 2026 14:19:54 +0700 Subject: [PATCH 06/10] refactor: remove PskFailTracker in favour of subscriber dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SimpleSubscriber already deduplicates consecutive identical log lines. Including the peer address in the warn message is sufficient — the per-IP HashMap tracker added complexity without meaningful benefit. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/daemon/src/mode/auto.rs | 4 +--- crates/daemon/src/mode/entry.rs | 8 +------- crates/daemon/src/mode/mod.rs | 31 ------------------------------- 3 files changed, 2 insertions(+), 41 deletions(-) diff --git a/crates/daemon/src/mode/auto.rs b/crates/daemon/src/mode/auto.rs index 0ad24245..5d2e2086 100644 --- a/crates/daemon/src/mode/auto.rs +++ b/crates/daemon/src/mode/auto.rs @@ -757,8 +757,6 @@ where tracing::info!("PSK authentication configured"); } - let mut psk_failures = super::PskFailTracker::new(); - loop { match server.accept(NodeRole::Indeterminate).await { Ok(Some(mut accept_result)) => { @@ -774,7 +772,7 @@ where .as_ref() .is_some_and(|b| hs.verify_psk_proof(psk.as_bytes(), b)); if !valid { - psk_failures.record(&peer_addr); + tracing::warn!("PSK authentication failed for {peer_addr}"); continue; } } diff --git a/crates/daemon/src/mode/entry.rs b/crates/daemon/src/mode/entry.rs index 8190d39b..65b376f7 100644 --- a/crates/daemon/src/mode/entry.rs +++ b/crates/daemon/src/mode/entry.rs @@ -697,8 +697,6 @@ where max_peers.unwrap_or(tokio::sync::Semaphore::MAX_PERMITS), )); - let mut psk_failures = super::PskFailTracker::new(); - // Main loop: handle incoming connections loop { match server.accept(NodeRole::Entry).await { @@ -726,11 +724,7 @@ where ) { Ok(id) => id, Err(e) => { - if matches!(&e, NodeError::PskAuth(_)) { - psk_failures.record(&peer_addr); - } else { - tracing::warn!("Handshake validation failed for {peer_addr}: {e}"); - } + tracing::warn!("Handshake failed for {peer_addr}: {e}"); continue; } }; diff --git a/crates/daemon/src/mode/mod.rs b/crates/daemon/src/mode/mod.rs index 3fa310ac..1cca80f9 100644 --- a/crates/daemon/src/mode/mod.rs +++ b/crates/daemon/src/mode/mod.rs @@ -20,37 +20,6 @@ use crate::{ daemon_config::{DaemonConfig, ModeConfig}, }; -/// Deduplicates repeated PSK authentication failure logs per source IP. -/// -/// Keys on IP only (strips port) so reconnects from the same host with -/// different ephemeral ports are correctly deduplicated. -/// Logs on the first failure and at power-of-two counts (1, 2, 4, 8, …). -pub(crate) struct PskFailTracker { - counts: std::collections::HashMap, -} - -impl PskFailTracker { - pub fn new() -> Self { - Self { - counts: std::collections::HashMap::new(), - } - } - - /// Record a failure for `addr` (ip:port). Logs with dedup (first + powers of two). - pub fn record(&mut self, addr: &str) { - let ip = addr - .parse::() - .map(|sa| sa.ip()) - .or_else(|_| addr.parse::()) - .unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)); - let count = self.counts.entry(ip).or_insert(0); - *count += 1; - if *count == 1 || count.is_power_of_two() { - tracing::warn!("PSK authentication failed for {ip} (x{count})"); - } - } -} - /// Shared resources available to all node modes. pub(crate) struct NodeResources { pub metrics: Arc, From a06db1d54253b7555b928e9d8cb6b827675288ef Mon Sep 17 00:00:00 2001 From: Max Holman Date: Fri, 20 Mar 2026 16:48:08 +0700 Subject: [PATCH 07/10] =?UTF-8?q?chore:=20migrate=20neli=200.6=20=E2=86=92?= =?UTF-8?q?=200.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit neli 0.7 makes struct fields private (accessor methods), replaces struct literals with builders, moves NlSocketHandle to socket::synchronous, and changes flag types. Also extracts a shared recv_netlink_ack helper to deduplicate the route add/remove ACK handling. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 26 +++- crates/daemon/Cargo.toml | 2 +- crates/daemon/src/netlink.rs | 284 ++++++++++++++++++++--------------- 3 files changed, 182 insertions(+), 130 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 725b18d3..2dd3fec6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1304,6 +1304,18 @@ dependencies = [ "wasip3", ] +[[package]] +name = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "gimli" version = "0.32.3" @@ -1966,27 +1978,31 @@ checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" [[package]] name = "neli" -version = "0.6.5" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93062a0dce6da2517ea35f301dfc88184ce18d3601ec786a727a87bf535deca9" +checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" dependencies = [ + "bitflags 2.11.0", "byteorder", + "derive_builder", + "getset", "libc", "log", "neli-proc-macros", + "parking_lot", ] [[package]] name = "neli-proc-macros" -version = "0.1.4" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8034b7fbb6f9455b2a96c19e6edf8dc9fc34c70449938d8ee3b4df363f61fe" +checksum = "05d8d08c6e98f20a62417478ebf7be8e1425ec9acecc6f63e22da633f6b71609" dependencies = [ "either", "proc-macro2", "quote", "serde", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] diff --git a/crates/daemon/Cargo.toml b/crates/daemon/Cargo.toml index 8e6812c5..b63df89a 100644 --- a/crates/daemon/Cargo.toml +++ b/crates/daemon/Cargo.toml @@ -36,7 +36,7 @@ wallhack-ipc = { path = "../ipc", optional = true } console-subscriber = { workspace = true, optional = true } parking_lot = "0.12.5" zeroize = "1" -neli = "0.6" +neli = "0.7" [lints] workspace = true diff --git a/crates/daemon/src/netlink.rs b/crates/daemon/src/netlink.rs index f1f004d8..f8536ad5 100644 --- a/crates/daemon/src/netlink.rs +++ b/crates/daemon/src/netlink.rs @@ -4,15 +4,15 @@ use std::{net::IpAddr, str::FromStr}; use neli::{ consts::{ - nl::{NlmF, NlmFFlags}, - rtnl::{Ifa, IfaFFlags, RtAddrFamily, RtScope, RtTable, Rta, Rtm, RtmFFlags, Rtn, Rtprot}, + nl::NlmF, + rtnl::{Ifa, IfaF, RtAddrFamily, RtScope, RtTable, Rta, Rtm, RtmF, Rtn, Rtprot}, socket::NlFamily, }, - err::Nlmsgerr, - nl::{NlPayload, Nlmsghdr}, - rtnl::{Ifaddrmsg, Rtattr, Rtmsg}, - socket::NlSocketHandle, + nl::{NlPayload, NlmsghdrBuilder}, + rtnl::{Ifaddrmsg, IfaddrmsgBuilder, RtattrBuilder, RtmsgBuilder}, + socket::synchronous::NlSocketHandle, types::RtBuffer, + utils::Groups, }; use wallhack_core::Cidr; @@ -32,7 +32,7 @@ pub(crate) fn remove_os_route(cidr: &str, dev: &str) -> Result<(), String> { let if_index = get_if_index(dev).map_err(|e| format!("Failed to resolve interface {dev}: {e}"))?; - let mut socket = NlSocketHandle::connect(NlFamily::Route, None, &[]) + let mut socket = NlSocketHandle::connect(NlFamily::Route, None, Groups::empty()) .map_err(|e| format!("Netlink connect failed: {e}"))?; let (rt_family, dst_bytes) = match cidr.addr() { @@ -41,57 +41,47 @@ pub(crate) fn remove_os_route(cidr: &str, dev: &str) -> Result<(), String> { }; let mut rtattrs = RtBuffer::new(); - rtattrs.push(Rtattr::new(None, Rta::Dst, dst_bytes).unwrap()); + rtattrs.push( + RtattrBuilder::default() + .rta_type(Rta::Dst) + .rta_payload(dst_bytes) + .build() + .unwrap(), + ); #[allow(clippy::cast_possible_wrap)] - rtattrs.push(Rtattr::new(None, Rta::Oif, if_index as i32).unwrap()); - - let rtmsg = Rtmsg { - rtm_family: rt_family, - rtm_dst_len: cidr.prefix_len(), - rtm_src_len: 0, - rtm_tos: 0, - rtm_table: RtTable::Main, - rtm_protocol: Rtprot::Boot, - rtm_scope: RtScope::Universe, - rtm_type: Rtn::Unicast, - rtm_flags: RtmFFlags::empty(), - rtattrs, - }; - - let nlmsg = Nlmsghdr::new( - None, - Rtm::Delroute, - NlmFFlags::new(&[NlmF::Request, NlmF::Ack]), - None, - None, - NlPayload::Payload(rtmsg), + rtattrs.push( + RtattrBuilder::default() + .rta_type(Rta::Oif) + .rta_payload(if_index as i32) + .build() + .unwrap(), ); - match socket.send(nlmsg) { - Ok(()) => match socket.recv::>() { - Ok(Some(msg)) => { - if msg.nl_type == 2 { - if let NlPayload::Payload(e) = msg.nl_payload { - if e.error == 0 || e.error == -3 { - // Success or ESRCH (not found — already gone) - Ok(()) - } else { - let err_msg = format!("Netlink error: {}", e.error); - tracing::warn!("Failed to remove OS route: {}", err_msg); - Err(err_msg) - } - } else { - Err("Unexpected payload in ACK".into()) - } - } else { - Err(format!("Unexpected message type: {}", msg.nl_type)) - } - } - Ok(None) => Err("Netlink socket closed unexpectedly".into()), - Err(e) => Err(format!("Failed to receive Netlink ACK: {e}")), - }, - Err(e) => Err(format!("Failed to send Netlink request: {e}")), - } + let rtmsg = RtmsgBuilder::default() + .rtm_family(rt_family) + .rtm_dst_len(cidr.prefix_len()) + .rtm_src_len(0) + .rtm_tos(0) + .rtm_table(RtTable::Main) + .rtm_protocol(Rtprot::Boot) + .rtm_scope(RtScope::Universe) + .rtm_type(Rtn::Unicast) + .rtm_flags(RtmF::empty()) + .rtattrs(rtattrs) + .build() + .unwrap(); + + let nlmsg = NlmsghdrBuilder::default() + .nl_type(Rtm::Delroute) + .nl_flags(NlmF::REQUEST | NlmF::ACK) + .nl_payload(NlPayload::Payload(rtmsg)) + .build() + .map_err(|e| format!("Failed to build netlink message: {e}"))?; + + socket + .send(&nlmsg) + .map_err(|e| format!("Failed to send Netlink request: {e}"))?; + recv_netlink_ack(&mut socket, "remove OS route") } /// Add an OS-level route via Netlink. @@ -100,7 +90,7 @@ pub(crate) fn add_os_route(cidr: &str, dev: &str) -> Result<(), String> { let if_index = get_if_index(dev).map_err(|e| format!("Failed to resolve interface {dev}: {e}"))?; - let mut socket = NlSocketHandle::connect(NlFamily::Route, None, &[]) + let mut socket = NlSocketHandle::connect(NlFamily::Route, None, Groups::empty()) .map_err(|e| format!("Netlink connect failed: {e}"))?; let (rt_family, dst_bytes) = match cidr.addr() { @@ -109,56 +99,87 @@ pub(crate) fn add_os_route(cidr: &str, dev: &str) -> Result<(), String> { }; let mut rtattrs = RtBuffer::new(); - rtattrs.push(Rtattr::new(None, Rta::Dst, dst_bytes).unwrap()); + rtattrs.push( + RtattrBuilder::default() + .rta_type(Rta::Dst) + .rta_payload(dst_bytes) + .build() + .unwrap(), + ); #[allow(clippy::cast_possible_wrap)] - rtattrs.push(Rtattr::new(None, Rta::Oif, if_index as i32).unwrap()); - - let rtmsg = Rtmsg { - rtm_family: rt_family, - rtm_dst_len: cidr.prefix_len(), - rtm_src_len: 0, - rtm_tos: 0, - rtm_table: RtTable::Main, - rtm_protocol: Rtprot::Boot, - rtm_scope: RtScope::Universe, - rtm_type: Rtn::Unicast, - rtm_flags: RtmFFlags::empty(), - rtattrs, - }; - - let nlmsg = Nlmsghdr::new( - None, - Rtm::Newroute, - NlmFFlags::new(&[NlmF::Request, NlmF::Create, NlmF::Excl, NlmF::Ack]), - None, - None, - NlPayload::Payload(rtmsg), + rtattrs.push( + RtattrBuilder::default() + .rta_type(Rta::Oif) + .rta_payload(if_index as i32) + .build() + .unwrap(), ); - match socket.send(nlmsg) { - Ok(()) => match socket.recv::>() { - Ok(Some(msg)) => { - if msg.nl_type == 2 { - if let NlPayload::Payload(e) = msg.nl_payload { - if e.error == 0 || e.error == -17 { - // Success or EEXIST (route already present) - Ok(()) - } else { - let err_msg = format!("Netlink error: {}", e.error); - tracing::warn!("Failed to add OS route: {}", err_msg); - Err(err_msg) - } - } else { - Err("Unexpected payload in ACK".into()) - } + let rtmsg = RtmsgBuilder::default() + .rtm_family(rt_family) + .rtm_dst_len(cidr.prefix_len()) + .rtm_src_len(0) + .rtm_tos(0) + .rtm_table(RtTable::Main) + .rtm_protocol(Rtprot::Boot) + .rtm_scope(RtScope::Universe) + .rtm_type(Rtn::Unicast) + .rtm_flags(RtmF::empty()) + .rtattrs(rtattrs) + .build() + .unwrap(); + + let nlmsg = NlmsghdrBuilder::default() + .nl_type(Rtm::Newroute) + .nl_flags(NlmF::REQUEST | NlmF::CREATE | NlmF::EXCL | NlmF::ACK) + .nl_payload(NlPayload::Payload(rtmsg)) + .build() + .map_err(|e| format!("Failed to build netlink message: {e}"))?; + + socket + .send(&nlmsg) + .map_err(|e| format!("Failed to send Netlink request: {e}"))?; + recv_netlink_ack(&mut socket, "add OS route") +} + +/// Receive and check the Netlink ACK/error response. +/// +/// `NLMSG_ERROR` (type 2) carries a 4-byte `i32` error code at the start of its +/// payload. Error 0 = success (pure ACK), negative = errno. +/// `-3` (ESRCH) after route delete and `-17` (EEXIST) after route add are +/// treated as success (idempotent operations). +fn recv_netlink_ack(socket: &mut NlSocketHandle, op: &str) -> Result<(), String> { + let (mut iter, _groups) = socket + .recv::() + .map_err(|e| format!("Failed to receive Netlink ACK: {e}"))?; + + let Some(msg_result) = iter.next() else { + return Err("Netlink socket closed unexpectedly".into()); + }; + let msg = msg_result.map_err(|e| format!("Netlink recv error: {e}"))?; + + // NLMSG_ERROR = 2 + if *msg.nl_type() == 2 { + if let NlPayload::Payload(buf) = msg.nl_payload() { + let bytes: &[u8] = buf.as_ref(); + if bytes.len() >= 4 { + let error = i32::from_ne_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); + // 0 = success, -3 = ESRCH (already gone), -17 = EEXIST (already present) + if error == 0 || error == -3 || error == -17 { + Ok(()) } else { - Err(format!("Unexpected message type: {}", msg.nl_type)) + let err_msg = format!("Netlink error: {error}"); + tracing::warn!("Failed to {op}: {err_msg}"); + Err(err_msg) } + } else { + Err("Netlink ACK payload too short".into()) } - Ok(None) => Err("Netlink socket closed unexpectedly".into()), - Err(e) => Err(format!("Failed to receive Netlink ACK: {e}")), - }, - Err(e) => Err(format!("Failed to send Netlink request: {e}")), + } else { + Err("Unexpected payload in ACK".into()) + } + } else { + Err(format!("Unexpected message type: {}", msg.nl_type())) } } @@ -197,7 +218,7 @@ pub(crate) fn delete_tun(name: &str) { /// Only `RT_SCOPE_UNIVERSE` (globally routable) addresses are included. /// Loopback, link-local, unspecified, and multicast addresses are skipped. pub(crate) fn enumerate_local_cidrs() -> Vec { - let mut socket = match NlSocketHandle::connect(NlFamily::Route, None, &[]) { + let socket = match NlSocketHandle::connect(NlFamily::Route, None, Groups::empty()) { Ok(s) => s, Err(e) => { tracing::warn!("netlink: cannot open socket for address enumeration: {e}"); @@ -205,30 +226,45 @@ pub(crate) fn enumerate_local_cidrs() -> Vec { } }; - let request = Nlmsghdr::new( - None, - Rtm::Getaddr, - NlmFFlags::new(&[NlmF::Request, NlmF::Dump]), - None, - None, - NlPayload::Payload(Ifaddrmsg { - ifa_family: RtAddrFamily::Unspecified, - ifa_prefixlen: 0, - ifa_flags: IfaFFlags::empty(), - ifa_scope: 0, - ifa_index: 0, - rtattrs: RtBuffer::new(), - }), - ); + let request = match NlmsghdrBuilder::default() + .nl_type(Rtm::Getaddr) + .nl_flags(NlmF::REQUEST | NlmF::DUMP) + .nl_payload(NlPayload::Payload( + IfaddrmsgBuilder::default() + .ifa_family(RtAddrFamily::Unspecified) + .ifa_prefixlen(0) + .ifa_flags(IfaF::empty()) + .ifa_scope(RtScope::Universe) + .ifa_index(0) + .rtattrs(RtBuffer::new()) + .build() + .unwrap(), + )) + .build() + { + Ok(r) => r, + Err(e) => { + tracing::warn!("netlink: failed to build RTM_GETADDR request: {e}"); + return Vec::new(); + } + }; - if let Err(e) = socket.send(request) { + if let Err(e) = socket.send(&request) { tracing::warn!("netlink: failed to send RTM_GETADDR: {e}"); return Vec::new(); } let mut cidrs = Vec::new(); - for msg in socket.iter::(false) { + let (iter, _groups) = match socket.recv::() { + Ok(r) => r, + Err(e) => { + tracing::warn!("netlink: failed to recv RTM_GETADDR: {e}"); + return Vec::new(); + } + }; + + for msg in iter { let msg = match msg { Ok(m) => m, Err(e) => { @@ -237,22 +273,22 @@ pub(crate) fn enumerate_local_cidrs() -> Vec { } }; - let NlPayload::Payload(ifaddrmsg) = msg.nl_payload else { + let NlPayload::Payload(ifaddrmsg) = msg.nl_payload() else { continue; }; - // Only globally routable addresses (RT_SCOPE_UNIVERSE = 0). - if ifaddrmsg.ifa_scope != 0 { + // Only globally routable addresses. + if *ifaddrmsg.ifa_scope() != RtScope::Universe { continue; } - let prefix_len = ifaddrmsg.ifa_prefixlen; + let prefix_len = *ifaddrmsg.ifa_prefixlen(); if prefix_len == 0 { // Skip default routes. continue; } - let handle = ifaddrmsg.rtattrs.get_attr_handle(); + let handle = ifaddrmsg.rtattrs().get_attr_handle(); // IFA_LOCAL is preferred for point-to-point links; IFA_ADDRESS is the // typical case for broadcast interfaces. @@ -260,7 +296,7 @@ pub(crate) fn enumerate_local_cidrs() -> Vec { .get_attribute(Ifa::Local) .or_else(|| handle.get_attribute(Ifa::Address)) { - Some(attr) => attr.rta_payload.as_ref(), + Some(attr) => attr.rta_payload().as_ref(), None => continue, }; From 393e44fcb9bf2978d4adc2c09ac211722258b01f Mon Sep 17 00:00:00 2001 From: Max Holman Date: Fri, 20 Mar 2026 16:48:40 +0700 Subject: [PATCH 08/10] =?UTF-8?q?chore:=20mark=20completed=20TODO=20items?= =?UTF-8?q?=20(PskFailTracker,=20client=E2=86=92source,=20neli)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- TODO.md | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/TODO.md b/TODO.md index 7eaf887b..b0815c66 100644 --- a/TODO.md +++ b/TODO.md @@ -392,16 +392,12 @@ ## Code Quality & QOL -- [ ] Remove `PskFailTracker` — replace with generic subscriber dedup by - including IP in the log message. `PskFailTracker` is a per-IP HashMap in - `daemon/src/mode/mod.rs`, used in `auto.rs` and `entry.rs`. The subscriber's - consecutive-dedup handles the common case (single attacker hammering from one - IP) just as well. +- [x] ~~Remove `PskFailTracker`~~ — done: subscriber dedup handles the common + case; plain `tracing::warn!` with peer address is sufficient. - [x] ~~single character variable names anti-pattern~~ — done: full codebase sweep shadowed all non-shadowed clones and renamed opaque abbreviations. -- [ ] `neli` pinned at `0.6` (`crates/daemon/Cargo.toml`) — 0.7.4 available. - Likely a breaking API change; needs migration of - `crates/daemon/src/netlink.rs`. +- [x] ~~`neli` pinned at `0.6`~~ — done: migrated to 0.7 (builder API, private + fields, synchronous socket module). ### Channel sprawl refactor - [ ] `ControlChannels` — 6-field struct, most `None`. Replace with @@ -429,7 +425,7 @@ - [x] ~~`SetHintRequestBody` → `HintSetRequestBody`~~ — done. - [x] ~~MCP "Remove a route" → "Delete a route"~~ — done. - [x] ~~`downstream` in node_api.rs doc~~ — done. -- [ ] `client` variable in entry/session.rs, icmp.rs → `source` +- [x] ~~`client` variable in entry/session.rs, icmp.rs → `source`~~ — done. - [x] ~~OpenAPI operationId consistency~~ — done (peerPing moot: ping removed). ## Next batch: Phase 13f — Security Posture From f5baa8d016eb6138981075564c860792203c06fa Mon Sep 17 00:00:00 2001 From: Max Holman Date: Fri, 20 Mar 2026 16:49:46 +0700 Subject: [PATCH 09/10] chore: remove stale website.just TODO (file is in the correct location) Co-Authored-By: Claude Opus 4.6 (1M context) --- TODO.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/TODO.md b/TODO.md index b0815c66..142ae198 100644 --- a/TODO.md +++ b/TODO.md @@ -437,6 +437,3 @@ ## CLI - [x] ~~`wallhack peers --json`~~ — done: `--json` output matching REST API shape with `tun_name` field. - -## Website -- [ ] `website.just` file is in the wrong place? \ No newline at end of file From 845f50284daaf91ef86c41f57a2ccbd53705bede Mon Sep 17 00:00:00 2001 From: Max Holman Date: Fri, 20 Mar 2026 16:53:28 +0700 Subject: [PATCH 10/10] chore: add .gitattributes to force Cargo.lock merge conflicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marks Cargo.lock as binary for merge purposes so git never silently auto-merges it — forces regeneration on conflict instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitattributes | 3 + Cargo.lock | 168 +++++++++++++++++++++++++------------------------ 2 files changed, 90 insertions(+), 81 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..df05eaf7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Force merge conflicts on lock files so they are always regenerated, never +# silently auto-merged with potentially wrong dependency resolution. +Cargo.lock merge=binary diff --git a/Cargo.lock b/Cargo.lock index 93f52fc0..253a81f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,9 +58,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" @@ -108,9 +108,9 @@ dependencies = [ [[package]] name = "argh" -version = "0.1.14" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f384d96bfd3c0b3c41f24dae69ee9602c091d64fc432225cf5295b5abbe0036" +checksum = "211818e820cda9ca6f167a64a5c808837366a6dfd807157c64c1304c486cd033" dependencies = [ "argh_derive", "argh_shared", @@ -118,9 +118,9 @@ dependencies = [ [[package]] name = "argh_derive" -version = "0.1.14" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "938e5f66269c1f168035e29ed3fb437b084e476465e9314a0328f4005d7be599" +checksum = "c442a9d18cef5dde467405d27d461d080d68972d6d0dfd0408265b6749ec427d" dependencies = [ "argh_shared", "proc-macro2", @@ -130,9 +130,9 @@ dependencies = [ [[package]] name = "argh_shared" -version = "0.1.14" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5127f8a5bc1cfb0faf1f6248491452b8a5b6901068d8da2d47cbb285986ae683" +checksum = "e5ade012bac4db278517a0132c8c10c6427025868dca16c801087c28d5a411f1" dependencies = [ "serde", ] @@ -213,9 +213,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" dependencies = [ "aws-lc-sys", "zeroize", @@ -223,9 +223,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" dependencies = [ "cc", "cmake", @@ -471,9 +471,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "jobserver", @@ -536,18 +536,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstyle", "clap_lex", @@ -555,9 +555,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cmake" @@ -570,9 +570,9 @@ dependencies = [ [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "concurrent-queue" @@ -1286,20 +1286,20 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -1568,7 +1568,7 @@ dependencies = [ "hyper", "libc", "pin-project-lite", - "socket2 0.6.2", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -1736,9 +1736,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "is-terminal" @@ -1786,9 +1786,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jobserver" @@ -1802,9 +1802,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.90" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14dc6f6450b3f6d4ed5b16327f38fed626d375a886159ca555bd7822c0c3a5a6" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ "once_cell", "wasm-bindgen", @@ -1824,9 +1824,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.182" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libloading" @@ -1955,9 +1955,9 @@ dependencies = [ [[package]] name = "moka" -version = "0.12.13" +version = "0.12.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" +checksum = "85f8024e1c8e71c778968af91d43700ce1d11b219d127d79fb2934153b82b42b" dependencies = [ "crossbeam-channel", "crossbeam-epoch", @@ -2090,9 +2090,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" dependencies = [ "critical-section", "portable-atomic", @@ -2196,18 +2196,18 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", @@ -2216,9 +2216,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -2228,9 +2228,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "piper" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", "fastrand", @@ -2437,7 +2437,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.2", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -2474,16 +2474,16 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.2", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -2494,6 +2494,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -3016,12 +3022,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3132,12 +3138,12 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "tempfile" -version = "3.26.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.1", + "getrandom 0.4.2", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -3269,9 +3275,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -3284,16 +3290,16 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.49.0" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", "mio", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.2", + "socket2 0.6.3", "tokio-macros", "tracing", "windows-sys 0.61.2", @@ -3301,9 +3307,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", @@ -3530,9 +3536,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", @@ -3669,11 +3675,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.21.0" +version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ - "getrandom 0.4.1", + "getrandom 0.4.2", "js-sys", "wasm-bindgen", ] @@ -3981,9 +3987,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.113" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60722a937f594b7fde9adb894d7c092fc1bb6612897c46368d18e7a20208eff2" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -3994,9 +4000,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.113" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fac8c6395094b6b91c4af293f4c79371c163f9a6f56184d2c9a85f5a95f3950" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4004,9 +4010,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.113" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3fabce6159dc20728033842636887e4877688ae94382766e00b180abac9d60" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", @@ -4017,9 +4023,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.113" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de0e091bdb824da87dc01d967388880d017a0a9bc4f3bdc0d86ee9f9336e3bb5" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] @@ -4493,18 +4499,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.39" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.39" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote",