diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..df05eaf --- /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/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index e96ce17..1ccceba 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 diff --git a/Cargo.lock b/Cargo.lock index 633c2d2..253a81f 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", @@ -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", @@ -469,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", @@ -534,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", @@ -553,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" @@ -568,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" @@ -1284,24 +1286,36 @@ 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", ] +[[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" @@ -1554,7 +1568,7 @@ dependencies = [ "hyper", "libc", "pin-project-lite", - "socket2 0.6.2", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -1722,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" @@ -1772,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" @@ -1788,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", @@ -1810,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" @@ -1941,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", @@ -1964,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]] @@ -2072,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", @@ -2178,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", @@ -2198,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" @@ -2210,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", @@ -2419,7 +2437,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.2", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -2456,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", ] @@ -2476,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" @@ -2766,6 +2790,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 +2908,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" @@ -2980,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]] @@ -3096,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", @@ -3233,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", ] @@ -3248,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", @@ -3265,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", @@ -3494,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", @@ -3633,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", ] @@ -3945,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", @@ -3958,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", @@ -3968,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", @@ -3981,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", ] @@ -4457,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", diff --git a/TODO.md b/TODO.md index 0171859..142ae19 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 @@ -194,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 @@ -403,16 +392,41 @@ ## 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. -- [ ] single character variable names anti-pattern: its pointless and confusing. - see the coding standards rules. shadow the original variable when cloning -- [ ] `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] ~~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. +- [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 + 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. +- [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 - [ ] When any auth flag (`--psk`, `--cert`, etc.) is provided, automatically @@ -423,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 diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index 9e873df..d5fe481 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 3d16feb..5115552 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 bfa7323..72c419f 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 30390e5..54693fb 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 30f5d0b..602b85e 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 d219544..3633ac7 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 dbe024c..10562f0 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 7c01c49..08aaf1b 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 2bec4ef..6bd8a49 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,10 @@ pub struct Handler { impl 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 +137,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 +516,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 +543,7 @@ mod tests { peers, routes, tokio::sync::broadcast::channel(16).0, + None, ) } @@ -570,6 +584,7 @@ mod tests { peers, routes, tokio::sync::broadcast::channel(16).0, + None, ); let request = ControlRequest { request: Some(control_request::Request::Stats( @@ -733,6 +748,7 @@ mod tests { peers, routes, tokio::sync::broadcast::channel(16).0, + None, ); let request = ControlRequest { @@ -769,6 +785,7 @@ mod tests { peers, routes, tokio::sync::broadcast::channel(16).0, + None, ); let status = crate::node_api::NodeApi::info(&handler); @@ -823,6 +840,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 0000000..37502cf --- /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 9a88da0..cdc3cc1 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 5cba3ba..e894099 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/entry/icmp.rs b/crates/core/src/entry/icmp.rs index f550054..eea139e 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 59dc0b9..79a623c 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 5eaf5ab..8b3f162 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) diff --git a/crates/core/src/ipc.rs b/crates/core/src/ipc.rs index 98d7fef..e3a10f8 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 e5665f0..7d29472 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 93bfc6c..fc92520 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 8478ba7..4716ca1 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/Cargo.toml b/crates/daemon/Cargo.toml index 8e6812c..b63df89 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/lib.rs b/crates/daemon/src/lib.rs index 1843478..a9984d2 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/auto.rs b/crates/daemon/src/mode/auto.rs index 0ad2424..5d2e208 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 cb53bce..65b376f 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; } }; @@ -1120,6 +1114,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/daemon/src/mode/mod.rs b/crates/daemon/src/mode/mod.rs index 3fa310a..1cca80f 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, diff --git a/crates/daemon/src/netlink.rs b/crates/daemon/src/netlink.rs index f1f004d..f8536ad 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, }; diff --git a/crates/mcp/src/convert.rs b/crates/mcp/src/convert.rs index a72c0c3..3ec68ff 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 581b355..c4ad653 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 8ef5f22..460c52b 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/justfile b/justfile index 8644944..0fa37be 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 diff --git a/website/src/data/openapi.json b/website/src/data/openapi.json index 2c85c03..1c548b1 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",