what a plan! what an impl! this is ahh

This commit is contained in:
lda
2026-04-11 15:25:18 +07:00 Unverified
parent a36e9d058d
commit e2feaeab8c
43 changed files with 1090 additions and 1629 deletions
+1 -10
View File
@@ -1,10 +1 @@
you should use block code to refence code, like so:
```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.
We work towards clean, maintainable design. im not the best at this thing, so you help me.
+32
View File
@@ -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.
Generated
+251 -103
View File
@@ -110,7 +110,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
dependencies = [
"axum-core",
"axum-macros",
"base64",
"bytes",
"form_urlencoded",
"futures-util",
@@ -129,8 +129,10 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite 0.28.0",
"tower",
"tower-layer",
"tower-service",
@@ -156,42 +158,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "axum-extra"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76"
dependencies = [
"axum",
"axum-core",
"bytes",
"form_urlencoded",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"serde_core",
"serde_html_form",
"serde_path_to_error",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-macros"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "backtrace"
version = "0.3.76"
@@ -530,6 +496,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -659,11 +631,24 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"wasip2",
"wasip3",
]
[[package]]
name = "gimli"
version = "0.32.3"
@@ -676,6 +661,15 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -727,12 +721,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -912,6 +900,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ident_case"
version = "1.0.1"
@@ -1030,6 +1024,12 @@ dependencies = [
"tokio",
]
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.184"
@@ -1111,16 +1111,6 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -1317,6 +1307,16 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -1396,6 +1396,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.5"
@@ -1673,6 +1679,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
@@ -1703,19 +1715,6 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_html_form"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f"
dependencies = [
"form_urlencoded",
"indexmap 2.13.1",
"itoa",
"ryu",
"serde_core",
]
[[package]]
name = "serde_json"
version = "1.0.149"
@@ -2074,21 +2073,20 @@ dependencies = [
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite",
"tungstenite 0.24.0",
"webpki-roots 0.26.11",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
name = "tokio-tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"futures-util",
"log",
"tokio",
"tungstenite 0.28.0",
]
[[package]]
@@ -2156,24 +2154,14 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"bitflags",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"iri-string",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -2286,18 +2274,29 @@ dependencies = [
"utf-8",
]
[[package]]
name = "tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.2",
"sha1",
"thiserror 2.0.18",
"utf-8",
]
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -2316,6 +2315,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -2334,12 +2339,6 @@ dependencies = [
"serde",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf-8"
version = "0.7.6"
@@ -2358,6 +2357,18 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
dependencies = [
"getrandom 0.4.2",
"js-sys",
"serde_core",
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"
@@ -2375,8 +2386,6 @@ name = "wakey"
version = "0.2.0-a1"
dependencies = [
"anyhow",
"axum",
"axum-extra",
"chrono",
"clap",
"color-eyre",
@@ -2385,13 +2394,10 @@ dependencies = [
"lda-ipjs",
"macaddr",
"serde",
"serde_html_form",
"serde_json",
"tokio",
"tower-http",
"tracing",
"tracing-subscriber",
"urlencoding",
"wakey-core",
"wakey-linux",
]
@@ -2409,7 +2415,7 @@ dependencies = [
"serde",
"serde_json",
"tokio",
"tokio-tungstenite",
"tokio-tungstenite 0.24.0",
"toml",
"tracing",
"tracing-subscriber",
@@ -2418,6 +2424,23 @@ dependencies = [
"wakey-core",
]
[[package]]
name = "wakey-control-plane"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"clap",
"futures-util",
"serde",
"serde_json",
"tokio",
"tracing",
"tracing-subscriber",
"uuid",
"wakey-agent",
]
[[package]]
name = "wakey-core"
version = "0.1.0"
@@ -2466,6 +2489,15 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.117"
@@ -2521,6 +2553,40 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap 2.13.1",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap 2.13.1",
"semver",
]
[[package]]
name = "web-sys"
version = "0.3.94"
@@ -2810,6 +2876,88 @@ name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap 2.13.1",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap 2.13.1",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap 2.13.1",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "writeable"
+2 -6
View File
@@ -6,8 +6,6 @@ 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"
@@ -15,7 +13,6 @@ comfy-table = "7"
futures = "0"
macaddr = { version = "1", features = ["serde", "serde_std"] }
serde = { version = "1", features = ["derive"] }
serde_html_form = "0"
serde_json = "1"
tokio = { version = "1", features = [
"fs",
@@ -23,18 +20,17 @@ tokio = { version = "1", features = [
"rt-multi-thread",
"io-util",
"macros",
"time",
] }
tower-http = { version = "0", features = ["fs", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
urlencoding = "2"
[profile.release]
opt-level = "z"
strip = true
[workspace]
members = ["ipjs", "wakey-agent", "wakey-core", "wakey-linux"]
members = ["ipjs", "wakey-agent", "wakey-control-plane", "wakey-core", "wakey-linux"]
[dependencies.lda-ipjs]
path = "ipjs"
+12 -40
View File
@@ -8,7 +8,7 @@ service-first project with:
- a reusable core model
- a Linux/OpenWrt adapter layer
- a CLI for operators
- a temporary legacy HTTP/static adapter during migration
- an outbound agent plus control-plane model
## What it does
@@ -28,7 +28,6 @@ wakey leases --include-state
wakey devs
wakey wake bedroom-pc
wakey wake --mac aa:bb:cc:dd:ee:ff
wakey http --host :: --port 12012
```
## Workspace layout
@@ -40,7 +39,11 @@ wakey http --host :: --port 12012
- Linux/OpenWrt adapter
- DHCP lease loading, interface summaries, neighbor lookup, WoL sending
- `wakey`
- service layer, CLI, and temporary HTTP/static adapter
- 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
@@ -52,40 +55,20 @@ Inside the `wakey` crate:
- `src/service`
- the real use-case layer
- status, leases, inventory, interfaces, wake, and query resolution
- `src/http`
- temporary legacy HTTP/static adapter
- compatibility mapping for the current `/static` client
- `src/legacy`
- transitional compatibility wrappers kept during the migration
The long-term direction is:
- keep the service layer stable
- keep HTTP as an adapter, not the architecture
- eventually move toward an agent + control-plane model
- keep the local CLI stable for operators
- run remote control via outbound agent + control-plane relay
## Future direction
The likely next large step is splitting the current temporary HTTP/web hosting
role away from the main `wakey` binary.
The project now uses:
The intended shape is roughly:
- `wakey`
- stable service layer and operator CLI
- `wakey-agent`
- router-side daemon exposing a network API over the service layer
- control-center app
- remote UI or control plane that talks to one or more agents
That future agent layer will likely need:
- explicit registration/authentication
- a stable remote API
- a small deployment/bootstrap story on the router
The current CLI and compatibility HTTP adapter are being kept small on purpose
so that split can happen later without moving the real product logic again.
- `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
## CLI
@@ -150,17 +133,6 @@ wakey devs --up
wakey devs --json
```
### Temporary HTTP adapter
The old web/static app can still be served during migration:
```sh
wakey http --host :: --port 12012
```
This should be treated as a compatibility surface, not the long-term product
shape.
## Tests
This repo has two useful testing modes:
-14
View File
@@ -69,20 +69,6 @@ try {
Invoke-Scp -Local $localBin -Dest $destTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet
Invoke-Scp -Local $localAgentBin -Dest $agentDestTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet
# 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 -Port $Port -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 -Port $Port -Quiet:$Quiet -Recurse
}
# Push deploy helper if exists
if (Test-Path $localDeploy) {
Invoke-Scp -Local $localDeploy -Dest "$User@${HostName}:$deployTmp" -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet
+2 -4
View File
@@ -5,13 +5,11 @@ START=99
USE_PROCD=1
NAME=wakey
# BIN=/root/.bin/wakey-agent
BIN=/root/.bin/wakey
BIN=/root/.bin/wakey-agent
start_service() {
procd_open_instance
# procd_set_param command "$BIN" serve
procd_set_param command "$BIN" http --host :: --port 12012
procd_set_param command "$BIN" serve
procd_set_param respawn 5 1 0
procd_set_param stdout 1
procd_set_param stderr 1
-6
View File
@@ -62,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
+10 -30
View File
@@ -2,20 +2,17 @@
pub mod table;
use std::net::{IpAddr, SocketAddr};
use std::net::IpAddr;
use anyhow::Result;
use clap::{ArgAction, Args, Parser, Subcommand};
use tracing::{debug, info};
use tracing::debug;
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
use wakey_core::{DeviceFilters, DeviceQuery, InterfaceSummary, WakeResult};
#[derive(Parser)]
#[command(name = "wakey")]
#[command(version, about = "CLI and temporary HTTP adapter for Wakey")]
#[command(
long_about = "Wakey can run as a local/operator CLI or serve the legacy HTTP/static interface during the migration to a service-first architecture."
)]
#[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)]
@@ -27,8 +24,6 @@ pub struct Cli {
#[derive(Subcommand)]
pub enum Command {
/// Serve the temporary legacy HTTP/static app.
Http(HttpArgs),
/// Show device status rows from neighbor/device data.
Status(StatusArgs),
/// Show DHCP leases, optionally enriched with current neighbor state.
@@ -39,16 +34,6 @@ pub enum Command {
Devs(DevsArgs),
}
#[derive(Args)]
pub struct HttpArgs {
/// Host address to bind the HTTP server to.
#[arg(long, default_value = "::")]
pub host: IpAddr,
/// TCP port to bind the HTTP server to.
#[arg(long, default_value_t = 12012)]
pub port: u16,
}
#[derive(Args)]
pub struct LeasesArgs {
/// Include best-known current neighbor state for each lease IP.
@@ -143,9 +128,9 @@ pub fn init_tracing(verbose: u8) {
pub fn default_filter_for_verbosity(verbose: u8) -> &'static str {
match verbose {
0 => "wakey=info,tower_http=info",
1 => "wakey=debug,tower_http=debug",
_ => "wakey=trace,tower_http=trace",
0 => "wakey=info",
1 => "wakey=debug",
_ => "wakey=trace",
}
}
@@ -153,11 +138,6 @@ pub async fn run(cli: Cli) -> Result<()> {
init_tracing(cli.verbose);
match cli.command {
Command::Http(args) => {
let addr = SocketAddr::new(args.host, args.port);
info!(%addr, "dispatching http command");
wakey::serve_http_from_current_exe(addr).await?;
}
Command::Status(args) => {
let as_json = args.json;
let query = status_args_to_query(args);
@@ -357,9 +337,9 @@ mod tests {
#[test]
fn verbosity_maps_to_expected_default_filters() {
assert_eq!(default_filter_for_verbosity(0), "wakey=info,tower_http=info");
assert_eq!(default_filter_for_verbosity(1), "wakey=debug,tower_http=debug");
assert_eq!(default_filter_for_verbosity(2), "wakey=trace,tower_http=trace");
assert_eq!(default_filter_for_verbosity(9), "wakey=trace,tower_http=trace");
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");
}
}
-234
View File
@@ -1,234 +0,0 @@
//! Compatibility types and mappers for the legacy HTTP/static client.
//!
//! These types intentionally preserve old JSON shapes expected by `/static`
//! while the core and service layers evolve underneath them. They do not define
//! the long-term domain model of the project.
use serde::Serialize;
use wakey_core::parse::mac;
use wakey_core::{
Device, DeviceFilters, DeviceInventory, DhcpLeaseWithState, NeighborEntry, Status, WakeResult,
WakeTargetResult,
};
/// Legacy status row shape expected by the old `/static` frontend.
#[derive(Debug, Clone, Serialize)]
pub struct LegacyStatusRow {
pub ip: std::net::IpAddr,
pub dev: Option<String>,
#[serde(with = "mac::option_mac")]
pub mac: Option<macaddr::MacAddr>,
pub state: wakey_core::NeighborState,
}
/// Legacy status response shape expected by the old `/static` frontend.
#[derive(Debug, Clone, Serialize)]
pub struct LegacyStatusResponse {
pub name: Option<String>,
pub table: Vec<LegacyStatusRow>,
pub filters: DeviceFilters,
}
/// Legacy DHCP lease row shape expected by the old `/static` frontend.
#[derive(Debug, Clone, Serialize)]
pub struct LegacyLeaseRow {
pub expires_epoch: u64,
pub ip: std::net::IpAddr,
#[serde(with = "mac")]
pub mac: macaddr::MacAddr,
pub name: Option<String>,
pub nud_state: Option<wakey_core::NeighborState>,
}
/// Legacy wake response wrapper expected by the old `/static` frontend.
#[derive(Debug, Clone, Serialize)]
pub struct LegacyWakeResult {
pub result: Vec<LegacyWakeResultRow>,
}
/// Legacy per-target wake row shape.
#[derive(Debug, Clone, Copy, Serialize)]
pub struct LegacyWakeResultRow {
#[serde(flatten)]
pub target: wakey_core::WakeTarget,
pub status: wakey_core::WakeStatus,
}
/// Map legacy-style status rows into the old response shape.
///
/// This helper exists for compatibility with the original frontend contract.
pub fn legacy_status_from_domain(status: Status<NeighborEntry>) -> LegacyStatusResponse {
LegacyStatusResponse {
name: status.name,
table: status.table.into_iter().map(legacy_status_row).collect(),
filters: status.filters,
}
}
/// Project a device inventory into the legacy status response shape.
pub fn legacy_status_from_inventory(
inventory: DeviceInventory,
name: Option<String>,
filters: DeviceFilters,
) -> LegacyStatusResponse {
let table = inventory
.devices
.into_iter()
.flat_map(legacy_status_rows_from_device)
.collect();
LegacyStatusResponse {
name,
table,
filters,
}
}
/// Convert one neighbor row to the legacy status row shape.
pub fn legacy_status_row(row: NeighborEntry) -> LegacyStatusRow {
LegacyStatusRow {
ip: row.ip,
dev: row.dev,
mac: row.mac,
state: row.state,
}
}
/// Project one merged device back into legacy status rows.
pub fn legacy_status_rows_from_device(device: Device) -> Vec<LegacyStatusRow> {
if !device.neighbors.is_empty() {
return device
.neighbors
.into_iter()
.map(legacy_status_row)
.collect::<Vec<_>>();
}
let fallback_mac = device.macs.first().copied();
let fallback_dev = device.interfaces.first().cloned();
let fallback_state = match device.presence {
wakey_core::Presence::Online => wakey_core::NeighborState::Reachable,
wakey_core::Presence::LikelyOnline => wakey_core::NeighborState::Stale,
wakey_core::Presence::Offline => wakey_core::NeighborState::Failed,
wakey_core::Presence::Unknown => wakey_core::NeighborState::None,
};
device
.ips
.into_iter()
.map(|ip| LegacyStatusRow {
ip,
dev: fallback_dev.clone(),
mac: fallback_mac,
state: fallback_state,
})
.collect()
}
/// Convert lease rows into the legacy frontend shape.
pub fn legacy_leases_from_domain(leases: Vec<DhcpLeaseWithState>) -> Vec<LegacyLeaseRow> {
leases.into_iter().map(legacy_lease_row).collect()
}
/// Convert one lease row into the legacy frontend shape.
pub fn legacy_lease_row(lease: DhcpLeaseWithState) -> LegacyLeaseRow {
LegacyLeaseRow {
expires_epoch: lease.lease_line.expires_epoch,
ip: lease.lease_line.ip,
mac: lease.lease_line.mac,
name: lease.lease_line.name,
nud_state: lease.nud_state,
}
}
/// Convert wake results into the legacy frontend shape.
pub fn legacy_wake_from_domain(result: WakeResult) -> LegacyWakeResult {
LegacyWakeResult {
result: result.result.into_iter().map(legacy_wake_row).collect(),
}
}
/// Convert one wake result row into the legacy frontend shape.
pub fn legacy_wake_row(row: WakeTargetResult) -> LegacyWakeResultRow {
LegacyWakeResultRow {
target: row.target,
status: row.status,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr};
use wakey_core::{DhcpLease, NeighborState, WakeStatus, WakeTarget};
#[test]
fn maps_status_to_legacy_shape() {
let status = Status {
name: Some("pc".into()),
table: 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,
}],
filters: DeviceFilters::default(),
};
let legacy = legacy_status_from_domain(status);
assert_eq!(legacy.name.as_deref(), Some("pc"));
assert_eq!(legacy.table.len(), 1);
assert_eq!(legacy.table[0].state, NeighborState::Reachable);
}
#[test]
fn maps_inventory_to_legacy_status_shape() {
let inventory = DeviceInventory {
devices: vec![Device::from_parts(
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,
}],
vec![],
)],
};
let legacy =
legacy_status_from_inventory(inventory, Some("pc".into()), DeviceFilters::default());
assert_eq!(legacy.name.as_deref(), Some("pc"));
assert_eq!(legacy.table.len(), 1);
assert_eq!(legacy.table[0].state, NeighborState::Reachable);
}
#[test]
fn maps_leases_to_legacy_shape() {
let leases = vec![DhcpLeaseWithState {
lease_line: DhcpLease {
expires_epoch: 42,
ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
mac: "aa:bb:cc:dd:ee:ff".parse().expect("mac"),
name: Some("pc".into()),
},
nud_state: Some(NeighborState::Reachable),
}];
let legacy = legacy_leases_from_domain(leases);
assert_eq!(legacy.len(), 1);
assert_eq!(legacy[0].name.as_deref(), Some("pc"));
assert_eq!(legacy[0].nud_state, Some(NeighborState::Reachable));
}
#[test]
fn maps_wake_to_legacy_shape() {
let result = WakeResult {
result: vec![WakeTargetResult {
target: WakeTarget {
ip: Some(IpAddr::V4(Ipv4Addr::LOCALHOST)),
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
},
status: WakeStatus::Succeed,
}],
};
let legacy = legacy_wake_from_domain(result);
assert_eq!(legacy.result.len(), 1);
assert_eq!(legacy.result[0].status, WakeStatus::Succeed);
}
}
-53
View File
@@ -1,53 +0,0 @@
//! Temporary HTTP adapter for the legacy web/static surface.
//!
//! This module exists to keep the old `/api` routes and `/static` frontend
//! working while the project is migrated toward a service-first architecture.
//! New product logic should live in [`crate::service`], not here.
pub mod compat;
pub mod route;
use std::{io, net::SocketAddr};
use axum::Router;
use tokio::net::TcpListener;
use tower_http::{services::ServeDir, trace::TraceLayer};
use tracing::info;
/// Build the temporary HTTP app that serves the legacy API and static frontend.
///
/// This is a compatibility surface. It should stay thin and delegate actual
/// product behavior to the service layer.
pub fn http_app(static_root: std::path::PathBuf) -> Router {
Router::new()
.nest("/api", route::api_router())
.layer(TraceLayer::new_for_http())
.fallback_service(axum::routing::get_service(
ServeDir::new(static_root)
.append_index_html_on_directories(true)
.precompressed_br()
.precompressed_deflate()
.precompressed_gzip()
.precompressed_zstd(),
))
}
/// Serve the temporary HTTP app on the provided socket address.
///
/// This is intended for transition and compatibility, not as the long-term
/// architecture boundary of the project.
pub async fn serve_http(addr: SocketAddr, static_root: std::path::PathBuf) -> io::Result<()> {
info!(%addr, static_root = %static_root.display(), "starting legacy http adapter");
let listener = TcpListener::bind(addr).await?;
axum::serve(listener, http_app(static_root).into_make_service()).await
}
/// Serve the HTTP app using the `static/` directory next to the current
/// executable.
pub async fn serve_http_from_current_exe(addr: SocketAddr) -> io::Result<()> {
let exe = std::env::current_exe()?;
let root = exe
.parent()
.ok_or_else(|| io::Error::other("no parent dir"))?;
serve_http(addr, root.join("static")).await
}
-44
View File
@@ -1,44 +0,0 @@
use crate::http::route::error::ApiError;
use axum::Json;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::{extract::Path, response::Redirect};
use wakey_core::DeviceQuery;
use crate::http::route::status::NamePath;
// 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> {
let query: DeviceQuery = match crate::service::resolve_query(q).await {
Ok(query) => query,
Err(e) => {
return Err(ApiError {
error: e.to_string(),
code: StatusCode::BAD_GATEWAY,
});
}
};
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 {
crate::service::get_ips(&name).await.map_or_else(
|e| ApiError::ise(e.to_string()).into_response(),
|ips| Json(ips).into_response(),
)
}
-10
View File
@@ -1,10 +0,0 @@
use crate::http::route::error::ApiError;
use axum::{Json, response::IntoResponse};
pub async fn devs_router() -> impl IntoResponse {
match crate::service::list_interfaces().await {
Ok(devs) => Json(devs).into_response(),
Err(e) => ApiError::ise(e.to_string()).into_response(),
}
}
// Device listing endpoints
-27
View File
@@ -1,27 +0,0 @@
use crate::{http::route::error::ApiError, utils::parse::boolish_str};
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(DhcpLeasesQueryRaw { include_state }): Query<DhcpLeasesQueryRaw>,
) -> impl IntoResponse {
let include_state = include_state.as_deref().map(boolish_str).unwrap_or(false);
match crate::service::get_leases(wakey_core::LeaseQuery { include_state }).await {
Ok(leases) => (
StatusCode::OK,
Json(crate::http::compat::legacy_leases_from_domain(leases)),
)
.into_response(),
Err(e) => ApiError {
error: e.to_string(),
code: StatusCode::BAD_GATEWAY,
}
.into_response(),
}
}
-29
View File
@@ -1,29 +0,0 @@
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct ApiError<T: Serialize> {
#[serde(skip_serializing, skip_deserializing)]
pub code: StatusCode,
pub error: T,
}
impl<T: Serialize> ApiError<T> {
/// [StatusCode::INTERNAL_SERVER_ERROR] shortcut
pub const fn ise(error: T) -> Self {
Self {
code: StatusCode::INTERNAL_SERVER_ERROR,
error,
}
}
}
impl<T: Serialize> IntoResponse for ApiError<T> {
fn into_response(self) -> Response {
(self.code, Json(self)).into_response()
}
}
-55
View File
@@ -1,55 +0,0 @@
pub mod api;
pub mod devs;
pub mod dhcp;
pub mod error;
pub mod status;
pub mod wake;
use crate::http::route::api::ip;
use crate::http::route::api::status_redirect;
use crate::http::route::api::status_smart_redirect;
use crate::http::route::devs::devs_router;
use crate::http::route::dhcp::get_dhcp_leases;
use crate::http::route::error::ApiError;
use crate::http::route::status::get_status_json;
use crate::http::route::wake::wake_multi;
use crate::legacy::dhcpparse::load_mac_name_cache;
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) => ApiError::ise(e.to_string()).into_response(),
}),
)
.layer(middleware::from_fn(add_performance_header))
}
-28
View File
@@ -1,28 +0,0 @@
use crate::http::route::error::ApiError;
use axum::{Json, http::StatusCode, response::IntoResponse};
use axum_extra::extract::Query;
pub use wakey_core::{DeviceQuery, NamePath};
pub async fn get_status_json(Query(query): Query<DeviceQuery>) -> impl IntoResponse {
match crate::service::inventory(query.clone()).await {
Ok(inventory) => (
StatusCode::OK,
Json(crate::http::compat::legacy_status_from_inventory(
inventory,
query.name,
query.filter,
)),
)
.into_response(),
Err(error) => ApiError {
code: StatusCode::BAD_GATEWAY,
error: error
.chain()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(": "),
}
.into_response(),
}
}
// Status endpoints
-17
View File
@@ -1,17 +0,0 @@
use crate::http::route::error::ApiError;
use axum::{extract::Json, http::StatusCode, response::IntoResponse};
pub use wakey_core::WakeTarget;
pub async fn wake_multi(Json(req): Json<Vec<WakeTarget>>) -> impl IntoResponse {
match crate::service::wake_targets(req).await {
Ok(result) => (
StatusCode::OK,
Json(crate::http::compat::legacy_wake_from_domain(result)),
)
.into_response(),
Err(error) => {
let error = format!("Error: {}", error);
ApiError::ise(error).into_response()
}
}
}
-2
View File
@@ -1,2 +0,0 @@
pub use wakey_core::NeighborEntry as IpNeighLine;
pub use wakey_core::NeighborState as NUDState;
-1
View File
@@ -1 +0,0 @@
pub use wakey_linux::dhcp::{load_mac_name_cache, read_dhcp_leases_with_names};
-8
View File
@@ -1,8 +0,0 @@
//! Transitional compatibility wrappers preserved during the migration.
//!
//! Items in this module exist so the codebase can keep working while older
//! parsing paths and adapter surfaces are being retired or replaced. New logic
//! should prefer the service layer and the dedicated crate boundaries instead.
pub mod arpparse;
pub mod dhcpparse;
-3
View File
@@ -1,9 +1,6 @@
pub mod http;
pub mod legacy;
pub mod service;
pub mod utils;
pub use http::{http_app, serve_http, serve_http_from_current_exe};
pub use service::{
StatusResponse, broadcast_wake_targets, device_to_status_rows, get_interface_summaries,
get_interface_summary, get_ips, get_leases, get_status, get_status_for_input, inventory,
-4
View File
@@ -12,7 +12,3 @@
// 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;
-41
View File
@@ -1,41 +0,0 @@
/// 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))
}
+4 -3
View File
@@ -6,8 +6,9 @@ use tokio::{
net::{TcpStream, ToSocketAddrs},
time::timeout,
};
use wakey_core::NeighborState;
use crate::{legacy::arpparse::NUDState, utils::query::get_mac};
use crate::utils::query::get_mac;
pub async fn _ping_ip<T: ToSocketAddrs>(addr: T) -> bool {
timeout(Duration::from_secs(1), TcpStream::connect(addr))
@@ -19,13 +20,13 @@ pub async fn _ping_ip_2<T: ToSocketAddrs>(_addr: T) -> bool {
}
pub async fn _ping_ip_3<T: Into<IpAddr>>(addr: T) -> u8 {
match get_mac(Some(addr.into()), None, &[] as &[NUDState]).await {
match get_mac(Some(addr.into()), None, &[] as &[NeighborState]).await {
Err(_) => 0,
Ok(l) => l
.into_iter()
.map(|e| e.state)
.max()
.map(NUDState::rank)
.map(NeighborState::rank)
.unwrap_or_default(),
}
}
+5 -6
View File
@@ -16,8 +16,7 @@ pub use macs::*;
pub mod macs {
use anyhow::Result;
use std::net::IpAddr;
use crate::legacy::arpparse::{IpNeighLine, NUDState};
use wakey_core::{NeighborEntry, NeighborState};
pub async fn get_ips(machine_name: &str) -> Result<impl Iterator<Item = IpAddr>> {
wakey_linux::devices::get_ips(machine_name).await
@@ -27,17 +26,17 @@ pub mod macs {
machine_names: &[impl AsRef<str>],
ips: &[IpAddr],
devs: &[impl AsRef<str>],
state: &[NUDState],
state: &[NeighborState],
macs: &[macaddr::MacAddr],
) -> Result<Vec<IpNeighLine>> {
) -> Result<Vec<NeighborEntry>> {
wakey_linux::devices::get_neighbors(machine_names, ips, devs, state, macs).await
}
pub async fn get_mac(
ip: Option<IpAddr>,
dev: Option<&str>,
state: &[NUDState],
) -> Result<Vec<IpNeighLine>> {
state: &[NeighborState],
) -> Result<Vec<NeighborEntry>> {
let ips: Vec<IpAddr> = ip.into_iter().collect();
let devs: Vec<&str> = dev.into_iter().collect();
get_macs(&[] as &[&str], &ips, &devs, state, &[]).await
-36
View File
@@ -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();
}
-82
View File
@@ -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 {}
}
-90
View File
@@ -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();
}
-207
View File
@@ -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");
}
}
-182
View File
@@ -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;
}
-55
View File
@@ -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
);
}
-135
View File
@@ -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.error) {
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"
}
}
-49
View File
@@ -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>
+15 -1
View File
@@ -1,10 +1,11 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::Path;
pub const DEFAULT_CONFIG_PATH: &str = "/etc/wakey-agent/config.toml";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AgentConfig {
pub server_url: String,
pub agent_id: String,
@@ -15,6 +16,18 @@ pub struct AgentConfig {
pub reconnect_max_ms: u64,
}
impl fmt::Debug for AgentConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AgentConfig")
.field("server_url", &self.server_url)
.field("agent_id", &self.agent_id)
.field("agent_token", &"<redacted>")
.field("reconnect_base_ms", &self.reconnect_base_ms)
.field("reconnect_max_ms", &self.reconnect_max_ms)
.finish()
}
}
const fn default_reconnect_base_ms() -> u64 {
1_000
}
@@ -69,6 +82,7 @@ mod tests {
save_config(&path, &config).expect("save");
let loaded = load_config(&path).expect("load");
assert_eq!(loaded, config);
assert!(format!("{:?}", loaded).contains("<redacted>"));
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir_all(&dir);
}
+68 -1
View File
@@ -61,10 +61,77 @@ pub fn normalize_server_url(server_url: &str) -> String {
#[cfg(test)]
mod tests {
use super::normalize_server_url;
use super::*;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener};
use std::thread;
fn spawn_enroll_server(response_body: &'static str, status: &'static str) -> (String, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener");
let addr: SocketAddr = listener.local_addr().expect("local addr");
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept connection");
// Read until end-of-headers; body content is irrelevant for this test.
let mut buf = [0u8; 4096];
let mut req = Vec::new();
loop {
let n = stream.read(&mut buf).expect("read request");
if n == 0 {
break;
}
req.extend_from_slice(&buf[..n]);
if req.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response_body.len(),
response_body
);
stream
.write_all(response.as_bytes())
.expect("write response");
});
(format!("http://{}", addr), handle)
}
#[test]
fn normalize_server_url_trims_slash() {
assert_eq!(normalize_server_url("https://example.com/"), "https://example.com");
}
#[tokio::test]
async fn enroll_response_persists_config() {
let response = r#"{"agent_id":"agent-123","agent_token":"token-xyz","server_url":"https://control.example.com"}"#;
let (server_url, handle) = spawn_enroll_server(response, "200 OK");
let dir = std::env::temp_dir().join(format!(
"wakey-agent-enroll-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos()
));
let path = dir.join("config.toml");
let config = enroll(&server_url, "enroll-abc", &path)
.await
.expect("enroll should succeed");
assert_eq!(config.agent_id, "agent-123");
assert_eq!(config.agent_token, "token-xyz");
assert_eq!(config.server_url, "https://control.example.com");
let persisted = crate::config::load_config(&path).expect("load persisted config");
assert_eq!(persisted, config);
handle.join().expect("server thread joined");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir_all(&dir);
}
}
+1
View File
@@ -0,0 +1 @@
pub mod protocol;
+71 -5
View File
@@ -1,5 +1,6 @@
use macaddr::MacAddr;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::net::IpAddr;
use wakey_core::{
DeviceFilters, DeviceInventory, DeviceQuery, DhcpLeaseWithState, InterfaceSummary, NeighborEntry,
@@ -7,6 +8,65 @@ use wakey_core::{
};
use wakey_core::parse::mac;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RequestId(String);
impl RequestId {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for RequestId {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
if value.trim().is_empty() {
return Err("request_id must not be empty".into());
}
Ok(Self(value))
}
}
impl From<RequestId> for String {
fn from(value: RequestId) -> Self {
value.0
}
}
impl fmt::Display for RequestId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl Serialize for RequestId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.0)
}
}
impl<'de> Deserialize<'de> for RequestId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
RequestId::try_from(raw).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorPayload {
pub code: String,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retryable: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatusRequest {
pub query: Option<String>,
@@ -135,12 +195,12 @@ pub enum ClientMessage {
agent_id: String,
},
Result {
request_id: String,
request_id: RequestId,
result: CommandResult,
},
Error {
request_id: String,
error: String,
request_id: RequestId,
error: ErrorPayload,
},
}
@@ -148,7 +208,7 @@ pub enum ClientMessage {
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMessage {
Command {
request_id: String,
request_id: RequestId,
command: AgentCommand,
},
}
@@ -160,7 +220,7 @@ mod tests {
#[test]
fn command_serialization_is_stable() {
let msg = ServerMessage::Command {
request_id: "req-1".into(),
request_id: RequestId::try_from("req-1".to_string()).expect("request id"),
command: AgentCommand::Leases(LeasesRequest {
include_state: true,
}),
@@ -170,4 +230,10 @@ mod tests {
assert!(json.contains("\"request_id\":\"req-1\""));
assert!(json.contains("\"kind\":\"leases\""));
}
#[test]
fn request_id_rejects_empty() {
let err = RequestId::try_from(" ".to_string()).expect_err("must fail");
assert!(err.contains("must not be empty"));
}
}
+36 -8
View File
@@ -6,7 +6,7 @@ use tracing::{debug, error, info, warn};
use crate::config::AgentConfig;
use crate::dispatch::dispatch_command;
use crate::protocol::{ClientMessage, ServerMessage};
use crate::protocol::{ClientMessage, ErrorPayload, ServerMessage};
pub async fn run(config: AgentConfig) -> Result<()> {
let mut backoff = config.reconnect_base_ms.max(100);
@@ -18,7 +18,7 @@ pub async fn run(config: AgentConfig) -> Result<()> {
Err(err) => {
warn!(error = %err, backoff_ms = backoff, "agent session ended; reconnecting");
sleep(Duration::from_millis(backoff)).await;
backoff = (backoff.saturating_mul(2)).min(config.reconnect_max_ms.max(backoff));
backoff = next_backoff_ms(backoff, config.reconnect_max_ms);
}
}
}
@@ -63,9 +63,16 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
match msg {
Message::Text(text) => {
let message: ServerMessage = serde_json::from_str(&text)
.context("failed to decode server message")?;
handle_server_message(&mut sink, message).await?;
match serde_json::from_str::<ServerMessage>(&text) {
Ok(message) => {
handle_server_message(&mut sink, message).await?;
}
Err(err) => {
// Allow the server to introduce extra frame types without
// forcing reconnects for older agents.
warn!(error = %err, payload = %text, "ignoring unknown server message");
}
}
}
Message::Ping(payload) => {
sink.send(Message::Pong(payload)).await.context("failed to send pong")?;
@@ -84,6 +91,11 @@ async fn run_once(config: &AgentConfig) -> Result<()> {
}
}
pub fn next_backoff_ms(current_ms: u64, max_ms: u64) -> u64 {
let cap = max_ms.max(current_ms);
current_ms.saturating_mul(2).min(cap)
}
async fn handle_server_message<S>(sink: &mut S, message: ServerMessage) -> Result<()>
where
S: SinkExt<Message> + Unpin,
@@ -98,12 +110,16 @@ where
send_json(sink, &ClientMessage::Result { request_id, result }).await?;
}
Err(err) => {
error!(request_id, error = %err, "command dispatch failed");
error!(request_id = %request_id, error = %err, "command dispatch failed");
send_json(
sink,
&ClientMessage::Error {
request_id,
error: err.to_string(),
error: ErrorPayload {
code: "command_dispatch_failed".into(),
message: err.to_string(),
retryable: None,
},
},
)
.await?;
@@ -146,11 +162,23 @@ pub fn websocket_url(server_url: &str) -> Result<url::Url> {
#[cfg(test)]
mod tests {
use super::websocket_url;
use super::{next_backoff_ms, websocket_url};
#[test]
fn websocket_url_uses_expected_path() {
let url = websocket_url("https://example.com/control").expect("url");
assert_eq!(url.as_str(), "wss://example.com/api/v1/agent/ws");
}
#[test]
fn backoff_doubles_until_cap() {
assert_eq!(next_backoff_ms(1_000, 30_000), 2_000);
assert_eq!(next_backoff_ms(16_000, 30_000), 30_000);
assert_eq!(next_backoff_ms(30_000, 30_000), 30_000);
}
#[test]
fn backoff_never_shrinks_when_max_is_lower() {
assert_eq!(next_backoff_ms(8_000, 1_000), 8_000);
}
}
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "wakey-control-plane"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1"
axum = { version = "0.8", features = ["ws", "json"] }
clap = { version = "4", features = ["derive"] }
futures-util = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = [
"macros",
"rt-multi-thread",
"fs",
"sync",
"time",
"signal",
] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
uuid = { version = "1", features = ["v4", "serde"] }
wakey-agent = { path = "../wakey-agent", registry = "gitea" }
+416
View File
@@ -0,0 +1,416 @@
mod state;
mod tracing;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use ::tracing::{debug, info, warn};
use anyhow::{Context, Result};
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::{get, post};
use axum::{Json, Router};
use clap::{ArgAction, Parser};
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use tokio::sync::{Mutex, RwLock, mpsc, oneshot};
use uuid::Uuid;
use wakey_agent::protocol::{AgentCommand, ErrorPayload, RequestId, ServerMessage};
#[derive(Parser)]
#[command(name = "wakey-control-plane")]
#[command(version, about = "Control plane server for wakey-agent fleets")]
struct Cli {
#[arg(short = 'v', long = "verbose", action = ArgAction::Count, global = true)]
verbose: u8,
#[arg(long, default_value = "0.0.0.0:8080")]
bind: SocketAddr,
#[arg(long, default_value = "http://127.0.0.1:8080")]
public_url: String,
#[arg(long, default_value = "/var/lib/wakey-control-plane/state.json")]
state_file: std::path::PathBuf,
#[arg(long = "enroll-token")]
enroll_tokens: Vec<String>,
#[arg(long, default_value_t = 30_000)]
command_timeout_ms: u64,
}
#[derive(Clone)]
struct AppState {
store: Arc<state::Store>,
sessions: Arc<RwLock<HashMap<String, mpsc::UnboundedSender<ServerMessage>>>>,
pending: Arc<Mutex<HashMap<String, oneshot::Sender<AgentReply>>>>,
public_url: String,
command_timeout: Duration,
}
enum AgentReply {
Result(serde_json::Value),
Error(ErrorPayload),
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum IncomingClientMessage {
Hello {
agent_id: String,
},
Auth {
agent_id: String,
agent_token: String,
},
Heartbeat {
agent_id: String,
},
Result {
request_id: RequestId,
result: serde_json::Value,
},
Error {
request_id: RequestId,
error: ErrorPayload,
},
}
#[derive(Debug, Deserialize)]
struct EnrollRequest {
enroll_token: String,
}
#[derive(Debug, Serialize)]
struct EnrollResponse {
agent_id: String,
agent_token: String,
server_url: String,
}
#[derive(Debug, Serialize)]
struct AgentStatus {
agent_id: String,
connected: bool,
}
#[derive(Debug, Deserialize)]
struct RelayCommandRequest {
command: AgentCommand,
timeout_ms: Option<u64>,
}
#[derive(Debug, Serialize)]
struct RelayCommandResponse {
request_id: String,
status: String,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<ErrorPayload>,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
tracing::init(cli.verbose);
let store = state::Store::load_or_init(&cli.state_file, cli.enroll_tokens)
.await
.with_context(|| format!("failed to initialize store {}", cli.state_file.display()))?;
let app_state = AppState {
store: Arc::new(store),
sessions: Arc::new(RwLock::new(HashMap::new())),
pending: Arc::new(Mutex::new(HashMap::new())),
public_url: cli.public_url.trim_end_matches('/').to_string(),
command_timeout: Duration::from_millis(cli.command_timeout_ms.max(1)),
};
let app = Router::new()
.route("/healthz", get(healthz))
.route("/api/v1/agents/enroll", post(enroll))
.route("/api/v1/agent/ws", get(agent_ws))
.route("/api/v1/control/agents", get(list_agents))
.route(
"/api/v1/control/agents/{agent_id}/command",
post(run_command),
)
.with_state(app_state);
info!(bind = %cli.bind, "starting control-plane server");
let listener = TcpListener::bind(cli.bind).await?;
axum::serve(listener, app).await?;
Ok(())
}
async fn healthz() -> &'static str {
"ok"
}
async fn enroll(
State(state): State<AppState>,
Json(req): Json<EnrollRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state.store.enroll(&req.enroll_token).await {
Ok(issued) => Ok((
StatusCode::OK,
Json(EnrollResponse {
agent_id: issued.agent_id,
agent_token: issued.agent_token,
server_url: state.public_url,
}),
)),
Err(err) => Err(json_error(
StatusCode::UNAUTHORIZED,
"enrollment_rejected",
&err.to_string(),
)),
}
}
async fn list_agents(
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let enrolled = state.store.list_agents().await;
let sessions = state.sessions.read().await;
let agents = enrolled
.into_iter()
.map(|agent_id| AgentStatus {
connected: sessions.contains_key(&agent_id),
agent_id,
})
.collect::<Vec<_>>();
Ok((StatusCode::OK, Json(agents)))
}
async fn run_command(
State(state): State<AppState>,
Path(agent_id): Path<String>,
Json(req): Json<RelayCommandRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let request_id_string = format!("req-{}", Uuid::new_v4());
let request_id = RequestId::try_from(request_id_string.clone()).map_err(|err| {
json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"invalid_request_id",
&err,
)
})?;
let tx = {
let sessions = state.sessions.read().await;
sessions.get(&agent_id).cloned()
}
.ok_or_else(|| {
json_error(
StatusCode::NOT_FOUND,
"agent_not_connected",
"agent is not connected",
)
})?;
let (pending_tx, pending_rx) = oneshot::channel();
state
.pending
.lock()
.await
.insert(request_id_string.clone(), pending_tx);
if let Err(err) = tx.send(ServerMessage::Command {
request_id,
command: req.command,
}) {
state.pending.lock().await.remove(&request_id_string);
return Err(json_error(
StatusCode::BAD_GATEWAY,
"agent_send_failed",
&format!("failed to send command to agent: {err}"),
));
}
let timeout = Duration::from_millis(
req.timeout_ms
.unwrap_or(state.command_timeout.as_millis() as u64)
.max(1),
);
let outcome = tokio::time::timeout(timeout, pending_rx).await;
let response = match outcome {
Ok(Ok(AgentReply::Result(result))) => RelayCommandResponse {
request_id: request_id_string,
status: "ok".into(),
result: Some(result),
error: None,
},
Ok(Ok(AgentReply::Error(error))) => RelayCommandResponse {
request_id: request_id_string,
status: "error".into(),
result: None,
error: Some(error),
},
Ok(Err(_)) => {
return Err(json_error(
StatusCode::BAD_GATEWAY,
"agent_response_dropped",
"agent response channel dropped",
));
}
Err(_) => {
state.pending.lock().await.remove(&request_id_string);
return Err(json_error(
StatusCode::GATEWAY_TIMEOUT,
"agent_timeout",
"agent did not answer before timeout",
));
}
};
Ok((StatusCode::OK, Json(response)))
}
async fn agent_ws(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_agent_socket(state, socket))
}
async fn handle_agent_socket(state: AppState, socket: WebSocket) {
let (mut write, mut read) = socket.split();
let (tx, mut rx) = mpsc::unbounded_channel::<ServerMessage>();
let writer = tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
let encoded = match serde_json::to_string(&msg) {
Ok(s) => s,
Err(err) => {
warn!(error = %err, "failed to encode server websocket message");
continue;
}
};
if let Err(err) = write.send(Message::Text(encoded.into())).await {
warn!(error = %err, "failed to send websocket message");
break;
}
}
});
let mut authed_agent_id: Option<String> = None;
loop {
let frame = read.next().await;
let msg = match frame {
Some(Ok(msg)) => msg,
Some(Err(err)) => {
warn!(error = %err, "agent websocket receive error");
break;
}
None => break,
};
match msg {
Message::Text(text) => {
if let Err(err) = process_agent_text(&state, &tx, &mut authed_agent_id, &text).await
{
warn!(error = %err, "closing agent websocket due to protocol/auth error");
break;
}
}
Message::Ping(_) => {}
Message::Pong(_) => {}
Message::Close(_) => break,
Message::Binary(_) => {
debug!("ignoring unexpected binary websocket frame");
}
}
}
if let Some(agent_id) = authed_agent_id {
info!(agent_id = %agent_id, "agent disconnected");
state.sessions.write().await.remove(&agent_id);
}
writer.abort();
}
async fn process_agent_text(
state: &AppState,
tx: &mpsc::UnboundedSender<ServerMessage>,
authed_agent_id: &mut Option<String>,
text: &str,
) -> Result<()> {
let message: IncomingClientMessage =
serde_json::from_str(text).context("invalid client websocket payload")?;
match message {
IncomingClientMessage::Hello { agent_id } => {
debug!(agent_id = %agent_id, "agent hello received");
}
IncomingClientMessage::Auth {
agent_id,
agent_token,
} => {
if !state
.store
.verify_agent_token(&agent_id, &agent_token)
.await
{
anyhow::bail!("agent auth rejected");
}
state
.sessions
.write()
.await
.insert(agent_id.clone(), tx.clone());
*authed_agent_id = Some(agent_id.clone());
info!(agent_id = %agent_id, "agent authenticated");
}
IncomingClientMessage::Heartbeat { agent_id } => {
if authed_agent_id.as_deref() != Some(agent_id.as_str()) {
anyhow::bail!("heartbeat for unauthenticated or mismatched agent");
}
debug!(agent_id = %agent_id, "heartbeat received");
}
IncomingClientMessage::Result { request_id, result } => {
if authed_agent_id.is_none() {
anyhow::bail!("result before auth");
}
let key = request_id.as_str().to_string();
if let Some(waiter) = state.pending.lock().await.remove(&key) {
let _ = waiter.send(AgentReply::Result(result));
}
}
IncomingClientMessage::Error { request_id, error } => {
if authed_agent_id.is_none() {
anyhow::bail!("error before auth");
}
let key = request_id.as_str().to_string();
if let Some(waiter) = state.pending.lock().await.remove(&key) {
let _ = waiter.send(AgentReply::Error(error));
}
}
}
Ok(())
}
fn json_error(
status: StatusCode,
code: &str,
message: &str,
) -> (StatusCode, Json<serde_json::Value>) {
(
status,
Json(serde_json::json!({
"error": {
"code": code,
"message": message,
}
})),
)
}
+120
View File
@@ -0,0 +1,120 @@
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssuedAgent {
pub agent_id: String,
pub agent_token: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PersistedState {
enroll_tokens: HashSet<String>,
agents: HashMap<String, String>,
}
pub struct Store {
path: PathBuf,
state: RwLock<PersistedState>,
}
impl Store {
pub async fn load_or_init(path: &Path, enroll_tokens: Vec<String>) -> Result<Self> {
let seeded_tokens = enroll_tokens
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<HashSet<_>>();
let initial = if path.exists() {
let raw = tokio::fs::read_to_string(path)
.await
.with_context(|| format!("failed to read store {}", path.display()))?;
serde_json::from_str::<PersistedState>(&raw)
.with_context(|| format!("failed to decode store {}", path.display()))?
} else {
PersistedState {
enroll_tokens: seeded_tokens,
agents: HashMap::new(),
}
};
let store = Self {
path: path.to_path_buf(),
state: RwLock::new(initial),
};
store.save().await?;
Ok(store)
}
pub async fn enroll(&self, enroll_token: &str) -> Result<IssuedAgent> {
let mut state = self.state.write().await;
if !state.enroll_tokens.remove(enroll_token) {
anyhow::bail!("invalid or already-used enroll token");
}
let agent_id = format!("agent-{}", Uuid::new_v4());
let agent_token = format!("tok-{}", Uuid::new_v4());
state.agents.insert(agent_id.clone(), agent_token.clone());
drop(state);
self.save().await?;
Ok(IssuedAgent {
agent_id,
agent_token,
})
}
pub async fn verify_agent_token(&self, agent_id: &str, token: &str) -> bool {
self.state
.read()
.await
.agents
.get(agent_id)
.map(|stored| stored == token)
.unwrap_or(false)
}
pub async fn list_agents(&self) -> Vec<String> {
let mut out = self
.state
.read()
.await
.agents
.keys()
.cloned()
.collect::<Vec<_>>();
out.sort();
out
}
async fn save(&self) -> Result<()> {
let snapshot = self.state.read().await;
let body =
serde_json::to_string_pretty(&*snapshot).context("failed to serialize store state")?;
if let Some(parent) = self.path.parent() {
tokio::fs::create_dir_all(parent)
.await
.with_context(|| format!("failed to create store dir {}", parent.display()))?;
}
let tmp = self.path.with_extension("json.tmp");
tokio::fs::write(&tmp, body)
.await
.with_context(|| format!("failed to write temp store {}", tmp.display()))?;
tokio::fs::rename(&tmp, &self.path).await.with_context(|| {
format!(
"failed to atomically move temp store {} into {}",
tmp.display(),
self.path.display()
)
})?;
Ok(())
}
}
+20
View File
@@ -0,0 +1,20 @@
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
pub fn init(verbose: u8) {
let filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(default_filter(verbose)))
.expect("static tracing filter should parse");
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer())
.init();
}
fn default_filter(verbose: u8) -> &'static str {
match verbose {
0 => "wakey_control_plane=info",
1 => "wakey_control_plane=debug",
_ => "wakey_control_plane=trace",
}
}