what a plan! what an impl! this is ahh
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
pub use wakey_core::NeighborEntry as IpNeighLine;
|
||||
pub use wakey_core::NeighborState as NUDState;
|
||||
@@ -1 +0,0 @@
|
||||
pub use wakey_linux::dhcp::{load_mac_name_cache, read_dhcp_leases_with_names};
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pub mod protocol;
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,10 +63,17 @@ 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")?;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
@@ -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,
|
||||
}
|
||||
})),
|
||||
)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user