Compare commits
211
Commits
v0.1.0
..
fe6155da83
No files matched your search
+32
-4
@@ -1,5 +1,33 @@
|
|||||||
[build]
|
|
||||||
target = "armv7-unknown-linux-musleabihf"
|
|
||||||
|
|
||||||
[target.armv7-unknown-linux-musleabihf]
|
[target.armv7-unknown-linux-musleabihf]
|
||||||
linker = "rust-lld"
|
linker = "rust-lld"
|
||||||
|
|
||||||
|
# [target.aarch64-unknown-linux-gnu]
|
||||||
|
# linker = "aarch64-linux-gnu-gcc"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
# target = "armv7-unknown-linux-musleabihf"
|
||||||
|
|
||||||
|
[alias]
|
||||||
|
cl = "c --target=armv7-unknown-linux-musleabihf"
|
||||||
|
bl = "b --target=armv7-unknown-linux-musleabihf"
|
||||||
|
tl = "t --target=armv7-unknown-linux-musleabihf"
|
||||||
|
|
||||||
|
cw = "c --target=x86_64-pc-windows-msvc"
|
||||||
|
bw = "b --target=x86_64-pc-windows-msvc"
|
||||||
|
tw = "t --target=x86_64-pc-windows-msvc"
|
||||||
|
|
||||||
|
|
||||||
|
check-all = "check --workspace --all-features --all-targets"
|
||||||
|
clippy-all = "clippy --workspace --all-features --all-targets"
|
||||||
|
test-all = "test --workspace --all-features --all-targets"
|
||||||
|
fmt-all = "fmt --all"
|
||||||
|
|
||||||
|
# idk below
|
||||||
|
ldar = "r -qrp"
|
||||||
|
ldabr = "b -r --target=armv7-unknown-linux-musleabihf"
|
||||||
|
t = "test -- --nocapture --test-threads=1"
|
||||||
|
tdebug = "test -- --nocapture --test-threads=1 --show-output"
|
||||||
|
|
||||||
|
|
||||||
|
[registries.gitea]
|
||||||
|
index = "sparse+https://git.ldlda.com/api/packages/lda/cargo/" # Sparse index
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
SQLX_OFFLINE=true
|
||||||
@@ -7,9 +7,14 @@ on:
|
|||||||
- "v*"
|
- "v*"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
# When using cargo-zigbuild for *-gnu targets, link against this glibc baseline (wider VPS compatibility).
|
||||||
|
WAKEY_CC_GLIBC_VERSION: "2.28"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: [self-hosted, windows]
|
# My Machine has arm-musl stuff - or else i have to use cross :(
|
||||||
|
runs-on: [self-hosted, linux, fedora, wsl, release]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -20,6 +25,8 @@ jobs:
|
|||||||
rustc -V
|
rustc -V
|
||||||
cargo -V
|
cargo -V
|
||||||
rustup target add armv7-unknown-linux-musleabihf
|
rustup target add armv7-unknown-linux-musleabihf
|
||||||
|
rustup target add x86_64-unknown-linux-gnu
|
||||||
|
rustup target add aarch64-unknown-linux-gnu
|
||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -28,28 +35,78 @@ jobs:
|
|||||||
~/.cargo/registry
|
~/.cargo/registry
|
||||||
~/.cargo/git
|
~/.cargo/git
|
||||||
target
|
target
|
||||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
key: linux-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
- name: Build (armv7-musl)
|
- name: Build (armv7-musl)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
cargo build --release --target armv7-unknown-linux-musleabihf
|
cargo build --release --target armv7-unknown-linux-musleabihf -p wakey -p wakey-agent
|
||||||
|
|
||||||
|
- name: Build UI dist
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd ui
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
- name: Build control-plane (linux-gnu; zig + older glibc when available)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
GLIBC_VER="${WAKEY_CC_GLIBC_VERSION:-2.28}"
|
||||||
|
GNU_TARGETS=(x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu)
|
||||||
|
|
||||||
|
use_zig=false
|
||||||
|
if command -v zig >/dev/null 2>&1 && cargo zigbuild -h >/dev/null 2>&1; then
|
||||||
|
use_zig=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$use_zig" = true ]; then
|
||||||
|
echo "cargo-zigbuild + zig found: building control-plane with glibc ${GLIBC_VER}"
|
||||||
|
for base in "${GNU_TARGETS[@]}"; do
|
||||||
|
cargo zigbuild --release --target "${base}.${GLIBC_VER}" -p wakey-control-plane
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "zig or cargo zigbuild not available: using plain cargo build (binary needs glibc >= host)"
|
||||||
|
for base in "${GNU_TARGETS[@]}"; do
|
||||||
|
cargo build --release --target "$base" -p wakey-control-plane
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Package rootfs tarball
|
- name: Package rootfs tarball
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
./scripts/package_rootfs.ps1 -Version "${{ github.ref_name }}" -Target armv7-unknown-linux-musleabihf
|
./scripts/package_rootfs.ps1 -Version "${{ github.ref_name }}" -Target armv7-unknown-linux-musleabihf
|
||||||
|
|
||||||
|
- name: Package control-plane bundles
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
chmod +x scripts/update_wakey_cc.sh scripts/package_wakey_cc_bundle.sh
|
||||||
|
for target in x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do
|
||||||
|
./scripts/package_wakey_cc_bundle.sh --version "${{ github.ref_name }}" --target "$target"
|
||||||
|
done
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: wakey-rootfs
|
name: wakey-rootfs
|
||||||
path: dist/wakey-rootfs-*armv7-unknown-linux-musleabihf.tgz
|
path: dist/wakey-rootfs-*armv7-unknown-linux-musleabihf.tgz
|
||||||
|
|
||||||
- name: Publish Release to Gitea (API)
|
- name: Upload control-plane bundles
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: wakey-cc-bundles
|
||||||
|
path: dist/wakey-cc-*unknown-linux-gnu.tgz
|
||||||
|
|
||||||
|
- name: Publish Release to Gitea
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
shell: pwsh
|
# use their personal repo cuz this searches github.com T_T
|
||||||
env:
|
uses: akkuman/gitea-release-action@v1
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
with:
|
||||||
run: |
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
./scripts/ci_gitea_release.ps1 -Tag "$env:GITHUB_REF_NAME" -AssetPattern "dist/wakey-rootfs-*armv7-unknown-linux-musleabihf.tgz"
|
files: |
|
||||||
|
dist/wakey-rootfs-*armv7-unknown-linux-musleabihf.tgz
|
||||||
|
dist/wakey-cc-*unknown-linux-gnu.tgz
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Wakey Checkpoint (2026-04-11)
|
||||||
|
|
||||||
|
## Snapshot
|
||||||
|
|
||||||
|
This checkpoint captures the current state after control-plane migration, logging hardening, config ergonomics, and state storage upgrades.
|
||||||
|
|
||||||
|
## What Is Done
|
||||||
|
|
||||||
|
- Legacy router-hosted HTTP/static layer removed from `wakey` crate.
|
||||||
|
- New `wakey-control-plane` crate is active for:
|
||||||
|
- enroll-token issuance
|
||||||
|
- agent enrollment
|
||||||
|
- connected-agent websocket registry
|
||||||
|
- command relay to agent
|
||||||
|
- `wakey-agent` is active for outbound websocket execution and local command dispatch.
|
||||||
|
|
||||||
|
## Logging + Telemetry
|
||||||
|
|
||||||
|
- High-signal logs were added across:
|
||||||
|
- control-plane API relay path
|
||||||
|
- control-plane websocket lifecycle
|
||||||
|
- control-plane state lifecycle
|
||||||
|
- agent command lifecycle, session lifecycle, and enrollment
|
||||||
|
- Correlated relay spans include command context (`agent_id`, `request_id`, `command`).
|
||||||
|
- Control-plane telemetry is config-driven:
|
||||||
|
- optional OTLP endpoint
|
||||||
|
- optional JSON logs
|
||||||
|
- fallback local logs when OTLP endpoint is not set
|
||||||
|
|
||||||
|
## Config Ergonomics
|
||||||
|
|
||||||
|
### Control-plane
|
||||||
|
|
||||||
|
- Config file support is wired (`/etc/wakey-control-plane/config.toml` by default).
|
||||||
|
- `serve` can read defaults from config file and CLI can override.
|
||||||
|
- New `init-config` command scaffolds a control-plane config file.
|
||||||
|
|
||||||
|
### Agent
|
||||||
|
|
||||||
|
- Existing `init-config` command scaffolds agent config.
|
||||||
|
- Enrollment can optionally signal reload of running daemon.
|
||||||
|
|
||||||
|
## State Storage Upgrade
|
||||||
|
|
||||||
|
- Control-plane state backend moved from JSON snapshot to embedded `sled` DB.
|
||||||
|
- Default state path changed to `/var/lib/wakey-control-plane/state.db`.
|
||||||
|
- Legacy JSON migration support has been removed; sled is now the only supported
|
||||||
|
state format.
|
||||||
|
- Enroll tokens include persisted expiry timestamps and are validated on enroll.
|
||||||
|
- Periodic and explicit garbage collection remove expired tokens.
|
||||||
|
|
||||||
|
## Operator Commands
|
||||||
|
|
||||||
|
### Control-plane bootstrap
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wakey-control-plane init-config
|
||||||
|
wakey-control-plane serve --config-file /etc/wakey-control-plane/config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue enroll token (live daemon path)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wakey-control-plane issue-enroll-token --public-url https://cp.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agent bootstrap
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wakey-agent enroll --server-url https://cp.example.com --enroll-token <token>
|
||||||
|
wakey-agent serve --config /etc/wakey-agent/config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build Health
|
||||||
|
|
||||||
|
- Last verified passing:
|
||||||
|
- `cargo check --workspace`
|
||||||
|
- `cargo clippy --workspace`
|
||||||
|
|
||||||
|
## Test Coverage Added In This Pass
|
||||||
|
|
||||||
|
- Unit tests in `wakey-control-plane/src/state/store.rs` now cover:
|
||||||
|
- expired-token garbage collection removes persisted stale tokens
|
||||||
|
- enroll rejects expired tokens and consumes stale entries
|
||||||
|
- state stats counters for agents and expired token totals
|
||||||
|
|
||||||
|
## Known Tradeoffs / Follow-ups
|
||||||
|
|
||||||
|
- Reload semantics with `sled` are now mostly no-op for in-memory state (data is durable in DB).
|
||||||
|
- No dedicated state-inspection CLI command yet (suggestion: add `state-stats` command).
|
||||||
|
- OTLP configuration is currently control-plane focused; agent parity can be added if needed.
|
||||||
|
|
||||||
|
## Suggested Next Steps
|
||||||
|
|
||||||
|
1. Add a control-plane `state-stats` command to print DB path, agent count, token count.
|
||||||
|
2. Add symmetric telemetry config support in `wakey-agent` config file.
|
||||||
|
3. Add integration tests for:
|
||||||
|
- enroll + relay over websocket
|
||||||
|
- legacy JSON-to-sled migration path
|
||||||
|
- init-config command behavior and overrides
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Wakey Checkpoint (2026-04-12)
|
||||||
|
|
||||||
|
## Snapshot
|
||||||
|
|
||||||
|
This checkpoint captures progress after adding audit persistence, alert evaluation and transitions, control-plane alert APIs, and websocket timing diagnostics across agent and control-plane.
|
||||||
|
|
||||||
|
## Major Changes Landed
|
||||||
|
|
||||||
|
- Control-plane audit system implemented with persistent sled-backed events.
|
||||||
|
- Audit emission wired into:
|
||||||
|
- enroll accept/reject
|
||||||
|
- token issue/list/revoke
|
||||||
|
- command dispatch/result/error/timeout
|
||||||
|
- websocket auth accept/reject and disconnect
|
||||||
|
- Audit query API added:
|
||||||
|
- `GET /api/v1/control/audit/events`
|
||||||
|
- Active alert engine added with deterministic rules over audit + live session state.
|
||||||
|
- Alert APIs added:
|
||||||
|
- `GET /api/v1/control/alerts`
|
||||||
|
- `GET /api/v1/control/alerts/history`
|
||||||
|
- `GET /api/v1/control/alerts/ws`
|
||||||
|
- Alert transition persistence added (open/resolve transitions tracked across evaluations).
|
||||||
|
- Route classes split explicitly in runtime:
|
||||||
|
- public routes (enroll/ws/health)
|
||||||
|
- control routes (`/api/v1/control/*`)
|
||||||
|
- Caddy template added for edge policy and Cloudflare Access boundary:
|
||||||
|
- `deploy/Caddyfile.control-plane.example`
|
||||||
|
|
||||||
|
## Reliability and Diagnostics Improvements
|
||||||
|
|
||||||
|
- Agent websocket connect diagnostics now include:
|
||||||
|
- DNS resolution timing (`dns_resolve_ms`)
|
||||||
|
- websocket connect timing (`ws_connect_ms`)
|
||||||
|
- Control-plane websocket lifecycle logs include:
|
||||||
|
- connect-to-hello timing
|
||||||
|
- connect-to-auth timing
|
||||||
|
- hello-to-auth timing
|
||||||
|
- Slow connect warnings are now emitted when timing thresholds are exceeded.
|
||||||
|
|
||||||
|
## Root-Cause Findings Captured
|
||||||
|
|
||||||
|
- Long agent websocket connect delays were reproduced and traced to hostname resolution path.
|
||||||
|
- Switching agent `server_url` hostname to direct IP made connect immediate.
|
||||||
|
- This confirms app-level relay logic was not the source of the startup delay.
|
||||||
|
|
||||||
|
## Verification Status
|
||||||
|
|
||||||
|
- `cargo check --workspace` passing after all changes.
|
||||||
|
- Added and passing tests include:
|
||||||
|
- audit event append/filter in state store
|
||||||
|
- alert transition open/resolve persistence
|
||||||
|
- alert evaluator rule checks (offline + timeout, auth/enroll rejection spikes)
|
||||||
|
|
||||||
|
## Current API Surface for UI Start
|
||||||
|
|
||||||
|
- Agents and command execution:
|
||||||
|
- `GET /api/v1/control/agents`
|
||||||
|
- `POST /api/v1/control/agents/{agent_id}/command`
|
||||||
|
- Audits:
|
||||||
|
- `GET /api/v1/control/audit/events`
|
||||||
|
- Alerts:
|
||||||
|
- `GET /api/v1/control/alerts`
|
||||||
|
- `GET /api/v1/control/alerts/history`
|
||||||
|
- websocket subscribe: `GET /api/v1/control/alerts/ws`
|
||||||
|
|
||||||
|
## Remaining Plan Items (Most Significant)
|
||||||
|
|
||||||
|
1. UI implementation (`/ui` app shell and pages) is still open.
|
||||||
|
2. Alert dedupe/cooldown persistence and tuning are still basic and need hardening.
|
||||||
|
3. Audit retention pruning policy and long-run storage controls are not finalized.
|
||||||
|
4. Edge auth enforcement tests and deployment rehearsals remain to be added.
|
||||||
|
5. Multi-day soak drills and failure-injection validation remain open.
|
||||||
|
|
||||||
|
## Suggested Next Actions
|
||||||
|
|
||||||
|
1. Build minimal UI shell with three views:
|
||||||
|
- agents/commands
|
||||||
|
- audit timeline
|
||||||
|
- alerts panel (active + history + websocket stream)
|
||||||
|
2. Add periodic retention task for audit and alert transition trees.
|
||||||
|
3. Add proxy-level integration tests that assert private endpoints are blocked without Access headers.
|
||||||
|
4. Run a 48-72h soak with hostname vs IP connect-path metrics collected.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Control Plane Review
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
1. High: the admin/control API is effectively unauthenticated, so anyone who can reach the server can issue enroll tokens, list/revoke tokens, inspect audit/alerts, and send live commands to agents. In [`wakey-control-plane/src/runtime/mod.rs:61`](c:/Users/Admin/Documents/realshit/wakey/wakey-control-plane/src/runtime/mod.rs#L61) through [`wakey-control-plane/src/runtime/mod.rs:109`](c:/Users/Admin/Documents/realshit/wakey/wakey-control-plane/src/runtime/mod.rs#L109), `control_api_routes()` is merged straight into the app with no auth middleware despite the comment saying these routes are “admin-only.” The UI also calls those endpoints directly with plain `fetch` and no auth material in [`ui/src/api.ts:60`](c:/Users/Admin/Documents/realshit/wakey/ui/src/api.ts#L60) through [`ui/src/api.ts:123`](c:/Users/Admin/Documents/realshit/wakey/ui/src/api.ts#L123). This is a full remote-takeover issue for the control plane, not just a missing polish item.
|
||||||
|
|
||||||
|
2. High: a second websocket connection for the same `agent_id` silently replaces the current session, but the old authenticated socket is left alive and can still submit `result`/`error` frames against pending requests. In [`wakey-control-plane/src/ws.rs:195`](c:/Users/Admin/Documents/realshit/wakey/wakey-control-plane/src/ws.rs#L195) through [`wakey-control-plane/src/ws.rs:200`](c:/Users/Admin/Documents/realshit/wakey/wakey-control-plane/src/ws.rs#L200), a successful auth simply overwrites `sessions[agent_id] = tx.clone()`. The previous socket is not closed or demoted. Later, any authenticated socket can satisfy pending requests in [`wakey-control-plane/src/ws.rs:229`](c:/Users/Admin/Documents/realshit/wakey/wakey-control-plane/src/ws.rs#L229) through [`wakey-control-plane/src/ws.rs:247`](c:/Users/Admin/Documents/realshit/wakey/wakey-control-plane/src/ws.rs#L247), while requests are correlated only by `request_id` created in [`wakey-control-plane/src/api/commands.rs:91`](c:/Users/Admin/Documents/realshit/wakey/wakey-control-plane/src/api/commands.rs#L91) through [`wakey-control-plane/src/api/commands.rs:97`](c:/Users/Admin/Documents/realshit/wakey/wakey-control-plane/src/api/commands.rs#L97). That creates a split-brain/race condition where a stale or malicious prior session for the same agent can inject or win replies.
|
||||||
|
|
||||||
|
3. Medium: configured seed enroll tokens are reinserted into the database on every startup, so “one-time” tokens become reusable after a restart if they remain in config. In [`wakey-control-plane/src/state/store.rs:52`](c:/Users/Admin/Documents/realshit/wakey/wakey-control-plane/src/state/store.rs#L52) through [`wakey-control-plane/src/state/store.rs:68`](c:/Users/Admin/Documents/realshit/wakey/wakey-control-plane/src/state/store.rs#L68), `load_or_init()` blindly seeds `daemon.enroll_tokens` into sled every time the process starts. Enrollment consumes tokens in [`wakey-control-plane/src/state/store.rs:91`](c:/Users/Admin/Documents/realshit/wakey/wakey-control-plane/src/state/store.rs#L91) through [`wakey-control-plane/src/state/store.rs:109`](c:/Users/Admin/Documents/realshit/wakey/wakey-control-plane/src/state/store.rs#L109), but that consumption is undone on the next boot if the token still exists in config. That breaks the “short-lived/one-time” assumption and makes operational mistakes much more likely.
|
||||||
|
|
||||||
|
## Residual Risks
|
||||||
|
|
||||||
|
- The control-plane/UI surface is growing quickly and currently assumes a trusted environment in multiple places. Even after adding admin auth, I would expect more authz/session-boundary issues to surface.
|
||||||
|
- The command relay path is conceptually good, but it needs stronger session ownership rules before it is trustworthy under reconnect races or duplicated agents.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
you can use block code as so:
|
We work towards clean, modular, maintainable design, clear documentation... im not the best at this thing, my choices can be bad, so you help me.
|
||||||
```rust
|
|
||||||
hello_world!(println);
|
DRY: spawn subagents.
|
||||||
```
|
Context: break those files out.
|
||||||
in the chat.
|
Test: make functions easy to test.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
## Plan: Audit, Alerts, UI, and Safe Edge Exposure
|
||||||
|
|
||||||
|
Recommended approach: ship in this order audit first, alerts second, UI third, then edge hardening and soak. This gives you observability truth before you build subscriptions and screens, and keeps risky control APIs private behind Cloudflare Access at Caddy.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
1. Phase A: Lock trust boundaries and endpoint classes.
|
||||||
|
2. Public Agent API stays exposed: /api/v1/agents/enroll, /api/v1/agent/ws, /healthz.
|
||||||
|
3. Private Control API stays protected: /api/v1/control/* and /ui/*.
|
||||||
|
4. Phase A: Define AuditEvent schema and retention defaults.
|
||||||
|
5. Phase B: Add audit persistence in sled and emit at key points.
|
||||||
|
6. Emit audit on token issue/list/revoke, ws auth/disconnect, command dispatch/result/timeout/error, and reload/config operations.
|
||||||
|
7. Phase B: Add audit query API with filters and pagination.
|
||||||
|
8. Phase C: Implement deterministic alert rules and evaluator loop.
|
||||||
|
9. Start with offline agent threshold, timeout-rate threshold, auth-failure spike, token misuse attempts.
|
||||||
|
10. Add dedupe and cooldown so alerts do not flap.
|
||||||
|
11. Phase C: Add alert delivery APIs.
|
||||||
|
12. Poll-first endpoint for active alerts and recent transitions, websocket stream optional after rules stabilize.
|
||||||
|
13. Phase D: Build same-domain UI app shell at /ui with origin-relative API client.
|
||||||
|
14. Phase D: Build pages: Agent Health, Command Runner, Audit Timeline, Alerts Panel.
|
||||||
|
15. Phase E: Add Caddy deployment template with Cloudflare Access policy boundaries and websocket support.
|
||||||
|
16. Phase E: Run end-to-end drills and 48-72h soak.
|
||||||
|
|
||||||
|
**Relevant files**
|
||||||
|
- [wakey-control-plane/src/runtime/mod.rs](wakey-control-plane/src/runtime/mod.rs)
|
||||||
|
- [wakey-control-plane/src/api/commands.rs](wakey-control-plane/src/api/commands.rs)
|
||||||
|
- [wakey-control-plane/src/api/control.rs](wakey-control-plane/src/api/control.rs)
|
||||||
|
- [wakey-control-plane/src/ws.rs](wakey-control-plane/src/ws.rs)
|
||||||
|
- [wakey-control-plane/src/state/store.rs](wakey-control-plane/src/state/store.rs)
|
||||||
|
- [wakey-control-plane/src/state/types.rs](wakey-control-plane/src/state/types.rs)
|
||||||
|
- [wakey-control-plane/src/config/types.rs](wakey-control-plane/src/config/types.rs)
|
||||||
|
- [wakey-control-plane/src/config/resolve.rs](wakey-control-plane/src/config/resolve.rs)
|
||||||
|
- [wakey-control-plane/src/cli.rs](wakey-control-plane/src/cli.rs)
|
||||||
|
- [README.md](README.md)
|
||||||
|
- [scripts/init/openwrt/wakey](scripts/init/openwrt/wakey)
|
||||||
|
- [.github/plan-controlPlaneAppV1.prompt.md](.github/plan-controlPlaneAppV1.prompt.md)
|
||||||
|
|
||||||
|
**Verification**
|
||||||
|
1. Unit tests for audit append/query, pagination, retention pruning.
|
||||||
|
2. Unit tests for alert rule evaluation, dedupe, cooldown.
|
||||||
|
3. Contract tests for request_id correlation across command result and timeout/error audit records.
|
||||||
|
4. Integration tests for ws auth/disconnect audit events and command timeout event emission.
|
||||||
|
5. API tests for audit and alert endpoints.
|
||||||
|
6. Edge security tests that unauthenticated /api/v1/control/* and /ui/* are denied.
|
||||||
|
7. Soak tests for reconnect churn and audit growth stability.
|
||||||
|
|
||||||
|
**Decisions captured**
|
||||||
|
- Admin auth default: Cloudflare Access only, enforced at Caddy.
|
||||||
|
- UI host: same domain path deployment.
|
||||||
|
- Shell bridge: excluded from v1 due high risk and low break-glass value during hard router failures.
|
||||||
|
- Alert delivery: poll-first in v1, websocket stream optional.
|
||||||
|
|
||||||
|
**Caddy policy shape for this plan**
|
||||||
|
1. Route /api/v1/agents/enroll and /api/v1/agent/ws to control-plane upstream without Cloudflare Access gate.
|
||||||
|
2. Route /api/v1/control/* and /ui/* only when Cloudflare Access authentication is valid.
|
||||||
|
3. Preserve websocket upgrade headers on /api/v1/agent/ws.
|
||||||
|
4. Keep control-plane process bound to private interface or localhost behind Caddy.
|
||||||
|
5. Deny direct exposure of /api/v1/control/* from origin network paths.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
## Plan: Control Plane App v1
|
||||||
|
|
||||||
|
Build an end-to-end, ops-ready v1 over 6+ weeks by reusing current wakey and wakey-core logic, keeping wakey-agent as outbound executor, and adding a dedicated control-plane server plus minimal operator UI.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
1. Phase 1, contract baseline: finalize relay contract for command, result, error, request correlation, timeout, retry, and forward compatibility behavior.
|
||||||
|
2. Phase 1, boundary lock: keep execution in wakey service functions and keep domain DTOs in wakey-core while removing legacy HTTP/static compatibility code.
|
||||||
|
3. Phase 2, server skeleton: implement enrollment endpoint, agent registry, websocket acceptor, and request correlation map. Depends on step 1 and step 2.
|
||||||
|
4. Phase 2, relay core: implement command submission to connected agents, request_id correlation, timeout paths, and structured relay errors. Depends on step 3.
|
||||||
|
5. Phase 2, persistence and identity: durable agent records, enroll token lifecycle, and safe credential metadata. Parallel with step 4 after schema is stable.
|
||||||
|
6. Phase 3, operator surface: add API for agent inventory, health, command execution, and recent outcomes; add minimal UI for core operations. Depends on step 4 and step 5.
|
||||||
|
7. Phase 3, ops hardening: metrics, logs, audits, heartbeat liveness checks, and alert thresholds. Parallel with step 6.
|
||||||
|
8. Phase 4, deployment pipeline: add server build and deploy artifacts, environment templates, and rollback workflow. Depends on step 6 and step 7.
|
||||||
|
9. Phase 4, validation and soak: run enrollment-to-command end-to-end tests and disconnect/failure drills with multi-day soak. Depends on step 8.
|
||||||
|
|
||||||
|
**Relevant files to reuse**
|
||||||
|
- [wakey-agent/src/protocol.rs](wakey-agent/src/protocol.rs)
|
||||||
|
- [wakey-agent/src/session.rs](wakey-agent/src/session.rs)
|
||||||
|
- [wakey-agent/src/dispatch.rs](wakey-agent/src/dispatch.rs)
|
||||||
|
- [src/service/mod.rs](src/service/mod.rs)
|
||||||
|
- [wakey-core/src/model](wakey-core/src/model)
|
||||||
|
- [scripts/package_rootfs.ps1](scripts/package_rootfs.ps1)
|
||||||
|
- [.gitea/workflows/release.yml](.gitea/workflows/release.yml)
|
||||||
|
|
||||||
|
**Verification**
|
||||||
|
1. Contract tests for command and result serialization, request_id stability, and unknown frame tolerance.
|
||||||
|
2. Relay integration tests for enrollment, websocket auth flow, correlation, and timeout handling.
|
||||||
|
3. Security tests for token lifecycle and invalid credential rejection.
|
||||||
|
4. API tests for registry and command execution behavior.
|
||||||
|
5. Observability tests for heartbeat, reconnect counters, latency, and failure alerts.
|
||||||
|
6. Deployment tests for build, release, rollback rehearsal, and staging smoke checks.
|
||||||
|
7. On-device tests for enroll, procd lifecycle, reconnect, and remote command round trips.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
## Plan: wakey UI v1 (Device-First)
|
||||||
|
|
||||||
|
The Operator UI is not the end goal by itself.
|
||||||
|
The goal is to make wakey excellent at its original purpose: quickly finding devices and waking them reliably.
|
||||||
|
|
||||||
|
Agent, audit, and token features remain important, but they should support the device workflow rather than dominate the navigation and development effort.
|
||||||
|
|
||||||
|
## Product North Star
|
||||||
|
|
||||||
|
An operator should be able to do this in under 10 seconds:
|
||||||
|
1. Open the UI.
|
||||||
|
2. Search for a device by name, IP, or MAC.
|
||||||
|
3. See whether it looks online/reachable.
|
||||||
|
4. Trigger wake.
|
||||||
|
5. See immediate command result and short follow-up status.
|
||||||
|
|
||||||
|
## Scope Priorities
|
||||||
|
|
||||||
|
1. P0: Device discovery and wake UX.
|
||||||
|
2. P1: Fast troubleshooting context around wake results.
|
||||||
|
3. P2: Fleet/agent/admin operations.
|
||||||
|
|
||||||
|
This explicitly means pages and components for Agent, Audit, Alerts, and Tokens should be present but secondary in visual hierarchy and effort until P0 is complete.
|
||||||
|
|
||||||
|
## IA Direction (v1)
|
||||||
|
|
||||||
|
Primary top-level focus:
|
||||||
|
1. Devices
|
||||||
|
2. Wake Queue (or Recent Actions)
|
||||||
|
|
||||||
|
Secondary top-level focus:
|
||||||
|
1. Fleet Health
|
||||||
|
2. Audit
|
||||||
|
3. Alerts
|
||||||
|
4. Access/Tokens
|
||||||
|
|
||||||
|
If needed, keep current routes during transition, but adjust default landing and navigation emphasis so Devices is the home workflow.
|
||||||
|
|
||||||
|
## Phase Plan
|
||||||
|
|
||||||
|
### Phase 1: Device-Centric Foundation
|
||||||
|
|
||||||
|
1. Add a dedicated Devices page that merges existing status/leases/inventory signal into one operator list.
|
||||||
|
2. Include searchable columns for name, IP, MAC, interface/dev, and recency indicators.
|
||||||
|
3. Provide row-level wake action and bulk-safe interaction model (single-click row action, confirm for bulk).
|
||||||
|
4. Define a compact "device confidence" heuristic from available data (for example: recent lease + reachable neighbor).
|
||||||
|
5. Set the default route to Devices, with prominent search and wake controls above the fold.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
1. Search by hostname, IP, and MAC all work from one input.
|
||||||
|
2. Wake action reachable in one click from list row.
|
||||||
|
3. Response feedback shown immediately with clear success/error text.
|
||||||
|
|
||||||
|
### Phase 2: Wake Execution UX
|
||||||
|
|
||||||
|
1. Build a focused wake panel with explicit target preview before send.
|
||||||
|
2. Provide quick presets: "wake by selected device", "wake by MAC", "wake by query".
|
||||||
|
3. Persist recent wake targets locally for operator speed.
|
||||||
|
4. Add post-wake verification loop (short timed refresh of status indicators).
|
||||||
|
5. Surface request correlation id and a copy action for incident sharing.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
1. Operator can retry wake with one click.
|
||||||
|
2. Operator can see last 20 wake attempts with outcome and timestamp.
|
||||||
|
3. Error states differentiate validation, timeout, and execution failure.
|
||||||
|
|
||||||
|
### Phase 3: Context Without Workflow Drift
|
||||||
|
|
||||||
|
1. Keep Alerts and Audit accessible from device rows and wake outcomes.
|
||||||
|
2. Add contextual deep links: device -> related alerts, device -> recent audit events.
|
||||||
|
3. Improve filtering for alerts/audit with saved local filter presets.
|
||||||
|
4. Add gentle live updates, keeping websocket optional with polling fallback.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
1. From any failed wake, operator can jump to relevant audit entries in one step.
|
||||||
|
2. Alerts page can be filtered by kind/severity/agent and linked back to impacted devices.
|
||||||
|
|
||||||
|
### Phase 4: Fleet/Admin Hardening
|
||||||
|
|
||||||
|
1. Keep Agents page for connectivity and control routing visibility.
|
||||||
|
2. Keep Tokens page for enrollment lifecycle operations.
|
||||||
|
3. Keep Dashboard but reframe metrics around "device availability" and "wake success" first.
|
||||||
|
4. Add audit-friendly confirmation flows for destructive actions.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
1. Admin flows do not block or slow P0 device workflows.
|
||||||
|
2. All admin actions produce clear audit-visible outcomes.
|
||||||
|
|
||||||
|
### Phase 5: Validation and Production Readiness
|
||||||
|
|
||||||
|
1. Add smoke tests for P0 flow: find device -> wake -> observe result.
|
||||||
|
2. Add contract checks for command payload/response shapes used by device and wake screens.
|
||||||
|
3. Add scenario drills (offline agent, delayed command, websocket drop, expired token).
|
||||||
|
4. Verify edge policy still protects control routes while preserving required public endpoints.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
1. P0 flow remains usable during partial degradation.
|
||||||
|
2. Build/typecheck/test gates stay green in CI.
|
||||||
|
|
||||||
|
## Concrete UI Backlog (Ordered)
|
||||||
|
|
||||||
|
1. Create DevicesPage with unified searchable table.
|
||||||
|
2. Wire wake action directly from device row.
|
||||||
|
3. Add Recent Wake Actions panel with outcomes.
|
||||||
|
4. Add post-wake short verification refresh.
|
||||||
|
5. Rework Dashboard cards to device-first metrics.
|
||||||
|
6. Add deep links from wake result to audit and alerts context.
|
||||||
|
7. Add empty/loading/error skeleton patterns tuned for device list scale.
|
||||||
|
|
||||||
|
## Metrics of Success
|
||||||
|
|
||||||
|
1. Time-to-wake median under 10 seconds for known target.
|
||||||
|
2. Wake success rate visible per time window.
|
||||||
|
3. Fewer operator clicks for common tasks (device lookup + wake).
|
||||||
|
4. Reduced navigation to agent-centric pages for everyday usage.
|
||||||
|
|
||||||
|
## Design and Interaction Principles
|
||||||
|
|
||||||
|
1. Device-first information density over generic admin dashboards.
|
||||||
|
2. Search and action bar always visible on desktop and mobile.
|
||||||
|
3. Action feedback must be immediate and explicit.
|
||||||
|
4. Keep advanced infrastructure details available but visually de-emphasized.
|
||||||
|
|
||||||
|
## Architecture Decisions
|
||||||
|
|
||||||
|
1. Keep same-domain /ui serving and origin-relative API requests.
|
||||||
|
2. Keep route and API contracts stable while iterating UX aggressively.
|
||||||
|
3. Treat websocket as progressive enhancement; polling fallback required.
|
||||||
|
4. Preserve Cloudflare Access boundary for all control endpoints.
|
||||||
|
|
||||||
|
## Relevant Files
|
||||||
|
|
||||||
|
1. [ui/src/App.tsx](ui/src/App.tsx)
|
||||||
|
2. [ui/src/pages/CommandsPage.tsx](ui/src/pages/CommandsPage.tsx)
|
||||||
|
3. [ui/src/pages/DashboardPage.tsx](ui/src/pages/DashboardPage.tsx)
|
||||||
|
4. [ui/src/api.ts](ui/src/api.ts)
|
||||||
|
5. [wakey-control-plane/src/api/commands.rs](wakey-control-plane/src/api/commands.rs)
|
||||||
|
6. [wakey-control-plane/src/api/audit.rs](wakey-control-plane/src/api/audit.rs)
|
||||||
|
7. [wakey-control-plane/src/api/alerts.rs](wakey-control-plane/src/api/alerts.rs)
|
||||||
|
8. [wakey-control-plane/src/api/control.rs](wakey-control-plane/src/api/control.rs)
|
||||||
|
9. [wakey-control-plane/src/runtime/mod.rs](wakey-control-plane/src/runtime/mod.rs)
|
||||||
|
10. [README.md](README.md)
|
||||||
|
|
||||||
|
## Immediate Next Build Slice
|
||||||
|
|
||||||
|
1. Implement DevicesPage and make it default route.
|
||||||
|
2. Add row-level wake action with in-place result feedback.
|
||||||
|
3. Add Recent Wake Actions section backed by local state first, then audit correlation.
|
||||||
|
4. Rebalance navigation labels/order to make device workflows primary.
|
||||||
@@ -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/`.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -18,3 +18,5 @@ target/
|
|||||||
#.idea/
|
#.idea/
|
||||||
|
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
.codex
|
||||||
+12
@@ -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"
|
||||||
|
}
|
||||||
+12
@@ -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"
|
||||||
|
}
|
||||||
+12
@@ -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"
|
||||||
|
}
|
||||||
+12
@@ -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"
|
||||||
|
}
|
||||||
+20
@@ -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"
|
||||||
|
}
|
||||||
+12
@@ -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"
|
||||||
|
}
|
||||||
+86
@@ -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"
|
||||||
|
}
|
||||||
+50
@@ -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"
|
||||||
|
}
|
||||||
+20
@@ -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"
|
||||||
|
}
|
||||||
+12
@@ -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"
|
||||||
|
}
|
||||||
+20
@@ -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"
|
||||||
|
}
|
||||||
+68
@@ -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"
|
||||||
|
}
|
||||||
+26
@@ -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"
|
||||||
|
}
|
||||||
+86
@@ -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"
|
||||||
|
}
|
||||||
+20
@@ -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"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM agents WHERE agent_id = ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "51cb6ec47368803af60114a39dd9e70ba9d31946bc39980c9e85dbc4e842a2c8"
|
||||||
|
}
|
||||||
+12
@@ -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"
|
||||||
|
}
|
||||||
+20
@@ -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"
|
||||||
|
}
|
||||||
+68
@@ -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"
|
||||||
|
}
|
||||||
+20
@@ -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"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM active_alerts",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 0
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "72afd48584f822116318c623f7dd42ddf4c82b337d8a3d52ed5973d52e08c163"
|
||||||
|
}
|
||||||
+12
@@ -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"
|
||||||
|
}
|
||||||
+26
@@ -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"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM agent_meta WHERE agent_id = ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "8bf8470846bd228d2ea9b243f70f946f002915b9b1983bce818abc0ae3dd97f0"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM enroll_tokens WHERE token = ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "8f3499a2f461f738711b9942a9c9d6254c1f7ed0db54db8b3d65e10fa8dadfc9"
|
||||||
|
}
|
||||||
+86
@@ -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.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.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": "908d763909c76fb6170b818784e360372c02295523ef9970c91199db7d4f535a"
|
||||||
|
}
|
||||||
+50
@@ -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"
|
||||||
|
}
|
||||||
+26
@@ -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"
|
||||||
|
}
|
||||||
+20
@@ -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"
|
||||||
|
}
|
||||||
+50
@@ -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"
|
||||||
|
}
|
||||||
+20
@@ -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"
|
||||||
|
}
|
||||||
+68
@@ -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"
|
||||||
|
}
|
||||||
+20
@@ -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"
|
||||||
|
}
|
||||||
+74
@@ -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"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM known_devices WHERE device_id = ?1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "b1c6150ef7bd67ce78f8934fc5d451b36f5bed834d39bdde01356e331c5e5ddb"
|
||||||
|
}
|
||||||
+20
@@ -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"
|
||||||
|
}
|
||||||
+12
@@ -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"
|
||||||
|
}
|
||||||
+68
@@ -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"
|
||||||
|
}
|
||||||
+12
@@ -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"
|
||||||
|
}
|
||||||
+44
@@ -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"
|
||||||
|
}
|
||||||
+20
@@ -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"
|
||||||
|
}
|
||||||
+12
@@ -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"
|
||||||
|
}
|
||||||
+20
@@ -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"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO meta (key, value) VALUES (?1, ?2)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "ffed939cfeecca1d46bdf77b67aca2919b34d496e729ad08f87ac9782fb8e382"
|
||||||
|
}
|
||||||
Vendored
+3
-2
@@ -1,3 +1,4 @@
|
|||||||
{
|
{
|
||||||
"rust-analyzer.cargo.target": "armv7-unknown-linux-musleabihf"
|
"rust-analyzer.cargo.target": "armv7-unknown-linux-musleabihf",
|
||||||
}
|
// "rust-analyzer.diagnostics.disabled": ["unlinked-file"]
|
||||||
|
}
|
||||||
Generated
+3508
-319
File diff suppressed because it is too large.
Load diff
+38
-13
@@ -1,23 +1,48 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wakey"
|
name = "wakey"
|
||||||
version = "0.1.0"
|
version = "0.2.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = ["gitea"]
|
publish = ["gitea"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { version = "0.8.4", features = ["macros"] }
|
anyhow = "1"
|
||||||
axum-extra = { version = "0.10.1", features = ["query"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
color-eyre = "0.6.5"
|
clap = { version = "4", features = ["derive"] }
|
||||||
futures = "0.3.31"
|
color-eyre = "0"
|
||||||
macaddr = { version = "1.0.1", features = ["serde", "serde_std"] }
|
comfy-table = "7"
|
||||||
serde = { version = "1.0.219", features = ["derive"] }
|
futures = "0"
|
||||||
serde_json = "1.0.143"
|
macaddr = { version = "1", features = ["serde", "serde_std"] }
|
||||||
serde_with = { version = "3.14.0", features = ["json"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
strum = { version = "0.27.2", features = ["derive", "strum_macros"] }
|
serde_json = "1"
|
||||||
thiserror = "2.0.16"
|
tokio = { version = "1", features = [
|
||||||
tokio = { version = "1.47.1", features = ["fs", "process", "rt-multi-thread"] }
|
"fs",
|
||||||
urlencoding = "2.1.3"
|
"process",
|
||||||
|
"rt-multi-thread",
|
||||||
|
"io-util",
|
||||||
|
"macros",
|
||||||
|
"time",
|
||||||
|
] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
strip = true
|
strip = true
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
members = ["ipjs", "wakey-agent", "wakey-control-plane", "wakey-core", "wakey-linux"]
|
||||||
|
|
||||||
|
[dependencies.lda-ipjs]
|
||||||
|
path = "ipjs"
|
||||||
|
registry = "gitea"
|
||||||
|
version = "0"
|
||||||
|
|
||||||
|
[dependencies.wakey-core]
|
||||||
|
path = "wakey-core"
|
||||||
|
registry = "gitea"
|
||||||
|
version = "0"
|
||||||
|
|
||||||
|
[dependencies.wakey-linux]
|
||||||
|
path = "wakey-linux"
|
||||||
|
registry = "gitea"
|
||||||
|
version = "0"
|
||||||
@@ -1,20 +1,418 @@
|
|||||||
# wakey
|
# wakey
|
||||||
|
|
||||||
router shit
|
`wakey` is a Wake-on-LAN and LAN-observability tool for a Linux router.
|
||||||
|
|
||||||
## usage
|
It started as a small web UI running on-device. It is now being reshaped into a
|
||||||
|
service-first project with:
|
||||||
|
|
||||||
- Build (Windows host OK):
|
- a reusable core model
|
||||||
- cargo build --release --target armv7-unknown-linux-musleabihf
|
- a Linux/OpenWrt adapter layer
|
||||||
- Run on a Linux box/router:
|
- a CLI for operators
|
||||||
- copy target/armv7-unknown-linux-musleabihf/release/wakey to the device
|
- an outbound agent plus control-plane model
|
||||||
- chmod +x wakey && ./wakey
|
|
||||||
- Install via release asset (OpenWrt):
|
|
||||||
- wget -O- `https://your.gitea/owner/repo/releases/download/vX.Y.Z/wakey-rootfs-vX.Y.Z-armv7-unknown-linux-musleabihf.tgz` | tar -xz -C /
|
|
||||||
- /etc/init.d/wakey enable && /etc/init.d/wakey start
|
|
||||||
|
|
||||||
Optional updater (fetch latest automatically on OpenWrt):
|
## What it does
|
||||||
|
|
||||||
|
`wakey` currently focuses on a small set of router-side jobs:
|
||||||
|
|
||||||
|
- inspect LAN neighbor state
|
||||||
|
- read DHCP lease data
|
||||||
|
- merge those facts into a higher-level device inventory
|
||||||
|
- list useful interface/broadcast information
|
||||||
|
- send Wake-on-LAN packets
|
||||||
|
|
||||||
|
In practice that means commands like:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
WAKEY_HOST=git.ldlda.com WAKEY_OWNER=lda WAKEY_REPO=wakey sh /etc/ldlda_help/update_wakey.sh
|
wakey inventory bedroom-pc
|
||||||
|
wakey leases --include-state
|
||||||
|
wakey devs
|
||||||
|
wakey wake bedroom-pc
|
||||||
|
wakey wake --mac aa:bb:cc:dd:ee:ff
|
||||||
```
|
```
|
||||||
|
|
||||||
|
(`inventory` is the canonical subcommand; `status` is a visible alias for the same behavior.)
|
||||||
|
|
||||||
|
## Architecture at a glance
|
||||||
|
|
||||||
|
- **On-router (local):** run `wakey` for `inventory`, `leases`, `devs`, and `wake` against live Linux neighbor/DHCP/interface data.
|
||||||
|
- **Fleet (remote):** `wakey-agent` on each router enrolls with `wakey-control-plane`, keeps an authenticated WebSocket, and executes relayed commands using the same service logic as the local CLI.
|
||||||
|
|
||||||
|
## Workspace layout
|
||||||
|
|
||||||
|
- `wakey-core`
|
||||||
|
- shared types and parsing
|
||||||
|
- device, neighbor, DHCP, interface, and wake models
|
||||||
|
- `wakey-linux`
|
||||||
|
- Linux/OpenWrt adapter
|
||||||
|
- DHCP lease loading, interface summaries, neighbor lookup, WoL sending
|
||||||
|
- `wakey`
|
||||||
|
- service layer and operator CLI
|
||||||
|
- `wakey-agent`
|
||||||
|
- outbound router daemon, enrollment, websocket command execution
|
||||||
|
- `wakey-control-plane`
|
||||||
|
- enrollment endpoint, connected-agent registry, command relay API
|
||||||
|
- `ipjs`
|
||||||
|
- typed wrappers around Linux `ip -j ...` data
|
||||||
|
- JSON-first, with optional experimental netlink backends
|
||||||
|
|
||||||
|
## Current architecture
|
||||||
|
|
||||||
|
Inside the `wakey` crate:
|
||||||
|
|
||||||
|
- `src/service`
|
||||||
|
- the real use-case layer
|
||||||
|
- inventory, leases, interfaces, wake, and query resolution
|
||||||
|
|
||||||
|
The long-term direction is:
|
||||||
|
|
||||||
|
- keep the service layer stable
|
||||||
|
- keep the local CLI stable for operators
|
||||||
|
- run remote control via outbound agent + control-plane relay
|
||||||
|
|
||||||
|
## Future direction
|
||||||
|
|
||||||
|
The project now uses:
|
||||||
|
|
||||||
|
- `wakey` for local operator workflows and shared service behavior
|
||||||
|
- `wakey-agent` for outbound enrollment and websocket execution
|
||||||
|
- `wakey-control-plane` for enrollment, registry, and command relay
|
||||||
|
|
||||||
|
## Logging and troubleshooting
|
||||||
|
|
||||||
|
Both daemons emit structured tracing logs to stderr. If you are not seeing
|
||||||
|
enrollment/command activity, run with increased verbosity.
|
||||||
|
|
||||||
|
Control-plane also supports a config file at
|
||||||
|
`/etc/wakey-control-plane/config.toml` (override with `--config-file`) so you
|
||||||
|
can persist telemetry settings instead of passing flags.
|
||||||
|
|
||||||
|
You can scaffold this file with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wakey-control-plane init-config
|
||||||
|
```
|
||||||
|
|
||||||
|
Or bootstrap once during serve (writes only if config is missing):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wakey-control-plane serve --bootstrap-config
|
||||||
|
```
|
||||||
|
|
||||||
|
Inspect current persisted state:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wakey-control-plane state-stats
|
||||||
|
```
|
||||||
|
|
||||||
|
List/revoke enroll tokens from CLI:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wakey-control-plane list-enroll-tokens
|
||||||
|
wakey-control-plane revoke-enroll-token --token enr-...
|
||||||
|
wakey-control-plane revoke-agent --agent-id agent-...
|
||||||
|
```
|
||||||
|
|
||||||
|
Machine-readable output is available:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wakey-control-plane list-enroll-tokens --json
|
||||||
|
wakey-control-plane state-stats --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
data_dir = "/var/lib/wakey-control-plane"
|
||||||
|
bind = "0.0.0.0:8080"
|
||||||
|
public_url = "https://cp.example.com"
|
||||||
|
state_file = "state.sqlite3"
|
||||||
|
pid_file = "wakey-control-plane.pid"
|
||||||
|
ui_dist_dir = "/opt/wakey/ui/dist"
|
||||||
|
command_timeout_ms = 30000
|
||||||
|
enroll_token_ttl_seconds = 86400
|
||||||
|
|
||||||
|
[telemetry]
|
||||||
|
otlp_endpoint = "http://127.0.0.1:4317"
|
||||||
|
service_name = "wakey-control-plane"
|
||||||
|
json_logs = false
|
||||||
|
```
|
||||||
|
|
||||||
|
If `telemetry.otlp_endpoint` is omitted, logs still work normally and only local
|
||||||
|
structured logs are emitted.
|
||||||
|
|
||||||
|
State is persisted in an embedded SQLite database (default
|
||||||
|
`/var/lib/wakey-control-plane/state.sqlite3`).
|
||||||
|
Relative paths in config are resolved under `data_dir`.
|
||||||
|
Legacy sled state can be imported explicitly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wakey-control-plane import-sled-state --from-sled-state /var/lib/wakey-control-plane/state.db --to-state-file /var/lib/wakey-control-plane/state.sqlite3
|
||||||
|
```
|
||||||
|
|
||||||
|
Enroll tokens are now expiring and revocable. Issuance returns `expires_at_unix`.
|
||||||
|
Expired tokens are rejected on enroll and can be garbage-collected periodically
|
||||||
|
or on demand.
|
||||||
|
|
||||||
|
### Quick start
|
||||||
|
|
||||||
|
Control-plane:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wakey-control-plane -v serve --bind 0.0.0.0:8787 --public-url https://cp.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Agent:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wakey-agent -v serve --config /etc/wakey-agent/config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fine-grained log filters
|
||||||
|
|
||||||
|
Use `RUST_LOG` when you want to focus on websocket/API internals.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
RUST_LOG=wakey_control_plane=debug,wakey_agent=debug wakey-control-plane serve
|
||||||
|
RUST_LOG=wakey_agent=debug wakey-agent serve
|
||||||
|
```
|
||||||
|
|
||||||
|
### What you should see
|
||||||
|
|
||||||
|
During registration/enroll:
|
||||||
|
|
||||||
|
- control-plane: `issued enroll token`, then `agent enrollment accepted`
|
||||||
|
- agent: `starting agent enrollment`, then `agent enrollment succeeded and config was written`
|
||||||
|
|
||||||
|
During live connectivity:
|
||||||
|
|
||||||
|
- control-plane: `agent websocket upgraded`, `agent authenticated`, `agent disconnected`
|
||||||
|
- agent: `connecting agent websocket`, `agent websocket dns resolved`, `agent websocket connected`, `agent websocket session authenticated`, `heartbeat sent` (debug)
|
||||||
|
|
||||||
|
During command relay:
|
||||||
|
|
||||||
|
- control-plane: `dispatching command to agent`
|
||||||
|
- agent: `received command from control-plane`, then `command execution completed` (or `command dispatch failed`)
|
||||||
|
- control-plane: `agent command completed` (or timeout/error warnings)
|
||||||
|
|
||||||
|
During daemon control/state operations:
|
||||||
|
|
||||||
|
- control-plane: `wrote control-plane pid file`, `saved control-plane store`, `reloaded control-plane store from disk`
|
||||||
|
- agent: `wrote wakey-agent pid file`, `sending wakey-agent reload signal`
|
||||||
|
|
||||||
|
Control-plane admin API includes token management endpoints:
|
||||||
|
|
||||||
|
- `POST /api/v1/control/enroll-token?ttl_seconds=<n>`
|
||||||
|
- `GET /api/v1/control/enroll-tokens`
|
||||||
|
- `DELETE /api/v1/control/enroll-tokens/{token}`
|
||||||
|
- `GET /api/v1/control/audit/events?agent_id=<id>&event_type=<type>&limit=<n>`
|
||||||
|
- `GET /api/v1/control/alerts?lookback_seconds=900`
|
||||||
|
- `GET /api/v1/control/alerts/history?since_unix=<ts>&limit=<n>`
|
||||||
|
- `GET /api/v1/control/alerts/ws` (websocket snapshots + recent transitions)
|
||||||
|
- `DELETE /api/v1/control/agents/{agent_id}`
|
||||||
|
- `PATCH /api/v1/control/agents/{agent_id}/nickname`
|
||||||
|
|
||||||
|
If commands still appear silent, verify both processes are running with `-v`
|
||||||
|
and that `RUST_LOG` is not overriding to a stricter level.
|
||||||
|
|
||||||
|
If websocket connect feels delayed, compare `dns_resolve_ms` and `ws_connect_ms`
|
||||||
|
from agent logs. Slow DNS is a common source of multi-second connection stalls
|
||||||
|
when using hostnames; using a stable IP or local host mapping can avoid this.
|
||||||
|
|
||||||
|
## Edge Exposure (Caddy + Cloudflare Access)
|
||||||
|
|
||||||
|
Control-plane is intended to run behind a reverse proxy with TLS termination.
|
||||||
|
Use Cloudflare Access to protect `/ui/*` and `/api/v1/control/*`, while keeping
|
||||||
|
agent enrollment and websocket endpoints reachable.
|
||||||
|
|
||||||
|
An example Caddy config is provided at:
|
||||||
|
|
||||||
|
- `deploy/control-plane.Caddyfile`
|
||||||
|
|
||||||
|
Expected exposure model:
|
||||||
|
|
||||||
|
- Public: `/healthz`, `/api/v1/agents/enroll`, `/api/v1/agent/ws`
|
||||||
|
- Private (Cloudflare Access): `/ui/*`, `/api/v1/control/*`
|
||||||
|
|
||||||
|
Control-plane routing is organized with the same boundary in code:
|
||||||
|
|
||||||
|
- public router: health, enroll, agent websocket
|
||||||
|
- control router: all `/api/v1/control/*` admin endpoints
|
||||||
|
|
||||||
|
This keeps edge policy and app routing aligned as features grow.
|
||||||
|
|
||||||
|
## UI (Initial Shell)
|
||||||
|
|
||||||
|
Control-plane serves the built Operator UI at `/ui/` from `ui_dist_dir`
|
||||||
|
(defaults to `ui/dist`). Configure this in
|
||||||
|
`/etc/wakey-control-plane/config.toml` or pass `--ui-dist-dir` to `serve`.
|
||||||
|
|
||||||
|
Build UI assets before starting control-plane:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd ui
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
Then start control-plane and open `/ui/` on the same host/port.
|
||||||
|
|
||||||
|
## VPS Deploy (Manual Updates)
|
||||||
|
|
||||||
|
Suggested layout on VPS:
|
||||||
|
|
||||||
|
- `/opt/wakey/bin/wakey-control-plane`
|
||||||
|
- `/opt/wakey/ui/dist/*`
|
||||||
|
- `/etc/wakey-control-plane/config.toml` with `ui_dist_dir = "/opt/wakey/ui/dist"`
|
||||||
|
|
||||||
|
Use the provided unit template:
|
||||||
|
|
||||||
|
- `deploy/systemd/wakey-cc.service`
|
||||||
|
|
||||||
|
Install on VPS:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo install -m 0644 deploy/systemd/wakey-cc.service /etc/systemd/system/wakey-cc.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now wakey-cc.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual update helper for VPS:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
chmod +x scripts/update_wakey_cc.sh
|
||||||
|
cd /opt/wakey
|
||||||
|
sudo -E ./scripts/update_wakey_cc.sh
|
||||||
|
|
||||||
|
# optional: pin target and/or version
|
||||||
|
WAKEY_CC_TARGET=x86_64-unknown-linux-gnu sudo -E ./scripts/update_wakey_cc.sh
|
||||||
|
WAKEY_CC_VERSION=v0.2.0 WAKEY_CC_TARGET=x86_64-unknown-linux-gnu sudo -E ./scripts/update_wakey_cc.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The update tarball is expected to contain:
|
||||||
|
|
||||||
|
- `bin/wakey-control-plane`
|
||||||
|
- `ui/dist/index.html` (plus `ui/dist/assets/*`)
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
`wakey` is usable as a local/operator CLI.
|
||||||
|
|
||||||
|
### Inventory
|
||||||
|
|
||||||
|
Show merged device inventory rows using a free-form selector:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wakey inventory bedroom-pc
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use explicit filters:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wakey inventory --dev br-lan --nud reachable
|
||||||
|
wakey inventory --mac aa:bb:cc:dd:ee:ff
|
||||||
|
wakey inventory --json
|
||||||
|
```
|
||||||
|
|
||||||
|
The `status` subcommand is an alias for `inventory` (same flags and output).
|
||||||
|
|
||||||
|
### Leases
|
||||||
|
|
||||||
|
Show DHCP leases:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wakey leases
|
||||||
|
wakey leases --include-state
|
||||||
|
wakey leases --include-state --json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wake
|
||||||
|
|
||||||
|
Query mode:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wakey wake bedroom-pc
|
||||||
|
```
|
||||||
|
|
||||||
|
Explicit/manual mode:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wakey wake --mac aa:bb:cc:dd:ee:ff
|
||||||
|
wakey wake --mac aa:bb:cc:dd:ee:ff --ip 192.168.1.255
|
||||||
|
wakey wake --mac aa:bb:cc:dd:ee:ff --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- query mode and explicit `--mac/--ip` mode are mutually exclusive
|
||||||
|
- `--ip` requires `--mac`
|
||||||
|
- `--mac` without `--ip` fans out to interface broadcast targets
|
||||||
|
|
||||||
|
### Interfaces
|
||||||
|
|
||||||
|
Show condensed interface summaries:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
wakey devs
|
||||||
|
wakey devs br-lan
|
||||||
|
wakey devs --up
|
||||||
|
wakey devs --json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
This repo has two useful testing modes:
|
||||||
|
|
||||||
|
- local compile checks
|
||||||
|
- live on-device tests against the real router/runtime environment
|
||||||
|
|
||||||
|
### Local
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo check
|
||||||
|
cargo test --no-run
|
||||||
|
cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
On GitHub Actions (`.github/workflows/ci.yml`), the same Rust checks run on
|
||||||
|
`ubuntu-latest`, plus a separate **`ui`** job: `pnpm install --frozen-lockfile`
|
||||||
|
in `ui/`, then `typecheck`, `format:check`, and `vite build`.
|
||||||
|
|
||||||
|
Focused control-plane state tests:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test -p wakey-control-plane state::store::tests::gc_removes_expired_tokens
|
||||||
|
cargo test -p wakey-control-plane state::store::tests::enroll_rejects_expired_token
|
||||||
|
cargo test -p wakey-control-plane state::store::tests::stats_counts_agents_and_expired_tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
### On-device
|
||||||
|
|
||||||
|
Some integration tests are intentionally `#[ignore]` because they use real
|
||||||
|
router state. Use the PowerShell helper:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./scripts/test_remote.ps1 -Package wakey -BinaryFilter integration_live_services -Ignored -NoCapture
|
||||||
|
./scripts/test_remote.ps1 -Package wakey -BinaryFilter integration_inventory -Ignored -NoCapture
|
||||||
|
```
|
||||||
|
|
||||||
|
You can further narrow execution with `-Filter` to select individual Rust test
|
||||||
|
functions inside a test binary.
|
||||||
|
|
||||||
|
## Build target
|
||||||
|
|
||||||
|
This project is primarily aimed at an OpenWrt/Linux ARM router target. The
|
||||||
|
workspace is commonly built for:
|
||||||
|
|
||||||
|
```text
|
||||||
|
armv7-unknown-linux-musleabihf
|
||||||
|
```
|
||||||
|
|
||||||
|
Some crates and tests are Linux-specific by design.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `ipjs` is JSON-first by default; experimental netlink paths exist where they
|
||||||
|
are worth keeping.
|
||||||
|
- the current web client is temporary and kept alive through explicit
|
||||||
|
compatibility mapping
|
||||||
|
- the service layer is the part intended to survive the migration
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Caddy template for wakey-control-plane with Cloudflare Access.
|
||||||
|
#
|
||||||
|
# Security model:
|
||||||
|
# - Public endpoints for agents: /healthz, /api/v1/agents/enroll, /api/v1/agent/ws
|
||||||
|
# - Private admin surface: /ui/* and /api/v1/control/* (requires CF Access headers)
|
||||||
|
#
|
||||||
|
# Replace cp.example.com with your public control-plane domain.
|
||||||
|
|
||||||
|
wakey.ldlda.com {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
# Public agent-facing endpoints.
|
||||||
|
@public path /healthz /api/v1/agents/enroll /api/v1/agent/ws
|
||||||
|
handle @public {
|
||||||
|
reverse_proxy 127.0.0.1:6767
|
||||||
|
}
|
||||||
|
|
||||||
|
# Admin surface requires Cloudflare Access headers.
|
||||||
|
@admin path /ui* /api/v1/control/*
|
||||||
|
@cf_access header_regexp CFJWT Cf-Access-Jwt-Assertion .+
|
||||||
|
|
||||||
|
handle @admin {
|
||||||
|
handle @cf_access {
|
||||||
|
reverse_proxy 127.0.0.1:6767
|
||||||
|
}
|
||||||
|
respond "forbidden" 403
|
||||||
|
}
|
||||||
|
|
||||||
|
# Deny unknown paths by default.
|
||||||
|
respond "not found" 404
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Wakey Control Plane
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=lda
|
||||||
|
Group=lda
|
||||||
|
WorkingDirectory=/opt/wakey
|
||||||
|
ExecStart=/opt/wakey/bin/wakey-control-plane serve --config-file /etc/wakey-control-plane/config.toml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
Environment=RUST_LOG=info
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
server_url = "https://control-plane.example.com"
|
||||||
|
agent_id = "REPLACE_ME_AGENT_ID"
|
||||||
|
agent_token = "REPLACE_ME_AGENT_TOKEN"
|
||||||
|
reconnect_base_ms = 1000
|
||||||
|
reconnect_max_ms = 30000
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
data_dir = "/var/lib/wakey-control-plane"
|
||||||
|
bind = "0.0.0.0:8080"
|
||||||
|
public_url = "http://127.0.0.1:8080"
|
||||||
|
state_file = "state.db"
|
||||||
|
command_timeout_ms = 30000
|
||||||
|
enroll_token_ttl_seconds = 86400
|
||||||
|
pid_file = "wakey-control-plane.pid"
|
||||||
|
|
||||||
|
[telemetry]
|
||||||
|
service_name = "wakey-control-plane"
|
||||||
|
json_logs = false
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
[package]
|
||||||
|
name = "lda-ipjs"
|
||||||
|
description = "ip -j show schemas"
|
||||||
|
version = "0.0.4"
|
||||||
|
edition = "2024"
|
||||||
|
publish = ["gitea"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
experimental-nl = ["dep:rtnetlink"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
macaddr = { version = "1", features = ["serde", "serde_std"] }
|
||||||
|
strum = { version = "0", features = ["derive", "strum_macros"] }
|
||||||
|
serde_json = "1"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_with = { version = "3", features = ["json"] }
|
||||||
|
thiserror = "2"
|
||||||
|
anyhow = "1"
|
||||||
|
tokio = { version = "1", features = [
|
||||||
|
"fs",
|
||||||
|
"process",
|
||||||
|
"rt-multi-thread",
|
||||||
|
"io-util",
|
||||||
|
"macros",
|
||||||
|
] }
|
||||||
|
futures = "0"
|
||||||
|
|
||||||
|
|
||||||
|
[target.'cfg(unix)'.dependencies]
|
||||||
|
rtnetlink = { version = "0", optional = true }
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# lda-ipjs
|
||||||
|
|
||||||
|
Typed Rust wrappers around Linux `ip -j ...` output, with optional experimental rtnetlink backends.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
`lda-ipjs` exists so `wakey` can ask Linux networking questions in typed Rust instead of:
|
||||||
|
|
||||||
|
- parsing plain-text shell output
|
||||||
|
- scattering `tokio::process::Command::new("ip")` everywhere
|
||||||
|
- mixing product logic with Linux networking trivia
|
||||||
|
|
||||||
|
## Current contract
|
||||||
|
|
||||||
|
Stable default behavior:
|
||||||
|
|
||||||
|
- `address::get(...)` uses JSON (`ip -j address show`)
|
||||||
|
- `link::get(...)` uses JSON (`ip -j link show`)
|
||||||
|
- `neighbor::get(...)` uses JSON (`ip -j neigh show`)
|
||||||
|
|
||||||
|
Optional experimental behavior:
|
||||||
|
|
||||||
|
- feature: `experimental-nl`
|
||||||
|
- enables rtnetlink-backed implementations
|
||||||
|
- intended for places where one-pass kernel queries are materially better than repeated `ip -j` calls
|
||||||
|
|
||||||
|
This means the public API is:
|
||||||
|
|
||||||
|
- `get(...)` for the default backend
|
||||||
|
- `get_with_backend(Backend::Json | Backend::Netlink)` when backend choice matters
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
- `subcommands::address`
|
||||||
|
- typed address/interface-address data
|
||||||
|
- good place for subnet/broadcast derivation later
|
||||||
|
- `subcommands::link`
|
||||||
|
- typed link/interface data
|
||||||
|
- useful for `ifindex -> ifname` mapping and interface metadata
|
||||||
|
- `subcommands::neighbor`
|
||||||
|
- typed neighbor-table data
|
||||||
|
- currently the most useful experimental netlink surface
|
||||||
|
|
||||||
|
## Backend policy
|
||||||
|
|
||||||
|
JSON is the normal path.
|
||||||
|
|
||||||
|
Use netlink only when:
|
||||||
|
|
||||||
|
- the call is hot enough to matter
|
||||||
|
- repeated shelling out is obviously wasteful
|
||||||
|
- the netlink implementation is at least as coherent as the JSON one
|
||||||
|
|
||||||
|
Today that mainly applies to `neighbor::nl`.
|
||||||
|
|
||||||
|
## Relationship to wakey
|
||||||
|
|
||||||
|
`wakey` is the product.
|
||||||
|
|
||||||
|
`lda-ipjs` is a Linux networking adapter crate underneath it.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
trait IpCommand {
|
||||||
|
|
||||||
|
}
|
||||||
Whitespace-only changes.
@@ -0,0 +1,19 @@
|
|||||||
|
//! low
|
||||||
|
//!
|
||||||
|
//! # lda-ipjs
|
||||||
|
//!
|
||||||
|
//! this package will represent all my needs with the all the subcommands of ip -j.
|
||||||
|
//!
|
||||||
|
//! ## what i need
|
||||||
|
//!
|
||||||
|
//! ```console
|
||||||
|
//! ip -j neigh show
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! i also need to see devices and idk MAYBE maybe not MAYBE UHHHHHH maybe broadcast
|
||||||
|
//!
|
||||||
|
//! LOWK if this were to be calls to kernel or some bullshit then PLEASE because doing ts parsing its hell cuh
|
||||||
|
pub mod subcommands;
|
||||||
|
pub mod utils;
|
||||||
|
|
||||||
|
// i want a generalized way to build and call
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// ip address [ show [ dev IFNAME ] [ scope SCOPE-ID ] [ master DEVICE ]
|
||||||
|
// [ type TYPE ] [ to PREFIX ] [ FLAG-LIST ]
|
||||||
|
// [ label LABEL ] [up] [ vrf NAME ] ]
|
||||||
|
// fuck is this mean
|
||||||
|
|
||||||
|
// do i need all this? do i need anything but `ip -j a show dev br-lan`?
|
||||||
|
|
||||||
|
// TYPE := { vlan | veth | vcan | vxcan | dummy | ifb | macvlan | macvtap |
|
||||||
|
// bridge | bond | ipoib | ip6tnl | ipip | sit | vxlan | lowpan |
|
||||||
|
// gre | gretap | erspan | ip6gre | ip6gretap | ip6erspan | vti |
|
||||||
|
// nlmon | can | bond_slave | ipvlan | geneve | bridge_slave |
|
||||||
|
// hsr | macsec | netdevsim }
|
||||||
|
// FLAG-LIST := [ FLAG-LIST ] FLAG
|
||||||
|
// FLAG := [ permanent | dynamic | secondary | primary |
|
||||||
|
// [-]tentative | [-]deprecated | [-]dadfailed | temporary |
|
||||||
|
// CONFFLAG-LIST ]
|
||||||
|
// CONFFLAG-LIST := [ CONFFLAG-LIST ] CONFFLAG
|
||||||
|
// CONFFLAG := [ home | nodad | mngtmpaddr | noprefixroute | autojoin ]
|
||||||
|
|
||||||
|
// prefix seems to be a cidr. both 6 and 4 works. idfk dog
|
||||||
|
|
||||||
|
use std::{io, process::Output};
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
|
||||||
|
use super::AddrOutput;
|
||||||
|
|
||||||
|
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<AddrOutput>> {
|
||||||
|
let output = _get(dev).await.context("Can not run command")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
anyhow::bail!(String::from_utf8_lossy(&output.stderr).into_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::from_slice(&output.stdout).context("Deserialize failed")
|
||||||
|
}
|
||||||
|
pub async fn _get(dev: Option<&str>) -> io::Result<Output> {
|
||||||
|
let mut cmd = tokio::process::Command::new("ip");
|
||||||
|
cmd.args(["-j", "address", "show"]);
|
||||||
|
|
||||||
|
if let Some(d) = dev {
|
||||||
|
cmd.args(["dev", d]);
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.output().await
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
//! Typed wrappers for `ip -j address show`.
|
||||||
|
//!
|
||||||
|
//! This module is intentionally close to the Linux output shape while still
|
||||||
|
//! tightening a few fields into more useful Rust types.
|
||||||
|
|
||||||
|
pub mod json;
|
||||||
|
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||||
|
pub mod nl;
|
||||||
|
|
||||||
|
pub use crate::subcommands::Backend;
|
||||||
|
use crate::utils::serialize::mac::option_mac;
|
||||||
|
use macaddr::MacAddr;
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
|
use std::fmt;
|
||||||
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
|
|
||||||
|
use crate::subcommands::link::OperState;
|
||||||
|
|
||||||
|
/// One interface row from `ip -j address show`.
|
||||||
|
#[derive(Serialize, Debug, Deserialize)]
|
||||||
|
pub struct AddrOutput {
|
||||||
|
pub ifindex: u32,
|
||||||
|
pub ifname: String,
|
||||||
|
/// Interface operational state.
|
||||||
|
pub operstate: OperState,
|
||||||
|
#[serde(with = "option_mac", default)]
|
||||||
|
pub address: Option<MacAddr>,
|
||||||
|
/// Per-address entries attached to this interface.
|
||||||
|
#[serde(default)]
|
||||||
|
pub addr_info: Vec<AddrInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One address entry nested under an interface row.
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct AddrInfo {
|
||||||
|
pub family: Option<AddressFamily>,
|
||||||
|
|
||||||
|
#[serde(flatten, default)]
|
||||||
|
pub cidr: InterfaceCidr,
|
||||||
|
|
||||||
|
pub broadcast: Option<Ipv4Addr>,
|
||||||
|
|
||||||
|
pub scope: Option<String>,
|
||||||
|
pub label: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Address family used by `ip address` output.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AddressFamily {
|
||||||
|
Inet,
|
||||||
|
Inet6,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AddressFamily {
|
||||||
|
pub fn parse_lossy(value: &str) -> Self {
|
||||||
|
match value.to_ascii_lowercase().as_str() {
|
||||||
|
"inet" => Self::Inet,
|
||||||
|
"inet6" => Self::Inet6,
|
||||||
|
_ => Self::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Inet => "inet",
|
||||||
|
Self::Inet6 => "inet6",
|
||||||
|
Self::Other => "other",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for AddressFamily {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for AddressFamily {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let value = String::deserialize(deserializer)?;
|
||||||
|
Ok(Self::parse_lossy(&value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw parsed local address plus prefix length.
|
||||||
|
///
|
||||||
|
/// This is still a source-shaped type; callers that need a guaranteed usable
|
||||||
|
/// CIDR should validate that both fields are present.
|
||||||
|
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct InterfaceCidr {
|
||||||
|
pub local: Option<IpAddr>,
|
||||||
|
pub prefixlen: Option<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InterfaceCidr {
|
||||||
|
/// Return whether both parts needed for a usable CIDR are present.
|
||||||
|
pub fn is_complete(&self) -> bool {
|
||||||
|
self.local.is_some() && self.prefixlen.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a validated complete CIDR when both fields are present.
|
||||||
|
pub fn complete(&self) -> Option<CompleteInterfaceCidr> {
|
||||||
|
Some(CompleteInterfaceCidr {
|
||||||
|
local: self.local?,
|
||||||
|
prefixlen: self.prefixlen?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format the CIDR as `addr/prefixlen` when both fields are present.
|
||||||
|
pub fn to_cidr_string(&self) -> Option<String> {
|
||||||
|
self.complete().map(|cidr| cidr.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validated interface CIDR with both local address and prefix length present.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct CompleteInterfaceCidr {
|
||||||
|
pub local: IpAddr,
|
||||||
|
pub prefixlen: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for CompleteInterfaceCidr {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}/{}", self.local, self.prefixlen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AddrInfo {
|
||||||
|
/// Return the parsed local IP address when present.
|
||||||
|
pub fn local_addr(&self) -> Option<IpAddr> {
|
||||||
|
self.cidr.local
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the parsed prefix length when present.
|
||||||
|
pub fn prefixlen(&self) -> Option<u8> {
|
||||||
|
self.cidr.prefixlen
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return whether this row is IPv4.
|
||||||
|
pub fn is_ipv4(&self) -> bool {
|
||||||
|
matches!(self.family, Some(AddressFamily::Inet))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return whether this row is IPv6.
|
||||||
|
pub fn is_ipv6(&self) -> bool {
|
||||||
|
matches!(self.family, Some(AddressFamily::Inet6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AddrOutput {
|
||||||
|
/// Iterate IPv4 address entries.
|
||||||
|
pub fn ipv4_addrs(&self) -> impl Iterator<Item = &AddrInfo> {
|
||||||
|
self.addr_info.iter().filter(|info| info.is_ipv4())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterate IPv6 address entries.
|
||||||
|
pub fn ipv6_addrs(&self) -> impl Iterator<Item = &AddrInfo> {
|
||||||
|
self.addr_info.iter().filter(|info| info.is_ipv6())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch address data using the default backend.
|
||||||
|
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<AddrOutput>> {
|
||||||
|
get_with_backend(Backend::Json, dev).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch address data using an explicit backend.
|
||||||
|
pub async fn get_with_backend(
|
||||||
|
backend: Backend,
|
||||||
|
dev: Option<&str>,
|
||||||
|
) -> anyhow::Result<Vec<AddrOutput>> {
|
||||||
|
match backend {
|
||||||
|
Backend::Json => json::get(dev).await,
|
||||||
|
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||||
|
Backend::Netlink => nl::get(dev).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
|
|
||||||
|
use super::{AddrInfo, AddrOutput, AddressFamily, InterfaceCidr};
|
||||||
|
use crate::subcommands::link::OperState;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn address_family_parses_known_values() {
|
||||||
|
assert_eq!(AddressFamily::parse_lossy("inet"), AddressFamily::Inet);
|
||||||
|
assert_eq!(AddressFamily::parse_lossy("INET6"), AddressFamily::Inet6);
|
||||||
|
assert_eq!(AddressFamily::parse_lossy("weird"), AddressFamily::Other);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn addr_info_deserializes_typed_ip_fields() {
|
||||||
|
let info: AddrInfo = serde_json::from_str(
|
||||||
|
r#"{"family":"inet","local":"192.168.1.1","prefixlen":24,"broadcast":"192.168.1.255","scope":"global"}"#,
|
||||||
|
)
|
||||||
|
.expect("addr_info json should deserialize");
|
||||||
|
|
||||||
|
assert_eq!(info.family, Some(AddressFamily::Inet));
|
||||||
|
assert_eq!(
|
||||||
|
info.local_addr(),
|
||||||
|
Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)))
|
||||||
|
);
|
||||||
|
assert_eq!(info.prefixlen(), Some(24));
|
||||||
|
assert_eq!(info.broadcast, Some(Ipv4Addr::new(192, 168, 1, 255)));
|
||||||
|
assert!(info.cidr.is_complete());
|
||||||
|
assert_eq!(
|
||||||
|
info.cidr.to_cidr_string().as_deref(),
|
||||||
|
Some("192.168.1.1/24")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn addr_output_deserializes_typed_operstate() {
|
||||||
|
let output: AddrOutput = serde_json::from_str(
|
||||||
|
r#"{"ifindex":2,"ifname":"br-lan","operstate":"UP","address":"aa:bb:cc:dd:ee:ff","addr_info":[]}"#,
|
||||||
|
)
|
||||||
|
.expect("addr_output json should deserialize");
|
||||||
|
|
||||||
|
assert_eq!(output.operstate, OperState::Up);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interface_cidr_complete_formats() {
|
||||||
|
let cidr = InterfaceCidr {
|
||||||
|
local: Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))),
|
||||||
|
prefixlen: Some(16),
|
||||||
|
};
|
||||||
|
|
||||||
|
let complete = cidr.complete().expect("cidr should be complete");
|
||||||
|
assert_eq!(complete.to_string(), "10.0.0.1/16");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
//! i said i aint doing ts no more why am i still here
|
||||||
|
#![cfg(unix)]
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use futures::TryStreamExt;
|
||||||
|
use rtnetlink::Handle;
|
||||||
|
use rtnetlink::packet_route::{AddressFamily, address::AddressAttribute};
|
||||||
|
|
||||||
|
use crate::subcommands::link::LinkOutput;
|
||||||
|
use crate::subcommands::{
|
||||||
|
address::{AddrInfo, AddrOutput, AddressFamily as IpAddressFamily, InterfaceCidr},
|
||||||
|
link,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<AddrOutput>> {
|
||||||
|
let (conn, handle, _) = rtnetlink::new_connection()?;
|
||||||
|
tokio::spawn(conn);
|
||||||
|
|
||||||
|
get_with_handle(&handle, dev).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_with_handle(
|
||||||
|
handle: &Handle,
|
||||||
|
dev: Option<&str>,
|
||||||
|
) -> anyhow::Result<Vec<AddrOutput>> {
|
||||||
|
let links = link::nl::get_with_handle(handle, dev).await?;
|
||||||
|
let link_by_index: BTreeMap<u32, LinkOutput> =
|
||||||
|
links.into_iter().map(|link| (link.ifindex, link)).collect();
|
||||||
|
|
||||||
|
let mut address = handle.address().get();
|
||||||
|
if let Some(dev) = dev {
|
||||||
|
if let Some(index) = link_by_index
|
||||||
|
.values()
|
||||||
|
.find(|link| link.ifname == dev)
|
||||||
|
.map(|link| link.ifindex)
|
||||||
|
{
|
||||||
|
address = address.set_link_index_filter(index);
|
||||||
|
} else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stream = address.execute();
|
||||||
|
let mut addr_info_by_index: BTreeMap<u32, Vec<AddrInfo>> = BTreeMap::new();
|
||||||
|
|
||||||
|
while let Some(msg) = stream.try_next().await? {
|
||||||
|
if !matches!(
|
||||||
|
msg.header.family,
|
||||||
|
AddressFamily::Inet | AddressFamily::Inet6
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let family = match msg.header.family {
|
||||||
|
AddressFamily::Inet => Some(IpAddressFamily::Inet),
|
||||||
|
AddressFamily::Inet6 => Some(IpAddressFamily::Inet6),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let prefixlen = Some(msg.header.prefix_len);
|
||||||
|
let scope = Some(format!("{:?}", msg.header.scope).to_lowercase());
|
||||||
|
let mut local = None;
|
||||||
|
let mut broadcast = None;
|
||||||
|
let mut label = None;
|
||||||
|
|
||||||
|
for attr in msg.attributes {
|
||||||
|
match attr {
|
||||||
|
AddressAttribute::Address(addr) | AddressAttribute::Local(addr)
|
||||||
|
if local.is_none() =>
|
||||||
|
{
|
||||||
|
local = addr.to_string().parse().ok();
|
||||||
|
}
|
||||||
|
AddressAttribute::Broadcast(addr) => {
|
||||||
|
broadcast = addr.to_string().parse().ok();
|
||||||
|
}
|
||||||
|
AddressAttribute::Label(name) => {
|
||||||
|
label = Some(name);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addr_info_by_index
|
||||||
|
.entry(msg.header.index)
|
||||||
|
.or_default()
|
||||||
|
.push(AddrInfo {
|
||||||
|
family,
|
||||||
|
cidr: InterfaceCidr { local, prefixlen },
|
||||||
|
broadcast,
|
||||||
|
scope,
|
||||||
|
label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = link_by_index
|
||||||
|
.into_values()
|
||||||
|
.map(|link| AddrOutput {
|
||||||
|
ifindex: link.ifindex,
|
||||||
|
ifname: link.ifname,
|
||||||
|
operstate: link.operstate.unwrap_or(link::OperState::Unknown),
|
||||||
|
address: link.address,
|
||||||
|
addr_info: addr_info_by_index.remove(&link.ifindex).unwrap_or_default(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
use std::{io, process::Output};
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
|
||||||
|
use super::LinkOutput;
|
||||||
|
|
||||||
|
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<LinkOutput>> {
|
||||||
|
let output = _get(dev).await.context("Can not run command")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
anyhow::bail!(String::from_utf8_lossy(&output.stderr).into_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::from_slice(&output.stdout).context("Deserialize failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn _get(dev: Option<&str>) -> io::Result<Output> {
|
||||||
|
let mut cmd = tokio::process::Command::new("ip");
|
||||||
|
cmd.args(["-j", "link", "show"]);
|
||||||
|
|
||||||
|
if let Some(d) = dev {
|
||||||
|
cmd.arg(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.output().await
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
//! Typed wrappers for `ip -j link show`.
|
||||||
|
|
||||||
|
pub mod json;
|
||||||
|
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||||
|
pub mod nl;
|
||||||
|
|
||||||
|
pub use crate::subcommands::Backend;
|
||||||
|
use crate::utils::serialize::mac::option_mac;
|
||||||
|
use macaddr::MacAddr;
|
||||||
|
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||||
|
use rtnetlink::packet_route::link::State as NetlinkOperState;
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
|
|
||||||
|
/// One interface row from `ip -j link show`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LinkOutput {
|
||||||
|
pub ifindex: u32,
|
||||||
|
pub ifname: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub operstate: Option<OperState>,
|
||||||
|
#[serde(default, with = "option_mac")]
|
||||||
|
pub address: Option<MacAddr>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Operational state of a Linux network interface.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum OperState {
|
||||||
|
Up,
|
||||||
|
Down,
|
||||||
|
Unknown,
|
||||||
|
Dormant,
|
||||||
|
LowerLayerDown,
|
||||||
|
NotPresent,
|
||||||
|
Testing,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OperState {
|
||||||
|
pub fn parse_lossy(value: &str) -> Self {
|
||||||
|
match value.to_ascii_uppercase().as_str() {
|
||||||
|
"UP" => Self::Up,
|
||||||
|
"DOWN" => Self::Down,
|
||||||
|
"UNKNOWN" => Self::Unknown,
|
||||||
|
"DORMANT" => Self::Dormant,
|
||||||
|
"LOWERLAYERDOWN" | "LOWER_LAYER_DOWN" | "LOWERLAYER_DOWN" => Self::LowerLayerDown,
|
||||||
|
"NOTPRESENT" | "NOT_PRESENT" => Self::NotPresent,
|
||||||
|
"TESTING" => Self::Testing,
|
||||||
|
_ => Self::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Up => "UP",
|
||||||
|
Self::Down => "DOWN",
|
||||||
|
Self::Unknown => "UNKNOWN",
|
||||||
|
Self::Dormant => "DORMANT",
|
||||||
|
Self::LowerLayerDown => "LOWERLAYERDOWN",
|
||||||
|
Self::NotPresent => "NOTPRESENT",
|
||||||
|
Self::Testing => "TESTING",
|
||||||
|
Self::Other => "OTHER",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_up(self) -> bool {
|
||||||
|
matches!(self, Self::Up)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for OperState {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for OperState {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let value = String::deserialize(deserializer)?;
|
||||||
|
Ok(Self::parse_lossy(&value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||||
|
impl From<NetlinkOperState> for OperState {
|
||||||
|
fn from(value: NetlinkOperState) -> Self {
|
||||||
|
match value {
|
||||||
|
NetlinkOperState::Up => Self::Up,
|
||||||
|
NetlinkOperState::Down => Self::Down,
|
||||||
|
NetlinkOperState::Unknown => Self::Unknown,
|
||||||
|
NetlinkOperState::Dormant => Self::Dormant,
|
||||||
|
NetlinkOperState::LowerLayerDown => Self::LowerLayerDown,
|
||||||
|
NetlinkOperState::NotPresent => Self::NotPresent,
|
||||||
|
NetlinkOperState::Testing => Self::Testing,
|
||||||
|
_ => Self::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch link rows using the default backend.
|
||||||
|
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<LinkOutput>> {
|
||||||
|
get_with_backend(Backend::Json, dev).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch link rows using an explicit backend.
|
||||||
|
pub async fn get_with_backend(
|
||||||
|
backend: Backend,
|
||||||
|
dev: Option<&str>,
|
||||||
|
) -> anyhow::Result<Vec<LinkOutput>> {
|
||||||
|
match backend {
|
||||||
|
Backend::Json => json::get(dev).await,
|
||||||
|
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||||
|
Backend::Netlink => nl::get(dev).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{LinkOutput, OperState};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn operstate_parses_json_and_netlink_spellings() {
|
||||||
|
assert_eq!(OperState::parse_lossy("UP"), OperState::Up);
|
||||||
|
assert_eq!(OperState::parse_lossy("Up"), OperState::Up);
|
||||||
|
assert_eq!(
|
||||||
|
OperState::parse_lossy("LOWERLAYERDOWN"),
|
||||||
|
OperState::LowerLayerDown
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
OperState::parse_lossy("LowerLayerDown"),
|
||||||
|
OperState::LowerLayerDown
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn link_output_deserializes_typed_operstate() {
|
||||||
|
let link: LinkOutput = serde_json::from_str(
|
||||||
|
r#"{"ifindex":1,"ifname":"eth0","operstate":"UP","address":"aa:bb:cc:dd:ee:ff"}"#,
|
||||||
|
)
|
||||||
|
.expect("link json should deserialize");
|
||||||
|
|
||||||
|
assert_eq!(link.operstate, Some(OperState::Up));
|
||||||
|
assert_eq!(
|
||||||
|
link.address
|
||||||
|
.map(|mac| mac.to_string().to_ascii_lowercase())
|
||||||
|
.as_deref(),
|
||||||
|
Some("aa:bb:cc:dd:ee:ff")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#![cfg(unix)]
|
||||||
|
|
||||||
|
use futures::TryStreamExt;
|
||||||
|
use rtnetlink::Handle;
|
||||||
|
use rtnetlink::packet_route::link::LinkAttribute;
|
||||||
|
|
||||||
|
use super::LinkOutput;
|
||||||
|
|
||||||
|
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<LinkOutput>> {
|
||||||
|
let (conn, handle, _) = rtnetlink::new_connection()?;
|
||||||
|
tokio::spawn(conn);
|
||||||
|
|
||||||
|
get_with_handle(&handle, dev).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_with_handle(
|
||||||
|
handle: &Handle,
|
||||||
|
dev: Option<&str>,
|
||||||
|
) -> anyhow::Result<Vec<LinkOutput>> {
|
||||||
|
let mut req = handle.link().get();
|
||||||
|
if let Some(dev) = dev {
|
||||||
|
req = req.match_name(dev.to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stream = req.execute();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
|
||||||
|
while let Some(link) = stream.try_next().await? {
|
||||||
|
let mut ifname = None;
|
||||||
|
let mut operstate = None;
|
||||||
|
let mut address = None;
|
||||||
|
|
||||||
|
for attr in link.attributes {
|
||||||
|
match attr {
|
||||||
|
LinkAttribute::IfName(name) => ifname = Some(name),
|
||||||
|
LinkAttribute::Address(bytes) => {
|
||||||
|
address = match bytes.len() {
|
||||||
|
6 => bytes.first_chunk::<6>().map(|&b| macaddr::MacAddr::from(b)),
|
||||||
|
8 => bytes.first_chunk::<8>().map(|&b| macaddr::MacAddr::from(b)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LinkAttribute::OperState(state) => operstate = Some(state.into()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ifname) = ifname {
|
||||||
|
out.push(LinkOutput {
|
||||||
|
ifindex: link.header.index,
|
||||||
|
ifname,
|
||||||
|
operstate,
|
||||||
|
address,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
pub mod address;
|
||||||
|
pub mod link;
|
||||||
|
pub mod neighbor;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Backend {
|
||||||
|
Json,
|
||||||
|
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||||
|
Netlink,
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
//! idk what to put here
|
||||||
|
|
||||||
|
use std::{io, net::IpAddr, process::Output};
|
||||||
|
|
||||||
|
use anyhow::{Context, bail};
|
||||||
|
|
||||||
|
use super::{NUDState, NeighborItem};
|
||||||
|
|
||||||
|
// loose translation of [wakey::utils::query::macs::get_mac]
|
||||||
|
// i think ill write tokio::process every time tho (for this if let thing) because iterate through all ts youll have to as str and all the hooplas.
|
||||||
|
// it all turns to live osstr tho so ts just for my own sanity
|
||||||
|
// thiserror? anyhow
|
||||||
|
|
||||||
|
// what is vro sayin
|
||||||
|
// ahh. instead of using [wakey::utils::cmd::exec_command] which is ass we jus write everything out. so i dont have to .as_str() so often.
|
||||||
|
|
||||||
|
pub async fn get(
|
||||||
|
ip: Option<IpAddr>,
|
||||||
|
dev: Option<&str>,
|
||||||
|
nud: &[NUDState],
|
||||||
|
) -> anyhow::Result<Vec<NeighborItem>> {
|
||||||
|
let output = _get(ip, dev, nud).await.context("Can not run command")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
bail!(String::from_utf8_lossy(&output.stderr).into_owned())
|
||||||
|
} else {
|
||||||
|
let mut fuckass: Vec<NeighborItem> =
|
||||||
|
serde_json::from_slice(&output.stdout).context("Deserialize failed")?;
|
||||||
|
|
||||||
|
// i hate ts.
|
||||||
|
if let Some(dev) = dev {
|
||||||
|
for item in &mut fuckass {
|
||||||
|
if item.dev.is_none() {
|
||||||
|
item.dev = Some(dev.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(fuckass)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn _get(ip: Option<IpAddr>, dev: Option<&str>, nud: &[NUDState]) -> io::Result<Output> {
|
||||||
|
let mut cmd = tokio::process::Command::new("ip");
|
||||||
|
cmd.args(["-j", "neigh", "show"]);
|
||||||
|
|
||||||
|
if let Some(ip) = ip {
|
||||||
|
// cmd.arg("to");
|
||||||
|
cmd.arg(ip.to_canonical().to_string());
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(dev) = dev {
|
||||||
|
cmd.args(["dev", dev]);
|
||||||
|
}
|
||||||
|
for nud in nud {
|
||||||
|
cmd.arg("nud");
|
||||||
|
cmd.arg(nud.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.output().await
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
//! Typed wrappers for `ip -j neigh show`.
|
||||||
|
|
||||||
|
pub mod json;
|
||||||
|
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||||
|
pub mod nl;
|
||||||
|
|
||||||
|
pub use crate::subcommands::Backend;
|
||||||
|
use crate::utils::serialize::mac::option_mac;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use macaddr::MacAddr;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use strum::{Display, EnumString};
|
||||||
|
|
||||||
|
/// Structured neighbor query input matching the common `ip neigh` flags.
|
||||||
|
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct NeighborInput {
|
||||||
|
/// Destination address filter. The optional `to` keyword in CLI form is implicit here.
|
||||||
|
pub to: Option<IpAddr>,
|
||||||
|
/// Interface-name filter.
|
||||||
|
pub dev: Option<String>,
|
||||||
|
/// Neighbor-state filters.
|
||||||
|
pub nud: Vec<NUDState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linux neighbor reachability states.
|
||||||
|
#[derive(
|
||||||
|
Debug, PartialEq, Eq, EnumString, Display, Clone, Copy, Hash, Serialize, Deserialize, Default,
|
||||||
|
)]
|
||||||
|
#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
|
||||||
|
#[serde(rename_all = "UPPERCASE")]
|
||||||
|
pub enum NUDState {
|
||||||
|
/// the neighbour entry is valid forever and can
|
||||||
|
/// be only be removed administratively.
|
||||||
|
Permanent,
|
||||||
|
/// the neighbour entry is valid. No attempts to
|
||||||
|
/// validate this entry will be made but it can
|
||||||
|
/// be removed when its lifetime expires.
|
||||||
|
Noarp,
|
||||||
|
/// the neighbour entry is valid until the
|
||||||
|
/// reachability timeout expires.
|
||||||
|
Reachable,
|
||||||
|
/// the neighbour entry is valid but suspicious.
|
||||||
|
/// This option to ip neigh does not change the
|
||||||
|
/// neighbour state if it was valid and the
|
||||||
|
/// address is not changed by this command.
|
||||||
|
Stale,
|
||||||
|
/// this is a pseudo state used when initially
|
||||||
|
/// creating a neighbour entry or after trying to
|
||||||
|
/// remove it before it becomes free to do so.
|
||||||
|
#[default]
|
||||||
|
None,
|
||||||
|
/// the neighbour entry has not (yet) been
|
||||||
|
/// validated/resolved.
|
||||||
|
Incomplete,
|
||||||
|
/// neighbor entry validation is currently
|
||||||
|
/// delayed.
|
||||||
|
Delay,
|
||||||
|
/// neighbor is being probed.
|
||||||
|
Probe,
|
||||||
|
/// max number of probes exceeded without
|
||||||
|
/// success, neighbor validation has ultimately
|
||||||
|
/// failed.
|
||||||
|
Failed,
|
||||||
|
|
||||||
|
Other(u16),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One neighbor row from `ip -j neigh show`.
|
||||||
|
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct NeighborItem {
|
||||||
|
#[serde(rename(deserialize = "dst"))]
|
||||||
|
pub ip: IpAddr,
|
||||||
|
#[serde(default)]
|
||||||
|
pub dev: Option<String>,
|
||||||
|
#[serde(with = "option_mac", default, rename(deserialize = "lladdr"))]
|
||||||
|
pub mac: Option<MacAddr>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub state: Vec<NUDState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch neighbor rows using the default backend.
|
||||||
|
pub async fn get(
|
||||||
|
ip: Option<IpAddr>,
|
||||||
|
dev: Option<&str>,
|
||||||
|
nud: &[NUDState],
|
||||||
|
) -> anyhow::Result<Vec<NeighborItem>> {
|
||||||
|
get_with_backend(Backend::Json, ip, dev, nud).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch neighbor rows using an explicit backend.
|
||||||
|
pub async fn get_with_backend(
|
||||||
|
backend: Backend,
|
||||||
|
ip: Option<IpAddr>,
|
||||||
|
dev: Option<&str>,
|
||||||
|
nud: &[NUDState],
|
||||||
|
) -> anyhow::Result<Vec<NeighborItem>> {
|
||||||
|
match backend {
|
||||||
|
Backend::Json => json::get(ip, dev, nud).await,
|
||||||
|
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||||
|
Backend::Netlink => {
|
||||||
|
let ips: Vec<IpAddr> = ip.into_iter().collect();
|
||||||
|
let devs: Vec<&str> = dev.into_iter().collect();
|
||||||
|
nl::get(&ips, &devs, nud, &[]).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
//! rtnetlink-based neighbor table query. One syscall, filter in userspace.
|
||||||
|
#![cfg(unix)]
|
||||||
|
use std::{
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
net::IpAddr,
|
||||||
|
};
|
||||||
|
|
||||||
|
use futures::TryStreamExt;
|
||||||
|
use macaddr::MacAddr;
|
||||||
|
use rtnetlink::Handle;
|
||||||
|
use rtnetlink::packet_route::{
|
||||||
|
AddressFamily,
|
||||||
|
neighbour::{NeighbourAddress, NeighbourAttribute, NeighbourState},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{NUDState, NeighborItem};
|
||||||
|
use crate::subcommands::link;
|
||||||
|
|
||||||
|
/// Fetch neighbors via rtnetlink. Empty slice = no filter (match all).
|
||||||
|
/// Non-empty slice = match ANY in the set.
|
||||||
|
// https://github.com/rust-netlink/rtnetlink/blob/main/examples/get_neighbours.rs
|
||||||
|
pub async fn get(
|
||||||
|
ips: &[IpAddr],
|
||||||
|
devs: &[impl AsRef<str>],
|
||||||
|
nuds: &[NUDState],
|
||||||
|
macs: &[MacAddr],
|
||||||
|
) -> anyhow::Result<Vec<NeighborItem>> {
|
||||||
|
let (conn, handle, _) = rtnetlink::new_connection()?;
|
||||||
|
tokio::spawn(conn);
|
||||||
|
|
||||||
|
get_with_handle(&handle, ips, devs, nuds, macs).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_with_handle(
|
||||||
|
handle: &Handle,
|
||||||
|
ips: &[IpAddr],
|
||||||
|
devs: &[impl AsRef<str>],
|
||||||
|
nuds: &[NUDState],
|
||||||
|
macs: &[MacAddr],
|
||||||
|
) -> anyhow::Result<Vec<NeighborItem>> {
|
||||||
|
// Build filter sets (empty = match all)
|
||||||
|
let ip_set: HashSet<IpAddr> = ips.iter().copied().collect();
|
||||||
|
let dev_set: HashSet<&str> = devs.iter().map(AsRef::as_ref).collect();
|
||||||
|
let nud_set: HashSet<&NUDState> = nuds.iter().collect();
|
||||||
|
let mac_set: HashSet<MacAddr> = macs.iter().copied().collect();
|
||||||
|
|
||||||
|
// Prefetch all links once; link lookups were the ugliest and most expensive part.
|
||||||
|
let ifname_cache: HashMap<u32, String> = link::nl::get_with_handle(handle, None)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|link| (link.ifindex, link.ifname))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut neighbor_data = handle.neighbours().get().execute();
|
||||||
|
let mut result = vec![];
|
||||||
|
|
||||||
|
'row: while let Some(msg) = neighbor_data.try_next().await? {
|
||||||
|
// Only IPv4/IPv6, skip NOARP
|
||||||
|
if !matches!(
|
||||||
|
msg.header.family,
|
||||||
|
AddressFamily::Inet | AddressFamily::Inet6
|
||||||
|
) || matches!(msg.header.state, NeighbourState::Noarp)
|
||||||
|
{
|
||||||
|
continue 'row;
|
||||||
|
}
|
||||||
|
|
||||||
|
let state: NUDState = msg.header.state.try_into().unwrap_or_default();
|
||||||
|
let mut ip = None;
|
||||||
|
let mut mac = None;
|
||||||
|
|
||||||
|
for attr in msg.attributes {
|
||||||
|
match attr {
|
||||||
|
NeighbourAttribute::Destination(addr) => match addr {
|
||||||
|
NeighbourAddress::Inet(v4) => ip = Some(IpAddr::from(v4)),
|
||||||
|
NeighbourAddress::Inet6(v6) => ip = Some(IpAddr::from(v6)),
|
||||||
|
_ => continue 'row,
|
||||||
|
},
|
||||||
|
NeighbourAttribute::LinkLocalAddress(bytes) => {
|
||||||
|
mac = match bytes.len() {
|
||||||
|
6 => bytes.first_chunk::<6>().map(|&b| MacAddr::from(b)),
|
||||||
|
8 => bytes.first_chunk::<8>().map(|&b| MacAddr::from(b)),
|
||||||
|
_ => continue 'row,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve ifindex -> name (cached)
|
||||||
|
let dev = ifname_cache.get(&msg.header.ifindex).cloned();
|
||||||
|
|
||||||
|
let (Some(ip), Some(dev)) = (ip, dev) else {
|
||||||
|
continue 'row;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply filters (empty set = match all)
|
||||||
|
if !ip_set.is_empty() && !ip_set.contains(&ip) {
|
||||||
|
continue 'row;
|
||||||
|
}
|
||||||
|
if !dev_set.is_empty() && !dev_set.contains(dev.as_str()) {
|
||||||
|
continue 'row;
|
||||||
|
}
|
||||||
|
if !nud_set.is_empty() && !nud_set.contains(&state) {
|
||||||
|
continue 'row;
|
||||||
|
}
|
||||||
|
if !mac_set.is_empty() && !mac.is_some_and(|m| mac_set.contains(&m)) {
|
||||||
|
continue 'row;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(NeighborItem {
|
||||||
|
ip,
|
||||||
|
dev: Some(dev),
|
||||||
|
mac,
|
||||||
|
state: vec![state],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<NeighbourState> for NUDState {
|
||||||
|
fn try_from(value: NeighbourState) -> Result<Self, Self::Error> {
|
||||||
|
match value {
|
||||||
|
NeighbourState::Incomplete => Ok(Self::Incomplete),
|
||||||
|
NeighbourState::Reachable => Ok(Self::Reachable),
|
||||||
|
NeighbourState::Stale => Ok(Self::Stale),
|
||||||
|
NeighbourState::Delay => Ok(Self::Delay),
|
||||||
|
NeighbourState::Probe => Ok(Self::Probe),
|
||||||
|
NeighbourState::Failed => Ok(Self::Failed),
|
||||||
|
NeighbourState::Noarp => Ok(Self::Noarp),
|
||||||
|
NeighbourState::Permanent => Ok(Self::Permanent),
|
||||||
|
NeighbourState::None => Ok(Self::None),
|
||||||
|
NeighbourState::Other(e) => Ok(Self::Other(e)),
|
||||||
|
_ => Err(u16::MAX),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Error = u16;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
use std::iter::Fuse;
|
||||||
|
|
||||||
|
/// wrap ts into a [Fuse][std::iter::Fuse] or something
|
||||||
|
pub struct Real<A: Clone, B: Iterator<Item = A>> {
|
||||||
|
prepend: A,
|
||||||
|
iter: B,
|
||||||
|
my_turn: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A: Clone, B: Iterator<Item = A>> Real<A, B> {
|
||||||
|
pub fn new(prepend: A, iter: B) -> Self {
|
||||||
|
Self {
|
||||||
|
prepend,
|
||||||
|
iter,
|
||||||
|
my_turn: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn fuse(prepend: A, iter: B) -> Fuse<Self> {
|
||||||
|
Self::new(prepend, iter).fuse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A: Clone, B: Iterator<Item = A>> Iterator for Real<A, B> {
|
||||||
|
type Item = A;
|
||||||
|
|
||||||
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
|
let a = if self.my_turn {
|
||||||
|
Some(self.prepend.clone())
|
||||||
|
} else {
|
||||||
|
self.iter.next()
|
||||||
|
};
|
||||||
|
self.my_turn = !self.my_turn;
|
||||||
|
a
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// pub mod iter;
|
||||||
|
pub mod serialize;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
use macaddr::MacAddr;
|
||||||
|
use serde::{self, Deserialize, Deserializer, de::Error as DeError};
|
||||||
|
use serde::{Serialize, Serializer};
|
||||||
|
|
||||||
|
pub fn serialize_macs<S>(macs: &[MacAddr], serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
let strings: Vec<String> = macs.iter().map(|m| m.to_string()).collect();
|
||||||
|
serde::Serialize::serialize(&strings, serializer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize a MacAddr as a string
|
||||||
|
pub fn serialize<S>(mac: &MacAddr, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(&mac.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserialize a MacAddr from a string
|
||||||
|
pub fn deserialize<'de, D>(deserializer: D) -> Result<MacAddr, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let s = <String as serde::Deserialize>::deserialize(deserializer)?;
|
||||||
|
s.parse::<MacAddr>().map_err(DeError::custom)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod option_mac {
|
||||||
|
use super::*;
|
||||||
|
/// serialize an [`Option<MacAddr>`]
|
||||||
|
pub fn serialize<S: Serializer>(bro: &Option<MacAddr>, ser: S) -> Result<S::Ok, S::Error> {
|
||||||
|
Option::<String>::serialize(&bro.as_ref().map(ToString::to_string), ser)
|
||||||
|
}
|
||||||
|
/// deserialize an [`Option<MacAddr>`], returns None for invalid input (::, 0.0.0.0)
|
||||||
|
pub fn deserialize<'de, D>(des: D) -> Result<Option<MacAddr>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let s: Option<&str> = Option::<&str>::deserialize(des)?;
|
||||||
|
match s {
|
||||||
|
Some(val) => match val.parse::<MacAddr>() {
|
||||||
|
Ok(mac) => Ok(Some(mac)),
|
||||||
|
Err(_) => Ok(None),
|
||||||
|
},
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod mac;
|
||||||
|
// pub mod vec;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use serde::de;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum OneOrMany<T> {
|
||||||
|
One(T),
|
||||||
|
Many(Vec<T>),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D, T>(des: D) -> Result<Vec<T>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
T: std::str::FromStr,
|
||||||
|
T::Err: std::fmt::Display,
|
||||||
|
{
|
||||||
|
let raw: OneOrMany<String> = OneOrMany::<String>::deserialize(des)?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
match raw {
|
||||||
|
OneOrMany::One(s) => {
|
||||||
|
let t = s.trim();
|
||||||
|
if !t.is_empty() {
|
||||||
|
out.push(t.parse().map_err(de::Error::custom)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OneOrMany::Many(vs) => {
|
||||||
|
for s in vs {
|
||||||
|
let t = s.trim();
|
||||||
|
if t.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(t.parse().map_err(de::Error::custom)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use lda_ipjs::subcommands::{address, neighbor};
|
||||||
|
|
||||||
|
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||||
|
#[tokio::test] // ← Use tokio::test instead of manual #[tokio::main]
|
||||||
|
async fn ball1() -> anyhow::Result<()> {
|
||||||
|
let result = neighbor::get_with_backend(neighbor::Backend::Netlink, None, None, &[]).await?;
|
||||||
|
println!("netlink results: {:?}", result);
|
||||||
|
Ok(()) // ← Don't force error, let it succeed
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ball2() -> anyhow::Result<()> {
|
||||||
|
let result = neighbor::get_with_backend(neighbor::Backend::Json, None, None, &[]).await?;
|
||||||
|
println!("json results: {:?}", result);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add this to debug the raw JSON
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ball_raw_json() -> anyhow::Result<()> {
|
||||||
|
let output = tokio::process::Command::new("ip")
|
||||||
|
.args(["-j", "neigh", "show"])
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let json = String::from_utf8_lossy(&output.stdout);
|
||||||
|
println!("Raw JSON:\n{}", json);
|
||||||
|
|
||||||
|
// Try to parse it
|
||||||
|
let parsed: Result<Vec<neighbor::NeighborItem>, _> = serde_json::from_slice(&output.stdout);
|
||||||
|
match parsed {
|
||||||
|
Ok(items) => println!("Parsed {} items", items.len()),
|
||||||
|
Err(e) => println!("Parse error: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check what fields actually exist in the JSON
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ball_field_analysis() -> anyhow::Result<()> {
|
||||||
|
let output = tokio::process::Command::new("ip")
|
||||||
|
.args(["-j", "neigh", "show"])
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let raw: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout)?;
|
||||||
|
|
||||||
|
println!("Found {} neighbor entries", raw.len());
|
||||||
|
|
||||||
|
// Collect all unique field names across all entries
|
||||||
|
let mut all_fields = std::collections::HashSet::new();
|
||||||
|
for (i, entry) in raw.iter().enumerate() {
|
||||||
|
if let Some(obj) = entry.as_object() {
|
||||||
|
println!("\nEntry {}: {} fields", i, obj.len());
|
||||||
|
for (key, value) in obj {
|
||||||
|
all_fields.insert(key.clone());
|
||||||
|
println!(" {}: {} = {:?}", key, value.type_name(), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n=== All unique fields seen ===");
|
||||||
|
for field in &all_fields {
|
||||||
|
println!(" - {}", field);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper trait to get type name for JSON values
|
||||||
|
trait TypeName {
|
||||||
|
fn type_name(&self) -> &str;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TypeName for serde_json::Value {
|
||||||
|
fn type_name(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
serde_json::Value::Null => "null",
|
||||||
|
serde_json::Value::Bool(_) => "bool",
|
||||||
|
serde_json::Value::Number(_) => "number",
|
||||||
|
serde_json::Value::String(_) => "string",
|
||||||
|
serde_json::Value::Array(_) => "array",
|
||||||
|
serde_json::Value::Object(_) => "object",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test filtering logic
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ball_compare_backends() -> anyhow::Result<()> {
|
||||||
|
println!("=== JSON Backend ===");
|
||||||
|
let json_result = neighbor::get_with_backend(neighbor::Backend::Json, None, None, &[]).await?;
|
||||||
|
println!("Got {} entries from JSON", json_result.len());
|
||||||
|
|
||||||
|
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||||
|
{
|
||||||
|
println!("\n=== Netlink Backend ===");
|
||||||
|
let nl_result =
|
||||||
|
neighbor::get_with_backend(neighbor::Backend::Netlink, None, None, &[]).await?;
|
||||||
|
println!("Got {} entries from netlink", nl_result.len());
|
||||||
|
|
||||||
|
// Compare counts
|
||||||
|
if json_result.len() != nl_result.len() {
|
||||||
|
println!(
|
||||||
|
"\n⚠️ Count mismatch! JSON: {}, Netlink: {}",
|
||||||
|
json_result.len(),
|
||||||
|
nl_result.len()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("\n✅ Both backends returned same count");
|
||||||
|
}
|
||||||
|
let a: HashSet<neighbor::NeighborItem> = HashSet::from_iter(json_result);
|
||||||
|
let b: HashSet<neighbor::NeighborItem> = HashSet::from_iter(nl_result);
|
||||||
|
println!(
|
||||||
|
"istg {len1} == {len2} or else",
|
||||||
|
len1 = a.len(),
|
||||||
|
len2 = b.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// #[tokio::test]
|
||||||
|
// async fn cidr_filter() {
|
||||||
|
// unimplemented!("never. i aint add what i dont need")
|
||||||
|
// }
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ipjas() -> anyhow::Result<()> {
|
||||||
|
let cuh = address::get_with_backend(address::Backend::Json, None).await?;
|
||||||
|
println!("{cuh:#?}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ipjas_raw() -> anyhow::Result<()> {
|
||||||
|
let cuh = address::json::_get(None).await?;
|
||||||
|
println!("{cuh:#?}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
|
!copy.ps1
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
# this does one thing
|
||||||
|
param(
|
||||||
|
[string]$PASSWD
|
||||||
|
)
|
||||||
|
. "$(Split-Path -Parent $PSScriptRoot)/scripts/lib.ps1"
|
||||||
|
|
||||||
|
$PASSWD = Get-DefaultPassword $PASSWD
|
||||||
|
|
||||||
|
# idk what this does it works like that then thats how it is
|
||||||
|
pscp.exe -l root -scp -pw $PASSWD -r 192.168.100.1:/etc/ldlda_help $PSScriptRoot
|
||||||
|
pscp.exe -l root -scp -pw $PASSWD 192.168.100.1:/etc/rc.local $PSScriptRoot
|
||||||
|
pscp.exe -l root -scp -pw $PASSWD -r 192.168.100.1:/etc/wakey-agent $PSScriptRoot
|
||||||
|
|
||||||
|
Remove-Item -Recurse (Join-Path $PSScriptRoot "root")
|
||||||
|
pscp.exe -l root -scp -pw $PASSWD -r 192.168.100.1:/root $PSScriptRoot
|
||||||
|
|
||||||
|
|
||||||
|
$initDir = Join-Path $PSScriptRoot 'init.d'
|
||||||
|
New-Item -ItemType Directory -Force -Path $initDir | Out-Null
|
||||||
|
|
||||||
|
# these are sum
|
||||||
|
$files = "update_wakey" , "wakey" , "update_tailscale" , "wireguard_setup", "lda-override"
|
||||||
|
$remote = $files.ForEach({ "/etc/init.d/$_" })
|
||||||
|
$remote | ForEach-Object {
|
||||||
|
pscp.exe -l root -scp -pw $PASSWD "192.168.100.1:$_" "$initDir\"
|
||||||
|
}
|
||||||
+139
-28
@@ -4,56 +4,161 @@ This folder has small helpers for build, CI, and router install. Keep it simple;
|
|||||||
|
|
||||||
## Quick start (recommended)
|
## Quick start (recommended)
|
||||||
|
|
||||||
1. Start your Gitea runner on this Windows PC
|
1. Start your Fedora/WSL Gitea runner (recommended release path):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
./scripts/act_runner.ps1
|
./scripts/act_runner_wsl.ps1 -Action start -Attach
|
||||||
```
|
```
|
||||||
|
|
||||||
If first-time, it prints the exact register command. Run it once, check labels include `windows:host,self-hosted`, then rerun the script.
|
If first-time, use the runbook in [`WSL_RUNNER.md`](./WSL_RUNNER.md) to update and register the Fedora runner first.
|
||||||
|
|
||||||
1. Tag and push to trigger CI
|
2. Publish, tag, and push (recommended):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
git tag v0.1.0
|
./scripts/publish.ps1 -Tag
|
||||||
git push origin v0.1.0
|
```
|
||||||
```
|
|
||||||
|
|
||||||
1. Install on the router
|
This will bump the version (if you specify one), build locally, tag, push, and start the Fedora/WSL runner in `--once` mode by default. To also publish to the registry, add `-Publish`.
|
||||||
|
|
||||||
Use the release asset URL from your Gitea Release page:
|
3. Install on the router
|
||||||
|
|
||||||
```sh
|
Use the updater on the router to fetch the latest automatically:
|
||||||
wget -O- https://<gitea>/<owner>/<repo>/releases/download/v0.1.0/wakey-rootfs-v0.1.0-armv7-unknown-linux-musleabihf.tgz | tar -xz -C /
|
|
||||||
chmod +x /etc/init.d/wakey && /etc/init.d/wakey enable && /etc/init.d/wakey restart
|
|
||||||
```
|
|
||||||
|
|
||||||
Alternatively, use the updater on the router to fetch the latest automatically:
|
```sh
|
||||||
|
WAKEY_HOST=git.ldlda.com WAKEY_OWNER=lda WAKEY_REPO=wakey sh /etc/ldlda_help/update_wakey.sh
|
||||||
|
```
|
||||||
|
|
||||||
```sh
|
Alternatively, use the release asset URL from your Gitea Release page:
|
||||||
WAKEY_HOST=git.ldlda.com WAKEY_OWNER=lda WAKEY_REPO=wakey sh /etc/ldlda_help/update_wakey.sh
|
|
||||||
```
|
```sh
|
||||||
|
wget -O- https://<gitea>/<owner>/<repo>/releases/download/v0.1.0/wakey-rootfs-v0.1.0-armv7-unknown-linux-musleabihf.tgz | tar -xz -C /
|
||||||
|
chmod +x /etc/init.d/wakey && /etc/init.d/wakey enable && /etc/init.d/wakey restart
|
||||||
|
```
|
||||||
|
|
||||||
## Scripts overview
|
## Scripts overview
|
||||||
|
|
||||||
- `act_runner.ps1` — Start/seed the local Gitea runner. Use `-Attach` to see logs, `-ForceConfigure` to register non-interactively.
|
- `act_runner.ps1` — Start/seed the local Gitea runner. Use `-Attach` to see logs, `-ForceConfigure` to register non-interactively.
|
||||||
- `cross_build.ps1` — Simple wrapper for `cargo build` per target (default: armv7-unknown-linux-musleabihf).
|
- `act_runner_wsl.ps1` — Windows PowerShell wrapper that controls the Fedora/WSL runner.
|
||||||
|
- `act_runner_fedora.sh` — Linux/Fedora helper used inside WSL for update/register/start/stop/status.
|
||||||
|
- `dev_push.ps1` — Fast dev loop: build + upload to router. Usage:
|
||||||
|
- `./scripts/dev_push.ps1 -Pass <password> [-HostName <ip>] [-RemotePath </root/.bin/wakey>] [-Restart] [-Quiet]`
|
||||||
|
- Uploads to `<RemotePath>.tmp` then atomically moves into place; `-Restart` restarts the service; `-Quiet` silences MOTD.
|
||||||
- `package_rootfs.ps1` — Produces `dist/wakey-rootfs-<version>-<target>.tgz` with `/root/.bin/wakey` and `/etc/init.d/*`.
|
- `package_rootfs.ps1` — Produces `dist/wakey-rootfs-<version>-<target>.tgz` with `/root/.bin/wakey` and `/etc/init.d/*`.
|
||||||
- `package.ps1` — Dev bundle with binaries + init scripts (not a rootfs layout).
|
- `update_wakey_cc.sh` — Linux VPS updater for control-plane bundles; extracts into the selected directory (default: current directory) and restarts `wakey-cc.service`.
|
||||||
|
- `update_wakey_cc_from_repo.sh` — Linux VPS source updater; clones repo to temp dir, builds UI + control-plane locally, packages bundle tgz, installs into selected directory, and restarts `wakey-cc.service`.
|
||||||
|
- `package_wakey_cc_bundle.sh` — Produces `dist/wakey-cc-<version>-<target>.tgz` with `bin/wakey-control-plane`, `ui/dist/*`, updater script, and systemd template.
|
||||||
- `publish.ps1` — Optional version bump + build + tag (and `cargo publish` only if you pass `-Publish`).
|
- `publish.ps1` — Optional version bump + build + tag (and `cargo publish` only if you pass `-Publish`).
|
||||||
|
- `test_remote.ps1` — Build ARM Linux test binaries locally, upload them to the router, and run them there.
|
||||||
|
|
||||||
|
## VPS updater (control-plane)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
chmod +x ./scripts/update_wakey_cc.sh
|
||||||
|
cd /opt/wakey
|
||||||
|
sudo -E ./scripts/update_wakey_cc.sh
|
||||||
|
|
||||||
|
# optional: pin version/target
|
||||||
|
WAKEY_CC_VERSION=v0.2.0 WAKEY_CC_TARGET=x86_64-unknown-linux-gnu \
|
||||||
|
sudo -E ./scripts/update_wakey_cc.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## VPS source updater (control-plane)
|
||||||
|
|
||||||
|
Build-and-install locally on the VPS (avoids glibc mismatch from prebuilt binaries):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
chmod +x ./scripts/update_wakey_cc_from_repo.sh
|
||||||
|
cd /opt/wakey
|
||||||
|
sudo -E /path/to/repo/scripts/update_wakey_cc_from_repo.sh
|
||||||
|
|
||||||
|
# optional: pin ref/target and skip restart
|
||||||
|
WAKEY_CC_REF=main WAKEY_CC_TARGET=x86_64-unknown-linux-gnu WAKEY_CC_NO_RESTART=1 \
|
||||||
|
sudo -E /path/to/repo/scripts/update_wakey_cc_from_repo.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected tarball layout:
|
||||||
|
|
||||||
|
- `bin/wakey-control-plane`
|
||||||
|
- `ui/dist/index.html`
|
||||||
|
- `ui/dist/assets/*`
|
||||||
|
- `scripts/update_wakey_cc.sh`
|
||||||
|
- `deploy/systemd/wakey-cc.service`
|
||||||
|
|
||||||
|
## Remote test runner
|
||||||
|
|
||||||
|
`test_remote.ps1` is the main way to run Linux/router-specific tests that do not
|
||||||
|
make sense on Windows.
|
||||||
|
|
||||||
|
Basic examples:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./scripts/test_remote.ps1 -Package wakey
|
||||||
|
./scripts/test_remote.ps1 -Package wakey -BinaryFilter integration_live_services -Ignored -NoCapture
|
||||||
|
./scripts/test_remote.ps1 -Package wakey -BinaryFilter integration_live_services -Filter inventory_real_router_default_query_returns_rows_or_empty_cleanly -Ignored -NoCapture
|
||||||
|
./scripts/test_remote.ps1 -AllPackages
|
||||||
|
```
|
||||||
|
|
||||||
|
Important semantics:
|
||||||
|
|
||||||
|
- `-BinaryFilter`
|
||||||
|
- selects which compiled Rust test executable(s) to run
|
||||||
|
- `-Filter`
|
||||||
|
- filters test functions inside a selected Rust test binary
|
||||||
|
- `-Ignored`
|
||||||
|
- runs `#[ignore]` tests
|
||||||
|
- `-IncludeIgnored`
|
||||||
|
- includes ignored tests in addition to normal ones
|
||||||
|
- `-List`
|
||||||
|
- lists tests inside the remote binary instead of executing them
|
||||||
|
|
||||||
|
Useful flags:
|
||||||
|
|
||||||
|
- `-BuildProfile debug|release`
|
||||||
|
- `-Exact`
|
||||||
|
- `-NoCapture`
|
||||||
|
- `-ShowOutput`
|
||||||
|
- `-Threads <n>`
|
||||||
|
- `-RemoteHost root@<router-ip>`
|
||||||
|
- `-RemoteTestPath /tmp/tmp/wakey-test`
|
||||||
|
|
||||||
|
Implementation notes:
|
||||||
|
|
||||||
|
- test executables are discovered via Cargo JSON messages, not regexing human output
|
||||||
|
- binaries are batch-uploaded per package run
|
||||||
|
- each package run gets a unique remote temp directory for safer concurrent use
|
||||||
|
- packages with no test executables are skipped instead of treated as failures
|
||||||
|
|
||||||
|
## Release checklist (tag push)
|
||||||
|
|
||||||
|
1. **Version:** bump crate versions / changelog as you prefer; tag `v*` and push.
|
||||||
|
2. **Runner:** Fedora/WSL self-hosted labels `self-hosted`, `linux`, `fedora`, `wsl`, `release` online (see [`WSL_RUNNER.md`](./WSL_RUNNER.md)).
|
||||||
|
3. **Control-plane bundle:** release job builds `ui/dist`, then `wakey-control-plane` for `x86_64-unknown-linux-gnu` and `aarch64-unknown-linux-gnu`. With **zig** + **cargo-zigbuild** on the runner, gnu builds use workflow env `WAKEY_CC_GLIBC_VERSION` (default `2.28`) for a predictable glibc floor; without them, binaries match the host glibc.
|
||||||
|
4. **Artifacts:** expect `dist/wakey-rootfs-*-armv7-unknown-linux-musleabihf.tgz` and `dist/wakey-cc-*-unknown-linux-gnu.tgz` attached to the Gitea release.
|
||||||
|
5. **Secret:** `GITEA_TOKEN` must be set for the publish step.
|
||||||
|
|
||||||
## CI (Gitea)
|
## 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.
|
||||||
- Runs on your `self-hosted, windows` runner.
|
- Release job targets your Fedora/WSL runner labels: `self-hosted`, `linux`, `fedora`, `wsl`, `release`.
|
||||||
- Steps: cross build → 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
|
||||||
|
|
||||||
|
- Fedora/WSL is now the intended release-builder path.
|
||||||
|
- The Windows runner can stay registered in parallel during migration and fallback.
|
||||||
|
- The detailed operator runbook is in [`WSL_RUNNER.md`](./WSL_RUNNER.md).
|
||||||
|
|
||||||
## Local build/install (manual path)
|
## Local build/install (manual path)
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Build (requires cross installed)
|
# Build
|
||||||
./scripts/cross_build.ps1 -Release -Targets armv7-unknown-linux-musleabihf
|
cargo build --release --target armv7-unknown-linux-musleabihf
|
||||||
|
|
||||||
# Package rootfs
|
# Package rootfs
|
||||||
./scripts/package_rootfs.ps1 -Version v0.1.0 -Target armv7-unknown-linux-musleabihf
|
./scripts/package_rootfs.ps1 -Version v0.1.0 -Target armv7-unknown-linux-musleabihf
|
||||||
@@ -68,4 +173,10 @@ tar -xz -f /tmp/wakey.tgz -C /
|
|||||||
chmod +x /etc/init.d/wakey && /etc/init.d/wakey enable && /etc/init.d/wakey restart
|
chmod +x /etc/init.d/wakey && /etc/init.d/wakey enable && /etc/init.d/wakey restart
|
||||||
```
|
```
|
||||||
|
|
||||||
That’s it. Keep the flow: start runner → tag push → install.
|
Or you can use the script:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./scripts/dev_push.ps1 -Pass <password>
|
||||||
|
```
|
||||||
|
|
||||||
|
That’s it. Keep the flow: start Fedora/WSL runner → tag push → install.
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# Fedora / WSL Gitea Runner
|
||||||
|
|
||||||
|
This repo now expects Linux/router release builds to run on a Fedora 43 runner
|
||||||
|
inside WSL, not on the Windows host runner.
|
||||||
|
|
||||||
|
The goal is simple:
|
||||||
|
|
||||||
|
- build `wakey` and `wakey-agent` in a Linux-native environment
|
||||||
|
- stop fighting Windows-host ARM/musl/TLS toolchain issues
|
||||||
|
- keep PowerShell on Windows as the operator front door
|
||||||
|
|
||||||
|
## Runner layout
|
||||||
|
|
||||||
|
Recommended WSL location:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/mnt/c/Users/Admin/Documents/realshit/wakey/ar_data/wsl_runner
|
||||||
|
```
|
||||||
|
|
||||||
|
This matches the default used by `act_runner_wsl.ps1` and
|
||||||
|
`act_runner_fedora.sh`, so WSL runner config is isolated at
|
||||||
|
`ar_data/wsl_runner/config.yaml` in this repo.
|
||||||
|
|
||||||
|
Recommended labels:
|
||||||
|
|
||||||
|
```text
|
||||||
|
self-hosted,linux,fedora,wsl,release:host
|
||||||
|
```
|
||||||
|
|
||||||
|
The Windows runner can stay registered during migration as fallback.
|
||||||
|
|
||||||
|
## Fedora setup
|
||||||
|
|
||||||
|
Install the basics in Fedora 43:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dnf install -y git curl tar gzip findutils python3
|
||||||
|
sudo dnf install -y powershell
|
||||||
|
```
|
||||||
|
|
||||||
|
Install Rust and the router target:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rustup target add armv7-unknown-linux-musleabihf
|
||||||
|
```
|
||||||
|
|
||||||
|
Install the native cross toolchain pieces you need for Linux/ARM router builds.
|
||||||
|
The exact package names can vary over time, so confirm the current Fedora names
|
||||||
|
for:
|
||||||
|
|
||||||
|
- ARM Linux GCC
|
||||||
|
- musl development/toolchain support
|
||||||
|
- OpenSSL/ring/TLS build prerequisites if needed by your dependency graph
|
||||||
|
|
||||||
|
## Runner lifecycle
|
||||||
|
|
||||||
|
From Windows PowerShell, using the WSL wrapper:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./scripts/act_runner_wsl.ps1 -Action update
|
||||||
|
./scripts/act_runner_wsl.ps1 -Action register -ServerUrl https://git.ldlda.com/ -Token <runner-token>
|
||||||
|
./scripts/act_runner_wsl.ps1 -Action start -Attach
|
||||||
|
./scripts/act_runner_wsl.ps1 -Action start -Once
|
||||||
|
./scripts/act_runner_wsl.ps1 -Action status
|
||||||
|
./scripts/act_runner_wsl.ps1 -Action stop
|
||||||
|
```
|
||||||
|
|
||||||
|
Path control (binary and config are independent):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./scripts/act_runner_wsl.ps1 -Action status `
|
||||||
|
-RunnerBin /home/lda/gitea-runner/act_runner `
|
||||||
|
-ConfigPath /mnt/c/Users/Admin/Documents/realshit/wakey/ar_data/wsl_runner/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
If your distro name is not `FedoraLinux-43`, set it explicitly:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./scripts/act_runner_wsl.ps1 -Distro FedoraLinux-43 -Action status
|
||||||
|
```
|
||||||
|
|
||||||
|
Or set:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:WAKEY_WSL_DISTRO = "FedoraLinux-43"
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside Fedora directly, you can also use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/act_runner_fedora.sh update
|
||||||
|
./scripts/act_runner_fedora.sh register --server-url https://git.ldlda.com/ --token <runner-token>
|
||||||
|
./scripts/act_runner_fedora.sh start --attach
|
||||||
|
./scripts/act_runner_fedora.sh start --once
|
||||||
|
./scripts/act_runner_fedora.sh status
|
||||||
|
./scripts/act_runner_fedora.sh stop
|
||||||
|
```
|
||||||
|
|
||||||
|
## Release flow
|
||||||
|
|
||||||
|
The release workflow is expected to run on the Fedora/WSL runner labels:
|
||||||
|
|
||||||
|
```text
|
||||||
|
self-hosted + linux + fedora + wsl + release
|
||||||
|
```
|
||||||
|
|
||||||
|
It builds:
|
||||||
|
|
||||||
|
- `wakey`
|
||||||
|
- `wakey-agent`
|
||||||
|
|
||||||
|
and packages both into the rootfs tarball.
|
||||||
|
|
||||||
|
From Windows, the intended operator flow remains:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./scripts/publish.ps1 -Tag
|
||||||
|
```
|
||||||
|
|
||||||
|
That script should trigger the WSL runner in `--once` mode by default.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
If the Fedora runner path is broken:
|
||||||
|
|
||||||
|
1. stop using the WSL runner labels in the workflow
|
||||||
|
2. temporarily point the workflow back to the Windows runner labels
|
||||||
|
3. use the existing Windows runner as fallback while fixing the Fedora path
|
||||||
|
|
||||||
|
Do not delete the Windows runner until the Fedora path has already produced at
|
||||||
|
least one successful full release and one successful router install.
|
||||||
Regular → Executable
+24
-10
@@ -1,16 +1,19 @@
|
|||||||
# Starts the Gitea act_runner if not already running.
|
#!/usr/bin/env pwsh
|
||||||
# Usage: ./scripts/act_runner.ps1 -Config "C:/Users/Admin/Documents/gitea/runner.yaml" -RunnerPath "C:/Users/Admin/Documents/gitea/act_runner.exe" -ServerUrl "https://git.ldlda.com/" -Token "<reg token>" -Labels "self-hosted,windows"
|
# Starts the Windows-host Gitea act_runner if not already running.
|
||||||
|
# Usage: ./scripts/act_runner.ps1 -Config "C:/Users/Admin/Documents/gitea/runner.yaml" -RunnerPath "C:/Users/Admin/Documents/gitea/act_runner.exe" -ServerUrl "https://git.ldlda.com/" -Token "<reg token>" -Labels "self-hosted,windows:host"
|
||||||
param(
|
param(
|
||||||
[string]$RunnerPath = "$HOME/Documents/gitea/act_runner.exe",
|
[string]$RunnerPath = "$HOME/Documents/gitea/act_runner.exe",
|
||||||
# Config is a FILE path (e.g., runner.yaml). Parent folder will be created if missing.
|
# Config is a FILE path (e.g., runner.yaml). Parent folder will be created if missing.
|
||||||
[string]$Config = (Join-Path (Split-Path -Parent $PSScriptRoot) 'ar_data/config.yaml'),
|
[string]$Config = (Join-Path (Split-Path -Parent $PSScriptRoot) 'ar_data/config.yaml'),
|
||||||
[string]$ServerUrl,
|
[string]$ServerUrl,
|
||||||
[string]$Token,
|
[string]$Token,
|
||||||
[string]$Labels = "windows:host,self-hosted",
|
[string]$Labels = "self-hosted,windows:host",
|
||||||
# Opt-in to non-interactive configure; by default we print the command for you to run manually.
|
# Opt-in to non-interactive configure; by default we print the command for you to run manually.
|
||||||
[switch]$ForceConfigure,
|
[switch]$ForceConfigure,
|
||||||
# When -Attach, run in the foreground to see logs (good for first-time troubleshooting)
|
# When -Attach, run in the foreground to see logs (good for first-time troubleshooting)
|
||||||
[switch]$Attach
|
[switch]$Attach,
|
||||||
|
# Run one job then exit (great for CI tag pushes)
|
||||||
|
[switch]$Once
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
@@ -38,20 +41,31 @@ if (-not (Test-Path $configFile)) {
|
|||||||
}
|
}
|
||||||
if ($ForceConfigure) {
|
if ($ForceConfigure) {
|
||||||
Write-Host "Config not found. Initializing non-interactively (ForceConfigure)..."
|
Write-Host "Config not found. Initializing non-interactively (ForceConfigure)..."
|
||||||
|
$configParent = Split-Path -Parent $configFile
|
||||||
|
Push-Location $configParent
|
||||||
& $RunnerPath register --no-interactive --config $configFile --instance $ServerUrl --token $Token --labels $Labels
|
& $RunnerPath register --no-interactive --config $configFile --instance $ServerUrl --token $Token --labels $Labels
|
||||||
|
Pop-Location
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Write-Host "Config not found. Run this manually once (note labels embed host executor on Windows):"
|
Write-Host "Config not found. Run this manually once (Windows-host runner labels):"
|
||||||
Write-Host "`t$RunnerPath register --no-interactive --config `"$configFile`" --instance `"$ServerUrl`" --token `"$Token`" --labels `"$Labels`""
|
Write-Host "`t$RunnerPath register --no-interactive --config `"$configFile`" --instance `"$ServerUrl`" --token `"$Token`" --labels `"$Labels`""
|
||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($Attach) {
|
if ($Attach -or $Once) {
|
||||||
Write-Host "Starting act_runner (attached)... Ctrl+C to stop"
|
Write-Host "Starting act_runner $(if ($Once) {'(once mode)'} else {'(attached)'})... $(if (-not $Once) {'Ctrl+C to stop'})"
|
||||||
& $RunnerPath daemon --config $configFile
|
$configParent = Split-Path -Parent $configFile
|
||||||
|
Push-Location $configParent
|
||||||
|
$daemonArgs = @('daemon', '--config', $configFile)
|
||||||
|
if ($Once) { $daemonArgs += '--once' }
|
||||||
|
& $RunnerPath @daemonArgs
|
||||||
|
Pop-Location
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Start-Process -FilePath $RunnerPath -ArgumentList @("daemon", "--config", $configFile) -WindowStyle Hidden
|
$configParent = Split-Path -Parent $configFile
|
||||||
Write-Host "act_runner started."
|
Push-Location $configParent
|
||||||
|
$proc = Start-Process -FilePath $RunnerPath -ArgumentList @("daemon", "--config", $configFile) -WindowStyle Hidden -PassThru
|
||||||
|
Pop-Location
|
||||||
|
Write-Host "act_runner started (PID=$($proc.Id))."
|
||||||
}
|
}
|
||||||
Executable
+231
@@ -0,0 +1,231 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
RUNNER_BIN="${RUNNER_BIN:-/home/lda/gitea-runner/act_runner}"
|
||||||
|
CONFIG_PATH="${CONFIG_PATH:-$REPO_ROOT/ar_data/wsl_runner/config.yaml}"
|
||||||
|
RUNNER_HOME="${RUNNER_HOME:-$(dirname "$CONFIG_PATH")}"
|
||||||
|
SERVER_URL="${SERVER_URL:-}"
|
||||||
|
TOKEN="${TOKEN:-}"
|
||||||
|
LABELS="${LABELS:-self-hosted,linux,fedora,wsl,release:host}"
|
||||||
|
|
||||||
|
normalize_paths() {
|
||||||
|
local config_dir config_file
|
||||||
|
config_dir="$(cd "$(dirname "$CONFIG_PATH")" && pwd)"
|
||||||
|
config_file="$(basename "$CONFIG_PATH")"
|
||||||
|
CONFIG_PATH="$config_dir/$config_file"
|
||||||
|
RUNNER_HOME="$config_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
act_runner_fedora.sh update
|
||||||
|
act_runner_fedora.sh register --server-url URL --token TOKEN [--labels LABELS]
|
||||||
|
act_runner_fedora.sh start [--attach] [--once]
|
||||||
|
act_runner_fedora.sh stop
|
||||||
|
act_runner_fedora.sh status
|
||||||
|
|
||||||
|
Environment overrides:
|
||||||
|
RUNNER_HOME, RUNNER_BIN, CONFIG_PATH, SERVER_URL, TOKEN, LABELS
|
||||||
|
|
||||||
|
Path overrides:
|
||||||
|
--runner-home DIR
|
||||||
|
--runner-bin PATH
|
||||||
|
--config-path PATH
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_dirs() {
|
||||||
|
mkdir -p "$RUNNER_HOME" "$(dirname "$RUNNER_BIN")"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_runner_bin() {
|
||||||
|
if [[ ! -x "$RUNNER_BIN" ]]; then
|
||||||
|
echo "Runner binary not found or not executable at $RUNNER_BIN" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
runner_pids() {
|
||||||
|
ps -eo pid=,args= | awk -v bin="$RUNNER_BIN" -v config="$CONFIG_PATH" '
|
||||||
|
$2 == bin && $3 == "daemon" && $4 == "--config" && $5 == config { print $1 }
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
update_runner() {
|
||||||
|
ensure_dirs
|
||||||
|
local api="https://gitea.com/api/v1/repos/gitea/act_runner/releases/latest"
|
||||||
|
local release
|
||||||
|
release="$(curl -fsSL "$api")"
|
||||||
|
local asset_url
|
||||||
|
asset_url="$(
|
||||||
|
python3 - "$(printf '%s' "$release")" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
data = json.loads(sys.argv[1])
|
||||||
|
for asset in data["assets"]:
|
||||||
|
if asset["name"].endswith("linux-amd64"):
|
||||||
|
print(asset["browser_download_url"])
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise SystemExit("no linux-amd64 act_runner asset found")
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
local tmp="$RUNNER_HOME/act_runner.tmp"
|
||||||
|
curl -fsSL "$asset_url" -o "$tmp"
|
||||||
|
chmod +x "$tmp"
|
||||||
|
mv -f "$tmp" "$RUNNER_BIN"
|
||||||
|
echo "Updated act_runner -> $RUNNER_BIN"
|
||||||
|
}
|
||||||
|
|
||||||
|
register_runner() {
|
||||||
|
ensure_dirs
|
||||||
|
ensure_runner_bin
|
||||||
|
|
||||||
|
if [[ -z "$SERVER_URL" || -z "$TOKEN" ]]; then
|
||||||
|
echo "register requires SERVER_URL and TOKEN (or --server-url/--token)" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$RUNNER_BIN" register \
|
||||||
|
--no-interactive \
|
||||||
|
--config "$CONFIG_PATH" \
|
||||||
|
--instance "$SERVER_URL" \
|
||||||
|
--token "$TOKEN" \
|
||||||
|
--labels "$LABELS"
|
||||||
|
}
|
||||||
|
|
||||||
|
start_runner() {
|
||||||
|
local mode="${1:-daemon}"
|
||||||
|
local config_dir config_file
|
||||||
|
ensure_dirs
|
||||||
|
ensure_runner_bin
|
||||||
|
|
||||||
|
if [[ ! -f "$CONFIG_PATH" ]]; then
|
||||||
|
echo "Runner config not found at $CONFIG_PATH" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
config_dir="$(dirname "$CONFIG_PATH")"
|
||||||
|
config_file="$(basename "$CONFIG_PATH")"
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
attach)
|
||||||
|
cd "$config_dir"
|
||||||
|
exec "$RUNNER_BIN" daemon --config "$config_file"
|
||||||
|
;;
|
||||||
|
once)
|
||||||
|
cd "$config_dir"
|
||||||
|
exec "$RUNNER_BIN" daemon --config "$config_file" --once
|
||||||
|
;;
|
||||||
|
daemon)
|
||||||
|
(
|
||||||
|
cd "$config_dir"
|
||||||
|
nohup "$RUNNER_BIN" daemon --config "$config_file" >/tmp/act_runner.log 2>&1 &
|
||||||
|
)
|
||||||
|
echo "Started act_runner in background"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown start mode: $mode" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_runner() {
|
||||||
|
local pids
|
||||||
|
pids="$(runner_pids)"
|
||||||
|
if [[ -z "$pids" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
kill $pids
|
||||||
|
}
|
||||||
|
|
||||||
|
status_runner() {
|
||||||
|
ensure_runner_bin
|
||||||
|
|
||||||
|
if [[ -n "$(runner_pids)" ]]; then
|
||||||
|
echo "act_runner is running"
|
||||||
|
else
|
||||||
|
echo "act_runner is off"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ACTION="${1:-}"
|
||||||
|
shift || true
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--server-url)
|
||||||
|
SERVER_URL="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--token)
|
||||||
|
TOKEN="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--labels)
|
||||||
|
LABELS="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--runner-home)
|
||||||
|
RUNNER_HOME="$2"
|
||||||
|
if [[ "${RUNNER_BIN:-}" == "/home/lda/gitea-runner/act_runner" ]]; then
|
||||||
|
RUNNER_BIN="$RUNNER_HOME/act_runner"
|
||||||
|
fi
|
||||||
|
if [[ "${CONFIG_PATH:-}" == "$REPO_ROOT/ar_data/wsl_runner/config.yaml" ]]; then
|
||||||
|
CONFIG_PATH="$RUNNER_HOME/config.yaml"
|
||||||
|
fi
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--runner-bin)
|
||||||
|
RUNNER_BIN="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--config-path)
|
||||||
|
CONFIG_PATH="$2"
|
||||||
|
RUNNER_HOME="$(dirname "$CONFIG_PATH")"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--attach)
|
||||||
|
START_MODE="attach"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--once)
|
||||||
|
START_MODE="once"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown argument: $1" >&2
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
normalize_paths
|
||||||
|
|
||||||
|
case "$ACTION" in
|
||||||
|
update)
|
||||||
|
update_runner
|
||||||
|
;;
|
||||||
|
register)
|
||||||
|
register_runner
|
||||||
|
;;
|
||||||
|
start)
|
||||||
|
start_runner "${START_MODE:-daemon}"
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
stop_runner
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
status_runner
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Executable
+61
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
param(
|
||||||
|
[ValidateSet("update", "register", "start", "stop", "status")]
|
||||||
|
[string]$Action = "start",
|
||||||
|
[string]$Distro = $(if ($env:WAKEY_WSL_DISTRO) { $env:WAKEY_WSL_DISTRO } else { "FedoraLinux-43" }),
|
||||||
|
[string]$RunnerHome,
|
||||||
|
[string]$RunnerBin = "/home/lda/gitea-runner/act_runner",
|
||||||
|
[string]$ConfigPath,
|
||||||
|
[string]$ServerUrl,
|
||||||
|
[string]$Token,
|
||||||
|
[string]$Labels = "self-hosted,linux,fedora,wsl,release:host",
|
||||||
|
[switch]$Attach,
|
||||||
|
[switch]$Once
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
. "$PSScriptRoot/lib.ps1"
|
||||||
|
|
||||||
|
$scriptWin = Join-Path $PSScriptRoot "act_runner_fedora.sh"
|
||||||
|
if (-not (Test-Path $scriptWin)) {
|
||||||
|
throw "Missing Fedora runner helper: $scriptWin"
|
||||||
|
}
|
||||||
|
|
||||||
|
$scriptLinux = (& wsl.exe -d $Distro -e wslpath -a "$scriptWin").Trim()
|
||||||
|
if (-not $scriptLinux) {
|
||||||
|
throw "Failed to resolve WSL path for $scriptWin"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $RunnerHome) {
|
||||||
|
$repoRootWin = Split-Path -Parent $PSScriptRoot
|
||||||
|
$defaultRunnerHomeWin = Join-Path $repoRootWin "ar_data/wsl_runner"
|
||||||
|
$RunnerHome = (& wsl.exe -d $Distro -e wslpath -a "$defaultRunnerHomeWin").Trim()
|
||||||
|
if (-not $RunnerHome) {
|
||||||
|
throw "Failed to resolve WSL runner home for $defaultRunnerHomeWin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $ConfigPath) {
|
||||||
|
$repoRootWin = Split-Path -Parent $PSScriptRoot
|
||||||
|
$defaultConfigWin = Join-Path $repoRootWin "ar_data/wsl_runner/config.yaml"
|
||||||
|
$ConfigPath = (& wsl.exe -d $Distro -e wslpath -a "$defaultConfigWin").Trim()
|
||||||
|
if (-not $ConfigPath) {
|
||||||
|
throw "Failed to resolve WSL config path for $defaultConfigWin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = @("bash", (Quote-ShArg $scriptLinux), (Quote-ShArg $Action), "--runner-home", (Quote-ShArg $RunnerHome))
|
||||||
|
$parts += @("--runner-bin", (Quote-ShArg $RunnerBin), "--config-path", (Quote-ShArg $ConfigPath))
|
||||||
|
if ($ServerUrl) { $parts += @("--server-url", (Quote-ShArg $ServerUrl)) }
|
||||||
|
if ($Token) { $parts += @("--token", (Quote-ShArg $Token)) }
|
||||||
|
if ($Labels) { $parts += @("--labels", (Quote-ShArg $Labels)) }
|
||||||
|
if ($Attach) { $parts += "--attach" }
|
||||||
|
if ($Once) { $parts += "--once" }
|
||||||
|
|
||||||
|
$cmd = $parts -join " "
|
||||||
|
Write-Host $cmd
|
||||||
|
& wsl.exe -d $Distro -e bash -lc $cmd
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "WSL runner action failed ($LASTEXITCODE)"
|
||||||
|
}
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
param(
|
|
||||||
[Parameter(Mandatory = $true)][string]$Tag,
|
|
||||||
[Parameter(Mandatory = $true)][string]$AssetPattern,
|
|
||||||
[string]$ServerUrl,
|
|
||||||
[string]$Token,
|
|
||||||
[string]$Owner,
|
|
||||||
[string]$Repo
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
|
|
||||||
# Resolve token
|
|
||||||
if (-not $Token) { $Token = $env:GITEA_TOKEN }
|
|
||||||
if (-not $Token) { throw 'Missing Gitea token. Set -Token or $env:GITEA_TOKEN' }
|
|
||||||
|
|
||||||
# Resolve repo owner/name
|
|
||||||
if (-not $Owner -or -not $Repo) {
|
|
||||||
if ($env:GITHUB_REPOSITORY) {
|
|
||||||
$parts = $env:GITHUB_REPOSITORY -split '/'
|
|
||||||
if (-not $Owner -and $parts.Length -ge 1) { $Owner = $parts[0] }
|
|
||||||
if (-not $Repo -and $parts.Length -ge 2) { $Repo = $parts[1] }
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
throw 'Missing Owner/Repo and GITHUB_REPOSITORY not set'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Resolve API base
|
|
||||||
$api = $env:GITHUB_API_URL
|
|
||||||
if (-not $api -and $env:GITHUB_SERVER_URL) { $api = "$($env:GITHUB_SERVER_URL)/api/v1" }
|
|
||||||
if (-not $api -and $ServerUrl) { $api = "$ServerUrl/api/v1" }
|
|
||||||
if (-not $api) { throw 'Could not determine Gitea API URL. Provide -ServerUrl or set GITHUB_SERVER_URL/GITHUB_API_URL' }
|
|
||||||
|
|
||||||
# Find asset
|
|
||||||
$asset = Get-ChildItem -Path $AssetPattern -ErrorAction Stop | Select-Object -First 1
|
|
||||||
if (-not $asset) { throw "No asset matches pattern: $AssetPattern" }
|
|
||||||
|
|
||||||
$headers = @{ Authorization = "token $Token" }
|
|
||||||
$body = @{ tag_name = $Tag; name = $Tag; draft = $false; prerelease = $false } | ConvertTo-Json
|
|
||||||
|
|
||||||
Write-Host "Creating release $Tag for $Owner/$Repo"
|
|
||||||
try {
|
|
||||||
$release = Invoke-RestMethod -Headers $headers -Uri "$api/repos/$Owner/$Repo/releases" -Method Post -Body $body -ContentType 'application/json'
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
# If already exists, fetch by tag
|
|
||||||
Write-Host "Create failed; trying to fetch existing release for tag $Tag" -ForegroundColor Yellow
|
|
||||||
$release = Invoke-RestMethod -Headers $headers -Uri "$api/repos/$Owner/$Repo/releases/tags/$Tag" -Method Get
|
|
||||||
}
|
|
||||||
|
|
||||||
$rid = $release.id
|
|
||||||
if (-not $rid) { throw "Failed to resolve release id: $($release | ConvertTo-Json -Depth 5)" }
|
|
||||||
|
|
||||||
$uploadUri = "$api/repos/$Owner/$Repo/releases/$rid/assets?name=$($asset.Name)"
|
|
||||||
Write-Host "Uploading asset $($asset.Name)"
|
|
||||||
Invoke-WebRequest -UseBasicParsing -Headers $headers -Uri $uploadUri -Method Post -InFile $asset.FullName -ContentType 'application/octet-stream' | Out-Null
|
|
||||||
Write-Host "Release published: tag $Tag with asset $($asset.Name)"
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Build release binaries using cross for Linux targets.
|
|
||||||
# Requires: cross installed (cargo install cross)
|
|
||||||
param(
|
|
||||||
[string[]]$Targets = @("armv7-unknown-linux-musleabihf"),
|
|
||||||
[switch]$Release
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
|
|
||||||
$mode = if ($Release) { "--release" } else { "" }
|
|
||||||
|
|
||||||
foreach ($t in $Targets) {
|
|
||||||
Write-Host "Building for $t..."
|
|
||||||
cross build $mode --target $t
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Done."
|
|
||||||
Executable
+136
@@ -0,0 +1,136 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidDefaultValueSwitchParameter', "",
|
||||||
|
Justification = 'Default true is intentional for fast dev loop')]
|
||||||
|
param(
|
||||||
|
[ValidateSet("cargo", "cross")]
|
||||||
|
[string]$Cargo = "cargo",
|
||||||
|
[string]$Pass,
|
||||||
|
[string]$HostName = "192.168.100.1",
|
||||||
|
[int]$Port = 22,
|
||||||
|
[string]$User = "root",
|
||||||
|
[string]$RemotePath = "/root/.bin/wakey",
|
||||||
|
[string]$AgentRemotePath = "/root/.bin/wakey-agent",
|
||||||
|
[string]$RemoteInitPath = "/etc/init.d/wakey",
|
||||||
|
[string]$Target = "armv7-unknown-linux-musleabihf",
|
||||||
|
[string]$BinName = "wakey",
|
||||||
|
[string]$AgentBinName = "wakey-agent",
|
||||||
|
[string]$HostKey,
|
||||||
|
[switch]$ForcePassword,
|
||||||
|
[switch]$SkipInitScript,
|
||||||
|
[switch]$Restart = $true,
|
||||||
|
[switch]$Quiet = $true
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
try { $PSStyle.OutputRendering = 'Host' } catch {}
|
||||||
|
|
||||||
|
. "$PSScriptRoot/lib.ps1"
|
||||||
|
|
||||||
|
$Pass = Get-DefaultPassword $Pass
|
||||||
|
|
||||||
|
# Backward compatibility: allow HostName in the form host:port.
|
||||||
|
$hostPort = Split-HostPort -HostName $HostName -DefaultPort $Port
|
||||||
|
$HostName = $hostPort.Host
|
||||||
|
$Port = $hostPort.Port
|
||||||
|
|
||||||
|
function Get-DeployScript {
|
||||||
|
param($DeployPreferred, $DeployTmp, $RemoteTmp, $RemotePath, $RestartFlag)
|
||||||
|
return @"
|
||||||
|
DEPLOY=$DeployPreferred;
|
||||||
|
if [ ! -x "`$DEPLOY" ] && [ -f $DeployTmp ]; then
|
||||||
|
sed -i "s/\r$//" $DeployTmp 2>/dev/null || true
|
||||||
|
chmod +x $DeployTmp
|
||||||
|
DEPLOY=$DeployTmp
|
||||||
|
fi
|
||||||
|
sh "`$DEPLOY" $RemoteTmp $RemotePath $RestartFlag
|
||||||
|
"@
|
||||||
|
}
|
||||||
|
|
||||||
|
#---- Main Flow ----
|
||||||
|
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||||
|
Push-Location $repoRoot
|
||||||
|
|
||||||
|
try {
|
||||||
|
Write-Host "[build] $Cargo build --release --target $Target -p wakey -p wakey-agent" -ForegroundColor Cyan
|
||||||
|
. "$Cargo" build --release --target $Target -p wakey -p wakey-agent
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "$Cargo build failed ($LASTEXITCODE)" }
|
||||||
|
|
||||||
|
$localBin = Join-Path $repoRoot "target/$Target/release/$BinName"
|
||||||
|
$localAgentBin = Join-Path $repoRoot "target/$Target/release/$AgentBinName"
|
||||||
|
if (-not (Test-Path $localBin)) { throw "binary not found: $localBin" }
|
||||||
|
if (-not (Test-Path $localAgentBin)) { throw "binary not found: $localAgentBin" }
|
||||||
|
|
||||||
|
$remoteTmp = "$RemotePath.tmp"
|
||||||
|
$agentRemoteTmp = "$AgentRemotePath.tmp"
|
||||||
|
$destTmp = "$User@${HostName}:$remoteTmp"
|
||||||
|
$agentDestTmp = "$User@${HostName}:$agentRemoteTmp"
|
||||||
|
$initTmp = '/var/tmp/wakey.init.tmp'
|
||||||
|
$initDestTmp = "$User@${HostName}:$initTmp"
|
||||||
|
$localDeploy = Join-Path $repoRoot 'scripts/remote_deploy_wakey.sh'
|
||||||
|
$localInit = Join-Path $repoRoot 'scripts/init/openwrt/wakey'
|
||||||
|
$localDhcpHotplug = Join-Path $repoRoot 'scripts/hotplug/openwrt/dhcp/95-wakey'
|
||||||
|
$localNeighHotplug = Join-Path $repoRoot 'scripts/hotplug/openwrt/neigh/95-wakey'
|
||||||
|
$deployTmp = '/var/tmp/remote_deploy_wakey.sh'
|
||||||
|
$deployPreferred = '/root/.bin/remote_deploy_wakey.sh'
|
||||||
|
$dhcpHotplugTmp = '/var/tmp/wakey.hotplug.dhcp.tmp'
|
||||||
|
$neighHotplugTmp = '/var/tmp/wakey.hotplug.neigh.tmp'
|
||||||
|
|
||||||
|
# Push binaries
|
||||||
|
Invoke-Scp -Local $localBin -Dest $destTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||||
|
Invoke-Scp -Local $localAgentBin -Dest $agentDestTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||||
|
|
||||||
|
# Push OpenWrt init script unless explicitly skipped
|
||||||
|
if (-not $SkipInitScript -and (Test-Path $localInit)) {
|
||||||
|
Invoke-Scp -Local $localInit -Dest $initDestTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||||
|
}
|
||||||
|
if (Test-Path $localDhcpHotplug) {
|
||||||
|
Invoke-Scp -Local $localDhcpHotplug -Dest "$User@${HostName}:$dhcpHotplugTmp" -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||||
|
}
|
||||||
|
if (Test-Path $localNeighHotplug) {
|
||||||
|
Invoke-Scp -Local $localNeighHotplug -Dest "$User@${HostName}:$neighHotplugTmp" -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||||
|
}
|
||||||
|
|
||||||
|
# Push deploy helper if exists
|
||||||
|
if (Test-Path $localDeploy) {
|
||||||
|
Invoke-Scp -Local $localDeploy -Dest "$User@${HostName}:$deployTmp" -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build and run remote deploy command
|
||||||
|
$restartFlag = $(if ($Restart) { '1' } else { '0' })
|
||||||
|
$script = @"
|
||||||
|
$(Get-DeployScript $deployPreferred $deployTmp $remoteTmp $RemotePath 0)
|
||||||
|
$(Get-DeployScript $deployPreferred $deployTmp $agentRemoteTmp $AgentRemotePath $restartFlag)
|
||||||
|
$(if (-not $SkipInitScript) {
|
||||||
|
@"
|
||||||
|
if [ -f $initTmp ]; then
|
||||||
|
sed -i "s/\r$//" $initTmp 2>/dev/null || true
|
||||||
|
if command -v install >/dev/null 2>&1; then
|
||||||
|
install -m 0755 $initTmp $RemoteInitPath
|
||||||
|
else
|
||||||
|
cp -f $initTmp $RemoteInitPath
|
||||||
|
chmod 0755 $RemoteInitPath
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
"@
|
||||||
|
})
|
||||||
|
if [ -f $dhcpHotplugTmp ]; then
|
||||||
|
sed -i "s/\r$//" $dhcpHotplugTmp 2>/dev/null || true
|
||||||
|
mkdir -p /etc/hotplug.d/dhcp
|
||||||
|
cp -f $dhcpHotplugTmp /etc/hotplug.d/dhcp/95-wakey
|
||||||
|
fi
|
||||||
|
if [ -f $neighHotplugTmp ]; then
|
||||||
|
sed -i "s/\r$//" $neighHotplugTmp 2>/dev/null || true
|
||||||
|
mkdir -p /etc/hotplug.d/neigh
|
||||||
|
cp -f $neighHotplugTmp /etc/hotplug.d/neigh/95-wakey
|
||||||
|
fi
|
||||||
|
"@
|
||||||
|
$remoteCmd = "sh -lc '$($script -replace "`r",'')'"
|
||||||
|
if ($Quiet) { $remoteCmd += " >/dev/null 2>&1" }
|
||||||
|
|
||||||
|
Invoke-Ssh -Cmd $remoteCmd -User $User -Remote $HostName -Pass $Pass -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||||
|
|
||||||
|
Write-Host "done ✔" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
if [ -n "$IPADDR" ] && [ -n "$HOSTNAME" ]; then
|
||||||
|
"$BIN" observe dhcp --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" --hostname "$HOSTNAME" >/dev/null 2>&1 || true
|
||||||
|
elif [ -n "$IPADDR" ]; then
|
||||||
|
"$BIN" observe dhcp --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" >/dev/null 2>&1 || true
|
||||||
|
elif [ -n "$HOSTNAME" ]; then
|
||||||
|
"$BIN" observe dhcp --action "$ACTION" --mac "$MACADDR" --hostname "$HOSTNAME" >/dev/null 2>&1 || true
|
||||||
|
else
|
||||||
|
"$BIN" observe dhcp --action "$ACTION" --mac "$MACADDR" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
if [ -n "$MACADDR" ] && [ -n "$IPADDR" ]; then
|
||||||
|
"$BIN" observe neigh --action "$ACTION" --mac "$MACADDR" --ip "$IPADDR" >/dev/null 2>&1 || true
|
||||||
|
elif [ -n "$MACADDR" ]; then
|
||||||
|
"$BIN" observe neigh --action "$ACTION" --mac "$MACADDR" >/dev/null 2>&1 || true
|
||||||
|
elif [ -n "$IPADDR" ]; then
|
||||||
|
"$BIN" observe neigh --action "$ACTION" --ip "$IPADDR" >/dev/null 2>&1 || true
|
||||||
|
else
|
||||||
|
"$BIN" observe neigh --action "$ACTION" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
Loaded 100 of 257 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user