167 Commits
317 changed files with 33290 additions and 2977 deletions

No files matched your search

+25 -2
View File
@@ -1,10 +1,33 @@
[target.armv7-unknown-linux-musleabihf]
linker = "rust-lld"
# [target.aarch64-unknown-linux-gnu]
# linker = "aarch64-linux-gnu-gcc"
[build]
target = "armv7-unknown-linux-musleabihf"
# target = "armv7-unknown-linux-musleabihf"
[alias]
ldabr = "b -r --target=target.armv7-unknown-linux-musleabihf"
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*"
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:
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:
- name: Checkout
uses: actions/checkout@v4
@@ -20,6 +25,8 @@ jobs:
rustc -V
cargo -V
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
uses: actions/cache@v4
@@ -28,28 +35,78 @@ jobs:
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
key: linux-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build (armv7-musl)
shell: pwsh
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
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@v3
with:
name: wakey-rootfs
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/')
shell: pwsh
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
./scripts/ci_gitea_release.ps1 -Tag "$env:GITHUB_REF_NAME" -AssetPattern "dist/wakey-rootfs-*armv7-unknown-linux-musleabihf.tgz"
# use their personal repo cuz this searches github.com T_T
uses: akkuman/gitea-release-action@v1
with:
token: ${{ secrets.GITEA_TOKEN }}
files: |
dist/wakey-rootfs-*armv7-unknown-linux-musleabihf.tgz
dist/wakey-cc-*unknown-linux-gnu.tgz
tag_name: ${{ github.ref_name }}
+4 -9
View File
@@ -1,10 +1,5 @@
you should use block code to refence code, like 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);
// indented lines
```
in the chat. Else it fucks up formatting.
We work towards clean, maintainable design. im not the best at this thing.
DRY: spawn subagents.
Context: break those files out.
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/
.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": "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,12 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO agents (agent_id, agent_token) VALUES (?1, ?2)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "1d4677e5e37a32a8c0f8ab29ec07986142a621c099a62d3c5973f368e664991c"
}
@@ -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,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,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,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,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,12 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE 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": "a69a1ea43b1a59254cb29b549b5cd3ebfcab8130941dc2d9a78814bc2072c0d5"
}
@@ -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,12 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO agent_meta (agent_id, nickname) VALUES (?1, ?2)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "b69fb8f030b9745db1d8c9db8225426334b4d1edea5d8a8c9b289c17f194c153"
}
@@ -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,12 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE 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": "c496c7a8ada53d6d88ea1a29d9e7bf178fe5f3ee9067357dba05384fa1e2772c"
}
@@ -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,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,12 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE 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": "e508aeb39a57b9defc7cec1bf854669150b8f3a02a7d8312e8fea5951391c1bb"
}
@@ -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,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"
}
Generated
+3214 -228
View File
File diff suppressed because it is too large. Load diff
+35 -16
View File
@@ -1,45 +1,64 @@
[package]
name = "wakey"
version = "0.1.7"
version = "0.3.0"
edition = "2024"
publish = ["gitea"]
[dependencies]
anyhow = "1"
axum = { version = "0", features = ["macros"] }
axum-extra = { version = "0", features = ["query"] }
chrono = { version = "0.4", default-features = false, features = ["clock"] }
clap = { version = "4", features = ["derive"] }
color-eyre = "0"
comfy-table = "7"
futures = "0"
macaddr = { version = "1", features = ["serde", "serde_std"] }
serde = { version = "1", features = ["derive"] }
serde_html_form = "0"
serde_json = "1"
serde_with = { version = "3", features = ["json"] }
strum = { version = "0", features = ["derive", "strum_macros"] }
thiserror = "2"
tokio = { version = "1", features = [
"fs",
"process",
"rt-multi-thread",
"io-util",
"macros",
"time",
] }
tower-http = { version = "0", features = ["fs"] }
urlencoding = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
[profile.release]
opt-level = "z"
strip = true
[features]
default = ["very-smart-parsing"]
very-smart-parsing = [
] # this is the a-bit-redundant parse thing that copilot made
[workspace]
members = ["ipjs"]
members = ["ipjs", "wakey-agent", "wakey-control-plane", "wakey-core", "wakey-linux"]
[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 = "*"
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
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):
- cargo build --release --target armv7-unknown-linux-musleabihf
- Run on a Linux box/router:
- copy target/armv7-unknown-linux-musleabihf/release/wakey to the device
- 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
- a reusable core model
- a Linux/OpenWrt adapter layer
- a CLI for operators
- an outbound agent plus control-plane model
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
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 import-sled-state --from-sled-state /var/lib/wakey-control-plane/state.db --to-state-file /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
+15 -3
View File
@@ -1,10 +1,14 @@
[package]
name = "lda-ipjs"
description = "ip -j show schemas"
version = "0.0.1"
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"] }
@@ -13,7 +17,15 @@ 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"] }
rtnetlink = "0"
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.
-1
View File
@@ -13,7 +13,6 @@
//! 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;
+216 -17
View File
@@ -1,42 +1,241 @@
//! ts
//! Typed wrappers for `ip -j address show`.
//!
//! deals with both ip a (addroutput) and ip l (commonoutput)
//!
//! lowk why its free but its indirection and its ass
//! 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, Serialize};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
use std::net::{IpAddr, Ipv4Addr};
/// i dont include what i dont know about (almost all ts)
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,
/// i imagine UP or DOWN, unknown
pub operstate: String,
// 6 has a serde and the enum doesnt? why. (serializing ts is ass although... im not given an array. they string formatted ts)
/// Interface operational state.
pub operstate: OperState,
#[serde(with = "option_mac", default)]
pub address: Option<MacAddr>,
#[serde(default)] // i wish we have intellisense for this... fuck you metaprogramming
/// Per-address entries attached to this interface.
#[serde(default)]
pub addr_info: Vec<AddrInfo>,
}
// i be copying
// Raw JSON shape from ip -j -4 address show
/// One address entry nested under an interface row.
#[derive(Debug, Deserialize, Serialize)]
pub struct AddrInfo {
pub family: Option<String>,
pub family: Option<AddressFamily>,
pub local: Option<String>,
pub prefixlen: Option<u8>,
#[serde(flatten, default)]
pub cidr: InterfaceCidr,
pub broadcast: Option<String>,
pub broadcast: Option<Ipv4Addr>,
pub scope: Option<String>,
pub label: Option<String>,
// many more exist; we only take what we need
}
/// 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");
}
}
+99 -13
View File
@@ -1,21 +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::address::AddrOutput;
use crate::subcommands::link::LinkOutput;
use crate::subcommands::{
address::{AddrInfo, AddrOutput, AddressFamily as IpAddressFamily, InterfaceCidr},
link,
};
// shit this one is even worse you needa collect info from two places
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<AddrOutput>> {
let (conn, handle, _) = rtnetlink::new_connection()?;
tokio::spawn(conn); // every time?
let mut address = handle.address().get();
let mut link = handle.link().get();
if let Some(dev) = dev {
link = link.match_name(dev.to_owned());
if let Some(ind) = link.execute().try_next().await?.map(|a| a.header.index) {
address = address.set_link_index_filter(ind);
}
};
address.execute().try_next().await?;
todo!()
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)
}
+8
View File
@@ -1,2 +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,
}
+37 -12
View File
@@ -1,12 +1,10 @@
//! ```bash
//! ip -j n s
//! ```
//!
//! yes. this is a real call.
//! 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;
@@ -14,18 +12,18 @@ 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 {
/// supports only the last item (ignore), `to` keyword is optional
/// Destination address filter. The optional `to` keyword in CLI form is implicit here.
pub to: Option<IpAddr>,
/// supports only one item (it complains if multiple)
pub dev: Option<String>, // im all for simplicity
/// takes multiple, has to have `nud` before bro or it will think you `to`
/// Interface-name filter.
pub dev: Option<String>,
/// Neighbor-state filters.
pub nud: Vec<NUDState>,
}
// as input this must be lowercase. as output it is uppercase
/// docs for items come from a random ahh man website idk
/// Linux neighbor reachability states.
#[derive(
Debug, PartialEq, Eq, EnumString, Display, Clone, Copy, Hash, Serialize, Deserialize, Default,
)]
@@ -68,7 +66,7 @@ pub enum NUDState {
Other(u16),
}
/// everything i see
/// One neighbor row from `ip -j neigh show`.
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
pub struct NeighborItem {
#[serde(rename(deserialize = "dst"))]
@@ -80,3 +78,30 @@ pub struct NeighborItem {
#[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
}
}
}
+72 -81
View File
@@ -1,6 +1,5 @@
//! this is purely experimental. im not doing ts no mo
// hallo
//! rtnetlink-based neighbor table query. One syscall, filter in userspace.
#![cfg(unix)]
use std::{
collections::{HashMap, HashSet},
net::IpAddr,
@@ -8,123 +7,115 @@ use std::{
use futures::TryStreamExt;
use macaddr::MacAddr;
use rtnetlink::Handle;
use rtnetlink::packet_route::{
AddressFamily,
link::LinkAttribute,
neighbour::{NeighbourAddress, NeighbourAttribute, NeighbourState},
};
use super::{NUDState, NeighborItem};
use crate::subcommands::link;
// dont you love https://github.com/rust-netlink/rtnetlink/blob/main/examples/get_neighbours.rs
// NeighborItem.state guarantees to be a single thing.
/// 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(
ip: Option<IpAddr>,
dev: Option<&str>,
nud: &[NUDState],
ips: &[IpAddr],
devs: &[impl AsRef<str>],
nuds: &[NUDState],
macs: &[MacAddr],
) -> anyhow::Result<Vec<NeighborItem>> {
let (gip, gdev, gnud) = (ip, dev, nud);
let (conn, handle, _) = rtnetlink::new_connection()?;
tokio::spawn(conn); // every time?
let mut neighbor_data = handle.neighbours().get().execute();
let nudset: HashSet<&NUDState> = HashSet::from_iter(gnud);
tokio::spawn(conn);
// map ifindex to name
let mut ball: HashMap<u32, String> = HashMap::new();
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![];
'big: while let Some(neighbour_message_item) = neighbor_data.try_next().await? {
// Filter by address family
'row: while let Some(msg) = neighbor_data.try_next().await? {
// Only IPv4/IPv6, skip NOARP
if !matches!(
neighbour_message_item.header.family,
msg.header.family,
AddressFamily::Inet | AddressFamily::Inet6
) || matches!(neighbour_message_item.header.state, NeighbourState::Noarp)
// copilot says this to match ip -j n s
) || matches!(msg.header.state, NeighbourState::Noarp)
{
continue 'big;
continue 'row;
}
let state = vec![
neighbour_message_item
.header
.state
.try_into()
.unwrap_or_default(),
]; // ONE ITEM. why tf ts design json.
let state: NUDState = msg.header.state.try_into().unwrap_or_default();
let mut ip = None;
let mut mac = None;
for neigh_attr in neighbour_message_item.attributes {
match neigh_attr {
NeighbourAttribute::Destination(neighbour_address) => match neighbour_address {
NeighbourAddress::Inet(ipv4_addr) => ip = Some(ipv4_addr.into()),
NeighbourAddress::Inet6(ipv6_addr) => ip = Some(ipv6_addr.into()),
_ => continue 'big,
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(items) => {
mac = match items.len() {
6 => items.first_chunk::<6>().map(|&e| MacAddr::from(e)),
8 => items.first_chunk::<8>().map(|&e| MacAddr::from(e)),
_ => continue 'big,
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,
}
}
_ => continue,
_ => {}
}
}
// exquisite
let dev = if let Some(cached) = ball.get(&neighbour_message_item.header.ifindex) {
Some(cached.clone())
} else {
// Query and cache
let name = handle
.link()
.get()
.match_index(neighbour_message_item.header.ifindex)
.execute()
.try_next()
.await?
.and_then(|a| {
a.attributes.into_iter().find_map(|attr| match attr {
LinkAttribute::IfName(name) => Some(name),
_ => None,
})
});
if let Some(ref n) = name {
ball.insert(neighbour_message_item.header.ifindex, n.clone());
}
name
};
// Resolve ifindex -> name (cached)
let dev = ifname_cache.get(&msg.header.ifindex).cloned();
let (Some(ip), Some(dev)) = (ip, dev) else {
continue 'big;
continue 'row;
};
{
// low block
if let Some(fip) = gip
&& fip != ip
{
continue 'big;
// Apply filters (empty set = match all)
if !ip_set.is_empty() && !ip_set.contains(&ip) {
continue 'row;
}
if let Some(fdev) = gdev
&& dev != fdev
{
continue 'big;
if !dev_set.is_empty() && !dev_set.contains(dev.as_str()) {
continue 'row;
}
if !nudset.is_empty() && !nudset.contains(&state[0]) {
continue 'big;
};
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,
state: vec![state],
});
}
Ok(result) // now i need another pass to filter out the uh.
Ok(result)
}
impl TryFrom<NeighbourState> for NUDState {
@@ -140,7 +131,7 @@ impl TryFrom<NeighbourState> for NUDState {
NeighbourState::Permanent => Ok(Self::Permanent),
NeighbourState::None => Ok(Self::None),
NeighbourState::Other(e) => Ok(Self::Other(e)),
_ => Err(u16::MAX), // idk
_ => Err(u16::MAX),
}
}
+10 -5
View File
@@ -2,16 +2,17 @@ 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::nl::get(None, None, &[]).await?;
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::json::get(None, None, &[]).await?;
let result = neighbor::get_with_backend(neighbor::Backend::Json, None, None, &[]).await?;
println!("json results: {:?}", result);
Ok(())
}
@@ -91,11 +92,14 @@ impl TypeName for serde_json::Value {
#[tokio::test]
async fn ball_compare_backends() -> anyhow::Result<()> {
println!("=== JSON Backend ===");
let json_result = neighbor::json::get(None, None, &[]).await?;
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::nl::get(None, None, &[]).await?;
let nl_result =
neighbor::get_with_backend(neighbor::Backend::Netlink, None, None, &[]).await?;
println!("Got {} entries from netlink", nl_result.len());
// Compare counts
@@ -115,6 +119,7 @@ async fn ball_compare_backends() -> anyhow::Result<()> {
len1 = a.len(),
len2 = b.len()
);
}
Ok(())
}
@@ -126,7 +131,7 @@ async fn ball_compare_backends() -> anyhow::Result<()> {
#[tokio::test]
async fn ipjas() -> anyhow::Result<()> {
let cuh = address::json::get(None).await?;
let cuh = address::get_with_backend(address::Backend::Json, None).await?;
println!("{cuh:#?}");
Ok(())
}
+3
View File
@@ -0,0 +1,3 @@
*
!.gitignore
!copy.ps1
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env pwsh
# this does one thing
param(
[string]$PASSWD
)
. "$(Split-Path -Parent $PSScriptRoot)/scripts/lib.ps1"
$PASSWD = Get-DefaultPassword $PASSWD
Write-Output $PSScriptRoot
# idk what this does it works like that then thats how it is
pscp.exe -l root -scp -pw $PASSWD -r 192.168.100.1:/etc/ldlda_help $PSScriptRoot
pscp.exe -l root -scp -pw $PASSWD 192.168.100.1:/etc/rc.local $PSScriptRoot
pscp.exe -l root -scp -pw $PASSWD -r 192.168.100.1:/etc/wakey-agent $PSScriptRoot
Remove-Item -Recurse (Join-Path $PSScriptRoot "root")
pscp.exe -l root -scp -pw $PASSWD -r 192.168.100.1:/root $PSScriptRoot
$initDir = Join-Path $PSScriptRoot 'init.d'
New-Item -ItemType Directory -Force -Path $initDir | Out-Null
# these are sum
$files = "update_wakey" , "wakey" , "update_tailscale" , "wireguard_setup", "lda-override"
$remote = $files.ForEach({ "/etc/init.d/$_" })
$remote | ForEach-Object {
pscp.exe -l root -scp -pw $PASSWD "192.168.100.1:$_" "$initDir/"
}
$hotplugDir = Join-Path $PSScriptRoot 'hotplug.d'
New-Item -ItemType Directory -Force -Path $hotplugDir | Out-Null
$files = "dhcp/95-wakey", "neigh/95-wakey"
$files | ForEach-Object {
New-Item -ItemType Directory -Force -Path (Split-Path "$hotplugDir/$_")
pscp.exe -l root -scp -pw $PASSWD "192.168.100.1:/etc/hotplug.d/$_" "$hotplugDir/$_"
}
+74
View File
@@ -0,0 +1,74 @@
# Report: Device State Rewrite
## What changed
The control plane now stores **complete per-agent Device snapshots** instead of flat observation rows. The agent sends `Vec<Device>` (merged from DHCP, neighbors, hooks). The control plane upserts per agent, deletes stale, fleet view merges across agents at read time.
## Architecture (before → after)
**Before**: Agent → raw hook observations → control plane flattens into rows → re-merges at fleet read → presence recomputed from flat actions
**After**: Agent → inventory → `Vec<Device>` with correct presence → control plane stores as-is → fleet merge at read time from typed rows
## Schema
Old tables dropped: `agent_device_observations`, `agent_device_observation_events`, `agent_observation_snapshots`, `agent_observation_snapshot_keys`
New tables:
- `agent_devices` — per-agent device row (keyed by `DeviceId` serialization)
- `agent_device_macs` — MACs per device, indexed for cross-agent merge
- `agent_device_ips` — IPs per device, indexed for cross-agent merge
- `agent_device_hostnames` — hostnames per device
- `agent_device_facts` — debug/source material (JSON, not identity)
## Concrete types throughout
- `FleetDevice.macs`: `Vec<MacAddr>`, `ips`: `Vec<IpAddr>`, `presence`: `Presence`
- `FleetWakeRoute.mac`: `Option<MacAddr>`, `ip`: `Option<IpAddr>`
- `AgentDeviceWithChildren.macs`: `Vec<MacAddr>`, `ips`: `Vec<IpAddr>`
- Row types (`AgentDeviceMacRow`, `AgentDeviceIpRow`) stay `String` from SQLite, convert via `TryFrom` impls
- `AgentDeviceRow` has `.presence()`, `.first_seen()`, `.last_seen()` methods — no string matching in business code
- `Presence` from wakey-core: `Ord`, `PartialOrd`, `From<&str>`, `.as_str()`
## Rust features used
- `TryFrom<&AgentDeviceMacRow> for MacAddr`, `TryFrom<&AgentDeviceIpRow> for IpAddr`
- `From<&str> for Presence`
- `#[allow(dead_code)]` where appropriate (test-only methods, debug fields)
- Workspace dependencies for shared crates (`macaddr`, `serde`, `tokio`, etc.)
- Shared `TestStore` with `Drop` guard for clean test teardown
## What was removed
- `AgentObservation` struct (replaced by `Device`)
- `AgentDeviceObservation`, `AgentDeviceObservationView`, `AgentDeviceObservationEvent`, `AgentDeviceObservationInput` types
- `inventory_result_to_observations()` — the flatten-then-rebuild function
- `upload_agent_observations`, `list_agent_observations`, `list_agent_observation_history` API endpoints
- `gc_stale_observations()` — no longer needed
- `SyncObservations` CLI command from agent
- `prune_removed_observations_from_path` calls from agent session
- Observations page from UI
## What stayed
- Agent observation store (`/tmp/wakey_observations.json`) — agent-local memory, not forwarded
- Hotplug hooks — still update agent-local store, feed next inventory
- Known devices + identifiers — manual lifecycle, never auto-deleted
- Audit events, alerts — unchanged
- `observation_retention` config field — `#[allow(dead_code)]`, kept for config file compat
## Verification
```
cargo fmt — clean
cargo clippy-all — clean (0 warnings)
cargo test-all — 89 tests pass, 6 ignored (on-device only)
pnpm typecheck — clean
```
## Remaining
- UI cleanup: remove observations page nav entry, observation API types from `api.ts`
- Known device identifier UI controls (API exists, UI needs affordance)
- The `observation_retention` config field could be removed in a future cleanup
- `list_agent_device_rows_for_agent` is `#[allow(dead_code)]` — used in tests, could be `#[cfg(test)]`
+111 -8
View File
@@ -4,13 +4,13 @@ This folder has small helpers for build, CI, and router install. Keep it simple;
## Quick start (recommended)
1. Start your Gitea runner on this Windows PC (if not already running):
1. Start your Fedora/WSL Gitea runner (recommended release path):
```powershell
./scripts/act_runner.ps1
./scripts/act_runner_wsl.ps1 -Action start -Attach
```
If first-time, it prints the exact register command. Run it once, check labels include `windows:host,self-hosted`, then rerun the script.
If first-time, use the runbook in [`WSL_RUNNER.md`](./WSL_RUNNER.md) to update and register the Fedora runner first.
2. Publish, tag, and push (recommended):
@@ -18,7 +18,7 @@ This folder has small helpers for build, CI, and router install. Keep it simple;
./scripts/publish.ps1 -Tag
```
This will bump the version (if you specify one), build, ensure the runner is started, tag, and push. To also publish to the registry, add `-Publish`.
This will bump the version (if you specify one), build locally, tag, push, and start the Fedora/WSL runner in `--once` mode by default. To also publish to the registry, add `-Publish`.
3. Install on the router
@@ -38,19 +38,122 @@ This folder has small helpers for build, CI, and router install. Keep it simple;
## Scripts overview
- `act_runner.ps1` — Start/seed the local Gitea runner. Use `-Attach` to see logs, `-ForceConfigure` to register non-interactively.
- `act_runner_wsl.ps1` — Windows PowerShell wrapper that controls the Fedora/WSL runner.
- `act_runner_fedora.sh` — Linux/Fedora helper used inside WSL for update/register/start/stop/status.
- `dev_push.ps1` — Fast dev loop: build + upload to router. Usage:
- `./scripts/dev_push.ps1 -Pass <password> [-HostName <ip>] [-RemotePath </root/.bin/wakey>] [-Restart] [-Quiet]`
- Uploads to `<RemotePath>.tmp` then atomically moves into place; `-Restart` restarts the service; `-Quiet` silences MOTD.
- `package_rootfs.ps1` — Produces `dist/wakey-rootfs-<version>-<target>.tgz` with `/root/.bin/wakey` and `/etc/init.d/*`.
- `update_wakey_cc.sh` — Linux VPS updater for control-plane bundles; extracts into the selected directory (default: current directory) and restarts `wakey-cc.service`.
- `update_wakey_cc_from_repo.sh` — Linux VPS source updater; clones repo to temp dir, builds UI + control-plane locally, packages bundle tgz, installs into selected directory, and restarts `wakey-cc.service`.
- `package_wakey_cc_bundle.sh` — Produces `dist/wakey-cc-<version>-<target>.tgz` with `bin/wakey-control-plane`, `ui/dist/*`, updater script, and systemd template.
- `publish.ps1` — Optional version bump + build + tag (and `cargo publish` only if you pass `-Publish`).
- `test_remote.ps1` — Build ARM Linux test binaries locally, upload them to the router, and run them there.
## VPS updater (control-plane)
```sh
chmod +x ./scripts/update_wakey_cc.sh
cd /opt/wakey
sudo -E ./scripts/update_wakey_cc.sh
# optional: pin version/target
WAKEY_CC_VERSION=v0.2.0 WAKEY_CC_TARGET=x86_64-unknown-linux-gnu \
sudo -E ./scripts/update_wakey_cc.sh
```
## VPS source updater (control-plane)
Build-and-install locally on the VPS (avoids glibc mismatch from prebuilt binaries):
```sh
chmod +x ./scripts/update_wakey_cc_from_repo.sh
cd /opt/wakey
sudo -E /path/to/repo/scripts/update_wakey_cc_from_repo.sh
# optional: pin ref/target and skip restart
WAKEY_CC_REF=main WAKEY_CC_TARGET=x86_64-unknown-linux-gnu WAKEY_CC_NO_RESTART=1 \
sudo -E /path/to/repo/scripts/update_wakey_cc_from_repo.sh
```
Expected tarball layout:
- `bin/wakey-control-plane`
- `ui/dist/index.html`
- `ui/dist/assets/*`
- `scripts/update_wakey_cc.sh`
- `deploy/systemd/wakey-cc.service`
## Remote test runner
`test_remote.ps1` is the main way to run Linux/router-specific tests that do not
make sense on Windows.
Basic examples:
```powershell
./scripts/test_remote.ps1 -Package wakey
./scripts/test_remote.ps1 -Package wakey -BinaryFilter integration_live_services -Ignored -NoCapture
./scripts/test_remote.ps1 -Package wakey -BinaryFilter integration_live_services -Filter inventory_real_router_default_query_returns_rows_or_empty_cleanly -Ignored -NoCapture
./scripts/test_remote.ps1 -AllPackages
```
Important semantics:
- `-BinaryFilter`
- selects which compiled Rust test executable(s) to run
- `-Filter`
- filters test functions inside a selected Rust test binary
- `-Ignored`
- runs `#[ignore]` tests
- `-IncludeIgnored`
- includes ignored tests in addition to normal ones
- `-List`
- lists tests inside the remote binary instead of executing them
Useful flags:
- `-BuildProfile debug|release`
- `-Exact`
- `-NoCapture`
- `-ShowOutput`
- `-Threads <n>`
- `-RemoteHost root@<router-ip>`
- `-RemoteTestPath /tmp/tmp/wakey-test`
Implementation notes:
- test executables are discovered via Cargo JSON messages, not regexing human output
- binaries are batch-uploaded per package run
- each package run gets a unique remote temp directory for safer concurrent use
- packages with no test executables are skipped instead of treated as failures
## Release checklist (tag push)
1. **Version:** bump crate versions / changelog as you prefer; tag `v*` and push.
2. **Runner:** Fedora/WSL self-hosted labels `self-hosted`, `linux`, `fedora`, `wsl`, `release` online (see [`WSL_RUNNER.md`](./WSL_RUNNER.md)).
3. **Control-plane bundle:** release job builds `ui/dist`, then `wakey-control-plane` for `x86_64-unknown-linux-gnu` and `aarch64-unknown-linux-gnu`. With **zig** + **cargo-zigbuild** on the runner, gnu builds use workflow env `WAKEY_CC_GLIBC_VERSION` (default `2.28`) for a predictable glibc floor; without them, binaries match the host glibc.
4. **Artifacts:** expect `dist/wakey-rootfs-*-armv7-unknown-linux-musleabihf.tgz` and `dist/wakey-cc-*-unknown-linux-gnu.tgz` attached to the Gitea release.
5. **Secret:** `GITEA_TOKEN` must be set for the publish step.
## CI (Gitea)
- Defined in `.gitea/workflows/release.yml`.
- Runs on your `self-hosted, windows` runner.
- Steps: cross build → package rootfs → upload artifact (v3) → publish a release and attach tgz.
- Defined in `.gitea/workflows/release.yml` (tag / manual dispatch): router rootfs + control-plane bundles and Gitea release attach.
- Release job targets your Fedora/WSL runner labels: `self-hosted`, `linux`, `fedora`, `wsl`, `release`.
- Requires `secrets.GITEA_TOKEN` in the repo to publish the release.
## CI (GitHub)
- Defined in `.github/workflows/ci.yml` on pushes/PRs to `main` or `master`:
- **`rust` job:** `cargo fmt --check`, `clippy -D warnings`, `cargo test --workspace` on `ubuntu-latest`.
- **`ui` job:** under `ui/`, `pnpm install --frozen-lockfile`, then `pnpm run typecheck`, `format:check`, and `build` (mirrors what you need before packaging the control-plane bundle). GitHub sets `CI=true` for the runner; pnpm uses lockfile `ui/pnpm-lock.yaml` for cache.
## Runner migration
- Fedora/WSL is now the intended release-builder path.
- The Windows runner can stay registered in parallel during migration and fallback.
- The detailed operator runbook is in [`WSL_RUNNER.md`](./WSL_RUNNER.md).
## Local build/install (manual path)
```powershell
@@ -76,4 +179,4 @@ Or you can use the script:
./scripts/dev_push.ps1 -Pass <password>
```
That’s it. Keep the flow: start runner → tag push → install.
That’s it. Keep the flow: start Fedora/WSL runner → tag push → install.
+131
View File
@@ -0,0 +1,131 @@
# Fedora / WSL Gitea Runner
This repo now expects Linux/router release builds to run on a Fedora 43 runner
inside WSL, not on the Windows host runner.
The goal is simple:
- build `wakey` and `wakey-agent` in a Linux-native environment
- stop fighting Windows-host ARM/musl/TLS toolchain issues
- keep PowerShell on Windows as the operator front door
## Runner layout
Recommended WSL location:
```text
/mnt/c/Users/Admin/Documents/realshit/wakey/ar_data/wsl_runner
```
This matches the default used by `act_runner_wsl.ps1` and
`act_runner_fedora.sh`, so WSL runner config is isolated at
`ar_data/wsl_runner/config.yaml` in this repo.
Recommended labels:
```text
self-hosted,linux,fedora,wsl,release:host
```
The Windows runner can stay registered during migration as fallback.
## Fedora setup
Install the basics in Fedora 43:
```bash
sudo dnf install -y git curl tar gzip findutils python3
sudo dnf install -y powershell
```
Install Rust and the router target:
```bash
rustup target add armv7-unknown-linux-musleabihf
```
Install the native cross toolchain pieces you need for Linux/ARM router builds.
The exact package names can vary over time, so confirm the current Fedora names
for:
- ARM Linux GCC
- musl development/toolchain support
- OpenSSL/ring/TLS build prerequisites if needed by your dependency graph
## Runner lifecycle
From Windows PowerShell, using the WSL wrapper:
```powershell
./scripts/act_runner_wsl.ps1 -Action update
./scripts/act_runner_wsl.ps1 -Action register -ServerUrl https://git.ldlda.com/ -Token <runner-token>
./scripts/act_runner_wsl.ps1 -Action start -Attach
./scripts/act_runner_wsl.ps1 -Action start -Once
./scripts/act_runner_wsl.ps1 -Action status
./scripts/act_runner_wsl.ps1 -Action stop
```
Path control (binary and config are independent):
```powershell
./scripts/act_runner_wsl.ps1 -Action status `
-RunnerBin /home/lda/gitea-runner/act_runner `
-ConfigPath /mnt/c/Users/Admin/Documents/realshit/wakey/ar_data/wsl_runner/config.yaml
```
If your distro name is not `FedoraLinux-43`, set it explicitly:
```powershell
./scripts/act_runner_wsl.ps1 -Distro FedoraLinux-43 -Action status
```
Or set:
```powershell
$env:WAKEY_WSL_DISTRO = "FedoraLinux-43"
```
Inside Fedora directly, you can also use:
```bash
./scripts/act_runner_fedora.sh update
./scripts/act_runner_fedora.sh register --server-url https://git.ldlda.com/ --token <runner-token>
./scripts/act_runner_fedora.sh start --attach
./scripts/act_runner_fedora.sh start --once
./scripts/act_runner_fedora.sh status
./scripts/act_runner_fedora.sh stop
```
## Release flow
The release workflow is expected to run on the Fedora/WSL runner labels:
```text
self-hosted + linux + fedora + wsl + release
```
It builds:
- `wakey`
- `wakey-agent`
and packages both into the rootfs tarball.
From Windows, the intended operator flow remains:
```powershell
./scripts/publish.ps1 -Tag
```
That script should trigger the WSL runner in `--once` mode by default.
## Rollback
If the Fedora runner path is broken:
1. stop using the WSL runner labels in the workflow
2. temporarily point the workflow back to the Windows runner labels
3. use the existing Windows runner as fallback while fixing the Fedora path
Do not delete the Windows runner until the Fedora path has already produced at
least one successful full release and one successful router install.
Regular → Executable
+5 -4
View File
@@ -1,12 +1,13 @@
# Starts the Gitea act_runner if not already running.
# Usage: ./scripts/act_runner.ps1 -Config "C:/Users/Admin/Documents/gitea/runner.yaml" -RunnerPath "C:/Users/Admin/Documents/gitea/act_runner.exe" -ServerUrl "https://git.ldlda.com/" -Token "<reg token>" -Labels "self-hosted,windows"
#!/usr/bin/env pwsh
# Starts the Windows-host Gitea act_runner if not already running.
# Usage: ./scripts/act_runner.ps1 -Config "C:/Users/Admin/Documents/gitea/runner.yaml" -RunnerPath "C:/Users/Admin/Documents/gitea/act_runner.exe" -ServerUrl "https://git.ldlda.com/" -Token "<reg token>" -Labels "self-hosted,windows:host"
param(
[string]$RunnerPath = "$HOME/Documents/gitea/act_runner.exe",
# Config is a FILE path (e.g., runner.yaml). Parent folder will be created if missing.
[string]$Config = (Join-Path (Split-Path -Parent $PSScriptRoot) 'ar_data/config.yaml'),
[string]$ServerUrl,
[string]$Token,
[string]$Labels = "windows:host,self-hosted",
[string]$Labels = "self-hosted,windows:host",
# Opt-in to non-interactive configure; by default we print the command for you to run manually.
[switch]$ForceConfigure,
# When -Attach, run in the foreground to see logs (good for first-time troubleshooting)
@@ -46,7 +47,7 @@ if (-not (Test-Path $configFile)) {
Pop-Location
}
else {
Write-Host "Config not found. Run this manually once (note labels embed host executor on Windows):"
Write-Host "Config not found. Run this manually once (Windows-host runner labels):"
Write-Host "`t$RunnerPath register --no-interactive --config `"$configFile`" --instance `"$ServerUrl`" --token `"$Token`" --labels `"$Labels`""
exit 2
}
+231
View File
@@ -0,0 +1,231 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
RUNNER_BIN="${RUNNER_BIN:-/home/lda/gitea-runner/act_runner}"
CONFIG_PATH="${CONFIG_PATH:-$REPO_ROOT/ar_data/wsl_runner/config.yaml}"
RUNNER_HOME="${RUNNER_HOME:-$(dirname "$CONFIG_PATH")}"
SERVER_URL="${SERVER_URL:-}"
TOKEN="${TOKEN:-}"
LABELS="${LABELS:-self-hosted,linux,fedora,wsl,release:host}"
normalize_paths() {
local config_dir config_file
config_dir="$(cd "$(dirname "$CONFIG_PATH")" && pwd)"
config_file="$(basename "$CONFIG_PATH")"
CONFIG_PATH="$config_dir/$config_file"
RUNNER_HOME="$config_dir"
}
usage() {
cat <<'EOF'
Usage:
act_runner_fedora.sh update
act_runner_fedora.sh register --server-url URL --token TOKEN [--labels LABELS]
act_runner_fedora.sh start [--attach] [--once]
act_runner_fedora.sh stop
act_runner_fedora.sh status
Environment overrides:
RUNNER_HOME, RUNNER_BIN, CONFIG_PATH, SERVER_URL, TOKEN, LABELS
Path overrides:
--runner-home DIR
--runner-bin PATH
--config-path PATH
EOF
}
ensure_dirs() {
mkdir -p "$RUNNER_HOME" "$(dirname "$RUNNER_BIN")"
}
ensure_runner_bin() {
if [[ ! -x "$RUNNER_BIN" ]]; then
echo "Runner binary not found or not executable at $RUNNER_BIN" >&2
exit 2
fi
}
runner_pids() {
ps -eo pid=,args= | awk -v bin="$RUNNER_BIN" -v config="$CONFIG_PATH" '
$2 == bin && $3 == "daemon" && $4 == "--config" && $5 == config { print $1 }
'
}
update_runner() {
ensure_dirs
local api="https://gitea.com/api/v1/repos/gitea/act_runner/releases/latest"
local release
release="$(curl -fsSL "$api")"
local asset_url
asset_url="$(
python3 - "$(printf '%s' "$release")" <<'PY'
import json, sys
data = json.loads(sys.argv[1])
for asset in data["assets"]:
if asset["name"].endswith("linux-amd64"):
print(asset["browser_download_url"])
break
else:
raise SystemExit("no linux-amd64 act_runner asset found")
PY
)"
local tmp="$RUNNER_HOME/act_runner.tmp"
curl -fsSL "$asset_url" -o "$tmp"
chmod +x "$tmp"
mv -f "$tmp" "$RUNNER_BIN"
echo "Updated act_runner -> $RUNNER_BIN"
}
register_runner() {
ensure_dirs
ensure_runner_bin
if [[ -z "$SERVER_URL" || -z "$TOKEN" ]]; then
echo "register requires SERVER_URL and TOKEN (or --server-url/--token)" >&2
exit 2
fi
"$RUNNER_BIN" register \
--no-interactive \
--config "$CONFIG_PATH" \
--instance "$SERVER_URL" \
--token "$TOKEN" \
--labels "$LABELS"
}
start_runner() {
local mode="${1:-daemon}"
local config_dir config_file
ensure_dirs
ensure_runner_bin
if [[ ! -f "$CONFIG_PATH" ]]; then
echo "Runner config not found at $CONFIG_PATH" >&2
exit 2
fi
config_dir="$(dirname "$CONFIG_PATH")"
config_file="$(basename "$CONFIG_PATH")"
case "$mode" in
attach)
cd "$config_dir"
exec "$RUNNER_BIN" daemon --config "$config_file"
;;
once)
cd "$config_dir"
exec "$RUNNER_BIN" daemon --config "$config_file" --once
;;
daemon)
(
cd "$config_dir"
nohup "$RUNNER_BIN" daemon --config "$config_file" >/tmp/act_runner.log 2>&1 &
)
echo "Started act_runner in background"
;;
*)
echo "Unknown start mode: $mode" >&2
exit 2
;;
esac
}
stop_runner() {
local pids
pids="$(runner_pids)"
if [[ -z "$pids" ]]; then
return 0
fi
kill $pids
}
status_runner() {
ensure_runner_bin
if [[ -n "$(runner_pids)" ]]; then
echo "act_runner is running"
else
echo "act_runner is off"
fi
}
ACTION="${1:-}"
shift || true
while [[ $# -gt 0 ]]; do
case "$1" in
--server-url)
SERVER_URL="$2"
shift 2
;;
--token)
TOKEN="$2"
shift 2
;;
--labels)
LABELS="$2"
shift 2
;;
--runner-home)
RUNNER_HOME="$2"
if [[ "${RUNNER_BIN:-}" == "/home/lda/gitea-runner/act_runner" ]]; then
RUNNER_BIN="$RUNNER_HOME/act_runner"
fi
if [[ "${CONFIG_PATH:-}" == "$REPO_ROOT/ar_data/wsl_runner/config.yaml" ]]; then
CONFIG_PATH="$RUNNER_HOME/config.yaml"
fi
shift 2
;;
--runner-bin)
RUNNER_BIN="$2"
shift 2
;;
--config-path)
CONFIG_PATH="$2"
RUNNER_HOME="$(dirname "$CONFIG_PATH")"
shift 2
;;
--attach)
START_MODE="attach"
shift
;;
--once)
START_MODE="once"
shift
;;
*)
echo "Unknown argument: $1" >&2
usage
exit 2
;;
esac
done
normalize_paths
case "$ACTION" in
update)
update_runner
;;
register)
register_runner
;;
start)
start_runner "${START_MODE:-daemon}"
;;
stop)
stop_runner
;;
status)
status_runner
;;
*)
usage
exit 2
;;
esac
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env pwsh
param(
[ValidateSet("update", "register", "start", "stop", "status")]
[string]$Action = "start",
[string]$Distro = $(if ($env:WAKEY_WSL_DISTRO) { $env:WAKEY_WSL_DISTRO } else { "FedoraLinux-43" }),
[string]$RunnerHome,
[string]$RunnerBin = "/home/lda/gitea-runner/act_runner",
[string]$ConfigPath,
[string]$ServerUrl,
[string]$Token,
[string]$Labels = "self-hosted,linux,fedora,wsl,release:host",
[switch]$Attach,
[switch]$Once
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot/lib.ps1"
$scriptWin = Join-Path $PSScriptRoot "act_runner_fedora.sh"
if (-not (Test-Path $scriptWin)) {
throw "Missing Fedora runner helper: $scriptWin"
}
$scriptLinux = (& wsl.exe -d $Distro -e wslpath -a "$scriptWin").Trim()
if (-not $scriptLinux) {
throw "Failed to resolve WSL path for $scriptWin"
}
if (-not $RunnerHome) {
$repoRootWin = Split-Path -Parent $PSScriptRoot
$defaultRunnerHomeWin = Join-Path $repoRootWin "ar_data/wsl_runner"
$RunnerHome = (& wsl.exe -d $Distro -e wslpath -a "$defaultRunnerHomeWin").Trim()
if (-not $RunnerHome) {
throw "Failed to resolve WSL runner home for $defaultRunnerHomeWin"
}
}
if (-not $ConfigPath) {
$repoRootWin = Split-Path -Parent $PSScriptRoot
$defaultConfigWin = Join-Path $repoRootWin "ar_data/wsl_runner/config.yaml"
$ConfigPath = (& wsl.exe -d $Distro -e wslpath -a "$defaultConfigWin").Trim()
if (-not $ConfigPath) {
throw "Failed to resolve WSL config path for $defaultConfigWin"
}
}
$parts = @("bash", (Quote-ShArg $scriptLinux), (Quote-ShArg $Action), "--runner-home", (Quote-ShArg $RunnerHome))
$parts += @("--runner-bin", (Quote-ShArg $RunnerBin), "--config-path", (Quote-ShArg $ConfigPath))
if ($ServerUrl) { $parts += @("--server-url", (Quote-ShArg $ServerUrl)) }
if ($Token) { $parts += @("--token", (Quote-ShArg $Token)) }
if ($Labels) { $parts += @("--labels", (Quote-ShArg $Labels)) }
if ($Attach) { $parts += "--attach" }
if ($Once) { $parts += "--once" }
$cmd = $parts -join " "
Write-Host $cmd
& wsl.exe -d $Distro -e bash -lc $cmd
if ($LASTEXITCODE -ne 0) {
throw "WSL runner action failed ($LASTEXITCODE)"
}
-57
View File
@@ -1,57 +0,0 @@
param(
[Parameter(Mandatory = $true)][string]$Tag,
[Parameter(Mandatory = $true)][string]$AssetPattern,
[string]$ServerUrl,
[string]$Token,
[string]$Owner,
[string]$Repo
)
$ErrorActionPreference = 'Stop'
# Resolve token
if (-not $Token) { $Token = $env:GITEA_TOKEN }
if (-not $Token) { throw 'Missing Gitea token. Set -Token or $env:GITEA_TOKEN' }
# Resolve repo owner/name
if (-not $Owner -or -not $Repo) {
if ($env:GITHUB_REPOSITORY) {
$parts = $env:GITHUB_REPOSITORY -split '/'
if (-not $Owner -and $parts.Length -ge 1) { $Owner = $parts[0] }
if (-not $Repo -and $parts.Length -ge 2) { $Repo = $parts[1] }
}
else {
throw 'Missing Owner/Repo and GITHUB_REPOSITORY not set'
}
}
# Resolve API base
$api = $env:GITHUB_API_URL
if (-not $api -and $env:GITHUB_SERVER_URL) { $api = "$($env:GITHUB_SERVER_URL)/api/v1" }
if (-not $api -and $ServerUrl) { $api = "$ServerUrl/api/v1" }
if (-not $api) { throw 'Could not determine Gitea API URL. Provide -ServerUrl or set GITHUB_SERVER_URL/GITHUB_API_URL' }
# Find asset
$asset = Get-ChildItem -Path $AssetPattern -ErrorAction Stop | Select-Object -First 1
if (-not $asset) { throw "No asset matches pattern: $AssetPattern" }
$headers = @{ Authorization = "token $Token" }
$body = @{ tag_name = $Tag; name = $Tag; draft = $false; prerelease = $false } | ConvertTo-Json
Write-Host "Creating release $Tag for $Owner/$Repo"
try {
$release = Invoke-RestMethod -Headers $headers -Uri "$api/repos/$Owner/$Repo/releases" -Method Post -Body $body -ContentType 'application/json'
}
catch {
# If already exists, fetch by tag
Write-Host "Create failed; trying to fetch existing release for tag $Tag" -ForegroundColor Yellow
$release = Invoke-RestMethod -Headers $headers -Uri "$api/repos/$Owner/$Repo/releases/tags/$Tag" -Method Get
}
$rid = $release.id
if (-not $rid) { throw "Failed to resolve release id: $($release | ConvertTo-Json -Depth 5)" }
$uploadUri = "$api/repos/$Owner/$Repo/releases/$rid/assets?name=$($asset.Name)"
Write-Host "Uploading asset $($asset.Name)"
Invoke-WebRequest -UseBasicParsing -Headers $headers -Uri $uploadUri -Method Post -InFile $asset.FullName -ContentType 'application/octet-stream' | Out-Null
Write-Host "Release published: tag $Tag with asset $($asset.Name)"
Regular → Executable
+71 -20
View File
@@ -1,13 +1,22 @@
#!/usr/bin/env pwsh
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidDefaultValueSwitchParameter', "",
Justification = 'Default true is intentional for fast dev loop')]
param(
[ValidateSet("cargo", "cross")]
[string]$Cargo = "cargo",
[string]$Pass,
[string]$HostName = "192.168.100.1",
[int]$Port = 22,
[string]$User = "root",
[string]$RemotePath = "/root/.bin/wakey",
[string]$AgentRemotePath = "/root/.bin/wakey-agent",
[string]$RemoteInitPath = "/etc/init.d/wakey",
[string]$Target = "armv7-unknown-linux-musleabihf",
[string]$BinName = "wakey",
[string]$AgentBinName = "wakey-agent",
[string]$HostKey,
[switch]$ForcePassword,
[switch]$SkipInitScript,
[switch]$Restart = $true,
[switch]$Quiet = $true
)
@@ -17,6 +26,13 @@ try { $PSStyle.OutputRendering = 'Host' } catch {}
. "$PSScriptRoot/lib.ps1"
$Pass = Get-DefaultPassword $Pass
# Backward compatibility: allow HostName in the form host:port.
$hostPort = Split-HostPort -HostName $HostName -DefaultPort $Port
$HostName = $hostPort.Host
$Port = $hostPort.Port
function Get-DeployScript {
param($DeployPreferred, $DeployTmp, $RemoteTmp, $RemotePath, $RestartFlag)
return @"
@@ -35,48 +51,83 @@ $repoRoot = Split-Path -Parent $PSScriptRoot
Push-Location $repoRoot
try {
Write-Host "[build] cargo build --release --target $Target" -ForegroundColor Cyan
cargo build --release --target $Target
if ($LASTEXITCODE -ne 0) { throw "cargo build failed ($LASTEXITCODE)" }
Write-Host "[build] $Cargo build --release --target $Target -p wakey -p wakey-agent" -ForegroundColor Cyan
. "$Cargo" build --release --target $Target -p wakey -p wakey-agent
if ($LASTEXITCODE -ne 0) { throw "$Cargo build failed ($LASTEXITCODE)" }
$localBin = Join-Path $repoRoot "target/$Target/release/$BinName"
$localAgentBin = Join-Path $repoRoot "target/$Target/release/$AgentBinName"
if (-not (Test-Path $localBin)) { throw "binary not found: $localBin" }
if (-not (Test-Path $localAgentBin)) { throw "binary not found: $localAgentBin" }
$remoteTmp = "$RemotePath.tmp"
$agentRemoteTmp = "$AgentRemotePath.tmp"
$destTmp = "$User@${HostName}:$remoteTmp"
$agentDestTmp = "$User@${HostName}:$agentRemoteTmp"
$initTmp = '/var/tmp/wakey.init.tmp'
$initDestTmp = "$User@${HostName}:$initTmp"
$localDeploy = Join-Path $repoRoot 'scripts/remote_deploy_wakey.sh'
$localInit = Join-Path $repoRoot 'scripts/init/openwrt/wakey'
$localDhcpHotplug = Join-Path $repoRoot 'scripts/hotplug/openwrt/dhcp/95-wakey'
$localNeighHotplug = Join-Path $repoRoot 'scripts/hotplug/openwrt/neigh/95-wakey'
$deployTmp = '/var/tmp/remote_deploy_wakey.sh'
$deployPreferred = '/root/.bin/remote_deploy_wakey.sh'
$dhcpHotplugTmp = '/var/tmp/wakey.hotplug.dhcp.tmp'
$neighHotplugTmp = '/var/tmp/wakey.hotplug.neigh.tmp'
# Push main binary
Invoke-Scp -Local $localBin -Dest $destTmp -Pass $Pass -HostKey $HostKey -Quiet:$Quiet
# Push binaries
Invoke-Scp -Local $localBin -Dest $destTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
Invoke-Scp -Local $localAgentBin -Dest $agentDestTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
# Push static assets
$localStatic = Join-Path $repoRoot "static"
if (Test-Path $localStatic) {
# Assuming RemotePath is like /root/.bin/wakey, we want /root/.bin/static
# So we push 'static' directory to /root/.bin/
$remoteDir = (Split-Path $RemotePath -Parent) -replace '\\', '/'
# Ensure remote dir exists (ssh mkdir -p)
Invoke-Ssh -Cmd "mkdir -p $remoteDir" -User $User -Remote $HostName -Pass $Pass -Quiet:$Quiet
# SCP -r static user@host:/root/.bin/
# Note: pscp/scp behavior: if dest is a dir, it copies the source dir INTO it.
Invoke-Scp -Local $localStatic -Dest "$User@${HostName}:$remoteDir/" -Pass $Pass -HostKey $HostKey -Quiet:$Quiet -Recurse
# Push OpenWrt init script unless explicitly skipped
if (-not $SkipInitScript -and (Test-Path $localInit)) {
Invoke-Scp -Local $localInit -Dest $initDestTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
}
if (Test-Path $localDhcpHotplug) {
Invoke-Scp -Local $localDhcpHotplug -Dest "$User@${HostName}:$dhcpHotplugTmp" -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
}
if (Test-Path $localNeighHotplug) {
Invoke-Scp -Local $localNeighHotplug -Dest "$User@${HostName}:$neighHotplugTmp" -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
}
# Push deploy helper if exists
if (Test-Path $localDeploy) {
Invoke-Scp -Local $localDeploy -Dest "$User@${HostName}:$deployTmp" -Pass $Pass -HostKey $HostKey -Quiet:$Quiet
Invoke-Scp -Local $localDeploy -Dest "$User@${HostName}:$deployTmp" -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
}
# Build and run remote deploy command
$restartFlag = $(if ($Restart) { '1' } else { '0' })
$script = Get-DeployScript $deployPreferred $deployTmp $remoteTmp $RemotePath $restartFlag
$script = @"
$(Get-DeployScript $deployPreferred $deployTmp $remoteTmp $RemotePath 0)
$(Get-DeployScript $deployPreferred $deployTmp $agentRemoteTmp $AgentRemotePath $restartFlag)
$(if (-not $SkipInitScript) {
@"
if [ -f $initTmp ]; then
sed -i "s/\r$//" $initTmp 2>/dev/null || true
if command -v install >/dev/null 2>&1; then
install -m 0755 $initTmp $RemoteInitPath
else
cp -f $initTmp $RemoteInitPath
chmod 0755 $RemoteInitPath
fi
fi
"@
})
if [ -f $dhcpHotplugTmp ]; then
sed -i "s/\r$//" $dhcpHotplugTmp 2>/dev/null || true
mkdir -p /etc/hotplug.d/dhcp
cp -f $dhcpHotplugTmp /etc/hotplug.d/dhcp/95-wakey
fi
if [ -f $neighHotplugTmp ]; then
sed -i "s/\r$//" $neighHotplugTmp 2>/dev/null || true
mkdir -p /etc/hotplug.d/neigh
cp -f $neighHotplugTmp /etc/hotplug.d/neigh/95-wakey
fi
"@
$remoteCmd = "sh -lc '$($script -replace "`r",'')'"
if ($Quiet) { $remoteCmd += " >/dev/null 2>&1" }
Invoke-Ssh -Cmd $remoteCmd -User $User -Remote $HostName -Pass $Pass -Quiet:$Quiet
Invoke-Ssh -Cmd $remoteCmd -User $User -Remote $HostName -Pass $Pass -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
Write-Host "done ✔" -ForegroundColor Green
}
+32
View File
@@ -0,0 +1,32 @@
#!/bin/sh
BIN="/root/.bin/wakey-agent"
[ -x "$BIN" ] || BIN="$(command -v wakey-agent 2>/dev/null)"
[ -n "$BIN" ] || exit 0
[ -n "$ACTION" ] || exit 0
[ -n "$MACADDR" ] || exit 0
run_wakey_agent() {
if [ -n "$WAKEY_AGENT_CONFIG" ]; then
set -- --config "$WAKEY_AGENT_CONFIG" "$@"
fi
if [ "$WAKEY_HOTPLUG_VERBOSE" = "1" ]; then
set -- -v "$@"
fi
# [ "$WAKEY_HOTPLUG_LOG" = "1" ] &&
if command -v logger >/dev/null 2>&1; then
"$BIN" "$@" 2>&1 | logger -t wakey-hotplug
else
"$BIN" "$@" >/dev/null 2>&1
fi
}
if [ -n "$IPADDR" ] && [ -n "$HOSTNAME" ]; then
run_wakey_agent observe dhcp --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" --hostname "$HOSTNAME" || true
elif [ -n "$IPADDR" ]; then
run_wakey_agent observe dhcp --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" || true
elif [ -n "$HOSTNAME" ]; then
run_wakey_agent observe dhcp --action "$ACTION" --mac "$MACADDR" --hostname "$HOSTNAME" || true
else
run_wakey_agent observe dhcp --action "$ACTION" --mac "$MACADDR" || true
fi
+31
View File
@@ -0,0 +1,31 @@
#!/bin/sh
BIN="/root/.bin/wakey-agent"
[ -x "$BIN" ] || BIN="$(command -v wakey-agent 2>/dev/null)"
[ -n "$BIN" ] || exit 0
[ -n "$ACTION" ] || exit 0
run_wakey_agent() {
if [ -n "$WAKEY_AGENT_CONFIG" ]; then
set -- --config "$WAKEY_AGENT_CONFIG" "$@"
fi
if [ "$WAKEY_HOTPLUG_VERBOSE" = "1" ]; then
set -- -v "$@"
fi
# [ "$WAKEY_HOTPLUG_LOG" = "1" ] &&
if command -v logger >/dev/null 2>&1; then
"$BIN" "$@" 2>&1 | logger -t wakey-hotplug
else
"$BIN" "$@" >/dev/null 2>&1
fi
}
if [ -n "$MACADDR" ] && [ -n "$IPADDR" ]; then
run_wakey_agent observe neigh --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" || true
elif [ -n "$MACADDR" ]; then
run_wakey_agent observe neigh --action "$ACTION" --mac "$MACADDR" || true
elif [ -n "$IPADDR" ]; then
run_wakey_agent observe neigh --action "$ACTION" --ip "$IPADDR" || true
else
run_wakey_agent observe neigh --action "$ACTION" || true
fi
+10 -2
View File
@@ -5,11 +5,14 @@ START=99
USE_PROCD=1
NAME=wakey
BIN=/root/.bin/wakey
BIN=/root/.bin/wakey-agent
CONFIG=/etc/wakey-agent/config.toml
start_service() {
procd_open_instance
procd_set_param command "$BIN"
procd_set_param command "$BIN" serve --config "$CONFIG"
procd_set_param file "$CONFIG"
procd_set_param env RUST_LOG=wakey_agent=debug,wakey=debug
procd_set_param respawn 5 1 0
procd_set_param stdout 1
procd_set_param stderr 1
@@ -19,3 +22,8 @@ start_service() {
stop_service() {
: # procd manages the process; nothing to do here
}
reload_service() {
# Prefer in-process reload; fallback to full restart if signal path is unavailable.
procd_send_signal "$NAME" HUP 2>/dev/null || restart
}
Regular → Executable
+1 -1
View File
@@ -15,7 +15,7 @@ fi
kill -TERM "$pids" 2>/dev/null || true
# Optional: hard kill if still alive after a short grace
usleep 250000 # uhhh sleep is stupid
usleep 250000 # uhhh sleep is not found
remain=""
for p in $pids; do
kill -0 "$p" 2>/dev/null && remain="$remain $p"
Loaded 100 of 317 files, more files were not shown because too many files have changed in this diff. Show more