19 Commits
59 changed files with 3150 additions and 527 deletions

No files matched your search

+6 -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"
@@ -20,3 +20,7 @@ tw = "t --target=x86_64-pc-windows-msvc"
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
+23 -4
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)
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 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.
+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
Generated
+722 -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.1"
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"]
+3 -3
View File
@@ -65,11 +65,11 @@ 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")
); );
} }
+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.
+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"
+155
View File
@@ -0,0 +1,155 @@
#!/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:
# sudo -E ./scripts/update_wakey_cc_from_repo.sh
#
# Env options:
# 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_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 (default: sudo when needed)
#
# Requires: git, cargo, tar, pnpm, systemctl (optional for restart)
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'
}
main() {
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}"
SUDO_BIN=$(sudo_cmd)
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.$$"
cleanup() {
if [ -z "${WAKEY_CC_KEEP_TMP:-}" ]; then
rm -rf "$WORKDIR" "$STAGING"
else
log "keeping temp dir: $WORKDIR"
fi
}
trap cleanup EXIT INT TERM
log "cloning $REPO_URL (ref: $REF)"
git clone --depth 1 --branch "$REF" "$REPO_URL" "$WORKDIR" ||
fail 'git clone failed'
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'
if [ "$(id -u)" -ne 0 ] && [ -n "$SUDO_BIN" ]; then
$SUDO_BIN mkdir -p "$ROOT"
$SUDO_BIN cp -a "$STAGING/." "$ROOT/"
else
mkdir -p "$ROOT"
cp -a "$STAGING/." "$ROOT/"
fi
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"
if [ "$(id -u)" -ne 0 ] && [ -n "$SUDO_BIN" ]; then
$SUDO_BIN systemctl daemon-reload
$SUDO_BIN systemctl restart "$SERVICE"
$SUDO_BIN systemctl --no-pager --full status "$SERVICE" | sed -n '1,16p'
else
systemctl daemon-reload
systemctl restart "$SERVICE"
systemctl --no-pager --full status "$SERVICE" | sed -n '1,16p'
fi
else
log "service $SERVICE not installed; skipped restart"
fi
fi
log "done: installed into $ROOT"
}
main "$@"
+1
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")]
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),
+5 -3
View File
@@ -1,11 +1,13 @@
//! 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 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, inventory, leases_without_state, merge_devices, resolve_devices, resolve_query,
resolve_query, resolve_selector, resolve_wake_targets, wake_explicit, wake_from_query, resolve_selector, resolve_wake_targets, wake_explicit, wake_from_query, wake_targets,
wake_targets,
}; };
#[cfg(test)] #[cfg(test)]
+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)."
)) ))
} }
+30 -28
View File
@@ -59,8 +59,8 @@ pub fn merge_devices(
.map(|(neighbors, leases)| Device::from_parts(neighbors, leases)) .map(|(neighbors, leases)| Device::from_parts(neighbors, leases))
.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 +72,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| { devices.retain(|device| {
device (texts.is_empty() || device.names.iter().any(|n| texts.contains(&n.as_str())))
.interfaces && (devs.is_empty() || device.interfaces.iter().any(|i| devs.contains(&i.as_str()))) // same pattern as iter().any
.iter() && (ips.is_empty() || device.ips.iter().any(|ip| ips.contains(ip)))
.any(|iface| devs.iter().any(|d| iface == d)) && (macs.is_empty() || device.macs.iter().any(|mac| macs.contains(mac)))
&& (nuds.is_empty() || device.neighbors.iter().any(|n| nuds.contains(&n.state)))
}); });
}
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.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))
@@ -122,7 +103,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 +154,25 @@ 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))))
);
}
} }
+1 -1
View File
@@ -7,7 +7,7 @@ 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::{inventory, merge_devices, resolve_devices};
pub use leases::{get_leases, leases_without_state}; pub use leases::{get_leases, leases_without_state};
pub use query::{query_to_inventory_query, resolve_query, resolve_selector}; 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)))
}
+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,
};
+19 -1
View File
@@ -12,6 +12,7 @@ import {
SelectTrigger, SelectTrigger,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { DeviceInventoryTable } from "@/pages/devices/DeviceInventoryTable"; import { DeviceInventoryTable } from "@/pages/devices/DeviceInventoryTable";
import { DeviceDetailsDialog } from "@/pages/devices/DeviceDetailsDialog";
import { WakeHistoryCard } from "@/pages/devices/WakeHistoryCard"; import { WakeHistoryCard } from "@/pages/devices/WakeHistoryCard";
import type { import type {
DeviceRow, DeviceRow,
@@ -112,6 +113,7 @@ export function DevicesPage({
const [bulkWakeBusy, setBulkWakeBusy] = useState(false); const [bulkWakeBusy, setBulkWakeBusy] = useState(false);
const [quickWake, setQuickWake] = useState(""); const [quickWake, setQuickWake] = useState("");
const [selectedIds, setSelectedIds] = useState<string[]>([]); const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [detailsDevice, setDetailsDevice] = useState<DeviceRow | null>(null);
const [copyStatus, setCopyStatus] = useState(""); const [copyStatus, setCopyStatus] = useState("");
const [recentWakes, setRecentWakes] = useState<WakeEvent[]>([]); const [recentWakes, setRecentWakes] = useState<WakeEvent[]>([]);
function appendWakeEvent(event: WakeEvent) { function appendWakeEvent(event: WakeEvent) {
@@ -288,9 +290,17 @@ export function DevicesPage({
); );
} }
// Sort // Sort
if (sort.key === "name") {
result = [...result].sort((a, b) => {
if (a.isUnnamed !== b.isUnnamed) return a.isUnnamed ? 1 : -1;
const cmp = a.name.localeCompare(b.name);
return sort.dir === "desc" ? -cmp : cmp;
});
} else {
const sorter = sorters[sort.key]; const sorter = sorters[sort.key];
result = [...result].sort(sorter); result = [...result].sort(sorter);
if (sort.dir === "desc") result.reverse(); if (sort.dir === "desc") result.reverse();
}
return result; return result;
}, [rows, query, sort, presenceFilter]); }, [rows, query, sort, presenceFilter]);
@@ -342,7 +352,7 @@ export function DevicesPage({
<span>Agent</span> <span>Agent</span>
<Select <Select
value={selectedAgentId} value={selectedAgentId}
onValueChange={(value) => { onValueChange={(value: string | null) => {
if (value) onSelectAgent(value); if (value) onSelectAgent(value);
}} }}
> >
@@ -505,6 +515,14 @@ export function DevicesPage({
onSortChange={setSort} onSortChange={setSort}
onWakeDevice={(device) => void wakeDevice(device)} onWakeDevice={(device) => void wakeDevice(device)}
onCopyValue={(label, value) => void copyValue(label, value)} onCopyValue={(label, value) => void copyValue(label, value)}
onOpenDetails={setDetailsDevice}
/>
<DeviceDetailsDialog
device={detailsDevice}
open={Boolean(detailsDevice)}
onOpenChange={(open) => {
if (!open) setDetailsDevice(null);
}}
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -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)}
+3 -8
View File
@@ -52,11 +52,11 @@
display: grid; display: grid;
grid-template-columns: grid-template-columns:
36px minmax(110px, 1.1fr) minmax(160px, 2fr) minmax(130px, 1.5fr) 36px minmax(110px, 1.1fr) minmax(160px, 2fr) minmax(130px, 1.5fr)
minmax(100px, 0.85fr) minmax(100px, 0.9fr) minmax(170px, max-content); minmax(100px, 0.85fr) minmax(170px, max-content);
align-items: center; align-items: center;
column-gap: 0.4rem; column-gap: 0.4rem;
row-gap: 0.55rem; row-gap: 0.55rem;
min-width: 980px; min-width: 860px;
} }
.device-header { .device-header {
@@ -111,8 +111,7 @@
"name name" "name name"
"presence presence" "presence presence"
"ip ip" "ip ip"
"mac mac" "mac mac";
"interfaces interfaces";
align-items: start; align-items: start;
min-width: 0; min-width: 0;
} }
@@ -162,10 +161,6 @@
grid-area: mac; grid-area: mac;
} }
.device-row .device-cell[data-label="Interfaces"] {
grid-area: interfaces;
}
.device-row .device-action { .device-row .device-action {
grid-area: action; grid-area: action;
justify-self: end; justify-self: end;
+1
View File
@@ -1,6 +1,7 @@
export type DeviceRow = { export type DeviceRow = {
id: string; id: string;
name: string; name: string;
isUnnamed: boolean;
ips: string[]; ips: string[];
macs: string[]; macs: string[];
interfaces: string[]; interfaces: string[];
+3 -3
View File
@@ -50,11 +50,13 @@ export function parseInventoryRows(payload: unknown): DeviceRow[] {
: []; : [];
const presence = const presence =
typeof device.presence === "string" ? device.presence : "unknown"; typeof device.presence === "string" ? device.presence : "unknown";
const isUnnamed = names.length === 0;
const id = macs[0] || ips[0] || names[0] || `row-${idx}`; const id = macs[0] || ips[0] || names[0] || `row-${idx}`;
return { return {
id, id,
name: names[0] || "(unnamed)", name: names[0] || "(unnamed)",
isUnnamed,
ips, ips,
macs, macs,
interfaces, interfaces,
@@ -122,9 +124,7 @@ export function parseWakeSummary(response: unknown): {
} }
export function chooseWakeTarget(device: DeviceRow): string { export function chooseWakeTarget(device: DeviceRow): string {
return device.name !== "(unnamed)" return device.isUnnamed ? device.macs[0] || device.ips[0] || "" : device.name;
? device.name
: device.macs[0] || device.ips[0] || "";
} }
export function summarize(values: string[]): string { export function summarize(values: string[]): string {
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "wakey-agent" name = "wakey-agent"
version = "0.0.1" version = "0.1.0"
edition = "2024" edition = "2024"
publish = ["gitea"] publish = ["gitea"]
+2
View File
@@ -1 +1,3 @@
//! Outbound router agent: enrollment, websocket session, and dispatch into local wakey services.
pub mod protocol; pub mod protocol;
+1 -1
View File
@@ -109,7 +109,7 @@ fn init_config(args: InitConfigArgs) -> Result<()> {
let cfg = config::AgentConfig { let cfg = config::AgentConfig {
server_url: args server_url: args
.server_url .server_url
.unwrap_or_else(|| "https://control-plane.example.com".to_string()), .unwrap_or_else(|| "https://wakey.ldlda.com".to_string()),
agent_id: args agent_id: args
.agent_id .agent_id
.unwrap_or_else(|| "REPLACE_ME_AGENT_ID".to_string()), .unwrap_or_else(|| "REPLACE_ME_AGENT_ID".to_string()),
+1 -1
View File
@@ -36,7 +36,7 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
.unwrap_or_default() .unwrap_or_default()
); );
let span = info_span!("agent_session", session_id = %session_id, agent_id = %config.agent_id); let span = info_span!("agent_session", session_id = %session_id, agent_id = %config.agent_id);
let _span_guard = span.enter(); let _span_guard = span.enter(); // the ENTIRETY of below is agent_session.
let ws_url = websocket_url(&config.server_url)?; let ws_url = websocket_url(&config.server_url)?;
info!(%ws_url, agent_id = %config.agent_id, "connecting agent websocket"); info!(%ws_url, agent_id = %config.agent_id, "connecting agent websocket");
+9 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "wakey-control-plane" name = "wakey-control-plane"
version = "0.1.0" version = "0.1.1"
edition = "2024" edition = "2024"
publish = ["gitea"] publish = ["gitea"]
@@ -20,6 +20,14 @@ reqwest = { version = "0.13", default-features = false, features = [
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
sled = "0.34" sled = "0.34"
sqlx = { version = "0.8", features = [
"runtime-tokio",
"sqlite",
"macros",
"migrate",
"uuid",
"json",
] }
toml = "1.1" toml = "1.1"
tokio = { version = "1", features = [ tokio = { version = "1", features = [
"macros", "macros",
@@ -0,0 +1,87 @@
CREATE TABLE meta (
key TEXT PRIMARY KEY,
value BLOB NOT NULL
);
CREATE TABLE enroll_tokens (
token TEXT PRIMARY KEY,
expires_at_unix INTEGER NOT NULL
);
CREATE TABLE agents (
agent_id TEXT PRIMARY KEY,
agent_token TEXT NOT NULL
);
CREATE TABLE agent_meta (
agent_id TEXT PRIMARY KEY REFERENCES agents(agent_id) ON DELETE CASCADE,
nickname TEXT
);
CREATE TABLE known_devices (
device_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
pinned INTEGER NOT NULL,
created_at_unix INTEGER NOT NULL,
updated_at_unix INTEGER NOT NULL,
notes TEXT
);
CREATE TABLE device_identifiers (
identifier_key TEXT PRIMARY KEY,
device_id TEXT NOT NULL REFERENCES known_devices(device_id) ON DELETE CASCADE,
kind TEXT NOT NULL,
value TEXT NOT NULL,
created_at_unix INTEGER NOT NULL,
UNIQUE(kind, value)
);
CREATE INDEX device_identifiers_device_id_idx ON device_identifiers(device_id);
CREATE TABLE audit_events (
event_key TEXT PRIMARY KEY,
event_id TEXT NOT NULL UNIQUE,
ts_unix INTEGER NOT NULL,
actor_type TEXT NOT NULL,
actor_id TEXT,
agent_id TEXT,
request_id TEXT,
event_type TEXT NOT NULL,
outcome TEXT NOT NULL,
latency_ms INTEGER,
message TEXT NOT NULL,
metadata_json TEXT NOT NULL
);
CREATE INDEX audit_events_ts_unix_idx ON audit_events(ts_unix);
CREATE INDEX audit_events_agent_id_idx ON audit_events(agent_id);
CREATE INDEX audit_events_request_id_idx ON audit_events(request_id);
CREATE INDEX audit_events_event_type_outcome_idx ON audit_events(event_type, outcome);
CREATE TABLE active_alerts (
alert_id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
severity TEXT NOT NULL,
status TEXT NOT NULL,
agent_id TEXT,
message TEXT NOT NULL,
value INTEGER NOT NULL,
threshold INTEGER NOT NULL,
last_seen_unix INTEGER NOT NULL,
metadata_json TEXT NOT NULL
);
CREATE TABLE alert_transitions (
transition_key TEXT PRIMARY KEY,
transition_id TEXT NOT NULL UNIQUE,
ts_unix INTEGER NOT NULL,
alert_id TEXT NOT NULL,
kind TEXT NOT NULL,
agent_id TEXT,
from_status TEXT,
to_status TEXT NOT NULL,
message TEXT NOT NULL,
metadata_json TEXT NOT NULL
);
CREATE INDEX alert_transitions_ts_unix_idx ON alert_transitions(ts_unix);
+175 -1
View File
@@ -7,7 +7,7 @@ use tracing::{info, warn};
use crate::api::json_error; use crate::api::json_error;
use crate::runtime::{AppState, SessionEvent}; use crate::runtime::{AppState, SessionEvent};
use crate::state::AuditEventInput; use crate::state::{AuditEventInput, DeviceIdentifierInput, KnownDeviceInput};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct EnrollRequest { pub struct EnrollRequest {
@@ -72,6 +72,48 @@ pub struct StateStatsResponse {
pub expired_enroll_token_count: usize, pub expired_enroll_token_count: usize,
} }
#[derive(Debug, Deserialize)]
pub struct CreateKnownDeviceRequest {
pub display_name: String,
#[serde(default)]
pub pinned: bool,
pub notes: Option<String>,
#[serde(default)]
pub identifiers: Vec<DeviceIdentifierRequest>,
}
#[derive(Debug, Deserialize)]
pub struct DeviceIdentifierRequest {
pub kind: String,
pub value: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct KnownDeviceResponse {
pub device_id: String,
pub display_name: String,
pub pinned: bool,
pub created_at_unix: u64,
pub updated_at_unix: u64,
pub notes: Option<String>,
pub identifiers: Vec<DeviceIdentifierResponse>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DeviceIdentifierResponse {
pub identifier_key: String,
pub device_id: String,
pub kind: String,
pub value: String,
pub created_at_unix: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ForgetKnownDeviceResponse {
pub device_id: String,
pub forgotten: bool,
}
pub async fn healthz() -> &'static str { pub async fn healthz() -> &'static str {
"ok" "ok"
} }
@@ -407,6 +449,116 @@ pub async fn set_agent_nickname(
} }
} }
pub async fn create_known_device(
State(state): State<AppState>,
Json(req): Json<CreateKnownDeviceRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let input = KnownDeviceInput {
display_name: req.display_name,
pinned: req.pinned,
notes: req.notes,
identifiers: req
.identifiers
.into_iter()
.map(|identifier| DeviceIdentifierInput {
kind: identifier.kind,
value: identifier.value,
})
.collect(),
};
match state.store.create_known_device(input).await {
Ok(device) => Ok((StatusCode::CREATED, Json(known_device_response(device)))),
Err(err) => {
warn!(error = %err, "failed to create known device");
Err(json_error(
StatusCode::BAD_REQUEST,
"create_known_device_failed",
&err.to_string(),
))
}
}
}
pub async fn list_known_devices(
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state.store.list_known_devices().await {
Ok(devices) => Ok((
StatusCode::OK,
Json(
devices
.into_iter()
.map(known_device_response)
.collect::<Vec<_>>(),
),
)),
Err(err) => {
warn!(error = %err, "failed to list known devices");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"list_known_devices_failed",
&err.to_string(),
))
}
}
}
pub async fn forget_known_device(
State(state): State<AppState>,
AxumPath(device_id): AxumPath<String>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state.store.forget_known_device(&device_id).await {
Ok(forgotten) => Ok((
StatusCode::OK,
Json(ForgetKnownDeviceResponse {
device_id,
forgotten,
}),
)),
Err(err) => {
warn!(error = %err, "failed to forget known device");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"forget_known_device_failed",
&err.to_string(),
))
}
}
}
pub async fn attach_device_identifier(
State(state): State<AppState>,
AxumPath(device_id): AxumPath<String>,
Json(req): Json<DeviceIdentifierRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let input = DeviceIdentifierInput {
kind: req.kind,
value: req.value,
};
match state
.store
.attach_device_identifier(&device_id, input)
.await
{
Ok(Some(device)) => Ok((StatusCode::OK, Json(known_device_response(device)))),
Ok(None) => Err(json_error(
StatusCode::NOT_FOUND,
"known_device_not_found",
"known device not found",
)),
Err(err) => {
warn!(error = %err, "failed to attach device identifier");
Err(json_error(
StatusCode::BAD_REQUEST,
"attach_device_identifier_failed",
&err.to_string(),
))
}
}
}
pub async fn state_stats( pub async fn state_stats(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> { ) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
@@ -431,3 +583,25 @@ pub async fn state_stats(
} }
} }
} }
fn known_device_response(device: crate::state::KnownDevice) -> KnownDeviceResponse {
KnownDeviceResponse {
device_id: device.device_id,
display_name: device.display_name,
pinned: device.pinned,
created_at_unix: device.created_at_unix,
updated_at_unix: device.updated_at_unix,
notes: device.notes,
identifiers: device
.identifiers
.into_iter()
.map(|identifier| DeviceIdentifierResponse {
identifier_key: identifier.identifier_key,
device_id: identifier.device_id,
kind: identifier.kind,
value: identifier.value,
created_at_unix: identifier.created_at_unix,
})
.collect(),
}
}
+2 -1
View File
@@ -11,7 +11,8 @@ pub use audit::list_audit_events;
pub use commands::{list_agents, run_command}; pub use commands::{list_agents, run_command};
pub use control::{ pub use control::{
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse, EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
StateStatsResponse, enroll, healthz, issue_enroll_token, list_enroll_tokens, revoke_agent, StateStatsResponse, attach_device_identifier, create_known_device, enroll, forget_known_device,
healthz, issue_enroll_token, list_enroll_tokens, list_known_devices, revoke_agent,
revoke_enroll_token, set_agent_nickname, state_stats, revoke_enroll_token, set_agent_nickname, state_stats,
}; };
+14
View File
@@ -43,6 +43,8 @@ pub enum Command {
RevokeAgent(RevokeAgentArgs), RevokeAgent(RevokeAgentArgs),
/// Print state backend stats. /// Print state backend stats.
StateStats(StateStatsArgs), StateStats(StateStatsArgs),
/// Import a legacy sled state directory into a SQLite state file.
ImportSledState(ImportSledStateArgs),
/// Send SIGHUP to an already-running daemon. /// Send SIGHUP to an already-running daemon.
Reload(ReloadArgs), Reload(ReloadArgs),
} }
@@ -236,6 +238,18 @@ pub struct StateStatsArgs {
pub target: AdminTargetArgs, pub target: AdminTargetArgs,
} }
#[derive(Args)]
pub struct ImportSledStateArgs {
#[arg(long)]
pub from_sled_state: PathBuf,
#[arg(long)]
pub to_state_file: PathBuf,
#[arg(long)]
pub force: bool,
}
#[derive(Args)] #[derive(Args)]
pub struct ReloadArgs { pub struct ReloadArgs {
#[arg(long, default_value = DEFAULT_PID_FILE)] #[arg(long, default_value = DEFAULT_PID_FILE)]
+1 -1
View File
@@ -40,7 +40,7 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
let state_file_raw = args let state_file_raw = args
.state_file .state_file
.clone() .clone()
.unwrap_or_else(|| PathBuf::from("state.db")); .unwrap_or_else(|| PathBuf::from("state.sqlite3"));
let state_file = resolve_path(&data_dir, state_file_raw); let state_file = resolve_path(&data_dir, state_file_raw);
let pid_file_raw = args let pid_file_raw = args
+7 -4
View File
@@ -49,7 +49,7 @@ impl DaemonConfig {
.state_file .state_file
.clone() .clone()
.or(file.state_file) .or(file.state_file)
.unwrap_or_else(|| PathBuf::from("state.db")); .unwrap_or_else(|| PathBuf::from("state.sqlite3"));
let state_file = resolve_path(&data_dir, state_file_raw); let state_file = resolve_path(&data_dir, state_file_raw);
let command_timeout = Duration::from_millis( let command_timeout = Duration::from_millis(
@@ -255,7 +255,7 @@ fn resolve_state_access(
&data_dir, &data_dir,
cli_state_file cli_state_file
.or(file.state_file) .or(file.state_file)
.unwrap_or_else(|| PathBuf::from("state.db")), .unwrap_or_else(|| PathBuf::from("state.sqlite3")),
); );
let resolved_public_url = cli_public_url let resolved_public_url = cli_public_url
@@ -288,8 +288,11 @@ mod tests {
fn resolve_path_joins_relative_path() { fn resolve_path_joins_relative_path() {
let out = resolve_path( let out = resolve_path(
Path::new("/var/lib/wakey-control-plane"), Path::new("/var/lib/wakey-control-plane"),
PathBuf::from("state.db"), PathBuf::from("state.sqlite3"),
);
assert_eq!(
out,
PathBuf::from("/var/lib/wakey-control-plane/state.sqlite3")
); );
assert_eq!(out, PathBuf::from("/var/lib/wakey-control-plane/state.db"));
} }
} }
+6
View File
@@ -1,3 +1,5 @@
//! HTTP + WebSocket control plane: agent enrollment, registry, command relay, and operator UI hosting.
mod api; mod api;
mod cli; mod cli;
mod config; mod config;
@@ -59,6 +61,10 @@ async fn main() -> Result<()> {
tracing::init(cli.verbose, &config::TelemetryConfig::default())?; tracing::init(cli.verbose, &config::TelemetryConfig::default())?;
runtime::state_stats(args).await runtime::state_stats(args).await
} }
Command::ImportSledState(args) => {
tracing::init(cli.verbose, &config::TelemetryConfig::default())?;
runtime::import_sled_state(args).await
}
Command::Reload(args) => { Command::Reload(args) => {
tracing::init(cli.verbose, &config::TelemetryConfig::default())?; tracing::init(cli.verbose, &config::TelemetryConfig::default())?;
runtime::reload_daemon(&args.pid_file) runtime::reload_daemon(&args.pid_file)
+10 -2
View File
@@ -4,8 +4,8 @@ use anyhow::{Context, Result};
use crate::api; use crate::api;
use crate::cli::{ use crate::cli::{
IssueEnrollTokenArgs, ListEnrollTokensArgs, RevokeAgentArgs, RevokeEnrollTokenArgs, ImportSledStateArgs, IssueEnrollTokenArgs, ListEnrollTokensArgs, RevokeAgentArgs,
StateStatsArgs, RevokeEnrollTokenArgs, StateStatsArgs,
}; };
use crate::config; use crate::config;
use crate::state; use crate::state;
@@ -276,3 +276,11 @@ pub async fn state_stats(args: StateStatsArgs) -> Result<()> {
); );
Ok(()) Ok(())
} }
pub async fn import_sled_state(args: ImportSledStateArgs) -> Result<()> {
state::Store::import_sled_state(&args.from_sled_state, &args.to_state_file, args.force).await?;
println!("from_sled_state={}", args.from_sled_state.display());
println!("to_state_file={}", args.to_state_file.display());
println!("imported=true");
Ok(())
}
+16 -1
View File
@@ -26,7 +26,9 @@ use crate::ws;
mod admin; mod admin;
mod process; mod process;
pub use admin::revoke_agent; pub use admin::revoke_agent;
pub use admin::{issue_enroll_token, list_enroll_tokens, revoke_enroll_token, state_stats}; pub use admin::{
import_sled_state, issue_enroll_token, list_enroll_tokens, revoke_enroll_token, state_stats,
};
pub use process::reload_daemon; pub use process::reload_daemon;
use process::{remove_pid_file, write_pid_file}; use process::{remove_pid_file, write_pid_file};
@@ -62,6 +64,7 @@ fn public_api_routes(ui_dist_dir: std::path::PathBuf) -> Router<AppState> {
Router::new() Router::new()
.route("/ui", get(|| async { Redirect::temporary("/ui/") })) .route("/ui", get(|| async { Redirect::temporary("/ui/") }))
.route("/", get(|| async { Redirect::temporary("/ui/") })) // same as caddyfile
.nest_service( .nest_service(
"/ui/", "/ui/",
get_service(ServeDir::new(ui_dist_dir).not_found_service(ServeFile::new(index_file))), get_service(ServeDir::new(ui_dist_dir).not_found_service(ServeFile::new(index_file))),
@@ -86,6 +89,18 @@ fn control_api_routes() -> Router<AppState> {
axum::routing::delete(api::revoke_enroll_token), axum::routing::delete(api::revoke_enroll_token),
) )
.route("/api/v1/control/state-stats", get(api::state_stats)) .route("/api/v1/control/state-stats", get(api::state_stats))
.route(
"/api/v1/control/devices",
get(api::list_known_devices).post(api::create_known_device),
)
.route(
"/api/v1/control/devices/{device_id}",
axum::routing::delete(api::forget_known_device),
)
.route(
"/api/v1/control/devices/{device_id}/identifiers",
post(api::attach_device_identifier),
)
.route("/api/v1/control/audit/events", get(api::list_audit_events)) .route("/api/v1/control/audit/events", get(api::list_audit_events))
.route("/api/v1/control/alerts", get(api::active_alerts)) .route("/api/v1/control/alerts", get(api::active_alerts))
.route("/api/v1/control/alerts/history", get(api::alert_history)) .route("/api/v1/control/alerts/history", get(api::alert_history))
+4 -1
View File
@@ -2,4 +2,7 @@ mod store;
mod types; mod types;
pub use store::Store; pub use store::Store;
pub use types::{AlertState, AuditEvent, AuditEventFilter, AuditEventInput}; pub use types::{
AlertState, AuditEvent, AuditEventFilter, AuditEventInput, DeviceIdentifierInput, KnownDevice,
KnownDeviceInput,
};
File diff suppressed because it is too large. Load diff
+34
View File
@@ -30,6 +30,40 @@ pub struct StateStats {
pub expired_enroll_token_count: usize, pub expired_enroll_token_count: usize,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KnownDevice {
pub device_id: String,
pub display_name: String,
pub pinned: bool,
pub created_at_unix: u64,
pub updated_at_unix: u64,
pub notes: Option<String>,
pub identifiers: Vec<DeviceIdentifier>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceIdentifier {
pub identifier_key: String,
pub device_id: String,
pub kind: String,
pub value: String,
pub created_at_unix: u64,
}
#[derive(Debug, Clone)]
pub struct KnownDeviceInput {
pub display_name: String,
pub pinned: bool,
pub notes: Option<String>,
pub identifiers: Vec<DeviceIdentifierInput>,
}
#[derive(Debug, Clone)]
pub struct DeviceIdentifierInput {
pub kind: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEvent { pub struct AuditEvent {
pub event_id: String, pub event_id: String,
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "wakey-core" name = "wakey-core"
version = "0.2.0" version = "0.2.1"
edition = "2024" edition = "2024"
publish = ["gitea"] publish = ["gitea"]
+2
View File
@@ -1,3 +1,5 @@
//! Shared domain types and parsing for DHCP, neighbors, interfaces, devices, and wake flows.
pub mod model; pub mod model;
pub mod parse; pub mod parse;
+24 -13
View File
@@ -1,5 +1,5 @@
use macaddr::MacAddr; use macaddr::MacAddr;
use serde::Serialize; use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none; use serde_with::skip_serializing_none;
use std::net::IpAddr; use std::net::IpAddr;
@@ -32,11 +32,16 @@ impl From<NeighborState> for Presence {
} }
} }
/// MAC-first identifier for a device aggregate. /// Observed identifier for a discovered device aggregate.
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize)] ///
pub struct DeviceId { /// This is not a durable, user-approved identity. The control plane may attach
/// many observed identifiers to one saved device.
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum DeviceId {
#[serde(with = "mac")] #[serde(with = "mac")]
pub mac: MacAddr, Mac(MacAddr),
Ip(IpAddr),
} }
/// Merged view of one discovered network identity. /// Merged view of one discovered network identity.
@@ -71,8 +76,8 @@ impl Device {
for lease in &leases { for lease in &leases {
ips.insert(lease.ip); ips.insert(lease.ip);
macs.insert(lease.mac); macs.insert(lease.mac);
if let Some(name) = &lease.name { if let Some(name) = lease.name.as_deref() {
names.insert(name.clone()); names.insert(name);
} }
} }
for neighbor in &neighbors { for neighbor in &neighbors {
@@ -80,8 +85,8 @@ impl Device {
if let Some(mac) = neighbor.mac { if let Some(mac) = neighbor.mac {
macs.insert(mac); macs.insert(mac);
} }
if let Some(dev) = &neighbor.dev { if let Some(dev) = neighbor.dev.as_deref() {
interfaces.insert(dev.clone()); interfaces.insert(dev);
} }
presence = std::cmp::max( presence = std::cmp::max(
presence_rank(presence), presence_rank(presence),
@@ -91,12 +96,18 @@ impl Device {
} }
let macs: Vec<MacAddr> = macs.into_iter().collect(); let macs: Vec<MacAddr> = macs.into_iter().collect();
let ips: Vec<IpAddr> = ips.into_iter().collect();
let id = macs
.first()
.copied()
.map(DeviceId::Mac)
.or_else(|| ips.first().copied().map(DeviceId::Ip));
Self { Self {
id: macs.first().copied().map(|mac| DeviceId { mac }), id,
names: names.into_iter().collect(), names: names.into_iter().map(|n| n.to_owned()).collect(),
ips: ips.into_iter().collect(), ips,
macs, macs,
interfaces: interfaces.into_iter().collect(), interfaces: interfaces.into_iter().map(|d| d.to_owned()).collect(),
neighbors, neighbors,
leases, leases,
presence, presence,
+1 -1
View File
@@ -50,7 +50,7 @@ pub struct WakeTargetResult {
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum WakeStatus { pub enum WakeStatus {
Succeed, Succeed,
NonexistentAddress, NonexistentAddress, // how do i know this isnt some other errors? what!
WrongSize, WrongSize,
Incomplete, Incomplete,
} }
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "wakey-linux" name = "wakey-linux"
version = "0.2.0" version = "0.2.1"
edition = "2024" edition = "2024"
publish = ["gitea"] publish = ["gitea"]
+2 -1
View File
@@ -13,7 +13,7 @@ use wakey_core::{InterfaceAddr, InterfaceSummary};
/// Loopback is intentionally excluded. /// Loopback is intentionally excluded.
pub async fn list_interface_summaries() -> Result<Vec<InterfaceSummary>> { pub async fn list_interface_summaries() -> Result<Vec<InterfaceSummary>> {
#[cfg(unix)] #[cfg(unix)]
let rows = address::nl::get(None) let rows = address::nl::get(None) // <- why not do filtering here
.await .await
.context("rtnetlink address query failed")?; .context("rtnetlink address query failed")?;
@@ -22,6 +22,7 @@ pub async fn list_interface_summaries() -> Result<Vec<InterfaceSummary>> {
.await .await
.context("ip -j address show failed")?; .context("ip -j address show failed")?;
// convert semi raw [AddrOutput] into Almost the same [InterfaceSummary]
let mut out: Vec<InterfaceSummary> = rows let mut out: Vec<InterfaceSummary> = rows
.into_iter() .into_iter()
.filter(|row| row.ifname != "lo") .filter(|row| row.ifname != "lo")
+2
View File
@@ -1,3 +1,5 @@
//! Linux/OpenWrt adapters: DHCP leases, interface summaries, neighbor tables, and WoL.
pub mod devices; pub mod devices;
pub mod dhcp; pub mod dhcp;
pub mod wake; pub mod wake;