237 Commits
329 changed files with 34446 additions and 2657 deletions

No files matched your search

+31 -3
View File
@@ -1,5 +1,33 @@
[build]
target = "armv7-unknown-linux-musleabihf"
[target.armv7-unknown-linux-musleabihf] [target.armv7-unknown-linux-musleabihf]
linker = "rust-lld" linker = "rust-lld"
# [target.aarch64-unknown-linux-gnu]
# linker = "aarch64-linux-gnu-gcc"
[build]
# target = "armv7-unknown-linux-musleabihf"
[alias]
cl = "c --target=armv7-unknown-linux-musleabihf"
bl = "b --target=armv7-unknown-linux-musleabihf"
tl = "t --target=armv7-unknown-linux-musleabihf"
cw = "c --target=x86_64-pc-windows-msvc"
bw = "b --target=x86_64-pc-windows-msvc"
tw = "t --target=x86_64-pc-windows-msvc"
check-all = "check --workspace --all-features --all-targets"
clippy-all = "clippy --workspace --all-targets --all-features" # -- -D warnings
test-all = "test --workspace --all-features --all-targets"
fmt-all = "fmt --all"
# idk below
ldar = "r -qrp"
ldabr = "b -r --target=armv7-unknown-linux-musleabihf"
t = "test -- --nocapture --test-threads=1"
tdebug = "test -- --nocapture --test-threads=1 --show-output"
[registries.gitea]
index = "sparse+https://git.ldlda.com/api/packages/lda/cargo/" # Sparse index
+1
View File
@@ -0,0 +1 @@
SQLX_OFFLINE=true
+66 -9
View File
@@ -7,9 +7,14 @@ on:
- "v*" - "v*"
workflow_dispatch: workflow_dispatch:
env:
# When using cargo-zigbuild for *-gnu targets, link against this glibc baseline (wider VPS compatibility).
WAKEY_CC_GLIBC_VERSION: "2.28"
jobs: jobs:
build: build:
runs-on: [self-hosted, windows] # My Machine has arm-musl stuff - or else i have to use cross :(
runs-on: [self-hosted, linux, fedora, wsl, release]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -20,6 +25,8 @@ jobs:
rustc -V rustc -V
cargo -V cargo -V
rustup target add armv7-unknown-linux-musleabihf rustup target add armv7-unknown-linux-musleabihf
rustup target add x86_64-unknown-linux-gnu
rustup target add aarch64-unknown-linux-gnu
- name: Cache cargo - name: Cache cargo
uses: actions/cache@v4 uses: actions/cache@v4
@@ -28,28 +35,78 @@ jobs:
~/.cargo/registry ~/.cargo/registry
~/.cargo/git ~/.cargo/git
target target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} key: linux-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build (armv7-musl) - name: Build (armv7-musl)
shell: pwsh shell: pwsh
run: | run: |
cargo build --release --target armv7-unknown-linux-musleabihf cargo build --release --target armv7-unknown-linux-musleabihf -p wakey -p wakey-agent
- name: Build UI dist
shell: bash
run: |
set -euo pipefail
cd ui
pnpm install --frozen-lockfile
pnpm build
- name: Build control-plane (linux-gnu; zig + older glibc when available)
shell: bash
run: |
set -euo pipefail
GLIBC_VER="${WAKEY_CC_GLIBC_VERSION:-2.28}"
GNU_TARGETS=(x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu)
use_zig=false
if command -v zig >/dev/null 2>&1 && cargo zigbuild -h >/dev/null 2>&1; then
use_zig=true
fi
if [ "$use_zig" = true ]; then
echo "cargo-zigbuild + zig found: building control-plane with glibc ${GLIBC_VER}"
for base in "${GNU_TARGETS[@]}"; do
cargo zigbuild --release --target "${base}.${GLIBC_VER}" -p wakey-control-plane
done
else
echo "zig or cargo zigbuild not available: using plain cargo build (binary needs glibc >= host)"
for base in "${GNU_TARGETS[@]}"; do
cargo build --release --target "$base" -p wakey-control-plane
done
fi
- name: Package rootfs tarball - name: Package rootfs tarball
shell: pwsh shell: pwsh
run: | run: |
./scripts/package_rootfs.ps1 -Version "${{ github.ref_name }}" -Target armv7-unknown-linux-musleabihf ./scripts/package_rootfs.ps1 -Version "${{ github.ref_name }}" -Target armv7-unknown-linux-musleabihf
- name: Package control-plane bundles
shell: bash
run: |
set -euo pipefail
chmod +x scripts/update_wakey_cc.sh scripts/package_wakey_cc_bundle.sh
for target in x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do
./scripts/package_wakey_cc_bundle.sh --version "${{ github.ref_name }}" --target "$target"
done
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: wakey-rootfs name: wakey-rootfs
path: dist/wakey-rootfs-*armv7-unknown-linux-musleabihf.tgz path: dist/wakey-rootfs-*armv7-unknown-linux-musleabihf.tgz
- name: Publish Release to Gitea (API) - name: Upload control-plane bundles
uses: actions/upload-artifact@v3
with:
name: wakey-cc-bundles
path: dist/wakey-cc-*unknown-linux-gnu.tgz
- name: Publish Release to Gitea
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
shell: pwsh # use their personal repo cuz this searches github.com T_T
env: uses: akkuman/gitea-release-action@v1
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} with:
run: | token: ${{ secrets.GITEA_TOKEN }}
./scripts/ci_gitea_release.ps1 -Tag "$env:GITHUB_REF_NAME" -AssetPattern "dist/wakey-rootfs-*armv7-unknown-linux-musleabihf.tgz" files: |
dist/wakey-rootfs-*armv7-unknown-linux-musleabihf.tgz
dist/wakey-cc-*unknown-linux-gnu.tgz
tag_name: ${{ github.ref_name }}
+5 -5
View File
@@ -1,5 +1,5 @@
you can use block code as so: We work towards clean, modular, maintainable design, clear documentation... im not the best at this thing, my choices can be bad, so you help me.
```rust
hello_world!(println); DRY: spawn subagents.
``` Context: break those files out.
in the chat. Test: make functions easy to test.
+57
View File
@@ -0,0 +1,57 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
rust:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: cargo fmt
run: cargo fmt --all -- --check
- name: cargo clippy
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
- name: cargo test
run: cargo test --workspace --all-features
# Operator UI (`ui/`): same checks you’d run locally before a control-plane bundle.
ui:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ui
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: pnpm
cache-dependency-path: ui/pnpm-lock.yaml
- name: pnpm install
run: pnpm install --frozen-lockfile
- name: TypeScript (no emit)
run: pnpm run typecheck
- name: Prettier
run: pnpm run format:check
- name: Vite production build
run: pnpm run build
+104
View File
@@ -0,0 +1,104 @@
name: release
run-name: Release ${{ github.ref_name }}
on:
push:
tags:
- "v*"
workflow_dispatch:
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: pnpm
cache-dependency-path: ui/pnpm-lock.yaml
- name: Install cross
run: cargo install cross --locked
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: linux-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build (armv7-musl with cross)
run: |
cross build --release --target armv7-unknown-linux-musleabihf -p wakey -p wakey-agent
- name: Build UI dist
shell: bash
run: |
set -euo pipefail
cd ui
pnpm install --frozen-lockfile
pnpm build
- name: Build control-plane (linux-gnu)
shell: bash
run: |
set -euo pipefail
# x86_64 builds natively; aarch64 needs cross
echo "Building control-plane x86_64-unknown-linux-gnu (native)"
cargo build --release --target x86_64-unknown-linux-gnu -p wakey-control-plane
echo "Building control-plane aarch64-unknown-linux-gnu (cross)"
cross build --release --target aarch64-unknown-linux-gnu -p wakey-control-plane
- name: Package rootfs tarball
shell: pwsh
run: |
./scripts/package_rootfs.ps1 -Version "${{ github.ref_name }}" -Target armv7-unknown-linux-musleabihf
- name: Package control-plane bundles
shell: bash
run: |
set -euo pipefail
chmod +x scripts/update_wakey_cc.sh scripts/package_wakey_cc_bundle.sh
for target in x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do
./scripts/package_wakey_cc_bundle.sh --version "${{ github.ref_name }}" --target "$target"
done
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: wakey-rootfs
path: dist/wakey-rootfs-*armv7-unknown-linux-musleabihf.tgz
- name: Upload control-plane bundles
uses: actions/upload-artifact@v4
with:
name: wakey-cc-bundles
path: dist/wakey-cc-*unknown-linux-gnu.tgz
- name: Publish Release to GitHub
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ github.ref_name }}
files: |
dist/wakey-rootfs-*armv7-unknown-linux-musleabihf.tgz
dist/wakey-cc-*unknown-linux-gnu.tgz
+2
View File
@@ -18,3 +18,5 @@ target/
#.idea/ #.idea/
.env .env
.codex
+2
View File
@@ -0,0 +1,2 @@
/cache
/project.local.yml
+12
View File
@@ -0,0 +1,12 @@
# Completion Checklist
Before handing back code changes, run the smallest checks covering the changed area:
- Rust formatting: `cargo fmt`.
- For control-plane SQLx/state/API work: `cargo test -p wakey-control-plane` and `SQLX_OFFLINE=true cargo test -p wakey-control-plane`.
- For SQLx query macro changes: run `scripts/prepare_sqlx_sqlite.sh` and include the `.sqlx` metadata changes.
- For wakey-linux/core inventory/observation work: `cargo test -p wakey-linux -p wakey-core`.
- For agent session/config/protocol work: `cargo test -p wakey-agent`.
- For UI work: `pnpm --dir ui typecheck` and `pnpm --dir ui build`; use `pnpm --dir ui format` when touching TS/TSX/CSS formatting.
- Run relevant `cargo clippy -p <crate> --all-targets --all-features` when behavior or API shape changes.
Always check `git status --short` and mention unrelated dirty files or generated metadata in the final summary.
+14
View File
@@ -0,0 +1,14 @@
# Wakey Project Overview
Wakey is a Wake-on-LAN and LAN observability workspace for Linux/OpenWrt routers. It includes:
- `wakey-core`: shared model and parsing types for devices, neighbors, DHCP, interfaces, wake results.
- `wakey-linux`: Linux/OpenWrt adapter layer for DHCP lease files, neighbor lookup, interface data, WoL sending, and local observation storage.
- root `wakey` crate: service layer and operator CLI for inventory, leases, devs, wake, and observe.
- `wakey-agent`: outbound router daemon. Enrolls with control plane, maintains authenticated websocket, executes relayed commands, syncs observations.
- `wakey-control-plane`: Axum control plane, SQLx SQLite state, agent registry, command relay, known devices, observations, audit/alerts, fleet APIs, static UI hosting.
- `ipjs`: typed wrappers around Linux `ip -j` data.
- `ui`: React/Vite operator UI using shadcn/base-ui style components.
Current persistence direction: control plane uses SQLx SQLite with migrations in `wakey-control-plane/migrations` and offline query metadata in `.sqlx` plus `wakey-control-plane/.sqlx`. Legacy sled import exists as explicit command.
Current product direction: fleet-first device UI and backend. Known device identity is manual. Observations/inventory provide suggestions/current state but should not automatically promote or delete durable identifiers.
+23
View File
@@ -0,0 +1,23 @@
# Style And Conventions
Rust:
- Edition 2024.
- Prefer explicit, small service/API functions over large abstractions.
- Use `anyhow::Context` for fallible operations in application layers.
- Use `tracing` for logs.
- Shared domain types live in `wakey-core`; Linux/OpenWrt I/O in `wakey-linux`; root crate owns service/CLI behavior; agent/control-plane should reuse service/domain types where possible.
- SQLx query macros are used in control-plane state. Regenerate `.sqlx` metadata after query string changes.
- Device identity is manual: observations and inventory may suggest, but should not silently pin, merge, promote, or delete known identifiers.
- Hotplug observation actions should stay event-shaped (`add`, `update`, `remove`). Do not store NUD state as the action. NUD belongs to live neighbor/inventory data.
Frontend:
- React + Vite + TypeScript.
- Use existing component/style conventions; shadcn/base-ui/lucide are available.
- Main operator UI is fleet-first: common path is find device, inspect current IP/MAC/agent, wake, copy, remember/merge.
- Admin/debug pages stay available but should not dominate the primary nav.
Editing:
- Keep changes scoped.
- Preserve user edits in dirty worktrees.
- Use `apply_patch` for manual file edits.
- Default to ASCII unless files already use non-ASCII for a reason.
+31
View File
@@ -0,0 +1,31 @@
# Suggested Commands
Rust formatting and checks:
- `cargo fmt`
- `cargo test -p wakey-control-plane`
- `SQLX_OFFLINE=true cargo test -p wakey-control-plane`
- `cargo test -p wakey-linux -p wakey-core`
- `cargo test -p wakey-agent` (may need elevated permissions if tests bind local sockets)
- `cargo clippy -p wakey-control-plane --all-targets --all-features`
- `cargo clippy -p wakey-linux -p wakey-core --all-targets --all-features`
SQLx metadata after query macro changes:
- `scripts/prepare_sqlx_sqlite.sh`
UI checks:
- `pnpm --dir ui typecheck`
- `pnpm --dir ui build`
- `pnpm --dir ui format`
Local run examples:
- `cargo run -- inventory <query>`
- `cargo run -- leases`
- `cargo run -- devs`
- `cargo run -- wake --mac aa:bb:cc:dd:ee:ff`
- `cargo run -p wakey-agent -- serve --config /etc/wakey-agent/config.toml`
- `cargo run -p wakey-control-plane -- serve --config-file /etc/wakey-control-plane/config.toml`
Useful shell tools:
- Prefer `rg` and `rg --files` for search.
- Use `git status --short`, `git diff --stat`, and focused `git diff -- <path>` before edits.
- Avoid destructive git commands unless explicitly requested.
+155
View File
@@ -0,0 +1,155 @@
# the name by which the project can be referenced within Serena
project_name: "wakey"
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# haxe java julia kotlin lua
# markdown
# matlab nix pascal perl php
# php_phpactor powershell python python_jedi r
# rego ruby ruby_solargraph rust scala
# swift terraform toml typescript typescript_vts
# vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- rust
- typescript
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
#
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project based on the project name or path.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_memory`: Delete a memory file. Should only happen if a user asks for it explicitly,
# for example by saying that the information retrieved from a memory file is no longer correct
# or no longer relevant for the project.
# * `edit_memory`: Replaces content matching a regular expression in a memory.
# * `execute_shell_command`: Executes a shell command.
# * `find_file`: Finds files in the given relative paths
# * `find_referencing_symbols`: Finds symbols that reference the given symbol using the language server backend
# * `find_symbol`: Performs a global (or local) search using the language server backend.
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Provides instructions Serena usage (i.e. the 'Serena Instructions Manual')
# for clients that do not read the initial instructions when the MCP server is connected.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: List available memories. Any memory can be read using the `read_memory` tool.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Read the content of a memory file. This tool should only be used if the information
# is relevant to the current task. You can infer whether the information
# is relevant from the memory file name.
# You should not read the same memory file multiple times in the same conversation.
# * `rename_memory`: Renames or moves a memory. Moving between project and global scope is supported
# (e.g., renaming "global/foo" to "bar" moves it from global to project scope).
# * `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities.
# For JB, we use a separate tool.
# * `replace_content`: Replaces content in a file (optionally using regular expressions).
# * `replace_symbol_body`: Replaces the full definition of a symbol using the language server backend.
# * `safe_delete_symbol`:
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `write_memory`: Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format.
# The memory name should be meaningful.
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as read‑only.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO agents (agent_id, agent_token) VALUES (?1, ?2)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "020b33576bfc2d171a051f903d40ccc78c972fa9d5d983a5678f03abbc8b8488"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO enroll_tokens (token, expires_at_unix) VALUES (?1, ?2)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "07d7f283daba2742e5522abf99074171ed484c03f70138be611e0c4b17e311b8"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM agent_devices WHERE agent_id = ?1 AND device_key = ?2",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "09db61a070a351a6255e21d5949285ccb19fca7f8f29a6c18dddac5495257a45"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO audit_events\n (event_key, event_id, ts_unix, actor_type, actor_id, agent_id, request_id,\n event_type, outcome, latency_ms, message, metadata_json)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
"describe": {
"columns": [],
"parameters": {
"Right": 12
},
"nullable": []
},
"hash": "0c47aade9d5c71d6c7e2023c6ff66c6ab4b3a0dd2b78af3a600dedc89da508f2"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(*) as \"count!: i64\" FROM enroll_tokens",
"describe": {
"columns": [
{
"name": "count!: i64",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "0ca58d0cbc25c59d2c956222d41c02eb0c76a135f29a56a7570b750f7378738d"
}
@@ -0,0 +1,32 @@
{
"db_name": "SQLite",
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", fact_json as \"fact_json!\"\n FROM agent_device_facts",
"describe": {
"columns": [
{
"name": "agent_id!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "device_key!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "fact_json!",
"ordinal": 2,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false
]
},
"hash": "12e443ec0991c042cf527498b567bbbb6d02885f72eb3fa426c9493368f8a9e7"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO agent_meta (agent_id, nickname) VALUES (?1, ?2)\n ON CONFLICT(agent_id) DO UPDATE SET nickname = excluded.nickname",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "181992e554afdd8f21bd5df71bfb7880a5a9de28af8cd0c766d8d4aa649b3c90"
}
@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "SELECT device_id as \"device_id!\", display_name as \"display_name!\",\n pinned, created_at_unix, updated_at_unix, notes\n FROM known_devices\n ORDER BY pinned DESC, display_name, device_id",
"describe": {
"columns": [
{
"name": "device_id!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "display_name!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "pinned",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "created_at_unix",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "updated_at_unix",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "notes",
"ordinal": 5,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
true,
false,
false,
false,
false,
true
]
},
"hash": "19b5fc20ffc99589bbaf11b94661f7c0dfcf80748c60f8f3289bb3296114635a"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(*) as \"count!: i64\" FROM agents",
"describe": {
"columns": [
{
"name": "count!: i64",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "21e5986ad3e8efd3393f2bc4929ee79e664cbc2b55948c276d52ca2a6fdc6a48"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO known_devices\n (device_id, display_name, pinned, created_at_unix, updated_at_unix, notes)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "2bff8b8b070f5b5dd946e754c5e15ea405f6c29baa4c2c31347f455227437ae2"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT 1 as \"ok!\"",
"describe": {
"columns": [
{
"name": "ok!",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "2c0dd5d9be90cc716da219c75c2d3b75dae8c0902c64ef5baa96c902f23abc5d"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM agent_device_hostnames WHERE agent_id = ?1 AND device_key = ?2",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "2cd47bbce9b9ae2ebf39f31b12722bb8d19ef6d90ac634731932a4cefbde03b7"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT device_key FROM agent_devices WHERE agent_id = ?1",
"describe": {
"columns": [
{
"name": "device_key",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "3f24dfb51be2c3051dc8a8f732bd3e3fe9ca6b2649e1202d3725693b8011f269"
}
@@ -0,0 +1,32 @@
{
"db_name": "SQLite",
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", hostname as \"hostname!\"\n FROM agent_device_hostnames\n WHERE agent_id = ?1",
"describe": {
"columns": [
{
"name": "agent_id!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "device_key!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "hostname!",
"ordinal": 2,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false
]
},
"hash": "4188fb9a0dac2e19fb1f83e023eb1e8d93f6ca896cd78a53668080639fc048d9"
}
@@ -0,0 +1,32 @@
{
"db_name": "SQLite",
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", ip as \"ip!\"\n FROM agent_device_ips",
"describe": {
"columns": [
{
"name": "agent_id!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "device_key!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "ip!",
"ordinal": 2,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false
]
},
"hash": "4dd5e580826b0f6980bb8c2adecdc81ff95992ec337f93263463c5a4e357dc72"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM agent_device_macs WHERE agent_id = ?1 AND device_key = ?2",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "4f4329b0a47a00a3b881a1bc5cf8bb9855351714ff827ccff917b3e623191504"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(*) as \"count!: i64\" FROM enroll_tokens WHERE expires_at_unix <= ?1",
"describe": {
"columns": [
{
"name": "count!: i64",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "5083fd5878c57f4d54e8b24ad0cef2771f7cac36afb7c370c65137fc0784f093"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM agents WHERE agent_id = ?1",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "51cb6ec47368803af60114a39dd9e70ba9d31946bc39980c9e85dbc4e842a2c8"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT expires_at_unix FROM enroll_tokens WHERE token = ?1",
"describe": {
"columns": [
{
"name": "expires_at_unix",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "540f57604aa987b97930bb0bcfe440094d97b769fd758fd5cffc32f6d5716bb4"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM agent_device_facts WHERE agent_id = ?1 AND device_key = ?2",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "55340d50a29e0a2d324ed946b6fa6503fbd4ea0674bb7ec24e3426c5b0e5832f"
}
@@ -0,0 +1,32 @@
{
"db_name": "SQLite",
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", mac as \"mac!\"\n FROM agent_device_macs\n WHERE agent_id = ?1",
"describe": {
"columns": [
{
"name": "agent_id!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "device_key!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "mac!",
"ordinal": 2,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false
]
},
"hash": "580f6bc100d373137db6f853b8babd7633c700dbfaf17ded507b704fd98e46a5"
}
@@ -0,0 +1,32 @@
{
"db_name": "SQLite",
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", mac as \"mac!\"\n FROM agent_device_macs",
"describe": {
"columns": [
{
"name": "agent_id!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "device_key!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "mac!",
"ordinal": 2,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false
]
},
"hash": "5824c6cb841f7aaeb2a039f846effe17ba8038e35998b674136a19a4d42d8b88"
}
@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "SELECT agent_id, device_key, presence, display_name,\n first_seen_unix, last_seen_unix\n FROM agent_devices\n WHERE agent_id = ?1\n ORDER BY device_key",
"describe": {
"columns": [
{
"name": "agent_id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "device_key",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "presence",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "display_name",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "first_seen_unix",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "last_seen_unix",
"ordinal": 5,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
false,
false
]
},
"hash": "5f7e043ca389f1431c7b3478ea506b0c9bb706d32c06871d1267068dbe9cb746"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(*) as \"count!: i64\" FROM agents WHERE agent_id = ?1",
"describe": {
"columns": [
{
"name": "count!: i64",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "60c6631ace995d7ca109d1bee1d878c823edd40f15f8571b42e00680a120ad74"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO alert_transitions\n (transition_key, transition_id, ts_unix, alert_id, kind, agent_id,\n from_status, to_status, message, metadata_json)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
"describe": {
"columns": [],
"parameters": {
"Right": 10
},
"nullable": []
},
"hash": "69601f4f2268a759f03a2d533a0a30dec5cf05797817fd8d2426714cd84d7ada"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE device_identifiers SET device_id = ?1 WHERE device_id = ?2",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "7247cc488921de2313a602697590502771a231bde7d03cd9ccac55a70971da45"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM device_identifiers WHERE device_id = ?1 AND identifier_key = ?2",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "72aadefca9851a8becdd2dcce54b646aff095b64cef9837f5e8e4ea8dde71d02"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM active_alerts",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "72afd48584f822116318c623f7dd42ddf4c82b337d8a3d52ed5973d52e08c163"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO device_identifiers\n (identifier_key, device_id, kind, value, created_at_unix)\n VALUES (?1, ?2, ?3, ?4, ?5)",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "7dc7edbbe5539f89783cbccb0475e676cb6d369b676b0c5aaedf99854ffbd760"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT token as \"token!\", expires_at_unix FROM enroll_tokens ORDER BY expires_at_unix, token",
"describe": {
"columns": [
{
"name": "token!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "expires_at_unix",
"ordinal": 1,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
true,
false
]
},
"hash": "7f6c5c108953cd4eda8f4e1d4f4db6b3b88270d3d7087b2948362d0ea94ad246"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM agent_meta WHERE agent_id = ?1",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "8bf8470846bd228d2ea9b243f70f946f002915b9b1983bce818abc0ae3dd97f0"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM enroll_tokens WHERE token = ?1",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "8f3499a2f461f738711b9942a9c9d6254c1f7ed0db54db8b3d65e10fa8dadfc9"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT agents.agent_id as \"agent_id!\", agent_meta.nickname\n FROM agents\n LEFT JOIN agent_meta ON agent_meta.agent_id = agents.agent_id\n ORDER BY agents.agent_id",
"describe": {
"columns": [
{
"name": "agent_id!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "nickname",
"ordinal": 1,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
true,
true
]
},
"hash": "977adb8bdfb9e031a738055fb81c02c6180ca9c37c90e39b3e4efb9168bd1944"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(*) as \"count!: i64\" FROM meta WHERE key = ?1",
"describe": {
"columns": [
{
"name": "count!: i64",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "9f5d340530083d040794bed2a45b01522f57dc4132184dab0eb32d1b6811992d"
}
@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "SELECT device_id as \"device_id!\", display_name as \"display_name!\",\n pinned, created_at_unix, updated_at_unix, notes\n FROM known_devices\n WHERE device_id = ?1",
"describe": {
"columns": [
{
"name": "device_id!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "display_name!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "pinned",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "created_at_unix",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "updated_at_unix",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "notes",
"ordinal": 5,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
false,
false,
true
]
},
"hash": "9fe9f41d6716305ffebc764a72a3aea11e4ba348fae664aa6ba4bffbb3a2bf34"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT device_id FROM device_identifiers WHERE identifier_key = ?1",
"describe": {
"columns": [
{
"name": "device_id",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "a1bca2de4e1328fa066d5a8703ef5b8bda560b234b9f1e75093827efeac10eec"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO agent_devices (agent_id, device_key, presence, display_name, first_seen_unix, last_seen_unix)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6)\n ON CONFLICT (agent_id, device_key) DO UPDATE SET\n presence = excluded.presence,\n display_name = excluded.display_name,\n last_seen_unix = excluded.last_seen_unix",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "a29f58cb7dd1b1bc510a0d04ecd1dbc7250824ba089dbc36ed9ac2e6dc920fd7"
}
@@ -0,0 +1,68 @@
{
"db_name": "SQLite",
"query": "SELECT transition_id as \"transition_id!\", ts_unix, alert_id as \"alert_id!\",\n kind as \"kind!\", agent_id, from_status, to_status as \"to_status!\",\n message as \"message!\", metadata_json as \"metadata_json!\"\n FROM alert_transitions\n ORDER BY transition_key DESC\n LIMIT ?1",
"describe": {
"columns": [
{
"name": "transition_id!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "ts_unix",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "alert_id!",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "kind!",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "agent_id",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "from_status",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "to_status!",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "message!",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "metadata_json!",
"ordinal": 8,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
true,
true,
false,
false,
false
]
},
"hash": "a34accb5d8ef1fc90e1029b808956c85fa7cb00c0e879f27bbb99b5a76b5e151"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT agent_id as \"agent_id!\" FROM agents ORDER BY agent_id",
"describe": {
"columns": [
{
"name": "agent_id!",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
true
]
},
"hash": "a5baac2d2f6dc38946ff15f6cba46ac085780e6521ba23a605a78b4b92a62073"
}
@@ -0,0 +1,74 @@
{
"db_name": "SQLite",
"query": "SELECT alert_id as \"alert_id!\", kind as \"kind!\", severity as \"severity!\",\n status as \"status!\", agent_id, message as \"message!\",\n value, threshold, last_seen_unix, metadata_json as \"metadata_json!\"\n FROM active_alerts",
"describe": {
"columns": [
{
"name": "alert_id!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "kind!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "severity!",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "status!",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "agent_id",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "message!",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "value",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "threshold",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "last_seen_unix",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "metadata_json!",
"ordinal": 9,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
true,
false,
false,
false,
true,
false,
false,
false,
false,
false
]
},
"hash": "b031d0fa0f9d0640555fc78b400b7bb951a3f013715fb5be51030c2be63e864d"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM known_devices WHERE device_id = ?1",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "b1c6150ef7bd67ce78f8934fc5d451b36f5bed834d39bdde01356e331c5e5ddb"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT value FROM meta WHERE key = ?1",
"describe": {
"columns": [
{
"name": "value",
"ordinal": 0,
"type_info": "Blob"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "b805f8dada38af1c79a7b3bbc063fd949bf81757705ace7173658de9f185868b"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO enroll_tokens (token, expires_at_unix) VALUES (?1, ?2)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "bee62978a2ce59095f9bc679258b5747e074a55d52c43b982ea019c79aff1479"
}
@@ -0,0 +1,32 @@
{
"db_name": "SQLite",
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", ip as \"ip!\"\n FROM agent_device_ips\n WHERE agent_id = ?1",
"describe": {
"columns": [
{
"name": "agent_id!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "device_key!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "ip!",
"ordinal": 2,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false
]
},
"hash": "c423e005d85659547302f506380ad217cc31329262a1c0254ef532653b3a37bb"
}
@@ -0,0 +1,68 @@
{
"db_name": "SQLite",
"query": "SELECT transition_id as \"transition_id!\", ts_unix, alert_id as \"alert_id!\",\n kind as \"kind!\", agent_id, from_status, to_status as \"to_status!\",\n message as \"message!\", metadata_json as \"metadata_json!\"\n FROM alert_transitions\n WHERE ts_unix >= ?1\n ORDER BY transition_key DESC\n LIMIT ?2",
"describe": {
"columns": [
{
"name": "transition_id!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "ts_unix",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "alert_id!",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "kind!",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "agent_id",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "from_status",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "to_status!",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "message!",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "metadata_json!",
"ordinal": 8,
"type_info": "Text"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
false,
true,
true,
false,
false,
false
]
},
"hash": "ca117e134d45464a8930d0483ff8670d2798f38718c67f49adce06f74656bf2c"
}
@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "SELECT agent_id, device_key, presence, display_name,\n first_seen_unix, last_seen_unix\n FROM agent_devices\n ORDER BY agent_id, device_key",
"describe": {
"columns": [
{
"name": "agent_id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "device_key",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "presence",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "display_name",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "first_seen_unix",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "last_seen_unix",
"ordinal": 5,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true,
false,
false
]
},
"hash": "cd8585b9431d223962c376a2d463f0ca8128e7759c8694631f41db0cbd8eb221"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE known_devices SET updated_at_unix = ?1 WHERE device_id = ?2",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "d2c2ce1761d22477164b3e5cfde9718877b3b472886ae1dc7e64dc36ad75b2ea"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM agent_device_ips WHERE agent_id = ?1 AND device_key = ?2",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "d442949dce7a4b9a8af97dc34fc34608029564740ad327c525c7f1466bc18a11"
}
@@ -0,0 +1,32 @@
{
"db_name": "SQLite",
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", hostname as \"hostname!\"\n FROM agent_device_hostnames",
"describe": {
"columns": [
{
"name": "agent_id!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "device_key!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "hostname!",
"ordinal": 2,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false
]
},
"hash": "dba637bf03d53bf3394ead0f01466f9a72fafc54f9ce239d2e5ac7f76b63b168"
}
@@ -0,0 +1,44 @@
{
"db_name": "SQLite",
"query": "SELECT identifier_key as \"identifier_key!\", device_id as \"device_id!\",\n kind as \"kind!\", value as \"value!\", created_at_unix\n FROM device_identifiers\n WHERE device_id = ?1\n ORDER BY kind, value",
"describe": {
"columns": [
{
"name": "identifier_key!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "device_id!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "kind!",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "value!",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "created_at_unix",
"ordinal": 4,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
false,
false
]
},
"hash": "dd1d6ba80d6380cb07227eac0b90951c93f63dc4c89349cf1920405943e2093a"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT agent_token FROM agents WHERE agent_id = ?1",
"describe": {
"columns": [
{
"name": "agent_token",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "e0def02317412d21994af8d7770a9e717f4be97e022c0f2022983d59391c775b"
}
@@ -0,0 +1,32 @@
{
"db_name": "SQLite",
"query": "SELECT agent_id as \"agent_id!\", device_key as \"device_key!\", fact_json as \"fact_json!\"\n FROM agent_device_facts\n WHERE agent_id = ?1",
"describe": {
"columns": [
{
"name": "agent_id!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "device_key!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "fact_json!",
"ordinal": 2,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false
]
},
"hash": "e6be4d9782f66f98ebe4d4b6ec6e93ea9efe8e4c7f1147e1ff68d41a311d57f9"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM enroll_tokens WHERE expires_at_unix <= ?1",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "ee6d28dc501f6bb8cf1249ad03b82e3554f1167d5056f02a0cc823601f480e53"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(*) as \"count!: i64\" FROM known_devices WHERE device_id = ?1",
"describe": {
"columns": [
{
"name": "count!: i64",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "f18ab386ab1961e938ae7fec3e752bdd958118aa4ca81cab3421ac5827b04029"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(*) FROM enroll_tokens WHERE token = ?1",
"describe": {
"columns": [
{
"name": "COUNT(*)",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "f5a815d4d6e3d088aba3cd01607764d89afaae6108aad0d17e93b1bfc9f7e772"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO active_alerts\n (alert_id, kind, severity, status, agent_id, message, value, threshold,\n last_seen_unix, metadata_json)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
"describe": {
"columns": [],
"parameters": {
"Right": 10
},
"nullable": []
},
"hash": "fa09ff199445b7bf689ef4426cbd20d89040230ffff977cc1b4c73b499b1b33f"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO meta (key, value) VALUES (?1, ?2)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "ffed939cfeecca1d46bdf77b67aca2919b34d496e729ad08f87ac9782fb8e382"
}
+2 -1
View File
@@ -1,3 +1,4 @@
{ {
"rust-analyzer.cargo.target": "armv7-unknown-linux-musleabihf" "rust-analyzer.cargo.target": "armv7-unknown-linux-musleabihf",
// "rust-analyzer.diagnostics.disabled": ["unlinked-file"]
} }
Generated
+3407 -319
View File
File diff suppressed because it is too large. Load diff
+53 -16
View File
@@ -1,27 +1,64 @@
[package] [package]
name = "wakey" name = "wakey"
version = "0.1.1" version = "0.3.0"
edition = "2024" edition = "2024"
publish = ["gitea"] publish = ["gitea"]
[dependencies] [dependencies]
axum = { version = "0.8.4", features = ["macros"] } anyhow = "1"
axum-extra = { version = "0.10.1", features = ["query"] } chrono = { version = "0.4", default-features = false, features = ["clock"] }
color-eyre = "0.6.5" clap = { version = "4", features = ["derive"] }
futures = "0.3.31" color-eyre = "0"
macaddr = { version = "1.0.1", features = ["serde", "serde_std"] } comfy-table = "7"
serde = { version = "1.0.219", features = ["derive"] } futures = "0"
serde_json = "1.0.143" macaddr = { version = "1", features = ["serde", "serde_std"] }
serde_with = { version = "3.14.0", features = ["json"] } serde = { version = "1", features = ["derive"] }
strum = { version = "0.27.2", features = ["derive", "strum_macros"] } serde_json = "1"
thiserror = "2.0.16" tokio = { version = "1", features = [
tokio = { version = "1.47.1", features = ["fs", "process", "rt-multi-thread", "io-util"] } "fs",
urlencoding = "2.1.3" "process",
"rt-multi-thread",
"io-util",
"macros",
"time",
] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
[profile.release] [profile.release]
opt-level = "z" opt-level = "z"
strip = true strip = true
[features] [workspace]
default = ["very-smart-parsing"] members = ["ipjs", "wakey-agent", "wakey-control-plane", "wakey-core", "wakey-linux"]
very-smart-parsing = [] # this is the a-bit-redundant parse thing that copilot made
[workspace.dependencies]
anyhow = "1"
macaddr = { version = "1", features = ["serde", "serde_std"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = [
"fs",
"process",
"rt-multi-thread",
"io-util",
"macros",
"time",
] }
tracing = "0.1"
uuid = { version = "1", features = ["v4"] }
[dependencies.lda-ipjs]
path = "ipjs"
registry = "gitea"
version = "0"
[dependencies.wakey-core]
path = "wakey-core"
registry = "gitea"
version = "0"
[dependencies.wakey-linux]
path = "wakey-linux"
registry = "gitea"
version = "0"
+410 -12
View File
@@ -1,20 +1,418 @@
# wakey # wakey
router shit `wakey` is a Wake-on-LAN and LAN-observability tool for a Linux router.
## usage It started as a small web UI running on-device. It is now being reshaped into a
service-first project with:
- Build (Windows host OK): - a reusable core model
- cargo build --release --target armv7-unknown-linux-musleabihf - a Linux/OpenWrt adapter layer
- Run on a Linux box/router: - a CLI for operators
- copy target/armv7-unknown-linux-musleabihf/release/wakey to the device - an outbound agent plus control-plane model
- chmod +x wakey && ./wakey
- Install via release asset (OpenWrt):
- wget -O- `https://your.gitea/owner/repo/releases/download/vX.Y.Z/wakey-rootfs-vX.Y.Z-armv7-unknown-linux-musleabihf.tgz` | tar -xz -C /
- /etc/init.d/wakey enable && /etc/init.d/wakey start
Optional updater (fetch latest automatically on OpenWrt): ## What it does
`wakey` currently focuses on a small set of router-side jobs:
- inspect LAN neighbor state
- read DHCP lease data
- merge those facts into a higher-level device inventory
- list useful interface/broadcast information
- send Wake-on-LAN packets
In practice that means commands like:
```sh ```sh
WAKEY_HOST=git.ldlda.com WAKEY_OWNER=lda WAKEY_REPO=wakey sh /etc/ldlda_help/update_wakey.sh wakey inventory bedroom-pc
wakey leases --include-state
wakey devs
wakey wake bedroom-pc
wakey wake --mac aa:bb:cc:dd:ee:ff
``` ```
(`inventory` is the canonical subcommand; `status` is a visible alias for the same behavior.)
## Architecture at a glance
- **On-router (local):** run `wakey` for `inventory`, `leases`, `devs`, and `wake` against live Linux neighbor/DHCP/interface data.
- **Fleet (remote):** `wakey-agent` on each router enrolls with `wakey-control-plane`, keeps an authenticated WebSocket, and executes relayed commands using the same service logic as the local CLI.
## Workspace layout
- `wakey-core`
- shared types and parsing
- device, neighbor, DHCP, interface, and wake models
- `wakey-linux`
- Linux/OpenWrt adapter
- DHCP lease loading, interface summaries, neighbor lookup, WoL sending
- `wakey`
- service layer and operator CLI
- `wakey-agent`
- outbound router daemon, enrollment, websocket command execution
- `wakey-control-plane`
- enrollment endpoint, connected-agent registry, command relay API
- `ipjs`
- typed wrappers around Linux `ip -j ...` data
- JSON-first, with optional experimental netlink backends
## Current architecture
Inside the `wakey` crate:
- `src/service`
- the real use-case layer
- inventory, leases, interfaces, wake, and query resolution
The long-term direction is:
- keep the service layer stable
- keep the local CLI stable for operators
- run remote control via outbound agent + control-plane relay
## Future direction
The project now uses:
- `wakey` for local operator workflows and shared service behavior
- `wakey-agent` for outbound enrollment and websocket execution
- `wakey-control-plane` for enrollment, registry, and command relay
## Logging and troubleshooting
Both daemons emit structured tracing logs to stderr. If you are not seeing
enrollment/command activity, run with increased verbosity.
Control-plane also supports a config file at
`/etc/wakey-control-plane/config.toml` (override with `--config-file`) so you
can persist telemetry settings instead of passing flags.
You can scaffold this file with:
```sh
wakey-control-plane init-config
```
Or bootstrap once during serve (writes only if config is missing):
```sh
wakey-control-plane serve --bootstrap-config
```
Inspect current persisted state:
```sh
wakey-control-plane state-stats
```
List/revoke enroll tokens from CLI:
```sh
wakey-control-plane list-enroll-tokens
wakey-control-plane revoke-enroll-token --token enr-...
wakey-control-plane revoke-agent --agent-id agent-...
```
Machine-readable output is available:
```sh
wakey-control-plane list-enroll-tokens --json
wakey-control-plane state-stats --json
```
Example:
```toml
data_dir = "/var/lib/wakey-control-plane"
bind = "0.0.0.0:8080"
public_url = "https://cp.example.com"
state_file = "state.sqlite3"
pid_file = "wakey-control-plane.pid"
ui_dist_dir = "/opt/wakey/ui/dist"
command_timeout_ms = 30000
enroll_token_ttl_seconds = 86400
[telemetry]
otlp_endpoint = "http://127.0.0.1:4317"
service_name = "wakey-control-plane"
json_logs = false
```
If `telemetry.otlp_endpoint` is omitted, logs still work normally and only local
structured logs are emitted.
State is persisted in an embedded SQLite database (default
`/var/lib/wakey-control-plane/state.sqlite3`).
Relative paths in config are resolved under `data_dir`.
Legacy sled state can be imported explicitly:
```sh
wakey-control-plane migrate-sqlite-state --from /var/lib/wakey-control-plane/legacy.sqlite3 --to /var/lib/wakey-control-plane/state.sqlite3
```
Enroll tokens are now expiring and revocable. Issuance returns `expires_at_unix`.
Expired tokens are rejected on enroll and can be garbage-collected periodically
or on demand.
### Quick start
Control-plane:
```sh
wakey-control-plane -v serve --bind 0.0.0.0:8787 --public-url https://cp.example.com
```
Agent:
```sh
wakey-agent -v serve --config /etc/wakey-agent/config.toml
```
### Fine-grained log filters
Use `RUST_LOG` when you want to focus on websocket/API internals.
```sh
RUST_LOG=wakey_control_plane=debug,wakey_agent=debug wakey-control-plane serve
RUST_LOG=wakey_agent=debug wakey-agent serve
```
### What you should see
During registration/enroll:
- control-plane: `issued enroll token`, then `agent enrollment accepted`
- agent: `starting agent enrollment`, then `agent enrollment succeeded and config was written`
During live connectivity:
- control-plane: `agent websocket upgraded`, `agent authenticated`, `agent disconnected`
- agent: `connecting agent websocket`, `agent websocket dns resolved`, `agent websocket connected`, `agent websocket session authenticated`, `heartbeat sent` (debug)
During command relay:
- control-plane: `dispatching command to agent`
- agent: `received command from control-plane`, then `command execution completed` (or `command dispatch failed`)
- control-plane: `agent command completed` (or timeout/error warnings)
During daemon control/state operations:
- control-plane: `wrote control-plane pid file`, `saved control-plane store`, `reloaded control-plane store from disk`
- agent: `wrote wakey-agent pid file`, `sending wakey-agent reload signal`
Control-plane admin API includes token management endpoints:
- `POST /api/v1/control/enroll-token?ttl_seconds=<n>`
- `GET /api/v1/control/enroll-tokens`
- `DELETE /api/v1/control/enroll-tokens/{token}`
- `GET /api/v1/control/audit/events?agent_id=<id>&event_type=<type>&limit=<n>`
- `GET /api/v1/control/alerts?lookback_seconds=900`
- `GET /api/v1/control/alerts/history?since_unix=<ts>&limit=<n>`
- `GET /api/v1/control/alerts/ws` (websocket snapshots + recent transitions)
- `DELETE /api/v1/control/agents/{agent_id}`
- `PATCH /api/v1/control/agents/{agent_id}/nickname`
If commands still appear silent, verify both processes are running with `-v`
and that `RUST_LOG` is not overriding to a stricter level.
If websocket connect feels delayed, compare `dns_resolve_ms` and `ws_connect_ms`
from agent logs. Slow DNS is a common source of multi-second connection stalls
when using hostnames; using a stable IP or local host mapping can avoid this.
## Edge Exposure (Caddy + Cloudflare Access)
Control-plane is intended to run behind a reverse proxy with TLS termination.
Use Cloudflare Access to protect `/ui/*` and `/api/v1/control/*`, while keeping
agent enrollment and websocket endpoints reachable.
An example Caddy config is provided at:
- `deploy/control-plane.Caddyfile`
Expected exposure model:
- Public: `/healthz`, `/api/v1/agents/enroll`, `/api/v1/agent/ws`
- Private (Cloudflare Access): `/ui/*`, `/api/v1/control/*`
Control-plane routing is organized with the same boundary in code:
- public router: health, enroll, agent websocket
- control router: all `/api/v1/control/*` admin endpoints
This keeps edge policy and app routing aligned as features grow.
## UI (Initial Shell)
Control-plane serves the built Operator UI at `/ui/` from `ui_dist_dir`
(defaults to `ui/dist`). Configure this in
`/etc/wakey-control-plane/config.toml` or pass `--ui-dist-dir` to `serve`.
Build UI assets before starting control-plane:
```sh
cd ui
pnpm install
pnpm build
```
Then start control-plane and open `/ui/` on the same host/port.
## VPS Deploy (Manual Updates)
Suggested layout on VPS:
- `/opt/wakey/bin/wakey-control-plane`
- `/opt/wakey/ui/dist/*`
- `/etc/wakey-control-plane/config.toml` with `ui_dist_dir = "/opt/wakey/ui/dist"`
Use the provided unit template:
- `deploy/systemd/wakey-cc.service`
Install on VPS:
```sh
sudo install -m 0644 deploy/systemd/wakey-cc.service /etc/systemd/system/wakey-cc.service
sudo systemctl daemon-reload
sudo systemctl enable --now wakey-cc.service
```
Manual update helper for VPS:
```sh
chmod +x scripts/update_wakey_cc.sh
cd /opt/wakey
sudo -E ./scripts/update_wakey_cc.sh
# optional: pin target and/or version
WAKEY_CC_TARGET=x86_64-unknown-linux-gnu sudo -E ./scripts/update_wakey_cc.sh
WAKEY_CC_VERSION=v0.2.0 WAKEY_CC_TARGET=x86_64-unknown-linux-gnu sudo -E ./scripts/update_wakey_cc.sh
```
The update tarball is expected to contain:
- `bin/wakey-control-plane`
- `ui/dist/index.html` (plus `ui/dist/assets/*`)
## CLI
`wakey` is usable as a local/operator CLI.
### Inventory
Show merged device inventory rows using a free-form selector:
```sh
wakey inventory bedroom-pc
```
Or use explicit filters:
```sh
wakey inventory --dev br-lan --nud reachable
wakey inventory --mac aa:bb:cc:dd:ee:ff
wakey inventory --json
```
The `status` subcommand is an alias for `inventory` (same flags and output).
### Leases
Show DHCP leases:
```sh
wakey leases
wakey leases --include-state
wakey leases --include-state --json
```
### Wake
Query mode:
```sh
wakey wake bedroom-pc
```
Explicit/manual mode:
```sh
wakey wake --mac aa:bb:cc:dd:ee:ff
wakey wake --mac aa:bb:cc:dd:ee:ff --ip 192.168.1.255
wakey wake --mac aa:bb:cc:dd:ee:ff --json
```
Rules:
- query mode and explicit `--mac/--ip` mode are mutually exclusive
- `--ip` requires `--mac`
- `--mac` without `--ip` fans out to interface broadcast targets
### Interfaces
Show condensed interface summaries:
```sh
wakey devs
wakey devs br-lan
wakey devs --up
wakey devs --json
```
## Tests
This repo has two useful testing modes:
- local compile checks
- live on-device tests against the real router/runtime environment
### Local
```sh
cargo check
cargo test --no-run
cargo clippy --all-targets --all-features -- -D warnings
```
On GitHub Actions (`.github/workflows/ci.yml`), the same Rust checks run on
`ubuntu-latest`, plus a separate **`ui`** job: `pnpm install --frozen-lockfile`
in `ui/`, then `typecheck`, `format:check`, and `vite build`.
Focused control-plane state tests:
```sh
cargo test -p wakey-control-plane state::store::tests::gc_removes_expired_tokens
cargo test -p wakey-control-plane state::store::tests::enroll_rejects_expired_token
cargo test -p wakey-control-plane state::store::tests::stats_counts_agents_and_expired_tokens
```
### On-device
Some integration tests are intentionally `#[ignore]` because they use real
router state. Use the PowerShell helper:
```powershell
./scripts/test_remote.ps1 -Package wakey -BinaryFilter integration_live_services -Ignored -NoCapture
./scripts/test_remote.ps1 -Package wakey -BinaryFilter integration_inventory -Ignored -NoCapture
```
You can further narrow execution with `-Filter` to select individual Rust test
functions inside a test binary.
## Build target
This project is primarily aimed at an OpenWrt/Linux ARM router target. The
workspace is commonly built for:
```text
armv7-unknown-linux-musleabihf
```
Some crates and tests are Linux-specific by design.
## Notes
- `ipjs` is JSON-first by default; experimental netlink paths exist where they
are worth keeping.
- the current web client is temporary and kept alive through explicit
compatibility mapping
- the service layer is the part intended to survive the migration
+31
View File
@@ -0,0 +1,31 @@
# Caddy template for wakey-control-plane with Cloudflare Access.
#
# Security model:
# - Public endpoints for agents: /healthz, /api/v1/agents/enroll, /api/v1/agent/ws
# - Private admin surface: /ui/* and /api/v1/control/* (requires CF Access headers)
#
# Replace cp.example.com with your public control-plane domain.
wakey.ldlda.com {
encode zstd gzip
# Public agent-facing endpoints.
@public path /healthz /api/v1/agents/enroll /api/v1/agent/ws
handle @public {
reverse_proxy 127.0.0.1:6767
}
# Admin surface requires Cloudflare Access headers.
@admin path /ui* /api/v1/control/*
@cf_access header_regexp CFJWT Cf-Access-Jwt-Assertion .+
handle @admin {
handle @cf_access {
reverse_proxy 127.0.0.1:6767
}
respond "forbidden" 403
}
# Deny unknown paths by default.
respond "not found" 404
}
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=Wakey Control Plane
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=lda
Group=lda
WorkingDirectory=/opt/wakey
ExecStart=/opt/wakey/bin/wakey-control-plane serve --config-file /etc/wakey-control-plane/config.toml
Restart=on-failure
RestartSec=2
Environment=RUST_LOG=info
[Install]
WantedBy=multi-user.target
+5
View File
@@ -0,0 +1,5 @@
server_url = "https://control-plane.example.com"
agent_id = "REPLACE_ME_AGENT_ID"
agent_token = "REPLACE_ME_AGENT_TOKEN"
reconnect_base_ms = 1000
reconnect_max_ms = 30000
+11
View File
@@ -0,0 +1,11 @@
data_dir = "/var/lib/wakey-control-plane"
bind = "0.0.0.0:8080"
public_url = "http://127.0.0.1:8080"
state_file = "state.db"
command_timeout_ms = 30000
enroll_token_ttl_seconds = 86400
pid_file = "wakey-control-plane.pid"
[telemetry]
service_name = "wakey-control-plane"
json_logs = false
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "lda-ipjs"
description = "ip -j show schemas"
version = "0.0.4"
edition = "2024"
publish = ["gitea"]
[features]
default = []
experimental-nl = ["dep:rtnetlink"]
[dependencies]
macaddr = { version = "1", features = ["serde", "serde_std"] }
strum = { version = "0", features = ["derive", "strum_macros"] }
serde_json = "1"
serde = { version = "1", features = ["derive"] }
serde_with = { version = "3", features = ["json"] }
thiserror = "2"
anyhow = "1"
tokio = { version = "1", features = [
"fs",
"process",
"rt-multi-thread",
"io-util",
"macros",
] }
futures = "0"
[target.'cfg(unix)'.dependencies]
rtnetlink = { version = "0", optional = true }
+60
View File
@@ -0,0 +1,60 @@
# lda-ipjs
Typed Rust wrappers around Linux `ip -j ...` output, with optional experimental rtnetlink backends.
## Purpose
`lda-ipjs` exists so `wakey` can ask Linux networking questions in typed Rust instead of:
- parsing plain-text shell output
- scattering `tokio::process::Command::new("ip")` everywhere
- mixing product logic with Linux networking trivia
## Current contract
Stable default behavior:
- `address::get(...)` uses JSON (`ip -j address show`)
- `link::get(...)` uses JSON (`ip -j link show`)
- `neighbor::get(...)` uses JSON (`ip -j neigh show`)
Optional experimental behavior:
- feature: `experimental-nl`
- enables rtnetlink-backed implementations
- intended for places where one-pass kernel queries are materially better than repeated `ip -j` calls
This means the public API is:
- `get(...)` for the default backend
- `get_with_backend(Backend::Json | Backend::Netlink)` when backend choice matters
## Modules
- `subcommands::address`
- typed address/interface-address data
- good place for subnet/broadcast derivation later
- `subcommands::link`
- typed link/interface data
- useful for `ifindex -> ifname` mapping and interface metadata
- `subcommands::neighbor`
- typed neighbor-table data
- currently the most useful experimental netlink surface
## Backend policy
JSON is the normal path.
Use netlink only when:
- the call is hot enough to matter
- repeated shelling out is obviously wasteful
- the netlink implementation is at least as coherent as the JSON one
Today that mainly applies to `neighbor::nl`.
## Relationship to wakey
`wakey` is the product.
`lda-ipjs` is a Linux networking adapter crate underneath it.
+3
View File
@@ -0,0 +1,3 @@
trait IpCommand {
}
View File
Whitespace-only changes.
+19
View File
@@ -0,0 +1,19 @@
//! low
//!
//! # lda-ipjs
//!
//! this package will represent all my needs with the all the subcommands of ip -j.
//!
//! ## what i need
//!
//! ```console
//! ip -j neigh show
//! ```
//!
//! i also need to see devices and idk MAYBE maybe not MAYBE UHHHHHH maybe broadcast
//!
//! LOWK if this were to be calls to kernel or some bullshit then PLEASE because doing ts parsing its hell cuh
pub mod subcommands;
pub mod utils;
// i want a generalized way to build and call
+46
View File
@@ -0,0 +1,46 @@
// ip address [ show [ dev IFNAME ] [ scope SCOPE-ID ] [ master DEVICE ]
// [ type TYPE ] [ to PREFIX ] [ FLAG-LIST ]
// [ label LABEL ] [up] [ vrf NAME ] ]
// fuck is this mean
// do i need all this? do i need anything but `ip -j a show dev br-lan`?
// TYPE := { vlan | veth | vcan | vxcan | dummy | ifb | macvlan | macvtap |
// bridge | bond | ipoib | ip6tnl | ipip | sit | vxlan | lowpan |
// gre | gretap | erspan | ip6gre | ip6gretap | ip6erspan | vti |
// nlmon | can | bond_slave | ipvlan | geneve | bridge_slave |
// hsr | macsec | netdevsim }
// FLAG-LIST := [ FLAG-LIST ] FLAG
// FLAG := [ permanent | dynamic | secondary | primary |
// [-]tentative | [-]deprecated | [-]dadfailed | temporary |
// CONFFLAG-LIST ]
// CONFFLAG-LIST := [ CONFFLAG-LIST ] CONFFLAG
// CONFFLAG := [ home | nodad | mngtmpaddr | noprefixroute | autojoin ]
// prefix seems to be a cidr. both 6 and 4 works. idfk dog
use std::{io, process::Output};
use anyhow::Context;
use super::AddrOutput;
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<AddrOutput>> {
let output = _get(dev).await.context("Can not run command")?;
if !output.status.success() {
anyhow::bail!(String::from_utf8_lossy(&output.stderr).into_owned());
}
serde_json::from_slice(&output.stdout).context("Deserialize failed")
}
pub async fn _get(dev: Option<&str>) -> io::Result<Output> {
let mut cmd = tokio::process::Command::new("ip");
cmd.args(["-j", "address", "show"]);
if let Some(d) = dev {
cmd.args(["dev", d]);
}
cmd.output().await
}
+241
View File
@@ -0,0 +1,241 @@
//! Typed wrappers for `ip -j address show`.
//!
//! This module is intentionally close to the Linux output shape while still
//! tightening a few fields into more useful Rust types.
pub mod json;
#[cfg(all(unix, feature = "experimental-nl"))]
pub mod nl;
pub use crate::subcommands::Backend;
use crate::utils::serialize::mac::option_mac;
use macaddr::MacAddr;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
use std::net::{IpAddr, Ipv4Addr};
use crate::subcommands::link::OperState;
/// One interface row from `ip -j address show`.
#[derive(Serialize, Debug, Deserialize)]
pub struct AddrOutput {
pub ifindex: u32,
pub ifname: String,
/// Interface operational state.
pub operstate: OperState,
#[serde(with = "option_mac", default)]
pub address: Option<MacAddr>,
/// Per-address entries attached to this interface.
#[serde(default)]
pub addr_info: Vec<AddrInfo>,
}
/// One address entry nested under an interface row.
#[derive(Debug, Deserialize, Serialize)]
pub struct AddrInfo {
pub family: Option<AddressFamily>,
#[serde(flatten, default)]
pub cidr: InterfaceCidr,
pub broadcast: Option<Ipv4Addr>,
pub scope: Option<String>,
pub label: Option<String>,
}
/// Address family used by `ip address` output.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddressFamily {
Inet,
Inet6,
Other,
}
impl AddressFamily {
pub fn parse_lossy(value: &str) -> Self {
match value.to_ascii_lowercase().as_str() {
"inet" => Self::Inet,
"inet6" => Self::Inet6,
_ => Self::Other,
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Inet => "inet",
Self::Inet6 => "inet6",
Self::Other => "other",
}
}
}
impl Serialize for AddressFamily {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for AddressFamily {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Ok(Self::parse_lossy(&value))
}
}
/// Raw parsed local address plus prefix length.
///
/// This is still a source-shaped type; callers that need a guaranteed usable
/// CIDR should validate that both fields are present.
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
pub struct InterfaceCidr {
pub local: Option<IpAddr>,
pub prefixlen: Option<u8>,
}
impl InterfaceCidr {
/// Return whether both parts needed for a usable CIDR are present.
pub fn is_complete(&self) -> bool {
self.local.is_some() && self.prefixlen.is_some()
}
/// Return a validated complete CIDR when both fields are present.
pub fn complete(&self) -> Option<CompleteInterfaceCidr> {
Some(CompleteInterfaceCidr {
local: self.local?,
prefixlen: self.prefixlen?,
})
}
/// Format the CIDR as `addr/prefixlen` when both fields are present.
pub fn to_cidr_string(&self) -> Option<String> {
self.complete().map(|cidr| cidr.to_string())
}
}
/// Validated interface CIDR with both local address and prefix length present.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CompleteInterfaceCidr {
pub local: IpAddr,
pub prefixlen: u8,
}
impl fmt::Display for CompleteInterfaceCidr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}", self.local, self.prefixlen)
}
}
impl AddrInfo {
/// Return the parsed local IP address when present.
pub fn local_addr(&self) -> Option<IpAddr> {
self.cidr.local
}
/// Return the parsed prefix length when present.
pub fn prefixlen(&self) -> Option<u8> {
self.cidr.prefixlen
}
/// Return whether this row is IPv4.
pub fn is_ipv4(&self) -> bool {
matches!(self.family, Some(AddressFamily::Inet))
}
/// Return whether this row is IPv6.
pub fn is_ipv6(&self) -> bool {
matches!(self.family, Some(AddressFamily::Inet6))
}
}
impl AddrOutput {
/// Iterate IPv4 address entries.
pub fn ipv4_addrs(&self) -> impl Iterator<Item = &AddrInfo> {
self.addr_info.iter().filter(|info| info.is_ipv4())
}
/// Iterate IPv6 address entries.
pub fn ipv6_addrs(&self) -> impl Iterator<Item = &AddrInfo> {
self.addr_info.iter().filter(|info| info.is_ipv6())
}
}
/// Fetch address data using the default backend.
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<AddrOutput>> {
get_with_backend(Backend::Json, dev).await
}
/// Fetch address data using an explicit backend.
pub async fn get_with_backend(
backend: Backend,
dev: Option<&str>,
) -> anyhow::Result<Vec<AddrOutput>> {
match backend {
Backend::Json => json::get(dev).await,
#[cfg(all(unix, feature = "experimental-nl"))]
Backend::Netlink => nl::get(dev).await,
}
}
#[cfg(test)]
mod tests {
use std::net::{IpAddr, Ipv4Addr};
use super::{AddrInfo, AddrOutput, AddressFamily, InterfaceCidr};
use crate::subcommands::link::OperState;
#[test]
fn address_family_parses_known_values() {
assert_eq!(AddressFamily::parse_lossy("inet"), AddressFamily::Inet);
assert_eq!(AddressFamily::parse_lossy("INET6"), AddressFamily::Inet6);
assert_eq!(AddressFamily::parse_lossy("weird"), AddressFamily::Other);
}
#[test]
fn addr_info_deserializes_typed_ip_fields() {
let info: AddrInfo = serde_json::from_str(
r#"{"family":"inet","local":"192.168.1.1","prefixlen":24,"broadcast":"192.168.1.255","scope":"global"}"#,
)
.expect("addr_info json should deserialize");
assert_eq!(info.family, Some(AddressFamily::Inet));
assert_eq!(
info.local_addr(),
Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)))
);
assert_eq!(info.prefixlen(), Some(24));
assert_eq!(info.broadcast, Some(Ipv4Addr::new(192, 168, 1, 255)));
assert!(info.cidr.is_complete());
assert_eq!(
info.cidr.to_cidr_string().as_deref(),
Some("192.168.1.1/24")
);
}
#[test]
fn addr_output_deserializes_typed_operstate() {
let output: AddrOutput = serde_json::from_str(
r#"{"ifindex":2,"ifname":"br-lan","operstate":"UP","address":"aa:bb:cc:dd:ee:ff","addr_info":[]}"#,
)
.expect("addr_output json should deserialize");
assert_eq!(output.operstate, OperState::Up);
}
#[test]
fn interface_cidr_complete_formats() {
let cidr = InterfaceCidr {
local: Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))),
prefixlen: Some(16),
};
let complete = cidr.complete().expect("cidr should be complete");
assert_eq!(complete.to_string(), "10.0.0.1/16");
}
}
+107
View File
@@ -0,0 +1,107 @@
//! i said i aint doing ts no more why am i still here
#![cfg(unix)]
use std::collections::BTreeMap;
use futures::TryStreamExt;
use rtnetlink::Handle;
use rtnetlink::packet_route::{AddressFamily, address::AddressAttribute};
use crate::subcommands::link::LinkOutput;
use crate::subcommands::{
address::{AddrInfo, AddrOutput, AddressFamily as IpAddressFamily, InterfaceCidr},
link,
};
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<AddrOutput>> {
let (conn, handle, _) = rtnetlink::new_connection()?;
tokio::spawn(conn);
get_with_handle(&handle, dev).await
}
pub async fn get_with_handle(
handle: &Handle,
dev: Option<&str>,
) -> anyhow::Result<Vec<AddrOutput>> {
let links = link::nl::get_with_handle(handle, dev).await?;
let link_by_index: BTreeMap<u32, LinkOutput> =
links.into_iter().map(|link| (link.ifindex, link)).collect();
let mut address = handle.address().get();
if let Some(dev) = dev {
if let Some(index) = link_by_index
.values()
.find(|link| link.ifname == dev)
.map(|link| link.ifindex)
{
address = address.set_link_index_filter(index);
} else {
return Ok(Vec::new());
}
}
let mut stream = address.execute();
let mut addr_info_by_index: BTreeMap<u32, Vec<AddrInfo>> = BTreeMap::new();
while let Some(msg) = stream.try_next().await? {
if !matches!(
msg.header.family,
AddressFamily::Inet | AddressFamily::Inet6
) {
continue;
}
let family = match msg.header.family {
AddressFamily::Inet => Some(IpAddressFamily::Inet),
AddressFamily::Inet6 => Some(IpAddressFamily::Inet6),
_ => None,
};
let prefixlen = Some(msg.header.prefix_len);
let scope = Some(format!("{:?}", msg.header.scope).to_lowercase());
let mut local = None;
let mut broadcast = None;
let mut label = None;
for attr in msg.attributes {
match attr {
AddressAttribute::Address(addr) | AddressAttribute::Local(addr)
if local.is_none() =>
{
local = addr.to_string().parse().ok();
}
AddressAttribute::Broadcast(addr) => {
broadcast = addr.to_string().parse().ok();
}
AddressAttribute::Label(name) => {
label = Some(name);
}
_ => {}
}
}
addr_info_by_index
.entry(msg.header.index)
.or_default()
.push(AddrInfo {
family,
cidr: InterfaceCidr { local, prefixlen },
broadcast,
scope,
label,
});
}
let out = link_by_index
.into_values()
.map(|link| AddrOutput {
ifindex: link.ifindex,
ifname: link.ifname,
operstate: link.operstate.unwrap_or(link::OperState::Unknown),
address: link.address,
addr_info: addr_info_by_index.remove(&link.ifindex).unwrap_or_default(),
})
.collect();
Ok(out)
}
+26
View File
@@ -0,0 +1,26 @@
use std::{io, process::Output};
use anyhow::Context;
use super::LinkOutput;
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<LinkOutput>> {
let output = _get(dev).await.context("Can not run command")?;
if !output.status.success() {
anyhow::bail!(String::from_utf8_lossy(&output.stderr).into_owned());
}
serde_json::from_slice(&output.stdout).context("Deserialize failed")
}
pub async fn _get(dev: Option<&str>) -> io::Result<Output> {
let mut cmd = tokio::process::Command::new("ip");
cmd.args(["-j", "link", "show"]);
if let Some(d) = dev {
cmd.arg(d);
}
cmd.output().await
}
+155
View File
@@ -0,0 +1,155 @@
//! Typed wrappers for `ip -j link show`.
pub mod json;
#[cfg(all(unix, feature = "experimental-nl"))]
pub mod nl;
pub use crate::subcommands::Backend;
use crate::utils::serialize::mac::option_mac;
use macaddr::MacAddr;
#[cfg(all(unix, feature = "experimental-nl"))]
use rtnetlink::packet_route::link::State as NetlinkOperState;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// One interface row from `ip -j link show`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinkOutput {
pub ifindex: u32,
pub ifname: String,
#[serde(default)]
pub operstate: Option<OperState>,
#[serde(default, with = "option_mac")]
pub address: Option<MacAddr>,
}
/// Operational state of a Linux network interface.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OperState {
Up,
Down,
Unknown,
Dormant,
LowerLayerDown,
NotPresent,
Testing,
Other,
}
impl OperState {
pub fn parse_lossy(value: &str) -> Self {
match value.to_ascii_uppercase().as_str() {
"UP" => Self::Up,
"DOWN" => Self::Down,
"UNKNOWN" => Self::Unknown,
"DORMANT" => Self::Dormant,
"LOWERLAYERDOWN" | "LOWER_LAYER_DOWN" | "LOWERLAYER_DOWN" => Self::LowerLayerDown,
"NOTPRESENT" | "NOT_PRESENT" => Self::NotPresent,
"TESTING" => Self::Testing,
_ => Self::Other,
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Up => "UP",
Self::Down => "DOWN",
Self::Unknown => "UNKNOWN",
Self::Dormant => "DORMANT",
Self::LowerLayerDown => "LOWERLAYERDOWN",
Self::NotPresent => "NOTPRESENT",
Self::Testing => "TESTING",
Self::Other => "OTHER",
}
}
pub fn is_up(self) -> bool {
matches!(self, Self::Up)
}
}
impl Serialize for OperState {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for OperState {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Ok(Self::parse_lossy(&value))
}
}
#[cfg(all(unix, feature = "experimental-nl"))]
impl From<NetlinkOperState> for OperState {
fn from(value: NetlinkOperState) -> Self {
match value {
NetlinkOperState::Up => Self::Up,
NetlinkOperState::Down => Self::Down,
NetlinkOperState::Unknown => Self::Unknown,
NetlinkOperState::Dormant => Self::Dormant,
NetlinkOperState::LowerLayerDown => Self::LowerLayerDown,
NetlinkOperState::NotPresent => Self::NotPresent,
NetlinkOperState::Testing => Self::Testing,
_ => Self::Other,
}
}
}
/// Fetch link rows using the default backend.
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<LinkOutput>> {
get_with_backend(Backend::Json, dev).await
}
/// Fetch link rows using an explicit backend.
pub async fn get_with_backend(
backend: Backend,
dev: Option<&str>,
) -> anyhow::Result<Vec<LinkOutput>> {
match backend {
Backend::Json => json::get(dev).await,
#[cfg(all(unix, feature = "experimental-nl"))]
Backend::Netlink => nl::get(dev).await,
}
}
#[cfg(test)]
mod tests {
use super::{LinkOutput, OperState};
#[test]
fn operstate_parses_json_and_netlink_spellings() {
assert_eq!(OperState::parse_lossy("UP"), OperState::Up);
assert_eq!(OperState::parse_lossy("Up"), OperState::Up);
assert_eq!(
OperState::parse_lossy("LOWERLAYERDOWN"),
OperState::LowerLayerDown
);
assert_eq!(
OperState::parse_lossy("LowerLayerDown"),
OperState::LowerLayerDown
);
}
#[test]
fn link_output_deserializes_typed_operstate() {
let link: LinkOutput = serde_json::from_str(
r#"{"ifindex":1,"ifname":"eth0","operstate":"UP","address":"aa:bb:cc:dd:ee:ff"}"#,
)
.expect("link json should deserialize");
assert_eq!(link.operstate, Some(OperState::Up));
assert_eq!(
link.address
.map(|mac| mac.to_string().to_ascii_lowercase())
.as_deref(),
Some("aa:bb:cc:dd:ee:ff")
);
}
}
+59
View File
@@ -0,0 +1,59 @@
#![cfg(unix)]
use futures::TryStreamExt;
use rtnetlink::Handle;
use rtnetlink::packet_route::link::LinkAttribute;
use super::LinkOutput;
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<LinkOutput>> {
let (conn, handle, _) = rtnetlink::new_connection()?;
tokio::spawn(conn);
get_with_handle(&handle, dev).await
}
pub async fn get_with_handle(
handle: &Handle,
dev: Option<&str>,
) -> anyhow::Result<Vec<LinkOutput>> {
let mut req = handle.link().get();
if let Some(dev) = dev {
req = req.match_name(dev.to_owned());
}
let mut stream = req.execute();
let mut out = Vec::new();
while let Some(link) = stream.try_next().await? {
let mut ifname = None;
let mut operstate = None;
let mut address = None;
for attr in link.attributes {
match attr {
LinkAttribute::IfName(name) => ifname = Some(name),
LinkAttribute::Address(bytes) => {
address = match bytes.len() {
6 => bytes.first_chunk::<6>().map(|&b| macaddr::MacAddr::from(b)),
8 => bytes.first_chunk::<8>().map(|&b| macaddr::MacAddr::from(b)),
_ => None,
}
}
LinkAttribute::OperState(state) => operstate = Some(state.into()),
_ => {}
}
}
if let Some(ifname) = ifname {
out.push(LinkOutput {
ifindex: link.header.index,
ifname,
operstate,
address,
});
}
}
Ok(out)
}
+10
View File
@@ -0,0 +1,10 @@
pub mod address;
pub mod link;
pub mod neighbor;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Backend {
Json,
#[cfg(all(unix, feature = "experimental-nl"))]
Netlink,
}
+60
View File
@@ -0,0 +1,60 @@
//! idk what to put here
use std::{io, net::IpAddr, process::Output};
use anyhow::{Context, bail};
use super::{NUDState, NeighborItem};
// loose translation of [wakey::utils::query::macs::get_mac]
// i think ill write tokio::process every time tho (for this if let thing) because iterate through all ts youll have to as str and all the hooplas.
// it all turns to live osstr tho so ts just for my own sanity
// thiserror? anyhow
// what is vro sayin
// ahh. instead of using [wakey::utils::cmd::exec_command] which is ass we jus write everything out. so i dont have to .as_str() so often.
pub async fn get(
ip: Option<IpAddr>,
dev: Option<&str>,
nud: &[NUDState],
) -> anyhow::Result<Vec<NeighborItem>> {
let output = _get(ip, dev, nud).await.context("Can not run command")?;
if !output.status.success() {
bail!(String::from_utf8_lossy(&output.stderr).into_owned())
} else {
let mut fuckass: Vec<NeighborItem> =
serde_json::from_slice(&output.stdout).context("Deserialize failed")?;
// i hate ts.
if let Some(dev) = dev {
for item in &mut fuckass {
if item.dev.is_none() {
item.dev = Some(dev.to_owned());
}
}
};
Ok(fuckass)
}
}
pub async fn _get(ip: Option<IpAddr>, dev: Option<&str>, nud: &[NUDState]) -> io::Result<Output> {
let mut cmd = tokio::process::Command::new("ip");
cmd.args(["-j", "neigh", "show"]);
if let Some(ip) = ip {
// cmd.arg("to");
cmd.arg(ip.to_canonical().to_string());
};
if let Some(dev) = dev {
cmd.args(["dev", dev]);
}
for nud in nud {
cmd.arg("nud");
cmd.arg(nud.to_string());
}
cmd.output().await
}
+107
View File
@@ -0,0 +1,107 @@
//! Typed wrappers for `ip -j neigh show`.
pub mod json;
#[cfg(all(unix, feature = "experimental-nl"))]
pub mod nl;
pub use crate::subcommands::Backend;
use crate::utils::serialize::mac::option_mac;
use std::net::IpAddr;
use macaddr::MacAddr;
use serde::{Deserialize, Serialize};
use strum::{Display, EnumString};
/// Structured neighbor query input matching the common `ip neigh` flags.
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
pub struct NeighborInput {
/// Destination address filter. The optional `to` keyword in CLI form is implicit here.
pub to: Option<IpAddr>,
/// Interface-name filter.
pub dev: Option<String>,
/// Neighbor-state filters.
pub nud: Vec<NUDState>,
}
/// Linux neighbor reachability states.
#[derive(
Debug, PartialEq, Eq, EnumString, Display, Clone, Copy, Hash, Serialize, Deserialize, Default,
)]
#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
#[serde(rename_all = "UPPERCASE")]
pub enum NUDState {
/// the neighbour entry is valid forever and can
/// be only be removed administratively.
Permanent,
/// the neighbour entry is valid. No attempts to
/// validate this entry will be made but it can
/// be removed when its lifetime expires.
Noarp,
/// the neighbour entry is valid until the
/// reachability timeout expires.
Reachable,
/// the neighbour entry is valid but suspicious.
/// This option to ip neigh does not change the
/// neighbour state if it was valid and the
/// address is not changed by this command.
Stale,
/// this is a pseudo state used when initially
/// creating a neighbour entry or after trying to
/// remove it before it becomes free to do so.
#[default]
None,
/// the neighbour entry has not (yet) been
/// validated/resolved.
Incomplete,
/// neighbor entry validation is currently
/// delayed.
Delay,
/// neighbor is being probed.
Probe,
/// max number of probes exceeded without
/// success, neighbor validation has ultimately
/// failed.
Failed,
Other(u16),
}
/// One neighbor row from `ip -j neigh show`.
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
pub struct NeighborItem {
#[serde(rename(deserialize = "dst"))]
pub ip: IpAddr,
#[serde(default)]
pub dev: Option<String>,
#[serde(with = "option_mac", default, rename(deserialize = "lladdr"))]
pub mac: Option<MacAddr>,
#[serde(default)]
pub state: Vec<NUDState>,
}
/// Fetch neighbor rows using the default backend.
pub async fn get(
ip: Option<IpAddr>,
dev: Option<&str>,
nud: &[NUDState],
) -> anyhow::Result<Vec<NeighborItem>> {
get_with_backend(Backend::Json, ip, dev, nud).await
}
/// Fetch neighbor rows using an explicit backend.
pub async fn get_with_backend(
backend: Backend,
ip: Option<IpAddr>,
dev: Option<&str>,
nud: &[NUDState],
) -> anyhow::Result<Vec<NeighborItem>> {
match backend {
Backend::Json => json::get(ip, dev, nud).await,
#[cfg(all(unix, feature = "experimental-nl"))]
Backend::Netlink => {
let ips: Vec<IpAddr> = ip.into_iter().collect();
let devs: Vec<&str> = dev.into_iter().collect();
nl::get(&ips, &devs, nud, &[]).await
}
}
}
+139
View File
@@ -0,0 +1,139 @@
//! rtnetlink-based neighbor table query. One syscall, filter in userspace.
#![cfg(unix)]
use std::{
collections::{HashMap, HashSet},
net::IpAddr,
};
use futures::TryStreamExt;
use macaddr::MacAddr;
use rtnetlink::Handle;
use rtnetlink::packet_route::{
AddressFamily,
neighbour::{NeighbourAddress, NeighbourAttribute, NeighbourState},
};
use super::{NUDState, NeighborItem};
use crate::subcommands::link;
/// Fetch neighbors via rtnetlink. Empty slice = no filter (match all).
/// Non-empty slice = match ANY in the set.
// https://github.com/rust-netlink/rtnetlink/blob/main/examples/get_neighbours.rs
pub async fn get(
ips: &[IpAddr],
devs: &[impl AsRef<str>],
nuds: &[NUDState],
macs: &[MacAddr],
) -> anyhow::Result<Vec<NeighborItem>> {
let (conn, handle, _) = rtnetlink::new_connection()?;
tokio::spawn(conn);
get_with_handle(&handle, ips, devs, nuds, macs).await
}
pub async fn get_with_handle(
handle: &Handle,
ips: &[IpAddr],
devs: &[impl AsRef<str>],
nuds: &[NUDState],
macs: &[MacAddr],
) -> anyhow::Result<Vec<NeighborItem>> {
// Build filter sets (empty = match all)
let ip_set: HashSet<IpAddr> = ips.iter().copied().collect();
let dev_set: HashSet<&str> = devs.iter().map(AsRef::as_ref).collect();
let nud_set: HashSet<&NUDState> = nuds.iter().collect();
let mac_set: HashSet<MacAddr> = macs.iter().copied().collect();
// Prefetch all links once; link lookups were the ugliest and most expensive part.
let ifname_cache: HashMap<u32, String> = link::nl::get_with_handle(handle, None)
.await?
.into_iter()
.map(|link| (link.ifindex, link.ifname))
.collect();
let mut neighbor_data = handle.neighbours().get().execute();
let mut result = vec![];
'row: while let Some(msg) = neighbor_data.try_next().await? {
// Only IPv4/IPv6, skip NOARP
if !matches!(
msg.header.family,
AddressFamily::Inet | AddressFamily::Inet6
) || matches!(msg.header.state, NeighbourState::Noarp)
{
continue 'row;
}
let state: NUDState = msg.header.state.try_into().unwrap_or_default();
let mut ip = None;
let mut mac = None;
for attr in msg.attributes {
match attr {
NeighbourAttribute::Destination(addr) => match addr {
NeighbourAddress::Inet(v4) => ip = Some(IpAddr::from(v4)),
NeighbourAddress::Inet6(v6) => ip = Some(IpAddr::from(v6)),
_ => continue 'row,
},
NeighbourAttribute::LinkLocalAddress(bytes) => {
mac = match bytes.len() {
6 => bytes.first_chunk::<6>().map(|&b| MacAddr::from(b)),
8 => bytes.first_chunk::<8>().map(|&b| MacAddr::from(b)),
_ => continue 'row,
}
}
_ => {}
}
}
// Resolve ifindex -> name (cached)
let dev = ifname_cache.get(&msg.header.ifindex).cloned();
let (Some(ip), Some(dev)) = (ip, dev) else {
continue 'row;
};
// Apply filters (empty set = match all)
if !ip_set.is_empty() && !ip_set.contains(&ip) {
continue 'row;
}
if !dev_set.is_empty() && !dev_set.contains(dev.as_str()) {
continue 'row;
}
if !nud_set.is_empty() && !nud_set.contains(&state) {
continue 'row;
}
if !mac_set.is_empty() && !mac.is_some_and(|m| mac_set.contains(&m)) {
continue 'row;
}
result.push(NeighborItem {
ip,
dev: Some(dev),
mac,
state: vec![state],
});
}
Ok(result)
}
impl TryFrom<NeighbourState> for NUDState {
fn try_from(value: NeighbourState) -> Result<Self, Self::Error> {
match value {
NeighbourState::Incomplete => Ok(Self::Incomplete),
NeighbourState::Reachable => Ok(Self::Reachable),
NeighbourState::Stale => Ok(Self::Stale),
NeighbourState::Delay => Ok(Self::Delay),
NeighbourState::Probe => Ok(Self::Probe),
NeighbourState::Failed => Ok(Self::Failed),
NeighbourState::Noarp => Ok(Self::Noarp),
NeighbourState::Permanent => Ok(Self::Permanent),
NeighbourState::None => Ok(Self::None),
NeighbourState::Other(e) => Ok(Self::Other(e)),
_ => Err(u16::MAX),
}
}
type Error = u16;
}
+35
View File
@@ -0,0 +1,35 @@
use std::iter::Fuse;
/// wrap ts into a [Fuse][std::iter::Fuse] or something
pub struct Real<A: Clone, B: Iterator<Item = A>> {
prepend: A,
iter: B,
my_turn: bool,
}
impl<A: Clone, B: Iterator<Item = A>> Real<A, B> {
pub fn new(prepend: A, iter: B) -> Self {
Self {
prepend,
iter,
my_turn: true,
}
}
pub fn fuse(prepend: A, iter: B) -> Fuse<Self> {
Self::new(prepend, iter).fuse()
}
}
impl<A: Clone, B: Iterator<Item = A>> Iterator for Real<A, B> {
type Item = A;
fn next(&mut self) -> Option<Self::Item> {
let a = if self.my_turn {
Some(self.prepend.clone())
} else {
self.iter.next()
};
self.my_turn = !self.my_turn;
a
}
}
+2
View File
@@ -0,0 +1,2 @@
// pub mod iter;
pub mod serialize;
+50
View File
@@ -0,0 +1,50 @@
use macaddr::MacAddr;
use serde::{self, Deserialize, Deserializer, de::Error as DeError};
use serde::{Serialize, Serializer};
pub fn serialize_macs<S>(macs: &[MacAddr], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let strings: Vec<String> = macs.iter().map(|m| m.to_string()).collect();
serde::Serialize::serialize(&strings, serializer)
}
/// Serialize a MacAddr as a string
pub fn serialize<S>(mac: &MacAddr, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&mac.to_string())
}
/// Deserialize a MacAddr from a string
pub fn deserialize<'de, D>(deserializer: D) -> Result<MacAddr, D::Error>
where
D: Deserializer<'de>,
{
let s = <String as serde::Deserialize>::deserialize(deserializer)?;
s.parse::<MacAddr>().map_err(DeError::custom)
}
pub mod option_mac {
use super::*;
/// serialize an [`Option<MacAddr>`]
pub fn serialize<S: Serializer>(bro: &Option<MacAddr>, ser: S) -> Result<S::Ok, S::Error> {
Option::<String>::serialize(&bro.as_ref().map(ToString::to_string), ser)
}
/// deserialize an [`Option<MacAddr>`], returns None for invalid input (::, 0.0.0.0)
pub fn deserialize<'de, D>(des: D) -> Result<Option<MacAddr>, D::Error>
where
D: serde::Deserializer<'de>,
{
let s: Option<&str> = Option::<&str>::deserialize(des)?;
match s {
Some(val) => match val.parse::<MacAddr>() {
Ok(mac) => Ok(Some(mac)),
Err(_) => Ok(None),
},
None => Ok(None),
}
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod mac;
// pub mod vec;
+37
View File
@@ -0,0 +1,37 @@
use serde::Deserialize;
use serde::de;
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany<T> {
One(T),
Many(Vec<T>),
}
pub fn deserialize<'de, D, T>(des: D) -> Result<Vec<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: std::str::FromStr,
T::Err: std::fmt::Display,
{
let raw: OneOrMany<String> = OneOrMany::<String>::deserialize(des)?;
let mut out = Vec::new();
match raw {
OneOrMany::One(s) => {
let t = s.trim();
if !t.is_empty() {
out.push(t.parse().map_err(de::Error::custom)?);
}
}
OneOrMany::Many(vs) => {
for s in vs {
let t = s.trim();
if t.is_empty() {
continue;
}
out.push(t.parse().map_err(de::Error::custom)?);
}
}
}
Ok(out)
}
+144
View File
@@ -0,0 +1,144 @@
use std::collections::HashSet;
use lda_ipjs::subcommands::{address, neighbor};
#[cfg(all(unix, feature = "experimental-nl"))]
#[tokio::test] // ← Use tokio::test instead of manual #[tokio::main]
async fn ball1() -> anyhow::Result<()> {
let result = neighbor::get_with_backend(neighbor::Backend::Netlink, None, None, &[]).await?;
println!("netlink results: {:?}", result);
Ok(()) // ← Don't force error, let it succeed
}
#[tokio::test]
async fn ball2() -> anyhow::Result<()> {
let result = neighbor::get_with_backend(neighbor::Backend::Json, None, None, &[]).await?;
println!("json results: {:?}", result);
Ok(())
}
// Add this to debug the raw JSON
#[tokio::test]
async fn ball_raw_json() -> anyhow::Result<()> {
let output = tokio::process::Command::new("ip")
.args(["-j", "neigh", "show"])
.output()
.await?;
let json = String::from_utf8_lossy(&output.stdout);
println!("Raw JSON:\n{}", json);
// Try to parse it
let parsed: Result<Vec<neighbor::NeighborItem>, _> = serde_json::from_slice(&output.stdout);
match parsed {
Ok(items) => println!("Parsed {} items", items.len()),
Err(e) => println!("Parse error: {}", e),
}
Ok(())
}
// Check what fields actually exist in the JSON
#[tokio::test]
async fn ball_field_analysis() -> anyhow::Result<()> {
let output = tokio::process::Command::new("ip")
.args(["-j", "neigh", "show"])
.output()
.await?;
let raw: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout)?;
println!("Found {} neighbor entries", raw.len());
// Collect all unique field names across all entries
let mut all_fields = std::collections::HashSet::new();
for (i, entry) in raw.iter().enumerate() {
if let Some(obj) = entry.as_object() {
println!("\nEntry {}: {} fields", i, obj.len());
for (key, value) in obj {
all_fields.insert(key.clone());
println!(" {}: {} = {:?}", key, value.type_name(), value);
}
}
}
println!("\n=== All unique fields seen ===");
for field in &all_fields {
println!(" - {}", field);
}
Ok(())
}
// Helper trait to get type name for JSON values
trait TypeName {
fn type_name(&self) -> &str;
}
impl TypeName for serde_json::Value {
fn type_name(&self) -> &str {
match self {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "bool",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}
}
// Test filtering logic
#[tokio::test]
async fn ball_compare_backends() -> anyhow::Result<()> {
println!("=== JSON Backend ===");
let json_result = neighbor::get_with_backend(neighbor::Backend::Json, None, None, &[]).await?;
println!("Got {} entries from JSON", json_result.len());
#[cfg(all(unix, feature = "experimental-nl"))]
{
println!("\n=== Netlink Backend ===");
let nl_result =
neighbor::get_with_backend(neighbor::Backend::Netlink, None, None, &[]).await?;
println!("Got {} entries from netlink", nl_result.len());
// Compare counts
if json_result.len() != nl_result.len() {
println!(
"\n⚠️ Count mismatch! JSON: {}, Netlink: {}",
json_result.len(),
nl_result.len()
);
} else {
println!("\n✅ Both backends returned same count");
}
let a: HashSet<neighbor::NeighborItem> = HashSet::from_iter(json_result);
let b: HashSet<neighbor::NeighborItem> = HashSet::from_iter(nl_result);
println!(
"istg {len1} == {len2} or else",
len1 = a.len(),
len2 = b.len()
);
}
Ok(())
}
// #[tokio::test]
// async fn cidr_filter() {
// unimplemented!("never. i aint add what i dont need")
// }
#[tokio::test]
async fn ipjas() -> anyhow::Result<()> {
let cuh = address::get_with_backend(address::Backend::Json, None).await?;
println!("{cuh:#?}");
Ok(())
}
#[tokio::test]
async fn ipjas_raw() -> anyhow::Result<()> {
let cuh = address::json::_get(None).await?;
println!("{cuh:#?}");
Ok(())
}
+3
View File
@@ -0,0 +1,3 @@
*
!.gitignore
!copy.ps1
Loaded 100 of 329 files, more files were not shown because too many files have changed in this diff. Show more