0.2.0 #8
+11
-2
@@ -2,9 +2,18 @@
|
||||
linker = "rust-lld"
|
||||
|
||||
[build]
|
||||
target = "armv7-unknown-linux-musleabihf"
|
||||
# target = "armv7-unknown-linux-musleabihf"
|
||||
|
||||
[alias]
|
||||
ldabr = "b -r --target=target.armv7-unknown-linux-musleabihf"
|
||||
cl = "c --target=armv7-unknown-linux-musleabihf"
|
||||
bl = "b --target=armv7-unknown-linux-musleabihf"
|
||||
tl = "t --target=armv7-unknown-linux-musleabihf"
|
||||
|
||||
cw = "c --target=x86_64-pc-windows-msvc"
|
||||
bw = "b --target=x86_64-pc-windows-msvc"
|
||||
tw = "t --target=x86_64-pc-windows-msvc"
|
||||
|
||||
# idk below
|
||||
ldabr = "b -r --target=armv7-unknown-linux-musleabihf"
|
||||
t = "test -- --nocapture --test-threads=1"
|
||||
tdebug = "test -- --nocapture --test-threads=1 --show-output"
|
||||
|
||||
@@ -9,7 +9,8 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: [self-hosted, windows]
|
||||
# My Machine has arm-musl stuff - or else i have to use cross :(
|
||||
runs-on: [self-hosted, linux, fedora, wsl, release]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -20,6 +21,8 @@ jobs:
|
||||
rustc -V
|
||||
cargo -V
|
||||
rustup target add armv7-unknown-linux-musleabihf
|
||||
rustup target add x86_64-unknown-linux-gnu
|
||||
rustup target add aarch64-unknown-linux-gnu
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
@@ -28,28 +31,62 @@ jobs:
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
key: linux-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Build (armv7-musl)
|
||||
shell: pwsh
|
||||
run: |
|
||||
cargo build --release --target armv7-unknown-linux-musleabihf
|
||||
cargo build --release --target armv7-unknown-linux-musleabihf -p wakey -p wakey-agent
|
||||
|
||||
- name: Build UI dist
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd ui
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
|
||||
- name: Build control-plane (linux gnu)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for target in x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do
|
||||
cargo build --release --target "$target" -p wakey-control-plane
|
||||
done
|
||||
|
||||
- name: Package rootfs tarball
|
||||
shell: pwsh
|
||||
run: |
|
||||
./scripts/package_rootfs.ps1 -Version "${{ github.ref_name }}" -Target armv7-unknown-linux-musleabihf
|
||||
|
||||
- name: Package control-plane bundles
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x scripts/update_wakey_cc.sh scripts/package_wakey_cc_bundle.sh
|
||||
for target in x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do
|
||||
./scripts/package_wakey_cc_bundle.sh --version "${{ github.ref_name }}" --target "$target"
|
||||
done
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: wakey-rootfs
|
||||
path: dist/wakey-rootfs-*armv7-unknown-linux-musleabihf.tgz
|
||||
|
||||
- name: Publish Release to Gitea (API)
|
||||
- name: Upload control-plane bundles
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: wakey-cc-bundles
|
||||
path: dist/wakey-cc-*unknown-linux-gnu.tgz
|
||||
|
||||
- name: Publish Release to Gitea
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
shell: pwsh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
./scripts/ci_gitea_release.ps1 -Tag "$env:GITHUB_REF_NAME" -AssetPattern "dist/wakey-rootfs-*armv7-unknown-linux-musleabihf.tgz"
|
||||
uses: actions/gitea-release-action@v1
|
||||
with:
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
files: |
|
||||
dist/wakey-rootfs-*armv7-unknown-linux-musleabihf.tgz
|
||||
dist/wakey-cc-*unknown-linux-gnu.tgz
|
||||
tag_name: ${{ github.ref_name }}
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
you should use block code to refence code, like so:
|
||||
We work towards clean, modular, maintainable design, clear documentation... im not the best at this thing, my choices can be bad, so you help me.
|
||||
|
||||
```rust
|
||||
hello_world!(println);
|
||||
// indented lines
|
||||
```
|
||||
|
||||
in the chat. Else it fucks up formatting.
|
||||
|
||||
We work towards clean, maintainable design. im not the best at this thing.
|
||||
DRY: spawn subagents.
|
||||
Context: break those files out.
|
||||
|
||||
@@ -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,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,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.
|
||||
Generated
+2455
-223
File diff suppressed because it is too large
Load Diff
+19
-16
@@ -1,45 +1,48 @@
|
||||
[package]
|
||||
name = "wakey"
|
||||
version = "0.1.7"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
axum = { version = "0", features = ["macros"] }
|
||||
axum-extra = { version = "0", features = ["query"] }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
color-eyre = "0"
|
||||
comfy-table = "7"
|
||||
futures = "0"
|
||||
macaddr = { version = "1", features = ["serde", "serde_std"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_html_form = "0"
|
||||
serde_json = "1"
|
||||
serde_with = { version = "3", features = ["json"] }
|
||||
strum = { version = "0", features = ["derive", "strum_macros"] }
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = [
|
||||
"fs",
|
||||
"process",
|
||||
"rt-multi-thread",
|
||||
"io-util",
|
||||
"macros",
|
||||
"time",
|
||||
] }
|
||||
tower-http = { version = "0", features = ["fs"] }
|
||||
urlencoding = "2"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
strip = true
|
||||
|
||||
[features]
|
||||
default = ["very-smart-parsing"]
|
||||
very-smart-parsing = [
|
||||
] # this is the a-bit-redundant parse thing that copilot made
|
||||
|
||||
[workspace]
|
||||
members = ["ipjs"]
|
||||
members = ["ipjs", "wakey-agent", "wakey-control-plane", "wakey-core", "wakey-linux"]
|
||||
|
||||
[dependencies.lda-ipjs]
|
||||
path = "ipjs"
|
||||
registry = "gitea"
|
||||
version = "*"
|
||||
version = "0"
|
||||
|
||||
[dependencies.wakey-core]
|
||||
path = "wakey-core"
|
||||
registry = "gitea"
|
||||
version = "0"
|
||||
|
||||
[dependencies.wakey-linux]
|
||||
path = "wakey-linux"
|
||||
registry = "gitea"
|
||||
version = "0"
|
||||
|
||||
@@ -1,17 +1,397 @@
|
||||
# wakey
|
||||
|
||||
router shit
|
||||
`wakey` is a Wake-on-LAN and LAN-observability tool for a Linux router.
|
||||
|
||||
## whatever i did must be BEAUTIFUL
|
||||
It started as a small web UI running on-device. It is now being reshaped into a
|
||||
service-first project with:
|
||||
|
||||
read [in vietnamese](https://ldlda.com/blogs/else/ssh-ax3000cv2-update-firmware-0-10-2)
|
||||
- a reusable core model
|
||||
- a Linux/OpenWrt adapter layer
|
||||
- a CLI for operators
|
||||
- an outbound agent plus control-plane model
|
||||
|
||||
## now i NEED the split arch to be on
|
||||
## What it does
|
||||
|
||||
like RN rn rn so i can have web outside and the lil agent can be sitting here configuring itself (Parse/append to rc.local files? add to init.d? fuhh)
|
||||
`wakey` currently focuses on a small set of router-side jobs:
|
||||
|
||||
I NEED IT
|
||||
- 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
|
||||
|
||||
I NEEED IT
|
||||
In practice that means commands like:
|
||||
|
||||
I NEEEEEED it.
|
||||
```sh
|
||||
wakey status bedroom-pc
|
||||
wakey leases --include-state
|
||||
wakey devs
|
||||
wakey wake bedroom-pc
|
||||
wakey wake --mac aa:bb:cc:dd:ee:ff
|
||||
```
|
||||
|
||||
## 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
|
||||
- status, leases, inventory, 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.db"
|
||||
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 `sled` database (default
|
||||
`/var/lib/wakey-control-plane/state.db`).
|
||||
Relative paths in config are resolved under `data_dir`.
|
||||
|
||||
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/Caddyfile.control-plane.example`
|
||||
|
||||
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
|
||||
WAKEY_CC_VERSION=v0.1.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.
|
||||
|
||||
### Status
|
||||
|
||||
Show device status rows using a free-form selector:
|
||||
|
||||
```sh
|
||||
wakey status bedroom-pc
|
||||
```
|
||||
|
||||
Or use explicit filters:
|
||||
|
||||
```sh
|
||||
wakey status --dev br-lan --nud reachable
|
||||
wakey status --mac aa:bb:cc:dd:ee:ff
|
||||
wakey status --json
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
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,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.
|
||||
@@ -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.
|
||||
|
||||
cp.example.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:8787
|
||||
}
|
||||
|
||||
# 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:8787
|
||||
}
|
||||
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=wakey
|
||||
Group=wakey
|
||||
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
|
||||
+15
-3
@@ -1,10 +1,14 @@
|
||||
[package]
|
||||
name = "lda-ipjs"
|
||||
description = "ip -j show schemas"
|
||||
version = "0.0.2"
|
||||
version = "0.0.3"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
experimental-nl = ["dep:rtnetlink"]
|
||||
|
||||
[dependencies]
|
||||
macaddr = { version = "1", features = ["serde", "serde_std"] }
|
||||
strum = { version = "0", features = ["derive", "strum_macros"] }
|
||||
@@ -13,7 +17,15 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_with = { version = "3", features = ["json"] }
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
tokio = { version = "1", features = ["fs", "process", "rt-multi-thread", "io-util", "macros"] }
|
||||
rtnetlink = "0"
|
||||
tokio = { version = "1", features = [
|
||||
"fs",
|
||||
"process",
|
||||
"rt-multi-thread",
|
||||
"io-util",
|
||||
"macros",
|
||||
] }
|
||||
futures = "0"
|
||||
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
rtnetlink = { version = "0", optional = true }
|
||||
|
||||
@@ -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.
|
||||
@@ -13,7 +13,6 @@
|
||||
//! i also need to see devices and idk MAYBE maybe not MAYBE UHHHHHH maybe broadcast
|
||||
//!
|
||||
//! LOWK if this were to be calls to kernel or some bullshit then PLEASE because doing ts parsing its hell cuh
|
||||
|
||||
pub mod subcommands;
|
||||
pub mod utils;
|
||||
|
||||
|
||||
@@ -1,42 +1,241 @@
|
||||
//! ts
|
||||
//! Typed wrappers for `ip -j address show`.
|
||||
//!
|
||||
//! deals with both ip a (addroutput) and ip l (commonoutput)
|
||||
//!
|
||||
//! lowk why its free but its indirection and its ass
|
||||
//! This module is intentionally close to the Linux output shape while still
|
||||
//! tightening a few fields into more useful Rust types.
|
||||
|
||||
pub mod json;
|
||||
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||
pub mod nl;
|
||||
|
||||
pub use crate::subcommands::Backend;
|
||||
use crate::utils::serialize::mac::option_mac;
|
||||
use macaddr::MacAddr;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use std::fmt;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
/// i dont include what i dont know about (almost all ts)
|
||||
use crate::subcommands::link::OperState;
|
||||
|
||||
/// One interface row from `ip -j address show`.
|
||||
#[derive(Serialize, Debug, Deserialize)]
|
||||
pub struct AddrOutput {
|
||||
pub ifindex: u32,
|
||||
pub ifname: String,
|
||||
/// i imagine UP or DOWN, unknown
|
||||
pub operstate: String,
|
||||
// 6 has a serde and the enum doesnt? why. (serializing ts is ass although... im not given an array. they string formatted ts)
|
||||
/// Interface operational state.
|
||||
pub operstate: OperState,
|
||||
#[serde(with = "option_mac", default)]
|
||||
pub address: Option<MacAddr>,
|
||||
#[serde(default)] // i wish we have intellisense for this... fuck you metaprogramming
|
||||
/// Per-address entries attached to this interface.
|
||||
#[serde(default)]
|
||||
pub addr_info: Vec<AddrInfo>,
|
||||
}
|
||||
|
||||
// i be copying
|
||||
// Raw JSON shape from ip -j -4 address show
|
||||
/// One address entry nested under an interface row.
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct AddrInfo {
|
||||
pub family: Option<String>,
|
||||
pub family: Option<AddressFamily>,
|
||||
|
||||
pub local: Option<String>,
|
||||
pub prefixlen: Option<u8>,
|
||||
#[serde(flatten, default)]
|
||||
pub cidr: InterfaceCidr,
|
||||
|
||||
pub broadcast: Option<String>,
|
||||
pub broadcast: Option<Ipv4Addr>,
|
||||
|
||||
pub scope: Option<String>,
|
||||
pub label: Option<String>,
|
||||
// many more exist; we only take what we need
|
||||
}
|
||||
|
||||
/// Address family used by `ip address` output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AddressFamily {
|
||||
Inet,
|
||||
Inet6,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl AddressFamily {
|
||||
pub fn parse_lossy(value: &str) -> Self {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"inet" => Self::Inet,
|
||||
"inet6" => Self::Inet6,
|
||||
_ => Self::Other,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Inet => "inet",
|
||||
Self::Inet6 => "inet6",
|
||||
Self::Other => "other",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for AddressFamily {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for AddressFamily {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = String::deserialize(deserializer)?;
|
||||
Ok(Self::parse_lossy(&value))
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw parsed local address plus prefix length.
|
||||
///
|
||||
/// This is still a source-shaped type; callers that need a guaranteed usable
|
||||
/// CIDR should validate that both fields are present.
|
||||
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
|
||||
pub struct InterfaceCidr {
|
||||
pub local: Option<IpAddr>,
|
||||
pub prefixlen: Option<u8>,
|
||||
}
|
||||
|
||||
impl InterfaceCidr {
|
||||
/// Return whether both parts needed for a usable CIDR are present.
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.local.is_some() && self.prefixlen.is_some()
|
||||
}
|
||||
|
||||
/// Return a validated complete CIDR when both fields are present.
|
||||
pub fn complete(&self) -> Option<CompleteInterfaceCidr> {
|
||||
Some(CompleteInterfaceCidr {
|
||||
local: self.local?,
|
||||
prefixlen: self.prefixlen?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Format the CIDR as `addr/prefixlen` when both fields are present.
|
||||
pub fn to_cidr_string(&self) -> Option<String> {
|
||||
self.complete().map(|cidr| cidr.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Validated interface CIDR with both local address and prefix length present.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CompleteInterfaceCidr {
|
||||
pub local: IpAddr,
|
||||
pub prefixlen: u8,
|
||||
}
|
||||
|
||||
impl fmt::Display for CompleteInterfaceCidr {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}/{}", self.local, self.prefixlen)
|
||||
}
|
||||
}
|
||||
|
||||
impl AddrInfo {
|
||||
/// Return the parsed local IP address when present.
|
||||
pub fn local_addr(&self) -> Option<IpAddr> {
|
||||
self.cidr.local
|
||||
}
|
||||
|
||||
/// Return the parsed prefix length when present.
|
||||
pub fn prefixlen(&self) -> Option<u8> {
|
||||
self.cidr.prefixlen
|
||||
}
|
||||
|
||||
/// Return whether this row is IPv4.
|
||||
pub fn is_ipv4(&self) -> bool {
|
||||
matches!(self.family, Some(AddressFamily::Inet))
|
||||
}
|
||||
|
||||
/// Return whether this row is IPv6.
|
||||
pub fn is_ipv6(&self) -> bool {
|
||||
matches!(self.family, Some(AddressFamily::Inet6))
|
||||
}
|
||||
}
|
||||
|
||||
impl AddrOutput {
|
||||
/// Iterate IPv4 address entries.
|
||||
pub fn ipv4_addrs(&self) -> impl Iterator<Item = &AddrInfo> {
|
||||
self.addr_info.iter().filter(|info| info.is_ipv4())
|
||||
}
|
||||
|
||||
/// Iterate IPv6 address entries.
|
||||
pub fn ipv6_addrs(&self) -> impl Iterator<Item = &AddrInfo> {
|
||||
self.addr_info.iter().filter(|info| info.is_ipv6())
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch address data using the default backend.
|
||||
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<AddrOutput>> {
|
||||
get_with_backend(Backend::Json, dev).await
|
||||
}
|
||||
|
||||
/// Fetch address data using an explicit backend.
|
||||
pub async fn get_with_backend(
|
||||
backend: Backend,
|
||||
dev: Option<&str>,
|
||||
) -> anyhow::Result<Vec<AddrOutput>> {
|
||||
match backend {
|
||||
Backend::Json => json::get(dev).await,
|
||||
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||
Backend::Netlink => nl::get(dev).await,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
use super::{AddrInfo, AddrOutput, AddressFamily, InterfaceCidr};
|
||||
use crate::subcommands::link::OperState;
|
||||
|
||||
#[test]
|
||||
fn address_family_parses_known_values() {
|
||||
assert_eq!(AddressFamily::parse_lossy("inet"), AddressFamily::Inet);
|
||||
assert_eq!(AddressFamily::parse_lossy("INET6"), AddressFamily::Inet6);
|
||||
assert_eq!(AddressFamily::parse_lossy("weird"), AddressFamily::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn addr_info_deserializes_typed_ip_fields() {
|
||||
let info: AddrInfo = serde_json::from_str(
|
||||
r#"{"family":"inet","local":"192.168.1.1","prefixlen":24,"broadcast":"192.168.1.255","scope":"global"}"#,
|
||||
)
|
||||
.expect("addr_info json should deserialize");
|
||||
|
||||
assert_eq!(info.family, Some(AddressFamily::Inet));
|
||||
assert_eq!(
|
||||
info.local_addr(),
|
||||
Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)))
|
||||
);
|
||||
assert_eq!(info.prefixlen(), Some(24));
|
||||
assert_eq!(info.broadcast, Some(Ipv4Addr::new(192, 168, 1, 255)));
|
||||
assert!(info.cidr.is_complete());
|
||||
assert_eq!(
|
||||
info.cidr.to_cidr_string().as_deref(),
|
||||
Some("192.168.1.1/24")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn addr_output_deserializes_typed_operstate() {
|
||||
let output: AddrOutput = serde_json::from_str(
|
||||
r#"{"ifindex":2,"ifname":"br-lan","operstate":"UP","address":"aa:bb:cc:dd:ee:ff","addr_info":[]}"#,
|
||||
)
|
||||
.expect("addr_output json should deserialize");
|
||||
|
||||
assert_eq!(output.operstate, OperState::Up);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_cidr_complete_formats() {
|
||||
let cidr = InterfaceCidr {
|
||||
local: Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))),
|
||||
prefixlen: Some(16),
|
||||
};
|
||||
|
||||
let complete = cidr.complete().expect("cidr should be complete");
|
||||
assert_eq!(complete.to_string(), "10.0.0.1/16");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,107 @@
|
||||
//! i said i aint doing ts no more why am i still here
|
||||
#![cfg(unix)]
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use futures::TryStreamExt;
|
||||
use rtnetlink::Handle;
|
||||
use rtnetlink::packet_route::{AddressFamily, address::AddressAttribute};
|
||||
|
||||
use crate::subcommands::address::AddrOutput;
|
||||
use crate::subcommands::link::LinkOutput;
|
||||
use crate::subcommands::{
|
||||
address::{AddrInfo, AddrOutput, AddressFamily as IpAddressFamily, InterfaceCidr},
|
||||
link,
|
||||
};
|
||||
|
||||
// shit this one is even worse you needa collect info from two places
|
||||
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<AddrOutput>> {
|
||||
let (conn, handle, _) = rtnetlink::new_connection()?;
|
||||
tokio::spawn(conn); // every time?
|
||||
let mut address = handle.address().get();
|
||||
let mut link = handle.link().get();
|
||||
if let Some(dev) = dev {
|
||||
link = link.match_name(dev.to_owned());
|
||||
if let Some(ind) = link.execute().try_next().await?.map(|a| a.header.index) {
|
||||
address = address.set_link_index_filter(ind);
|
||||
};
|
||||
address.execute().try_next().await?;
|
||||
} else {
|
||||
};
|
||||
todo!();
|
||||
tokio::spawn(conn);
|
||||
|
||||
get_with_handle(&handle, dev).await
|
||||
}
|
||||
|
||||
pub async fn get_with_handle(
|
||||
handle: &Handle,
|
||||
dev: Option<&str>,
|
||||
) -> anyhow::Result<Vec<AddrOutput>> {
|
||||
let links = link::nl::get_with_handle(handle, dev).await?;
|
||||
let link_by_index: BTreeMap<u32, LinkOutput> =
|
||||
links.into_iter().map(|link| (link.ifindex, link)).collect();
|
||||
|
||||
let mut address = handle.address().get();
|
||||
if let Some(dev) = dev {
|
||||
if let Some(index) = link_by_index
|
||||
.values()
|
||||
.find(|link| link.ifname == dev)
|
||||
.map(|link| link.ifindex)
|
||||
{
|
||||
address = address.set_link_index_filter(index);
|
||||
} else {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
let mut stream = address.execute();
|
||||
let mut addr_info_by_index: BTreeMap<u32, Vec<AddrInfo>> = BTreeMap::new();
|
||||
|
||||
while let Some(msg) = stream.try_next().await? {
|
||||
if !matches!(
|
||||
msg.header.family,
|
||||
AddressFamily::Inet | AddressFamily::Inet6
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let family = match msg.header.family {
|
||||
AddressFamily::Inet => Some(IpAddressFamily::Inet),
|
||||
AddressFamily::Inet6 => Some(IpAddressFamily::Inet6),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let prefixlen = Some(msg.header.prefix_len);
|
||||
let scope = Some(format!("{:?}", msg.header.scope).to_lowercase());
|
||||
let mut local = None;
|
||||
let mut broadcast = None;
|
||||
let mut label = None;
|
||||
|
||||
for attr in msg.attributes {
|
||||
match attr {
|
||||
AddressAttribute::Address(addr) | AddressAttribute::Local(addr) => {
|
||||
if local.is_none() {
|
||||
local = addr.to_string().parse().ok();
|
||||
}
|
||||
}
|
||||
AddressAttribute::Broadcast(addr) => {
|
||||
broadcast = addr.to_string().parse().ok();
|
||||
}
|
||||
AddressAttribute::Label(name) => {
|
||||
label = Some(name);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
addr_info_by_index
|
||||
.entry(msg.header.index)
|
||||
.or_default()
|
||||
.push(AddrInfo {
|
||||
family,
|
||||
cidr: InterfaceCidr { local, prefixlen },
|
||||
broadcast,
|
||||
scope,
|
||||
label,
|
||||
});
|
||||
}
|
||||
|
||||
let out = link_by_index
|
||||
.into_values()
|
||||
.map(|link| AddrOutput {
|
||||
ifindex: link.ifindex,
|
||||
ifname: link.ifname,
|
||||
operstate: link.operstate.unwrap_or(link::OperState::Unknown),
|
||||
address: link.address,
|
||||
addr_info: addr_info_by_index.remove(&link.ifindex).unwrap_or_default(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1 +1,153 @@
|
||||
//! this is for link. You need link; at least to build an index -> name map. i Need It. sometimes.
|
||||
//! 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()).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)
|
||||
}
|
||||
@@ -1,2 +1,10 @@
|
||||
pub mod address;
|
||||
pub mod link;
|
||||
pub mod neighbor;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Backend {
|
||||
Json,
|
||||
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||
Netlink,
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
//! ```bash
|
||||
//! ip -j n s
|
||||
//! ```
|
||||
//!
|
||||
//! yes. this is a real call.
|
||||
//! Typed wrappers for `ip -j neigh show`.
|
||||
|
||||
pub mod json;
|
||||
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||
pub mod nl;
|
||||
|
||||
pub use crate::subcommands::Backend;
|
||||
use crate::utils::serialize::mac::option_mac;
|
||||
use std::net::IpAddr;
|
||||
|
||||
@@ -14,18 +12,18 @@ use macaddr::MacAddr;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use strum::{Display, EnumString};
|
||||
|
||||
/// Structured neighbor query input matching the common `ip neigh` flags.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
|
||||
pub struct NeighborInput {
|
||||
/// supports only the last item (ignore), `to` keyword is optional
|
||||
/// Destination address filter. The optional `to` keyword in CLI form is implicit here.
|
||||
pub to: Option<IpAddr>,
|
||||
/// supports only one item (it complains if multiple)
|
||||
pub dev: Option<String>, // im all for simplicity
|
||||
/// takes multiple, has to have `nud` before bro or it will think you `to`
|
||||
/// Interface-name filter.
|
||||
pub dev: Option<String>,
|
||||
/// Neighbor-state filters.
|
||||
pub nud: Vec<NUDState>,
|
||||
}
|
||||
|
||||
// as input this must be lowercase. as output it is uppercase
|
||||
/// docs for items come from a random ahh man website idk
|
||||
/// Linux neighbor reachability states.
|
||||
#[derive(
|
||||
Debug, PartialEq, Eq, EnumString, Display, Clone, Copy, Hash, Serialize, Deserialize, Default,
|
||||
)]
|
||||
@@ -68,7 +66,7 @@ pub enum NUDState {
|
||||
Other(u16),
|
||||
}
|
||||
|
||||
/// everything i see
|
||||
/// One neighbor row from `ip -j neigh show`.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
|
||||
pub struct NeighborItem {
|
||||
#[serde(rename(deserialize = "dst"))]
|
||||
@@ -80,3 +78,30 @@ pub struct NeighborItem {
|
||||
#[serde(default)]
|
||||
pub state: Vec<NUDState>,
|
||||
}
|
||||
|
||||
/// Fetch neighbor rows using the default backend.
|
||||
pub async fn get(
|
||||
ip: Option<IpAddr>,
|
||||
dev: Option<&str>,
|
||||
nud: &[NUDState],
|
||||
) -> anyhow::Result<Vec<NeighborItem>> {
|
||||
get_with_backend(Backend::Json, ip, dev, nud).await
|
||||
}
|
||||
|
||||
/// Fetch neighbor rows using an explicit backend.
|
||||
pub async fn get_with_backend(
|
||||
backend: Backend,
|
||||
ip: Option<IpAddr>,
|
||||
dev: Option<&str>,
|
||||
nud: &[NUDState],
|
||||
) -> anyhow::Result<Vec<NeighborItem>> {
|
||||
match backend {
|
||||
Backend::Json => json::get(ip, dev, nud).await,
|
||||
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||
Backend::Netlink => {
|
||||
let ips: Vec<IpAddr> = ip.into_iter().collect();
|
||||
let devs: Vec<&str> = dev.into_iter().collect();
|
||||
nl::get(&ips, &devs, nud, &[]).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! rtnetlink-based neighbor table query. One syscall, filter in userspace.
|
||||
|
||||
#![cfg(unix)]
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
net::IpAddr,
|
||||
@@ -7,13 +7,14 @@ use std::{
|
||||
|
||||
use futures::TryStreamExt;
|
||||
use macaddr::MacAddr;
|
||||
use rtnetlink::Handle;
|
||||
use rtnetlink::packet_route::{
|
||||
AddressFamily,
|
||||
link::LinkAttribute,
|
||||
neighbour::{NeighbourAddress, NeighbourAttribute, NeighbourState},
|
||||
};
|
||||
|
||||
use super::{NUDState, NeighborItem};
|
||||
use crate::subcommands::link;
|
||||
|
||||
/// Fetch neighbors via rtnetlink. Empty slice = no filter (match all).
|
||||
/// Non-empty slice = match ANY in the set.
|
||||
@@ -27,16 +28,30 @@ pub async fn get(
|
||||
let (conn, handle, _) = rtnetlink::new_connection()?;
|
||||
tokio::spawn(conn);
|
||||
|
||||
let mut neighbor_data = handle.neighbours().get().execute();
|
||||
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();
|
||||
|
||||
// Cache ifindex -> name
|
||||
let mut ifname_cache: HashMap<u32, String> = HashMap::new();
|
||||
// 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? {
|
||||
@@ -72,28 +87,7 @@ pub async fn get(
|
||||
}
|
||||
|
||||
// Resolve ifindex -> name (cached)
|
||||
let dev = match ifname_cache.get(&msg.header.ifindex) {
|
||||
Some(name) => Some(name.clone()),
|
||||
None => {
|
||||
let name = handle
|
||||
.link()
|
||||
.get()
|
||||
.match_index(msg.header.ifindex)
|
||||
.execute()
|
||||
.try_next()
|
||||
.await?
|
||||
.and_then(|link| {
|
||||
link.attributes.into_iter().find_map(|a| match a {
|
||||
LinkAttribute::IfName(n) => Some(n),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
if let Some(ref n) = name {
|
||||
ifname_cache.insert(msg.header.ifindex, n.clone());
|
||||
}
|
||||
name
|
||||
}
|
||||
};
|
||||
let dev = ifname_cache.get(&msg.header.ifindex).cloned();
|
||||
|
||||
let (Some(ip), Some(dev)) = (ip, dev) else {
|
||||
continue 'row;
|
||||
|
||||
+26
-21
@@ -2,16 +2,17 @@ use std::collections::HashSet;
|
||||
|
||||
use lda_ipjs::subcommands::{address, neighbor};
|
||||
|
||||
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||
#[tokio::test] // ← Use tokio::test instead of manual #[tokio::main]
|
||||
async fn ball1() -> anyhow::Result<()> {
|
||||
let result = neighbor::nl::get(&[], &[] as &[&str], &[], &[]).await?;
|
||||
let result = neighbor::get_with_backend(neighbor::Backend::Netlink, None, None, &[]).await?;
|
||||
println!("netlink results: {:?}", result);
|
||||
Ok(()) // ← Don't force error, let it succeed
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ball2() -> anyhow::Result<()> {
|
||||
let result = neighbor::json::get(None, None, &[]).await?;
|
||||
let result = neighbor::get_with_backend(neighbor::Backend::Json, None, None, &[]).await?;
|
||||
println!("json results: {:?}", result);
|
||||
Ok(())
|
||||
}
|
||||
@@ -91,30 +92,34 @@ impl TypeName for serde_json::Value {
|
||||
#[tokio::test]
|
||||
async fn ball_compare_backends() -> anyhow::Result<()> {
|
||||
println!("=== JSON Backend ===");
|
||||
let json_result = neighbor::json::get(None, None, &[]).await?;
|
||||
let json_result = neighbor::get_with_backend(neighbor::Backend::Json, None, None, &[]).await?;
|
||||
println!("Got {} entries from JSON", json_result.len());
|
||||
|
||||
println!("\n=== Netlink Backend ===");
|
||||
let nl_result = neighbor::nl::get(&[], &[] as &[&str], &[], &[]).await?;
|
||||
println!("Got {} entries from netlink", nl_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() {
|
||||
// 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!(
|
||||
"\n⚠️ Count mismatch! JSON: {}, Netlink: {}",
|
||||
json_result.len(),
|
||||
nl_result.len()
|
||||
"istg {len1} == {len2} or else",
|
||||
len1 = a.len(),
|
||||
len2 = b.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(())
|
||||
}
|
||||
@@ -126,7 +131,7 @@ async fn ball_compare_backends() -> anyhow::Result<()> {
|
||||
|
||||
#[tokio::test]
|
||||
async fn ipjas() -> anyhow::Result<()> {
|
||||
let cuh = address::json::get(None).await?;
|
||||
let cuh = address::get_with_backend(address::Backend::Json, None).await?;
|
||||
println!("{cuh:#?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!.gitignore
|
||||
!copy.ps1
|
||||
+11
-1
@@ -1,16 +1,26 @@
|
||||
#!/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"
|
||||
$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\"
|
||||
|
||||
+79
-7
@@ -4,13 +4,13 @@ This folder has small helpers for build, CI, and router install. Keep it simple;
|
||||
|
||||
## Quick start (recommended)
|
||||
|
||||
1. Start your Gitea runner on this Windows PC (if not already running):
|
||||
1. Start your Fedora/WSL Gitea runner (recommended release path):
|
||||
|
||||
```powershell
|
||||
./scripts/act_runner.ps1
|
||||
./scripts/act_runner_wsl.ps1 -Action start -Attach
|
||||
```
|
||||
|
||||
If first-time, it prints the exact register command. Run it once, check labels include `windows:host,self-hosted`, then rerun the script.
|
||||
If first-time, use the runbook in [`WSL_RUNNER.md`](./WSL_RUNNER.md) to update and register the Fedora runner first.
|
||||
|
||||
2. Publish, tag, and push (recommended):
|
||||
|
||||
@@ -18,7 +18,7 @@ This folder has small helpers for build, CI, and router install. Keep it simple;
|
||||
./scripts/publish.ps1 -Tag
|
||||
```
|
||||
|
||||
This will bump the version (if you specify one), build, ensure the runner is started, tag, and push. To also publish to the registry, add `-Publish`.
|
||||
This will bump the version (if you specify one), build locally, tag, push, and start the Fedora/WSL runner in `--once` mode by default. To also publish to the registry, add `-Publish`.
|
||||
|
||||
3. Install on the router
|
||||
|
||||
@@ -38,19 +38,91 @@ This folder has small helpers for build, CI, and router install. Keep it simple;
|
||||
## Scripts overview
|
||||
|
||||
- `act_runner.ps1` — Start/seed the local Gitea runner. Use `-Attach` to see logs, `-ForceConfigure` to register non-interactively.
|
||||
- `act_runner_wsl.ps1` — Windows PowerShell wrapper that controls the Fedora/WSL runner.
|
||||
- `act_runner_fedora.sh` — Linux/Fedora helper used inside WSL for update/register/start/stop/status.
|
||||
- `dev_push.ps1` — Fast dev loop: build + upload to router. Usage:
|
||||
- `./scripts/dev_push.ps1 -Pass <password> [-HostName <ip>] [-RemotePath </root/.bin/wakey>] [-Restart] [-Quiet]`
|
||||
- Uploads to `<RemotePath>.tmp` then atomically moves into place; `-Restart` restarts the service; `-Quiet` silences MOTD.
|
||||
- `package_rootfs.ps1` — Produces `dist/wakey-rootfs-<version>-<target>.tgz` with `/root/.bin/wakey` and `/etc/init.d/*`.
|
||||
- `update_wakey_cc.sh` — Linux VPS updater for control-plane bundles; extracts into the selected directory (default: current directory) and restarts `wakey-cc.service`.
|
||||
- `package_wakey_cc_bundle.sh` — Produces `dist/wakey-cc-<version>-<target>.tgz` with `bin/wakey-control-plane`, `ui/dist/*`, updater script, and systemd template.
|
||||
- `publish.ps1` — Optional version bump + build + tag (and `cargo publish` only if you pass `-Publish`).
|
||||
- `test_remote.ps1` — Build ARM Linux test binaries locally, upload them to the router, and run them there.
|
||||
|
||||
## VPS updater (control-plane)
|
||||
|
||||
```sh
|
||||
chmod +x ./scripts/update_wakey_cc.sh
|
||||
cd /opt/wakey
|
||||
WAKEY_CC_VERSION=v0.1.0 WAKEY_CC_TARGET=x86_64-unknown-linux-gnu \
|
||||
sudo -E ./scripts/update_wakey_cc.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 status_real_router_default_query_succeeds -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
|
||||
|
||||
## CI (Gitea)
|
||||
|
||||
- Defined in `.gitea/workflows/release.yml`.
|
||||
- Runs on your `self-hosted, windows` runner.
|
||||
- Steps: cross build → package rootfs → upload artifact (v3) → publish a release and attach tgz.
|
||||
- Release job now targets your Fedora/WSL runner labels: `self-hosted`, `linux`, `fedora`, `wsl`, `release`.
|
||||
- Steps: Linux-native build of `wakey` + `wakey-agent` → package rootfs → upload artifact (v3) → publish a release and attach tgz.
|
||||
- Requires `secrets.GITEA_TOKEN` in the repo to publish the release.
|
||||
|
||||
## Runner migration
|
||||
|
||||
- Fedora/WSL is now the intended release-builder path.
|
||||
- The Windows runner can stay registered in parallel during migration and fallback.
|
||||
- The detailed operator runbook is in [`WSL_RUNNER.md`](./WSL_RUNNER.md).
|
||||
|
||||
## Local build/install (manual path)
|
||||
|
||||
```powershell
|
||||
@@ -76,4 +148,4 @@ Or you can use the script:
|
||||
./scripts/dev_push.ps1 -Pass <password>
|
||||
```
|
||||
|
||||
That’s it. Keep the flow: start runner → tag push → install.
|
||||
That’s it. Keep the flow: start Fedora/WSL runner → tag push → install.
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# Fedora / WSL Gitea Runner
|
||||
|
||||
This repo now expects Linux/router release builds to run on a Fedora 43 runner
|
||||
inside WSL, not on the Windows host runner.
|
||||
|
||||
The goal is simple:
|
||||
|
||||
- build `wakey` and `wakey-agent` in a Linux-native environment
|
||||
- stop fighting Windows-host ARM/musl/TLS toolchain issues
|
||||
- keep PowerShell on Windows as the operator front door
|
||||
|
||||
## Runner layout
|
||||
|
||||
Recommended WSL location:
|
||||
|
||||
```text
|
||||
/mnt/c/Users/Admin/Documents/realshit/wakey/ar_data/wsl_runner
|
||||
```
|
||||
|
||||
This matches the default used by `act_runner_wsl.ps1` and
|
||||
`act_runner_fedora.sh`, so WSL runner config is isolated at
|
||||
`ar_data/wsl_runner/config.yaml` in this repo.
|
||||
|
||||
Recommended labels:
|
||||
|
||||
```text
|
||||
self-hosted,linux,fedora,wsl,release:host
|
||||
```
|
||||
|
||||
The Windows runner can stay registered during migration as fallback.
|
||||
|
||||
## Fedora setup
|
||||
|
||||
Install the basics in Fedora 43:
|
||||
|
||||
```bash
|
||||
sudo dnf install -y git curl tar gzip findutils python3
|
||||
sudo dnf install -y powershell
|
||||
```
|
||||
|
||||
Install Rust and the router target:
|
||||
|
||||
```bash
|
||||
rustup target add armv7-unknown-linux-musleabihf
|
||||
```
|
||||
|
||||
Install the native cross toolchain pieces you need for Linux/ARM router builds.
|
||||
The exact package names can vary over time, so confirm the current Fedora names
|
||||
for:
|
||||
|
||||
- ARM Linux GCC
|
||||
- musl development/toolchain support
|
||||
- OpenSSL/ring/TLS build prerequisites if needed by your dependency graph
|
||||
|
||||
## Runner lifecycle
|
||||
|
||||
From Windows PowerShell, using the WSL wrapper:
|
||||
|
||||
```powershell
|
||||
./scripts/act_runner_wsl.ps1 -Action update
|
||||
./scripts/act_runner_wsl.ps1 -Action register -ServerUrl https://git.ldlda.com/ -Token <runner-token>
|
||||
./scripts/act_runner_wsl.ps1 -Action start -Attach
|
||||
./scripts/act_runner_wsl.ps1 -Action start -Once
|
||||
./scripts/act_runner_wsl.ps1 -Action status
|
||||
./scripts/act_runner_wsl.ps1 -Action stop
|
||||
```
|
||||
|
||||
Path control (binary and config are independent):
|
||||
|
||||
```powershell
|
||||
./scripts/act_runner_wsl.ps1 -Action status `
|
||||
-RunnerBin /home/lda/gitea-runner/act_runner `
|
||||
-ConfigPath /mnt/c/Users/Admin/Documents/realshit/wakey/ar_data/wsl_runner/config.yaml
|
||||
```
|
||||
|
||||
If your distro name is not `FedoraLinux-43`, set it explicitly:
|
||||
|
||||
```powershell
|
||||
./scripts/act_runner_wsl.ps1 -Distro FedoraLinux-43 -Action status
|
||||
```
|
||||
|
||||
Or set:
|
||||
|
||||
```powershell
|
||||
$env:WAKEY_WSL_DISTRO = "FedoraLinux-43"
|
||||
```
|
||||
|
||||
Inside Fedora directly, you can also use:
|
||||
|
||||
```bash
|
||||
./scripts/act_runner_fedora.sh update
|
||||
./scripts/act_runner_fedora.sh register --server-url https://git.ldlda.com/ --token <runner-token>
|
||||
./scripts/act_runner_fedora.sh start --attach
|
||||
./scripts/act_runner_fedora.sh start --once
|
||||
./scripts/act_runner_fedora.sh status
|
||||
./scripts/act_runner_fedora.sh stop
|
||||
```
|
||||
|
||||
## Release flow
|
||||
|
||||
The release workflow is expected to run on the Fedora/WSL runner labels:
|
||||
|
||||
```text
|
||||
self-hosted + linux + fedora + wsl + release
|
||||
```
|
||||
|
||||
It builds:
|
||||
|
||||
- `wakey`
|
||||
- `wakey-agent`
|
||||
|
||||
and packages both into the rootfs tarball.
|
||||
|
||||
From Windows, the intended operator flow remains:
|
||||
|
||||
```powershell
|
||||
./scripts/publish.ps1 -Tag
|
||||
```
|
||||
|
||||
That script should trigger the WSL runner in `--once` mode by default.
|
||||
|
||||
## Rollback
|
||||
|
||||
If the Fedora runner path is broken:
|
||||
|
||||
1. stop using the WSL runner labels in the workflow
|
||||
2. temporarily point the workflow back to the Windows runner labels
|
||||
3. use the existing Windows runner as fallback while fixing the Fedora path
|
||||
|
||||
Do not delete the Windows runner until the Fedora path has already produced at
|
||||
least one successful full release and one successful router install.
|
||||
Regular → Executable
+5
-4
@@ -1,12 +1,13 @@
|
||||
# Starts the Gitea act_runner if not already running.
|
||||
# Usage: ./scripts/act_runner.ps1 -Config "C:/Users/Admin/Documents/gitea/runner.yaml" -RunnerPath "C:/Users/Admin/Documents/gitea/act_runner.exe" -ServerUrl "https://git.ldlda.com/" -Token "<reg token>" -Labels "self-hosted,windows"
|
||||
#!/usr/bin/env pwsh
|
||||
# Starts the Windows-host Gitea act_runner if not already running.
|
||||
# Usage: ./scripts/act_runner.ps1 -Config "C:/Users/Admin/Documents/gitea/runner.yaml" -RunnerPath "C:/Users/Admin/Documents/gitea/act_runner.exe" -ServerUrl "https://git.ldlda.com/" -Token "<reg token>" -Labels "self-hosted,windows:host"
|
||||
param(
|
||||
[string]$RunnerPath = "$HOME/Documents/gitea/act_runner.exe",
|
||||
# Config is a FILE path (e.g., runner.yaml). Parent folder will be created if missing.
|
||||
[string]$Config = (Join-Path (Split-Path -Parent $PSScriptRoot) 'ar_data/config.yaml'),
|
||||
[string]$ServerUrl,
|
||||
[string]$Token,
|
||||
[string]$Labels = "windows:host,self-hosted",
|
||||
[string]$Labels = "self-hosted,windows:host",
|
||||
# Opt-in to non-interactive configure; by default we print the command for you to run manually.
|
||||
[switch]$ForceConfigure,
|
||||
# When -Attach, run in the foreground to see logs (good for first-time troubleshooting)
|
||||
@@ -46,7 +47,7 @@ if (-not (Test-Path $configFile)) {
|
||||
Pop-Location
|
||||
}
|
||||
else {
|
||||
Write-Host "Config not found. Run this manually once (note labels embed host executor on Windows):"
|
||||
Write-Host "Config not found. Run this manually once (Windows-host runner labels):"
|
||||
Write-Host "`t$RunnerPath register --no-interactive --config `"$configFile`" --instance `"$ServerUrl`" --token `"$Token`" --labels `"$Labels`""
|
||||
exit 2
|
||||
}
|
||||
|
||||
Executable
+212
@@ -0,0 +1,212 @@
|
||||
#!/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}"
|
||||
|
||||
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}"
|
||||
ensure_dirs
|
||||
ensure_runner_bin
|
||||
|
||||
if [[ ! -f "$CONFIG_PATH" ]]; then
|
||||
echo "Runner config not found at $CONFIG_PATH" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
case "$mode" in
|
||||
attach)
|
||||
exec "$RUNNER_BIN" daemon --config "$CONFIG_PATH"
|
||||
;;
|
||||
once)
|
||||
exec "$RUNNER_BIN" daemon --config "$CONFIG_PATH" --once
|
||||
;;
|
||||
daemon)
|
||||
nohup "$RUNNER_BIN" daemon --config "$CONFIG_PATH" >/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
|
||||
|
||||
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)"
|
||||
Regular → Executable
+51
-20
@@ -1,13 +1,22 @@
|
||||
#!/usr/bin/env pwsh
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidDefaultValueSwitchParameter', "",
|
||||
Justification = 'Default true is intentional for fast dev loop')]
|
||||
param(
|
||||
[ValidateSet("cargo", "cross")]
|
||||
[string]$Cargo = "cargo",
|
||||
[string]$Pass,
|
||||
[string]$HostName = "192.168.100.1",
|
||||
[int]$Port = 22,
|
||||
[string]$User = "root",
|
||||
[string]$RemotePath = "/root/.bin/wakey",
|
||||
[string]$AgentRemotePath = "/root/.bin/wakey-agent",
|
||||
[string]$RemoteInitPath = "/etc/init.d/wakey",
|
||||
[string]$Target = "armv7-unknown-linux-musleabihf",
|
||||
[string]$BinName = "wakey",
|
||||
[string]$AgentBinName = "wakey-agent",
|
||||
[string]$HostKey,
|
||||
[switch]$ForcePassword,
|
||||
[switch]$SkipInitScript,
|
||||
[switch]$Restart = $true,
|
||||
[switch]$Quiet = $true
|
||||
)
|
||||
@@ -17,6 +26,13 @@ try { $PSStyle.OutputRendering = 'Host' } catch {}
|
||||
|
||||
. "$PSScriptRoot/lib.ps1"
|
||||
|
||||
$Pass = Get-DefaultPassword $Pass
|
||||
|
||||
# Backward compatibility: allow HostName in the form host:port.
|
||||
$hostPort = Split-HostPort -HostName $HostName -DefaultPort $Port
|
||||
$HostName = $hostPort.Host
|
||||
$Port = $hostPort.Port
|
||||
|
||||
function Get-DeployScript {
|
||||
param($DeployPreferred, $DeployTmp, $RemoteTmp, $RemotePath, $RestartFlag)
|
||||
return @"
|
||||
@@ -35,48 +51,63 @@ $repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
Push-Location $repoRoot
|
||||
|
||||
try {
|
||||
Write-Host "[build] cargo build --release --target $Target" -ForegroundColor Cyan
|
||||
cargo build --release --target $Target
|
||||
if ($LASTEXITCODE -ne 0) { throw "cargo build failed ($LASTEXITCODE)" }
|
||||
Write-Host "[build] $Cargo build --release --target $Target -p wakey -p wakey-agent" -ForegroundColor Cyan
|
||||
. "$Cargo" build --release --target $Target -p wakey -p wakey-agent
|
||||
if ($LASTEXITCODE -ne 0) { throw "$Cargo build failed ($LASTEXITCODE)" }
|
||||
|
||||
$localBin = Join-Path $repoRoot "target/$Target/release/$BinName"
|
||||
$localAgentBin = Join-Path $repoRoot "target/$Target/release/$AgentBinName"
|
||||
if (-not (Test-Path $localBin)) { throw "binary not found: $localBin" }
|
||||
if (-not (Test-Path $localAgentBin)) { throw "binary not found: $localAgentBin" }
|
||||
|
||||
$remoteTmp = "$RemotePath.tmp"
|
||||
$agentRemoteTmp = "$AgentRemotePath.tmp"
|
||||
$destTmp = "$User@${HostName}:$remoteTmp"
|
||||
$agentDestTmp = "$User@${HostName}:$agentRemoteTmp"
|
||||
$initTmp = '/var/tmp/wakey.init.tmp'
|
||||
$initDestTmp = "$User@${HostName}:$initTmp"
|
||||
$localDeploy = Join-Path $repoRoot 'scripts/remote_deploy_wakey.sh'
|
||||
$localInit = Join-Path $repoRoot 'scripts/init/openwrt/wakey'
|
||||
$deployTmp = '/var/tmp/remote_deploy_wakey.sh'
|
||||
$deployPreferred = '/root/.bin/remote_deploy_wakey.sh'
|
||||
|
||||
# Push main binary
|
||||
Invoke-Scp -Local $localBin -Dest $destTmp -Pass $Pass -HostKey $HostKey -Quiet:$Quiet
|
||||
# Push binaries
|
||||
Invoke-Scp -Local $localBin -Dest $destTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||
Invoke-Scp -Local $localAgentBin -Dest $agentDestTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||
|
||||
# Push static assets
|
||||
$localStatic = Join-Path $repoRoot "static"
|
||||
if (Test-Path $localStatic) {
|
||||
# Assuming RemotePath is like /root/.bin/wakey, we want /root/.bin/static
|
||||
# So we push 'static' directory to /root/.bin/
|
||||
$remoteDir = (Split-Path $RemotePath -Parent) -replace '\\', '/'
|
||||
# Ensure remote dir exists (ssh mkdir -p)
|
||||
Invoke-Ssh -Cmd "mkdir -p $remoteDir" -User $User -Remote $HostName -Pass $Pass -Quiet:$Quiet
|
||||
|
||||
# SCP -r static user@host:/root/.bin/
|
||||
# Note: pscp/scp behavior: if dest is a dir, it copies the source dir INTO it.
|
||||
Invoke-Scp -Local $localStatic -Dest "$User@${HostName}:$remoteDir/" -Pass $Pass -HostKey $HostKey -Quiet:$Quiet -Recurse
|
||||
# Push OpenWrt init script unless explicitly skipped
|
||||
if (-not $SkipInitScript -and (Test-Path $localInit)) {
|
||||
Invoke-Scp -Local $localInit -Dest $initDestTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||
}
|
||||
|
||||
# Push deploy helper if exists
|
||||
if (Test-Path $localDeploy) {
|
||||
Invoke-Scp -Local $localDeploy -Dest "$User@${HostName}:$deployTmp" -Pass $Pass -HostKey $HostKey -Quiet:$Quiet
|
||||
Invoke-Scp -Local $localDeploy -Dest "$User@${HostName}:$deployTmp" -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||
}
|
||||
|
||||
# Build and run remote deploy command
|
||||
$restartFlag = $(if ($Restart) { '1' } else { '0' })
|
||||
$script = Get-DeployScript $deployPreferred $deployTmp $remoteTmp $RemotePath $restartFlag
|
||||
$script = @"
|
||||
$(Get-DeployScript $deployPreferred $deployTmp $remoteTmp $RemotePath 0)
|
||||
$(Get-DeployScript $deployPreferred $deployTmp $agentRemoteTmp $AgentRemotePath $restartFlag)
|
||||
$(if (-not $SkipInitScript) {
|
||||
@"
|
||||
if [ -f $initTmp ]; then
|
||||
sed -i "s/\r$//" $initTmp 2>/dev/null || true
|
||||
if command -v install >/dev/null 2>&1; then
|
||||
install -m 0755 $initTmp $RemoteInitPath
|
||||
else
|
||||
cp -f $initTmp $RemoteInitPath
|
||||
chmod 0755 $RemoteInitPath
|
||||
fi
|
||||
fi
|
||||
"@
|
||||
})
|
||||
"@
|
||||
$remoteCmd = "sh -lc '$($script -replace "`r",'')'"
|
||||
if ($Quiet) { $remoteCmd += " >/dev/null 2>&1" }
|
||||
|
||||
Invoke-Ssh -Cmd $remoteCmd -User $User -Remote $HostName -Pass $Pass -Quiet:$Quiet
|
||||
Invoke-Ssh -Cmd $remoteCmd -User $User -Remote $HostName -Pass $Pass -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||
|
||||
Write-Host "done ✔" -ForegroundColor Green
|
||||
}
|
||||
|
||||
@@ -5,11 +5,14 @@ START=99
|
||||
USE_PROCD=1
|
||||
|
||||
NAME=wakey
|
||||
BIN=/root/.bin/wakey
|
||||
BIN=/root/.bin/wakey-agent
|
||||
CONFIG=/etc/wakey-agent/config.toml
|
||||
|
||||
start_service() {
|
||||
procd_open_instance
|
||||
procd_set_param command "$BIN"
|
||||
procd_set_param command "$BIN" serve --config "$CONFIG"
|
||||
procd_set_param file "$CONFIG"
|
||||
procd_set_param env RUST_LOG=wakey_agent=debug,wakey=debug
|
||||
procd_set_param respawn 5 1 0
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
@@ -19,3 +22,8 @@ start_service() {
|
||||
stop_service() {
|
||||
: # procd manages the process; nothing to do here
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
# Prefer in-process reload; fallback to full restart if signal path is unavailable.
|
||||
procd_send_signal "$NAME" HUP 2>/dev/null || restart
|
||||
}
|
||||
|
||||
Regular → Executable
+1
-1
@@ -15,7 +15,7 @@ fi
|
||||
kill -TERM "$pids" 2>/dev/null || true
|
||||
|
||||
# Optional: hard kill if still alive after a short grace
|
||||
usleep 250000 # uhhh sleep is stupid
|
||||
usleep 250000 # uhhh sleep is not found
|
||||
remain=""
|
||||
for p in $pids; do
|
||||
kill -0 "$p" 2>/dev/null && remain="$remain $p"
|
||||
|
||||
Regular → Executable
+258
-9
@@ -1,5 +1,158 @@
|
||||
# Shared functions for wakey scripts
|
||||
|
||||
# Platform detection globals
|
||||
$script:IsWsl = $null
|
||||
$script:WarnedNoSshpass = $false
|
||||
|
||||
function Test-IsWsl {
|
||||
if ($null -ne $script:IsWsl) {
|
||||
return $script:IsWsl
|
||||
}
|
||||
|
||||
$script:IsWsl = $false
|
||||
|
||||
# Check for WSL environment variables
|
||||
if ($env:WSL_DISTRO_NAME -or $env:WSL_INTEROP) {
|
||||
$script:IsWsl = $true
|
||||
return $true
|
||||
}
|
||||
|
||||
# Check for /proc/version containing "microsoft" or "wsl"
|
||||
if ((Test-Path '/proc/version' -ErrorAction SilentlyContinue) -and
|
||||
(Select-String -Path '/proc/version' -Pattern 'microsoft|wsl' -Quiet -ErrorAction SilentlyContinue)) {
|
||||
$script:IsWsl = $true
|
||||
return $true
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
function ConvertTo-WslPath {
|
||||
param([string]$WindowsPath)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($WindowsPath)) {
|
||||
return $WindowsPath
|
||||
}
|
||||
|
||||
# If already a POSIX path, return as-is
|
||||
if ($WindowsPath -match '^/') {
|
||||
return $WindowsPath
|
||||
}
|
||||
|
||||
# Handle Windows path (e.g., C:\path\to\file -> /mnt/c/path/to/file)
|
||||
if ($WindowsPath -match '^([A-Z]):(.*)$') {
|
||||
$drive = $matches[1].ToLower()
|
||||
$path = $matches[2] -replace '\\', '/'
|
||||
return "/mnt/$drive$path"
|
||||
}
|
||||
|
||||
return $WindowsPath
|
||||
}
|
||||
|
||||
function Get-DefaultPassword {
|
||||
param([string]$Password)
|
||||
|
||||
if ($Password) {
|
||||
return $Password
|
||||
}
|
||||
|
||||
$pwPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'ar_data/pw'
|
||||
if (-not (Test-Path $pwPath)) {
|
||||
throw "Password not provided and default file not found: $pwPath"
|
||||
}
|
||||
|
||||
return (Get-Content -Raw $pwPath).Trim()
|
||||
}
|
||||
|
||||
function Normalize-LineEndings {
|
||||
param([string]$Text)
|
||||
if ($null -eq $Text) {
|
||||
return $null
|
||||
}
|
||||
return ($Text -replace "`r`n", "`n" -replace "`r", "")
|
||||
}
|
||||
|
||||
function Quote-ShArg {
|
||||
param([AllowNull()][string]$Value)
|
||||
if ($null -eq $Value) {
|
||||
return "''"
|
||||
}
|
||||
return "'" + ($Value -replace "'", ("'" + '"' + "'" + '"' + "'")) + "'"
|
||||
}
|
||||
|
||||
function Normalize-PosixPath {
|
||||
param([string]$Path)
|
||||
if ([string]::IsNullOrWhiteSpace($Path)) {
|
||||
return $Path
|
||||
}
|
||||
|
||||
$normalized = $Path -replace '\\', '/'
|
||||
if ($normalized.Length -gt 1) {
|
||||
$normalized = $normalized -replace '/+', '/'
|
||||
}
|
||||
return $normalized
|
||||
}
|
||||
|
||||
function Join-PosixPath {
|
||||
param(
|
||||
[string]$Left,
|
||||
[string]$Right
|
||||
)
|
||||
|
||||
$leftNorm = Normalize-PosixPath $Left
|
||||
$rightNorm = Normalize-PosixPath $Right
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($leftNorm)) {
|
||||
return $rightNorm
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($rightNorm)) {
|
||||
return $leftNorm
|
||||
}
|
||||
|
||||
$leftTrim = $leftNorm.TrimEnd('/')
|
||||
$rightTrim = $rightNorm.TrimStart('/')
|
||||
return "$leftTrim/$rightTrim"
|
||||
}
|
||||
|
||||
function Split-HostPort {
|
||||
param(
|
||||
[string]$HostName,
|
||||
[int]$DefaultPort = 22
|
||||
)
|
||||
|
||||
$result = [ordered]@{
|
||||
Host = $HostName
|
||||
Port = $DefaultPort
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($HostName)) {
|
||||
return [PSCustomObject]$result
|
||||
}
|
||||
|
||||
# IPv6 with brackets: [2001:db8::1]:2222
|
||||
if ($HostName -match '^\[(.+)\]:(\d+)$') {
|
||||
$result.Host = $matches[1]
|
||||
$result.Port = [int]$matches[2]
|
||||
return [PSCustomObject]$result
|
||||
}
|
||||
|
||||
# host:port (single colon only, avoids plain IPv6 addresses)
|
||||
if ($HostName -match '^([^:]+):(\d+)$') {
|
||||
$result.Host = $matches[1]
|
||||
$result.Port = [int]$matches[2]
|
||||
return [PSCustomObject]$result
|
||||
}
|
||||
|
||||
return [PSCustomObject]$result
|
||||
}
|
||||
|
||||
function Get-SshpassCommand {
|
||||
if ($cmd = Get-Command sshpass -ErrorAction SilentlyContinue) {
|
||||
return $cmd.Path
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Invoke-Ext {
|
||||
param($Exe, $Arguments, $Label)
|
||||
$displayArgs = $Arguments.Clone()
|
||||
@@ -7,42 +160,103 @@ function Invoke-Ext {
|
||||
if ($displayArgs[$i] -eq '-pw' -and ($i + 1) -lt $displayArgs.Count) {
|
||||
$displayArgs[$i + 1] = '****'
|
||||
}
|
||||
if ($displayArgs[$i] -ceq '-p' -and ($i + 1) -lt $displayArgs.Count -and $Exe -like '*sshpass*') {
|
||||
$displayArgs[$i + 1] = '****'
|
||||
}
|
||||
}
|
||||
Write-Host ("[{0}] {1} {2}" -f $Label, $Exe, ($displayArgs -join ' ')) -ForegroundColor Cyan
|
||||
|
||||
$out = & $Exe @Arguments 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error ("{0} failed ({1}):`n{2}" -f $Label, $LASTEXITCODE, ($out -join "`n"))
|
||||
throw ("{0} failed ({1})" -f $Label, $LASTEXITCODE)
|
||||
if ($Exe -like '*sshpass*' -and $LASTEXITCODE -eq 6) {
|
||||
Write-Error ("{0} failed: sshpass could not confirm host key automatically (code 6). Use ssh once manually, or enable StrictHostKeyChecking=accept-new." -f $Label)
|
||||
}
|
||||
Write-Error ("{0} exited with code {1}:`n{2}" -f $Label, $LASTEXITCODE, ($out -join "`n"))
|
||||
throw ("{0} exited with code {1}" -f $Label, $LASTEXITCODE)
|
||||
}
|
||||
return $out
|
||||
}
|
||||
|
||||
function Invoke-Scp {
|
||||
param($Local, $Dest, $Pass, $HostKey, [switch]$Quiet, [switch]$Recurse)
|
||||
if ($pscp = Get-Command pscp.exe -ErrorAction SilentlyContinue) {
|
||||
param($Local, $Dest, $Pass, $HostKey, [int]$Port = 22, [switch]$Quiet, [switch]$Recurse, [switch]$ForcePassword)
|
||||
|
||||
$isWin = [bool]$IsWindows
|
||||
$isWsl = Test-IsWsl
|
||||
|
||||
# Only use PuTTY on Windows (not in WSL)
|
||||
if ($isWin -and -not $isWsl -and ($pscp = Get-Command pscp.exe -ErrorAction SilentlyContinue)) {
|
||||
$arguments = @('-scp')
|
||||
if ($Quiet) { $arguments += '-q' }
|
||||
if ($Recurse) { $arguments += '-r' }
|
||||
if ($Port -gt 0) { $arguments += @('-P', $Port) }
|
||||
if ($HostKey) { $arguments += @('-batch', '-hostkey', $HostKey) }
|
||||
if ($Pass) { $arguments += @('-pw', $Pass) }
|
||||
$arguments += @($Local, $Dest)
|
||||
$arguments += @($Local)
|
||||
$arguments += $Dest
|
||||
Invoke-Ext -Exe $pscp.Path -Arguments $arguments -Label 'scp'
|
||||
}
|
||||
else {
|
||||
# Convert Windows paths to WSL paths if needed
|
||||
if ($isWsl) {
|
||||
$Local = @($Local | ForEach-Object { ConvertTo-WslPath $_ })
|
||||
$Dest = ConvertTo-WslPath $Dest
|
||||
}
|
||||
|
||||
$arguments = @('-O')
|
||||
if ($Quiet) { $arguments += '-q' }
|
||||
if ($Recurse) { $arguments += '-r' }
|
||||
$arguments += @($Local, $Dest)
|
||||
# Avoid interactive host-key prompts (important for sshpass usage)
|
||||
$arguments += @('-o', 'StrictHostKeyChecking=accept-new')
|
||||
if ($ForcePassword) {
|
||||
$arguments += @('-o', 'PreferredAuthentications=keyboard-interactive,password')
|
||||
$arguments += @('-o', 'PubkeyAuthentication=no')
|
||||
$arguments += @('-o', 'KbdInteractiveAuthentication=yes')
|
||||
$arguments += @('-o', 'PasswordAuthentication=yes')
|
||||
$arguments += @('-o', 'NumberOfPasswordPrompts=1')
|
||||
}
|
||||
if ($Port -gt 0) { $arguments += @('-P', $Port) }
|
||||
$arguments += @($Local)
|
||||
$arguments += $Dest
|
||||
|
||||
if ($Pass) {
|
||||
$sshpassExe = Get-SshpassCommand
|
||||
if ($sshpassExe) {
|
||||
$wrapped = @('-p', $Pass, 'scp') + $arguments
|
||||
Invoke-Ext -Exe $sshpassExe -Arguments $wrapped -Label 'scp'
|
||||
return
|
||||
}
|
||||
if (-not $script:WarnedNoSshpass) {
|
||||
Write-Warning 'Password was provided but sshpass is not installed; falling back to plain scp (key/agent auth expected).'
|
||||
$script:WarnedNoSshpass = $true
|
||||
}
|
||||
}
|
||||
|
||||
if ($ForcePassword -and -not $Pass) {
|
||||
Write-Error 'ForcePassword requires Pass to be provided.'
|
||||
throw 'ForcePassword requires Pass'
|
||||
}
|
||||
|
||||
if ($ForcePassword -and $Pass -and -not (Get-SshpassCommand)) {
|
||||
Write-Error 'ForcePassword was requested but sshpass is not installed. Install sshpass or disable ForcePassword.'
|
||||
throw 'ForcePassword requires sshpass'
|
||||
}
|
||||
|
||||
Invoke-Ext -Exe 'scp' -Arguments $arguments -Label 'scp'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function Invoke-Ssh {
|
||||
param($Cmd, $User, $Remote, $Pass, [switch]$Quiet)
|
||||
$Cmd = $Cmd -replace "`r`n", "`n" -replace "`r", ""
|
||||
if ($plink = Get-Command plink.exe -ErrorAction SilentlyContinue) {
|
||||
param($Cmd, $User, $Remote, $Pass, [int]$Port = 22, [switch]$Quiet, [switch]$ForcePassword)
|
||||
$Cmd = Normalize-LineEndings $Cmd
|
||||
|
||||
$isWin = [bool]$IsWindows
|
||||
$isWsl = Test-IsWsl
|
||||
|
||||
# Only use PuTTY on Windows (not in WSL)
|
||||
if ($isWin -and -not $isWsl -and ($plink = Get-Command plink.exe -ErrorAction SilentlyContinue)) {
|
||||
$arguments = @('-batch', '-ssh')
|
||||
if ($Port -gt 0) { $arguments += @('-P', $Port) }
|
||||
if ($Pass) { $arguments += @('-pw', $Pass) }
|
||||
$dest = if ($User) { "$User@$Remote" } else { $Remote }
|
||||
$arguments += $dest, $Cmd
|
||||
@@ -51,8 +265,43 @@ function Invoke-Ssh {
|
||||
else {
|
||||
$arguments = @()
|
||||
if ($Quiet) { $arguments += '-q' }
|
||||
# Avoid interactive host-key prompts (important for sshpass usage)
|
||||
$arguments += @('-o', 'StrictHostKeyChecking=accept-new')
|
||||
if ($ForcePassword) {
|
||||
$arguments += @('-o', 'PreferredAuthentications=keyboard-interactive,password')
|
||||
$arguments += @('-o', 'PubkeyAuthentication=no')
|
||||
$arguments += @('-o', 'KbdInteractiveAuthentication=yes')
|
||||
$arguments += @('-o', 'PasswordAuthentication=yes')
|
||||
$arguments += @('-o', 'NumberOfPasswordPrompts=1')
|
||||
}
|
||||
if ($Port -gt 0) { $arguments += @('-p', $Port) }
|
||||
$dest = if ($User) { "$User@$Remote" } else { $Remote }
|
||||
$arguments += $dest, $Cmd
|
||||
|
||||
if ($Pass) {
|
||||
$sshpassExe = Get-SshpassCommand
|
||||
if ($sshpassExe) {
|
||||
$wrapped = @('-p', $Pass, 'ssh') + $arguments
|
||||
Invoke-Ext -Exe $sshpassExe -Arguments $wrapped -Label 'ssh'
|
||||
return
|
||||
}
|
||||
if (-not $script:WarnedNoSshpass) {
|
||||
Write-Warning 'Password was provided but sshpass is not installed; falling back to plain ssh (key/agent auth expected).'
|
||||
$script:WarnedNoSshpass = $true
|
||||
}
|
||||
}
|
||||
|
||||
if ($ForcePassword -and -not $Pass) {
|
||||
Write-Error 'ForcePassword requires Pass to be provided.'
|
||||
throw 'ForcePassword requires Pass'
|
||||
}
|
||||
|
||||
if ($ForcePassword -and $Pass -and -not (Get-SshpassCommand)) {
|
||||
Write-Error 'ForcePassword was requested but sshpass is not installed. Install sshpass or disable ForcePassword.'
|
||||
throw 'ForcePassword requires sshpass'
|
||||
}
|
||||
|
||||
Invoke-Ext -Exe 'ssh' -Arguments $arguments -Label 'ssh'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Regular → Executable
+10
-6
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Build a rootfs tarball suitable for `wget -O- ... | tar -xz -C /` on OpenWrt.
|
||||
# It lays out files as absolute-root paths inside the archive:
|
||||
# root/.bin/wakey
|
||||
# root/.bin/wakey-agent
|
||||
# etc/init.d/wakey
|
||||
# Usage: ./scripts/package_rootfs.ps1 -Version 0.1.0 -Target armv7-unknown-linux-musleabihf -OutDir dist
|
||||
param(
|
||||
@@ -17,7 +19,9 @@ $dist = Join-Path $root $OutDir
|
||||
New-Item -ItemType Directory -Force -Path $dist | Out-Null
|
||||
|
||||
$binName = if ($Target -like "*-windows-*") { "wakey.exe" } else { "wakey" }
|
||||
$agentBinName = if ($Target -like "*-windows-*") { "wakey-agent.exe" } else { "wakey-agent" }
|
||||
$binSrc = Join-Path $root "target/$Target/release/$binName"
|
||||
$agentBinSrc = Join-Path $root "target/$Target/release/$agentBinName"
|
||||
|
||||
$staging = Join-Path $dist ("rootfs-" + [System.Guid]::NewGuid().ToString("N"))
|
||||
New-Item -ItemType Directory -Force -Path $staging | Out-Null
|
||||
@@ -35,6 +39,12 @@ if (-not $NoBin) {
|
||||
else {
|
||||
throw "Missing binary: $binSrc (build it first or pass -NoBin)"
|
||||
}
|
||||
if (Test-Path $agentBinSrc) {
|
||||
Copy-Item $agentBinSrc (Join-Path $rootDir "wakey-agent") -Force
|
||||
}
|
||||
else {
|
||||
throw "Missing binary: $agentBinSrc (build it first or pass -NoBin)"
|
||||
}
|
||||
}
|
||||
# Normalize kill script line endings and copy
|
||||
$killSrc = Join-Path $root 'scripts/kill_wakey.sh'
|
||||
@@ -52,12 +62,6 @@ if (Test-Path $deploySrc) {
|
||||
Set-Content -NoNewline -LiteralPath (Join-Path $rootDir "remote_deploy_wakey.sh") -Value $deployContent -Encoding UTF8
|
||||
}
|
||||
|
||||
# Copy static assets
|
||||
$staticSrc = Join-Path $root "static"
|
||||
if (Test-Path $staticSrc) {
|
||||
Copy-Item -Recurse $staticSrc (Join-Path $rootDir "static") -Force
|
||||
}
|
||||
|
||||
# Copy all OpenWrt init scripts present in repo
|
||||
Get-ChildItem (Join-Path $root 'scripts/init/openwrt') -File | ForEach-Object {
|
||||
$dest = Join-Path $etcDir $_.Name
|
||||
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env sh
|
||||
# Package wakey-control-plane bundle (binary + UI dist + updater + systemd template).
|
||||
# Archive paths are relative so extraction can target any install directory.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/package_wakey_cc_bundle.sh --version v0.1.0 --target x86_64-unknown-linux-gnu
|
||||
#
|
||||
# Optional:
|
||||
# --out-dir dist
|
||||
# --binary target/<target>/release/wakey-control-plane
|
||||
# --ui-dist ui/dist
|
||||
|
||||
set -eu
|
||||
|
||||
VERSION=""
|
||||
TARGET=""
|
||||
OUT_DIR="dist"
|
||||
BINARY=""
|
||||
UI_DIST="ui/dist"
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--version)
|
||||
VERSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--target)
|
||||
TARGET="$2"
|
||||
shift 2
|
||||
;;
|
||||
--out-dir)
|
||||
OUT_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--binary)
|
||||
BINARY="$2"
|
||||
shift 2
|
||||
;;
|
||||
--ui-dist)
|
||||
UI_DIST="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "unknown arg: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$VERSION" ] || { echo "--version is required" >&2; exit 1; }
|
||||
[ -n "$TARGET" ] || { echo "--target is required" >&2; exit 1; }
|
||||
|
||||
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
if [ -z "$BINARY" ]; then
|
||||
BINARY="target/$TARGET/release/wakey-control-plane"
|
||||
fi
|
||||
|
||||
[ -f "$BINARY" ] || { echo "missing binary: $BINARY" >&2; exit 1; }
|
||||
[ -f "$UI_DIST/index.html" ] || { echo "missing UI dist index: $UI_DIST/index.html" >&2; exit 1; }
|
||||
[ -f "scripts/update_wakey_cc.sh" ] || { echo "missing updater script" >&2; exit 1; }
|
||||
[ -f "deploy/systemd/wakey-cc.service" ] || { echo "missing systemd template" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
STAGING="$OUT_DIR/wakey-cc-stage.$$"
|
||||
PKG="wakey-cc-$VERSION-$TARGET.tgz"
|
||||
|
||||
rm -rf "$STAGING"
|
||||
mkdir -p "$STAGING/bin" "$STAGING/ui" "$STAGING/scripts" "$STAGING/deploy/systemd"
|
||||
|
||||
cp "$BINARY" "$STAGING/bin/wakey-control-plane"
|
||||
cp -a "$UI_DIST" "$STAGING/ui/dist"
|
||||
cp "scripts/update_wakey_cc.sh" "$STAGING/scripts/update_wakey_cc.sh"
|
||||
cp "deploy/systemd/wakey-cc.service" "$STAGING/deploy/systemd/wakey-cc.service"
|
||||
|
||||
chmod +x "$STAGING/bin/wakey-control-plane" "$STAGING/scripts/update_wakey_cc.sh"
|
||||
|
||||
(
|
||||
cd "$STAGING"
|
||||
tar -czf "$ROOT_DIR/$OUT_DIR/$PKG" .
|
||||
)
|
||||
|
||||
rm -rf "$STAGING"
|
||||
|
||||
echo "Bundle package: $OUT_DIR/$PKG"
|
||||
echo "Contains: bin/wakey-control-plane, ui/dist, scripts/update_wakey_cc.sh, deploy/systemd/wakey-cc.service"
|
||||
Regular → Executable
+29
-9
@@ -1,3 +1,4 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Publish to registry and tag
|
||||
# Usage: ./scripts/publish.ps1 -Version 0.1.0 -Tag
|
||||
# Publish to registry and tag (manual; CI does not use this)
|
||||
@@ -10,7 +11,10 @@ param(
|
||||
[string]$Version,
|
||||
[switch]$Tag,
|
||||
[switch]$Publish,
|
||||
[string]$Registry
|
||||
[string]$Registry,
|
||||
[ValidateSet('wsl', 'windows', 'none')]
|
||||
[string]$RunnerMode = 'wsl',
|
||||
[string]$WslDistro = $(if ($env:WAKEY_WSL_DISTRO) { $env:WAKEY_WSL_DISTRO } else { 'FedoraLinux-43' })
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
@@ -60,13 +64,29 @@ if ($Tag -and $Version) {
|
||||
git push -f origin "v$Version"
|
||||
Write-Host "Pushed tag: v$Version"
|
||||
|
||||
# Run act_runner in --once mode to process the release job
|
||||
$actRunnerScript = Join-Path $PSScriptRoot 'act_runner.ps1'
|
||||
if (Test-Path $actRunnerScript) {
|
||||
Write-Host "Starting act_runner (once mode) to process release job..."
|
||||
& $actRunnerScript -Once
|
||||
}
|
||||
else {
|
||||
Write-Warning "act_runner.ps1 not found at $actRunnerScript. Skipping runner."
|
||||
switch ($RunnerMode) {
|
||||
'wsl' {
|
||||
$runnerScript = Join-Path $PSScriptRoot 'act_runner_wsl.ps1'
|
||||
if (Test-Path $runnerScript) {
|
||||
Write-Host "Starting Fedora/WSL act_runner (once mode) to process release job..."
|
||||
& $runnerScript -Distro $WslDistro -Action start -Once
|
||||
}
|
||||
else {
|
||||
Write-Warning "act_runner_wsl.ps1 not found at $runnerScript. Skipping runner."
|
||||
}
|
||||
}
|
||||
'windows' {
|
||||
$runnerScript = Join-Path $PSScriptRoot 'act_runner.ps1'
|
||||
if (Test-Path $runnerScript) {
|
||||
Write-Host "Starting Windows act_runner (once mode) to process release job..."
|
||||
& $runnerScript -Once
|
||||
}
|
||||
else {
|
||||
Write-Warning "act_runner.ps1 not found at $runnerScript. Skipping runner."
|
||||
}
|
||||
}
|
||||
'none' {
|
||||
Write-Host "RunnerMode=none; not starting a local runner helper."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Regular → Executable
Regular → Executable
+235
-40
@@ -3,61 +3,256 @@
|
||||
|
||||
param(
|
||||
[string]$Package = "lda-ipjs",
|
||||
[string]$TestName = "",
|
||||
[switch]$AllPackages,
|
||||
[string]$BinaryFilter = "",
|
||||
[string]$Filter = "",
|
||||
[switch]$Exact,
|
||||
[switch]$List,
|
||||
[ValidateSet("debug", "release")]
|
||||
[string]$BuildProfile = "debug",
|
||||
[string]$BuildProfile = "release",
|
||||
[string]$password,
|
||||
[switch]$Verbose,
|
||||
[string]$RemoteTestPath = "/root/.bin/test",
|
||||
[string]$RemoteHost = "[email protected]"
|
||||
[string]$RemoteTestPath = "/tmp/tmp/wakey-test",
|
||||
[string]$RemoteHost = "[email protected]",
|
||||
[int]$RemotePort = 2222,
|
||||
[switch]$ForcePassword,
|
||||
[switch]$Ignored,
|
||||
[switch]$IncludeIgnored,
|
||||
[switch]$NoCapture,
|
||||
[switch]$ShowOutput,
|
||||
[int]$Threads = 0
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
. "$PSScriptRoot/lib.ps1"
|
||||
|
||||
# Build tests and capture output
|
||||
Write-Host "Building tests for $Package..." -ForegroundColor Cyan
|
||||
$password = Get-DefaultPassword $password
|
||||
|
||||
# Stream cargo output anc convert to text
|
||||
$cargoOutput = cargo test --no-run -p $Package --target armv7-unknown-linux-musleabihf $(if ($BuildProfile -eq "release") { "-r" }) 2>&1 |
|
||||
ForEach-Object {
|
||||
$line = if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.ToString() } else { $_ }
|
||||
if ($Verbose) {
|
||||
Write-Host $line
|
||||
$sshRemote = $RemoteHost
|
||||
$hostPort = Split-HostPort -HostName $sshRemote -DefaultPort $RemotePort
|
||||
$sshRemote = $hostPort.Host
|
||||
$RemotePort = $hostPort.Port
|
||||
|
||||
function Get-WorkspacePackages {
|
||||
$metadata = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json
|
||||
$workspaceMembers = [System.Collections.Generic.HashSet[string]]::new()
|
||||
foreach ($member in $metadata.workspace_members) {
|
||||
[void]$workspaceMembers.Add($member)
|
||||
}
|
||||
$line # Pass through to capture
|
||||
|
||||
@(
|
||||
$metadata.packages |
|
||||
Where-Object { $workspaceMembers.Contains($_.id) } |
|
||||
Select-Object -ExpandProperty name
|
||||
)
|
||||
}
|
||||
|
||||
# Parse test binary paths from cargo output
|
||||
$testBinaries = $cargoOutput |
|
||||
Select-String -Pattern "Executable.*\((.+)\)" |
|
||||
ForEach-Object { $_.Matches.Groups[1].Value } |
|
||||
Get-Item
|
||||
function Get-TestBinaryPaths {
|
||||
param([string[]]$CargoOutput)
|
||||
|
||||
if ($testBinaries.Count -eq 0) {
|
||||
Write-Error "No test binaries found! Exiting..."
|
||||
# Write-Host "Cargo output:" -ForegroundColor Yellow
|
||||
# $cargoOutput | ForEach-Object { Write-Host $_ }
|
||||
$paths = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
foreach ($line in $CargoOutput) {
|
||||
if ([string]::IsNullOrWhiteSpace($line)) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
$msg = $line | ConvertFrom-Json -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
continue
|
||||
}
|
||||
|
||||
if ($msg.reason -ne "compiler-artifact") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (-not $msg.executable) {
|
||||
continue
|
||||
}
|
||||
|
||||
$isTestProfile = $false
|
||||
if ($null -ne $msg.profile -and $null -ne $msg.profile.test) {
|
||||
$isTestProfile = [bool]$msg.profile.test
|
||||
}
|
||||
if (-not $isTestProfile) {
|
||||
continue
|
||||
}
|
||||
|
||||
$paths.Add([string]$msg.executable)
|
||||
}
|
||||
|
||||
@($paths)
|
||||
}
|
||||
|
||||
function Build-RemoteExecCommand {
|
||||
param(
|
||||
[string]$RemotePath,
|
||||
[string[]]$Arguments
|
||||
)
|
||||
|
||||
$quotedPath = Quote-ShArg $RemotePath
|
||||
$quotedArgs = @($Arguments | ForEach-Object { Quote-ShArg "$_" })
|
||||
$exec = (@($quotedPath) + $quotedArgs) -join " "
|
||||
"chmod +x $quotedPath && $exec"
|
||||
}
|
||||
|
||||
function New-RemoteRunDir {
|
||||
param(
|
||||
[string]$BasePath,
|
||||
[string]$PackageName
|
||||
)
|
||||
|
||||
$baseNorm = Normalize-PosixPath $BasePath
|
||||
$leaf = [IO.Path]::GetFileName($baseNorm)
|
||||
$parent = Normalize-PosixPath ([IO.Path]::GetDirectoryName($baseNorm))
|
||||
if ([string]::IsNullOrWhiteSpace($parent)) {
|
||||
$parent = "/tmp"
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($leaf)) {
|
||||
$leaf = "wakey-test"
|
||||
}
|
||||
|
||||
$random = [System.Guid]::NewGuid().ToString("N").Substring(0, 10)
|
||||
$safePackage = ($PackageName -replace '[^A-Za-z0-9._-]', '_')
|
||||
return (Join-PosixPath $parent "$leaf-$safePackage-$random")
|
||||
}
|
||||
|
||||
function New-RemoteBinaryPath {
|
||||
param(
|
||||
[string]$RemoteRunDir,
|
||||
[System.IO.FileInfo]$TestBinary
|
||||
)
|
||||
|
||||
$safeName = ($TestBinary.Name -replace '[^A-Za-z0-9._-]', '_')
|
||||
Join-PosixPath $RemoteRunDir $safeName
|
||||
}
|
||||
|
||||
function Ensure-RemoteParentDir {
|
||||
param(
|
||||
[string]$RemoteDirPath,
|
||||
[string]$RemoteHost,
|
||||
[string]$Password,
|
||||
[int]$Port,
|
||||
[switch]$ForcePassword
|
||||
)
|
||||
|
||||
$dir = Normalize-PosixPath $RemoteDirPath
|
||||
if ([string]::IsNullOrWhiteSpace($dir)) {
|
||||
return
|
||||
}
|
||||
Invoke-Ssh -Cmd ("mkdir -p " + (Quote-ShArg $dir)) -Remote $RemoteHost -Pass $Password -Port $Port -Quiet -ForcePassword:$ForcePassword
|
||||
}
|
||||
|
||||
$packages = if ($AllPackages) { Get-WorkspacePackages } else { @($Package) }
|
||||
$failures = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
foreach ($packageName in $packages) {
|
||||
Write-Host "Building tests for $packageName..." -ForegroundColor Cyan
|
||||
|
||||
# Stream cargo output and convert to text
|
||||
$cargoOutput = cargo test --no-run -p $packageName --target armv7-unknown-linux-musleabihf --message-format json $(if ($BuildProfile -eq "release") { "-r" }) 2>&1 |
|
||||
ForEach-Object {
|
||||
$line = if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.ToString() } else { $_ }
|
||||
# if ($Verbose) {
|
||||
# Write-Host $line # this thing fills QUICK im not printing any
|
||||
# }
|
||||
$line
|
||||
}
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
$failures.Add("$packageName (build failed)")
|
||||
Write-Warning "Build failed for $packageName"
|
||||
continue
|
||||
}
|
||||
|
||||
$testBinaryPaths = Get-TestBinaryPaths $cargoOutput
|
||||
$testBinaries = @($testBinaryPaths | Get-Item)
|
||||
|
||||
if ($BinaryFilter) {
|
||||
$testBinaries = @(
|
||||
$testBinaries |
|
||||
Where-Object {
|
||||
$_.Name -like "*$BinaryFilter*" -or $_.BaseName -like "*$BinaryFilter*"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if ($testBinaries.Count -eq 0) {
|
||||
$reason = if ($BinaryFilter) {
|
||||
"no test executables matched binary filter '$BinaryFilter'"
|
||||
}
|
||||
else {
|
||||
"no test executables"
|
||||
}
|
||||
Write-Host "Skipping ${packageName}: $reason" -ForegroundColor DarkYellow
|
||||
continue
|
||||
}
|
||||
|
||||
Write-Host "Found $($testBinaries.Count) test $($testBinaries.Count -eq 1 ? "binary" : "binaries") for $packageName" -ForegroundColor Green
|
||||
|
||||
$remoteRunDir = New-RemoteRunDir -BasePath $RemoteTestPath -PackageName $packageName
|
||||
Ensure-RemoteParentDir -RemoteDirPath $remoteRunDir -RemoteHost $sshRemote -Password $password -Port $RemotePort -ForcePassword:$ForcePassword
|
||||
|
||||
try {
|
||||
$remoteUploadDir = (Normalize-PosixPath $remoteRunDir).TrimEnd('/') + '/'
|
||||
|
||||
# Convert local file paths to WSL paths if running in WSL
|
||||
$localFilePaths = @($testBinaries | ForEach-Object {
|
||||
if (Test-IsWsl) {
|
||||
ConvertTo-WslPath $_.FullName
|
||||
}
|
||||
else {
|
||||
$_.FullName
|
||||
}
|
||||
})
|
||||
|
||||
Invoke-Scp -Local $localFilePaths -Dest "${sshRemote}:$remoteUploadDir" -Pass $password -Port $RemotePort -Quiet -ForcePassword:$ForcePassword
|
||||
|
||||
foreach ($testBinary in $testBinaries) {
|
||||
Write-Host "`nTesting: $packageName / $($testBinary.Name)" -ForegroundColor Cyan
|
||||
|
||||
$remoteBinaryPath = New-RemoteBinaryPath -RemoteRunDir $remoteRunDir -TestBinary $testBinary
|
||||
|
||||
# Build test args
|
||||
$parts = @()
|
||||
if ($Filter) { $parts += $Filter }
|
||||
if ($Exact) { $parts += "--exact" }
|
||||
if ($List) { $parts += "--list" }
|
||||
if ($Ignored) { $parts += "--ignored" }
|
||||
if ($IncludeIgnored) { $parts += "--include-ignored" }
|
||||
if ($ShowOutput -or $Verbose) { $parts += "--show-output" }
|
||||
if ($NoCapture -or $Verbose) { $parts += "--nocapture" }
|
||||
if ($Threads -gt 0) { $parts += "--test-threads"; $parts += $Threads }
|
||||
$remoteCmd = Build-RemoteExecCommand -RemotePath $remoteBinaryPath -Arguments $parts
|
||||
|
||||
# Run test binary (with chmod to ensure executable)
|
||||
try {
|
||||
Invoke-Ssh -Cmd $remoteCmd -Remote $sshRemote -Pass $password -Port $RemotePort -ForcePassword:$ForcePassword
|
||||
}
|
||||
catch {
|
||||
$failures.Add("$packageName / $($testBinary.Name)")
|
||||
Write-Warning "Test binary failed: $packageName / $($testBinary.Name)"
|
||||
Write-Warning $_.Exception.Message
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
try {
|
||||
Invoke-Ssh -Cmd ("rm -rf " + (Quote-ShArg $remoteRunDir)) -Remote $sshRemote -Pass $password -Port $RemotePort -Quiet -ForcePassword:$ForcePassword
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to remove remote test dir: $remoteRunDir"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($failures.Count -gt 0) {
|
||||
Write-Error ("One or more test binaries failed: {0}" -f ($failures -join ", "))
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found $($testBinaries.Count) test $($testBinaries.Count -eq 1 ? "binary" : "binaries")" -ForegroundColor Green
|
||||
|
||||
# Run each test binary
|
||||
foreach ($testBinary in $testBinaries) {
|
||||
Write-Host "`nTesting: $($testBinary.Name)" -ForegroundColor Cyan
|
||||
|
||||
# Copy to target
|
||||
Invoke-Scp -Local $testBinary.FullName -Dest "${RemoteHost}:$RemoteTestPath" -Pass $password -Quiet
|
||||
|
||||
# Run on target
|
||||
$testArgs = "$(if ($Verbose) {"--nocapture --show-output"})"
|
||||
if ($TestName) {
|
||||
$testArgs = "$TestName $testArgs"
|
||||
}
|
||||
|
||||
Invoke-Ssh -Cmd "chmod +x $RemoteTestPath && $RemoteTestPath $testArgs" -Remote $RemoteHost -Pass $password
|
||||
}
|
||||
|
||||
Write-Host "`nDone!" -ForegroundColor Green
|
||||
|
||||
Executable
+115
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env sh
|
||||
# Update/install wakey-control-plane bundle on Linux VPS and optionally restart systemd unit.
|
||||
#
|
||||
# Expected tarball layout:
|
||||
# bin/wakey-control-plane
|
||||
# ui/dist/index.html
|
||||
# ui/dist/assets/*
|
||||
#
|
||||
# Env options:
|
||||
# WAKEY_CC_TGZ_URL Direct URL to bundle tarball (preferred)
|
||||
# WAKEY_HOST Release host (default: git.ldlda.com)
|
||||
# WAKEY_OWNER Repo owner (default: lda)
|
||||
# WAKEY_REPO Repo name (default: wakey)
|
||||
# WAKEY_CC_VERSION Tag, e.g. v0.1.0 (required unless WAKEY_CC_TGZ_URL set)
|
||||
# WAKEY_CC_TARGET Target triple (default from uname: x86_64-unknown-linux-gnu or aarch64-unknown-linux-gnu)
|
||||
# WAKEY_CC_FILE Asset filename (default: wakey-cc-${WAKEY_CC_VERSION}-${WAKEY_CC_TARGET}.tgz)
|
||||
# WAKEY_CC_ROOT Install root (default: current directory)
|
||||
# WAKEY_CC_SERVICE systemd unit name (default: wakey-cc.service)
|
||||
# WAKEY_CC_NO_RESTART If set, skip systemd restart
|
||||
# WAKEY_INSECURE If set, disable TLS verification
|
||||
#
|
||||
# Requires: tar, systemctl, curl or wget
|
||||
|
||||
set -eu
|
||||
|
||||
log() { printf '[update_wakey_cc] %s\n' "$*"; }
|
||||
fail() {
|
||||
printf '[update_wakey_cc] ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
fetch() {
|
||||
# fetch <url> <out>
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
if [ -n "${WAKEY_INSECURE:-}" ]; then
|
||||
curl -fSL -k -o "$2" "$1"
|
||||
else
|
||||
curl -fSL -o "$2" "$1"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v wget >/dev/null 2>&1; then
|
||||
# shellcheck disable=SC2086
|
||||
wget ${WAKEY_INSECURE:+--no-check-certificate} -O "$2" "$1"
|
||||
return
|
||||
fi
|
||||
|
||||
fail 'curl or wget is required'
|
||||
}
|
||||
|
||||
default_target() {
|
||||
arch=$(uname -m)
|
||||
case "$arch" in
|
||||
x86_64)
|
||||
printf '%s' 'x86_64-unknown-linux-gnu'
|
||||
;;
|
||||
aarch64|arm64)
|
||||
printf '%s' 'aarch64-unknown-linux-gnu'
|
||||
;;
|
||||
*)
|
||||
printf '%s' "$arch"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main() {
|
||||
ROOT="${WAKEY_CC_ROOT:-$PWD}"
|
||||
SERVICE="${WAKEY_CC_SERVICE:-wakey-cc.service}"
|
||||
TMPDIR="${TMPDIR:-/tmp}"
|
||||
ARCHIVE="$TMPDIR/wakey-cc.$$.tgz"
|
||||
STAGING="$TMPDIR/wakey-cc-stage.$$"
|
||||
|
||||
URL="${WAKEY_CC_TGZ_URL:-}"
|
||||
if [ -z "$URL" ]; then
|
||||
HOST="${WAKEY_HOST:-git.ldlda.com}"
|
||||
OWNER="${WAKEY_OWNER:-lda}"
|
||||
REPO="${WAKEY_REPO:-wakey}"
|
||||
VERSION="${WAKEY_CC_VERSION:-}"
|
||||
[ -n "$VERSION" ] || fail 'set WAKEY_CC_TGZ_URL or WAKEY_CC_VERSION'
|
||||
TARGET="${WAKEY_CC_TARGET:-$(default_target)}"
|
||||
FILE="${WAKEY_CC_FILE:-wakey-cc-${VERSION}-${TARGET}.tgz}"
|
||||
URL="https://$HOST/$OWNER/$REPO/releases/download/$VERSION/$FILE"
|
||||
fi
|
||||
|
||||
trap 'rm -f "$ARCHIVE"; rm -rf "$STAGING"' EXIT INT TERM
|
||||
|
||||
log "fetching $URL"
|
||||
fetch "$URL" "$ARCHIVE" || fail 'download failed'
|
||||
|
||||
rm -rf "$STAGING"
|
||||
mkdir -p "$STAGING"
|
||||
tar -xzf "$ARCHIVE" -C "$STAGING" || fail 'extract failed'
|
||||
|
||||
[ -f "$STAGING/bin/wakey-control-plane" ] || fail 'bundle missing bin/wakey-control-plane'
|
||||
[ -f "$STAGING/ui/dist/index.html" ] || fail 'bundle missing ui/dist/index.html'
|
||||
|
||||
mkdir -p "$ROOT"
|
||||
cp -a "$STAGING/." "$ROOT/"
|
||||
|
||||
if [ -z "${WAKEY_CC_NO_RESTART:-}" ] && command -v systemctl >/dev/null 2>&1; then
|
||||
if systemctl list-unit-files "$SERVICE" >/dev/null 2>&1; then
|
||||
log "restarting $SERVICE"
|
||||
systemctl daemon-reload
|
||||
systemctl restart "$SERVICE"
|
||||
systemctl --no-pager --full status "$SERVICE" | sed -n '1,16p'
|
||||
else
|
||||
log "service $SERVICE not installed; skipped restart"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "done: installed into $ROOT"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,14 +0,0 @@
|
||||
use std::net::AddrParseError;
|
||||
|
||||
use strum::Display;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Display, Error)]
|
||||
pub enum IPNeighParseError {
|
||||
IpWhere, // i never seen a ip neigh where the first thing aint an ip
|
||||
IpParseError(#[from] AddrParseError),
|
||||
// DevWhere,
|
||||
MacParseError(#[from] macaddr::ParseError),
|
||||
StateWhere, // i never seen a ip neigh without the big FAILED at the end
|
||||
StateParseError(#[from] strum::ParseError),
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
//! r#impl AHHHHHH
|
||||
|
||||
use serde::{Deserialize, Deserializer, de};
|
||||
|
||||
use super::NUDState;
|
||||
|
||||
// Case-insensitive parsing for NUDState via manual Deserialize
|
||||
impl<'de> Deserialize<'de> for NUDState {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s: &str = <&str as Deserialize>::deserialize(deserializer)?;
|
||||
s.parse().map_err(de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
use crate::arpparse::IpNeighLine;
|
||||
|
||||
use lda_ipjs::subcommands::neighbor::{self as ipjs_neigh, NeighborItem};
|
||||
|
||||
impl From<ipjs_neigh::NUDState> for NUDState {
|
||||
fn from(value: ipjs_neigh::NUDState) -> Self {
|
||||
match value {
|
||||
ipjs_neigh::NUDState::Permanent => NUDState::Permanent,
|
||||
ipjs_neigh::NUDState::Noarp => NUDState::Noarp,
|
||||
ipjs_neigh::NUDState::Reachable => NUDState::Reachable,
|
||||
ipjs_neigh::NUDState::Stale => NUDState::Stale,
|
||||
ipjs_neigh::NUDState::None => NUDState::None,
|
||||
ipjs_neigh::NUDState::Incomplete => NUDState::Incomplete,
|
||||
ipjs_neigh::NUDState::Delay => NUDState::Delay,
|
||||
ipjs_neigh::NUDState::Probe => NUDState::Probe,
|
||||
ipjs_neigh::NUDState::Failed => NUDState::Failed,
|
||||
ipjs_neigh::NUDState::Other(_) => NUDState::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<NUDState> for ipjs_neigh::NUDState {
|
||||
fn from(value: NUDState) -> Self {
|
||||
match value {
|
||||
NUDState::Permanent => ipjs_neigh::NUDState::Permanent,
|
||||
NUDState::Noarp => ipjs_neigh::NUDState::Noarp,
|
||||
NUDState::Reachable => ipjs_neigh::NUDState::Reachable,
|
||||
NUDState::Stale => ipjs_neigh::NUDState::Stale,
|
||||
NUDState::None => ipjs_neigh::NUDState::None,
|
||||
NUDState::Incomplete => ipjs_neigh::NUDState::Incomplete,
|
||||
NUDState::Delay => ipjs_neigh::NUDState::Delay,
|
||||
NUDState::Probe => ipjs_neigh::NUDState::Probe,
|
||||
NUDState::Failed => ipjs_neigh::NUDState::Failed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<NeighborItem> for IpNeighLine {
|
||||
fn from(
|
||||
NeighborItem {
|
||||
ip,
|
||||
dev,
|
||||
mac,
|
||||
state,
|
||||
}: NeighborItem,
|
||||
) -> Self {
|
||||
IpNeighLine {
|
||||
ip,
|
||||
dev,
|
||||
mac,
|
||||
state: state
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.max()
|
||||
.unwrap_or(NUDState::None),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
// struct arp;
|
||||
|
||||
// async fn read_arp() -> io::Result<()> {
|
||||
// let arp_file = tokio::fs::File::open("/proc/net/arp").await?;
|
||||
// let arp_read = BufReader::new(arp_file);
|
||||
// Ok(())
|
||||
// }
|
||||
|
||||
//! ip neigh pass
|
||||
|
||||
use std::{net::IpAddr, str::FromStr};
|
||||
|
||||
use crate::utils::parse::mac;
|
||||
use macaddr::MacAddr;
|
||||
use serde_with::skip_serializing_none;
|
||||
use strum::{Display, EnumString};
|
||||
|
||||
use crate::arpparse::error::IPNeighParseError;
|
||||
mod error;
|
||||
mod impls; // custom (de)serialization impls
|
||||
|
||||
/// ip neigh has some cool shit.
|
||||
///
|
||||
/// IP
|
||||
/// dev DEV | None
|
||||
/// lladdr MAC | None
|
||||
/// status { permanent | noarp | stale | reachable | none | incomplete | delay | probe | failed } (ip neigh help)
|
||||
///
|
||||
/// so you can see its damn good
|
||||
#[skip_serializing_none]
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Hash, serde::Serialize)]
|
||||
pub struct IpNeighLine {
|
||||
pub ip: IpAddr,
|
||||
pub dev: Option<String>,
|
||||
/// link layer address
|
||||
#[serde(with = "mac::option_mac")]
|
||||
pub mac: Option<MacAddr>,
|
||||
/// Neighbour Unreachability Detection
|
||||
pub state: NUDState,
|
||||
}
|
||||
|
||||
// NUDState custom Deserialize now lives in arpparse/impl.rs; use serde_with OneOrMany for Vec
|
||||
|
||||
#[derive(
|
||||
Debug, PartialEq, Eq, EnumString, Display, Clone, Copy, Hash, serde::Serialize, Default,
|
||||
)]
|
||||
#[strum(serialize_all = "UPPERCASE", 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,
|
||||
|
||||
/// 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,
|
||||
|
||||
/// 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.
|
||||
#[serde(other)]
|
||||
#[default]
|
||||
None,
|
||||
}
|
||||
|
||||
impl NUDState {
|
||||
/// Argument form expected by `ip neigh ... nud <state>` (lowercase)
|
||||
pub const fn as_ip_neigh_arg(self) -> &'static str {
|
||||
match self {
|
||||
NUDState::Permanent => "permanent",
|
||||
NUDState::Reachable => "reachable",
|
||||
NUDState::Stale => "stale",
|
||||
NUDState::Delay => "delay",
|
||||
NUDState::Probe => "probe",
|
||||
NUDState::Incomplete => "incomplete",
|
||||
NUDState::Noarp => "noarp",
|
||||
NUDState::None => "none",
|
||||
NUDState::Failed => "failed",
|
||||
}
|
||||
}
|
||||
/// dumb UI label
|
||||
pub fn _dumber_state(&self) -> &'static str {
|
||||
match self {
|
||||
NUDState::Permanent | NUDState::Reachable => "online",
|
||||
NUDState::Stale => "maybe online",
|
||||
NUDState::Delay | NUDState::Probe | NUDState::Incomplete => "resolving",
|
||||
NUDState::Noarp => "static",
|
||||
NUDState::None => "unknown",
|
||||
NUDState::Failed => "offline",
|
||||
}
|
||||
}
|
||||
/// dumb boolean: Some(true)=on, Some(false)=off, None=shrug
|
||||
pub fn _dumber_state_this_way(&self) -> Option<bool> {
|
||||
match self {
|
||||
NUDState::Permanent | NUDState::Reachable => Some(true),
|
||||
NUDState::Failed => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
// thanks copilot for the PEAK
|
||||
pub fn parse_ip_neigh_line(s: &str) -> Result<IpNeighLine, IPNeighParseError> {
|
||||
let mut it = s.split_whitespace();
|
||||
let ip: IpAddr = it.next().ok_or(IPNeighParseError::IpWhere)?.parse()?;
|
||||
|
||||
let mut dev: Option<String> = None;
|
||||
let mut mac: Option<MacAddr> = None;
|
||||
let mut state: Option<NUDState> = None;
|
||||
let mut last_tok: Option<&str> = None;
|
||||
|
||||
while let Some(tok) = it.next() {
|
||||
match tok {
|
||||
"dev" => dev = it.next().map(str::to_string),
|
||||
"lladdr" => {
|
||||
mac = it.next().map(|m| m.parse()).transpose()?;
|
||||
}
|
||||
"nud" => {
|
||||
// we know this aint happening
|
||||
state = it.next().map(|st| st.parse()).transpose()?;
|
||||
}
|
||||
other => last_tok = Some(other),
|
||||
}
|
||||
}
|
||||
|
||||
// If no explicit "nud", many outputs end with STATE
|
||||
if state.is_none()
|
||||
&& let Some(st) = last_tok
|
||||
{
|
||||
state = Some(st.parse()?);
|
||||
}
|
||||
|
||||
Ok(IpNeighLine {
|
||||
ip,
|
||||
dev,
|
||||
mac,
|
||||
state: state.ok_or(IPNeighParseError::StateWhere)?,
|
||||
})
|
||||
}
|
||||
|
||||
impl FromStr for IpNeighLine {
|
||||
type Err = IPNeighParseError;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
parse_ip_neigh_line(s)
|
||||
}
|
||||
}
|
||||
/// pls dont touch ts
|
||||
impl IpNeighLine {
|
||||
/* // these are some not needed fns
|
||||
pub fn set_ip(&mut self, ip: IpAddr) {
|
||||
self.ip = ip;
|
||||
}
|
||||
pub fn set_state(&mut self, state: NUDState) {
|
||||
self.state = state;
|
||||
}
|
||||
pub fn ip(self, ip: IpAddr) -> Self {
|
||||
Self { ip, ..self }
|
||||
}
|
||||
pub fn state(self, state: NUDState) -> Self {
|
||||
Self { state, ..self }
|
||||
}
|
||||
*/
|
||||
pub fn _with_dev(dev: impl Into<String>) -> impl FnMut(Self) -> Self {
|
||||
let dev = dev.into();
|
||||
move |self_| Self {
|
||||
dev: Some(dev.clone()),
|
||||
..self_
|
||||
}
|
||||
}
|
||||
pub fn _with_mac(mac: MacAddr) -> impl FnMut(Self) -> Self {
|
||||
move |self_| Self {
|
||||
mac: Some(mac),
|
||||
..self_
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ideas from copilot:
|
||||
|
||||
impl NUDState {
|
||||
// higher is "better"/more online
|
||||
pub const fn rank(self) -> u8 {
|
||||
match self {
|
||||
NUDState::Permanent | NUDState::Reachable => 5,
|
||||
NUDState::Stale => 4,
|
||||
NUDState::Delay | NUDState::Probe | NUDState::Incomplete => 3,
|
||||
NUDState::Noarp => 2,
|
||||
NUDState::None => 1,
|
||||
NUDState::Failed => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl PartialOrd for NUDState {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
impl Ord for NUDState {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.rank().cmp(&other.rank())
|
||||
}
|
||||
}
|
||||
|
||||
impl IpNeighLine {
|
||||
// score for “local and online”: state, has-mac, v4, iface preference
|
||||
pub fn _score(&self) -> (u8, u8, u8, u8) {
|
||||
let iface = self
|
||||
.dev
|
||||
.as_deref()
|
||||
.map(|d| {
|
||||
if d.starts_with("br") || d.starts_with("lan") || d.starts_with("eth") {
|
||||
2
|
||||
} else if d.starts_with("wlan") || d.starts_with("wl") {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
})
|
||||
.unwrap_or(0);
|
||||
(
|
||||
self.state.rank(),
|
||||
self.mac.is_some() as u8,
|
||||
matches!(self.ip, IpAddr::V4(_)) as u8,
|
||||
iface,
|
||||
)
|
||||
}
|
||||
}
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
//! CLI argument parsing, dispatch, rendering, and tracing defaults.
|
||||
|
||||
pub mod table;
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{ArgAction, Args, Parser, Subcommand};
|
||||
use tracing::debug;
|
||||
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
|
||||
use wakey_core::{InterfaceSummary, InventoryQuery, InventoryQueryBuilder, Query, WakeResult};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "wakey")]
|
||||
#[command(version, about = "Operator CLI for Wakey service actions")]
|
||||
pub struct Cli {
|
||||
/// Increase log verbosity. Use `-v` for debug and `-vv` for trace.
|
||||
#[arg(short = 'v', long = "verbose", action = ArgAction::Count, global = true)]
|
||||
pub verbose: u8,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Command {
|
||||
/// Show merged device inventory rows.
|
||||
Inventory(InventoryArgs),
|
||||
/// Show DHCP leases, optionally enriched with current neighbor state.
|
||||
Leases(LeasesArgs),
|
||||
/// Send Wake-on-LAN packets from a query or explicit MAC/IP pair.
|
||||
Wake(WakeArgs),
|
||||
/// Show condensed network interface summaries.
|
||||
Devs(DevsArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct LeasesArgs {
|
||||
/// Include best-known current neighbor state for each lease IP.
|
||||
#[arg(long)]
|
||||
pub include_state: bool,
|
||||
/// Print machine-readable JSON instead of a table.
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
#[command(after_long_help = "Examples:
|
||||
wakey wake bedroom-pc
|
||||
wakey wake --mac aa:bb:cc:dd:ee:ff
|
||||
wakey wake --mac aa:bb:cc:dd:ee:ff --ip 192.168.1.255
|
||||
|
||||
Rules:
|
||||
- query mode and explicit --mac/--ip mode are mutually exclusive
|
||||
- --ip requires --mac
|
||||
- --mac without --ip fans out to interface broadcast targets")]
|
||||
pub struct WakeArgs {
|
||||
/// Free-form device query, for example a hostname, IP, MAC, interface, or NUD state.
|
||||
pub query: Option<String>,
|
||||
/// Explicit MAC address for manual wake mode.
|
||||
#[arg(long)]
|
||||
pub mac: Option<macaddr::MacAddr>,
|
||||
/// Explicit destination IP or broadcast address for manual wake mode.
|
||||
#[arg(long)]
|
||||
pub ip: Option<IpAddr>,
|
||||
/// Print machine-readable JSON instead of a table.
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
#[command(after_long_help = "Examples:
|
||||
wakey inventory bedroom-pc
|
||||
wakey inventory --mac aa:bb:cc:dd:ee:ff
|
||||
wakey inventory --dev br-lan --nud reachable
|
||||
|
||||
If only the positional query is provided, it is treated as free-form input and resolved through the smart selector path.")]
|
||||
pub struct InventoryArgs {
|
||||
/// Free-form device query.
|
||||
pub query: Option<String>,
|
||||
/// Explicit name/text filter.
|
||||
#[arg(long)]
|
||||
pub name: Option<String>,
|
||||
/// Explicit IP filters.
|
||||
#[arg(long = "ip")]
|
||||
pub ips: Vec<std::net::IpAddr>,
|
||||
/// Explicit interface-name filters.
|
||||
#[arg(long = "dev")]
|
||||
pub devs: Vec<String>,
|
||||
/// Explicit neighbor-state filters.
|
||||
#[arg(long = "nud")]
|
||||
pub nuds: Vec<wakey_core::NeighborState>,
|
||||
/// Explicit MAC-address filters.
|
||||
#[arg(long = "mac")]
|
||||
pub macs: Vec<macaddr::MacAddr>,
|
||||
/// Print machine-readable JSON instead of a table.
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
#[command(after_long_help = "Examples:
|
||||
wakey devs
|
||||
wakey devs br-lan
|
||||
wakey devs --up
|
||||
wakey devs --json")]
|
||||
pub struct DevsArgs {
|
||||
/// Optional interface name to show.
|
||||
pub dev: Option<String>,
|
||||
/// Show only interfaces whose operstate is `up`.
|
||||
#[arg(long)]
|
||||
pub up: bool,
|
||||
/// Print machine-readable JSON instead of a table.
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
pub fn init_tracing(verbose: u8) {
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.or_else(|_| EnvFilter::try_new(default_filter_for_verbosity(verbose)))
|
||||
.expect("static tracing filter should parse");
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(fmt::layer())
|
||||
.init();
|
||||
}
|
||||
|
||||
pub fn default_filter_for_verbosity(verbose: u8) -> &'static str {
|
||||
match verbose {
|
||||
0 => "wakey=info",
|
||||
1 => "wakey=debug",
|
||||
_ => "wakey=trace",
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(cli: Cli) -> Result<()> {
|
||||
init_tracing(cli.verbose);
|
||||
|
||||
match cli.command {
|
||||
Command::Inventory(args) => {
|
||||
let as_json = args.json;
|
||||
let query = inventory_args_to_query(args);
|
||||
let selected_name = query.iter().find_map(|term| match term {
|
||||
Query::Text(text) => Some(text.clone()),
|
||||
_ => None,
|
||||
});
|
||||
debug!(?query, json = as_json, "dispatching inventory command");
|
||||
let status = wakey::inventory(query).await?;
|
||||
if as_json {
|
||||
println!("{}", serde_json::to_string_pretty(&status)?);
|
||||
} else {
|
||||
if let Some(name) = &selected_name {
|
||||
println!("name: {name}");
|
||||
}
|
||||
println!("{}", table::render_status_table(&status));
|
||||
}
|
||||
}
|
||||
Command::Leases(args) => {
|
||||
debug!(
|
||||
include_state = args.include_state,
|
||||
json = args.json,
|
||||
"dispatching leases command"
|
||||
);
|
||||
let leases = wakey::get_leases(wakey_core::LeaseQuery {
|
||||
include_state: args.include_state,
|
||||
})
|
||||
.await?;
|
||||
if args.json {
|
||||
println!("{}", serde_json::to_string_pretty(&leases)?);
|
||||
} else {
|
||||
println!("{}", table::render_leases_table(&leases));
|
||||
}
|
||||
}
|
||||
Command::Wake(args) => {
|
||||
let as_json = args.json;
|
||||
debug!(
|
||||
has_query = args.query.is_some(),
|
||||
has_mac = args.mac.is_some(),
|
||||
has_ip = args.ip.is_some(),
|
||||
json = as_json,
|
||||
"dispatching wake command"
|
||||
);
|
||||
let result = run_wake(args).await?;
|
||||
if as_json {
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
} else {
|
||||
println!("{}", table::render_wake_table(&result));
|
||||
}
|
||||
}
|
||||
Command::Devs(args) => {
|
||||
debug!(dev = ?args.dev, up = args.up, json = args.json, "dispatching devs command");
|
||||
let devs = if let Some(name) = &args.dev {
|
||||
wakey::get_interface_summary(name)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect()
|
||||
} else {
|
||||
wakey::get_interface_summaries().await?
|
||||
};
|
||||
let devs = filter_interface_summaries(devs, &args);
|
||||
if args.json {
|
||||
println!("{}", serde_json::to_string_pretty(&devs)?);
|
||||
} else {
|
||||
println!("{}", table::render_devs_table(&devs));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inventory_args_to_query(args: InventoryArgs) -> InventoryQuery {
|
||||
InventoryQueryBuilder::new()
|
||||
.maybe_text(args.name.or(args.query))
|
||||
.ips(args.ips)
|
||||
.interfaces(args.devs)
|
||||
.neighbor_states(args.nuds)
|
||||
.macs(args.macs)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn validate_wake_args(args: &WakeArgs) -> Result<()> {
|
||||
let has_query = args.query.is_some();
|
||||
let has_mac = args.mac.is_some();
|
||||
let has_ip = args.ip.is_some();
|
||||
|
||||
if has_ip && !has_mac {
|
||||
anyhow::bail!("`wakey wake --ip` needs `--mac`");
|
||||
}
|
||||
|
||||
if has_query && (has_mac || has_ip) {
|
||||
anyhow::bail!("query mode and explicit `--mac/--ip` mode are mutually exclusive");
|
||||
}
|
||||
|
||||
if !has_query && !has_mac {
|
||||
anyhow::bail!("provide either a query or `--mac`");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_wake(args: WakeArgs) -> Result<WakeResult> {
|
||||
validate_wake_args(&args)?;
|
||||
|
||||
match (args.query, args.mac, args.ip) {
|
||||
(Some(query), None, None) => wakey::wake_from_query(query).await,
|
||||
(None, Some(mac), ip) => wakey::wake_explicit(mac, ip).await,
|
||||
_ => unreachable!("wake args validated before dispatch"),
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_interface_summaries(
|
||||
mut devs: Vec<InterfaceSummary>,
|
||||
args: &DevsArgs,
|
||||
) -> Vec<InterfaceSummary> {
|
||||
if args.up {
|
||||
devs.retain(|dev| dev.operstate == "up");
|
||||
}
|
||||
if let Some(name) = &args.dev {
|
||||
devs.retain(|dev| &dev.ifname == name);
|
||||
}
|
||||
devs
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{WakeArgs, default_filter_for_verbosity};
|
||||
|
||||
#[test]
|
||||
fn wake_rejects_ip_without_mac() {
|
||||
let err = super::validate_wake_args(&WakeArgs {
|
||||
query: None,
|
||||
mac: None,
|
||||
ip: Some("192.168.1.10".parse().expect("ip")),
|
||||
json: false,
|
||||
})
|
||||
.expect_err("ip-only wake should be rejected");
|
||||
|
||||
assert!(err.to_string().contains("--ip"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wake_rejects_mixed_query_and_explicit_mode() {
|
||||
let err = super::validate_wake_args(&WakeArgs {
|
||||
query: Some("pc".into()),
|
||||
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
|
||||
ip: None,
|
||||
json: false,
|
||||
})
|
||||
.expect_err("mixed wake mode should be rejected");
|
||||
|
||||
assert!(err.to_string().contains("mutually exclusive"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wake_accepts_query_mode() {
|
||||
super::validate_wake_args(&WakeArgs {
|
||||
query: Some("pc".into()),
|
||||
mac: None,
|
||||
ip: None,
|
||||
json: false,
|
||||
})
|
||||
.expect("query mode should be accepted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wake_accepts_manual_mac_mode() {
|
||||
super::validate_wake_args(&WakeArgs {
|
||||
query: None,
|
||||
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
|
||||
ip: None,
|
||||
json: false,
|
||||
})
|
||||
.expect("manual mac mode should be accepted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verbosity_maps_to_expected_default_filters() {
|
||||
assert_eq!(default_filter_for_verbosity(0), "wakey=info");
|
||||
assert_eq!(default_filter_for_verbosity(1), "wakey=debug");
|
||||
assert_eq!(default_filter_for_verbosity(2), "wakey=trace");
|
||||
assert_eq!(default_filter_for_verbosity(9), "wakey=trace");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
use chrono::{DateTime, Local};
|
||||
use comfy_table::{Cell, ContentArrangement, Table, presets::UTF8_FULL};
|
||||
use wakey_core::{DeviceInventory, DhcpLeaseWithState, InterfaceSummary, WakeResult};
|
||||
|
||||
pub fn render_status_table(status: &DeviceInventory) -> Table {
|
||||
let mut table = base_table();
|
||||
table.set_header(vec!["Name", "IP", "MAC", "Presence", "Interfaces"]);
|
||||
for device in &status.devices {
|
||||
let name = device
|
||||
.names
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "(unnamed)".into());
|
||||
let ips: Vec<String> = if device.ips.is_empty() {
|
||||
vec![String::new()]
|
||||
} else {
|
||||
device.ips.iter().map(ToString::to_string).collect()
|
||||
};
|
||||
let macs: Vec<String> = if device.macs.is_empty() {
|
||||
vec![String::new()]
|
||||
} else {
|
||||
device.macs.iter().map(ToString::to_string).collect()
|
||||
};
|
||||
let interfaces: Vec<String> = if device.interfaces.is_empty() {
|
||||
vec![String::new()]
|
||||
} else {
|
||||
device.interfaces.clone()
|
||||
};
|
||||
|
||||
let row_count = ips.len().max(macs.len()).max(interfaces.len());
|
||||
for idx in 0..row_count {
|
||||
table.add_row(vec![
|
||||
Cell::new(if idx == 0 { name.as_str() } else { "" }),
|
||||
Cell::new(ips.get(idx).cloned().unwrap_or_default()),
|
||||
Cell::new(macs.get(idx).cloned().unwrap_or_default()),
|
||||
Cell::new(if idx == 0 {
|
||||
format!("{:?}", device.presence)
|
||||
} else {
|
||||
String::new()
|
||||
}),
|
||||
Cell::new(interfaces.get(idx).cloned().unwrap_or_default()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
table
|
||||
}
|
||||
|
||||
pub fn render_leases_table(leases: &[DhcpLeaseWithState]) -> Table {
|
||||
let mut table = base_table();
|
||||
table.set_header(vec!["Expires", "IP", "MAC", "Name", "State"]);
|
||||
for lease in leases {
|
||||
table.add_row(vec![
|
||||
Cell::new(format_epoch(lease.lease_line.expires_epoch)),
|
||||
Cell::new(lease.lease_line.ip.to_string()),
|
||||
Cell::new(lease.lease_line.mac.to_string()),
|
||||
Cell::new(lease.lease_line.name.clone().unwrap_or_default()),
|
||||
Cell::new(lease.nud_state.map(|v| v.to_string()).unwrap_or_default()),
|
||||
]);
|
||||
}
|
||||
table
|
||||
}
|
||||
|
||||
pub fn render_wake_table(result: &WakeResult) -> Table {
|
||||
let mut table = base_table();
|
||||
table.set_header(vec!["IP", "MAC", "Status"]);
|
||||
for row in &result.result {
|
||||
table.add_row(vec![
|
||||
Cell::new(row.target.ip.map(|v| v.to_string()).unwrap_or_default()),
|
||||
Cell::new(row.target.mac.map(|v| v.to_string()).unwrap_or_default()),
|
||||
Cell::new(format!("{:?}", row.status)),
|
||||
]);
|
||||
}
|
||||
table
|
||||
}
|
||||
|
||||
pub fn render_devs_table(devs: &[InterfaceSummary]) -> Table {
|
||||
let mut table = base_table();
|
||||
table.set_header(vec![
|
||||
"Ifname",
|
||||
"State",
|
||||
"MAC",
|
||||
"Family",
|
||||
"CIDR",
|
||||
"Broadcast",
|
||||
"Scope/Label",
|
||||
]);
|
||||
|
||||
for dev in devs {
|
||||
if dev.addrs.is_empty() {
|
||||
table.add_row(vec![
|
||||
Cell::new(&dev.ifname),
|
||||
Cell::new(&dev.operstate),
|
||||
Cell::new(dev.mac.map(|v| v.to_string()).unwrap_or_default()),
|
||||
Cell::new(""),
|
||||
Cell::new(""),
|
||||
Cell::new(""),
|
||||
Cell::new(""),
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (idx, addr) in dev.addrs.iter().enumerate() {
|
||||
let lead = idx == 0;
|
||||
table.add_row(vec![
|
||||
Cell::new(if lead { dev.ifname.as_str() } else { "" }),
|
||||
Cell::new(if lead { dev.operstate.as_str() } else { "" }),
|
||||
Cell::new(if lead {
|
||||
dev.mac.map(|v| v.to_string()).unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
}),
|
||||
Cell::new(addr.family.clone().unwrap_or_default()),
|
||||
Cell::new(addr.cidr.clone().unwrap_or_default()),
|
||||
Cell::new(addr.broadcast.map(|v| v.to_string()).unwrap_or_default()),
|
||||
Cell::new(match (&addr.scope, &addr.label) {
|
||||
(Some(scope), Some(label)) => format!("{scope} / {label}"),
|
||||
(Some(scope), None) => scope.clone(),
|
||||
(None, Some(label)) => label.clone(),
|
||||
(None, None) => String::new(),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
table
|
||||
}
|
||||
|
||||
fn base_table() -> Table {
|
||||
let mut table = Table::new();
|
||||
table
|
||||
.load_preset(UTF8_FULL)
|
||||
.set_content_arrangement(ContentArrangement::Dynamic);
|
||||
table
|
||||
}
|
||||
|
||||
fn format_epoch(epoch: u64) -> String {
|
||||
DateTime::from_timestamp(epoch as i64, 0)
|
||||
.map(|dt| {
|
||||
dt.with_timezone(&Local)
|
||||
.format("%Y-%m-%d %H:%M:%S")
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_else(|| epoch.to_string())
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
pub mod service;
|
||||
pub mod utils;
|
||||
|
||||
pub use service::{
|
||||
broadcast_wake_targets, get_interface_summaries, get_interface_summary, get_ips, get_leases,
|
||||
inventory, leases_without_state, merge_devices, query_to_inventory_query, resolve_devices,
|
||||
resolve_query, resolve_selector, resolve_wake_targets, wake_explicit, wake_from_query,
|
||||
wake_targets,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use wakey_core::{
|
||||
DhcpLease, NeighborEntry, NeighborState, Presence, Query, WakeStatus, WakeTarget,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_query_parses_ip() {
|
||||
let query = resolve_query("192.168.1.10").await.expect("resolve query");
|
||||
assert_eq!(
|
||||
query,
|
||||
vec![Query::Ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)))]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_query_parses_mac() {
|
||||
let query = resolve_query("aa:bb:cc:dd:ee:ff")
|
||||
.await
|
||||
.expect("resolve query");
|
||||
assert_eq!(
|
||||
query,
|
||||
vec![Query::Mac("aa:bb:cc:dd:ee:ff".parse().expect("mac"))]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_query_parses_nud() {
|
||||
let query = resolve_query("reachable").await.expect("resolve query");
|
||||
assert_eq!(query, vec![Query::NeighborState(NeighborState::Reachable)]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_selector_keeps_text_vs_structured() {
|
||||
let selector = resolve_selector("reachable")
|
||||
.await
|
||||
.expect("resolve selector");
|
||||
match selector {
|
||||
Query::NeighborState(NeighborState::Reachable) => {}
|
||||
_ => panic!("expected neighbor-state selector"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leases_without_state_clears_nud_state() {
|
||||
let leases = vec![DhcpLease {
|
||||
expires_epoch: 1,
|
||||
ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
mac: "aa:bb:cc:dd:ee:ff".parse().expect("mac"),
|
||||
name: Some("pc".into()),
|
||||
}];
|
||||
let out = leases_without_state(leases);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert!(out[0].nud_state.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_devices_combines_lease_and_neighbor() {
|
||||
let neighbors = vec![NeighborEntry {
|
||||
ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)),
|
||||
dev: Some("br-lan".into()),
|
||||
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
|
||||
state: NeighborState::Reachable,
|
||||
}];
|
||||
let leases = vec![wakey_core::DhcpLeaseWithState {
|
||||
lease_line: DhcpLease {
|
||||
expires_epoch: 1,
|
||||
ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)),
|
||||
mac: "aa:bb:cc:dd:ee:ff".parse().expect("mac"),
|
||||
name: Some("pc".into()),
|
||||
},
|
||||
nud_state: None,
|
||||
}];
|
||||
let devices = merge_devices(neighbors, leases, &wakey_core::InventoryQuery::default());
|
||||
assert_eq!(devices.len(), 1);
|
||||
assert_eq!(devices[0].presence, Presence::Online);
|
||||
assert_eq!(devices[0].names, vec!["pc".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wake_targets_marks_incomplete() {
|
||||
let out = wake_targets(vec![WakeTarget {
|
||||
ip: Some(IpAddr::V4(Ipv4Addr::LOCALHOST)),
|
||||
mac: None,
|
||||
}])
|
||||
.await
|
||||
.expect("wake");
|
||||
assert_eq!(out.result.len(), 1);
|
||||
assert_eq!(out.result[0].status, WakeStatus::Incomplete);
|
||||
}
|
||||
}
|
||||
+7
-56
@@ -1,65 +1,16 @@
|
||||
//! braindead version v0.1.x
|
||||
//!
|
||||
//! # whats next
|
||||
//!
|
||||
//! for version 2 i hope to have:
|
||||
//!
|
||||
//! 1. idk reworked frontend;
|
||||
//! 2. incorporate ip -j;
|
||||
//! 3. small 1-5 second caching;
|
||||
mod cli;
|
||||
|
||||
use axum::Router;
|
||||
use tokio::net::TcpListener;
|
||||
mod arpparse;
|
||||
mod dhcpparse;
|
||||
mod route;
|
||||
mod utils;
|
||||
use std::{env, io};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::main]
|
||||
async fn entry() -> io::Result<()> {
|
||||
use crate::route::api_router;
|
||||
use axum::routing::get_service;
|
||||
use tower_http::services::ServeDir;
|
||||
let exe = env::current_exe()?;
|
||||
let root = exe
|
||||
.parent()
|
||||
.ok_or_else(|| io::Error::other("no parent dir"))?;
|
||||
let static_dir = ServeDir::new(root.join("static"))
|
||||
.append_index_html_on_directories(true)
|
||||
.precompressed_br()
|
||||
.precompressed_deflate()
|
||||
.precompressed_gzip()
|
||||
.precompressed_zstd();
|
||||
let app = Router::new()
|
||||
// .route("/home", get(home))
|
||||
// .route("/", get(home_2))
|
||||
// .merge(home_2_route())
|
||||
// .route("/status", get(get_status_2))
|
||||
.nest("/api", api_router())
|
||||
.fallback_service(get_service(static_dir));
|
||||
|
||||
let port = TcpListener::bind("0.0.0.0:12012").await?;
|
||||
axum::serve(port, app.into_make_service()).await?;
|
||||
Ok(())
|
||||
}
|
||||
use clap::Parser;
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn main() -> color_eyre::Result<()> {
|
||||
use std::net::ToSocketAddrs;
|
||||
color_eyre::install()?;
|
||||
// use crate::arpparse::NUDState;
|
||||
// println!("{}", NUDState::Reachable.to_string().to_lowercase());
|
||||
println!("{:?}", "svuhuvshdv:331".to_socket_addrs());
|
||||
// Err(Os { code: 11001, kind: Uncategorized, message: "No such host is known." })
|
||||
Err(color_eyre::eyre::eyre!(
|
||||
fn main() -> anyhow::Result<()> {
|
||||
Err(anyhow::anyhow!(
|
||||
"OS not supported! run this on your ahh router!"
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() -> color_eyre::Result<()> {
|
||||
color_eyre::install()?;
|
||||
Ok(entry()?)
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
cli::run(cli::Cli::parse()).await
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
use crate::route::error::ApiError;
|
||||
use crate::utils::query::parser::{QueryType, parse_query};
|
||||
use axum::Json;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{extract::Path, response::Redirect};
|
||||
|
||||
use crate::route::status::{DeviceQuery, Filters, NamePath};
|
||||
use crate::utils::query::get_ips;
|
||||
|
||||
// Smart redirect: accept IP, MAC, dev, or NUD state and redirect to /api/status accordingly
|
||||
pub async fn status_smart_redirect(
|
||||
Path(q): Path<String>,
|
||||
) -> axum::response::Result<Redirect, impl IntoResponse> {
|
||||
// no less bullshit
|
||||
let query = match parse_query(q).await {
|
||||
QueryType::Ip(ip_addr) => DeviceQuery {
|
||||
filter: Filters {
|
||||
ips: vec![ip_addr],
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
QueryType::Mac(mac_addr) => DeviceQuery {
|
||||
filter: Filters {
|
||||
macs: vec![mac_addr],
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
QueryType::Dev(s) => DeviceQuery {
|
||||
filter: Filters {
|
||||
devs: vec![s],
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
QueryType::Nud(nudstate) => DeviceQuery {
|
||||
filter: Filters {
|
||||
nuds: vec![nudstate],
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
QueryType::Unknown(n) => DeviceQuery {
|
||||
name: Some(n),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
match serde_html_form::to_string(query) {
|
||||
Ok(e) => Ok(Redirect::to(&format!("/api/status?{e}"))),
|
||||
Err(e) => Err(ApiError {
|
||||
error: e.to_string(),
|
||||
code: StatusCode::BAD_GATEWAY,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn status_redirect(Path(NamePath { name }): Path<NamePath>) -> Redirect {
|
||||
Redirect::permanent(&format!(
|
||||
"/api/status?name={name}",
|
||||
name = urlencoding::encode(&name) // just for
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn ip(Path(name): Path<String>) -> impl IntoResponse {
|
||||
get_ips(&name).await.map_or_else(
|
||||
|e| ApiError::ise(e.to_string()).into_response(),
|
||||
|ips| Json(ips.collect::<Vec<_>>()).into_response(),
|
||||
)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
use crate::utils::query::dev;
|
||||
use axum::Json;
|
||||
|
||||
pub async fn devs_router() -> Json<Vec<String>> {
|
||||
dev::devs_sorted().await.into()
|
||||
}
|
||||
// Device listing endpoints
|
||||
@@ -1,35 +0,0 @@
|
||||
use crate::{
|
||||
dhcpparse::read_dhcp_leases_with_names,
|
||||
route::error::ApiError,
|
||||
utils::{parse::boolish_str, query::enrich_leases_with_nud_state},
|
||||
};
|
||||
use axum::{Json, extract::Query, http::StatusCode, response::IntoResponse};
|
||||
|
||||
// DHCP lease endpoints
|
||||
#[derive(Debug, Default, Clone, serde::Deserialize)]
|
||||
pub struct DhcpLeasesQueryRaw {
|
||||
include_state: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_dhcp_leases(Query(raw): Query<DhcpLeasesQueryRaw>) -> impl IntoResponse {
|
||||
let include_state = raw
|
||||
.include_state
|
||||
.as_deref()
|
||||
.map(boolish_str)
|
||||
.unwrap_or(false);
|
||||
|
||||
match read_dhcp_leases_with_names().await {
|
||||
Ok(leases_with_names) => {
|
||||
if !include_state {
|
||||
return (StatusCode::OK, Json(leases_with_names)).into_response();
|
||||
}
|
||||
let out = enrich_leases_with_nud_state(leases_with_names).await;
|
||||
(StatusCode::OK, Json(out)).into_response()
|
||||
}
|
||||
Err(e) => ApiError {
|
||||
error: e.to_string(),
|
||||
code: StatusCode::BAD_GATEWAY,
|
||||
}
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ApiError {
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
pub code: StatusCode,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
/// [StatusCode::INTERNAL_SERVER_ERROR] shortcut
|
||||
pub const fn ise(error: String) -> Self {
|
||||
Self {
|
||||
code: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
(self.code, Json(self)).into_response()
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
pub mod api;
|
||||
pub mod devs;
|
||||
pub mod dhcp;
|
||||
pub mod error;
|
||||
pub mod status;
|
||||
pub mod wake;
|
||||
|
||||
// use crate::assets;
|
||||
use crate::dhcpparse::load_mac_name_cache;
|
||||
use crate::route::api::ip;
|
||||
use crate::route::api::status_redirect;
|
||||
use crate::route::api::status_smart_redirect;
|
||||
use crate::route::devs::devs_router;
|
||||
use crate::route::dhcp::get_dhcp_leases;
|
||||
use crate::route::status::get_status_json;
|
||||
use crate::route::wake::wake_multi;
|
||||
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::http::Request;
|
||||
use axum::middleware::{self, Next};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use std::time::Instant;
|
||||
|
||||
async fn add_performance_header(req: Request<Body>, next: Next) -> Response {
|
||||
let start = Instant::now();
|
||||
let mut response = next.run(req).await;
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
if let Ok(val) = format!("work-time={}us", elapsed.as_micros()).parse() {
|
||||
response.headers_mut().insert("Lda-Performance", val);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
pub fn api_router() -> Router {
|
||||
Router::new()
|
||||
.route("/status/{name}", get(status_redirect))
|
||||
.route("/status", get(get_status_json))
|
||||
.route("/dhcp_leases", get(get_dhcp_leases))
|
||||
.route("/smart/{q}", get(status_smart_redirect))
|
||||
.route("/devs", get(devs_router))
|
||||
.route("/wake", post(wake_multi))
|
||||
.route("/ips/{name}", get(ip))
|
||||
.route(
|
||||
"/mac-cache",
|
||||
get(async || match load_mac_name_cache().await {
|
||||
Ok(h) => Json(h).into_response(),
|
||||
Err(e) => e.to_string().into_response(),
|
||||
}),
|
||||
)
|
||||
.layer(middleware::from_fn(add_performance_header))
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
use crate::route::error::ApiError;
|
||||
use axum::{Json, http::StatusCode, response::IntoResponse};
|
||||
use axum_extra::extract::Query;
|
||||
use macaddr::MacAddr;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::skip_serializing_none;
|
||||
use serde_with::{DisplayFromStr, OneOrMany, serde_as};
|
||||
use std::net::IpAddr;
|
||||
|
||||
use crate::arpparse::NUDState;
|
||||
use crate::utils::query::get_macs;
|
||||
|
||||
#[derive(Debug, Default, Clone, Hash, Deserialize, Serialize)]
|
||||
pub struct DeviceQuery {
|
||||
pub name: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub filter: Filters,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Hash, Deserialize)]
|
||||
pub struct NamePath {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[skip_serializing_none]
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
pub struct Status<T> {
|
||||
pub name: Option<String>,
|
||||
pub table: Vec<T>,
|
||||
pub filters: Filters,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Default, Clone, Hash, Serialize, Deserialize)]
|
||||
pub struct Filters {
|
||||
#[serde_as(as = "OneOrMany<_>")]
|
||||
#[serde(default)]
|
||||
pub ips: Vec<IpAddr>,
|
||||
#[serde_as(as = "OneOrMany<_>")]
|
||||
#[serde(default)]
|
||||
pub devs: Vec<String>,
|
||||
#[serde_as(as = "OneOrMany<_>")]
|
||||
#[serde(default)]
|
||||
pub nuds: Vec<NUDState>,
|
||||
#[serde_as(as = "OneOrMany<DisplayFromStr>")]
|
||||
#[serde(default)]
|
||||
pub macs: Vec<MacAddr>,
|
||||
}
|
||||
|
||||
pub async fn get_status_json(
|
||||
Query(DeviceQuery {
|
||||
name,
|
||||
filter: filters,
|
||||
..
|
||||
}): Query<DeviceQuery>,
|
||||
) -> impl IntoResponse {
|
||||
match get_macs(
|
||||
name.as_slice(),
|
||||
&filters.ips,
|
||||
&filters.devs,
|
||||
&filters.nuds,
|
||||
&filters.macs,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(table) => (
|
||||
StatusCode::OK,
|
||||
Json(Status {
|
||||
name,
|
||||
table,
|
||||
filters,
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
Err(error) => ApiError {
|
||||
code: StatusCode::BAD_GATEWAY,
|
||||
error: error
|
||||
.chain()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(": "),
|
||||
}
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
// Status endpoints
|
||||
@@ -1,113 +0,0 @@
|
||||
//! impls are at [`utils::wake::impl`](crate::utils::wake::r#impl) for some reason
|
||||
use std::io;
|
||||
use std::net::IpAddr;
|
||||
|
||||
/* use crate::arpparse::IpNeighLine;
|
||||
use crate::route::api::Status; */
|
||||
use crate::utils::parse::mac;
|
||||
use crate::utils::wake::wake_one;
|
||||
use axum::{extract::Json, http::StatusCode, response::IntoResponse};
|
||||
use futures::TryFutureExt;
|
||||
use macaddr::MacAddr;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::skip_serializing_none;
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
#[skip_serializing_none]
|
||||
#[derive(Debug, Default, Serialize, Clone)]
|
||||
pub struct WakeResult {
|
||||
pub success: bool,
|
||||
pub result: Option<Vec<WakeTargetResult>>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
#[skip_serializing_none]
|
||||
#[derive(Debug, Serialize, Clone, Copy)]
|
||||
pub struct WakeTargetResult {
|
||||
#[serde(flatten)]
|
||||
pub target: WakeTarget,
|
||||
pub status: WakeTargetStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone, Copy, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WakeTargetStatus {
|
||||
Succeed,
|
||||
/// not a real address...
|
||||
NonexistentAddress,
|
||||
WrongSize,
|
||||
/// input is not enough
|
||||
Incomplete,
|
||||
}
|
||||
|
||||
#[skip_serializing_none]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Copy)]
|
||||
pub struct WakeTarget {
|
||||
#[serde(default)]
|
||||
pub ip: Option<IpAddr>,
|
||||
#[serde(default, with = "mac::option_mac")]
|
||||
pub mac: Option<MacAddr>,
|
||||
}
|
||||
|
||||
pub async fn wake_multi(Json(req): Json<Vec<WakeTarget>>) -> impl IntoResponse {
|
||||
match wake_multi_split(req).await {
|
||||
Ok(results) => (
|
||||
StatusCode::OK,
|
||||
Json(WakeResult {
|
||||
success: true,
|
||||
result: Some(results),
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
Err(error) => {
|
||||
let error = Some(format!("Error: {}", error));
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(WakeResult {
|
||||
success: false,
|
||||
error,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// this is so bad
|
||||
pub async fn wake_multi_split(
|
||||
targets: impl IntoIterator<Item = WakeTarget>,
|
||||
) -> io::Result<Vec<WakeTargetResult>> {
|
||||
let sock = UdpSocket::bind("[::]:0")
|
||||
.or_else(|_| UdpSocket::bind(":0"))
|
||||
.await?;
|
||||
sock.set_broadcast(true)?;
|
||||
|
||||
let iter = targets.into_iter().map(async |c| {
|
||||
if c.is_incomplete() {
|
||||
c.to_incomplete()
|
||||
} else {
|
||||
let t = c.try_into().expect("complete struct failed to try_into");
|
||||
wake_one(&sock, t).await.into()
|
||||
}
|
||||
});
|
||||
Ok(futures::future::join_all(iter).await)
|
||||
}
|
||||
/* #[derive(Debug, Serialize)]
|
||||
pub struct WakeStatusLine {
|
||||
#[serde(flatten)]
|
||||
pub status: IpNeighLine, // most powerful find of the century
|
||||
pub wake_status: WakeTargetStatus,
|
||||
}
|
||||
pub type WakeStatus = Status<WakeStatusLine>; */
|
||||
// /// return status BUT plus a indicator of i sent a wake.
|
||||
// pub async fn wake_status(
|
||||
// Query(DeviceQuery {
|
||||
// name,
|
||||
// ip,
|
||||
// dev,
|
||||
// nud,
|
||||
// mac,
|
||||
// ..
|
||||
// }): Query<DeviceQuery>,
|
||||
// ) -> impl IntoResponse {
|
||||
// }
|
||||
pub mod impls;
|
||||
@@ -1,69 +0,0 @@
|
||||
use crate::utils::wake::{WakeStatus, WakeTarget, WakeTargetResult};
|
||||
use crate::route::wake::{
|
||||
WakeTarget as RouteWakeTarget, WakeTargetResult as RouteWakeResult,
|
||||
WakeTargetStatus as RouteWakeStatus,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Incomplete;
|
||||
|
||||
impl TryFrom<RouteWakeTarget> for WakeTarget {
|
||||
type Error = Incomplete;
|
||||
fn try_from(value: RouteWakeTarget) -> Result<Self, Self::Error> {
|
||||
if let RouteWakeTarget {
|
||||
ip: Some(ip),
|
||||
mac: Some(mac),
|
||||
} = value
|
||||
{
|
||||
Ok(Self { ip, mac })
|
||||
} else {
|
||||
Err(Incomplete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WakeTarget> for RouteWakeTarget {
|
||||
fn from(WakeTarget { ip, mac }: WakeTarget) -> Self {
|
||||
Self {
|
||||
ip: Some(ip),
|
||||
mac: Some(mac),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WakeTargetResult> for RouteWakeResult {
|
||||
fn from(WakeTargetResult { target, status }: WakeTargetResult) -> Self {
|
||||
Self {
|
||||
target: target.into(),
|
||||
status: status.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RouteWakeTarget {
|
||||
pub const fn to_incomplete(self) -> RouteWakeResult {
|
||||
RouteWakeResult {
|
||||
target: self,
|
||||
status: RouteWakeStatus::Incomplete,
|
||||
}
|
||||
}
|
||||
pub const fn is_incomplete(&self) -> bool {
|
||||
!matches!(
|
||||
self,
|
||||
Self {
|
||||
ip: Some(_),
|
||||
mac: Some(_)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WakeStatus> for RouteWakeStatus {
|
||||
fn from(value: WakeStatus) -> Self {
|
||||
match value {
|
||||
WakeStatus::NonexistentAddress => Self::NonexistentAddress,
|
||||
WakeStatus::Success => Self::Succeed,
|
||||
WakeStatus::WrongSize => Self::WrongSize,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use anyhow::Result;
|
||||
use tracing::{debug, instrument};
|
||||
use wakey_core::InterfaceSummary;
|
||||
|
||||
/// Return condensed interface summaries useful for CLI and wake routing.
|
||||
#[instrument(skip_all)]
|
||||
pub async fn get_interface_summaries() -> Result<Vec<InterfaceSummary>> {
|
||||
let summaries = wakey_linux::devices::list_interface_summaries().await?;
|
||||
debug!(count = summaries.len(), "loaded interface summaries");
|
||||
Ok(summaries)
|
||||
}
|
||||
|
||||
/// Return one named interface summary when present.
|
||||
#[instrument(skip_all, fields(ifname = name))]
|
||||
pub async fn get_interface_summary(name: &str) -> Result<Option<InterfaceSummary>> {
|
||||
let summary = get_interface_summaries()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|iface| iface.ifname == name);
|
||||
debug!(found = summary.is_some(), "resolved interface summary");
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
/// Resolve a hostname through the local resolver and collect all returned IPs.
|
||||
pub async fn get_ips(name: impl AsRef<str>) -> Result<Vec<std::net::IpAddr>> {
|
||||
Ok(wakey_linux::devices::get_ips(name.as_ref())
|
||||
.await?
|
||||
.collect())
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
use anyhow::Result;
|
||||
use wakey_core::{
|
||||
Device, DeviceInventory, DhcpLease, DhcpLeaseWithState, InventoryQuery, NeighborEntry,
|
||||
Presence, Query,
|
||||
};
|
||||
|
||||
use crate::service::leases::get_leases;
|
||||
use crate::service::query::resolve_query;
|
||||
|
||||
/// Resolve free-form input and return merged devices rather than raw source rows.
|
||||
pub async fn resolve_devices(input: impl Into<String>) -> Result<Vec<Device>> {
|
||||
let query = resolve_query(input).await?;
|
||||
inventory(query).await.map(|inventory| inventory.devices)
|
||||
}
|
||||
|
||||
/// Build a merged device inventory from neighbor-table and DHCP-lease sources.
|
||||
///
|
||||
/// This is the current center of gravity for the service layer. Higher-level
|
||||
/// status and wake flows should prefer deriving from inventory rather than
|
||||
/// directly from raw Linux source rows.
|
||||
pub async fn inventory(query: InventoryQuery) -> Result<DeviceInventory> {
|
||||
let neighbors = wakey_linux::devices::query_neighbors(&query).await?;
|
||||
let leases = get_leases(wakey_core::LeaseQuery {
|
||||
include_state: false,
|
||||
})
|
||||
.await?;
|
||||
Ok(DeviceInventory {
|
||||
devices: merge_devices(neighbors, leases, &query),
|
||||
})
|
||||
}
|
||||
|
||||
/// Merge raw neighbor entries and DHCP leases into device aggregates.
|
||||
///
|
||||
/// Identity is currently MAC-first, with an IP-based fallback when a neighbor
|
||||
/// row does not include a MAC address.
|
||||
pub fn merge_devices(
|
||||
neighbors: Vec<NeighborEntry>,
|
||||
leases: Vec<DhcpLeaseWithState>,
|
||||
query: &InventoryQuery,
|
||||
) -> Vec<Device> {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
let mut by_mac: BTreeMap<String, (Vec<NeighborEntry>, Vec<DhcpLease>)> = BTreeMap::new();
|
||||
|
||||
for row in neighbors {
|
||||
let key = row
|
||||
.mac
|
||||
.map(|m| m.to_string())
|
||||
.unwrap_or_else(|| format!("ip:{}", row.ip));
|
||||
by_mac.entry(key).or_default().0.push(row);
|
||||
}
|
||||
for lease in leases {
|
||||
let key = lease.lease_line.mac.to_string();
|
||||
by_mac.entry(key).or_default().1.push(lease.lease_line);
|
||||
}
|
||||
|
||||
let mut devices: Vec<Device> = by_mac
|
||||
.into_values()
|
||||
.map(|(neighbors, leases)| Device::from_parts(neighbors, leases))
|
||||
.collect();
|
||||
|
||||
let mut texts: Vec<&str> = Vec::new();
|
||||
let mut devs: Vec<&str> = Vec::new();
|
||||
let mut ips = Vec::new();
|
||||
let mut macs = Vec::new();
|
||||
let mut nuds = Vec::new();
|
||||
|
||||
for term in query {
|
||||
match term {
|
||||
Query::Text(v) => texts.push(v.as_str()),
|
||||
Query::Interface(v) => devs.push(v.as_str()),
|
||||
Query::Ip(v) => ips.push(*v),
|
||||
Query::Mac(v) => macs.push(*v),
|
||||
Query::NeighborState(v) => nuds.push(*v),
|
||||
}
|
||||
}
|
||||
|
||||
if !texts.is_empty() {
|
||||
devices.retain(|device| device.names.iter().any(|n| texts.iter().any(|t| n == t)));
|
||||
}
|
||||
if !devs.is_empty() {
|
||||
devices.retain(|device| {
|
||||
device
|
||||
.interfaces
|
||||
.iter()
|
||||
.any(|iface| devs.iter().any(|d| iface == d))
|
||||
});
|
||||
}
|
||||
if !ips.is_empty() {
|
||||
devices.retain(|device| device.ips.iter().any(|ip| ips.contains(ip)));
|
||||
}
|
||||
if !macs.is_empty() {
|
||||
devices.retain(|device| device.macs.iter().any(|mac| macs.contains(mac)));
|
||||
}
|
||||
if !nuds.is_empty() {
|
||||
devices.retain(|device| {
|
||||
device
|
||||
.neighbors
|
||||
.iter()
|
||||
.any(|neighbor| nuds.contains(&neighbor.state))
|
||||
});
|
||||
}
|
||||
|
||||
devices.sort_by(|a, b| {
|
||||
presence_rank(b.presence)
|
||||
.cmp(&presence_rank(a.presence))
|
||||
.then_with(|| a.names.first().cmp(&b.names.first()))
|
||||
});
|
||||
devices
|
||||
}
|
||||
|
||||
const fn presence_rank(presence: Presence) -> u8 {
|
||||
match presence {
|
||||
Presence::Online => 3,
|
||||
Presence::LikelyOnline => 2,
|
||||
Presence::Unknown => 1,
|
||||
Presence::Offline => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use wakey_core::{DhcpLease, InventoryQueryBuilder, NeighborState};
|
||||
|
||||
fn sample_neighbors() -> Vec<NeighborEntry> {
|
||||
vec![NeighborEntry {
|
||||
ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)),
|
||||
dev: Some("br-lan".to_string()),
|
||||
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
|
||||
state: NeighborState::Reachable,
|
||||
}]
|
||||
}
|
||||
|
||||
fn sample_leases() -> Vec<DhcpLeaseWithState> {
|
||||
vec![DhcpLeaseWithState {
|
||||
lease_line: DhcpLease {
|
||||
expires_epoch: 1,
|
||||
ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)),
|
||||
mac: "aa:bb:cc:dd:ee:ff".parse().expect("mac"),
|
||||
name: Some("pc".to_string()),
|
||||
},
|
||||
nud_state: None,
|
||||
}]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_devices_applies_and_across_categories() {
|
||||
let query = InventoryQueryBuilder::new()
|
||||
.maybe_text(Some("pc".to_string()))
|
||||
.interfaces(vec!["br-lan".to_string()])
|
||||
.neighbor_states(vec![NeighborState::Reachable])
|
||||
.build();
|
||||
|
||||
let out = merge_devices(sample_neighbors(), sample_leases(), &query);
|
||||
assert_eq!(out.len(), 1);
|
||||
|
||||
let no_match_query = InventoryQueryBuilder::new()
|
||||
.maybe_text(Some("pc".to_string()))
|
||||
.interfaces(vec!["eth9".to_string()])
|
||||
.build();
|
||||
let out = merge_devices(sample_neighbors(), sample_leases(), &no_match_query);
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_devices_allows_or_within_same_category() {
|
||||
let query = InventoryQueryBuilder::new()
|
||||
.neighbor_states(vec![NeighborState::Stale, NeighborState::Reachable])
|
||||
.build();
|
||||
|
||||
let out = merge_devices(sample_neighbors(), sample_leases(), &query);
|
||||
assert_eq!(out.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use anyhow::{Context, Result};
|
||||
use wakey_core::{DhcpLease, DhcpLeaseWithState, LeaseQuery};
|
||||
|
||||
/// Read DHCP leases and optionally enrich them with current neighbor-state data.
|
||||
pub async fn get_leases(query: LeaseQuery) -> Result<Vec<DhcpLeaseWithState>> {
|
||||
let leases = wakey_linux::dhcp::read_dhcp_leases_with_names()
|
||||
.await
|
||||
.context("failed to read DHCP leases")?;
|
||||
if query.include_state {
|
||||
Ok(wakey_linux::dhcp::enrich_leases_with_nud_state(leases).await)
|
||||
} else {
|
||||
Ok(leases_without_state(leases))
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap raw DHCP leases in the current service output shape without neighbor state.
|
||||
pub fn leases_without_state(leases: Vec<DhcpLease>) -> Vec<DhcpLeaseWithState> {
|
||||
leases
|
||||
.into_iter()
|
||||
.map(|lease_line| DhcpLeaseWithState {
|
||||
lease_line,
|
||||
nud_state: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
pub mod interfaces;
|
||||
pub mod inventory;
|
||||
pub mod leases;
|
||||
pub mod query;
|
||||
pub mod wake;
|
||||
|
||||
pub use interfaces::{get_interface_summaries, get_interface_summary, get_ips};
|
||||
pub use inventory::{inventory, merge_devices, resolve_devices};
|
||||
pub use leases::{get_leases, leases_without_state};
|
||||
pub use query::{query_to_inventory_query, resolve_query, resolve_selector};
|
||||
pub use wake::{
|
||||
broadcast_wake_targets, resolve_wake_targets, wake_explicit, wake_from_query, wake_targets,
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
use anyhow::Result;
|
||||
use wakey_core::{InventoryQuery, Query, QueryInput};
|
||||
|
||||
/// Resolve free-form user input into an `InventoryQuery` filter shape.
|
||||
///
|
||||
/// This is the compatibility entrypoint used by CLI and HTTP paths that still
|
||||
/// speak in terms of query/filter payloads.
|
||||
pub async fn resolve_query(input: impl Into<String>) -> Result<InventoryQuery> {
|
||||
query_to_inventory_query(resolve_selector(input).await?)
|
||||
}
|
||||
|
||||
/// Classify one piece of free-form user input into a typed selector.
|
||||
///
|
||||
/// The Linux adapter decides whether the input looks like an IP address, MAC,
|
||||
/// interface name, neighbor state, or plain text.
|
||||
pub async fn resolve_selector(input: impl Into<String>) -> Result<Query> {
|
||||
Ok(
|
||||
match wakey_linux::devices::classify_query(input.into()).await {
|
||||
QueryInput::Ip(ip_addr) => Query::Ip(ip_addr),
|
||||
QueryInput::Mac(mac_addr) => Query::Mac(mac_addr),
|
||||
QueryInput::Dev(dev) => Query::Interface(dev),
|
||||
QueryInput::Nud(state) => Query::NeighborState(state),
|
||||
QueryInput::Name(name) => Query::Text(name),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Convert the newer selector-oriented `Query` model into an `InventoryQuery`.
|
||||
///
|
||||
/// This keeps the old filter-based service and HTTP surfaces working while the
|
||||
/// internals migrate toward selector- and device-oriented APIs.
|
||||
pub fn query_to_inventory_query(query: Query) -> Result<InventoryQuery> {
|
||||
Ok(vec![query])
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
use anyhow::{Context, Result};
|
||||
use macaddr::MacAddr;
|
||||
use std::net::IpAddr;
|
||||
use tracing::{debug, instrument};
|
||||
use wakey_core::{InterfaceSummary, WakeResult, WakeTarget};
|
||||
|
||||
use crate::service::interfaces::get_interface_summaries;
|
||||
use crate::service::inventory::resolve_devices;
|
||||
|
||||
/// Send Wake-on-LAN packets for already-concrete wake targets.
|
||||
#[instrument(skip_all, fields(targets = targets.len()))]
|
||||
pub async fn wake_targets(targets: Vec<WakeTarget>) -> Result<WakeResult> {
|
||||
let result = wakey_linux::wake::wake_many(targets)
|
||||
.await
|
||||
.context("failed to send wake packets")?;
|
||||
debug!(results = result.len(), "wake packets sent");
|
||||
Ok(WakeResult { result })
|
||||
}
|
||||
|
||||
/// Resolve free-form input into wake targets and send the packets.
|
||||
#[instrument(skip_all)]
|
||||
pub async fn wake_from_query(input: impl Into<String>) -> Result<WakeResult> {
|
||||
let targets = resolve_wake_targets(input).await?;
|
||||
wake_targets(targets).await
|
||||
}
|
||||
|
||||
/// Build broadcast wake targets for every broadcast-capable interface.
|
||||
///
|
||||
/// This is used by explicit manual wake mode when only a MAC address is supplied.
|
||||
#[instrument(skip_all)]
|
||||
pub async fn broadcast_wake_targets(mac: MacAddr) -> Result<Vec<WakeTarget>> {
|
||||
let interfaces = get_interface_summaries().await?;
|
||||
broadcast_wake_targets_from_interfaces(&interfaces, mac)
|
||||
}
|
||||
|
||||
/// Wake a device explicitly by MAC, optionally targeting a specific IP/broadcast.
|
||||
#[instrument(skip_all, fields(has_ip = ip.is_some()))]
|
||||
pub async fn wake_explicit(mac: MacAddr, ip: Option<IpAddr>) -> Result<WakeResult> {
|
||||
let targets = match ip {
|
||||
Some(ip) => explicit_wake_targets_for_ip(mac, ip),
|
||||
None => broadcast_wake_targets(mac).await?,
|
||||
};
|
||||
wake_targets(targets).await
|
||||
}
|
||||
|
||||
/// Resolve free-form input into concrete wake targets.
|
||||
///
|
||||
/// The current resolution strategy fans out one wake target per resolved device IP,
|
||||
/// using the first known MAC address for that device.
|
||||
#[instrument(skip_all)]
|
||||
pub async fn resolve_wake_targets(input: impl Into<String>) -> Result<Vec<WakeTarget>> {
|
||||
let devices = resolve_devices(input).await?;
|
||||
let targets: Vec<WakeTarget> = devices
|
||||
.into_iter()
|
||||
.flat_map(|device| {
|
||||
let mac = device.macs.first().copied();
|
||||
device
|
||||
.ips
|
||||
.into_iter()
|
||||
.map(move |ip| WakeTarget { ip: Some(ip), mac })
|
||||
})
|
||||
.collect();
|
||||
debug!(targets = targets.len(), "resolved wake targets");
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
fn explicit_wake_targets_for_ip(mac: MacAddr, ip: IpAddr) -> Vec<WakeTarget> {
|
||||
vec![WakeTarget {
|
||||
ip: Some(ip),
|
||||
mac: Some(mac),
|
||||
}]
|
||||
}
|
||||
|
||||
fn broadcast_wake_targets_from_interfaces(
|
||||
interfaces: &[InterfaceSummary],
|
||||
mac: MacAddr,
|
||||
) -> Result<Vec<WakeTarget>> {
|
||||
let targets: Vec<WakeTarget> = interfaces
|
||||
.iter()
|
||||
.flat_map(|iface| iface.addrs.iter())
|
||||
.filter_map(|addr| addr.broadcast)
|
||||
.map(|ip| WakeTarget {
|
||||
ip: Some(IpAddr::V4(ip)),
|
||||
mac: Some(mac),
|
||||
})
|
||||
.collect();
|
||||
|
||||
if targets.is_empty() {
|
||||
anyhow::bail!("no broadcast-capable interfaces found");
|
||||
}
|
||||
|
||||
debug!(
|
||||
targets = targets.len(),
|
||||
interfaces = interfaces.len(),
|
||||
"built broadcast wake targets"
|
||||
);
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
use super::{broadcast_wake_targets_from_interfaces, explicit_wake_targets_for_ip};
|
||||
use wakey_core::{InterfaceAddr, InterfaceSummary};
|
||||
|
||||
#[test]
|
||||
fn explicit_wake_target_for_ip_builds_one_complete_target() {
|
||||
let mac: macaddr::MacAddr = "aa:bb:cc:dd:ee:ff".parse().expect("mac");
|
||||
let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 255));
|
||||
|
||||
let targets = explicit_wake_targets_for_ip(mac, ip);
|
||||
|
||||
assert_eq!(targets.len(), 1);
|
||||
assert_eq!(targets[0].ip, Some(ip));
|
||||
assert_eq!(targets[0].mac, Some(mac));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broadcast_wake_targets_from_interfaces_errors_when_no_broadcast_exists() {
|
||||
let mac: macaddr::MacAddr = "aa:bb:cc:dd:ee:ff".parse().expect("mac");
|
||||
let interfaces = vec![InterfaceSummary {
|
||||
ifindex: 1,
|
||||
ifname: "eth0".into(),
|
||||
operstate: "up".into(),
|
||||
mac: None,
|
||||
addrs: vec![InterfaceAddr {
|
||||
family: Some("inet".into()),
|
||||
cidr: Some("192.168.1.10/24".into()),
|
||||
broadcast: None,
|
||||
scope: Some("global".into()),
|
||||
label: None,
|
||||
}],
|
||||
}];
|
||||
|
||||
let err = broadcast_wake_targets_from_interfaces(&interfaces, mac)
|
||||
.expect_err("should error without broadcast-capable interfaces");
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("no broadcast-capable interfaces found")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broadcast_wake_targets_from_interfaces_builds_targets_from_broadcast_rows() {
|
||||
let mac: macaddr::MacAddr = "aa:bb:cc:dd:ee:ff".parse().expect("mac");
|
||||
let interfaces = vec![InterfaceSummary {
|
||||
ifindex: 2,
|
||||
ifname: "br-lan".into(),
|
||||
operstate: "up".into(),
|
||||
mac: None,
|
||||
addrs: vec![
|
||||
InterfaceAddr {
|
||||
family: Some("inet".into()),
|
||||
cidr: Some("192.168.1.1/24".into()),
|
||||
broadcast: Some(Ipv4Addr::new(192, 168, 1, 255)),
|
||||
scope: Some("global".into()),
|
||||
label: None,
|
||||
},
|
||||
InterfaceAddr {
|
||||
family: Some("inet6".into()),
|
||||
cidr: Some("fe80::1/64".into()),
|
||||
broadcast: None,
|
||||
scope: Some("link".into()),
|
||||
label: None,
|
||||
},
|
||||
],
|
||||
}];
|
||||
|
||||
let targets = broadcast_wake_targets_from_interfaces(&interfaces, mac)
|
||||
.expect("broadcast target resolution should succeed");
|
||||
|
||||
assert_eq!(targets.len(), 1);
|
||||
assert_eq!(
|
||||
targets[0].ip,
|
||||
Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 255)))
|
||||
);
|
||||
assert_eq!(targets[0].mac, Some(mac));
|
||||
}
|
||||
}
|
||||
+1
-19
@@ -1,19 +1 @@
|
||||
// pub const LDA_MACS: [[u8; 6]; 2] = [
|
||||
// // ether
|
||||
// [0x04, 0x7c, 0x16, 0x79, 0x6d, 0xee],
|
||||
// // wifi
|
||||
// [0xbc, 0x09, 0x1b, 0xec, 0x65, 0xd0],
|
||||
// ];
|
||||
|
||||
/// is it time to lookup host lda.lan for this...
|
||||
// pub static LDA_MACS_2: LazyLock<[MacAddr; 2]> = LazyLock::new(|| LDA_MACS.map(MacAddr::from));
|
||||
pub mod wake;
|
||||
|
||||
/// generic so you can do "123.45.67.89:22" or "lda.lan:22" as an input
|
||||
// this is so bad
|
||||
pub mod ping;
|
||||
pub mod query;
|
||||
|
||||
// no custom ip deserializer needed when using axum_extra::extract::Query
|
||||
// but we add a generic one to ignore blanks and accept OneOrMany
|
||||
pub(crate) mod parse;
|
||||
// Intentionally left empty.
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
use macaddr::MacAddr;
|
||||
use serde::{self, Deserialize, Deserializer, de::Error as DeError};
|
||||
use serde::{Serialize, Serializer, de};
|
||||
|
||||
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>`]
|
||||
pub fn deserialize<'de, D>(des: D) -> Result<Option<MacAddr>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Option::<&str>::deserialize(des)?
|
||||
.map(str::parse)
|
||||
.transpose()
|
||||
.map_err(de::Error::custom)
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
/// Parses Chrome-style numeric IPv4 forms: hex (0x...), decimal, octal.
|
||||
pub fn parse_numeric_ipv4(s: &str) -> Option<std::net::IpAddr> {
|
||||
let s = s.trim();
|
||||
// hex
|
||||
if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))
|
||||
&& hex.chars().all(|c| c.is_ascii_hexdigit())
|
||||
&& let Ok(n) = u32::from_str_radix(hex, 16)
|
||||
{
|
||||
return Some(std::net::IpAddr::V4(std::net::Ipv4Addr::from(n)));
|
||||
}
|
||||
// decimal
|
||||
if s.chars().all(|c| c.is_ascii_digit())
|
||||
&& let Ok(n) = s.parse::<u32>()
|
||||
{
|
||||
return Some(std::net::IpAddr::V4(std::net::Ipv4Addr::from(n)));
|
||||
}
|
||||
// octal (leading 0, all octal digits)
|
||||
if s.len() > 1
|
||||
&& s.as_bytes()[0] == b'0'
|
||||
&& s.chars().all(|c| matches!(c, '0'..='7'))
|
||||
&& let Ok(n) = u32::from_str_radix(s, 8)
|
||||
{
|
||||
return Some(std::net::IpAddr::V4(std::net::Ipv4Addr::from(n)));
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Extracts the host portion from a URL-like string, for smart input parsing.
|
||||
pub fn extract_host(input: &str) -> &str {
|
||||
let mut s = input.trim();
|
||||
// Strip scheme (e.g., http://, https://, ssh://) or network-path reference (//host)
|
||||
if let Some(idx) = s.find("://") {
|
||||
s = &s[idx + 3..];
|
||||
} else if let Some(rest) = s.strip_prefix("//") {
|
||||
s = rest;
|
||||
}
|
||||
// Strip potential userinfo (user@host)
|
||||
if let Some((_, host)) = s.rsplit_once('@') {
|
||||
s = host;
|
||||
}
|
||||
// If bracketed IPv6 like [::1]:8080/path -> extract inside brackets
|
||||
if let Some(host) = s.strip_prefix('[') {
|
||||
if let Some(end) = host.find(']') {
|
||||
s = &host[..end];
|
||||
}
|
||||
} else {
|
||||
// Trim path suffix if any
|
||||
if let Some(pos) = s.find('/') {
|
||||
s = &s[..pos];
|
||||
}
|
||||
// Drop trailing :port if present and numeric, but only if there's exactly one ':'
|
||||
if let Some((host, port)) = s.rsplit_once(':')
|
||||
&& s.matches(':').count() == 1
|
||||
&& port.chars().all(|c| c.is_ascii_digit())
|
||||
{
|
||||
s = host;
|
||||
}
|
||||
}
|
||||
s.trim()
|
||||
}
|
||||
|
||||
/// key for yes: "1" | "true" | "yes" | "on" | "y"
|
||||
///
|
||||
/// frfr
|
||||
pub fn _de_boolish<'de, D>(des: D) -> Result<bool, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::Deserialize;
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Boolish {
|
||||
B(bool),
|
||||
I(u8),
|
||||
S(String),
|
||||
}
|
||||
Ok(match Boolish::deserialize(des)? {
|
||||
Boolish::B(b) => b,
|
||||
Boolish::I(i) => i != 0,
|
||||
Boolish::S(s) => {
|
||||
let t = s.trim().to_ascii_lowercase();
|
||||
if t.is_empty() {
|
||||
true // presence implies true
|
||||
} else {
|
||||
matches!(t.as_str(), "1" | "true" | "yes" | "on" | "y")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a tolerant boolean value from a string.
|
||||
/// Accepts: "1", "true", "yes", "on", "y" as true; "0", "false", "no", "off", "n" as false.
|
||||
/// Empty string means true (presence-only query flag).
|
||||
pub fn boolish_str(s: &str) -> bool {
|
||||
let t = s.trim().to_ascii_lowercase();
|
||||
if t.is_empty() {
|
||||
return true;
|
||||
}
|
||||
matches!(t.as_str(), "1" | "true" | "yes" | "on" | "y")
|
||||
|| (!matches!(t.as_str(), "0" | "false" | "no" | "off" | "n")
|
||||
&& t.parse::<u64>().map(|n| n != 0).unwrap_or(false))
|
||||
}
|
||||
|
||||
pub mod mac;
|
||||
@@ -1,31 +0,0 @@
|
||||
// this ENTIRE file is redundant... or?
|
||||
|
||||
use std::{net::IpAddr, time::Duration};
|
||||
|
||||
use tokio::{
|
||||
net::{TcpStream, ToSocketAddrs},
|
||||
time::timeout,
|
||||
};
|
||||
|
||||
use crate::{arpparse::NUDState, utils::query::get_mac};
|
||||
|
||||
pub async fn _ping_ip<T: ToSocketAddrs>(addr: T) -> bool {
|
||||
timeout(Duration::from_secs(1), TcpStream::connect(addr))
|
||||
.await
|
||||
.is_ok()
|
||||
}
|
||||
pub async fn _ping_ip_2<T: ToSocketAddrs>(_addr: T) -> bool {
|
||||
todo!("use icmp")
|
||||
}
|
||||
|
||||
pub async fn _ping_ip_3<T: Into<IpAddr>>(addr: T) -> u8 {
|
||||
match get_mac(Some(addr.into()), None, &[] as &[NUDState]).await {
|
||||
Err(_) => 0,
|
||||
Ok(l) => l
|
||||
.into_iter()
|
||||
.map(|e| e.state)
|
||||
.max()
|
||||
.map(NUDState::rank)
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
// /// 50ms
|
||||
// pub async fn get_dev() -> HashSet<String> {
|
||||
// use lda_ipjs::subcommands::address::json as ipjs_json;
|
||||
// let mut devs: HashSet<String> = HashSet::new();
|
||||
// if let Ok(items) = ipjs_json::get(None).await {
|
||||
// for item in items {
|
||||
// if item.ifname != "lo" && !item.ifname.is_empty() {
|
||||
// devs.insert(item.ifname);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// devs
|
||||
// }
|
||||
|
||||
/// 3ms
|
||||
pub async fn get_dev() -> HashSet<String> {
|
||||
use std::fs;
|
||||
fn get_dev() -> HashSet<String> {
|
||||
let mut devs: HashSet<String> = HashSet::new();
|
||||
if let Ok(rd) = std::fs::read_dir("/sys/class/net") {
|
||||
for e in rd.flatten() {
|
||||
if e.file_type()
|
||||
.map(|ft| {
|
||||
if ft.is_symlink() {
|
||||
// true
|
||||
fs::metadata(e.path()).map(|m| m.is_dir()).unwrap_or(false)
|
||||
} else {
|
||||
ft.is_dir()
|
||||
}
|
||||
})
|
||||
.unwrap_or(false)
|
||||
&& let Ok(name) = e.file_name().into_string()
|
||||
&& name != "lo"
|
||||
&& !name.is_empty()
|
||||
{
|
||||
devs.insert(name);
|
||||
}
|
||||
}
|
||||
} else if let Ok(txt) = std::fs::read_to_string("/proc/net/dev") {
|
||||
for line in txt.lines().skip(2) {
|
||||
if let Some((name, _rest)) = line.split_once(':') {
|
||||
let n = name.trim().to_string();
|
||||
if n != "lo" && !n.is_empty() {
|
||||
devs.insert(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
devs
|
||||
}
|
||||
tokio::task::spawn_blocking(get_dev)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub async fn devs_sorted() -> Vec<String> {
|
||||
let mut v: Vec<String> = get_dev().await.into_iter().collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
pub async fn has_dev(name: &str) -> bool {
|
||||
get_dev().await.contains(name)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
use crate::arpparse::NUDState;
|
||||
use crate::dhcpparse::DhcpLeaseLine;
|
||||
use crate::utils::query::get_macs;
|
||||
use serde_with::skip_serializing_none;
|
||||
use std::net::IpAddr;
|
||||
|
||||
#[skip_serializing_none]
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct DhcpLeaseOut {
|
||||
#[serde(flatten)]
|
||||
pub lease_line: DhcpLeaseLine,
|
||||
pub nud_state: Option<NUDState>,
|
||||
}
|
||||
|
||||
/// Enrich DHCP leases with NUD state and rank using get_macs
|
||||
pub async fn enrich_leases_with_nud_state(leases: Vec<DhcpLeaseLine>) -> Vec<DhcpLeaseOut> {
|
||||
let ips: Vec<IpAddr> = leases.iter().map(|l| l.ip).collect();
|
||||
let mut map: std::collections::HashMap<IpAddr, NUDState> = std::collections::HashMap::new();
|
||||
if let Ok(rows) = get_macs(&[] as &[&str], &ips, &[] as &[&str], &[], &[]).await {
|
||||
for row in rows {
|
||||
let state = row.state;
|
||||
let r = state.rank();
|
||||
map.entry(row.ip)
|
||||
.and_modify(|e| {
|
||||
let er = e.rank();
|
||||
if r > er {
|
||||
*e = state
|
||||
}
|
||||
})
|
||||
.or_insert(state);
|
||||
}
|
||||
}
|
||||
leases
|
||||
.into_iter()
|
||||
.map(|lease_line| {
|
||||
let nud_state = map.get(&lease_line.ip).copied();
|
||||
DhcpLeaseOut {
|
||||
lease_line,
|
||||
nud_state,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
use lda_ipjs::subcommands::neighbor;
|
||||
use macaddr::MacAddr;
|
||||
|
||||
use crate::arpparse::{IpNeighLine, NUDState};
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashSet;
|
||||
use std::net::IpAddr;
|
||||
|
||||
pub async fn get_ips(machine_name: &str) -> Result<impl Iterator<Item = IpAddr>> {
|
||||
Ok(tokio::net::lookup_host((machine_name, 0))
|
||||
.await
|
||||
.with_context(|| format!("DNS resolve failed for {machine_name}"))?
|
||||
.map(|c| c.ip()))
|
||||
}
|
||||
|
||||
/// Query neighbor table with multi-filters. Empty slice = no filter.
|
||||
pub async fn get_macs(
|
||||
machine_names: &[impl AsRef<str>],
|
||||
ips: &[IpAddr],
|
||||
devs: &[impl AsRef<str>],
|
||||
state: &[NUDState],
|
||||
macs: &[MacAddr],
|
||||
) -> Result<Vec<IpNeighLine>> {
|
||||
// Resolve machine names to IPs
|
||||
let resolved_ips: HashSet<IpAddr> = if !machine_names.is_empty() {
|
||||
futures::future::try_join_all(machine_names.iter().map(|n| get_ips(n.as_ref())))
|
||||
.await?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
} else {
|
||||
HashSet::new()
|
||||
};
|
||||
|
||||
// Merge provided IPs with resolved IPs
|
||||
let ip_filter: Vec<IpAddr> = if ips.is_empty() && resolved_ips.is_empty() {
|
||||
vec![]
|
||||
} else if ips.is_empty() {
|
||||
resolved_ips.into_iter().collect()
|
||||
} else if resolved_ips.is_empty() {
|
||||
ips.iter().map(|ip| ip.to_canonical()).collect()
|
||||
} else {
|
||||
// Intersection: only IPs that appear in both
|
||||
ips.iter()
|
||||
.map(|ip| ip.to_canonical())
|
||||
.filter(|ip| resolved_ips.contains(ip))
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Convert state filter
|
||||
let nud_filter: Vec<neighbor::NUDState> = state.iter().copied().map(Into::into).collect();
|
||||
|
||||
// Convert devs to &str for nl::get
|
||||
let dev_strs: Vec<&str> = devs.iter().map(AsRef::as_ref).collect();
|
||||
|
||||
// Single rtnetlink call with all filters
|
||||
let results: Vec<IpNeighLine> = neighbor::nl::get(&ip_filter, &dev_strs, &nud_filter, macs)
|
||||
.await
|
||||
.context("rtnetlink failed")?
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect();
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Legacy single-filter wrapper. Use get_macs for multi-filter.
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_mac(
|
||||
ip: Option<IpAddr>,
|
||||
dev: Option<&str>,
|
||||
state: &[NUDState],
|
||||
) -> Result<Vec<IpNeighLine>> {
|
||||
let ips: Vec<IpAddr> = ip.into_iter().collect();
|
||||
let devs: Vec<&str> = dev.into_iter().collect();
|
||||
let nud: Vec<neighbor::NUDState> = state.iter().copied().map(Into::into).collect();
|
||||
|
||||
Ok(neighbor::nl::get(&ips, &devs, &nud, &[])
|
||||
.await
|
||||
.context("rtnetlink failed")?
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect())
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
pub mod dev;
|
||||
pub mod leases;
|
||||
pub mod macs;
|
||||
pub mod parser;
|
||||
|
||||
pub use leases::*;
|
||||
pub use macs::*;
|
||||
@@ -1,44 +0,0 @@
|
||||
use std::net::IpAddr;
|
||||
|
||||
use macaddr::MacAddr;
|
||||
|
||||
use crate::{arpparse::NUDState, utils::query::dev::has_dev};
|
||||
|
||||
pub enum QueryType {
|
||||
Ip(IpAddr),
|
||||
Mac(MacAddr),
|
||||
Dev(String),
|
||||
Nud(NUDState),
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
pub async fn parse_query(q: String) -> QueryType {
|
||||
let s = if cfg!(feature = "very-smart-parsing") {
|
||||
crate::utils::parse::extract_host(&q)
|
||||
} else {
|
||||
q.trim()
|
||||
};
|
||||
// 1) IP
|
||||
let ip = if cfg!(feature = "very-smart-parsing") {
|
||||
crate::utils::parse::parse_numeric_ipv4(s).or_else(|| s.parse::<IpAddr>().ok())
|
||||
} else {
|
||||
s.parse::<IpAddr>().ok()
|
||||
};
|
||||
if let Some(ip) = ip {
|
||||
return QueryType::Ip(ip);
|
||||
}
|
||||
// 2) MAC
|
||||
if let Ok(mac) = s.parse::<MacAddr>() {
|
||||
return QueryType::Mac(mac);
|
||||
}
|
||||
// 3) NUD state (reachable, stale, ...)
|
||||
if let Ok(state) = s.parse::<NUDState>() {
|
||||
return QueryType::Nud(state);
|
||||
}
|
||||
// 4) Known device? prefer dev first
|
||||
if has_dev(s).await {
|
||||
return QueryType::Dev(s.to_string());
|
||||
}
|
||||
// Default: name last // it will fail also
|
||||
QueryType::Unknown(s.to_string())
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
//! why did my Head Ass split these into two.
|
||||
|
||||
use std::{io, net::IpAddr};
|
||||
|
||||
use futures::TryFutureExt;
|
||||
use macaddr::MacAddr;
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Hash)]
|
||||
pub struct WakeTarget {
|
||||
pub ip: IpAddr,
|
||||
pub mac: MacAddr,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, Hash)]
|
||||
pub struct WakeTargetResult {
|
||||
pub target: WakeTarget,
|
||||
pub status: WakeStatus,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, Hash)]
|
||||
pub enum WakeStatus {
|
||||
Success,
|
||||
NonexistentAddress,
|
||||
WrongSize,
|
||||
}
|
||||
impl WakeTarget {
|
||||
const fn _new(ip: IpAddr, mac: MacAddr) -> Self {
|
||||
Self { ip, mac }
|
||||
}
|
||||
const fn good(self) -> WakeTargetResult {
|
||||
WakeTargetResult::new(self, WakeStatus::Success)
|
||||
}
|
||||
const fn bad(self) -> WakeTargetResult {
|
||||
WakeTargetResult::new(self, WakeStatus::WrongSize)
|
||||
}
|
||||
const fn errored(self) -> WakeTargetResult {
|
||||
WakeTargetResult::new(self, WakeStatus::NonexistentAddress)
|
||||
}
|
||||
}
|
||||
impl WakeTargetResult {
|
||||
const fn new(target: WakeTarget, status: WakeStatus) -> Self {
|
||||
Self { target, status }
|
||||
}
|
||||
}
|
||||
|
||||
// its time. we have the ip; the macs. we dont need to send to the uh the broadcast anymore???
|
||||
pub async fn _wake_multi(
|
||||
targets: impl IntoIterator<Item = WakeTarget>,
|
||||
) -> io::Result<Vec<WakeTargetResult>> {
|
||||
let sock = UdpSocket::bind("[::]:0")
|
||||
.or_else(|_| UdpSocket::bind(":0"))
|
||||
.await?;
|
||||
sock.set_broadcast(true)?;
|
||||
let fs = targets.into_iter().map(|t| wake_one(&sock, t));
|
||||
Ok(futures::future::join_all(fs).await)
|
||||
}
|
||||
|
||||
pub async fn wake_one(sock: &UdpSocket, t: WakeTarget) -> WakeTargetResult {
|
||||
let mac = t.mac;
|
||||
let mb = mac.as_bytes();
|
||||
let mut pac = [0; 6 + 6 * 16];
|
||||
pac[..6].fill(0xff);
|
||||
for i in 1..=16 {
|
||||
pac[i * 6..(i + 1) * 6].copy_from_slice(mb);
|
||||
}
|
||||
let ip = t.ip;
|
||||
let port = 9;
|
||||
match sock.send_to(&pac, (ip, port)).await {
|
||||
Ok(n) if n == pac.len() => t.good(),
|
||||
Ok(_) => t.bad(),
|
||||
Err(_) => t.errored(),
|
||||
}
|
||||
}
|
||||
|
||||
// pub async fn wake_query();
|
||||
@@ -1,36 +0,0 @@
|
||||
// import { filter_array } from "./status.js";
|
||||
|
||||
export const qs = new URLSearchParams(location.search);
|
||||
|
||||
// $ is normally queryselector are we fr
|
||||
export const $ = (id) => document.getElementById(id);
|
||||
export const elName = $("name");
|
||||
export const elCheck = $("check");
|
||||
export const elWake = $("wake");
|
||||
export const elLog = $("log");
|
||||
export const elHtml = $("html");
|
||||
export const elLeases = $("leases_html");
|
||||
export const pill = $("status-pill");
|
||||
export const link = $("permalink");
|
||||
export const elPreview = $("preview");
|
||||
|
||||
export function setPill(kind, text) {
|
||||
pill.className = `pill ${kind}`;
|
||||
pill.textContent = text;
|
||||
}
|
||||
|
||||
export function setLink(name, clear) {
|
||||
const url = new URL(location.href);
|
||||
if (clear) url.search = ""; // yo
|
||||
/* const hasExtraFilters = filter_array.some(
|
||||
(k) => url.searchParams.getAll(k).length
|
||||
);
|
||||
if (hasExtraFilters) {
|
||||
// jus returns whatever; they are specifying further
|
||||
} else */ {
|
||||
if (name) url.searchParams.set("name", name);
|
||||
else url.searchParams.delete("name");
|
||||
}
|
||||
history.replaceState(null, "", url);
|
||||
link.href = url.toString();
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { elLeases } from "./dom.js";
|
||||
import { rankState } from "./utils.js";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {{
|
||||
* expires_epoch: Number
|
||||
* rank?: Number
|
||||
* ip: String
|
||||
* mac: String
|
||||
* nud_state?: String
|
||||
* name?: String
|
||||
* }[]} leases
|
||||
* @returns
|
||||
*/
|
||||
export function renderLeases(leases) {
|
||||
if (!elLeases) return;
|
||||
if (!Array.isArray(leases) || leases.length === 0) {
|
||||
elLeases.textContent = "no leases";
|
||||
return;
|
||||
}
|
||||
const tbl = document.createElement("table");
|
||||
tbl.className = "table";
|
||||
|
||||
(
|
||||
tbl.tHead || tbl.createTHead()
|
||||
).innerHTML = `<tr><th></th><th>IP</th><th>MAC</th><th>Name</th><th>Expires</th></tr>`;
|
||||
|
||||
const tbd = tbl.tBodies.item(0) || tbl.createTBody();
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
for (const l of leases) {
|
||||
const tr = document.createElement("tr");
|
||||
const exp = Number(l.expires_epoch || 0);
|
||||
const expired = exp > 0 && exp <= nowSec;
|
||||
const when = exp > 0 ? new Date(exp * 1000) : null;
|
||||
const whenText = when ? when.toLocaleString() : "";
|
||||
let dotClass = "dot ok";
|
||||
if (expired) {
|
||||
dotClass = "dot bad";
|
||||
} else if (l?.nud_state) {
|
||||
if (rankState(l.nud_state) >= 5) dotClass = "dot ok";
|
||||
else if (rankState(l.nud_state) >= 2) dotClass = "dot warn";
|
||||
else dotClass = "dot bad";
|
||||
} else if (typeof l?.rank === "number") {
|
||||
if (l.rank >= 5) dotClass = "dot ok";
|
||||
else if (l.rank >= 2) dotClass = "dot warn";
|
||||
else dotClass = "dot bad";
|
||||
}
|
||||
const title = expired
|
||||
? "expired"
|
||||
: l?.nud_state
|
||||
? String(l.nud_state).toLowerCase()
|
||||
: "unknown";
|
||||
const ip = l.ip || "";
|
||||
const mac = l.mac || "";
|
||||
const name = l.name || "";
|
||||
tr.innerHTML = `
|
||||
<td><span class="${dotClass}" title="${title}"></span></td>
|
||||
${Object.entries({ ip, mac, name })
|
||||
.map(
|
||||
([name, value]) =>
|
||||
`<td>${
|
||||
value &&
|
||||
`<a href="#" class="pick" data-value="${value}" title="filter by ${name}">${value}</a>`
|
||||
}</td>`
|
||||
)
|
||||
.join("\n")}
|
||||
<td><span class="tiny">${whenText}</span></td>`;
|
||||
tbd.appendChild(tr);
|
||||
}
|
||||
elLeases.innerHTML = "";
|
||||
elLeases.appendChild(tbl);
|
||||
}
|
||||
|
||||
export async function fetchLeases() {
|
||||
try {
|
||||
const r = await fetch("/api/dhcp_leases?include_state=1");
|
||||
if (!r.ok) return;
|
||||
const leases = await r.json();
|
||||
renderLeases(leases);
|
||||
} catch {}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import {
|
||||
elName,
|
||||
elCheck,
|
||||
elWake,
|
||||
elHtml,
|
||||
elLeases,
|
||||
elPreview,
|
||||
setLink,
|
||||
setPill,
|
||||
qs,
|
||||
} from "./dom.js";
|
||||
import {
|
||||
getName,
|
||||
saveName,
|
||||
loadName,
|
||||
extractHostLikeBackend,
|
||||
} from "./utils.js";
|
||||
import { fetchStatus } from "./status.js";
|
||||
import { fetchLeases } from "./leases.js";
|
||||
import { sendWake } from "./wake.js";
|
||||
|
||||
// delegated click handler
|
||||
function handlePickClick(e) {
|
||||
const a = e.target && e.target.closest("a.pick");
|
||||
if (!a) return;
|
||||
e.preventDefault();
|
||||
const v = a.getAttribute("data-value");
|
||||
pickTarget(v);
|
||||
}
|
||||
if (elHtml) elHtml.addEventListener("click", handlePickClick);
|
||||
if (elLeases) elLeases.addEventListener("click", handlePickClick);
|
||||
|
||||
function updatePreview() {
|
||||
const raw = getName(elName);
|
||||
const host = extractHostLikeBackend(raw);
|
||||
elPreview.textContent = host && host !== raw ? `→ ${host}` : "";
|
||||
return host;
|
||||
}
|
||||
|
||||
function pickTarget(value) {
|
||||
const v = String(value || "").trim();
|
||||
if (!v) return;
|
||||
elName.value = v;
|
||||
updatePreview();
|
||||
saveName(v);
|
||||
setLink(v, true);
|
||||
fetchStatus(v);
|
||||
fetchLeases();
|
||||
}
|
||||
|
||||
// events
|
||||
elCheck.addEventListener("click", () => {
|
||||
const name = getName(elName);
|
||||
if (!name) return;
|
||||
saveName(name);
|
||||
setLink(name);
|
||||
fetchStatus(name);
|
||||
fetchLeases();
|
||||
});
|
||||
elWake.addEventListener("click", () => {
|
||||
const name = getName(elName);
|
||||
if (!name) return;
|
||||
saveName(name);
|
||||
setLink(name);
|
||||
sendWake(name);
|
||||
fetchLeases();
|
||||
});
|
||||
elName.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") elCheck.click();
|
||||
});
|
||||
elName.addEventListener("input", updatePreview);
|
||||
|
||||
// init
|
||||
const initial = loadName(qs);
|
||||
if (initial) {
|
||||
elName.value = initial;
|
||||
updatePreview();
|
||||
if (qs.has("name")) {
|
||||
setLink(initial);
|
||||
fetchStatus(initial);
|
||||
} else {
|
||||
fetchStatus();
|
||||
}
|
||||
fetchLeases();
|
||||
} else {
|
||||
setPill("warn", "unknown");
|
||||
updatePreview();
|
||||
fetchStatus();
|
||||
fetchLeases();
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
import { elHtml, elLog, setPill, qs, pill } from "./dom.js";
|
||||
import { rankState } from "./utils.js";
|
||||
import { merge_wake_data, translate_wake_message } from "./wake.js";
|
||||
|
||||
// IpNeighLine
|
||||
const status_map = {
|
||||
ip: "ip",
|
||||
mac: "mac",
|
||||
state: "state",
|
||||
dev: "interface",
|
||||
};
|
||||
export const status_array = Object.keys(status_map);
|
||||
// Filters
|
||||
export const filter_array = ["ips", "devs", "nuds", "macs"];
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {String} name
|
||||
* @returns {URL}
|
||||
*/
|
||||
function buildStatusUrl(name) {
|
||||
const hasExtraFilters = filter_array.some((k) => qs.getAll(k).length);
|
||||
if (name && !hasExtraFilters) {
|
||||
return new URL(`/api/smart/${encodeURIComponent(name)}`, location.origin);
|
||||
}
|
||||
const u = new URL("/api/status", location.origin);
|
||||
if (name) u.searchParams.set("name", name);
|
||||
for (const k of filter_array) {
|
||||
const vals = qs.getAll(k);
|
||||
for (const v of vals) u.searchParams.append(k, v);
|
||||
}
|
||||
return u;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {{
|
||||
* has_wake?: true
|
||||
* table: {
|
||||
* wake_status?: Boolean
|
||||
* ips: String
|
||||
* dev: String
|
||||
* mac: String
|
||||
* state: String
|
||||
* }[]
|
||||
* filters: {
|
||||
* ips?: String[]
|
||||
* devs?: String[]
|
||||
* nuds?: String[]
|
||||
* macs?: String[]
|
||||
* }
|
||||
* }} data
|
||||
*/
|
||||
export function renderStatus(data) {
|
||||
const tbl = document.createElement("table");
|
||||
tbl.className = "table";
|
||||
tbl.innerHTML = `<thead><tr>${
|
||||
data.has_wake ? "<th>Wake status</th>" : ""
|
||||
}<th>IP</th><th>MAC</th><th>State</th><th>IF</th></tr></thead>`;
|
||||
|
||||
// sum hax
|
||||
const tbd = tbl.tBodies.item(0) || tbl.createTBody();
|
||||
for (const row of data.table) {
|
||||
if (data.has_wake && !row.wake_status)
|
||||
throw TypeError("specified has wake but no wake stats");
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `${
|
||||
row.wake_status
|
||||
? `<td><span class="dot ${
|
||||
row.wake_status == "succeed" ? "ok" : "bad"
|
||||
}" title="${translate_wake_message(row.wake_status)}"></span></td>`
|
||||
: ""
|
||||
}${Object.entries(status_map)
|
||||
.map(([field, description]) => {
|
||||
const value = row[field];
|
||||
return `<td>${
|
||||
value
|
||||
? `<a href="#" class="pick" data-value="${value}" title="filter by ${description}">${value}</a>`
|
||||
: ""
|
||||
}</td>`;
|
||||
})
|
||||
.join("")}`;
|
||||
tbd.appendChild(tr);
|
||||
}
|
||||
|
||||
elHtml.innerHTML = "";
|
||||
if (data.filters) {
|
||||
const parts = [];
|
||||
filter_array.forEach((field) => {
|
||||
if (Array.isArray(data.filters[field]) && data.filters[field].length)
|
||||
parts.push(`${field}=[${data.filters[field].join(", ")}]`);
|
||||
});
|
||||
|
||||
if (parts.length) {
|
||||
const info = document.createElement("div");
|
||||
info.className = "filters";
|
||||
info.textContent = `Filters: ${parts.join("; ")}`;
|
||||
elHtml.appendChild(info);
|
||||
}
|
||||
}
|
||||
elHtml.appendChild(tbl);
|
||||
|
||||
if ((data.table || []).length > 0) {
|
||||
const best = data.table.reduce((a, b) =>
|
||||
rankState(b.state) > rankState(a.state) ? b : a
|
||||
);
|
||||
const r = rankState(best.state);
|
||||
if (r >= 5) setPill("ok", "online");
|
||||
else if (r >= 2) setPill("warn", "maybe");
|
||||
else setPill("bad", "offline");
|
||||
if (data.filters.nuds?.length > 0) pill.textContent += " (filtered)";
|
||||
} else {
|
||||
setPill("warn", "unknown");
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {String} name
|
||||
* @param {Boolean} render
|
||||
* @param {{
|
||||
* ip: String,
|
||||
* mac: String,
|
||||
* status:
|
||||
* "incomplete" | "succeed" | "nonexistent_address" | "wrong_size"
|
||||
* }} data_wake
|
||||
* @returns {{
|
||||
* has_wake: false
|
||||
* table: {
|
||||
* ip: String
|
||||
* dev: String
|
||||
* mac: String
|
||||
* state: String
|
||||
* }[]
|
||||
* filters: {
|
||||
* ips?: String[]
|
||||
* devs?: String[]
|
||||
* nuds?: String[]
|
||||
* macs?: String[]
|
||||
* }
|
||||
* } | {
|
||||
* has_wake: true
|
||||
* table: {
|
||||
* wake_status: Boolean
|
||||
* ips: String
|
||||
* dev: String
|
||||
* mac: String
|
||||
* state: String
|
||||
* }[]
|
||||
* filters: {
|
||||
* ips?: String[]
|
||||
* devs?: String[]
|
||||
* nuds?: String[]
|
||||
* macs?: String[]
|
||||
* }
|
||||
* }}
|
||||
*/
|
||||
export async function fetchStatus(name, render = true, data_wake) {
|
||||
setPill("warn", "checking…");
|
||||
const u = buildStatusUrl(name);
|
||||
elLog.textContent = "GET " + u.pathname + u.search;
|
||||
try {
|
||||
const r = await fetch(u);
|
||||
if (!r.ok) {
|
||||
let msg = String(r.status);
|
||||
try {
|
||||
/** @type {{error: string}} */
|
||||
const err = await r.clone().json();
|
||||
msg = err.error || JSON.stringify(err);
|
||||
} catch {
|
||||
msg = await r.text();
|
||||
}
|
||||
elLog.textContent = `status error: ${msg}`;
|
||||
setPill("bad", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* this one does not have data wake
|
||||
* @type {{
|
||||
* table: {
|
||||
* ip: String
|
||||
* dev: String
|
||||
* mac: String
|
||||
* state: String
|
||||
* wake_status?: String
|
||||
* }[]
|
||||
* filters: {
|
||||
* ips?: String[]
|
||||
* devs?: String[]
|
||||
* nuds?: String[]
|
||||
* macs?: String[]
|
||||
* }
|
||||
* has_wake?: true
|
||||
* }}
|
||||
*/
|
||||
const data = await r.json();
|
||||
|
||||
if (data_wake) {
|
||||
data.table = merge_wake_data(data.table, data_wake);
|
||||
data.has_wake = true;
|
||||
}
|
||||
if (render) renderStatus(data);
|
||||
return data;
|
||||
} catch (e) {
|
||||
elLog.textContent = "status error: " + e;
|
||||
setPill("bad", "error");
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #0b0b0b;
|
||||
--fg: #e6e6e6;
|
||||
--muted: #888;
|
||||
--ok: #17a34a;
|
||||
--warn: #d97706;
|
||||
--bad: #dc2626;
|
||||
--btn: #2563eb;
|
||||
--card: #111827;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
wrap {
|
||||
margin-inline: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
min-height: 100dvh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
.wrap {
|
||||
width: min(900px, 95vw);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
input[type="text"] {
|
||||
flex: 1 1 240px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2a2a2a;
|
||||
background: #0f0f0f;
|
||||
color: var(--fg);
|
||||
outline: none;
|
||||
}
|
||||
button {
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2a2a2a;
|
||||
background: var(--btn);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.secondary {
|
||||
background: #1f2937;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
border: 1px solid #2a2a2a;
|
||||
}
|
||||
.ok {
|
||||
background: #052e1a;
|
||||
border-color: #064e3b;
|
||||
color: #86efac;
|
||||
}
|
||||
.warn {
|
||||
background: #2b1800;
|
||||
border-color: #7c2d12;
|
||||
color: #fbbf24;
|
||||
}
|
||||
.bad {
|
||||
background: #330b0b;
|
||||
border-color: #7f1d1d;
|
||||
color: #fca5a5;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
background: var(--card);
|
||||
}
|
||||
#out {
|
||||
min-height: 100px;
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
margin: 0;
|
||||
}
|
||||
a {
|
||||
color: #93c5fd;
|
||||
}
|
||||
.table a.pick {
|
||||
color: #93c5fd;
|
||||
text-decoration: none;
|
||||
}
|
||||
.table a.pick:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#html {
|
||||
overflow-y: auto;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
#html::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin-bottom: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* simple table styling */
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
font-size: 12px;
|
||||
}
|
||||
.table th {
|
||||
text-align: left;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tiny {
|
||||
font-size: 11px;
|
||||
}
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
}
|
||||
.dot.ok {
|
||||
background: var(--ok);
|
||||
}
|
||||
.dot.bad {
|
||||
background: var(--bad);
|
||||
}
|
||||
.dot.warn {
|
||||
background: var(--warn);
|
||||
}
|
||||
|
||||
#preview:empty {
|
||||
display: none;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
export function getName(elName) {
|
||||
return (elName.value || "").trim();
|
||||
}
|
||||
|
||||
export function saveName(name) {
|
||||
try {
|
||||
localStorage.setItem("wakey:name", name);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function loadName(qs) {
|
||||
return qs.get("name") || localStorage.getItem("wakey:name") || "";
|
||||
}
|
||||
|
||||
export function extractHostLikeBackend(input) {
|
||||
let s = String(input || "").trim();
|
||||
if (!s) return "";
|
||||
const schemeIdx = s.indexOf("://");
|
||||
if (schemeIdx >= 0) s = s.slice(schemeIdx + 3);
|
||||
else if (s.startsWith("//")) s = s.slice(2);
|
||||
const at = s.lastIndexOf("@");
|
||||
if (at >= 0) s = s.slice(at + 1);
|
||||
if (s.startsWith("[")) {
|
||||
const end = s.indexOf("]");
|
||||
if (end > 1) s = s.slice(1, end);
|
||||
} else {
|
||||
const slash = s.indexOf("/");
|
||||
if (slash >= 0) s = s.slice(0, slash);
|
||||
const colon = s.lastIndexOf(":");
|
||||
if (colon > 0 && (s.match(/:/g) || []).length === 1) {
|
||||
const port = s.slice(colon + 1);
|
||||
if (/^\d+$/.test(port)) s = s.slice(0, colon);
|
||||
}
|
||||
}
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
export function rankState(s) {
|
||||
const key = String(s || "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
return (
|
||||
{
|
||||
PERMANENT: 5,
|
||||
REACHABLE: 5,
|
||||
STALE: 4,
|
||||
DELAY: 3,
|
||||
PROBE: 3,
|
||||
INCOMPLETE: 3,
|
||||
NOARP: 2,
|
||||
NONE: 1,
|
||||
FAILED: 0,
|
||||
}[key] ?? 0
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { elHtml, elLog, setPill } from "./dom.js";
|
||||
import { fetchStatus, renderStatus } from "./status.js";
|
||||
|
||||
/**
|
||||
* @param {String} name just plain name
|
||||
*/
|
||||
export async function sendWake(name) {
|
||||
const data = await fetchStatus(name, false);
|
||||
if (!data) return; // can not proceed; theres nothing.
|
||||
const wake_targets = data.table.map(({ ip, mac }) => {
|
||||
return { ip, mac };
|
||||
});
|
||||
setPill("warn", "waking…");
|
||||
elLog.textContent = "POST /api/wake";
|
||||
try {
|
||||
const r = await fetch(`/api/wake`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(wake_targets),
|
||||
});
|
||||
if (!r.ok) {
|
||||
let msg = String(r.status);
|
||||
try {
|
||||
const err = await r.clone().json();
|
||||
msg = err.error || JSON.stringify(err);
|
||||
} catch {
|
||||
msg = await r.text();
|
||||
}
|
||||
elLog.textContent = `wake error: ${msg}`;
|
||||
setPill("bad", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const j = await r.json();
|
||||
|
||||
if (!j.success) {
|
||||
elLog.textContent = `wake error: ${j.error}`;
|
||||
setPill("bad", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = j.result;
|
||||
data.table = merge_wake_data(data.table, result);
|
||||
data.has_wake = true;
|
||||
|
||||
renderStatus(data);
|
||||
|
||||
setTimeout(() => fetchStatus(name, undefined, result), 2000); // long ass timeout
|
||||
} catch (e) {
|
||||
elLog.textContent = "wake error: " + e;
|
||||
setPill("bad", "error");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {{
|
||||
* ip: String
|
||||
* dev: String
|
||||
* mac: String
|
||||
* state: String
|
||||
* }[]} table
|
||||
* @param {{
|
||||
* ip: String,
|
||||
* mac: String,
|
||||
* status: "incomplete" | "succeed" | "nonexistent_address" | "wrong_size"
|
||||
* }[]} wake
|
||||
* @returns {{
|
||||
* ip: String
|
||||
* dev: String
|
||||
* mac: String
|
||||
* state: String
|
||||
* }[] | {
|
||||
* ip: String
|
||||
* dev: String
|
||||
* mac: String
|
||||
* state: String
|
||||
* wake_status: boolean
|
||||
* }[]}
|
||||
*/
|
||||
export function merge_wake_data(table, wake) {
|
||||
if (!wake) return table;
|
||||
if (wake.length != table.length)
|
||||
throw TypeError(
|
||||
"wake status table and status table not of the same length"
|
||||
);
|
||||
const return_array = [];
|
||||
let linear_failed = false;
|
||||
for (const [index, entry] of table.entries()) {
|
||||
if (wake[index].ip != entry.ip || wake[index].mac != entry.mac) {
|
||||
linear_failed = true;
|
||||
break;
|
||||
} // use alternative method
|
||||
|
||||
return_array.push({ wake_status: wake[index].status, ...entry });
|
||||
}
|
||||
|
||||
// never happening AHH
|
||||
if (linear_failed) {
|
||||
return_array = [];
|
||||
const wake_map = new Map();
|
||||
wake.forEach(({ ip, mac, status }) => {
|
||||
wake_map.set(JSON.stringify({ ip, mac }), status);
|
||||
});
|
||||
return_array = table.map((entry) => {
|
||||
const { ip, mac } = entry;
|
||||
return {
|
||||
wake_status: wake_map.get(JSON.stringify({ ip, mac })),
|
||||
...entry,
|
||||
};
|
||||
});
|
||||
}
|
||||
//
|
||||
return return_array;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {"incomplete" | "succeed" | "nonexistent_address" | "wrong_size" | any} wake_msg
|
||||
* @returns {string}
|
||||
*/
|
||||
export function translate_wake_message(wake_msg) {
|
||||
switch (wake_msg) {
|
||||
case "incomplete":
|
||||
return "Incomplete address (both ip and MAC required)"
|
||||
case "succeed":
|
||||
return "Wake request sent successfully"
|
||||
case "nonexistent_address":
|
||||
return "Errored pinging this address (nonexistent address)"
|
||||
case "wrong_size":
|
||||
return "Wake request malformed"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>wakey • home</title>
|
||||
<link rel="stylesheet" href="home_2/styles.css" />
|
||||
<script type="module" src="home_2/main.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<h1>home <span class="muted">ping your device with a WoL</span></h1>
|
||||
<span id="status-pill" class="pill warn">unknown</span>
|
||||
</header>
|
||||
|
||||
<div class="row">
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="target (name, ip, mac...)"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<button id="check" class="secondary">Check</button>
|
||||
<button id="wake">Wake</button>
|
||||
</div>
|
||||
<div class="row muted" style="gap: 16px">
|
||||
<span
|
||||
>Uses query
|
||||
<span title="available keys: name, ips, macs, devs, nuds"
|
||||
>(?name=...)</span
|
||||
>
|
||||
on this page to view the status.</span
|
||||
><span id="preview" class="tiny"></span
|
||||
><a id="permalink" href="#">permalink</a>
|
||||
</div>
|
||||
|
||||
<section id="out" class="card">
|
||||
<pre id="log">ready.</pre>
|
||||
<div id="html"></div>
|
||||
</section>
|
||||
|
||||
<section id="leases" class="card">
|
||||
<h3 style="margin-top: 0">DHCP leases</h3>
|
||||
<div id="leases_html"></div>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
use wakey::inventory;
|
||||
use wakey_core::InventoryQuery;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "runs against live router data; use on-device or via scripts/test_remote.ps1"]
|
||||
async fn inventory_real_router_prints_device_inventory() -> anyhow::Result<()> {
|
||||
let inventory = inventory(InventoryQuery::default()).await?;
|
||||
println!("{}", serde_json::to_string_pretty(&inventory)?);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
use std::net::IpAddr;
|
||||
|
||||
use wakey::{broadcast_wake_targets, get_interface_summaries, inventory, resolve_query};
|
||||
use wakey_core::{InventoryQuery, Query};
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "runs against live router data; use on-device or via scripts/test_remote.ps1"]
|
||||
async fn interfaces_real_router_prints_interface_summaries() -> anyhow::Result<()> {
|
||||
let interfaces = get_interface_summaries().await?;
|
||||
assert!(
|
||||
!interfaces.is_empty(),
|
||||
"expected at least one non-loopback interface summary"
|
||||
);
|
||||
println!("{}", serde_json::to_string_pretty(&interfaces)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "runs against live router data; use on-device or via scripts/test_remote.ps1"]
|
||||
async fn inventory_real_router_default_query_returns_rows_or_empty_cleanly() -> anyhow::Result<()> {
|
||||
let inv = inventory(InventoryQuery::default()).await?;
|
||||
println!("{}", serde_json::to_string_pretty(&inv)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "runs against live router data; use on-device or via scripts/test_remote.ps1"]
|
||||
async fn inventory_real_router_for_interface_filter_succeeds() -> anyhow::Result<()> {
|
||||
let interfaces = get_interface_summaries().await?;
|
||||
let first = interfaces
|
||||
.first()
|
||||
.map(|iface| iface.ifname.clone())
|
||||
.expect("expected at least one interface");
|
||||
|
||||
let inv = inventory(vec![Query::Interface(first.clone())]).await?;
|
||||
|
||||
println!("filtered dev: {first}");
|
||||
println!("{}", serde_json::to_string_pretty(&inv)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "runs against live router data; use on-device or via scripts/test_remote.ps1"]
|
||||
async fn inventory_real_router_string_input_for_interface_succeeds() -> anyhow::Result<()> {
|
||||
let interfaces = get_interface_summaries().await?;
|
||||
let first = interfaces
|
||||
.first()
|
||||
.map(|iface| iface.ifname.clone())
|
||||
.expect("expected at least one interface");
|
||||
|
||||
let inv = inventory(resolve_query(first.clone()).await?).await?;
|
||||
println!("selector: {first}");
|
||||
println!("{}", serde_json::to_string_pretty(&inv)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "runs against live router data; use on-device or via scripts/test_remote.ps1"]
|
||||
async fn broadcast_wake_targets_real_router_resolve_from_interfaces() -> anyhow::Result<()> {
|
||||
let mac: macaddr::MacAddr = "aa:bb:cc:dd:ee:ff".parse()?;
|
||||
let targets = broadcast_wake_targets(mac).await?;
|
||||
|
||||
assert!(
|
||||
targets.iter().all(|target| target.mac == Some(mac)),
|
||||
"all broadcast targets should preserve the requested MAC"
|
||||
);
|
||||
assert!(
|
||||
targets
|
||||
.iter()
|
||||
.all(|target| matches!(target.ip, Some(IpAddr::V4(_)))),
|
||||
"broadcast targets should be IPv4 broadcast destinations"
|
||||
);
|
||||
|
||||
println!("{}", serde_json::to_string_pretty(&targets)?);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
node_modules/
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": true,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Wakey Operator UI</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "wakey-operator-ui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.0",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^18.3.1",
|
||||
"react-compiler-runtime": "^1.0.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"shadcn": "^4.2.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"prettier": "^3.8.2",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
Generated
+5412
File diff suppressed because it is too large
Load Diff
+202
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
|
||||
import {
|
||||
type Agent,
|
||||
type Alert,
|
||||
type AlertTransition,
|
||||
type AuditEvent,
|
||||
fetchAgents,
|
||||
fetchAlertHistory,
|
||||
fetchAlerts,
|
||||
fetchAudit,
|
||||
revokeAgent,
|
||||
setAgentNickname,
|
||||
} from "@/api";
|
||||
import { AppLayout } from "@/layout/AppLayout";
|
||||
import { AgentsPage } from "@/pages/AgentsPage";
|
||||
import { AlertsPage } from "@/pages/AlertsPage";
|
||||
import { AuditPage } from "@/pages/AuditPage";
|
||||
import { CommandsPage } from "@/pages/CommandsPage";
|
||||
import { DashboardPage } from "@/pages/DashboardPage";
|
||||
import { DevicesPage } from "@/pages/DevicesPage";
|
||||
import { TokensPage } from "@/pages/TokensPage";
|
||||
|
||||
type LoadState = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
export function App() {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [alerts, setAlerts] = useState<Alert[]>([]);
|
||||
const [history, setHistory] = useState<AlertTransition[]>([]);
|
||||
const [audit, setAudit] = useState<AuditEvent[]>([]);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState("");
|
||||
|
||||
const [state, setState] = useState<LoadState>("idle");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function loadAll() {
|
||||
setState("loading");
|
||||
setError("");
|
||||
try {
|
||||
const [nextAgents, nextAlerts, nextHistory, nextAudit] =
|
||||
await Promise.all([
|
||||
fetchAgents(),
|
||||
fetchAlerts(),
|
||||
fetchAlertHistory(20),
|
||||
fetchAudit(30),
|
||||
]);
|
||||
setAgents(nextAgents);
|
||||
setAlerts(nextAlerts);
|
||||
setHistory(nextHistory);
|
||||
setAudit(nextAudit);
|
||||
const firstConnectedAgentId =
|
||||
nextAgents.find((agent) => agent.connected)?.agent_id ?? "";
|
||||
if (!nextAgents.length) {
|
||||
setSelectedAgentId("");
|
||||
} else if (
|
||||
!selectedAgentId ||
|
||||
!nextAgents.some(
|
||||
(agent) => agent.agent_id === selectedAgentId && agent.connected,
|
||||
)
|
||||
) {
|
||||
setSelectedAgentId(firstConnectedAgentId);
|
||||
}
|
||||
setState("ready");
|
||||
} catch (err) {
|
||||
setState("error");
|
||||
setError(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function onRevokeAgent(agentId: string): Promise<boolean> {
|
||||
const result = await revokeAgent(agentId);
|
||||
await loadAll();
|
||||
return result.revoked;
|
||||
}
|
||||
|
||||
async function onSetAgentNickname(
|
||||
agentId: string,
|
||||
nickname: string | null,
|
||||
): Promise<boolean> {
|
||||
const result = await setAgentNickname(agentId, nickname);
|
||||
await loadAll();
|
||||
return result.updated;
|
||||
}
|
||||
|
||||
async function refreshAlertsAndHistory() {
|
||||
const [nextAlerts, nextHistory] = await Promise.all([
|
||||
fetchAlerts(),
|
||||
fetchAlertHistory(20),
|
||||
]);
|
||||
setAlerts(nextAlerts);
|
||||
setHistory(nextHistory);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const wsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/api/v1/control/alerts/ws`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onmessage = (evt) => {
|
||||
try {
|
||||
const payload = JSON.parse(String(evt.data)) as {
|
||||
alerts?: Alert[];
|
||||
recent_transitions?: AlertTransition[];
|
||||
};
|
||||
if (payload.alerts) setAlerts(payload.alerts);
|
||||
if (payload.recent_transitions) setHistory(payload.recent_transitions);
|
||||
} catch {
|
||||
// Ignore malformed stream payloads and keep current UI state.
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
const id = window.setInterval(() => {
|
||||
void refreshAlertsAndHistory().catch(() => undefined);
|
||||
}, 8000);
|
||||
ws.onclose = () => window.clearInterval(id);
|
||||
};
|
||||
|
||||
return () => ws.close();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <pre className="error">{error}</pre>}
|
||||
<Routes>
|
||||
<Route path="/" element={<AppLayout />}>
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<DevicesPage
|
||||
agents={agents}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onSelectAgent={setSelectedAgentId}
|
||||
onAfterWake={loadAll}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="dashboard"
|
||||
element={
|
||||
<DashboardPage
|
||||
agents={agents}
|
||||
alerts={alerts}
|
||||
transitions={history}
|
||||
loading={state === "loading"}
|
||||
onRefresh={loadAll}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="agents"
|
||||
element={
|
||||
<AgentsPage
|
||||
agents={agents}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onSelectAgent={setSelectedAgentId}
|
||||
onRevokeAgent={onRevokeAgent}
|
||||
onSetAgentNickname={onSetAgentNickname}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="commands"
|
||||
element={
|
||||
<CommandsPage
|
||||
agents={agents}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onSelectAgent={setSelectedAgentId}
|
||||
onAfterCommand={loadAll}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="audit"
|
||||
element={
|
||||
<AuditPage
|
||||
events={audit}
|
||||
onRefresh={() => fetchAudit(30).then(setAudit)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="alerts"
|
||||
element={
|
||||
<AlertsPage
|
||||
alerts={alerts}
|
||||
transitions={history}
|
||||
onRefresh={refreshAlertsAndHistory}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="tokens" element={<TokensPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
export type Agent = {
|
||||
agent_id: string;
|
||||
connected: boolean;
|
||||
nickname?: string | null;
|
||||
};
|
||||
|
||||
export type Alert = {
|
||||
alert_id: string;
|
||||
kind: string;
|
||||
severity: string;
|
||||
status: string;
|
||||
agent_id: string | null;
|
||||
message: string;
|
||||
value: number;
|
||||
threshold: number;
|
||||
last_seen_unix: number;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AlertTransition = {
|
||||
transition_id: string;
|
||||
ts_unix: number;
|
||||
alert_id: string;
|
||||
kind: string;
|
||||
agent_id: string | null;
|
||||
from_status: string | null;
|
||||
to_status: string;
|
||||
message: string;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AuditEvent = {
|
||||
event_id: string;
|
||||
ts_unix: number;
|
||||
actor_type: string;
|
||||
actor_id: string | null;
|
||||
agent_id: string | null;
|
||||
request_id: string | null;
|
||||
event_type: string;
|
||||
outcome: string;
|
||||
latency_ms: number | null;
|
||||
message: string;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CommandKind = "devs" | "leases" | "inventory" | "wake";
|
||||
|
||||
export type EnrollTokenStatus = {
|
||||
enroll_token: string;
|
||||
expires_at_unix: number;
|
||||
expired: boolean;
|
||||
};
|
||||
|
||||
export type IssueEnrollTokenResponse = {
|
||||
enroll_token: string;
|
||||
expires_at_unix: number;
|
||||
};
|
||||
|
||||
export type RevokeEnrollTokenResponse = {
|
||||
token: string;
|
||||
revoked: boolean;
|
||||
};
|
||||
|
||||
export type RevokeAgentResponse = {
|
||||
agent_id: string;
|
||||
revoked: boolean;
|
||||
};
|
||||
|
||||
export type SetAgentNicknameResponse = {
|
||||
agent_id: string;
|
||||
nickname: string | null;
|
||||
updated: boolean;
|
||||
};
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
let detail = raw;
|
||||
try {
|
||||
detail = JSON.stringify(JSON.parse(raw), null, 2);
|
||||
} catch {}
|
||||
throw new Error(`${res.status} ${res.statusText}\n${detail}`);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function fetchAgents(): Promise<Agent[]> {
|
||||
return request<Agent[]>("/api/v1/control/agents");
|
||||
}
|
||||
|
||||
export function fetchAlerts(): Promise<Alert[]> {
|
||||
return request<Alert[]>("/api/v1/control/alerts");
|
||||
}
|
||||
|
||||
export function fetchAlertHistory(limit = 50): Promise<AlertTransition[]> {
|
||||
return request<AlertTransition[]>(
|
||||
`/api/v1/control/alerts/history?limit=${limit}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchAudit(limit = 50): Promise<AuditEvent[]> {
|
||||
return request<AuditEvent[]>(`/api/v1/control/audit/events?limit=${limit}`);
|
||||
}
|
||||
|
||||
export function fetchEnrollTokens(): Promise<EnrollTokenStatus[]> {
|
||||
return request<EnrollTokenStatus[]>(`/api/v1/control/enroll-tokens`);
|
||||
}
|
||||
|
||||
export function issueEnrollToken(
|
||||
ttlSeconds: number,
|
||||
): Promise<IssueEnrollTokenResponse> {
|
||||
return request<IssueEnrollTokenResponse>(
|
||||
`/api/v1/control/enroll-token?ttl_seconds=${Math.max(1, Math.floor(ttlSeconds))}`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
export function revokeEnrollToken(
|
||||
token: string,
|
||||
): Promise<RevokeEnrollTokenResponse> {
|
||||
return request<RevokeEnrollTokenResponse>(
|
||||
`/api/v1/control/enroll-tokens/${encodeURIComponent(token)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
}
|
||||
|
||||
export function revokeAgent(agentId: string): Promise<RevokeAgentResponse> {
|
||||
return request<RevokeAgentResponse>(
|
||||
`/api/v1/control/agents/${encodeURIComponent(agentId)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
}
|
||||
|
||||
export function setAgentNickname(
|
||||
agentId: string,
|
||||
nickname: string | null,
|
||||
): Promise<SetAgentNicknameResponse> {
|
||||
const normalized = nickname?.trim() ?? "";
|
||||
return request<SetAgentNicknameResponse>(
|
||||
`/api/v1/control/agents/${encodeURIComponent(agentId)}/nickname`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ nickname: normalized ? normalized : null }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function runCommand(
|
||||
agentId: string,
|
||||
kind: CommandKind,
|
||||
query: string,
|
||||
): Promise<unknown> {
|
||||
const payload = buildCommandPayload(kind, query);
|
||||
return request(
|
||||
`/api/v1/control/agents/${encodeURIComponent(agentId)}/command`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function buildCommandPayload(
|
||||
kind: CommandKind,
|
||||
query: string,
|
||||
): { command: Record<string, unknown> } {
|
||||
if (kind === "devs") {
|
||||
return { command: { kind: "devs", dev: null, up_only: false } };
|
||||
}
|
||||
if (kind === "leases") {
|
||||
return { command: { kind: "leases", include_state: true } };
|
||||
}
|
||||
if (kind === "inventory") {
|
||||
return {
|
||||
command: {
|
||||
kind: "inventory",
|
||||
query: query || null,
|
||||
name: null,
|
||||
ips: [],
|
||||
devs: [],
|
||||
nuds: [],
|
||||
macs: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: {
|
||||
kind: "wake",
|
||||
query: query || null,
|
||||
mac: null,
|
||||
ip: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props";
|
||||
import { useRender } from "@base-ui/react/use-render";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props,
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
@@ -0,0 +1,270 @@
|
||||
import * as React from "react";
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react";
|
||||
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn(
|
||||
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-start-2 data-[side=inline-start]:slide-in-from-end-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:ps-7",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:ps-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:ps-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="rtl:rotate-180 ms-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "inline-end",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pe-8 ps-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:ps-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute end-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon />
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pe-8 ps-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:ps-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute end-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon />
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ms-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,200 @@
|
||||
import * as React from "react";
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex min-w-0 flex-1 truncate text-start", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pe-2 ps-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn(
|
||||
"relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-start-2 data-[side=inline-start]:slide-in-from-end-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pe-8 ps-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex min-w-0 flex-1 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute end-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user