43 Commits
207 changed files with 13458 additions and 1909 deletions

No files matched your search

+13 -2
View File
@@ -1,8 +1,8 @@
[target.armv7-unknown-linux-musleabihf] [target.armv7-unknown-linux-musleabihf]
linker = "rust-lld" linker = "rust-lld"
[target.aarch64-unknown-linux-gnu] # [target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc" # linker = "aarch64-linux-gnu-gcc"
[build] [build]
# target = "armv7-unknown-linux-musleabihf" # target = "armv7-unknown-linux-musleabihf"
@@ -16,7 +16,18 @@ cw = "c --target=x86_64-pc-windows-msvc"
bw = "b --target=x86_64-pc-windows-msvc" bw = "b --target=x86_64-pc-windows-msvc"
tw = "t --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-features --all-targets"
test-all = "test --workspace --all-features --all-targets"
fmt-all = "fmt --all"
# idk below # idk below
ldar = "r -qrp"
ldabr = "b -r --target=armv7-unknown-linux-musleabihf" ldabr = "b -r --target=armv7-unknown-linux-musleabihf"
t = "test -- --nocapture --test-threads=1" t = "test -- --nocapture --test-threads=1"
tdebug = "test -- --nocapture --test-threads=1 --show-output" 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
+24 -5
View File
@@ -7,6 +7,10 @@ on:
- "v*" - "v*"
workflow_dispatch: workflow_dispatch:
env:
# When using cargo-zigbuild for *-gnu targets, link against this glibc baseline (wider VPS compatibility).
WAKEY_CC_GLIBC_VERSION: "2.28"
jobs: jobs:
build: build:
# My Machine has arm-musl stuff - or else i have to use cross :( # My Machine has arm-musl stuff - or else i have to use cross :(
@@ -43,17 +47,32 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
cd ui cd ui
# corepack enable # no no no
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
pnpm build pnpm build
- name: Build control-plane (linux gnu) - name: Build control-plane (linux-gnu; zig + older glibc when available)
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
for target in x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do GLIBC_VER="${WAKEY_CC_GLIBC_VERSION:-2.28}"
cargo build --release --target "$target" -p wakey-control-plane GNU_TARGETS=(x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu)
done
use_zig=false
if command -v zig >/dev/null 2>&1 && cargo zigbuild -h >/dev/null 2>&1; then
use_zig=true
fi
if [ "$use_zig" = true ]; then
echo "cargo-zigbuild + zig found: building control-plane with glibc ${GLIBC_VER}"
for base in "${GNU_TARGETS[@]}"; do
cargo zigbuild --release --target "${base}.${GLIBC_VER}" -p wakey-control-plane
done
else
echo "zig or cargo zigbuild not available: using plain cargo build (binary needs glibc >= host)"
for base in "${GNU_TARGETS[@]}"; do
cargo build --release --target "$base" -p wakey-control-plane
done
fi
- name: Package rootfs tarball - name: Package rootfs tarball
shell: pwsh shell: pwsh
File renamed without changes.
File renamed without changes.
+1
View File
@@ -2,3 +2,4 @@ We work towards clean, modular, maintainable design, clear documentation... im n
DRY: spawn subagents. DRY: spawn subagents.
Context: break those files out. Context: break those files out.
Test: make functions easy to test.
@@ -0,0 +1,184 @@
# Plan: Observation Store And Sync V1
## Summary
Build device observation support in layers:
1. Local router observations are captured by `wakey observe ...` from OpenWrt hotplug.
2. `wakey-linux` stores current observed DHCP/neigh state locally.
3. `wakey-agent` forwards compact observations to the control plane.
4. The control plane stores observations per agent and later joins them to known devices through `device_identifiers`.
This is separate from durable identity. Observations are facts an agent saw at a time; known devices are manual/user-approved identity records.
## Network/API Boundary
The control plane has two route classes:
- Public agent routes: intended to be reachable by agents.
- Protected control routes: intended to sit behind Cloudflare Access/admin auth.
Observation upload belongs on the public agent route surface because routers/agents must call it:
```text
POST /api/v1/agents/observations
```
The endpoint must require normal agent authentication, using the same persistent `agent_id`/`agent_token` trust model as WebSocket auth. It must not be a Cloudflare Access-only admin endpoint.
Admin/UI read APIs belong under protected control routes:
```text
GET /api/v1/control/observations
GET /api/v1/control/devices
```
## Step 1: Local Observation Store
Replace the narrow `/tmp/wakey_mac_names.json` cache with a local observation store owned by `wakey-linux`.
Minimum local tables/state:
```text
observed_dhcp_clients:
mac
ip
hostname
first_seen_unix
last_seen_unix
last_action
observed_neighbors:
key
mac
ip
first_seen_unix
last_seen_unix
last_action
```
Behavior:
- Hotplug scripts stay minimal and call `wakey-agent observe ...`.
- `wakey-agent observe ...` delegates to `wakey observe ...`.
- `wakey observe dhcp ...` writes DHCP observations.
- `wakey observe neigh ...` writes neighbor observations.
- Existing `wakey leases` and `wakey inventory` still read live sources first, then enrich from the local observation store.
- Do not make local queries depend exclusively on hotplug events yet.
## Step 2: Control-Plane Observation Tables
Add observation tables early so future API/UI work does not need another major state refactor.
Control-plane schema:
```text
agent_device_observations:
observation_key TEXT PRIMARY KEY
agent_id TEXT NOT NULL
kind TEXT NOT NULL
mac TEXT
ip TEXT
hostname TEXT
first_seen_unix INTEGER NOT NULL
last_seen_unix INTEGER NOT NULL
last_action TEXT NOT NULL
agent_device_observation_events:
event_id TEXT PRIMARY KEY
agent_id TEXT NOT NULL
kind TEXT NOT NULL
action TEXT NOT NULL
mac TEXT
ip TEXT
hostname TEXT
ts_unix INTEGER NOT NULL
```
Keep `agent_device_observation_events` optional in behavior if needed, but create it in schema early. Current-state rows are the primary path; event history is for debugging/audit.
Indexes:
```text
agent_device_observations(agent_id)
agent_device_observations(mac)
agent_device_observations(ip)
agent_device_observations(hostname)
agent_device_observations(last_seen_unix)
agent_device_observation_events(agent_id, ts_unix)
agent_device_observation_events(mac)
```
## Step 3: Agent Upload
Add agent upload payload:
```json
{
"observations": [
{
"kind": "dhcp",
"action": "update",
"mac": "04:7c:16:79:6d:ee",
"ip": "192.168.100.94",
"hostname": "lda",
"first_seen_unix": 1770000000,
"last_seen_unix": 1770000123
}
]
}
```
Rules:
- Agent may send snapshots periodically and after local observe events.
- Control plane upserts current-state rows by stable observation key.
- Observation keys should be deterministic from `agent_id`, `kind`, and the best available identifier:
- DHCP with MAC: `agent:{agent_id}:dhcp:mac:{mac}`
- Neigh with MAC: `agent:{agent_id}:neigh:mac:{mac}`
- Neigh without MAC: `agent:{agent_id}:neigh:ip:{ip}`
- Uploads must not create known devices automatically.
## Step 4: Join Observations To Known Devices
Known devices already have durable IDs and manual identifiers:
```text
known_devices
device_identifiers
```
Join rule:
```text
agent_device_observations.mac
-> device_identifiers(kind = 'mac', value = mac)
-> known_devices.device_id
```
Unknown observations are rows that do not match any manual `device_identifiers` row.
This enables:
- same known device observed by multiple agents;
- same known device with multiple MACs;
- UI flow to attach an unknown observed MAC to an existing known device;
- wake flows that choose agent-local observed IP/MAC context for a known device.
## Step 5: UI/API Later
After storage and upload exist:
- show known devices with all matching observations grouped by agent;
- show unknown observations;
- add action: attach observation identifier to known device;
- add action: create known device from observation;
- add wake action from known device using a selected agent/observation.
## Defaults
- `wakey` remains the local router/debugging CLI.
- `wakey-agent` is the sync bridge to the control plane.
- `wakey-control-plane` is durable identity and multi-agent view.
- Local observation store is not authoritative; live sources still matter.
- Control-plane observations are not durable identity; only manual known-device identifiers are.
+69
View File
@@ -0,0 +1,69 @@
# Plan: SQLx Query Macro Adoption
## Summary
Move control-plane SQL from unchecked `sqlx::query(...)` calls toward `sqlx::query!` / `query_as!` where the SQL shape is static.
This gives compile-time checking for table/column names and Rust value types. Dynamic filter builders can stay on `QueryBuilder`.
## Offline Setup
Normal builds should not need a live database:
```text
SQLX_OFFLINE=true
```
`.env` is ignored, so copy:
```sh
cp .env.example .env
```
SQLx query metadata should be generated into `.sqlx/` and committed.
Prepare metadata with:
```sh
cargo install sqlx-cli --no-default-features --features sqlite
./scripts/prepare_sqlx_sqlite.sh
```
The script:
- creates a temporary SQLite database;
- runs `wakey-control-plane/migrations`;
- runs `cargo sqlx prepare --workspace -- -p wakey-control-plane --all-targets --all-features`;
- writes `.sqlx/`.
## Conversion Rules
Use `query!` / `query_as!` for static SQL:
- inserts;
- deletes;
- simple selects by primary key;
- fixed update statements;
- count queries.
Keep non-macro SQL for genuinely dynamic SQL:
- audit event filters built with `QueryBuilder`;
- observation listing with optional filters, unless split into fixed branches;
- any SQL where table/column names are intentionally generated.
## Test/CI
After query macros are introduced, CI should add:
```sh
cargo install sqlx-cli --no-default-features --features sqlite
./scripts/prepare_sqlx_sqlite.sh
cargo sqlx prepare --check --workspace -- --all-targets --all-features
```
Do not add the CI check until the first `.sqlx/` metadata is committed, otherwise it adds dependency install cost without catching anything.
## Notes
The SQLx docs say offline mode needs `cargo sqlx prepare` output checked into version control, and that `DATABASE_URL` takes precedence unless `SQLX_OFFLINE=true` is set. Keep `.env` local; commit `.env.example` and `.sqlx/`.
+57
View File
@@ -0,0 +1,57 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
rust:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: cargo fmt
run: cargo fmt --all -- --check
- name: cargo clippy
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
- name: cargo test
run: cargo test --workspace --all-features
# Operator UI (`ui/`): same checks you’d run locally before a control-plane bundle.
ui:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ui
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: pnpm
cache-dependency-path: ui/pnpm-lock.yaml
- name: pnpm install
run: pnpm install --frozen-lockfile
- name: TypeScript (no emit)
run: pnpm run typecheck
- name: Prettier
run: pnpm run format:check
- name: Vite production build
run: pnpm run build
+104
View File
@@ -0,0 +1,104 @@
name: release
run-name: Release ${{ github.ref_name }}
on:
push:
tags:
- "v*"
workflow_dispatch:
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: pnpm
cache-dependency-path: ui/pnpm-lock.yaml
- name: Install cross
run: cargo install cross --locked
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: linux-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build (armv7-musl with cross)
run: |
cross build --release --target armv7-unknown-linux-musleabihf -p wakey -p wakey-agent
- name: Build UI dist
shell: bash
run: |
set -euo pipefail
cd ui
pnpm install --frozen-lockfile
pnpm build
- name: Build control-plane (linux-gnu)
shell: bash
run: |
set -euo pipefail
# x86_64 builds natively; aarch64 needs cross
echo "Building control-plane x86_64-unknown-linux-gnu (native)"
cargo build --release --target x86_64-unknown-linux-gnu -p wakey-control-plane
echo "Building control-plane aarch64-unknown-linux-gnu (cross)"
cross build --release --target aarch64-unknown-linux-gnu -p wakey-control-plane
- name: Package rootfs tarball
shell: pwsh
run: |
./scripts/package_rootfs.ps1 -Version "${{ github.ref_name }}" -Target armv7-unknown-linux-musleabihf
- name: Package control-plane bundles
shell: bash
run: |
set -euo pipefail
chmod +x scripts/update_wakey_cc.sh scripts/package_wakey_cc_bundle.sh
for target in x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do
./scripts/package_wakey_cc_bundle.sh --version "${{ github.ref_name }}" --target "$target"
done
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: wakey-rootfs
path: dist/wakey-rootfs-*armv7-unknown-linux-musleabihf.tgz
- name: Upload control-plane bundles
uses: actions/upload-artifact@v4
with:
name: wakey-cc-bundles
path: dist/wakey-cc-*unknown-linux-gnu.tgz
- name: Publish Release to GitHub
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ github.ref_name }}
files: |
dist/wakey-rootfs-*armv7-unknown-linux-musleabihf.tgz
dist/wakey-cc-*unknown-linux-gnu.tgz
+2
View File
@@ -18,3 +18,5 @@ target/
#.idea/ #.idea/
.env .env
.codex
+2
View File
@@ -0,0 +1,2 @@
/cache
/project.local.yml
+12
View File
@@ -0,0 +1,12 @@
# Completion Checklist
Before handing back code changes, run the smallest checks covering the changed area:
- Rust formatting: `cargo fmt`.
- For control-plane SQLx/state/API work: `cargo test -p wakey-control-plane` and `SQLX_OFFLINE=true cargo test -p wakey-control-plane`.
- For SQLx query macro changes: run `scripts/prepare_sqlx_sqlite.sh` and include the `.sqlx` metadata changes.
- For wakey-linux/core inventory/observation work: `cargo test -p wakey-linux -p wakey-core`.
- For agent session/config/protocol work: `cargo test -p wakey-agent`.
- For UI work: `pnpm --dir ui typecheck` and `pnpm --dir ui build`; use `pnpm --dir ui format` when touching TS/TSX/CSS formatting.
- Run relevant `cargo clippy -p <crate> --all-targets --all-features` when behavior or API shape changes.
Always check `git status --short` and mention unrelated dirty files or generated metadata in the final summary.
+14
View File
@@ -0,0 +1,14 @@
# Wakey Project Overview
Wakey is a Wake-on-LAN and LAN observability workspace for Linux/OpenWrt routers. It includes:
- `wakey-core`: shared model and parsing types for devices, neighbors, DHCP, interfaces, wake results.
- `wakey-linux`: Linux/OpenWrt adapter layer for DHCP lease files, neighbor lookup, interface data, WoL sending, and local observation storage.
- root `wakey` crate: service layer and operator CLI for inventory, leases, devs, wake, and observe.
- `wakey-agent`: outbound router daemon. Enrolls with control plane, maintains authenticated websocket, executes relayed commands, syncs observations.
- `wakey-control-plane`: Axum control plane, SQLx SQLite state, agent registry, command relay, known devices, observations, audit/alerts, fleet APIs, static UI hosting.
- `ipjs`: typed wrappers around Linux `ip -j` data.
- `ui`: React/Vite operator UI using shadcn/base-ui style components.
Current persistence direction: control plane uses SQLx SQLite with migrations in `wakey-control-plane/migrations` and offline query metadata in `.sqlx` plus `wakey-control-plane/.sqlx`. Legacy sled import exists as explicit command.
Current product direction: fleet-first device UI and backend. Known device identity is manual. Observations/inventory provide suggestions/current state but should not automatically promote or delete durable identifiers.
+23
View File
@@ -0,0 +1,23 @@
# Style And Conventions
Rust:
- Edition 2024.
- Prefer explicit, small service/API functions over large abstractions.
- Use `anyhow::Context` for fallible operations in application layers.
- Use `tracing` for logs.
- Shared domain types live in `wakey-core`; Linux/OpenWrt I/O in `wakey-linux`; root crate owns service/CLI behavior; agent/control-plane should reuse service/domain types where possible.
- SQLx query macros are used in control-plane state. Regenerate `.sqlx` metadata after query string changes.
- Device identity is manual: observations and inventory may suggest, but should not silently pin, merge, promote, or delete known identifiers.
- Hotplug observation actions should stay event-shaped (`add`, `update`, `remove`). Do not store NUD state as the action. NUD belongs to live neighbor/inventory data.
Frontend:
- React + Vite + TypeScript.
- Use existing component/style conventions; shadcn/base-ui/lucide are available.
- Main operator UI is fleet-first: common path is find device, inspect current IP/MAC/agent, wake, copy, remember/merge.
- Admin/debug pages stay available but should not dominate the primary nav.
Editing:
- Keep changes scoped.
- Preserve user edits in dirty worktrees.
- Use `apply_patch` for manual file edits.
- Default to ASCII unless files already use non-ASCII for a reason.
+31
View File
@@ -0,0 +1,31 @@
# Suggested Commands
Rust formatting and checks:
- `cargo fmt`
- `cargo test -p wakey-control-plane`
- `SQLX_OFFLINE=true cargo test -p wakey-control-plane`
- `cargo test -p wakey-linux -p wakey-core`
- `cargo test -p wakey-agent` (may need elevated permissions if tests bind local sockets)
- `cargo clippy -p wakey-control-plane --all-targets --all-features`
- `cargo clippy -p wakey-linux -p wakey-core --all-targets --all-features`
SQLx metadata after query macro changes:
- `scripts/prepare_sqlx_sqlite.sh`
UI checks:
- `pnpm --dir ui typecheck`
- `pnpm --dir ui build`
- `pnpm --dir ui format`
Local run examples:
- `cargo run -- inventory <query>`
- `cargo run -- leases`
- `cargo run -- devs`
- `cargo run -- wake --mac aa:bb:cc:dd:ee:ff`
- `cargo run -p wakey-agent -- serve --config /etc/wakey-agent/config.toml`
- `cargo run -p wakey-control-plane -- serve --config-file /etc/wakey-control-plane/config.toml`
Useful shell tools:
- Prefer `rg` and `rg --files` for search.
- Use `git status --short`, `git diff --stat`, and focused `git diff -- <path>` before edits.
- Avoid destructive git commands unless explicitly requested.
+155
View File
@@ -0,0 +1,155 @@
# the name by which the project can be referenced within Serena
project_name: "wakey"
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# haxe java julia kotlin lua
# markdown
# matlab nix pascal perl php
# php_phpactor powershell python python_jedi r
# rego ruby ruby_solargraph rust scala
# swift terraform toml typescript typescript_vts
# vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- rust
- typescript
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
#
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project based on the project name or path.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_memory`: Delete a memory file. Should only happen if a user asks for it explicitly,
# for example by saying that the information retrieved from a memory file is no longer correct
# or no longer relevant for the project.
# * `edit_memory`: Replaces content matching a regular expression in a memory.
# * `execute_shell_command`: Executes a shell command.
# * `find_file`: Finds files in the given relative paths
# * `find_referencing_symbols`: Finds symbols that reference the given symbol using the language server backend
# * `find_symbol`: Performs a global (or local) search using the language server backend.
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Provides instructions Serena usage (i.e. the 'Serena Instructions Manual')
# for clients that do not read the initial instructions when the MCP server is connected.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: List available memories. Any memory can be read using the `read_memory` tool.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Read the content of a memory file. This tool should only be used if the information
# is relevant to the current task. You can infer whether the information
# is relevant from the memory file name.
# You should not read the same memory file multiple times in the same conversation.
# * `rename_memory`: Renames or moves a memory. Moving between project and global scope is supported
# (e.g., renaming "global/foo" to "bar" moves it from global to project scope).
# * `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities.
# For JB, we use a separate tool.
# * `replace_content`: Replaces content in a file (optionally using regular expressions).
# * `replace_symbol_body`: Replaces the full definition of a symbol using the language server backend.
# * `safe_delete_symbol`:
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `write_memory`: Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format.
# The memory name should be meaningful.
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as read‑only.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO agents (agent_id, agent_token) VALUES (?1, ?2)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "020b33576bfc2d171a051f903d40ccc78c972fa9d5d983a5678f03abbc8b8488"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO enroll_tokens (token, expires_at_unix) VALUES (?1, ?2)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "07d7f283daba2742e5522abf99074171ed484c03f70138be611e0c4b17e311b8"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO agent_device_observation_events\n (event_id, agent_id, kind, action, mac, ip, hostname, ts_unix)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "0a7efadab5487c6ebbf159b4fdd842faac795fe24901756ae67303acc8343e19"
}
@@ -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,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,86 @@
{
"db_name": "SQLite",
"query": "SELECT observations.observation_key as \"observation_key!\",\n observations.agent_id as \"agent_id!\",\n observations.kind as \"kind!\",\n observations.mac,\n observations.ip,\n observations.hostname,\n observations.first_seen_unix,\n observations.last_seen_unix,\n observations.last_action as \"last_action!\",\n known_devices.device_id,\n known_devices.display_name,\n known_devices.pinned\n FROM agent_device_observations observations\n LEFT JOIN device_identifiers identifiers\n ON identifiers.identifier_key =\n CASE\n WHEN observations.mac IS NOT NULL THEN 'mac:' || observations.mac\n WHEN observations.ip IS NOT NULL THEN 'ip:' || observations.ip\n END\n LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id\n WHERE observations.agent_id = ?1\n ORDER BY observations.last_seen_unix DESC\n LIMIT ?2",
"describe": {
"columns": [
{
"name": "observation_key!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "agent_id!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "kind!",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "mac",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "ip",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "hostname",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "first_seen_unix",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "last_seen_unix",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "last_action!",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "device_id",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "display_name",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "pinned",
"ordinal": 11,
"type_info": "Integer"
}
],
"parameters": {
"Right": 2
},
"nullable": [
true,
false,
false,
true,
true,
true,
false,
false,
false,
true,
false,
false
]
},
"hash": "190ea5014566f84255a8655599e03695fb2f5ce9b720700f93d625c1ae2780e6"
}
@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "SELECT device_id as \"device_id!\", display_name as \"display_name!\",\n pinned, created_at_unix, updated_at_unix, notes\n FROM known_devices\n ORDER BY pinned DESC, display_name, device_id",
"describe": {
"columns": [
{
"name": "device_id!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "display_name!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "pinned",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "created_at_unix",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "updated_at_unix",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "notes",
"ordinal": 5,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
true,
false,
false,
false,
false,
true
]
},
"hash": "19b5fc20ffc99589bbaf11b94661f7c0dfcf80748c60f8f3289bb3296114635a"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(*) as \"count!: i64\" FROM agents",
"describe": {
"columns": [
{
"name": "count!: i64",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "21e5986ad3e8efd3393f2bc4929ee79e664cbc2b55948c276d52ca2a6fdc6a48"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO known_devices\n (device_id, display_name, pinned, created_at_unix, updated_at_unix, notes)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "2bff8b8b070f5b5dd946e754c5e15ea405f6c29baa4c2c31347f455227437ae2"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT 1 as \"ok!\"",
"describe": {
"columns": [
{
"name": "ok!",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "2c0dd5d9be90cc716da219c75c2d3b75dae8c0902c64ef5baa96c902f23abc5d"
}
@@ -0,0 +1,68 @@
{
"db_name": "SQLite",
"query": "SELECT observation_key as \"observation_key!\", agent_id as \"agent_id!\",\n kind as \"kind!\", mac, ip, hostname,\n first_seen_unix, last_seen_unix, last_action as \"last_action!\"\n FROM agent_device_observations\n WHERE agent_id = ?1\n ORDER BY last_seen_unix DESC\n LIMIT ?2",
"describe": {
"columns": [
{
"name": "observation_key!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "agent_id!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "kind!",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "mac",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "ip",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "hostname",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "first_seen_unix",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "last_seen_unix",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "last_action!",
"ordinal": 8,
"type_info": "Text"
}
],
"parameters": {
"Right": 2
},
"nullable": [
true,
false,
false,
true,
true,
true,
false,
false,
false
]
},
"hash": "305f50ab2691e12c74e8a222ef12a16f14a65edd7b7c5400a196fd57a9788dcf"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT mac, ip\n FROM agent_device_observations\n WHERE observation_key = ?1",
"describe": {
"columns": [
{
"name": "mac",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "ip",
"ordinal": 1,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
true
]
},
"hash": "3b06698566cea17eed9d10a389ee12a95cee2db6c3d450dca7a3b2d08e8fed7b"
}
@@ -0,0 +1,86 @@
{
"db_name": "SQLite",
"query": "SELECT events.event_id as \"event_id!\",\n ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.kind IN ('neigh', 'inventory') AND events.mac IS NOT NULL AND events.ip IS NOT NULL\n THEN 'mac:' || events.mac || ':ip:' || events.ip\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) as \"observation_key!\",\n events.agent_id as \"agent_id!\",\n events.kind as \"kind!\",\n events.action as \"action!\",\n events.mac,\n events.ip,\n events.hostname,\n events.ts_unix,\n known_devices.device_id,\n known_devices.display_name,\n known_devices.pinned\n FROM agent_device_observation_events events\n LEFT JOIN device_identifiers identifiers\n ON identifiers.identifier_key =\n CASE\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n END\n LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id\n WHERE (?1 IS NULL OR events.agent_id = ?1)\n AND (?2 IS NULL OR events.kind = ?2)\n AND (?3 IS NULL OR events.mac = ?3)\n AND (?4 IS NULL OR events.ip = ?4)\n AND (?5 IS NULL OR ('agent:' || events.agent_id || ':' || events.kind || ':' ||\n CASE\n WHEN events.kind IN ('neigh', 'inventory') AND events.mac IS NOT NULL AND events.ip IS NOT NULL\n THEN 'mac:' || events.mac || ':ip:' || events.ip\n WHEN events.mac IS NOT NULL THEN 'mac:' || events.mac\n WHEN events.ip IS NOT NULL THEN 'ip:' || events.ip\n ELSE ''\n END) = ?5)\n ORDER BY events.ts_unix DESC\n LIMIT ?6",
"describe": {
"columns": [
{
"name": "event_id!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "observation_key!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "agent_id!",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "kind!",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "action!",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "mac",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "ip",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "hostname",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "ts_unix",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "device_id",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "display_name",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "pinned",
"ordinal": 11,
"type_info": "Integer"
}
],
"parameters": {
"Right": 6
},
"nullable": [
true,
true,
false,
false,
false,
true,
true,
true,
false,
true,
true,
true
]
},
"hash": "3bc141cecd0ba7c9ca937036b310728dff57c69ed7b74529fd81fa6a3c0e40cf"
}
@@ -0,0 +1,86 @@
{
"db_name": "SQLite",
"query": "SELECT observations.observation_key as \"observation_key!\",\n observations.agent_id as \"agent_id!\",\n observations.kind as \"kind!\",\n observations.mac,\n observations.ip,\n observations.hostname,\n observations.first_seen_unix,\n observations.last_seen_unix,\n observations.last_action as \"last_action!\",\n known_devices.device_id,\n known_devices.display_name,\n known_devices.pinned\n FROM agent_device_observations observations\n LEFT JOIN device_identifiers identifiers\n ON identifiers.identifier_key =\n CASE\n WHEN observations.mac IS NOT NULL THEN 'mac:' || observations.mac\n WHEN observations.ip IS NOT NULL THEN 'ip:' || observations.ip\n END\n LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id\n ORDER BY observations.last_seen_unix DESC\n LIMIT ?1",
"describe": {
"columns": [
{
"name": "observation_key!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "agent_id!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "kind!",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "mac",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "ip",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "hostname",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "first_seen_unix",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "last_seen_unix",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "last_action!",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "device_id",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "display_name",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "pinned",
"ordinal": 11,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
true,
true,
true,
false,
false,
false,
true,
true,
true
]
},
"hash": "409e1e7aa2d9bc46030001e5c522d7c2812b4583dceadfe5c535a866ac76489c"
}
@@ -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,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO agent_device_observations\n (observation_key, agent_id, kind, mac, ip, hostname,\n first_seen_unix, last_seen_unix, last_action)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)\n ON CONFLICT(observation_key) DO UPDATE SET\n mac = excluded.mac,\n ip = excluded.ip,\n hostname = excluded.hostname,\n first_seen_unix = MIN(agent_device_observations.first_seen_unix, excluded.first_seen_unix),\n last_seen_unix = MAX(agent_device_observations.last_seen_unix, excluded.last_seen_unix),\n last_action = excluded.last_action",
"describe": {
"columns": [],
"parameters": {
"Right": 9
},
"nullable": []
},
"hash": "52d22161e936b39b57d119aab592426e88f60387ccba58888cc7d440012777ed"
}
@@ -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,68 @@
{
"db_name": "SQLite",
"query": "SELECT observation_key as \"observation_key!\", agent_id as \"agent_id!\",\n kind as \"kind!\", mac, ip, hostname,\n first_seen_unix, last_seen_unix, last_action as \"last_action!\"\n FROM agent_device_observations\n ORDER BY last_seen_unix DESC\n LIMIT ?1",
"describe": {
"columns": [
{
"name": "observation_key!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "agent_id!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "kind!",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "mac",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "ip",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "hostname",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "first_seen_unix",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "last_seen_unix",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "last_action!",
"ordinal": 8,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
true,
true,
true,
false,
false,
false
]
},
"hash": "5a0f37b5da194e432c795972d5b3bf67c2d2a00f379143d1ae5871ea790d1bf2"
}
@@ -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": "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,50 @@
{
"db_name": "SQLite",
"query": "SELECT mac, ip, hostname, first_seen_unix, last_seen_unix,\n last_action as \"last_action!\"\n FROM agent_device_observations\n WHERE observation_key = ?1",
"describe": {
"columns": [
{
"name": "mac",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "ip",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "hostname",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "first_seen_unix",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "last_seen_unix",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "last_action!",
"ordinal": 5,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
true,
true,
false,
false,
false
]
},
"hash": "92775bf2e3b9f772d108bb8d0cd1aebbe01357ed70dc2af7b50ee348e668b319"
}
@@ -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,74 @@
{
"db_name": "SQLite",
"query": "SELECT alert_id as \"alert_id!\", kind as \"kind!\", severity as \"severity!\",\n status as \"status!\", agent_id, message as \"message!\",\n value, threshold, last_seen_unix, metadata_json as \"metadata_json!\"\n FROM active_alerts",
"describe": {
"columns": [
{
"name": "alert_id!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "kind!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "severity!",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "status!",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "agent_id",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "message!",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "value",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "threshold",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "last_seen_unix",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "metadata_json!",
"ordinal": 9,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
true,
false,
false,
false,
true,
false,
false,
false,
false,
false
]
},
"hash": "b031d0fa0f9d0640555fc78b400b7bb951a3f013715fb5be51030c2be63e864d"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM known_devices WHERE device_id = ?1",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "b1c6150ef7bd67ce78f8934fc5d451b36f5bed834d39bdde01356e331c5e5ddb"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT value FROM meta WHERE key = ?1",
"describe": {
"columns": [
{
"name": "value",
"ordinal": 0,
"type_info": "Blob"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "b805f8dada38af1c79a7b3bbc063fd949bf81757705ace7173658de9f185868b"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO enroll_tokens (token, expires_at_unix) VALUES (?1, ?2)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "bee62978a2ce59095f9bc679258b5747e074a55d52c43b982ea019c79aff1479"
}
@@ -0,0 +1,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,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,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": "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 meta (key, value) VALUES (?1, ?2)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "ffed939cfeecca1d46bdf77b67aca2919b34d496e729ad08f87ac9782fb8e382"
}
Generated
+765 -13
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "wakey" name = "wakey"
version = "0.2.0" version = "0.2.3"
edition = "2024" edition = "2024"
publish = ["gitea"] publish = ["gitea"]
+30 -12
View File
@@ -23,13 +23,20 @@ service-first project with:
In practice that means commands like: In practice that means commands like:
```sh ```sh
wakey status bedroom-pc wakey inventory bedroom-pc
wakey leases --include-state wakey leases --include-state
wakey devs wakey devs
wakey wake bedroom-pc wakey wake bedroom-pc
wakey wake --mac aa:bb:cc:dd:ee:ff 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 ## Workspace layout
- `wakey-core` - `wakey-core`
@@ -54,7 +61,7 @@ Inside the `wakey` crate:
- `src/service` - `src/service`
- the real use-case layer - the real use-case layer
- status, leases, inventory, interfaces, wake, and query resolution - inventory, leases, interfaces, wake, and query resolution
The long-term direction is: The long-term direction is:
@@ -118,7 +125,7 @@ Example:
data_dir = "/var/lib/wakey-control-plane" data_dir = "/var/lib/wakey-control-plane"
bind = "0.0.0.0:8080" bind = "0.0.0.0:8080"
public_url = "https://cp.example.com" public_url = "https://cp.example.com"
state_file = "state.db" state_file = "state.sqlite3"
pid_file = "wakey-control-plane.pid" pid_file = "wakey-control-plane.pid"
ui_dist_dir = "/opt/wakey/ui/dist" ui_dist_dir = "/opt/wakey/ui/dist"
command_timeout_ms = 30000 command_timeout_ms = 30000
@@ -133,9 +140,14 @@ json_logs = false
If `telemetry.otlp_endpoint` is omitted, logs still work normally and only local If `telemetry.otlp_endpoint` is omitted, logs still work normally and only local
structured logs are emitted. structured logs are emitted.
State is persisted in an embedded `sled` database (default State is persisted in an embedded SQLite database (default
`/var/lib/wakey-control-plane/state.db`). `/var/lib/wakey-control-plane/state.sqlite3`).
Relative paths in config are resolved under `data_dir`. 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`. Enroll tokens are now expiring and revocable. Issuance returns `expires_at_unix`.
Expired tokens are rejected on enroll and can be garbage-collected periodically Expired tokens are rejected on enroll and can be garbage-collected periodically
@@ -214,7 +226,7 @@ agent enrollment and websocket endpoints reachable.
An example Caddy config is provided at: An example Caddy config is provided at:
- `deploy/Caddyfile.control-plane.example` - `deploy/control-plane.Caddyfile`
Expected exposure model: Expected exposure model:
@@ -285,22 +297,24 @@ The update tarball is expected to contain:
`wakey` is usable as a local/operator CLI. `wakey` is usable as a local/operator CLI.
### Status ### Inventory
Show device status rows using a free-form selector: Show merged device inventory rows using a free-form selector:
```sh ```sh
wakey status bedroom-pc wakey inventory bedroom-pc
``` ```
Or use explicit filters: Or use explicit filters:
```sh ```sh
wakey status --dev br-lan --nud reachable wakey inventory --dev br-lan --nud reachable
wakey status --mac aa:bb:cc:dd:ee:ff wakey inventory --mac aa:bb:cc:dd:ee:ff
wakey status --json wakey inventory --json
``` ```
The `status` subcommand is an alias for `inventory` (same flags and output).
### Leases ### Leases
Show DHCP leases: Show DHCP leases:
@@ -359,6 +373,10 @@ cargo test --no-run
cargo clippy --all-targets --all-features -- -D warnings 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: Focused control-plane state tests:
```sh ```sh
@@ -6,13 +6,13 @@
# #
# Replace cp.example.com with your public control-plane domain. # Replace cp.example.com with your public control-plane domain.
cp.example.com { wakey.ldlda.com {
encode zstd gzip encode zstd gzip
# Public agent-facing endpoints. # Public agent-facing endpoints.
@public path /healthz /api/v1/agents/enroll /api/v1/agent/ws @public path /healthz /api/v1/agents/enroll /api/v1/agent/ws
handle @public { handle @public {
reverse_proxy 127.0.0.1:8787 reverse_proxy 127.0.0.1:6767
} }
# Admin surface requires Cloudflare Access headers. # Admin surface requires Cloudflare Access headers.
@@ -21,7 +21,7 @@ cp.example.com {
handle @admin { handle @admin {
handle @cf_access { handle @cf_access {
reverse_proxy 127.0.0.1:8787 reverse_proxy 127.0.0.1:6767
} }
respond "forbidden" 403 respond "forbidden" 403
} }
+2 -2
View File
@@ -5,8 +5,8 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
User=wakey User=lda
Group=wakey Group=lda
WorkingDirectory=/opt/wakey WorkingDirectory=/opt/wakey
ExecStart=/opt/wakey/bin/wakey-control-plane serve --config-file /etc/wakey-control-plane/config.toml ExecStart=/opt/wakey/bin/wakey-control-plane serve --config-file /etc/wakey-control-plane/config.toml
Restart=on-failure Restart=on-failure
+1 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "lda-ipjs" name = "lda-ipjs"
description = "ip -j show schemas" description = "ip -j show schemas"
version = "0.0.3" version = "0.0.4"
edition = "2024" edition = "2024"
publish = ["gitea"] publish = ["gitea"]
+4 -4
View File
@@ -65,10 +65,10 @@ pub async fn get_with_handle(
for attr in msg.attributes { for attr in msg.attributes {
match attr { match attr {
AddressAttribute::Address(addr) | AddressAttribute::Local(addr) => { AddressAttribute::Address(addr) | AddressAttribute::Local(addr)
if local.is_none() { if local.is_none() =>
local = addr.to_string().parse().ok(); {
} local = addr.to_string().parse().ok();
} }
AddressAttribute::Broadcast(addr) => { AddressAttribute::Broadcast(addr) => {
broadcast = addr.to_string().parse().ok(); broadcast = addr.to_string().parse().ok();
+3 -1
View File
@@ -146,7 +146,9 @@ mod tests {
assert_eq!(link.operstate, Some(OperState::Up)); assert_eq!(link.operstate, Some(OperState::Up));
assert_eq!( assert_eq!(
link.address.map(|mac| mac.to_string()).as_deref(), link.address
.map(|mac| mac.to_string().to_ascii_lowercase())
.as_deref(),
Some("aa:bb:cc:dd:ee:ff") Some("aa:bb:cc:dd:ee:ff")
); );
} }
+9 -1
View File
@@ -24,4 +24,12 @@ $files = "update_wakey" , "wakey" , "update_tailscale" , "wireguard_setup", "lda
$remote = $files.ForEach({ "/etc/init.d/$_" }) $remote = $files.ForEach({ "/etc/init.d/$_" })
$remote | ForEach-Object { $remote | ForEach-Object {
pscp.exe -l root -scp -pw $PASSWD "192.168.100.1:$_" "$initDir\" pscp.exe -l root -scp -pw $PASSWD "192.168.100.1:$_" "$initDir\"
} }
$files = "dhcp/95-wakey", "neigh/95-wakey"
$remote = $files.ForEach({ "/etc/hotplug.d/$_" })
$hotplugDir = Join-Path $PSScriptRoot 'hotplug.d'
$remote | ForEach-Object {
pscp.exe -l root -scp -pw $PASSWD "192.168.100.1:$_" "$hotplugDir\"
}
+32 -4
View File
@@ -45,6 +45,7 @@ This folder has small helpers for build, CI, and router install. Keep it simple;
- Uploads to `<RemotePath>.tmp` then atomically moves into place; `-Restart` restarts the service; `-Quiet` silences MOTD. - 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/*`. - `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.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. - `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`). - `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. - `test_remote.ps1` — Build ARM Linux test binaries locally, upload them to the router, and run them there.
@@ -61,6 +62,20 @@ WAKEY_CC_VERSION=v0.2.0 WAKEY_CC_TARGET=x86_64-unknown-linux-gnu \
sudo -E ./scripts/update_wakey_cc.sh 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: Expected tarball layout:
- `bin/wakey-control-plane` - `bin/wakey-control-plane`
@@ -79,7 +94,7 @@ Basic examples:
```powershell ```powershell
./scripts/test_remote.ps1 -Package wakey ./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 -Ignored -NoCapture
./scripts/test_remote.ps1 -Package wakey -BinaryFilter integration_live_services -Filter status_real_router_default_query_succeeds -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 ./scripts/test_remote.ps1 -AllPackages
``` ```
@@ -113,13 +128,26 @@ Implementation notes:
- each package run gets a unique remote temp directory for safer concurrent use - 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 - 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) ## CI (Gitea)
- Defined in `.gitea/workflows/release.yml`. - Defined in `.gitea/workflows/release.yml` (tag / manual dispatch): router rootfs + control-plane bundles and Gitea release attach.
- Release job now targets your Fedora/WSL runner labels: `self-hosted`, `linux`, `fedora`, `wsl`, `release`. - Release job targets your Fedora/WSL runner labels: `self-hosted`, `linux`, `fedora`, `wsl`, `release`.
- Steps: Linux-native build of `wakey` + `wakey-agent` → package rootfs → upload artifact (v3) → publish a release and attach tgz.
- Requires `secrets.GITEA_TOKEN` in the repo to publish the 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 ## Runner migration
- Fedora/WSL is now the intended release-builder path. - Fedora/WSL is now the intended release-builder path.
+20
View File
@@ -68,8 +68,12 @@ try {
$initDestTmp = "$User@${HostName}:$initTmp" $initDestTmp = "$User@${HostName}:$initTmp"
$localDeploy = Join-Path $repoRoot 'scripts/remote_deploy_wakey.sh' $localDeploy = Join-Path $repoRoot 'scripts/remote_deploy_wakey.sh'
$localInit = Join-Path $repoRoot 'scripts/init/openwrt/wakey' $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' $deployTmp = '/var/tmp/remote_deploy_wakey.sh'
$deployPreferred = '/root/.bin/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 binaries # Push binaries
Invoke-Scp -Local $localBin -Dest $destTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword Invoke-Scp -Local $localBin -Dest $destTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
@@ -79,6 +83,12 @@ try {
if (-not $SkipInitScript -and (Test-Path $localInit)) { if (-not $SkipInitScript -and (Test-Path $localInit)) {
Invoke-Scp -Local $localInit -Dest $initDestTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword 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 # Push deploy helper if exists
if (Test-Path $localDeploy) { if (Test-Path $localDeploy) {
@@ -103,6 +113,16 @@ if [ -f $initTmp ]; then
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",'')'" $remoteCmd = "sh -lc '$($script -replace "`r",'')'"
if ($Quiet) { $remoteCmd += " >/dev/null 2>&1" } if ($Quiet) { $remoteCmd += " >/dev/null 2>&1" }
+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
+5
View File
@@ -104,8 +104,10 @@ main() {
# Ensure execute bits on staged files we know should be executable # Ensure execute bits on staged files we know should be executable
for f in \ for f in \
"$STAGING/etc/init.d/"* \ "$STAGING/etc/init.d/"* \
"$STAGING/etc/hotplug.d/"*/* \
"$STAGING/etc/ldlda_help/"*.sh \ "$STAGING/etc/ldlda_help/"*.sh \
"$STAGING/root/.bin/wakey" \ "$STAGING/root/.bin/wakey" \
"$STAGING/root/.bin/wakey-agent" \
"$STAGING/root/.bin/kill_wakey.sh" \ "$STAGING/root/.bin/kill_wakey.sh" \
"$STAGING/root/.bin/remote_deploy_wakey.sh"; do "$STAGING/root/.bin/remote_deploy_wakey.sh"; do
[ -e "$f" ] && chmod +x "$f" 2>/dev/null || true [ -e "$f" ] && chmod +x "$f" 2>/dev/null || true
@@ -114,11 +116,14 @@ main() {
# Normalize line endings for shell scripts (avoid CRLF issues on OpenWrt) # Normalize line endings for shell scripts (avoid CRLF issues on OpenWrt)
for f in \ for f in \
"$STAGING/etc/init.d/"* \ "$STAGING/etc/init.d/"* \
"$STAGING/etc/hotplug.d/"*/* \
"$STAGING/etc/ldlda_help/"*.sh \ "$STAGING/etc/ldlda_help/"*.sh \
"$STAGING/root/.bin/"*.sh; do "$STAGING/root/.bin/"*.sh; do
[ -f "$f" ] && sed -i 's/\r$//' "$f" 2>/dev/null || true [ -f "$f" ] && sed -i 's/\r$//' "$f" 2>/dev/null || true
done done
chown -R root:root "$STAGING" 2>/dev/null || true
# Copy staged tree into / # Copy staged tree into /
tar -C "$STAGING" -cf - . | tar -C / -xpf - || fail "install copy failed" tar -C "$STAGING" -cf - . | tar -C / -xpf - || fail "install copy failed"
+18
View File
@@ -71,6 +71,24 @@ Get-ChildItem (Join-Path $root 'scripts/init/openwrt') -File | ForEach-Object {
Set-Content -NoNewline -LiteralPath $dest -Value $content -Encoding UTF8 Set-Content -NoNewline -LiteralPath $dest -Value $content -Encoding UTF8
} }
# Copy OpenWrt hotplug hooks.
$hotplugSrc = Join-Path $root 'scripts/hotplug/openwrt'
if (Test-Path $hotplugSrc) {
Get-ChildItem $hotplugSrc -Directory | ForEach-Object {
$hotplugTypeDir = Join-Path $staging ("etc/hotplug.d/" + $_.Name)
New-Item -ItemType Directory -Force -Path $hotplugTypeDir | Out-Null
Get-ChildItem $_.FullName -File | ForEach-Object {
$dest = Join-Path $hotplugTypeDir $_.Name
$content = Get-Content -Raw -LiteralPath $_.FullName
$content = $content -replace "`r`n", "`n"
Set-Content -NoNewline -LiteralPath $dest -Value $content -Encoding UTF8
if (Get-Command chmod -ErrorAction SilentlyContinue) {
chmod +x $dest
}
}
}
}
# Copy helper scripts intended for /etc/ldlda_help # Copy helper scripts intended for /etc/ldlda_help
$helpSrc = Join-Path $root 'scripts/ldlda_help' $helpSrc = Join-Path $root 'scripts/ldlda_help'
if (Test-Path $helpSrc) { if (Test-Path $helpSrc) {
+6 -3
View File
@@ -62,8 +62,9 @@ fi
[ -f "$BINARY" ] || { echo "missing binary: $BINARY" >&2; exit 1; } [ -f "$BINARY" ] || { echo "missing binary: $BINARY" >&2; exit 1; }
[ -f "$UI_DIST/index.html" ] || { echo "missing UI dist index: $UI_DIST/index.html" >&2; exit 1; } [ -f "$UI_DIST/index.html" ] || { echo "missing UI dist index: $UI_DIST/index.html" >&2; exit 1; }
[ -f "scripts/update_wakey_cc.sh" ] || { echo "missing updater script" >&2; exit 1; } [ -f "scripts/update_wakey_cc.sh" ] && [ -f "scripts/update_wakey_cc_from_repo.sh" ] || { echo "missing updater scripts" >&2; exit 1; }
[ -f "deploy/systemd/wakey-cc.service" ] || { echo "missing systemd template" >&2; exit 1; } [ -f "deploy/systemd/wakey-cc.service" ] || { echo "missing systemd template" >&2; exit 1; }
[ -f "deploy/control-plane.Caddyfile" ] || { echo "missing Caddyfile" >&2; exit 1; }
mkdir -p "$OUT_DIR" mkdir -p "$OUT_DIR"
STAGING="$OUT_DIR/wakey-cc-stage.$$" STAGING="$OUT_DIR/wakey-cc-stage.$$"
@@ -75,9 +76,11 @@ mkdir -p "$STAGING/bin" "$STAGING/ui" "$STAGING/scripts" "$STAGING/deploy/system
cp "$BINARY" "$STAGING/bin/wakey-control-plane" cp "$BINARY" "$STAGING/bin/wakey-control-plane"
cp -a "$UI_DIST" "$STAGING/ui/dist" cp -a "$UI_DIST" "$STAGING/ui/dist"
cp "scripts/update_wakey_cc.sh" "$STAGING/scripts/update_wakey_cc.sh" cp "scripts/update_wakey_cc.sh" "$STAGING/scripts/update_wakey_cc.sh"
cp "scripts/update_wakey_cc_from_repo.sh" "$STAGING/scripts/update_wakey_cc_from_repo.sh"
cp "deploy/systemd/wakey-cc.service" "$STAGING/deploy/systemd/wakey-cc.service" cp "deploy/systemd/wakey-cc.service" "$STAGING/deploy/systemd/wakey-cc.service"
cp "deploy/control-plane.Caddyfile" "$STAGING/deploy/control-plane.Caddyfile"
chmod +x "$STAGING/bin/wakey-control-plane" "$STAGING/scripts/update_wakey_cc.sh" chmod +x "$STAGING/bin/wakey-control-plane" "$STAGING/scripts/update_wakey_cc.sh" "$STAGING/scripts/update_wakey_cc_from_repo.sh" "$STAGING/deploy/control-plane.Caddyfile"
( (
cd "$STAGING" cd "$STAGING"
@@ -87,4 +90,4 @@ chmod +x "$STAGING/bin/wakey-control-plane" "$STAGING/scripts/update_wakey_cc.sh
rm -rf "$STAGING" rm -rf "$STAGING"
echo "Bundle package: $OUT_DIR/$PKG" echo "Bundle package: $OUT_DIR/$PKG"
echo "Contains: bin/wakey-control-plane, ui/dist, scripts/update_wakey_cc.sh, deploy/systemd/wakey-cc.service" echo "Contains: bin/wakey-control-plane, ui/dist, scripts/update_wakey_cc.sh, scripts/update_wakey_cc_from_repo.sh, deploy/systemd/wakey-cc.service, deploy/control-plane.Caddyfile"
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env sh
set -eu
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
DB="${SQLX_PREPARE_DB:-/tmp/wakey-sqlx-prepare.sqlite3}"
DATABASE_URL="sqlite://$DB"
if ! cargo sqlx --version >/dev/null 2>&1; then
echo "cargo-sqlx is not installed." >&2
echo "Install it with: cargo install sqlx-cli --no-default-features --features sqlite" >&2
exit 1
fi
rm -f "$DB" "$DB-shm" "$DB-wal"
cd "$ROOT"
DATABASE_URL="$DATABASE_URL" cargo sqlx database create
DATABASE_URL="$DATABASE_URL" cargo sqlx migrate run --source wakey-control-plane/migrations
DATABASE_URL="$DATABASE_URL" cargo sqlx prepare --workspace -- -p wakey-control-plane --all-targets --all-features
rm -rf wakey-control-plane/.sqlx
mkdir -p wakey-control-plane/.sqlx
cp .sqlx/*.json wakey-control-plane/.sqlx/
echo "SQLx metadata prepared in $ROOT/.sqlx"
echo "SQLx package metadata mirrored in $ROOT/wakey-control-plane/.sqlx"
+2 -1
View File
@@ -42,6 +42,7 @@ if ($versionProvided) {
} }
# Ensure build ok # Ensure build ok
$env:SQLX_OFFLINE = 'true'
cargo fmt cargo fmt
cargo build --release cargo build --release
@@ -85,7 +86,7 @@ if ($Publish) {
} }
if ($Tag -and $Version) { if ($Tag -and $Version) {
git tag -f "v$Version" git tag -f -a "v$Version" -m "Release $Version"
git push -f origin "v$Version" git push -f origin "v$Version"
Write-Host "Pushed tag: v$Version" Write-Host "Pushed tag: v$Version"
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env sh
# Build and install wakey-control-plane from repository source on the target host.
# This avoids glibc baseline mismatches from prebuilt artifacts by building locally,
# then packaging the same bundle format and installing it.
#
# Simplest use:
# ./scripts/update_wakey_cc_from_repo.sh
#
# Env options:
# WAKEY_CC_REPO_DIR Existing checkout to update/build instead of cloning
# WAKEY_CC_REPO_URL Git URL (default: https://git.ldlda.com/lda/wakey.git)
# WAKEY_CC_REF Git ref/branch/tag (default: main)
# WAKEY_CC_TARGET Rust target triple (default from uname)
# WAKEY_CC_ROOT Install root (default: current directory)
# WAKEY_CC_SERVICE systemd unit name (default: wakey-cc.service)
# WAKEY_CC_USER Owner for installed files/temp dirs (default: current user)
# WAKEY_CC_VERSION Bundle version label (default: v0.0.0-local-<short_sha>)
# WAKEY_CC_NO_RESTART If set, skip systemd restart
# WAKEY_CC_KEEP_TMP If set, keep temp checkout/build dir for debugging
# SUDO Privilege escalation command/path for install steps (default: sudo)
#
# Requires: git, cargo, tar, pnpm, systemctl (optional for restart)
# Common existing-checkout use:
: <<'USE CASE'
WAKEY_CC_REPO_DIR="$HOME/dev/wakey" \
WAKEY_CC_USER=lda \
WAKEY_CC_KEEP_TMP=1 \
scripts/update_wakey_cc_from_repo.sh
USE CASE
set -eu
log() { printf '[update_wakey_cc_from_repo] %s\n' "$*"; }
fail() {
printf '[update_wakey_cc_from_repo] ERROR: %s\n' "$*" >&2
exit 1
}
default_target() {
arch=$(uname -m)
case "$arch" in
x86_64)
printf '%s' 'x86_64-unknown-linux-gnu'
;;
aarch64|arm64)
printf '%s' 'aarch64-unknown-linux-gnu'
;;
*)
fail "unsupported arch for default target mapping: $arch (set WAKEY_CC_TARGET explicitly)"
;;
esac
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1"
}
sudo_cmd() {
if [ "$(id -u)" -eq 0 ]; then
return 0
fi
if [ -n "${SUDO:-}" ]; then
printf '%s' "$SUDO"
return 0
fi
if command -v sudo >/dev/null 2>&1; then
printf '%s' sudo
return 0
fi
fail 'root privileges are required for install/restart steps; set SUDO or run as root'
}
priv() {
if [ "$(id -u)" -ne 0 ]; then
$SUDO_BIN "$@"
else
"$@"
fi
}
restore_label() {
path=$1
if command -v restorecon >/dev/null 2>&1; then
priv restorecon "$path" || true
fi
}
chown_if_requested() {
[ -n "${OWNER:-}" ] || return 0
priv chown -R "$OWNER:$OWNER" "$@"
}
chmod_user_rw_if_requested() {
[ -n "${OWNER:-}" ] || return 0
priv chmod -R u+rwX "$@"
}
update_existing_repo() {
repo_dir=$1
ref=$2
[ -d "$repo_dir/.git" ] || fail "WAKEY_CC_REPO_DIR is not a git checkout: $repo_dir"
log "using existing checkout $repo_dir (ref: $ref)"
git -C "$repo_dir" fetch --prune origin "$ref" || fail 'git fetch failed'
git -C "$repo_dir" checkout "$ref" || fail "git checkout failed: $ref"
git -C "$repo_dir" pull --ff-only origin "$ref" || fail 'git pull --ff-only failed'
}
install_executable_force() {
src=$1
dst=$2
dir=$(dirname "$dst")
tmp="$dst.new.$$"
old="$dst.old.$$"
priv mkdir -p "$dir"
priv rm -f "$tmp"
priv cp -f "$src" "$tmp"
priv chmod 0755 "$tmp"
chown_if_requested "$tmp"
if [ -e "$dst" ]; then
priv mv -f "$dst" "$old" || true
fi
priv mv -f "$tmp" "$dst"
priv rm -f "$old"
restore_label "$dst"
}
main() {
REPO_DIR="${WAKEY_CC_REPO_DIR:-}"
REPO_URL="${WAKEY_CC_REPO_URL:-https://git.ldlda.com/lda/wakey.git}"
REF="${WAKEY_CC_REF:-main}"
TARGET="${WAKEY_CC_TARGET:-$(default_target)}"
ROOT="${WAKEY_CC_ROOT:-$PWD}"
SERVICE="${WAKEY_CC_SERVICE:-wakey-cc.service}"
if [ -n "${WAKEY_CC_USER:-}" ]; then
OWNER="$WAKEY_CC_USER"
elif [ "$(id -u)" -eq 0 ]; then
OWNER=
else
OWNER=$(id -un)
fi
SUDO_BIN=$(sudo_cmd)
if [ -n "$OWNER" ] && ! id "$OWNER" >/dev/null 2>&1; then
fail "WAKEY_CC_USER does not exist: $OWNER"
fi
require_cmd git
require_cmd cargo
require_cmd tar
require_cmd pnpm
TMPBASE="${TMPDIR:-/tmp}"
WORKDIR="$TMPBASE/wakey-cc-src.$$"
STAGING="$TMPBASE/wakey-cc-stage.$$"
CLONED_WORKDIR=1
cleanup() {
if [ -z "${WAKEY_CC_KEEP_TMP:-}" ]; then
[ "$CLONED_WORKDIR" = "0" ] || rm -rf "$WORKDIR"
rm -rf "$STAGING"
else
if [ "$CLONED_WORKDIR" != "0" ]; then
chown_if_requested "$WORKDIR"
chmod_user_rw_if_requested "$WORKDIR"
log "keeping temp dir: $WORKDIR"
fi
chown_if_requested "$STAGING"
chmod_user_rw_if_requested "$STAGING"
log "keeping temp dir: $STAGING"
fi
}
trap cleanup EXIT INT TERM
if [ -n "$REPO_DIR" ]; then
WORKDIR=$REPO_DIR
CLONED_WORKDIR=0
update_existing_repo "$WORKDIR" "$REF"
else
log "cloning $REPO_URL (ref: $REF)"
git clone --depth 1 --branch "$REF" "$REPO_URL" "$WORKDIR" ||
fail 'git clone failed'
fi
cd "$WORKDIR"
log 'building UI dist'
(
cd ui
pnpm install --frozen-lockfile
pnpm build
)
log "building control-plane target $TARGET"
cargo build --release --target "$TARGET" -p wakey-control-plane ||
fail 'cargo build failed'
SHA=$(git rev-parse --short HEAD)
VERSION="${WAKEY_CC_VERSION:-v0.0.0-local-$SHA}"
log "packaging bundle version $VERSION"
./scripts/package_wakey_cc_bundle.sh --version "$VERSION" --target "$TARGET" ||
fail 'bundle packaging failed'
PKG="dist/wakey-cc-$VERSION-$TARGET.tgz"
[ -f "$PKG" ] || fail "bundle not found: $PKG"
rm -rf "$STAGING"
mkdir -p "$STAGING"
tar -xzf "$PKG" -C "$STAGING" || fail 'bundle extract failed'
[ -f "$STAGING/bin/wakey-control-plane" ] || fail 'bundle missing bin/wakey-control-plane'
[ -f "$STAGING/ui/dist/index.html" ] || fail 'bundle missing ui/dist/index.html'
priv mkdir -p "$ROOT"
install_executable_force \
"$STAGING/bin/wakey-control-plane" \
"$ROOT/bin/wakey-control-plane"
priv cp -a "$STAGING/ui" "$ROOT/"
priv cp -a "$STAGING/scripts" "$ROOT/"
priv cp -a "$STAGING/deploy" "$ROOT/"
chown_if_requested \
"$ROOT/bin/wakey-control-plane" \
"$ROOT/ui" \
"$ROOT/scripts" \
"$ROOT/deploy"
chmod_user_rw_if_requested \
"$ROOT/ui" \
"$ROOT/scripts" \
"$ROOT/deploy"
if [ -z "${WAKEY_CC_NO_RESTART:-}" ] && command -v systemctl >/dev/null 2>&1; then
if systemctl list-unit-files "$SERVICE" >/dev/null 2>&1; then
log "restarting $SERVICE"
priv systemctl daemon-reload
priv systemctl restart "$SERVICE"
priv systemctl --no-pager --full status "$SERVICE" | sed -n '1,16p'
else
log "service $SERVICE not installed; skipped restart"
fi
fi
log "done: installed into $ROOT"
}
main "$@"
+71 -11
View File
@@ -25,6 +25,7 @@ pub struct Cli {
#[derive(Subcommand)] #[derive(Subcommand)]
pub enum Command { pub enum Command {
/// Show merged device inventory rows. /// Show merged device inventory rows.
#[command(visible_alias = "status", alias = "inv")]
Inventory(InventoryArgs), Inventory(InventoryArgs),
/// Show DHCP leases, optionally enriched with current neighbor state. /// Show DHCP leases, optionally enriched with current neighbor state.
Leases(LeasesArgs), Leases(LeasesArgs),
@@ -32,15 +33,20 @@ pub enum Command {
Wake(WakeArgs), Wake(WakeArgs),
/// Show condensed network interface summaries. /// Show condensed network interface summaries.
Devs(DevsArgs), Devs(DevsArgs),
/// Record local hotplug observations.
Observe(ObserveArgs),
} }
#[derive(Args)] #[derive(Args)]
pub struct LeasesArgs { pub struct LeasesArgs {
/// Include best-known current neighbor state for each lease IP. /// Legacy: include neighbor state on lease rows. Prefer `wakey inventory`.
#[arg(long)] #[arg(
long,
help = "Deprecated: include neighbor state on lease rows. Prefer `wakey inventory`."
)]
pub include_state: bool, pub include_state: bool,
/// Print machine-readable JSON instead of a table. /// Print machine-readable JSON instead of a table.
#[arg(long)] #[arg(long, short)]
pub json: bool, pub json: bool,
} }
@@ -64,7 +70,7 @@ pub struct WakeArgs {
#[arg(long)] #[arg(long)]
pub ip: Option<IpAddr>, pub ip: Option<IpAddr>,
/// Print machine-readable JSON instead of a table. /// Print machine-readable JSON instead of a table.
#[arg(long)] #[arg(short, long)]
pub json: bool, pub json: bool,
} }
@@ -94,7 +100,7 @@ pub struct InventoryArgs {
#[arg(long = "mac")] #[arg(long = "mac")]
pub macs: Vec<macaddr::MacAddr>, pub macs: Vec<macaddr::MacAddr>,
/// Print machine-readable JSON instead of a table. /// Print machine-readable JSON instead of a table.
#[arg(long)] #[arg(short, long)]
pub json: bool, pub json: bool,
} }
@@ -108,13 +114,49 @@ pub struct DevsArgs {
/// Optional interface name to show. /// Optional interface name to show.
pub dev: Option<String>, pub dev: Option<String>,
/// Show only interfaces whose operstate is `up`. /// Show only interfaces whose operstate is `up`.
#[arg(long)] #[arg(short, long)]
pub up: bool, pub up: bool,
/// Print machine-readable JSON instead of a table. /// Print machine-readable JSON instead of a table.
#[arg(long)] #[arg(short, long)]
pub json: bool, pub json: bool,
} }
#[derive(Args)]
pub struct ObserveArgs {
#[command(subcommand)]
pub command: ObserveCommand,
}
#[derive(Subcommand)]
pub enum ObserveCommand {
/// Observe a dnsmasq DHCP lease event.
Dhcp(ObserveDhcpArgs),
/// Observe a neighbor-table event.
Neigh(ObserveNeighArgs),
}
#[derive(Args)]
pub struct ObserveDhcpArgs {
#[arg(long)]
pub action: String,
#[arg(long)]
pub mac: macaddr::MacAddr,
#[arg(long)]
pub ip: Option<IpAddr>,
#[arg(long)]
pub hostname: Option<String>,
}
#[derive(Args)]
pub struct ObserveNeighArgs {
#[arg(long)]
pub action: String,
#[arg(long)]
pub mac: Option<macaddr::MacAddr>,
#[arg(long)]
pub ip: Option<IpAddr>,
}
pub fn init_tracing(verbose: u8) { pub fn init_tracing(verbose: u8) {
let filter = EnvFilter::try_from_default_env() let filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(default_filter_for_verbosity(verbose))) .or_else(|_| EnvFilter::try_new(default_filter_for_verbosity(verbose)))
@@ -162,10 +204,11 @@ pub async fn run(cli: Cli) -> Result<()> {
json = args.json, json = args.json,
"dispatching leases command" "dispatching leases command"
); );
let leases = wakey::get_leases(wakey_core::LeaseQuery { let leases = if args.include_state {
include_state: args.include_state, wakey::get_leases_with_neighbor_state().await?
}) } else {
.await?; wakey::get_leases().await?
};
if args.json { if args.json {
println!("{}", serde_json::to_string_pretty(&leases)?); println!("{}", serde_json::to_string_pretty(&leases)?);
} else { } else {
@@ -205,6 +248,23 @@ pub async fn run(cli: Cli) -> Result<()> {
println!("{}", table::render_devs_table(&devs)); println!("{}", table::render_devs_table(&devs));
} }
} }
Command::Observe(args) => match args.command {
ObserveCommand::Dhcp(args) => {
let changed = wakey::observe_dhcp_event(
&args.action,
args.mac,
args.ip,
args.hostname.as_deref(),
)
.await?;
println!("observed=dhcp changed={changed}");
}
ObserveCommand::Neigh(args) => {
let changed =
wakey::observe_neighbor_event(&args.action, args.mac, args.ip).await?;
println!("observed=neigh changed={changed}");
}
},
} }
Ok(()) Ok(())
+8 -3
View File
@@ -1,12 +1,17 @@
//! Operator-facing service API: inventory, leases, interfaces, wake, and query resolution.
//! The `wakey` binary is Linux-only; use `wakey-agent` / `wakey-control-plane` for remote control.
pub mod service; pub mod service;
pub mod utils; pub mod utils;
pub use wakey_linux;
pub use service::{ pub use service::{
broadcast_wake_targets, get_interface_summaries, get_interface_summary, get_ips, get_leases, broadcast_wake_targets, get_interface_summaries, get_interface_summary, get_ips, get_leases,
inventory, leases_without_state, merge_devices, query_to_inventory_query, resolve_devices, get_leases_with_neighbor_state, inventory, leases_without_state, local_observation_to_fact,
resolve_query, resolve_selector, resolve_wake_targets, wake_explicit, wake_from_query, merge_devices, merge_devices_with_observations, resolve_devices, resolve_query,
wake_targets, resolve_selector, resolve_wake_targets, wake_explicit, wake_from_query, wake_targets,
}; };
pub use wakey_linux::dhcp::{list_local_observations, observe_dhcp_event, observe_neighbor_event};
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
+1 -1
View File
@@ -5,7 +5,7 @@ use clap::Parser;
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
Err(anyhow::anyhow!( Err(anyhow::anyhow!(
"OS not supported! run this on your ahh router!" "wakey only runs on Linux (router/OpenWrt-style targets)."
)) ))
} }
+173 -44
View File
@@ -1,7 +1,8 @@
use anyhow::Result; use anyhow::Result;
use tracing::{debug, warn};
use wakey_core::{ use wakey_core::{
Device, DeviceInventory, DhcpLease, DhcpLeaseWithState, InventoryQuery, NeighborEntry, Device, DeviceInventory, DeviceObservationFact, DhcpLease, DhcpLeaseWithState, InventoryQuery,
Presence, Query, NeighborEntry, Presence, Query,
}; };
use crate::service::leases::get_leases; use crate::service::leases::get_leases;
@@ -20,13 +21,26 @@ pub async fn resolve_devices(input: impl Into<String>) -> Result<Vec<Device>> {
/// directly from raw Linux source rows. /// directly from raw Linux source rows.
pub async fn inventory(query: InventoryQuery) -> Result<DeviceInventory> { pub async fn inventory(query: InventoryQuery) -> Result<DeviceInventory> {
let neighbors = wakey_linux::devices::query_neighbors(&query).await?; let neighbors = wakey_linux::devices::query_neighbors(&query).await?;
let leases = get_leases(wakey_core::LeaseQuery { let leases = get_leases().await?;
include_state: false, let observations = match wakey_linux::dhcp::list_local_observations().await {
}) Ok(observations) => observations
.await?; .into_iter()
Ok(DeviceInventory { .filter_map(local_observation_to_fact)
devices: merge_devices(neighbors, leases, &query), .collect::<Vec<_>>(),
}) Err(err) => {
warn!(error = %err, "failed reading local hook observations for inventory");
Vec::new()
}
};
debug!(
neighbors = neighbors.len(),
leases = leases.len(),
observations = observations.len(),
"building device inventory"
);
let devices = merge_devices_with_observations(neighbors, leases, observations, &query);
debug!(devices = devices.len(), "merged device inventory");
Ok(DeviceInventory { devices })
} }
/// Merge raw neighbor entries and DHCP leases into device aggregates. /// Merge raw neighbor entries and DHCP leases into device aggregates.
@@ -37,30 +51,57 @@ pub fn merge_devices(
neighbors: Vec<NeighborEntry>, neighbors: Vec<NeighborEntry>,
leases: Vec<DhcpLeaseWithState>, leases: Vec<DhcpLeaseWithState>,
query: &InventoryQuery, query: &InventoryQuery,
) -> Vec<Device> {
merge_devices_with_observations(neighbors, leases, Vec::new(), query)
}
/// Merge raw neighbor entries, DHCP leases, and hook observations into device
/// aggregates.
pub fn merge_devices_with_observations(
neighbors: Vec<NeighborEntry>,
leases: Vec<DhcpLeaseWithState>,
observations: Vec<DeviceObservationFact>,
query: &InventoryQuery,
) -> Vec<Device> { ) -> Vec<Device> {
use std::collections::BTreeMap; use std::collections::BTreeMap;
let mut by_mac: BTreeMap<String, (Vec<NeighborEntry>, Vec<DhcpLease>)> = BTreeMap::new(); type DeviceParts = (
Vec<NeighborEntry>,
Vec<DhcpLease>,
Vec<DeviceObservationFact>,
);
let mut by_key: BTreeMap<String, DeviceParts> = BTreeMap::new();
for row in neighbors { for row in neighbors {
let key = row let key = row
.mac .mac
.map(|m| m.to_string()) .map(|m| m.to_string())
.unwrap_or_else(|| format!("ip:{}", row.ip)); .unwrap_or_else(|| format!("ip:{}", row.ip));
by_mac.entry(key).or_default().0.push(row); by_key.entry(key).or_default().0.push(row);
} }
for lease in leases { for lease in leases {
let key = lease.lease_line.mac.to_string(); let key = lease.lease_line.mac.to_string();
by_mac.entry(key).or_default().1.push(lease.lease_line); by_key.entry(key).or_default().1.push(lease.lease_line);
}
for observation in observations {
let key = observation
.mac
.map(|mac| mac.to_string())
.or_else(|| observation.ip.map(|ip| format!("ip:{ip}")))
.unwrap_or_else(|| "unknown".to_string());
by_key.entry(key).or_default().2.push(observation);
} }
let mut devices: Vec<Device> = by_mac let mut devices: Vec<Device> = by_key
.into_values() .into_values()
.map(|(neighbors, leases)| Device::from_parts(neighbors, leases)) .map(|(neighbors, leases, observations)| {
Device::from_parts_with_observations(neighbors, leases, observations)
})
.collect(); .collect();
let mut texts: Vec<&str> = Vec::new(); let mut texts = Vec::new();
let mut devs: Vec<&str> = Vec::new(); let mut devs = Vec::new();
let mut ips = Vec::new(); let mut ips = Vec::new();
let mut macs = Vec::new(); let mut macs = Vec::new();
let mut nuds = Vec::new(); let mut nuds = Vec::new();
@@ -72,35 +113,16 @@ pub fn merge_devices(
Query::Ip(v) => ips.push(*v), Query::Ip(v) => ips.push(*v),
Query::Mac(v) => macs.push(*v), Query::Mac(v) => macs.push(*v),
Query::NeighborState(v) => nuds.push(*v), Query::NeighborState(v) => nuds.push(*v),
} };
}
if !texts.is_empty() {
devices.retain(|device| device.names.iter().any(|n| texts.iter().any(|t| n == t)));
}
if !devs.is_empty() {
devices.retain(|device| {
device
.interfaces
.iter()
.any(|iface| devs.iter().any(|d| iface == d))
});
}
if !ips.is_empty() {
devices.retain(|device| device.ips.iter().any(|ip| ips.contains(ip)));
}
if !macs.is_empty() {
devices.retain(|device| device.macs.iter().any(|mac| macs.contains(mac)));
}
if !nuds.is_empty() {
devices.retain(|device| {
device
.neighbors
.iter()
.any(|neighbor| nuds.contains(&neighbor.state))
});
} }
devices.retain(|device| {
(texts.is_empty() || device.names.iter().any(|n| texts.contains(&n.as_str())))
&& (devs.is_empty() || device.interfaces.iter().any(|i| devs.contains(&i.as_str()))) // same pattern as iter().any
&& (ips.is_empty() || device.ips.iter().any(|ip| ips.contains(ip)))
&& (macs.is_empty() || device.macs.iter().any(|mac| macs.contains(mac)))
&& (nuds.is_empty() || device.neighbors.iter().any(|n| nuds.contains(&n.state)))
});
devices.sort_by(|a, b| { devices.sort_by(|a, b| {
presence_rank(b.presence) presence_rank(b.presence)
.cmp(&presence_rank(a.presence)) .cmp(&presence_rank(a.presence))
@@ -109,6 +131,42 @@ pub fn merge_devices(
devices devices
} }
pub fn local_observation_to_fact(
observation: wakey_linux::dhcp::LocalDeviceObservation,
) -> Option<DeviceObservationFact> {
let mac = match observation.mac.as_deref() {
Some(raw) => match raw.parse() {
Ok(mac) => Some(mac),
Err(err) => {
warn!(
mac = raw,
error = %err,
"ignoring invalid observed mac in inventory"
);
None
}
},
None => None,
};
if mac.is_none() && observation.ip.is_none() {
warn!(
kind = %observation.kind,
action = %observation.action,
"ignoring hook observation without mac or ip"
);
return None;
}
Some(DeviceObservationFact {
kind: observation.kind,
action: observation.action,
mac,
ip: observation.ip,
hostname: observation.hostname,
first_seen_unix: Some(observation.first_seen_unix),
last_seen_unix: Some(observation.last_seen_unix),
})
}
const fn presence_rank(presence: Presence) -> u8 { const fn presence_rank(presence: Presence) -> u8 {
match presence { match presence {
Presence::Online => 3, Presence::Online => 3,
@@ -122,7 +180,7 @@ const fn presence_rank(presence: Presence) -> u8 {
mod tests { mod tests {
use super::*; use super::*;
use std::net::{IpAddr, Ipv4Addr}; use std::net::{IpAddr, Ipv4Addr};
use wakey_core::{DhcpLease, InventoryQueryBuilder, NeighborState}; use wakey_core::{DeviceId, DhcpLease, InventoryQueryBuilder, NeighborState};
fn sample_neighbors() -> Vec<NeighborEntry> { fn sample_neighbors() -> Vec<NeighborEntry> {
vec![NeighborEntry { vec![NeighborEntry {
@@ -173,4 +231,75 @@ mod tests {
let out = merge_devices(sample_neighbors(), sample_leases(), &query); let out = merge_devices(sample_neighbors(), sample_leases(), &query);
assert_eq!(out.len(), 1); assert_eq!(out.len(), 1);
} }
#[test]
fn merge_devices_uses_ip_observed_id_when_mac_is_absent() {
let query = InventoryQueryBuilder::new().build();
let out = merge_devices(
vec![NeighborEntry {
ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20)),
dev: Some("br-lan".to_string()),
mac: None,
state: NeighborState::Stale,
}],
Vec::new(),
&query,
);
assert_eq!(out.len(), 1);
assert_eq!(
out[0].id,
Some(DeviceId::Ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20))))
);
}
#[test]
fn merge_devices_preserves_hook_observation_facts() {
let query = InventoryQueryBuilder::new().build();
let out = merge_devices_with_observations(
Vec::new(),
Vec::new(),
vec![DeviceObservationFact {
kind: "dhcp".into(),
action: "update".into(),
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
ip: Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 30))),
hostname: Some("lda".into()),
first_seen_unix: Some(10),
last_seen_unix: Some(20),
}],
&query,
);
assert_eq!(out.len(), 1);
assert_eq!(out[0].names, vec!["lda".to_string()]);
assert_eq!(
out[0].id,
Some(DeviceId::Mac("aa:bb:cc:dd:ee:ff".parse().expect("mac")))
);
assert_eq!(out[0].observations.len(), 1);
assert_eq!(out[0].presence, Presence::Unknown);
}
#[test]
fn merge_devices_marks_remove_only_observation_offline() {
let query = InventoryQueryBuilder::new().build();
let out = merge_devices_with_observations(
Vec::new(),
Vec::new(),
vec![DeviceObservationFact {
kind: "neigh".into(),
action: "remove".into(),
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
ip: Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 30))),
hostname: None,
first_seen_unix: Some(10),
last_seen_unix: Some(20),
}],
&query,
);
assert_eq!(out.len(), 1);
assert_eq!(out[0].presence, Presence::Offline);
}
} }
+14 -8
View File
@@ -1,16 +1,22 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use wakey_core::{DhcpLease, DhcpLeaseWithState, LeaseQuery}; use wakey_core::{DhcpLease, DhcpLeaseWithState};
/// Read DHCP leases and optionally enrich them with current neighbor-state data. /// Read DHCP leases from dnsmasq plus remembered hook names.
pub async fn get_leases(query: LeaseQuery) -> Result<Vec<DhcpLeaseWithState>> { pub async fn get_leases() -> Result<Vec<DhcpLeaseWithState>> {
let leases = wakey_linux::dhcp::read_dhcp_leases_with_names() let leases = wakey_linux::dhcp::read_dhcp_leases_with_names()
.await .await
.context("failed to read DHCP leases")?; .context("failed to read DHCP leases")?;
if query.include_state { Ok(leases_without_state(leases))
Ok(wakey_linux::dhcp::enrich_leases_with_nud_state(leases).await) }
} else {
Ok(leases_without_state(leases)) /// Read DHCP leases and enrich them with current neighbor state.
} ///
/// Prefer inventory for device status; this exists for the legacy leases view.
pub async fn get_leases_with_neighbor_state() -> Result<Vec<DhcpLeaseWithState>> {
let leases = wakey_linux::dhcp::read_dhcp_leases_with_names()
.await
.context("failed to read DHCP leases")?;
Ok(wakey_linux::dhcp::enrich_leases_with_nud_state(leases).await)
} }
/// Wrap raw DHCP leases in the current service output shape without neighbor state. /// Wrap raw DHCP leases in the current service output shape without neighbor state.
+6 -3
View File
@@ -5,9 +5,12 @@ pub mod query;
pub mod wake; pub mod wake;
pub use interfaces::{get_interface_summaries, get_interface_summary, get_ips}; pub use interfaces::{get_interface_summaries, get_interface_summary, get_ips};
pub use inventory::{inventory, merge_devices, resolve_devices}; pub use inventory::{
pub use leases::{get_leases, leases_without_state}; inventory, local_observation_to_fact, merge_devices, merge_devices_with_observations,
pub use query::{query_to_inventory_query, resolve_query, resolve_selector}; resolve_devices,
};
pub use leases::{get_leases, get_leases_with_neighbor_state, leases_without_state};
pub use query::{resolve_query, resolve_selector};
pub use wake::{ pub use wake::{
broadcast_wake_targets, resolve_wake_targets, wake_explicit, wake_from_query, wake_targets, broadcast_wake_targets, resolve_wake_targets, wake_explicit, wake_from_query, wake_targets,
}; };
+1 -12
View File
@@ -2,11 +2,8 @@ use anyhow::Result;
use wakey_core::{InventoryQuery, Query, QueryInput}; use wakey_core::{InventoryQuery, Query, QueryInput};
/// Resolve free-form user input into an `InventoryQuery` filter shape. /// Resolve free-form user input into an `InventoryQuery` filter shape.
///
/// This is the compatibility entrypoint used by CLI and HTTP paths that still
/// speak in terms of query/filter payloads.
pub async fn resolve_query(input: impl Into<String>) -> Result<InventoryQuery> { pub async fn resolve_query(input: impl Into<String>) -> Result<InventoryQuery> {
query_to_inventory_query(resolve_selector(input).await?) Ok(vec![resolve_selector(input).await?])
} }
/// Classify one piece of free-form user input into a typed selector. /// Classify one piece of free-form user input into a typed selector.
@@ -24,11 +21,3 @@ pub async fn resolve_selector(input: impl Into<String>) -> Result<Query> {
}, },
) )
} }
/// Convert the newer selector-oriented `Query` model into an `InventoryQuery`.
///
/// This keeps the old filter-based service and HTTP surfaces working while the
/// internals migrate toward selector- and device-oriented APIs.
pub fn query_to_inventory_query(query: Query) -> Result<InventoryQuery> {
Ok(vec![query])
}
+9 -7
View File
@@ -6,6 +6,7 @@ use wakey_core::{InterfaceSummary, WakeResult, WakeTarget};
use crate::service::interfaces::get_interface_summaries; use crate::service::interfaces::get_interface_summaries;
use crate::service::inventory::resolve_devices; use crate::service::inventory::resolve_devices;
use crate::utils::product;
/// Send Wake-on-LAN packets for already-concrete wake targets. /// Send Wake-on-LAN packets for already-concrete wake targets.
#[instrument(skip_all, fields(targets = targets.len()))] #[instrument(skip_all, fields(targets = targets.len()))]
@@ -50,14 +51,15 @@ pub async fn wake_explicit(mac: MacAddr, ip: Option<IpAddr>) -> Result<WakeResul
#[instrument(skip_all)] #[instrument(skip_all)]
pub async fn resolve_wake_targets(input: impl Into<String>) -> Result<Vec<WakeTarget>> { pub async fn resolve_wake_targets(input: impl Into<String>) -> Result<Vec<WakeTarget>> {
let devices = resolve_devices(input).await?; let devices = resolve_devices(input).await?;
let targets: Vec<WakeTarget> = devices let targets: Vec<WakeTarget> = devices
.into_iter() .into_iter()
.flat_map(|device| { .flat_map(|device| {
let mac = device.macs.first().copied(); // TRY EVERYTHING some has to work
device product(device.ips, device.macs).map(|(i, m)| WakeTarget {
.ips ip: Some(i),
.into_iter() mac: Some(m),
.map(move |ip| WakeTarget { ip: Some(ip), mac }) })
}) })
.collect(); .collect();
debug!(targets = targets.len(), "resolved wake targets"); debug!(targets = targets.len(), "resolved wake targets");
@@ -79,8 +81,8 @@ fn broadcast_wake_targets_from_interfaces(
.iter() .iter()
.flat_map(|iface| iface.addrs.iter()) .flat_map(|iface| iface.addrs.iter())
.filter_map(|addr| addr.broadcast) .filter_map(|addr| addr.broadcast)
.map(|ip| WakeTarget { .map(|br| WakeTarget {
ip: Some(IpAddr::V4(ip)), ip: Some(IpAddr::V4(br)), // some will hit eventually AHHH logic
mac: Some(mac), mac: Some(mac),
}) })
.collect(); .collect();
+15 -1
View File
@@ -1 +1,15 @@
// Intentionally left empty. //! rare fns that belong nowhere
/// absolute dih cheese
/// took some inspo from itertools
pub fn product<A, B, T1, T2>(i1: T1, i2: T2) -> impl Iterator<Item = (A, B)>
where
A: Clone,
T1: IntoIterator<Item = A>,
T2: IntoIterator<Item = B>,
T2::IntoIter: Clone,
{
let i2 = i2.into_iter();
i1.into_iter()
.flat_map(move |i1i| i2.clone().map(move |i2i| (i1i.clone(), i2i)))
}
+2
View File
@@ -20,6 +20,7 @@ import { AuditPage } from "@/pages/AuditPage";
import { CommandsPage } from "@/pages/CommandsPage"; import { CommandsPage } from "@/pages/CommandsPage";
import { DashboardPage } from "@/pages/DashboardPage"; import { DashboardPage } from "@/pages/DashboardPage";
import { DevicesPage } from "@/pages/DevicesPage"; import { DevicesPage } from "@/pages/DevicesPage";
import { ObservationsPage } from "@/pages/ObservationsPage";
import { TokensPage } from "@/pages/TokensPage"; import { TokensPage } from "@/pages/TokensPage";
type LoadState = "idle" | "loading" | "ready" | "error"; type LoadState = "idle" | "loading" | "ready" | "error";
@@ -151,6 +152,7 @@ export function App() {
/> />
} }
/> />
<Route path="observations" element={<ObservationsPage />} />
<Route <Route
path="agents" path="agents"
element={ element={
+251 -1
View File
@@ -72,6 +72,113 @@ export type SetAgentNicknameResponse = {
updated: boolean; updated: boolean;
}; };
export type DeviceIdentifier = {
identifier_key: string;
device_id: string;
kind: string;
value: string;
created_at_unix: number;
};
export type KnownDevice = {
device_id: string;
display_name: string;
pinned: boolean;
created_at_unix: number;
updated_at_unix: number;
notes: string | null;
identifiers: DeviceIdentifier[];
};
export type KnownDeviceSummary = {
device_id: string;
display_name: string;
pinned: boolean;
};
export type AgentDeviceObservation = {
observation_key: string;
agent_id: string;
kind: string;
mac: string | null;
ip: string | null;
hostname: string | null;
first_seen_unix: number;
last_seen_unix: number;
last_action: string;
known_device: KnownDeviceSummary | null;
};
export type AgentDeviceObservationEvent = {
event_id: string;
observation_key: string;
agent_id: string;
kind: string;
action: string;
mac: string | null;
ip: string | null;
hostname: string | null;
ts_unix: number;
known_device: KnownDeviceSummary | null;
};
export type FleetDeviceAgent = {
agent_id: string;
nickname?: string | null;
connected: boolean;
last_seen_unix: number;
};
export type FleetWakeRoute = {
route_id: string;
agent_id: string;
nickname?: string | null;
connected: boolean;
mac: string | null;
ip: string | null;
hostname: string | null;
source: string;
last_seen_unix: number;
wakeable: boolean;
};
export type FleetDevice = {
device_key: string;
display_name: string;
known_device: KnownDeviceSummary | null;
pinned: boolean;
ips: string[];
macs: string[];
hostnames: string[];
agents: FleetDeviceAgent[];
sources: string[];
first_seen_unix: number | null;
last_seen_unix: number | null;
presence: string;
route_candidates: FleetWakeRoute[];
recommended_route: FleetWakeRoute | null;
};
export type RefreshFleetDevicesResponse = {
total_accepted: number;
agents: {
agent_id: string;
status: string;
accepted: number;
error: string | null;
}[];
};
export type WakeFleetDeviceResponse = {
route: FleetWakeRoute;
command: {
request_id: string;
status: string;
result?: unknown;
error?: { code: string; message: string; retryable?: boolean | null };
};
};
async function request<T>(path: string, init?: RequestInit): Promise<T> { async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, { const res = await fetch(path, {
...init, ...init,
@@ -154,6 +261,149 @@ export function setAgentNickname(
); );
} }
export function fetchKnownDevices(): Promise<KnownDevice[]> {
return request<KnownDevice[]>("/api/v1/control/devices");
}
export function fetchFleetDevices(opts?: {
query?: string;
presence?: string;
known?: string;
agentId?: string;
limit?: number;
}): Promise<FleetDevice[]> {
const params = new URLSearchParams();
if (opts?.query) params.set("query", opts.query);
if (opts?.presence && opts.presence !== "all") {
params.set("presence", opts.presence);
}
if (opts?.known && opts.known !== "all") params.set("known", opts.known);
if (opts?.agentId) params.set("agent_id", opts.agentId);
params.set("limit", String(opts?.limit ?? 500));
return request<FleetDevice[]>(
`/api/v1/control/fleet/devices?${params.toString()}`,
);
}
export function refreshFleetDevices(input?: {
agentIds?: string[];
timeoutMs?: number;
}): Promise<RefreshFleetDevicesResponse> {
return request<RefreshFleetDevicesResponse>(
"/api/v1/control/fleet/devices/refresh",
{
method: "POST",
body: JSON.stringify({
agent_ids: input?.agentIds ?? [],
timeout_ms: input?.timeoutMs ?? null,
}),
},
);
}
export function wakeFleetDevice(input: {
deviceKey: string;
routeId?: string | null;
timeoutMs?: number;
}): Promise<WakeFleetDeviceResponse> {
return request<WakeFleetDeviceResponse>("/api/v1/control/fleet/wake", {
method: "POST",
body: JSON.stringify({
device_key: input.deviceKey,
route_id: input.routeId ?? null,
timeout_ms: input.timeoutMs ?? null,
}),
});
}
export function fetchObservations(opts?: {
agentId?: string;
limit?: number;
}): Promise<AgentDeviceObservation[]> {
const params = new URLSearchParams();
if (opts?.agentId) params.set("agent_id", opts.agentId);
params.set("limit", String(opts?.limit ?? 500));
return request<AgentDeviceObservation[]>(
`/api/v1/control/observations?${params.toString()}`,
);
}
export function fetchObservationHistory(opts?: {
agentId?: string;
kind?: string;
mac?: string;
ip?: string;
observationKey?: string;
limit?: number;
}): Promise<AgentDeviceObservationEvent[]> {
const params = new URLSearchParams();
if (opts?.agentId) params.set("agent_id", opts.agentId);
if (opts?.kind) params.set("kind", opts.kind);
if (opts?.mac) params.set("mac", opts.mac);
if (opts?.ip) params.set("ip", opts.ip);
if (opts?.observationKey) params.set("observation_key", opts.observationKey);
params.set("limit", String(opts?.limit ?? 500));
return request<AgentDeviceObservationEvent[]>(
`/api/v1/control/observations/history?${params.toString()}`,
);
}
export function createKnownDevice(input: {
display_name: string;
pinned?: boolean;
notes?: string | null;
identifiers?: { kind: string; value: string }[];
}): Promise<KnownDevice> {
return request<KnownDevice>("/api/v1/control/devices", {
method: "POST",
body: JSON.stringify({
display_name: input.display_name,
pinned: input.pinned ?? true,
notes: input.notes ?? null,
identifiers: input.identifiers ?? [],
}),
});
}
export function attachObservationIdentifier(
deviceId: string,
observationKey: string,
): Promise<KnownDevice> {
return request<KnownDevice>(
`/api/v1/control/devices/${encodeURIComponent(deviceId)}/identifiers/from-observation`,
{
method: "POST",
body: JSON.stringify({ observation_key: observationKey }),
},
);
}
export function attachDeviceIdentifier(
deviceId: string,
identifier: { kind: string; value: string },
): Promise<KnownDevice> {
return request<KnownDevice>(
`/api/v1/control/devices/${encodeURIComponent(deviceId)}/identifiers`,
{
method: "POST",
body: JSON.stringify(identifier),
},
);
}
export function mergeKnownDevice(
targetDeviceId: string,
sourceDeviceId: string,
): Promise<KnownDevice> {
return request<KnownDevice>(
`/api/v1/control/devices/${encodeURIComponent(targetDeviceId)}/merge`,
{
method: "POST",
body: JSON.stringify({ source_device_id: sourceDeviceId }),
},
);
}
export function runCommand( export function runCommand(
agentId: string, agentId: string,
kind: CommandKind, kind: CommandKind,
@@ -177,7 +427,7 @@ function buildCommandPayload(
return { command: { kind: "devs", dev: null, up_only: false } }; return { command: { kind: "devs", dev: null, up_only: false } };
} }
if (kind === "leases") { if (kind === "leases") {
return { command: { kind: "leases", include_state: true } }; return { command: { kind: "leases", include_state: false } };
} }
if (kind === "inventory") { if (kind === "inventory") {
return { return {
+157
View File
@@ -0,0 +1,157 @@
import * as React from "react";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react";
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 start-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 rtl:translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 end-2"
size="icon-sm"
/>
}
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className,
)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className,
)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+33 -10
View File
@@ -1,17 +1,27 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { NavLink, Outlet } from "react-router-dom"; import { NavLink, Outlet } from "react-router-dom";
import { Moon, Sun } from "lucide-react"; import { ChevronDown, Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const navItems = [ const navItems = [
["/", "Devices"], ["/", "Devices"],
["/commands", "Wake Tools"],
["/dashboard", "Fleet Health"], ["/dashboard", "Fleet Health"],
] as const;
const adminItems = [
["/agents", "Agents"], ["/agents", "Agents"],
["/audit", "Audit"],
["/alerts", "Alerts"],
["/tokens", "Tokens"], ["/tokens", "Tokens"],
["/alerts", "Alerts"],
["/observations", "Raw Observations"],
["/commands", "Command Runner"],
["/audit", "Audit"],
] as const; ] as const;
type Theme = "light" | "dark"; type Theme = "light" | "dark";
@@ -40,11 +50,9 @@ export function AppLayout() {
<div className="mx-auto max-w-7xl p-4"> <div className="mx-auto max-w-7xl p-4">
<header className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <header className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight"> <h1 className="text-2xl font-semibold tracking-tight">Wakey</h1>
Wakey Operator UI
</h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Find devices fast and wake them reliably Fleet devices, presence, and wake controls
</p> </p>
</div> </div>
<Button <Button
@@ -69,7 +77,7 @@ export function AppLayout() {
</Button> </Button>
</header> </header>
<nav className="mb-3 flex flex-wrap gap-2"> <nav className="mb-3 flex flex-wrap items-center gap-2">
{navItems.map(([to, label]) => ( {navItems.map(([to, label]) => (
<NavLink <NavLink
key={to} key={to}
@@ -77,7 +85,7 @@ export function AppLayout() {
end={to === "/"} end={to === "/"}
className={({ isActive }) => className={({ isActive }) =>
[ [
"rounded-full border px-3 py-1.5 text-sm transition-colors", "rounded-md border px-3 py-1.5 text-sm transition-colors",
isActive isActive
? "border-primary/60 bg-primary/10 text-foreground" ? "border-primary/60 bg-primary/10 text-foreground"
: "border-border bg-card text-muted-foreground hover:text-foreground", : "border-border bg-card text-muted-foreground hover:text-foreground",
@@ -87,6 +95,21 @@ export function AppLayout() {
{label} {label}
</NavLink> </NavLink>
))} ))}
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex items-center gap-1 rounded-md border border-border bg-card px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground">
Admin / Debug
<ChevronDown className="size-4" aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
{adminItems.map(([to, label]) => (
<DropdownMenuItem key={to}>
<NavLink className="w-full" to={to}>
{label}
</NavLink>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</nav> </nav>
<main> <main>
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
export { ObservationsPage } from "@/pages/observations/ObservationsPage";
@@ -0,0 +1,68 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import type { DeviceRow } from "@/pages/devices/types";
type Props = {
device: DeviceRow | null;
open: boolean;
onOpenChange: (open: boolean) => void;
};
function summarizeAll(values: string[]): string {
if (!values.length) return "-";
return values.join(", ");
}
export function DeviceDetailsDialog({ device, open, onOpenChange }: Props) {
if (!device) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl p-0">
<div className="grid gap-4 p-4 text-sm">
<DialogHeader className="pr-8">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<DialogTitle className="truncate">{device.name}</DialogTitle>
<DialogDescription>Device details</DialogDescription>
</div>
<Badge variant="outline">{device.presence}</Badge>
</div>
</DialogHeader>
<div>
<p className="mb-1 font-medium">IP addresses</p>
<p className="text-muted-foreground">{summarizeAll(device.ips)}</p>
</div>
<Separator />
<div>
<p className="mb-1 font-medium">MAC addresses</p>
<p className="text-muted-foreground">{summarizeAll(device.macs)}</p>
</div>
<Separator />
<div>
<p className="mb-1 font-medium">Interfaces</p>
<p className="text-muted-foreground">
{summarizeAll(device.interfaces)}
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
);
}
+21 -9
View File
@@ -28,6 +28,7 @@ type Props = {
onSortChange: (next: { key: SortKey; dir: SortDir }) => void; onSortChange: (next: { key: SortKey; dir: SortDir }) => void;
onWakeDevice: (device: DeviceRow) => void; onWakeDevice: (device: DeviceRow) => void;
onCopyValue: (label: string, value: string) => void; onCopyValue: (label: string, value: string) => void;
onOpenDetails: (device: DeviceRow) => void;
}; };
function sortArrow(active: boolean, dir: SortDir) { function sortArrow(active: boolean, dir: SortDir) {
@@ -48,7 +49,13 @@ export function DeviceInventoryTable({
onSortChange, onSortChange,
onWakeDevice, onWakeDevice,
onCopyValue, onCopyValue,
onOpenDetails,
}: Props) { }: Props) {
function shouldIgnoreRowClick(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return false;
return Boolean(target.closest("button, input, a, [role='menuitem']"));
}
return ( return (
<div className="device-list grid gap-2"> <div className="device-list grid gap-2">
<div className="device-row device-header rounded-md border bg-muted/60 px-3 py-2 text-sm"> <div className="device-row device-header rounded-md border bg-muted/60 px-3 py-2 text-sm">
@@ -85,14 +92,26 @@ export function DeviceInventoryTable({
> >
Presence {sortArrow(sort.key === "presence", sort.dir)} Presence {sortArrow(sort.key === "presence", sort.dir)}
</span> </span>
<span className="device-cell">Interfaces</span>
<span className="device-cell device-action">Actions</span> <span className="device-cell device-action">Actions</span>
</div> </div>
{rows.map((row) => ( {rows.map((row) => (
<div <div
className="device-row rounded-md border bg-card px-3 py-2 text-sm" className="device-row cursor-pointer rounded-md border bg-card px-3 py-2 text-sm transition-colors hover:bg-accent/30"
key={row.id} key={row.id}
role="button"
tabIndex={0}
onClick={(event) => {
if (shouldIgnoreRowClick(event.target)) return;
onOpenDetails(row);
}}
onKeyDown={(event) => {
if (shouldIgnoreRowClick(event.target)) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpenDetails(row);
}
}}
> >
<span className="device-cell device-select" data-label="Pick"> <span className="device-cell device-select" data-label="Pick">
<input <input
@@ -122,13 +141,6 @@ export function DeviceInventoryTable({
<span className="device-cell" data-label="Presence"> <span className="device-cell" data-label="Presence">
<Badge variant="outline">{row.presence}</Badge> <Badge variant="outline">{row.presence}</Badge>
</span> </span>
<span
className="device-cell"
data-label="Interfaces"
title={row.interfaces.join(", ") || "-"}
>
{summarize(row.interfaces)}
</span>
<span className="device-cell device-action" data-label=""> <span className="device-cell device-action" data-label="">
<Button <Button
onClick={() => onWakeDevice(row)} onClick={() => onWakeDevice(row)}
Loaded 100 of 207 files, more files were not shown because too many files have changed in this diff. Show more