120 Commits
129 changed files with 11650 additions and 2059 deletions

No files matched your search

+16 -2
View File
@@ -1,5 +1,19 @@
[target.armv7-unknown-linux-musleabihf]
linker = "rust-lld"
[build]
target = "armv7-unknown-linux-musleabihf"
[target.armv7-unknown-linux-musleabihf]
linker = "rust-lld"
[alias]
cl = "c --target=armv7-unknown-linux-musleabihf"
bl = "b --target=armv7-unknown-linux-musleabihf"
tl = "t --target=armv7-unknown-linux-musleabihf"
cw = "c --target=x86_64-pc-windows-msvc"
bw = "b --target=x86_64-pc-windows-msvc"
tw = "t --target=x86_64-pc-windows-msvc"
# idk below
ldabr = "b -r --target=armv7-unknown-linux-musleabihf"
t = "test -- --nocapture --test-threads=1"
tdebug = "test -- --nocapture --test-threads=1 --show-output"
+10 -9
View File
@@ -9,7 +9,7 @@ on:
jobs:
build:
runs-on: [self-hosted, windows]
runs-on: [self-hosted, linux, fedora, wsl, release]
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -28,12 +28,12 @@ jobs:
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
key: linux-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build (armv7-musl)
shell: pwsh
run: |
cargo build --release --target armv7-unknown-linux-musleabihf
cargo build --release --target armv7-unknown-linux-musleabihf -p wakey -p wakey-agent
- name: Package rootfs tarball
shell: pwsh
@@ -46,10 +46,11 @@ jobs:
name: wakey-rootfs
path: dist/wakey-rootfs-*armv7-unknown-linux-musleabihf.tgz
- name: Publish Release to Gitea (API)
- name: Publish Release to Gitea
if: startsWith(github.ref, 'refs/tags/')
shell: pwsh
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
./scripts/ci_gitea_release.ps1 -Tag "$env:GITHUB_REF_NAME" -AssetPattern "dist/wakey-rootfs-*armv7-unknown-linux-musleabihf.tgz"
uses: actions/gitea-release-action@v1
with:
token: ${{ secrets.GITEA_TOKEN }}
files: |
dist/wakey-rootfs-*armv7-unknown-linux-musleabihf.tgz
tag_name: ${{ github.ref_name }}
+3 -5
View File
@@ -1,5 +1,3 @@
you can use block code as so:
```rust
hello_world!(println);
```
in the chat.
We work towards clean, modular, maintainable design, clear documentation... im not the best at this thing, my choices can be bad, so you help me.
DRY: spawn subagents.
+32
View File
@@ -0,0 +1,32 @@
## Plan: Control Plane App v1
Build an end-to-end, ops-ready v1 over 6+ weeks by reusing current wakey and wakey-core logic, keeping wakey-agent as outbound executor, and adding a dedicated control-plane server plus minimal operator UI.
**Steps**
1. Phase 1, contract baseline: finalize relay contract for command, result, error, request correlation, timeout, retry, and forward compatibility behavior.
2. Phase 1, boundary lock: keep execution in wakey service functions and keep domain DTOs in wakey-core while removing legacy HTTP/static compatibility code.
3. Phase 2, server skeleton: implement enrollment endpoint, agent registry, websocket acceptor, and request correlation map. Depends on step 1 and step 2.
4. Phase 2, relay core: implement command submission to connected agents, request_id correlation, timeout paths, and structured relay errors. Depends on step 3.
5. Phase 2, persistence and identity: durable agent records, enroll token lifecycle, and safe credential metadata. Parallel with step 4 after schema is stable.
6. Phase 3, operator surface: add API for agent inventory, health, command execution, and recent outcomes; add minimal UI for core operations. Depends on step 4 and step 5.
7. Phase 3, ops hardening: metrics, logs, audits, heartbeat liveness checks, and alert thresholds. Parallel with step 6.
8. Phase 4, deployment pipeline: add server build and deploy artifacts, environment templates, and rollback workflow. Depends on step 6 and step 7.
9. Phase 4, validation and soak: run enrollment-to-command end-to-end tests and disconnect/failure drills with multi-day soak. Depends on step 8.
**Relevant files to reuse**
- [wakey-agent/src/protocol.rs](wakey-agent/src/protocol.rs)
- [wakey-agent/src/session.rs](wakey-agent/src/session.rs)
- [wakey-agent/src/dispatch.rs](wakey-agent/src/dispatch.rs)
- [src/service/mod.rs](src/service/mod.rs)
- [wakey-core/src/model](wakey-core/src/model)
- [scripts/package_rootfs.ps1](scripts/package_rootfs.ps1)
- [.gitea/workflows/release.yml](.gitea/workflows/release.yml)
**Verification**
1. Contract tests for command and result serialization, request_id stability, and unknown frame tolerance.
2. Relay integration tests for enrollment, websocket auth flow, correlation, and timeout handling.
3. Security tests for token lifecycle and invalid credential rejection.
4. API tests for registry and command execution behavior.
5. Observability tests for heartbeat, reconnect counters, latency, and failure alerts.
6. Deployment tests for build, release, rollback rehearsal, and staging smoke checks.
7. On-device tests for enroll, procd lifecycle, reconnect, and remote command round trips.
+3 -2
View File
@@ -1,3 +1,4 @@
{
"rust-analyzer.cargo.target": "armv7-unknown-linux-musleabihf"
}
"rust-analyzer.cargo.target": "armv7-unknown-linux-musleabihf",
// "rust-analyzer.diagnostics.disabled": ["unlinked-file"]
}
+91
View File
@@ -0,0 +1,91 @@
# Wakey Checkpoint (2026-04-11)
## Snapshot
This checkpoint captures the current state after control-plane migration, logging hardening, config ergonomics, and state storage upgrades.
## What Is Done
- Legacy router-hosted HTTP/static layer removed from `wakey` crate.
- New `wakey-control-plane` crate is active for:
- enroll-token issuance
- agent enrollment
- connected-agent websocket registry
- command relay to agent
- `wakey-agent` is active for outbound websocket execution and local command dispatch.
## Logging + Telemetry
- High-signal logs were added across:
- control-plane API relay path
- control-plane websocket lifecycle
- control-plane state lifecycle
- agent command lifecycle, session lifecycle, and enrollment
- Correlated relay spans include command context (`agent_id`, `request_id`, `command`).
- Control-plane telemetry is config-driven:
- optional OTLP endpoint
- optional JSON logs
- fallback local logs when OTLP endpoint is not set
## Config Ergonomics
### Control-plane
- Config file support is wired (`/etc/wakey-control-plane/config.toml` by default).
- `serve` can read defaults from config file and CLI can override.
- New `init-config` command scaffolds a control-plane config file.
### Agent
- Existing `init-config` command scaffolds agent config.
- Enrollment can optionally signal reload of running daemon.
## State Storage Upgrade
- Control-plane state backend moved from JSON snapshot to embedded `sled` DB.
- Default state path changed to `/var/lib/wakey-control-plane/state.db`.
- Legacy JSON migration support exists:
- if a `.json` path is configured and DB is empty, tokens/agents are migrated into DB.
## Operator Commands
### Control-plane bootstrap
```sh
wakey-control-plane init-config
wakey-control-plane serve --config-file /etc/wakey-control-plane/config.toml
```
### Issue enroll token (live daemon path)
```sh
wakey-control-plane issue-enroll-token --public-url https://cp.example.com
```
### Agent bootstrap
```sh
wakey-agent enroll --server-url https://cp.example.com --enroll-token <token>
wakey-agent serve --config /etc/wakey-agent/config.toml
```
## Build Health
- Last verified passing:
- `cargo check --workspace`
- `cargo clippy --workspace`
## Known Tradeoffs / Follow-ups
- Reload semantics with `sled` are now mostly no-op for in-memory state (data is durable in DB).
- No dedicated state-inspection CLI command yet (suggestion: add `state-stats` command).
- OTLP configuration is currently control-plane focused; agent parity can be added if needed.
## Suggested Next Steps
1. Add a control-plane `state-stats` command to print DB path, agent count, token count.
2. Add symmetric telemetry config support in `wakey-agent` config file.
3. Add integration tests for:
- enroll + relay over websocket
- legacy JSON-to-sled migration path
- init-config command behavior and overrides
Generated
+2393 -314
View File
File diff suppressed because it is too large. Load diff
+38 -13
View File
@@ -1,23 +1,48 @@
[package]
name = "wakey"
version = "0.1.0"
version = "0.2.0-a1"
edition = "2024"
publish = ["gitea"]
[dependencies]
axum = { version = "0.8.4", features = ["macros"] }
axum-extra = { version = "0.10.1", features = ["query"] }
color-eyre = "0.6.5"
futures = "0.3.31"
macaddr = { version = "1.0.1", features = ["serde", "serde_std"] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.143"
serde_with = { version = "3.14.0", features = ["json"] }
strum = { version = "0.27.2", features = ["derive", "strum_macros"] }
thiserror = "2.0.16"
tokio = { version = "1.47.1", features = ["fs", "process", "rt-multi-thread"] }
urlencoding = "2.1.3"
anyhow = "1"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
clap = { version = "4", features = ["derive"] }
color-eyre = "0"
comfy-table = "7"
futures = "0"
macaddr = { version = "1", features = ["serde", "serde_std"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = [
"fs",
"process",
"rt-multi-thread",
"io-util",
"macros",
"time",
] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
[profile.release]
opt-level = "z"
strip = true
[workspace]
members = ["ipjs", "wakey-agent", "wakey-control-plane", "wakey-core", "wakey-linux"]
[dependencies.lda-ipjs]
path = "ipjs"
registry = "gitea"
version = "0"
[dependencies.wakey-core]
path = "wakey-core"
registry = "gitea"
version = "0"
[dependencies.wakey-linux]
path = "wakey-linux"
registry = "gitea"
version = "0"
+295 -12
View File
@@ -1,20 +1,303 @@
# wakey
router shit
`wakey` is a Wake-on-LAN and LAN-observability tool for a Linux router.
## usage
It started as a small web UI running on-device. It is now being reshaped into a
service-first project with:
- Build (Windows host OK):
- cargo build --release --target armv7-unknown-linux-musleabihf
- Run on a Linux box/router:
- copy target/armv7-unknown-linux-musleabihf/release/wakey to the device
- chmod +x wakey && ./wakey
- Install via release asset (OpenWrt):
- wget -O- `https://your.gitea/owner/repo/releases/download/vX.Y.Z/wakey-rootfs-vX.Y.Z-armv7-unknown-linux-musleabihf.tgz` | tar -xz -C /
- /etc/init.d/wakey enable && /etc/init.d/wakey start
- a reusable core model
- a Linux/OpenWrt adapter layer
- a CLI for operators
- an outbound agent plus control-plane model
Optional updater (fetch latest automatically on OpenWrt):
## What it does
`wakey` currently focuses on a small set of router-side jobs:
- inspect LAN neighbor state
- read DHCP lease data
- merge those facts into a higher-level device inventory
- list useful interface/broadcast information
- send Wake-on-LAN packets
In practice that means commands like:
```sh
WAKEY_HOST=git.ldlda.com WAKEY_OWNER=lda WAKEY_REPO=wakey sh /etc/ldlda_help/update_wakey.sh
wakey status bedroom-pc
wakey leases --include-state
wakey devs
wakey wake bedroom-pc
wakey wake --mac aa:bb:cc:dd:ee:ff
```
## Workspace layout
- `wakey-core`
- shared types and parsing
- device, neighbor, DHCP, interface, and wake models
- `wakey-linux`
- Linux/OpenWrt adapter
- DHCP lease loading, interface summaries, neighbor lookup, WoL sending
- `wakey`
- service layer and operator CLI
- `wakey-agent`
- outbound router daemon, enrollment, websocket command execution
- `wakey-control-plane`
- enrollment endpoint, connected-agent registry, command relay API
- `ipjs`
- typed wrappers around Linux `ip -j ...` data
- JSON-first, with optional experimental netlink backends
## Current architecture
Inside the `wakey` crate:
- `src/service`
- the real use-case layer
- status, leases, inventory, interfaces, wake, and query resolution
The long-term direction is:
- keep the service layer stable
- keep the local CLI stable for operators
- run remote control via outbound agent + control-plane relay
## Future direction
The project now uses:
- `wakey` for local operator workflows and shared service behavior
- `wakey-agent` for outbound enrollment and websocket execution
- `wakey-control-plane` for enrollment, registry, and command relay
## Logging and troubleshooting
Both daemons emit structured tracing logs to stderr. If you are not seeing
enrollment/command activity, run with increased verbosity.
Control-plane also supports a config file at
`/etc/wakey-control-plane/config.toml` (override with `--config-file`) so you
can persist telemetry settings instead of passing flags.
You can scaffold this file with:
```sh
wakey-control-plane init-config
```
Or bootstrap once during serve (writes only if config is missing):
```sh
wakey-control-plane serve --bootstrap-config
```
Inspect current persisted state:
```sh
wakey-control-plane state-stats
```
List/revoke enroll tokens from CLI:
```sh
wakey-control-plane list-enroll-tokens --include-expired
wakey-control-plane revoke-enroll-token --token enr-...
```
Machine-readable output is available:
```sh
wakey-control-plane list-enroll-tokens --include-expired --json
wakey-control-plane state-stats --json
```
Example:
```toml
data_dir = "/var/lib/wakey-control-plane"
bind = "0.0.0.0:8080"
public_url = "https://cp.example.com"
state_file = "state.db"
pid_file = "wakey-control-plane.pid"
command_timeout_ms = 30000
enroll_token_ttl_seconds = 86400
[telemetry]
otlp_endpoint = "http://127.0.0.1:4317"
service_name = "wakey-control-plane"
json_logs = false
```
If `telemetry.otlp_endpoint` is omitted, logs still work normally and only local
structured logs are emitted.
State is persisted in an embedded `sled` database (default
`/var/lib/wakey-control-plane/state.db`).
Relative paths in config are resolved under `data_dir`.
Enroll tokens are now expiring and revocable. Issuance returns `expires_at_unix`.
### Quick start
Control-plane:
```sh
wakey-control-plane -v serve --bind 0.0.0.0:8787 --public-url https://cp.example.com
```
Agent:
```sh
wakey-agent -v serve --config /etc/wakey-agent/config.toml
```
### Fine-grained log filters
Use `RUST_LOG` when you want to focus on websocket/API internals.
```sh
RUST_LOG=wakey_control_plane=debug,wakey_agent=debug wakey-control-plane serve
RUST_LOG=wakey_agent=debug wakey-agent serve
```
### What you should see
During registration/enroll:
- control-plane: `issued enroll token`, then `agent enrollment accepted`
- agent: `starting agent enrollment`, then `agent enrollment succeeded and config was written`
During live connectivity:
- control-plane: `agent websocket upgraded`, `agent authenticated`, `agent disconnected`
- agent: `connecting agent websocket`, `agent websocket session authenticated`, `heartbeat sent` (debug)
During command relay:
- control-plane: `dispatching command to agent`
- agent: `received command from control-plane`, then `command execution completed` (or `command dispatch failed`)
- control-plane: `agent command completed` (or timeout/error warnings)
During daemon control/state operations:
- control-plane: `wrote control-plane pid file`, `saved control-plane store`, `reloaded control-plane store from disk`
- agent: `wrote wakey-agent pid file`, `sending wakey-agent reload signal`
Control-plane admin API includes token management endpoints:
- `POST /api/v1/control/enroll-token?ttl_seconds=<n>`
- `GET /api/v1/control/enroll-tokens?include_expired=true|false`
- `DELETE /api/v1/control/enroll-tokens/{token}`
If commands still appear silent, verify both processes are running with `-v`
and that `RUST_LOG` is not overriding to a stricter level.
## CLI
`wakey` is usable as a local/operator CLI.
### Status
Show device status rows using a free-form selector:
```sh
wakey status bedroom-pc
```
Or use explicit filters:
```sh
wakey status --dev br-lan --nud reachable
wakey status --mac aa:bb:cc:dd:ee:ff
wakey status --json
```
### Leases
Show DHCP leases:
```sh
wakey leases
wakey leases --include-state
wakey leases --include-state --json
```
### Wake
Query mode:
```sh
wakey wake bedroom-pc
```
Explicit/manual mode:
```sh
wakey wake --mac aa:bb:cc:dd:ee:ff
wakey wake --mac aa:bb:cc:dd:ee:ff --ip 192.168.1.255
wakey wake --mac aa:bb:cc:dd:ee:ff --json
```
Rules:
- query mode and explicit `--mac/--ip` mode are mutually exclusive
- `--ip` requires `--mac`
- `--mac` without `--ip` fans out to interface broadcast targets
### Interfaces
Show condensed interface summaries:
```sh
wakey devs
wakey devs br-lan
wakey devs --up
wakey devs --json
```
## Tests
This repo has two useful testing modes:
- local compile checks
- live on-device tests against the real router/runtime environment
### Local
```sh
cargo check
cargo test --no-run
cargo clippy --all-targets --all-features -- -D warnings
```
### On-device
Some integration tests are intentionally `#[ignore]` because they use real
router state. Use the PowerShell helper:
```powershell
./scripts/test_remote.ps1 -Package wakey -BinaryFilter integration_live_services -Ignored -NoCapture
./scripts/test_remote.ps1 -Package wakey -BinaryFilter integration_inventory -Ignored -NoCapture
```
You can further narrow execution with `-Filter` to select individual Rust test
functions inside a test binary.
## Build target
This project is primarily aimed at an OpenWrt/Linux ARM router target. The
workspace is commonly built for:
```text
armv7-unknown-linux-musleabihf
```
Some crates and tests are Linux-specific by design.
## Notes
- `ipjs` is JSON-first by default; experimental netlink paths exist where they
are worth keeping.
- the current web client is temporary and kept alive through explicit
compatibility mapping
- the service layer is the part intended to survive the migration
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "lda-ipjs"
description = "ip -j show schemas"
version = "0.0.3"
edition = "2024"
publish = ["gitea"]
[features]
default = []
experimental-nl = ["dep:rtnetlink"]
[dependencies]
macaddr = { version = "1", features = ["serde", "serde_std"] }
strum = { version = "0", features = ["derive", "strum_macros"] }
serde_json = "1"
serde = { version = "1", features = ["derive"] }
serde_with = { version = "3", features = ["json"] }
thiserror = "2"
anyhow = "1"
tokio = { version = "1", features = [
"fs",
"process",
"rt-multi-thread",
"io-util",
"macros",
] }
futures = "0"
[target.'cfg(unix)'.dependencies]
rtnetlink = { version = "0", optional = true }
+60
View File
@@ -0,0 +1,60 @@
# lda-ipjs
Typed Rust wrappers around Linux `ip -j ...` output, with optional experimental rtnetlink backends.
## Purpose
`lda-ipjs` exists so `wakey` can ask Linux networking questions in typed Rust instead of:
- parsing plain-text shell output
- scattering `tokio::process::Command::new("ip")` everywhere
- mixing product logic with Linux networking trivia
## Current contract
Stable default behavior:
- `address::get(...)` uses JSON (`ip -j address show`)
- `link::get(...)` uses JSON (`ip -j link show`)
- `neighbor::get(...)` uses JSON (`ip -j neigh show`)
Optional experimental behavior:
- feature: `experimental-nl`
- enables rtnetlink-backed implementations
- intended for places where one-pass kernel queries are materially better than repeated `ip -j` calls
This means the public API is:
- `get(...)` for the default backend
- `get_with_backend(Backend::Json | Backend::Netlink)` when backend choice matters
## Modules
- `subcommands::address`
- typed address/interface-address data
- good place for subnet/broadcast derivation later
- `subcommands::link`
- typed link/interface data
- useful for `ifindex -> ifname` mapping and interface metadata
- `subcommands::neighbor`
- typed neighbor-table data
- currently the most useful experimental netlink surface
## Backend policy
JSON is the normal path.
Use netlink only when:
- the call is hot enough to matter
- repeated shelling out is obviously wasteful
- the netlink implementation is at least as coherent as the JSON one
Today that mainly applies to `neighbor::nl`.
## Relationship to wakey
`wakey` is the product.
`lda-ipjs` is a Linux networking adapter crate underneath it.
+3
View File
@@ -0,0 +1,3 @@
trait IpCommand {
}
View File
Whitespace-only changes.
+19
View File
@@ -0,0 +1,19 @@
//! low
//!
//! # lda-ipjs
//!
//! this package will represent all my needs with the all the subcommands of ip -j.
//!
//! ## what i need
//!
//! ```console
//! ip -j neigh show
//! ```
//!
//! i also need to see devices and idk MAYBE maybe not MAYBE UHHHHHH maybe broadcast
//!
//! LOWK if this were to be calls to kernel or some bullshit then PLEASE because doing ts parsing its hell cuh
pub mod subcommands;
pub mod utils;
// i want a generalized way to build and call
+46
View File
@@ -0,0 +1,46 @@
// ip address [ show [ dev IFNAME ] [ scope SCOPE-ID ] [ master DEVICE ]
// [ type TYPE ] [ to PREFIX ] [ FLAG-LIST ]
// [ label LABEL ] [up] [ vrf NAME ] ]
// fuck is this mean
// do i need all this? do i need anything but `ip -j a show dev br-lan`?
// TYPE := { vlan | veth | vcan | vxcan | dummy | ifb | macvlan | macvtap |
// bridge | bond | ipoib | ip6tnl | ipip | sit | vxlan | lowpan |
// gre | gretap | erspan | ip6gre | ip6gretap | ip6erspan | vti |
// nlmon | can | bond_slave | ipvlan | geneve | bridge_slave |
// hsr | macsec | netdevsim }
// FLAG-LIST := [ FLAG-LIST ] FLAG
// FLAG := [ permanent | dynamic | secondary | primary |
// [-]tentative | [-]deprecated | [-]dadfailed | temporary |
// CONFFLAG-LIST ]
// CONFFLAG-LIST := [ CONFFLAG-LIST ] CONFFLAG
// CONFFLAG := [ home | nodad | mngtmpaddr | noprefixroute | autojoin ]
// prefix seems to be a cidr. both 6 and 4 works. idfk dog
use std::{io, process::Output};
use anyhow::Context;
use super::AddrOutput;
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<AddrOutput>> {
let output = _get(dev).await.context("Can not run command")?;
if !output.status.success() {
anyhow::bail!(String::from_utf8_lossy(&output.stderr).into_owned());
}
serde_json::from_slice(&output.stdout).context("Deserialize failed")
}
pub async fn _get(dev: Option<&str>) -> io::Result<Output> {
let mut cmd = tokio::process::Command::new("ip");
cmd.args(["-j", "address", "show"]);
if let Some(d) = dev {
cmd.args(["dev", d]);
}
cmd.output().await
}
+241
View File
@@ -0,0 +1,241 @@
//! Typed wrappers for `ip -j address show`.
//!
//! This module is intentionally close to the Linux output shape while still
//! tightening a few fields into more useful Rust types.
pub mod json;
#[cfg(all(unix, feature = "experimental-nl"))]
pub mod nl;
pub use crate::subcommands::Backend;
use crate::utils::serialize::mac::option_mac;
use macaddr::MacAddr;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
use std::net::{IpAddr, Ipv4Addr};
use crate::subcommands::link::OperState;
/// One interface row from `ip -j address show`.
#[derive(Serialize, Debug, Deserialize)]
pub struct AddrOutput {
pub ifindex: u32,
pub ifname: String,
/// Interface operational state.
pub operstate: OperState,
#[serde(with = "option_mac", default)]
pub address: Option<MacAddr>,
/// Per-address entries attached to this interface.
#[serde(default)]
pub addr_info: Vec<AddrInfo>,
}
/// One address entry nested under an interface row.
#[derive(Debug, Deserialize, Serialize)]
pub struct AddrInfo {
pub family: Option<AddressFamily>,
#[serde(flatten, default)]
pub cidr: InterfaceCidr,
pub broadcast: Option<Ipv4Addr>,
pub scope: Option<String>,
pub label: Option<String>,
}
/// Address family used by `ip address` output.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddressFamily {
Inet,
Inet6,
Other,
}
impl AddressFamily {
pub fn parse_lossy(value: &str) -> Self {
match value.to_ascii_lowercase().as_str() {
"inet" => Self::Inet,
"inet6" => Self::Inet6,
_ => Self::Other,
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Inet => "inet",
Self::Inet6 => "inet6",
Self::Other => "other",
}
}
}
impl Serialize for AddressFamily {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for AddressFamily {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Ok(Self::parse_lossy(&value))
}
}
/// Raw parsed local address plus prefix length.
///
/// This is still a source-shaped type; callers that need a guaranteed usable
/// CIDR should validate that both fields are present.
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
pub struct InterfaceCidr {
pub local: Option<IpAddr>,
pub prefixlen: Option<u8>,
}
impl InterfaceCidr {
/// Return whether both parts needed for a usable CIDR are present.
pub fn is_complete(&self) -> bool {
self.local.is_some() && self.prefixlen.is_some()
}
/// Return a validated complete CIDR when both fields are present.
pub fn complete(&self) -> Option<CompleteInterfaceCidr> {
Some(CompleteInterfaceCidr {
local: self.local?,
prefixlen: self.prefixlen?,
})
}
/// Format the CIDR as `addr/prefixlen` when both fields are present.
pub fn to_cidr_string(&self) -> Option<String> {
self.complete().map(|cidr| cidr.to_string())
}
}
/// Validated interface CIDR with both local address and prefix length present.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CompleteInterfaceCidr {
pub local: IpAddr,
pub prefixlen: u8,
}
impl fmt::Display for CompleteInterfaceCidr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}", self.local, self.prefixlen)
}
}
impl AddrInfo {
/// Return the parsed local IP address when present.
pub fn local_addr(&self) -> Option<IpAddr> {
self.cidr.local
}
/// Return the parsed prefix length when present.
pub fn prefixlen(&self) -> Option<u8> {
self.cidr.prefixlen
}
/// Return whether this row is IPv4.
pub fn is_ipv4(&self) -> bool {
matches!(self.family, Some(AddressFamily::Inet))
}
/// Return whether this row is IPv6.
pub fn is_ipv6(&self) -> bool {
matches!(self.family, Some(AddressFamily::Inet6))
}
}
impl AddrOutput {
/// Iterate IPv4 address entries.
pub fn ipv4_addrs(&self) -> impl Iterator<Item = &AddrInfo> {
self.addr_info.iter().filter(|info| info.is_ipv4())
}
/// Iterate IPv6 address entries.
pub fn ipv6_addrs(&self) -> impl Iterator<Item = &AddrInfo> {
self.addr_info.iter().filter(|info| info.is_ipv6())
}
}
/// Fetch address data using the default backend.
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<AddrOutput>> {
get_with_backend(Backend::Json, dev).await
}
/// Fetch address data using an explicit backend.
pub async fn get_with_backend(
backend: Backend,
dev: Option<&str>,
) -> anyhow::Result<Vec<AddrOutput>> {
match backend {
Backend::Json => json::get(dev).await,
#[cfg(all(unix, feature = "experimental-nl"))]
Backend::Netlink => nl::get(dev).await,
}
}
#[cfg(test)]
mod tests {
use std::net::{IpAddr, Ipv4Addr};
use super::{AddrInfo, AddrOutput, AddressFamily, InterfaceCidr};
use crate::subcommands::link::OperState;
#[test]
fn address_family_parses_known_values() {
assert_eq!(AddressFamily::parse_lossy("inet"), AddressFamily::Inet);
assert_eq!(AddressFamily::parse_lossy("INET6"), AddressFamily::Inet6);
assert_eq!(AddressFamily::parse_lossy("weird"), AddressFamily::Other);
}
#[test]
fn addr_info_deserializes_typed_ip_fields() {
let info: AddrInfo = serde_json::from_str(
r#"{"family":"inet","local":"192.168.1.1","prefixlen":24,"broadcast":"192.168.1.255","scope":"global"}"#,
)
.expect("addr_info json should deserialize");
assert_eq!(info.family, Some(AddressFamily::Inet));
assert_eq!(
info.local_addr(),
Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)))
);
assert_eq!(info.prefixlen(), Some(24));
assert_eq!(info.broadcast, Some(Ipv4Addr::new(192, 168, 1, 255)));
assert!(info.cidr.is_complete());
assert_eq!(
info.cidr.to_cidr_string().as_deref(),
Some("192.168.1.1/24")
);
}
#[test]
fn addr_output_deserializes_typed_operstate() {
let output: AddrOutput = serde_json::from_str(
r#"{"ifindex":2,"ifname":"br-lan","operstate":"UP","address":"aa:bb:cc:dd:ee:ff","addr_info":[]}"#,
)
.expect("addr_output json should deserialize");
assert_eq!(output.operstate, OperState::Up);
}
#[test]
fn interface_cidr_complete_formats() {
let cidr = InterfaceCidr {
local: Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))),
prefixlen: Some(16),
};
let complete = cidr.complete().expect("cidr should be complete");
assert_eq!(complete.to_string(), "10.0.0.1/16");
}
}
+110
View File
@@ -0,0 +1,110 @@
//! i said i aint doing ts no more why am i still here
#![cfg(unix)]
use std::collections::BTreeMap;
use futures::TryStreamExt;
use rtnetlink::Handle;
use rtnetlink::packet_route::{AddressFamily, address::AddressAttribute};
use crate::subcommands::link::LinkOutput;
use crate::subcommands::{
address::{AddrInfo, AddrOutput, AddressFamily as IpAddressFamily, InterfaceCidr},
link,
};
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<AddrOutput>> {
let (conn, handle, _) = rtnetlink::new_connection()?;
tokio::spawn(conn);
get_with_handle(&handle, dev).await
}
pub async fn get_with_handle(
handle: &Handle,
dev: Option<&str>,
) -> anyhow::Result<Vec<AddrOutput>> {
let links = link::nl::get_with_handle(handle, dev).await?;
let link_by_index: BTreeMap<u32, LinkOutput> =
links.into_iter().map(|link| (link.ifindex, link)).collect();
let mut address = handle.address().get();
if let Some(dev) = dev {
if let Some(index) = link_by_index
.values()
.find(|link| link.ifname == dev)
.map(|link| link.ifindex)
{
address = address.set_link_index_filter(index);
} else {
return Ok(Vec::new());
}
}
let mut stream = address.execute();
let mut addr_info_by_index: BTreeMap<u32, Vec<AddrInfo>> = BTreeMap::new();
while let Some(msg) = stream.try_next().await? {
if !matches!(
msg.header.family,
AddressFamily::Inet | AddressFamily::Inet6
) {
continue;
}
let family = match msg.header.family {
AddressFamily::Inet => Some(IpAddressFamily::Inet),
AddressFamily::Inet6 => Some(IpAddressFamily::Inet6),
_ => None,
};
let prefixlen = Some(msg.header.prefix_len);
let scope = Some(format!("{:?}", msg.header.scope).to_lowercase());
let mut local = None;
let mut broadcast = None;
let mut label = None;
for attr in msg.attributes {
match attr {
AddressAttribute::Address(addr) | AddressAttribute::Local(addr) => {
if local.is_none() {
local = addr.to_string().parse().ok();
}
}
AddressAttribute::Broadcast(addr) => {
broadcast = addr.to_string().parse().ok();
}
AddressAttribute::Label(name) => {
label = Some(name);
}
_ => {}
}
}
addr_info_by_index
.entry(msg.header.index)
.or_default()
.push(AddrInfo {
family,
cidr: InterfaceCidr {
local,
prefixlen,
},
broadcast,
scope,
label,
});
}
let out = link_by_index
.into_values()
.map(|link| AddrOutput {
ifindex: link.ifindex,
ifname: link.ifname,
operstate: link.operstate.unwrap_or(link::OperState::Unknown),
address: link.address,
addr_info: addr_info_by_index.remove(&link.ifindex).unwrap_or_default(),
})
.collect();
Ok(out)
}
+26
View File
@@ -0,0 +1,26 @@
use std::{io, process::Output};
use anyhow::Context;
use super::LinkOutput;
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<LinkOutput>> {
let output = _get(dev).await.context("Can not run command")?;
if !output.status.success() {
anyhow::bail!(String::from_utf8_lossy(&output.stderr).into_owned());
}
serde_json::from_slice(&output.stdout).context("Deserialize failed")
}
pub async fn _get(dev: Option<&str>) -> io::Result<Output> {
let mut cmd = tokio::process::Command::new("ip");
cmd.args(["-j", "link", "show"]);
if let Some(d) = dev {
cmd.arg(d);
}
cmd.output().await
}
+153
View File
@@ -0,0 +1,153 @@
//! Typed wrappers for `ip -j link show`.
pub mod json;
#[cfg(all(unix, feature = "experimental-nl"))]
pub mod nl;
pub use crate::subcommands::Backend;
use crate::utils::serialize::mac::option_mac;
use macaddr::MacAddr;
#[cfg(all(unix, feature = "experimental-nl"))]
use rtnetlink::packet_route::link::State as NetlinkOperState;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// One interface row from `ip -j link show`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinkOutput {
pub ifindex: u32,
pub ifname: String,
#[serde(default)]
pub operstate: Option<OperState>,
#[serde(default, with = "option_mac")]
pub address: Option<MacAddr>,
}
/// Operational state of a Linux network interface.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OperState {
Up,
Down,
Unknown,
Dormant,
LowerLayerDown,
NotPresent,
Testing,
Other,
}
impl OperState {
pub fn parse_lossy(value: &str) -> Self {
match value.to_ascii_uppercase().as_str() {
"UP" => Self::Up,
"DOWN" => Self::Down,
"UNKNOWN" => Self::Unknown,
"DORMANT" => Self::Dormant,
"LOWERLAYERDOWN" | "LOWER_LAYER_DOWN" | "LOWERLAYER_DOWN" => Self::LowerLayerDown,
"NOTPRESENT" | "NOT_PRESENT" => Self::NotPresent,
"TESTING" => Self::Testing,
_ => Self::Other,
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Up => "UP",
Self::Down => "DOWN",
Self::Unknown => "UNKNOWN",
Self::Dormant => "DORMANT",
Self::LowerLayerDown => "LOWERLAYERDOWN",
Self::NotPresent => "NOTPRESENT",
Self::Testing => "TESTING",
Self::Other => "OTHER",
}
}
pub fn is_up(self) -> bool {
matches!(self, Self::Up)
}
}
impl Serialize for OperState {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for OperState {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Ok(Self::parse_lossy(&value))
}
}
#[cfg(all(unix, feature = "experimental-nl"))]
impl From<NetlinkOperState> for OperState {
fn from(value: NetlinkOperState) -> Self {
match value {
NetlinkOperState::Up => Self::Up,
NetlinkOperState::Down => Self::Down,
NetlinkOperState::Unknown => Self::Unknown,
NetlinkOperState::Dormant => Self::Dormant,
NetlinkOperState::LowerLayerDown => Self::LowerLayerDown,
NetlinkOperState::NotPresent => Self::NotPresent,
NetlinkOperState::Testing => Self::Testing,
_ => Self::Other,
}
}
}
/// Fetch link rows using the default backend.
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<LinkOutput>> {
get_with_backend(Backend::Json, dev).await
}
/// Fetch link rows using an explicit backend.
pub async fn get_with_backend(
backend: Backend,
dev: Option<&str>,
) -> anyhow::Result<Vec<LinkOutput>> {
match backend {
Backend::Json => json::get(dev).await,
#[cfg(all(unix, feature = "experimental-nl"))]
Backend::Netlink => nl::get(dev).await,
}
}
#[cfg(test)]
mod tests {
use super::{LinkOutput, OperState};
#[test]
fn operstate_parses_json_and_netlink_spellings() {
assert_eq!(OperState::parse_lossy("UP"), OperState::Up);
assert_eq!(OperState::parse_lossy("Up"), OperState::Up);
assert_eq!(
OperState::parse_lossy("LOWERLAYERDOWN"),
OperState::LowerLayerDown
);
assert_eq!(
OperState::parse_lossy("LowerLayerDown"),
OperState::LowerLayerDown
);
}
#[test]
fn link_output_deserializes_typed_operstate() {
let link: LinkOutput = serde_json::from_str(
r#"{"ifindex":1,"ifname":"eth0","operstate":"UP","address":"aa:bb:cc:dd:ee:ff"}"#,
)
.expect("link json should deserialize");
assert_eq!(link.operstate, Some(OperState::Up));
assert_eq!(
link.address.map(|mac| mac.to_string()).as_deref(),
Some("aa:bb:cc:dd:ee:ff")
);
}
}
+59
View File
@@ -0,0 +1,59 @@
#![cfg(unix)]
use futures::TryStreamExt;
use rtnetlink::Handle;
use rtnetlink::packet_route::link::LinkAttribute;
use super::LinkOutput;
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<LinkOutput>> {
let (conn, handle, _) = rtnetlink::new_connection()?;
tokio::spawn(conn);
get_with_handle(&handle, dev).await
}
pub async fn get_with_handle(
handle: &Handle,
dev: Option<&str>,
) -> anyhow::Result<Vec<LinkOutput>> {
let mut req = handle.link().get();
if let Some(dev) = dev {
req = req.match_name(dev.to_owned());
}
let mut stream = req.execute();
let mut out = Vec::new();
while let Some(link) = stream.try_next().await? {
let mut ifname = None;
let mut operstate = None;
let mut address = None;
for attr in link.attributes {
match attr {
LinkAttribute::IfName(name) => ifname = Some(name),
LinkAttribute::Address(bytes) => {
address = match bytes.len() {
6 => bytes.first_chunk::<6>().map(|&b| macaddr::MacAddr::from(b)),
8 => bytes.first_chunk::<8>().map(|&b| macaddr::MacAddr::from(b)),
_ => None,
}
}
LinkAttribute::OperState(state) => operstate = Some(state.into()),
_ => {}
}
}
if let Some(ifname) = ifname {
out.push(LinkOutput {
ifindex: link.header.index,
ifname,
operstate,
address,
});
}
}
Ok(out)
}
+10
View File
@@ -0,0 +1,10 @@
pub mod address;
pub mod link;
pub mod neighbor;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Backend {
Json,
#[cfg(all(unix, feature = "experimental-nl"))]
Netlink,
}
+60
View File
@@ -0,0 +1,60 @@
//! idk what to put here
use std::{io, net::IpAddr, process::Output};
use anyhow::{Context, bail};
use super::{NUDState, NeighborItem};
// loose translation of [wakey::utils::query::macs::get_mac]
// i think ill write tokio::process every time tho (for this if let thing) because iterate through all ts youll have to as str and all the hooplas.
// it all turns to live osstr tho so ts just for my own sanity
// thiserror? anyhow
// what is vro sayin
// ahh. instead of using [wakey::utils::cmd::exec_command] which is ass we jus write everything out. so i dont have to .as_str() so often.
pub async fn get(
ip: Option<IpAddr>,
dev: Option<&str>,
nud: &[NUDState],
) -> anyhow::Result<Vec<NeighborItem>> {
let output = _get(ip, dev, nud).await.context("Can not run command")?;
if !output.status.success() {
bail!(String::from_utf8_lossy(&output.stderr).into_owned())
} else {
let mut fuckass: Vec<NeighborItem> =
serde_json::from_slice(&output.stdout).context("Deserialize failed")?;
// i hate ts.
if let Some(dev) = dev {
for item in &mut fuckass {
if item.dev.is_none() {
item.dev = Some(dev.to_owned());
}
}
};
Ok(fuckass)
}
}
pub async fn _get(ip: Option<IpAddr>, dev: Option<&str>, nud: &[NUDState]) -> io::Result<Output> {
let mut cmd = tokio::process::Command::new("ip");
cmd.args(["-j", "neigh", "show"]);
if let Some(ip) = ip {
// cmd.arg("to");
cmd.arg(ip.to_canonical().to_string());
};
if let Some(dev) = dev {
cmd.args(["dev", dev]);
}
for nud in nud {
cmd.arg("nud");
cmd.arg(nud.to_string());
}
cmd.output().await
}
+107
View File
@@ -0,0 +1,107 @@
//! Typed wrappers for `ip -j neigh show`.
pub mod json;
#[cfg(all(unix, feature = "experimental-nl"))]
pub mod nl;
pub use crate::subcommands::Backend;
use crate::utils::serialize::mac::option_mac;
use std::net::IpAddr;
use macaddr::MacAddr;
use serde::{Deserialize, Serialize};
use strum::{Display, EnumString};
/// Structured neighbor query input matching the common `ip neigh` flags.
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
pub struct NeighborInput {
/// Destination address filter. The optional `to` keyword in CLI form is implicit here.
pub to: Option<IpAddr>,
/// Interface-name filter.
pub dev: Option<String>,
/// Neighbor-state filters.
pub nud: Vec<NUDState>,
}
/// Linux neighbor reachability states.
#[derive(
Debug, PartialEq, Eq, EnumString, Display, Clone, Copy, Hash, Serialize, Deserialize, Default,
)]
#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
#[serde(rename_all = "UPPERCASE")]
pub enum NUDState {
/// the neighbour entry is valid forever and can
/// be only be removed administratively.
Permanent,
/// the neighbour entry is valid. No attempts to
/// validate this entry will be made but it can
/// be removed when its lifetime expires.
Noarp,
/// the neighbour entry is valid until the
/// reachability timeout expires.
Reachable,
/// the neighbour entry is valid but suspicious.
/// This option to ip neigh does not change the
/// neighbour state if it was valid and the
/// address is not changed by this command.
Stale,
/// this is a pseudo state used when initially
/// creating a neighbour entry or after trying to
/// remove it before it becomes free to do so.
#[default]
None,
/// the neighbour entry has not (yet) been
/// validated/resolved.
Incomplete,
/// neighbor entry validation is currently
/// delayed.
Delay,
/// neighbor is being probed.
Probe,
/// max number of probes exceeded without
/// success, neighbor validation has ultimately
/// failed.
Failed,
Other(u16),
}
/// One neighbor row from `ip -j neigh show`.
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
pub struct NeighborItem {
#[serde(rename(deserialize = "dst"))]
pub ip: IpAddr,
#[serde(default)]
pub dev: Option<String>,
#[serde(with = "option_mac", default, rename(deserialize = "lladdr"))]
pub mac: Option<MacAddr>,
#[serde(default)]
pub state: Vec<NUDState>,
}
/// Fetch neighbor rows using the default backend.
pub async fn get(
ip: Option<IpAddr>,
dev: Option<&str>,
nud: &[NUDState],
) -> anyhow::Result<Vec<NeighborItem>> {
get_with_backend(Backend::Json, ip, dev, nud).await
}
/// Fetch neighbor rows using an explicit backend.
pub async fn get_with_backend(
backend: Backend,
ip: Option<IpAddr>,
dev: Option<&str>,
nud: &[NUDState],
) -> anyhow::Result<Vec<NeighborItem>> {
match backend {
Backend::Json => json::get(ip, dev, nud).await,
#[cfg(all(unix, feature = "experimental-nl"))]
Backend::Netlink => {
let ips: Vec<IpAddr> = ip.into_iter().collect();
let devs: Vec<&str> = dev.into_iter().collect();
nl::get(&ips, &devs, nud, &[]).await
}
}
}
+139
View File
@@ -0,0 +1,139 @@
//! rtnetlink-based neighbor table query. One syscall, filter in userspace.
#![cfg(unix)]
use std::{
collections::{HashMap, HashSet},
net::IpAddr,
};
use futures::TryStreamExt;
use macaddr::MacAddr;
use rtnetlink::Handle;
use rtnetlink::packet_route::{
AddressFamily,
neighbour::{NeighbourAddress, NeighbourAttribute, NeighbourState},
};
use super::{NUDState, NeighborItem};
use crate::subcommands::link;
/// Fetch neighbors via rtnetlink. Empty slice = no filter (match all).
/// Non-empty slice = match ANY in the set.
// https://github.com/rust-netlink/rtnetlink/blob/main/examples/get_neighbours.rs
pub async fn get(
ips: &[IpAddr],
devs: &[impl AsRef<str>],
nuds: &[NUDState],
macs: &[MacAddr],
) -> anyhow::Result<Vec<NeighborItem>> {
let (conn, handle, _) = rtnetlink::new_connection()?;
tokio::spawn(conn);
get_with_handle(&handle, ips, devs, nuds, macs).await
}
pub async fn get_with_handle(
handle: &Handle,
ips: &[IpAddr],
devs: &[impl AsRef<str>],
nuds: &[NUDState],
macs: &[MacAddr],
) -> anyhow::Result<Vec<NeighborItem>> {
// Build filter sets (empty = match all)
let ip_set: HashSet<IpAddr> = ips.iter().copied().collect();
let dev_set: HashSet<&str> = devs.iter().map(AsRef::as_ref).collect();
let nud_set: HashSet<&NUDState> = nuds.iter().collect();
let mac_set: HashSet<MacAddr> = macs.iter().copied().collect();
// Prefetch all links once; link lookups were the ugliest and most expensive part.
let ifname_cache: HashMap<u32, String> = link::nl::get_with_handle(handle, None)
.await?
.into_iter()
.map(|link| (link.ifindex, link.ifname))
.collect();
let mut neighbor_data = handle.neighbours().get().execute();
let mut result = vec![];
'row: while let Some(msg) = neighbor_data.try_next().await? {
// Only IPv4/IPv6, skip NOARP
if !matches!(
msg.header.family,
AddressFamily::Inet | AddressFamily::Inet6
) || matches!(msg.header.state, NeighbourState::Noarp)
{
continue 'row;
}
let state: NUDState = msg.header.state.try_into().unwrap_or_default();
let mut ip = None;
let mut mac = None;
for attr in msg.attributes {
match attr {
NeighbourAttribute::Destination(addr) => match addr {
NeighbourAddress::Inet(v4) => ip = Some(IpAddr::from(v4)),
NeighbourAddress::Inet6(v6) => ip = Some(IpAddr::from(v6)),
_ => continue 'row,
},
NeighbourAttribute::LinkLocalAddress(bytes) => {
mac = match bytes.len() {
6 => bytes.first_chunk::<6>().map(|&b| MacAddr::from(b)),
8 => bytes.first_chunk::<8>().map(|&b| MacAddr::from(b)),
_ => continue 'row,
}
}
_ => {}
}
}
// Resolve ifindex -> name (cached)
let dev = ifname_cache.get(&msg.header.ifindex).cloned();
let (Some(ip), Some(dev)) = (ip, dev) else {
continue 'row;
};
// Apply filters (empty set = match all)
if !ip_set.is_empty() && !ip_set.contains(&ip) {
continue 'row;
}
if !dev_set.is_empty() && !dev_set.contains(dev.as_str()) {
continue 'row;
}
if !nud_set.is_empty() && !nud_set.contains(&state) {
continue 'row;
}
if !mac_set.is_empty() && !mac.is_some_and(|m| mac_set.contains(&m)) {
continue 'row;
}
result.push(NeighborItem {
ip,
dev: Some(dev),
mac,
state: vec![state],
});
}
Ok(result)
}
impl TryFrom<NeighbourState> for NUDState {
fn try_from(value: NeighbourState) -> Result<Self, Self::Error> {
match value {
NeighbourState::Incomplete => Ok(Self::Incomplete),
NeighbourState::Reachable => Ok(Self::Reachable),
NeighbourState::Stale => Ok(Self::Stale),
NeighbourState::Delay => Ok(Self::Delay),
NeighbourState::Probe => Ok(Self::Probe),
NeighbourState::Failed => Ok(Self::Failed),
NeighbourState::Noarp => Ok(Self::Noarp),
NeighbourState::Permanent => Ok(Self::Permanent),
NeighbourState::None => Ok(Self::None),
NeighbourState::Other(e) => Ok(Self::Other(e)),
_ => Err(u16::MAX),
}
}
type Error = u16;
}
+35
View File
@@ -0,0 +1,35 @@
use std::iter::Fuse;
/// wrap ts into a [Fuse][std::iter::Fuse] or something
pub struct Real<A: Clone, B: Iterator<Item = A>> {
prepend: A,
iter: B,
my_turn: bool,
}
impl<A: Clone, B: Iterator<Item = A>> Real<A, B> {
pub fn new(prepend: A, iter: B) -> Self {
Self {
prepend,
iter,
my_turn: true,
}
}
pub fn fuse(prepend: A, iter: B) -> Fuse<Self> {
Self::new(prepend, iter).fuse()
}
}
impl<A: Clone, B: Iterator<Item = A>> Iterator for Real<A, B> {
type Item = A;
fn next(&mut self) -> Option<Self::Item> {
let a = if self.my_turn {
Some(self.prepend.clone())
} else {
self.iter.next()
};
self.my_turn = !self.my_turn;
a
}
}
+2
View File
@@ -0,0 +1,2 @@
// pub mod iter;
pub mod serialize;
+50
View File
@@ -0,0 +1,50 @@
use macaddr::MacAddr;
use serde::{self, Deserialize, Deserializer, de::Error as DeError};
use serde::{Serialize, Serializer};
pub fn serialize_macs<S>(macs: &[MacAddr], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let strings: Vec<String> = macs.iter().map(|m| m.to_string()).collect();
serde::Serialize::serialize(&strings, serializer)
}
/// Serialize a MacAddr as a string
pub fn serialize<S>(mac: &MacAddr, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&mac.to_string())
}
/// Deserialize a MacAddr from a string
pub fn deserialize<'de, D>(deserializer: D) -> Result<MacAddr, D::Error>
where
D: Deserializer<'de>,
{
let s = <String as serde::Deserialize>::deserialize(deserializer)?;
s.parse::<MacAddr>().map_err(DeError::custom)
}
pub mod option_mac {
use super::*;
/// serialize an [`Option<MacAddr>`]
pub fn serialize<S: Serializer>(bro: &Option<MacAddr>, ser: S) -> Result<S::Ok, S::Error> {
Option::<String>::serialize(&bro.as_ref().map(ToString::to_string), ser)
}
/// deserialize an [`Option<MacAddr>`], returns None for invalid input (::, 0.0.0.0)
pub fn deserialize<'de, D>(des: D) -> Result<Option<MacAddr>, D::Error>
where
D: serde::Deserializer<'de>,
{
let s: Option<&str> = Option::<&str>::deserialize(des)?;
match s {
Some(val) => match val.parse::<MacAddr>() {
Ok(mac) => Ok(Some(mac)),
Err(_) => Ok(None),
},
None => Ok(None),
}
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod mac;
// pub mod vec;
+37
View File
@@ -0,0 +1,37 @@
use serde::Deserialize;
use serde::de;
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany<T> {
One(T),
Many(Vec<T>),
}
pub fn deserialize<'de, D, T>(des: D) -> Result<Vec<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: std::str::FromStr,
T::Err: std::fmt::Display,
{
let raw: OneOrMany<String> = OneOrMany::<String>::deserialize(des)?;
let mut out = Vec::new();
match raw {
OneOrMany::One(s) => {
let t = s.trim();
if !t.is_empty() {
out.push(t.parse().map_err(de::Error::custom)?);
}
}
OneOrMany::Many(vs) => {
for s in vs {
let t = s.trim();
if t.is_empty() {
continue;
}
out.push(t.parse().map_err(de::Error::custom)?);
}
}
}
Ok(out)
}
+144
View File
@@ -0,0 +1,144 @@
use std::collections::HashSet;
use lda_ipjs::subcommands::{address, neighbor};
#[cfg(all(unix, feature = "experimental-nl"))]
#[tokio::test] // ← Use tokio::test instead of manual #[tokio::main]
async fn ball1() -> anyhow::Result<()> {
let result = neighbor::get_with_backend(neighbor::Backend::Netlink, None, None, &[]).await?;
println!("netlink results: {:?}", result);
Ok(()) // ← Don't force error, let it succeed
}
#[tokio::test]
async fn ball2() -> anyhow::Result<()> {
let result = neighbor::get_with_backend(neighbor::Backend::Json, None, None, &[]).await?;
println!("json results: {:?}", result);
Ok(())
}
// Add this to debug the raw JSON
#[tokio::test]
async fn ball_raw_json() -> anyhow::Result<()> {
let output = tokio::process::Command::new("ip")
.args(["-j", "neigh", "show"])
.output()
.await?;
let json = String::from_utf8_lossy(&output.stdout);
println!("Raw JSON:\n{}", json);
// Try to parse it
let parsed: Result<Vec<neighbor::NeighborItem>, _> = serde_json::from_slice(&output.stdout);
match parsed {
Ok(items) => println!("Parsed {} items", items.len()),
Err(e) => println!("Parse error: {}", e),
}
Ok(())
}
// Check what fields actually exist in the JSON
#[tokio::test]
async fn ball_field_analysis() -> anyhow::Result<()> {
let output = tokio::process::Command::new("ip")
.args(["-j", "neigh", "show"])
.output()
.await?;
let raw: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout)?;
println!("Found {} neighbor entries", raw.len());
// Collect all unique field names across all entries
let mut all_fields = std::collections::HashSet::new();
for (i, entry) in raw.iter().enumerate() {
if let Some(obj) = entry.as_object() {
println!("\nEntry {}: {} fields", i, obj.len());
for (key, value) in obj {
all_fields.insert(key.clone());
println!(" {}: {} = {:?}", key, value.type_name(), value);
}
}
}
println!("\n=== All unique fields seen ===");
for field in &all_fields {
println!(" - {}", field);
}
Ok(())
}
// Helper trait to get type name for JSON values
trait TypeName {
fn type_name(&self) -> &str;
}
impl TypeName for serde_json::Value {
fn type_name(&self) -> &str {
match self {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "bool",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}
}
// Test filtering logic
#[tokio::test]
async fn ball_compare_backends() -> anyhow::Result<()> {
println!("=== JSON Backend ===");
let json_result = neighbor::get_with_backend(neighbor::Backend::Json, None, None, &[]).await?;
println!("Got {} entries from JSON", json_result.len());
#[cfg(all(unix, feature = "experimental-nl"))]
{
println!("\n=== Netlink Backend ===");
let nl_result =
neighbor::get_with_backend(neighbor::Backend::Netlink, None, None, &[]).await?;
println!("Got {} entries from netlink", nl_result.len());
// Compare counts
if json_result.len() != nl_result.len() {
println!(
"\n⚠️ Count mismatch! JSON: {}, Netlink: {}",
json_result.len(),
nl_result.len()
);
} else {
println!("\n✅ Both backends returned same count");
}
let a: HashSet<neighbor::NeighborItem> = HashSet::from_iter(json_result);
let b: HashSet<neighbor::NeighborItem> = HashSet::from_iter(nl_result);
println!(
"istg {len1} == {len2} or else",
len1 = a.len(),
len2 = b.len()
);
}
Ok(())
}
// #[tokio::test]
// async fn cidr_filter() {
// unimplemented!("never. i aint add what i dont need")
// }
#[tokio::test]
async fn ipjas() -> anyhow::Result<()> {
let cuh = address::get_with_backend(address::Backend::Json, None).await?;
println!("{cuh:#?}");
Ok(())
}
#[tokio::test]
async fn ipjas_raw() -> anyhow::Result<()> {
let cuh = address::json::_get(None).await?;
println!("{cuh:#?}");
Ok(())
}
+25
View File
@@ -0,0 +1,25 @@
# this does one thing
param(
[string]$PASSWD
)
. "$(Split-Path -Parent $PSScriptRoot)/scripts/lib.ps1"
$PASSWD = Get-DefaultPassword $PASSWD
# idk what this does it works like that then thats how it is
pscp.exe -l root -scp -pw $PASSWD -r 192.168.100.1:/etc/ldlda_help $PSScriptRoot
pscp.exe -l root -scp -pw $PASSWD 192.168.100.1:/etc/rc.local $PSScriptRoot
Remove-Item -Recurse (Join-Path $PSScriptRoot "root")
pscp.exe -l root -scp -pw $PASSWD -r 192.168.100.1:/root $PSScriptRoot
$initDir = Join-Path $PSScriptRoot 'init.d'
New-Item -ItemType Directory -Force -Path $initDir | Out-Null
# these are sum
$files = "update_wakey" , "wakey" , "update_tailscale" , "wireguard_setup", "lda-override"
$remote = $files.ForEach({ "/etc/init.d/$_" })
$remote | ForEach-Object {
pscp.exe -l root -scp -pw $PASSWD "192.168.100.1:$_" "$initDir\"
}
+88 -27
View File
@@ -4,56 +4,111 @@ This folder has small helpers for build, CI, and router install. Keep it simple;
## Quick start (recommended)
1. Start your Gitea runner on this Windows PC
1. Start your Fedora/WSL Gitea runner (recommended release path):
```powershell
./scripts/act_runner.ps1
```
```powershell
./scripts/act_runner_wsl.ps1 -Action start -Attach
```
If first-time, it prints the exact register command. Run it once, check labels include `windows:host,self-hosted`, then rerun the script.
If first-time, use the runbook in [`WSL_RUNNER.md`](./WSL_RUNNER.md) to update and register the Fedora runner first.
1. Tag and push to trigger CI
2. Publish, tag, and push (recommended):
```powershell
git tag v0.1.0
git push origin v0.1.0
```
```powershell
./scripts/publish.ps1 -Tag
```
1. Install on the router
This will bump the version (if you specify one), build locally, tag, push, and start the Fedora/WSL runner in `--once` mode by default. To also publish to the registry, add `-Publish`.
Use the release asset URL from your Gitea Release page:
3. Install on the router
```sh
wget -O- https://<gitea>/<owner>/<repo>/releases/download/v0.1.0/wakey-rootfs-v0.1.0-armv7-unknown-linux-musleabihf.tgz | tar -xz -C /
chmod +x /etc/init.d/wakey && /etc/init.d/wakey enable && /etc/init.d/wakey restart
```
Use the updater on the router to fetch the latest automatically:
Alternatively, use the updater on the router to fetch the latest automatically:
```sh
WAKEY_HOST=git.ldlda.com WAKEY_OWNER=lda WAKEY_REPO=wakey sh /etc/ldlda_help/update_wakey.sh
```
```sh
WAKEY_HOST=git.ldlda.com WAKEY_OWNER=lda WAKEY_REPO=wakey sh /etc/ldlda_help/update_wakey.sh
```
Alternatively, use the release asset URL from your Gitea Release page:
```sh
wget -O- https://<gitea>/<owner>/<repo>/releases/download/v0.1.0/wakey-rootfs-v0.1.0-armv7-unknown-linux-musleabihf.tgz | tar -xz -C /
chmod +x /etc/init.d/wakey && /etc/init.d/wakey enable && /etc/init.d/wakey restart
```
## Scripts overview
- `act_runner.ps1` — Start/seed the local Gitea runner. Use `-Attach` to see logs, `-ForceConfigure` to register non-interactively.
- `cross_build.ps1` — Simple wrapper for `cargo build` per target (default: armv7-unknown-linux-musleabihf).
- `act_runner_wsl.ps1` — Windows PowerShell wrapper that controls the Fedora/WSL runner.
- `act_runner_fedora.sh` — Linux/Fedora helper used inside WSL for update/register/start/stop/status.
- `dev_push.ps1` — Fast dev loop: build + upload to router. Usage:
- `./scripts/dev_push.ps1 -Pass <password> [-HostName <ip>] [-RemotePath </root/.bin/wakey>] [-Restart] [-Quiet]`
- Uploads to `<RemotePath>.tmp` then atomically moves into place; `-Restart` restarts the service; `-Quiet` silences MOTD.
- `package_rootfs.ps1` — Produces `dist/wakey-rootfs-<version>-<target>.tgz` with `/root/.bin/wakey` and `/etc/init.d/*`.
- `package.ps1` — Dev bundle with binaries + init scripts (not a rootfs layout).
- `publish.ps1` — Optional version bump + build + tag (and `cargo publish` only if you pass `-Publish`).
- `test_remote.ps1` — Build ARM Linux test binaries locally, upload them to the router, and run them there.
## Remote test runner
`test_remote.ps1` is the main way to run Linux/router-specific tests that do not
make sense on Windows.
Basic examples:
```powershell
./scripts/test_remote.ps1 -Package wakey
./scripts/test_remote.ps1 -Package wakey -BinaryFilter integration_live_services -Ignored -NoCapture
./scripts/test_remote.ps1 -Package wakey -BinaryFilter integration_live_services -Filter status_real_router_default_query_succeeds -Ignored -NoCapture
./scripts/test_remote.ps1 -AllPackages
```
Important semantics:
- `-BinaryFilter`
- selects which compiled Rust test executable(s) to run
- `-Filter`
- filters test functions inside a selected Rust test binary
- `-Ignored`
- runs `#[ignore]` tests
- `-IncludeIgnored`
- includes ignored tests in addition to normal ones
- `-List`
- lists tests inside the remote binary instead of executing them
Useful flags:
- `-BuildProfile debug|release`
- `-Exact`
- `-NoCapture`
- `-ShowOutput`
- `-Threads <n>`
- `-RemoteHost root@<router-ip>`
- `-RemoteTestPath /tmp/tmp/wakey-test`
Implementation notes:
- test executables are discovered via Cargo JSON messages, not regexing human output
- binaries are batch-uploaded per package run
- each package run gets a unique remote temp directory for safer concurrent use
- packages with no test executables are skipped instead of treated as failures
## CI (Gitea)
- Defined in `.gitea/workflows/release.yml`.
- Runs on your `self-hosted, windows` runner.
- Steps: cross build → package rootfs → upload artifact (v3) → publish a release and attach tgz.
- Release job now targets your Fedora/WSL runner labels: `self-hosted`, `linux`, `fedora`, `wsl`, `release`.
- Steps: Linux-native build of `wakey` + `wakey-agent` → package rootfs → upload artifact (v3) → publish a release and attach tgz.
- Requires `secrets.GITEA_TOKEN` in the repo to publish the release.
## Runner migration
- Fedora/WSL is now the intended release-builder path.
- The Windows runner can stay registered in parallel during migration and fallback.
- The detailed operator runbook is in [`WSL_RUNNER.md`](./WSL_RUNNER.md).
## Local build/install (manual path)
```powershell
# Build (requires cross installed)
./scripts/cross_build.ps1 -Release -Targets armv7-unknown-linux-musleabihf
# Build
cargo build --release --target armv7-unknown-linux-musleabihf
# Package rootfs
./scripts/package_rootfs.ps1 -Version v0.1.0 -Target armv7-unknown-linux-musleabihf
@@ -68,4 +123,10 @@ tar -xz -f /tmp/wakey.tgz -C /
chmod +x /etc/init.d/wakey && /etc/init.d/wakey enable && /etc/init.d/wakey restart
```
That’s it. Keep the flow: start runner → tag push → install.
Or you can use the script:
```powershell
./scripts/dev_push.ps1 -Pass <password>
```
That’s it. Keep the flow: start Fedora/WSL runner → tag push → install.
+131
View File
@@ -0,0 +1,131 @@
# Fedora / WSL Gitea Runner
This repo now expects Linux/router release builds to run on a Fedora 43 runner
inside WSL, not on the Windows host runner.
The goal is simple:
- build `wakey` and `wakey-agent` in a Linux-native environment
- stop fighting Windows-host ARM/musl/TLS toolchain issues
- keep PowerShell on Windows as the operator front door
## Runner layout
Recommended WSL location:
```text
/mnt/c/Users/Admin/Documents/realshit/wakey/ar_data/wsl_runner
```
This matches the default used by `act_runner_wsl.ps1` and
`act_runner_fedora.sh`, so WSL runner config is isolated at
`ar_data/wsl_runner/config.yaml` in this repo.
Recommended labels:
```text
self-hosted,linux,fedora,wsl,release:host
```
The Windows runner can stay registered during migration as fallback.
## Fedora setup
Install the basics in Fedora 43:
```bash
sudo dnf install -y git curl tar gzip findutils python3
sudo dnf install -y powershell
```
Install Rust and the router target:
```bash
rustup target add armv7-unknown-linux-musleabihf
```
Install the native cross toolchain pieces you need for Linux/ARM router builds.
The exact package names can vary over time, so confirm the current Fedora names
for:
- ARM Linux GCC
- musl development/toolchain support
- OpenSSL/ring/TLS build prerequisites if needed by your dependency graph
## Runner lifecycle
From Windows PowerShell, using the WSL wrapper:
```powershell
./scripts/act_runner_wsl.ps1 -Action update
./scripts/act_runner_wsl.ps1 -Action register -ServerUrl https://git.ldlda.com/ -Token <runner-token>
./scripts/act_runner_wsl.ps1 -Action start -Attach
./scripts/act_runner_wsl.ps1 -Action start -Once
./scripts/act_runner_wsl.ps1 -Action status
./scripts/act_runner_wsl.ps1 -Action stop
```
Path control (binary and config are independent):
```powershell
./scripts/act_runner_wsl.ps1 -Action status `
-RunnerBin /home/lda/gitea-runner/act_runner `
-ConfigPath /mnt/c/Users/Admin/Documents/realshit/wakey/ar_data/wsl_runner/config.yaml
```
If your distro name is not `FedoraLinux-43`, set it explicitly:
```powershell
./scripts/act_runner_wsl.ps1 -Distro FedoraLinux-43 -Action status
```
Or set:
```powershell
$env:WAKEY_WSL_DISTRO = "FedoraLinux-43"
```
Inside Fedora directly, you can also use:
```bash
./scripts/act_runner_fedora.sh update
./scripts/act_runner_fedora.sh register --server-url https://git.ldlda.com/ --token <runner-token>
./scripts/act_runner_fedora.sh start --attach
./scripts/act_runner_fedora.sh start --once
./scripts/act_runner_fedora.sh status
./scripts/act_runner_fedora.sh stop
```
## Release flow
The release workflow is expected to run on the Fedora/WSL runner labels:
```text
self-hosted + linux + fedora + wsl + release
```
It builds:
- `wakey`
- `wakey-agent`
and packages both into the rootfs tarball.
From Windows, the intended operator flow remains:
```powershell
./scripts/publish.ps1 -Tag
```
That script should trigger the WSL runner in `--once` mode by default.
## Rollback
If the Fedora runner path is broken:
1. stop using the WSL runner labels in the workflow
2. temporarily point the workflow back to the Windows runner labels
3. use the existing Windows runner as fallback while fixing the Fedora path
Do not delete the Windows runner until the Fedora path has already produced at
least one successful full release and one successful router install.
Regular → Executable
+24 -10
View File
@@ -1,16 +1,19 @@
# Starts the Gitea act_runner if not already running.
# Usage: ./scripts/act_runner.ps1 -Config "C:/Users/Admin/Documents/gitea/runner.yaml" -RunnerPath "C:/Users/Admin/Documents/gitea/act_runner.exe" -ServerUrl "https://git.ldlda.com/" -Token "<reg token>" -Labels "self-hosted,windows"
#!/usr/bin/env pwsh
# Starts the Windows-host Gitea act_runner if not already running.
# Usage: ./scripts/act_runner.ps1 -Config "C:/Users/Admin/Documents/gitea/runner.yaml" -RunnerPath "C:/Users/Admin/Documents/gitea/act_runner.exe" -ServerUrl "https://git.ldlda.com/" -Token "<reg token>" -Labels "self-hosted,windows:host"
param(
[string]$RunnerPath = "$HOME/Documents/gitea/act_runner.exe",
# Config is a FILE path (e.g., runner.yaml). Parent folder will be created if missing.
[string]$Config = (Join-Path (Split-Path -Parent $PSScriptRoot) 'ar_data/config.yaml'),
[string]$ServerUrl,
[string]$Token,
[string]$Labels = "windows:host,self-hosted",
[string]$Labels = "self-hosted,windows:host",
# Opt-in to non-interactive configure; by default we print the command for you to run manually.
[switch]$ForceConfigure,
# When -Attach, run in the foreground to see logs (good for first-time troubleshooting)
[switch]$Attach
[switch]$Attach,
# Run one job then exit (great for CI tag pushes)
[switch]$Once
)
$ErrorActionPreference = 'Stop'
@@ -38,20 +41,31 @@ if (-not (Test-Path $configFile)) {
}
if ($ForceConfigure) {
Write-Host "Config not found. Initializing non-interactively (ForceConfigure)..."
$configParent = Split-Path -Parent $configFile
Push-Location $configParent
& $RunnerPath register --no-interactive --config $configFile --instance $ServerUrl --token $Token --labels $Labels
Pop-Location
}
else {
Write-Host "Config not found. Run this manually once (note labels embed host executor on Windows):"
Write-Host "Config not found. Run this manually once (Windows-host runner labels):"
Write-Host "`t$RunnerPath register --no-interactive --config `"$configFile`" --instance `"$ServerUrl`" --token `"$Token`" --labels `"$Labels`""
exit 2
}
}
if ($Attach) {
Write-Host "Starting act_runner (attached)... Ctrl+C to stop"
& $RunnerPath daemon --config $configFile
if ($Attach -or $Once) {
Write-Host "Starting act_runner $(if ($Once) {'(once mode)'} else {'(attached)'})... $(if (-not $Once) {'Ctrl+C to stop'})"
$configParent = Split-Path -Parent $configFile
Push-Location $configParent
$daemonArgs = @('daemon', '--config', $configFile)
if ($Once) { $daemonArgs += '--once' }
& $RunnerPath @daemonArgs
Pop-Location
}
else {
Start-Process -FilePath $RunnerPath -ArgumentList @("daemon", "--config", $configFile) -WindowStyle Hidden
Write-Host "act_runner started."
$configParent = Split-Path -Parent $configFile
Push-Location $configParent
$proc = Start-Process -FilePath $RunnerPath -ArgumentList @("daemon", "--config", $configFile) -WindowStyle Hidden -PassThru
Pop-Location
Write-Host "act_runner started (PID=$($proc.Id))."
}
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
RUNNER_BIN="${RUNNER_BIN:-/home/lda/gitea-runner/act_runner}"
CONFIG_PATH="${CONFIG_PATH:-$REPO_ROOT/ar_data/wsl_runner/config.yaml}"
RUNNER_HOME="${RUNNER_HOME:-$(dirname "$CONFIG_PATH")}"
SERVER_URL="${SERVER_URL:-}"
TOKEN="${TOKEN:-}"
LABELS="${LABELS:-self-hosted,linux,fedora,wsl,release:host}"
usage() {
cat <<'EOF'
Usage:
act_runner_fedora.sh update
act_runner_fedora.sh register --server-url URL --token TOKEN [--labels LABELS]
act_runner_fedora.sh start [--attach] [--once]
act_runner_fedora.sh stop
act_runner_fedora.sh status
Environment overrides:
RUNNER_HOME, RUNNER_BIN, CONFIG_PATH, SERVER_URL, TOKEN, LABELS
Path overrides:
--runner-home DIR
--runner-bin PATH
--config-path PATH
EOF
}
ensure_dirs() {
mkdir -p "$RUNNER_HOME" "$(dirname "$RUNNER_BIN")"
}
ensure_runner_bin() {
if [[ ! -x "$RUNNER_BIN" ]]; then
echo "Runner binary not found or not executable at $RUNNER_BIN" >&2
exit 2
fi
}
runner_pids() {
ps -eo pid=,args= | awk -v bin="$RUNNER_BIN" -v config="$CONFIG_PATH" '
$2 == bin && $3 == "daemon" && $4 == "--config" && $5 == config { print $1 }
'
}
update_runner() {
ensure_dirs
local api="https://gitea.com/api/v1/repos/gitea/act_runner/releases/latest"
local release
release="$(curl -fsSL "$api")"
local asset_url
asset_url="$(
python3 - "$(printf '%s' "$release")" <<'PY'
import json, sys
data = json.loads(sys.argv[1])
for asset in data["assets"]:
if asset["name"].endswith("linux-amd64"):
print(asset["browser_download_url"])
break
else:
raise SystemExit("no linux-amd64 act_runner asset found")
PY
)"
local tmp="$RUNNER_HOME/act_runner.tmp"
curl -fsSL "$asset_url" -o "$tmp"
chmod +x "$tmp"
mv -f "$tmp" "$RUNNER_BIN"
echo "Updated act_runner -> $RUNNER_BIN"
}
register_runner() {
ensure_dirs
ensure_runner_bin
if [[ -z "$SERVER_URL" || -z "$TOKEN" ]]; then
echo "register requires SERVER_URL and TOKEN (or --server-url/--token)" >&2
exit 2
fi
"$RUNNER_BIN" register \
--no-interactive \
--config "$CONFIG_PATH" \
--instance "$SERVER_URL" \
--token "$TOKEN" \
--labels "$LABELS"
}
start_runner() {
local mode="${1:-daemon}"
ensure_dirs
ensure_runner_bin
if [[ ! -f "$CONFIG_PATH" ]]; then
echo "Runner config not found at $CONFIG_PATH" >&2
exit 2
fi
case "$mode" in
attach)
exec "$RUNNER_BIN" daemon --config "$CONFIG_PATH"
;;
once)
exec "$RUNNER_BIN" daemon --config "$CONFIG_PATH" --once
;;
daemon)
nohup "$RUNNER_BIN" daemon --config "$CONFIG_PATH" >/tmp/act_runner.log 2>&1 &
echo "Started act_runner in background"
;;
*)
echo "Unknown start mode: $mode" >&2
exit 2
;;
esac
}
stop_runner() {
local pids
pids="$(runner_pids)"
if [[ -z "$pids" ]]; then
return 0
fi
kill $pids
}
status_runner() {
ensure_runner_bin
if [[ -n "$(runner_pids)" ]]; then
echo "act_runner is running"
else
echo "act_runner is off"
fi
}
ACTION="${1:-}"
shift || true
while [[ $# -gt 0 ]]; do
case "$1" in
--server-url)
SERVER_URL="$2"
shift 2
;;
--token)
TOKEN="$2"
shift 2
;;
--labels)
LABELS="$2"
shift 2
;;
--runner-home)
RUNNER_HOME="$2"
if [[ "${RUNNER_BIN:-}" == "/home/lda/gitea-runner/act_runner" ]]; then
RUNNER_BIN="$RUNNER_HOME/act_runner"
fi
if [[ "${CONFIG_PATH:-}" == "$REPO_ROOT/ar_data/wsl_runner/config.yaml" ]]; then
CONFIG_PATH="$RUNNER_HOME/config.yaml"
fi
shift 2
;;
--runner-bin)
RUNNER_BIN="$2"
shift 2
;;
--config-path)
CONFIG_PATH="$2"
RUNNER_HOME="$(dirname "$CONFIG_PATH")"
shift 2
;;
--attach)
START_MODE="attach"
shift
;;
--once)
START_MODE="once"
shift
;;
*)
echo "Unknown argument: $1" >&2
usage
exit 2
;;
esac
done
case "$ACTION" in
update)
update_runner
;;
register)
register_runner
;;
start)
start_runner "${START_MODE:-daemon}"
;;
stop)
stop_runner
;;
status)
status_runner
;;
*)
usage
exit 2
;;
esac
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env pwsh
param(
[ValidateSet("update", "register", "start", "stop", "status")]
[string]$Action = "start",
[string]$Distro = $(if ($env:WAKEY_WSL_DISTRO) { $env:WAKEY_WSL_DISTRO } else { "FedoraLinux-43" }),
[string]$RunnerHome,
[string]$RunnerBin = "/home/lda/gitea-runner/act_runner",
[string]$ConfigPath,
[string]$ServerUrl,
[string]$Token,
[string]$Labels = "self-hosted,linux,fedora,wsl,release:host",
[switch]$Attach,
[switch]$Once
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot/lib.ps1"
$scriptWin = Join-Path $PSScriptRoot "act_runner_fedora.sh"
if (-not (Test-Path $scriptWin)) {
throw "Missing Fedora runner helper: $scriptWin"
}
$scriptLinux = (& wsl.exe -d $Distro -e wslpath -a "$scriptWin").Trim()
if (-not $scriptLinux) {
throw "Failed to resolve WSL path for $scriptWin"
}
if (-not $RunnerHome) {
$repoRootWin = Split-Path -Parent $PSScriptRoot
$defaultRunnerHomeWin = Join-Path $repoRootWin "ar_data/wsl_runner"
$RunnerHome = (& wsl.exe -d $Distro -e wslpath -a "$defaultRunnerHomeWin").Trim()
if (-not $RunnerHome) {
throw "Failed to resolve WSL runner home for $defaultRunnerHomeWin"
}
}
if (-not $ConfigPath) {
$repoRootWin = Split-Path -Parent $PSScriptRoot
$defaultConfigWin = Join-Path $repoRootWin "ar_data/wsl_runner/config.yaml"
$ConfigPath = (& wsl.exe -d $Distro -e wslpath -a "$defaultConfigWin").Trim()
if (-not $ConfigPath) {
throw "Failed to resolve WSL config path for $defaultConfigWin"
}
}
$parts = @("bash", (Quote-ShArg $scriptLinux), (Quote-ShArg $Action), "--runner-home", (Quote-ShArg $RunnerHome))
$parts += @("--runner-bin", (Quote-ShArg $RunnerBin), "--config-path", (Quote-ShArg $ConfigPath))
if ($ServerUrl) { $parts += @("--server-url", (Quote-ShArg $ServerUrl)) }
if ($Token) { $parts += @("--token", (Quote-ShArg $Token)) }
if ($Labels) { $parts += @("--labels", (Quote-ShArg $Labels)) }
if ($Attach) { $parts += "--attach" }
if ($Once) { $parts += "--once" }
$cmd = $parts -join " "
Write-Host $cmd
& wsl.exe -d $Distro -e bash -lc $cmd
if ($LASTEXITCODE -ne 0) {
throw "WSL runner action failed ($LASTEXITCODE)"
}
-57
View File
@@ -1,57 +0,0 @@
param(
[Parameter(Mandatory = $true)][string]$Tag,
[Parameter(Mandatory = $true)][string]$AssetPattern,
[string]$ServerUrl,
[string]$Token,
[string]$Owner,
[string]$Repo
)
$ErrorActionPreference = 'Stop'
# Resolve token
if (-not $Token) { $Token = $env:GITEA_TOKEN }
if (-not $Token) { throw 'Missing Gitea token. Set -Token or $env:GITEA_TOKEN' }
# Resolve repo owner/name
if (-not $Owner -or -not $Repo) {
if ($env:GITHUB_REPOSITORY) {
$parts = $env:GITHUB_REPOSITORY -split '/'
if (-not $Owner -and $parts.Length -ge 1) { $Owner = $parts[0] }
if (-not $Repo -and $parts.Length -ge 2) { $Repo = $parts[1] }
}
else {
throw 'Missing Owner/Repo and GITHUB_REPOSITORY not set'
}
}
# Resolve API base
$api = $env:GITHUB_API_URL
if (-not $api -and $env:GITHUB_SERVER_URL) { $api = "$($env:GITHUB_SERVER_URL)/api/v1" }
if (-not $api -and $ServerUrl) { $api = "$ServerUrl/api/v1" }
if (-not $api) { throw 'Could not determine Gitea API URL. Provide -ServerUrl or set GITHUB_SERVER_URL/GITHUB_API_URL' }
# Find asset
$asset = Get-ChildItem -Path $AssetPattern -ErrorAction Stop | Select-Object -First 1
if (-not $asset) { throw "No asset matches pattern: $AssetPattern" }
$headers = @{ Authorization = "token $Token" }
$body = @{ tag_name = $Tag; name = $Tag; draft = $false; prerelease = $false } | ConvertTo-Json
Write-Host "Creating release $Tag for $Owner/$Repo"
try {
$release = Invoke-RestMethod -Headers $headers -Uri "$api/repos/$Owner/$Repo/releases" -Method Post -Body $body -ContentType 'application/json'
}
catch {
# If already exists, fetch by tag
Write-Host "Create failed; trying to fetch existing release for tag $Tag" -ForegroundColor Yellow
$release = Invoke-RestMethod -Headers $headers -Uri "$api/repos/$Owner/$Repo/releases/tags/$Tag" -Method Get
}
$rid = $release.id
if (-not $rid) { throw "Failed to resolve release id: $($release | ConvertTo-Json -Depth 5)" }
$uploadUri = "$api/repos/$Owner/$Repo/releases/$rid/assets?name=$($asset.Name)"
Write-Host "Uploading asset $($asset.Name)"
Invoke-WebRequest -UseBasicParsing -Headers $headers -Uri $uploadUri -Method Post -InFile $asset.FullName -ContentType 'application/octet-stream' | Out-Null
Write-Host "Release published: tag $Tag with asset $($asset.Name)"
-17
View File
@@ -1,17 +0,0 @@
# Build release binaries using cross for Linux targets.
# Requires: cross installed (cargo install cross)
param(
[string[]]$Targets = @("armv7-unknown-linux-musleabihf"),
[switch]$Release
)
$ErrorActionPreference = 'Stop'
$mode = if ($Release) { "--release" } else { "" }
foreach ($t in $Targets) {
Write-Host "Building for $t..."
cross build $mode --target $t
}
Write-Host "Done."
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env pwsh
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidDefaultValueSwitchParameter', "",
Justification = 'Default true is intentional for fast dev loop')]
param(
[ValidateSet("cargo", "cross")]
[string]$Cargo = "cargo",
[string]$Pass,
[string]$HostName = "192.168.100.1",
[int]$Port = 22,
[string]$User = "root",
[string]$RemotePath = "/root/.bin/wakey",
[string]$AgentRemotePath = "/root/.bin/wakey-agent",
[string]$RemoteInitPath = "/etc/init.d/wakey",
[string]$Target = "armv7-unknown-linux-musleabihf",
[string]$BinName = "wakey",
[string]$AgentBinName = "wakey-agent",
[string]$HostKey,
[switch]$ForcePassword,
[switch]$SkipInitScript,
[switch]$Restart = $true,
[switch]$Quiet = $true
)
$ErrorActionPreference = 'Stop'
try { $PSStyle.OutputRendering = 'Host' } catch {}
. "$PSScriptRoot/lib.ps1"
$Pass = Get-DefaultPassword $Pass
# Backward compatibility: allow HostName in the form host:port.
$hostPort = Split-HostPort -HostName $HostName -DefaultPort $Port
$HostName = $hostPort.Host
$Port = $hostPort.Port
function Get-DeployScript {
param($DeployPreferred, $DeployTmp, $RemoteTmp, $RemotePath, $RestartFlag)
return @"
DEPLOY=$DeployPreferred;
if [ ! -x "`$DEPLOY" ] && [ -f $DeployTmp ]; then
sed -i "s/\r$//" $DeployTmp 2>/dev/null || true
chmod +x $DeployTmp
DEPLOY=$DeployTmp
fi
sh "`$DEPLOY" $RemoteTmp $RemotePath $RestartFlag
"@
}
#---- Main Flow ----
$repoRoot = Split-Path -Parent $PSScriptRoot
Push-Location $repoRoot
try {
Write-Host "[build] $Cargo build --release --target $Target -p wakey -p wakey-agent" -ForegroundColor Cyan
. "$Cargo" build --release --target $Target -p wakey -p wakey-agent
if ($LASTEXITCODE -ne 0) { throw "$Cargo build failed ($LASTEXITCODE)" }
$localBin = Join-Path $repoRoot "target/$Target/release/$BinName"
$localAgentBin = Join-Path $repoRoot "target/$Target/release/$AgentBinName"
if (-not (Test-Path $localBin)) { throw "binary not found: $localBin" }
if (-not (Test-Path $localAgentBin)) { throw "binary not found: $localAgentBin" }
$remoteTmp = "$RemotePath.tmp"
$agentRemoteTmp = "$AgentRemotePath.tmp"
$destTmp = "$User@${HostName}:$remoteTmp"
$agentDestTmp = "$User@${HostName}:$agentRemoteTmp"
$initTmp = '/var/tmp/wakey.init.tmp'
$initDestTmp = "$User@${HostName}:$initTmp"
$localDeploy = Join-Path $repoRoot 'scripts/remote_deploy_wakey.sh'
$localInit = Join-Path $repoRoot 'scripts/init/openwrt/wakey'
$deployTmp = '/var/tmp/remote_deploy_wakey.sh'
$deployPreferred = '/root/.bin/remote_deploy_wakey.sh'
# Push binaries
Invoke-Scp -Local $localBin -Dest $destTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
Invoke-Scp -Local $localAgentBin -Dest $agentDestTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
# Push OpenWrt init script unless explicitly skipped
if (-not $SkipInitScript -and (Test-Path $localInit)) {
Invoke-Scp -Local $localInit -Dest $initDestTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
}
# Push deploy helper if exists
if (Test-Path $localDeploy) {
Invoke-Scp -Local $localDeploy -Dest "$User@${HostName}:$deployTmp" -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
}
# Build and run remote deploy command
$restartFlag = $(if ($Restart) { '1' } else { '0' })
$script = @"
$(Get-DeployScript $deployPreferred $deployTmp $remoteTmp $RemotePath 0)
$(Get-DeployScript $deployPreferred $deployTmp $agentRemoteTmp $AgentRemotePath $restartFlag)
$(if (-not $SkipInitScript) {
@"
if [ -f $initTmp ]; then
sed -i "s/\r$//" $initTmp 2>/dev/null || true
if command -v install >/dev/null 2>&1; then
install -m 0755 $initTmp $RemoteInitPath
else
cp -f $initTmp $RemoteInitPath
chmod 0755 $RemoteInitPath
fi
fi
"@
})
"@
$remoteCmd = "sh -lc '$($script -replace "`r",'')'"
if ($Quiet) { $remoteCmd += " >/dev/null 2>&1" }
Invoke-Ssh -Cmd $remoteCmd -User $User -Remote $HostName -Pass $Pass -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
Write-Host "done ✔" -ForegroundColor Green
}
finally {
Pop-Location
}
+17 -11
View File
@@ -7,18 +7,24 @@
# Default-Stop:
# Short-Description: Pre-flight Tailscale update
START=50 # Run before Tailscale (typically START~80)
USE_PROCD=1
START=50 # Run before Tailscale (typically START~80)
start_service() {
procd_open_instance
procd_set_param command /etc/ldlda_help/update_tailscale.sh
procd_set_param respawn 0 0 0
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
do_update() {
MAX_RETRIES=10
RETRY_COUNT=0
while [ "$RETRY_COUNT" -lt "$MAX_RETRIES" ]; do
if /etc/ldlda_help/update_tailscale.sh; then
echo "[update_tailscale] Success at $(date)"
return 0
fi
RETRY_COUNT=$((RETRY_COUNT + 1))
echo "[update_tailscale] Retry $RETRY_COUNT at $(date)"
sleep 5
done
echo "[update_tailscale] Too many retries. Exiting at $(date)"
return 1
}
stop_service() {
: # one-shot
start() {
do_update >> /var/log/update_tailscale.log 2>&1 &
}
+19 -12
View File
@@ -1,17 +1,24 @@
#!/bin/sh /etc/rc.common
START=66
START=66 # Run after the resolvconf swap
USE_PROCD=1
start_service() {
procd_open_instance
procd_set_param command /etc/ldlda_help/update_wakey.sh
procd_set_param respawn 0 0 0
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
# does this work now fam
# exposal to wg is separate; so weon care
do_update() {
MAX_RETRIES=10
RETRY_COUNT=0
while [ "$RETRY_COUNT" -lt "$MAX_RETRIES" ]; do
if /etc/ldlda_help/update_wakey.sh; then
echo "[update_wakey] Success at $(date)"
return 0
fi
RETRY_COUNT=$((RETRY_COUNT + 1))
echo "[update_wakey] Retry $RETRY_COUNT at $(date)"
sleep 5
done
echo "[update_wakey] Too many retries. Exiting at $(date)"
return 1
}
stop_service() {
: # one-shot
start() {
do_update >>/var/log/update_wakey.log 2>&1 &
}
+8 -8
View File
@@ -5,17 +5,17 @@ START=99
USE_PROCD=1
NAME=wakey
BIN=/root/.bin/wakey
BIN=/root/.bin/wakey-agent
start_service() {
procd_open_instance
procd_set_param command "$BIN"
procd_set_param respawn 5 1 0
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
procd_open_instance
procd_set_param command "$BIN" serve
procd_set_param respawn 5 1 0
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
stop_service() {
: # procd manages the process; nothing to do here
: # procd manages the process; nothing to do here
}
+6 -6
View File
@@ -6,20 +6,20 @@ pids="$(pidof wakey 2>/dev/null)"
# 2) Fallback: ps | awk (avoid matching awk/grep themselves)
if [ -z "$pids" ]; then
pids="$(ps w 2>/dev/null | awk '/\/\.bin\/wakey/ && $0 !~ /awk/ {print $1}')"
pids="$(ps w 2>/dev/null | awk '/\/\.bin\/wakey/ && $0 !~ /awk/ {print $1}')"
fi
[ -n "$pids" ] || exit 0
# Send TERM first
kill -TERM $pids 2>/dev/null || true
kill -TERM "$pids" 2>/dev/null || true
# Optional: hard kill if still alive after a short grace
sleep 0.2
usleep 250000 # uhhh sleep is not found
remain=""
for p in $pids; do
kill -0 "$p" 2>/dev/null && remain="$remain $p"
kill -0 "$p" 2>/dev/null && remain="$remain $p"
done
[ -z "$remain" ] || kill -KILL $remain 2>/dev/null || true
[ -z "$remain" ] || kill -KILL "$remain" 2>/dev/null || true
exit 0
exit 0
+16 -7
View File
@@ -2,24 +2,33 @@
set -e
# 0th step from lda is to move the pre assed resolv.conf to the real resolv.conf
[ -f /tmp/resolv.conf ] && cp -af /tmp/resolv.conf /etc/resolv.conf || ([ -f /rom/etc/resolv.conf ] && cp -af /rom/etc/resolv.conf /etc/resolv.conf)
if [ -f /tmp/resolv.conf ]; then
cp -af /tmp/resolv.conf /etc/resolv.conf
elif [ -f /rom/etc/resolv.conf ]; then
cp -af /rom/etc/resolv.conf /etc/resolv.conf
fi
if [ -x "$(which tailscale)" ]; then
tailscale update
exit 0
fi
# 1. Fetch the latest version number from Tailscale's site
LATEST=$(curl -s https://pkgs.tailscale.com/stable/ | grep -Eo 'tailscale_[0-9\.]+_arm.tgz' | head -n1)
[ -z "$LATEST" ] && {
echo "❌ Couldn't find latest ARM binary."
exit 1
echo "❌ Couldn't find latest ARM binary."
exit 1
}
URL="https://pkgs.tailscale.com/stable/${LATEST}"
cd /var/tmp
[ -f "$LATEST" ] && {
echo "file exists"
rm "$LATEST"
}
if [ -f "$LATEST" ]; then
echo "file exists"
rm "$LATEST"
fi
echo "Downloading $LATEST..."
wget -q "$URL"
+99 -68
View File
@@ -15,91 +15,122 @@
set -eu
# reboot == new file.
FLAG=/var/wakey_updated.flag
ARCH=${WAKEY_ARCH:-armv7-unknown-linux-musleabihf}
TMPFILE="/tmp/wakey-rootfs.$$.$ARCH.tgz"
TMPDIR="/var/tmp"
TMPFILE="$TMPDIR/wakey-rootfs.$$.$ARCH.tgz"
STAGING="$TMPDIR/wakey-rootfs.$$.$ARCH"
log() { echo "[update_wakey] $*"; }
fail() { echo "[update_wakey] ERROR: $*" >&2; exit 1; }
fail() {
echo "[update_wakey] ERROR: $*" >&2
exit 1
}
http_get() {
# http_get <url>
if command -v uclient-fetch >/dev/null 2>&1; then
# uclient-fetch supports -O -, write to stdout
uclient-fetch -O - "$1" 2>/dev/null || return 1
elif command -v wget >/dev/null 2>&1; then
# shellcheck disable=SC2086
wget ${WAKEY_INSECURE:+--no-check-certificate} -qO- "$1" || return 1
elif command -v curl >/dev/null 2>&1; then
if [ -n "${WAKEY_INSECURE:-}" ]; then
curl -fsSL -k "$1" || return 1
else
curl -fsSL "$1" || return 1
fi
else
return 2
fi
# http_get <url>
if command -v uclient-fetch >/dev/null 2>&1; then
# uclient-fetch supports -O -, write to stdout
uclient-fetch -O - "$1" 2>/dev/null || return 1
elif command -v wget >/dev/null 2>&1; then
# shellcheck disable=SC2086
wget ${WAKEY_INSECURE:+--no-check-certificate} -qO- "$1" || return 1
elif command -v curl >/dev/null 2>&1; then
if [ -n "${WAKEY_INSECURE:-}" ]; then
curl -fsSL -k "$1" || return 1
else
curl -fsSL "$1" || return 1
fi
else
return 2
fi
}
fetch() {
# fetch <url> <out>
if command -v uclient-fetch >/dev/null 2>&1; then
uclient-fetch -O "$2" "$1" || return 1
elif command -v wget >/dev/null 2>&1; then
# shellcheck disable=SC2086
wget ${WAKEY_INSECURE:+--no-check-certificate} -O "$2" "$1" || return 1
elif command -v curl >/dev/null 2>&1; then
if [ -n "${WAKEY_INSECURE:-}" ]; then
curl -fSL -k -o "$2" "$1" || return 1
else
curl -fSL -o "$2" "$1" || return 1
fi
else
return 2
fi
# fetch <url> <out>
if command -v uclient-fetch >/dev/null 2>&1; then
uclient-fetch -O "$2" "$1" || return 1
elif command -v wget >/dev/null 2>&1; then
wget ${WAKEY_INSECURE:+--no-check-certificate} -O "$2" "$1" || return 1
elif command -v curl >/dev/null 2>&1; then
if [ -n "${WAKEY_INSECURE:-}" ]; then
curl -fSL -k -o "$2" "$1" || return 1
else
curl -fSL -o "$2" "$1" || return 1
fi
else
return 2
fi
}
latest_asset_url() {
# prints URL to stdout, returns 0 on success (public release only)
HOST=$1 OWNER=$2 REPO=$3 ARCH=$4
API="https://$HOST/api/v1/repos/$OWNER/$REPO/releases/latest"
RESP=$(http_get "$API") || return 1
# Extract the first browser_download_url ending with our ARCH .tgz (BusyBox-friendly)
echo "$RESP" \
| grep -o '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*'"$ARCH"'\.tgz"' \
| head -n1 \
| cut -d '"' -f 4
# prints URL to stdout, returns 0 on success (public release only)
HOST=$1 OWNER=$2 REPO=$3 ARCH=$4
API="https://$HOST/api/v1/repos/$OWNER/$REPO/releases/latest"
RESP=$(http_get "$API") || return 1
# Extract the LAST browser_download_url ending with our ARCH .tgz (BusyBox-friendly)
echo "$RESP" |
grep -o '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*'"$ARCH"'\.tgz"' |
tail -n1 |
cut -d '"' -f 4
}
main() {
URL="${WAKEY_TGZ_URL:-}"
if [ -z "$URL" ]; then
HOST=${WAKEY_HOST:-git.ldlda.com}
OWNER=${WAKEY_OWNER:-lda}
REPO=${WAKEY_REPO:-wakey}
if [ -n "${WAKEY_VERSION:-}" ]; then
FILE="wakey-rootfs-${WAKEY_VERSION}-${ARCH}.tgz"
URL="https://$HOST/$OWNER/$REPO/releases/download/${WAKEY_VERSION}/$FILE"
else
URL=$(latest_asset_url "$HOST" "$OWNER" "$REPO" "$ARCH") || fail "unable to resolve latest asset URL"
[ -n "$URL" ] || fail "no asset URL found for arch $ARCH"
fi
fi
[ -f "$FLAG" ] && return
URL="${WAKEY_TGZ_URL:-}"
if [ -z "$URL" ]; then
HOST=${WAKEY_HOST:-git.ldlda.com}
OWNER=${WAKEY_OWNER:-lda}
REPO=${WAKEY_REPO:-wakey}
if [ -n "${WAKEY_VERSION:-}" ]; then
FILE="wakey-rootfs-${WAKEY_VERSION}-${ARCH}.tgz"
URL="https://$HOST/$OWNER/$REPO/releases/download/${WAKEY_VERSION}/$FILE"
else
URL=$(latest_asset_url "$HOST" "$OWNER" "$REPO" "$ARCH") || fail "unable to resolve latest asset URL"
[ -n "$URL" ] || fail "no asset URL found for arch $ARCH"
fi
fi
log "fetching $URL"
if ! fetch "$URL" "$TMPFILE"; then
fail "download failed"
fi
log "fetching $URL"
if ! fetch "$URL" "$TMPFILE"; then
fail "download failed"
fi
log "installing"
tar -xz -f "$TMPFILE" -C / || fail "extract failed"
rm -f "$TMPFILE"
log "installing"
mkdir -p "$TMPDIR" "$STAGING"
tar -xz -f "$TMPFILE" -C "$STAGING" || fail "extract failed"
if [ -f /etc/init.d/wakey ]; then
chmod +x /etc/init.d/wakey || true
/etc/init.d/wakey enable || true
/etc/init.d/wakey restart || /etc/init.d/wakey start || true
fi
log "done"
# Ensure execute bits on staged files we know should be executable
for f in \
"$STAGING/etc/init.d/"* \
"$STAGING/etc/ldlda_help/"*.sh \
"$STAGING/root/.bin/wakey" \
"$STAGING/root/.bin/kill_wakey.sh" \
"$STAGING/root/.bin/remote_deploy_wakey.sh"; do
[ -e "$f" ] && chmod +x "$f" 2>/dev/null || true
done
# Normalize line endings for shell scripts (avoid CRLF issues on OpenWrt)
for f in \
"$STAGING/etc/init.d/"* \
"$STAGING/etc/ldlda_help/"*.sh \
"$STAGING/root/.bin/"*.sh; do
[ -f "$f" ] && sed -i 's/\r$//' "$f" 2>/dev/null || true
done
# Copy staged tree into /
tar -C "$STAGING" -cf - . | tar -C / -xpf - || fail "install copy failed"
rm -f "$TMPFILE"
rm -rf "$STAGING"
if [ -f /etc/init.d/wakey ]; then
/etc/init.d/wakey enable || true
/etc/init.d/wakey restart || /etc/init.d/wakey start || true
fi
touch "$FLAG"
log "done"
}
main "$@"
+307
View File
@@ -0,0 +1,307 @@
# Shared functions for wakey scripts
# Platform detection globals
$script:IsWsl = $null
$script:WarnedNoSshpass = $false
function Test-IsWsl {
if ($null -ne $script:IsWsl) {
return $script:IsWsl
}
$script:IsWsl = $false
# Check for WSL environment variables
if ($env:WSL_DISTRO_NAME -or $env:WSL_INTEROP) {
$script:IsWsl = $true
return $true
}
# Check for /proc/version containing "microsoft" or "wsl"
if ((Test-Path '/proc/version' -ErrorAction SilentlyContinue) -and
(Select-String -Path '/proc/version' -Pattern 'microsoft|wsl' -Quiet -ErrorAction SilentlyContinue)) {
$script:IsWsl = $true
return $true
}
return $false
}
function ConvertTo-WslPath {
param([string]$WindowsPath)
if ([string]::IsNullOrWhiteSpace($WindowsPath)) {
return $WindowsPath
}
# If already a POSIX path, return as-is
if ($WindowsPath -match '^/') {
return $WindowsPath
}
# Handle Windows path (e.g., C:\path\to\file -> /mnt/c/path/to/file)
if ($WindowsPath -match '^([A-Z]):(.*)$') {
$drive = $matches[1].ToLower()
$path = $matches[2] -replace '\\', '/'
return "/mnt/$drive$path"
}
return $WindowsPath
}
function Get-DefaultPassword {
param([string]$Password)
if ($Password) {
return $Password
}
$pwPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'ar_data/pw'
if (-not (Test-Path $pwPath)) {
throw "Password not provided and default file not found: $pwPath"
}
return (Get-Content -Raw $pwPath).Trim()
}
function Normalize-LineEndings {
param([string]$Text)
if ($null -eq $Text) {
return $null
}
return ($Text -replace "`r`n", "`n" -replace "`r", "")
}
function Quote-ShArg {
param([AllowNull()][string]$Value)
if ($null -eq $Value) {
return "''"
}
return "'" + ($Value -replace "'", ("'" + '"' + "'" + '"' + "'")) + "'"
}
function Normalize-PosixPath {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) {
return $Path
}
$normalized = $Path -replace '\\', '/'
if ($normalized.Length -gt 1) {
$normalized = $normalized -replace '/+', '/'
}
return $normalized
}
function Join-PosixPath {
param(
[string]$Left,
[string]$Right
)
$leftNorm = Normalize-PosixPath $Left
$rightNorm = Normalize-PosixPath $Right
if ([string]::IsNullOrWhiteSpace($leftNorm)) {
return $rightNorm
}
if ([string]::IsNullOrWhiteSpace($rightNorm)) {
return $leftNorm
}
$leftTrim = $leftNorm.TrimEnd('/')
$rightTrim = $rightNorm.TrimStart('/')
return "$leftTrim/$rightTrim"
}
function Split-HostPort {
param(
[string]$HostName,
[int]$DefaultPort = 22
)
$result = [ordered]@{
Host = $HostName
Port = $DefaultPort
}
if ([string]::IsNullOrWhiteSpace($HostName)) {
return [PSCustomObject]$result
}
# IPv6 with brackets: [2001:db8::1]:2222
if ($HostName -match '^\[(.+)\]:(\d+)$') {
$result.Host = $matches[1]
$result.Port = [int]$matches[2]
return [PSCustomObject]$result
}
# host:port (single colon only, avoids plain IPv6 addresses)
if ($HostName -match '^([^:]+):(\d+)$') {
$result.Host = $matches[1]
$result.Port = [int]$matches[2]
return [PSCustomObject]$result
}
return [PSCustomObject]$result
}
function Get-SshpassCommand {
if ($cmd = Get-Command sshpass -ErrorAction SilentlyContinue) {
return $cmd.Path
}
return $null
}
function Invoke-Ext {
param($Exe, $Arguments, $Label)
$displayArgs = $Arguments.Clone()
for ($i = 0; $i -lt $displayArgs.Count; $i++) {
if ($displayArgs[$i] -eq '-pw' -and ($i + 1) -lt $displayArgs.Count) {
$displayArgs[$i + 1] = '****'
}
if ($displayArgs[$i] -ceq '-p' -and ($i + 1) -lt $displayArgs.Count -and $Exe -like '*sshpass*') {
$displayArgs[$i + 1] = '****'
}
}
Write-Host ("[{0}] {1} {2}" -f $Label, $Exe, ($displayArgs -join ' ')) -ForegroundColor Cyan
$out = & $Exe @Arguments 2>&1
if ($LASTEXITCODE -ne 0) {
if ($Exe -like '*sshpass*' -and $LASTEXITCODE -eq 6) {
Write-Error ("{0} failed: sshpass could not confirm host key automatically (code 6). Use ssh once manually, or enable StrictHostKeyChecking=accept-new." -f $Label)
}
Write-Error ("{0} exited with code {1}:`n{2}" -f $Label, $LASTEXITCODE, ($out -join "`n"))
throw ("{0} exited with code {1}" -f $Label, $LASTEXITCODE)
}
return $out
}
function Invoke-Scp {
param($Local, $Dest, $Pass, $HostKey, [int]$Port = 22, [switch]$Quiet, [switch]$Recurse, [switch]$ForcePassword)
$isWin = [bool]$IsWindows
$isWsl = Test-IsWsl
# Only use PuTTY on Windows (not in WSL)
if ($isWin -and -not $isWsl -and ($pscp = Get-Command pscp.exe -ErrorAction SilentlyContinue)) {
$arguments = @('-scp')
if ($Quiet) { $arguments += '-q' }
if ($Recurse) { $arguments += '-r' }
if ($Port -gt 0) { $arguments += @('-P', $Port) }
if ($HostKey) { $arguments += @('-batch', '-hostkey', $HostKey) }
if ($Pass) { $arguments += @('-pw', $Pass) }
$arguments += @($Local)
$arguments += $Dest
Invoke-Ext -Exe $pscp.Path -Arguments $arguments -Label 'scp'
}
else {
# Convert Windows paths to WSL paths if needed
if ($isWsl) {
$Local = @($Local | ForEach-Object { ConvertTo-WslPath $_ })
$Dest = ConvertTo-WslPath $Dest
}
$arguments = @('-O')
if ($Quiet) { $arguments += '-q' }
if ($Recurse) { $arguments += '-r' }
# Avoid interactive host-key prompts (important for sshpass usage)
$arguments += @('-o', 'StrictHostKeyChecking=accept-new')
if ($ForcePassword) {
$arguments += @('-o', 'PreferredAuthentications=keyboard-interactive,password')
$arguments += @('-o', 'PubkeyAuthentication=no')
$arguments += @('-o', 'KbdInteractiveAuthentication=yes')
$arguments += @('-o', 'PasswordAuthentication=yes')
$arguments += @('-o', 'NumberOfPasswordPrompts=1')
}
if ($Port -gt 0) { $arguments += @('-P', $Port) }
$arguments += @($Local)
$arguments += $Dest
if ($Pass) {
$sshpassExe = Get-SshpassCommand
if ($sshpassExe) {
$wrapped = @('-p', $Pass, 'scp') + $arguments
Invoke-Ext -Exe $sshpassExe -Arguments $wrapped -Label 'scp'
return
}
if (-not $script:WarnedNoSshpass) {
Write-Warning 'Password was provided but sshpass is not installed; falling back to plain scp (key/agent auth expected).'
$script:WarnedNoSshpass = $true
}
}
if ($ForcePassword -and -not $Pass) {
Write-Error 'ForcePassword requires Pass to be provided.'
throw 'ForcePassword requires Pass'
}
if ($ForcePassword -and $Pass -and -not (Get-SshpassCommand)) {
Write-Error 'ForcePassword was requested but sshpass is not installed. Install sshpass or disable ForcePassword.'
throw 'ForcePassword requires sshpass'
}
Invoke-Ext -Exe 'scp' -Arguments $arguments -Label 'scp'
}
}
function Invoke-Ssh {
param($Cmd, $User, $Remote, $Pass, [int]$Port = 22, [switch]$Quiet, [switch]$ForcePassword)
$Cmd = Normalize-LineEndings $Cmd
$isWin = [bool]$IsWindows
$isWsl = Test-IsWsl
# Only use PuTTY on Windows (not in WSL)
if ($isWin -and -not $isWsl -and ($plink = Get-Command plink.exe -ErrorAction SilentlyContinue)) {
$arguments = @('-batch', '-ssh')
if ($Port -gt 0) { $arguments += @('-P', $Port) }
if ($Pass) { $arguments += @('-pw', $Pass) }
$dest = if ($User) { "$User@$Remote" } else { $Remote }
$arguments += $dest, $Cmd
Invoke-Ext -Exe $plink.Path -Arguments $arguments -Label 'ssh'
}
else {
$arguments = @()
if ($Quiet) { $arguments += '-q' }
# Avoid interactive host-key prompts (important for sshpass usage)
$arguments += @('-o', 'StrictHostKeyChecking=accept-new')
if ($ForcePassword) {
$arguments += @('-o', 'PreferredAuthentications=keyboard-interactive,password')
$arguments += @('-o', 'PubkeyAuthentication=no')
$arguments += @('-o', 'KbdInteractiveAuthentication=yes')
$arguments += @('-o', 'PasswordAuthentication=yes')
$arguments += @('-o', 'NumberOfPasswordPrompts=1')
}
if ($Port -gt 0) { $arguments += @('-p', $Port) }
$dest = if ($User) { "$User@$Remote" } else { $Remote }
$arguments += $dest, $Cmd
if ($Pass) {
$sshpassExe = Get-SshpassCommand
if ($sshpassExe) {
$wrapped = @('-p', $Pass, 'ssh') + $arguments
Invoke-Ext -Exe $sshpassExe -Arguments $wrapped -Label 'ssh'
return
}
if (-not $script:WarnedNoSshpass) {
Write-Warning 'Password was provided but sshpass is not installed; falling back to plain ssh (key/agent auth expected).'
$script:WarnedNoSshpass = $true
}
}
if ($ForcePassword -and -not $Pass) {
Write-Error 'ForcePassword requires Pass to be provided.'
throw 'ForcePassword requires Pass'
}
if ($ForcePassword -and $Pass -and -not (Get-SshpassCommand)) {
Write-Error 'ForcePassword was requested but sshpass is not installed. Install sshpass or disable ForcePassword.'
throw 'ForcePassword requires sshpass'
}
Invoke-Ext -Exe 'ssh' -Arguments $arguments -Label 'ssh'
}
}
+212
View File
@@ -0,0 +1,212 @@
"thanks chatgpt"
import os
import textwrap
from abc import ABC, abstractmethod
from collections.abc import Iterable
from pathlib import Path
from typing import Callable
root = Path(__file__).parent.parent
static = root / "static"
src = root / "src"
out_rs = src / "assets.rs"
assert 'name = "wakey"' in (root / "Cargo.toml").read_text(encoding="utf-8"), (
"uhh how do i explain this"
)
def sanitize(name: str) -> str:
# Valid Rust identifiers: letters, digits, underscores; no starting digit
s = "".join(c if c.isalnum() else "_" for c in name)
if s and s[0].isdigit():
s = "_" + s
return s
def indent(text: str, n: int) -> str:
pad = " " * n
return textwrap.indent(text, pad)
class RsAsset(ABC):
path: Path
class RsAssetWithName(ABC):
name: str
@staticmethod
@abstractmethod
def name_gen(path: Path) -> str:
"helper"
class RsAssetFile(RsAsset, RsAssetWithName):
__match_args__ = ("path", "name")
def __init__(self, path: Path, name: str | None = None):
self.path = path
if name is None:
self.name: str = RsAssetFile.name_gen(path)
else:
self.name = name
def path_relative_to(self, path: Path):
"helper"
return self.path.relative_to(path, walk_up=True).as_posix()
@staticmethod
def plain_template(const_name: str, relative_path: str):
return f'pub const {const_name}: &str = include_str!("{relative_path}");'
@staticmethod
def macroed_template(const_name: str, relative_path: str):
return f'file {const_name} "{relative_path}"'
@staticmethod
def name_gen(path: Path) -> str:
return sanitize(path.name).upper()
class RsAssetModule(RsAsset, RsAssetWithName):
__match_args__ = ("path", "name")
def __init__(self, path: Path, name: str | None = None):
self.path = path
if name is None:
self.name: str = RsAssetModule.name_gen(path)
else:
self.name = name
@staticmethod
def plain_template(sanitized_name: str, body: str):
"braindead"
indented_body = indent(body, 4)
return f"pub mod {sanitized_name} {{\n{indented_body}\n}}"
@staticmethod
def macroed_template(sanitized_name: str, body: str):
indented_body = indent(body, 4)
return f"folder {sanitized_name} {{\n{indented_body}\n}}"
@staticmethod
def process_body(path: Path, renderer: Callable[[RsAsset], str]):
"here just cuz. Path has to be a folder... so idk"
body = RsAssetModule.iterate_assets(path)
return "\n".join(map(renderer, body))
@staticmethod
def iterate_assets(path: Path) -> "Iterable[RsAssetFile | RsAssetModule]":
subs = []
for f in path.iterdir():
if f.is_file():
yield RsAssetFile(f)
elif f.is_dir():
subs.append(RsAssetModule(f))
yield from subs
@staticmethod
def name_gen(path: Path) -> str:
return sanitize(path.name).lower()
# i meant plain
def render_pain(ass: RsAsset) -> str:
"""
fym i have to deal with all RsAsset subclasses.
ts like a matrix of ahh.
"""
match ass:
case RsAssetRoot(path):
body = RsAssetModule.process_body(path, render_pain)
return RsAssetRoot.plain_template(body)
case RsAssetModule(path, name):
body = RsAssetModule.process_body(path, render_pain)
return RsAssetModule.plain_template(name, body)
case RsAssetFile(path, name) as file:
return RsAssetFile.plain_template(name, file.path_relative_to(src))
case _:
raise TypeError("who are you?")
def render_macro(ass: RsAsset) -> str:
"""
ts is ridiculous
"""
match ass:
case RsAssetRoot(path):
body = RsAssetModule.process_body(path, render_macro)
return RsAssetRoot.macroed_template(body)
case RsAssetModule(path, name):
body = RsAssetModule.process_body(path, render_macro)
return RsAssetModule.macroed_template(name, body)
case RsAssetFile(path, name) as file:
return RsAssetFile.macroed_template(name, file.path_relative_to(src))
case _:
raise TypeError("who are you?")
# region shit ass
# im deleting this code because its so ass
# endregion
lda_macro = """
macro_rules! hehe {
// Folder
(folder $name:ident { $($children:tt)* } $($rest:tt)*) => {
pub mod $name {
hehe!{$($children)*}
}
hehe!{$($rest)*}
};
// File
(file $name:ident $file:literal $($rest:tt)*) => {
pub const $name: &str = include_str!($file);
hehe!{$($rest)*}
};
// Base case
() => {};
}
"""
header = "// generated with ./scripts/map_static.py"
class RsAssetRoot(RsAsset):
__match_args__ = ("path",)
@staticmethod
def plain_template(body: str):
return f"""{header}
{body}
"""
@staticmethod
def macroed_template(body: str):
indented_body = indent(body, 4)
return f"""{header}
{lda_macro}
hehe! {{
{indented_body}
}}
"""
def __init__(self, path: Path) -> None:
os.scandir(path) # is you a dir?
self.path = path
asset_root = RsAssetRoot(static)
# print(render_pain(asset_root))
# print(render_macro(asset_root))
# rs_code = render_pain(asset_root)
rs_code = render_macro(asset_root)
# generate
out_rs.write_text(rs_code, encoding="utf-8")
print(f"written to {out_rs.relative_to(Path.cwd(), walk_up=True)}")
-53
View File
@@ -1,53 +0,0 @@
# Package a release tarball with binaries and init scripts.
# Usage: ./scripts/package.ps1 -Version 0.1.0 -OutDir dist
param(
[Parameter(Mandatory = $true)][string]$Version,
[string]$OutDir = "dist",
[string[]]$Targets = @("armv7-unknown-linux-musleabihf")
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $MyInvocation.MyCommand.Path | Split-Path -Parent
$dist = Join-Path $root $OutDir
New-Item -ItemType Directory -Force -Path $dist | Out-Null
$pkgName = "wakey-$Version"
$pkgDir = Join-Path $dist $pkgName
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $pkgDir
New-Item -ItemType Directory -Force -Path $pkgDir | Out-Null
# Copy LICENSE/README if present
if (Test-Path "$root/README.md") { Copy-Item "$root/README.md" $pkgDir }
if (Test-Path "$root/LICENSE") { Copy-Item "$root/LICENSE" $pkgDir }
# Binaries per target
foreach ($t in $Targets) {
$binName = if ($t -like "*-windows-*") { "wakey.exe" } else { "wakey" }
$src = Join-Path $root "target/$t/release/$binName"
if (-not (Test-Path $src)) { throw "Missing binary: $src" }
$tDir = Join-Path $pkgDir "bin/$t"
New-Item -ItemType Directory -Force -Path $tDir | Out-Null
Copy-Item $src $tDir
}
# Init scripts
$initDir = Join-Path $pkgDir "init"
New-Item -ItemType Directory -Force -Path $initDir | Out-Null
Copy-Item "$root/scripts/init" $initDir -Recurse
# Systemd service (for Linux hosts)
Copy-Item "$root/scripts/systemd" $initDir -Recurse -ErrorAction SilentlyContinue
# Tarball
Push-Location $dist
if (Get-Command tar -ErrorAction SilentlyContinue) {
tar -czf "$pkgName.tgz" "$pkgName"
}
else {
# Fallback to zip on Windows
Compress-Archive -Path "$pkgName" -DestinationPath "$pkgName.zip" -Force
}
Pop-Location
Write-Host "Packaged: $dist/$pkgName.(tgz|zip)"
Regular → Executable
+48 -5
View File
@@ -1,12 +1,15 @@
#!/usr/bin/env pwsh
# Build a rootfs tarball suitable for `wget -O- ... | tar -xz -C /` on OpenWrt.
# It lays out files as absolute-root paths inside the archive:
# root/.bin/wakey
# root/.bin/wakey-agent
# etc/init.d/wakey
# Usage: ./scripts/package_rootfs.ps1 -Version 0.1.0 -Target armv7-unknown-linux-musleabihf -OutDir dist
param(
[Parameter(Mandatory = $true)][string]$Version,
[Parameter(Mandatory = $true)][string]$Target,
[string]$OutDir = "dist"
[string]$OutDir = "dist",
[switch]$NoBin
)
$ErrorActionPreference = 'Stop'
@@ -16,8 +19,9 @@ $dist = Join-Path $root $OutDir
New-Item -ItemType Directory -Force -Path $dist | Out-Null
$binName = if ($Target -like "*-windows-*") { "wakey.exe" } else { "wakey" }
$agentBinName = if ($Target -like "*-windows-*") { "wakey-agent.exe" } else { "wakey-agent" }
$binSrc = Join-Path $root "target/$Target/release/$binName"
if (-not (Test-Path $binSrc)) { throw "Missing binary: $binSrc (build it first)" }
$agentBinSrc = Join-Path $root "target/$Target/release/$agentBinName"
$staging = Join-Path $dist ("rootfs-" + [System.Guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Force -Path $staging | Out-Null
@@ -28,12 +32,43 @@ $etcDir = Join-Path $staging "etc/init.d"
New-Item -ItemType Directory -Force -Path $rootDir | Out-Null
New-Item -ItemType Directory -Force -Path $etcDir | Out-Null
Copy-Item $binSrc (Join-Path $rootDir "wakey") -Force
if (-not $NoBin) {
if (Test-Path $binSrc) {
Copy-Item $binSrc (Join-Path $rootDir "wakey") -Force
}
else {
throw "Missing binary: $binSrc (build it first or pass -NoBin)"
}
if (Test-Path $agentBinSrc) {
Copy-Item $agentBinSrc (Join-Path $rootDir "wakey-agent") -Force
}
else {
throw "Missing binary: $agentBinSrc (build it first or pass -NoBin)"
}
}
# Normalize kill script line endings and copy
$killSrc = Join-Path $root 'scripts/kill_wakey.sh'
if (Test-Path $killSrc) {
$killContent = Get-Content -Raw -LiteralPath $killSrc
$killContent = $killContent -replace "`r`n", "`n"
Set-Content -NoNewline -LiteralPath (Join-Path $rootDir "kill_wakey.sh") -Value $killContent -Encoding UTF8
}
# Normalize remote deploy script and copy (optional helper)
$deploySrc = Join-Path $root 'scripts/remote_deploy_wakey.sh'
if (Test-Path $deploySrc) {
$deployContent = Get-Content -Raw -LiteralPath $deploySrc
$deployContent = $deployContent -replace "`r`n", "`n"
Set-Content -NoNewline -LiteralPath (Join-Path $rootDir "remote_deploy_wakey.sh") -Value $deployContent -Encoding UTF8
}
# Copy all OpenWrt init scripts present in repo
Get-ChildItem (Join-Path $root 'scripts/init/openwrt') -File | ForEach-Object {
$dest = Join-Path $etcDir $_.Name
Copy-Item $_.FullName $dest -Force
$content = Get-Content -Raw -LiteralPath $_.FullName
# normalize to LF line endings
$content = $content -replace "`r`n", "`n"
Set-Content -NoNewline -LiteralPath $dest -Value $content -Encoding UTF8
}
# Copy helper scripts intended for /etc/ldlda_help
@@ -42,7 +77,10 @@ if (Test-Path $helpSrc) {
$etcHelpDir = Join-Path $staging 'etc/ldlda_help'
New-Item -ItemType Directory -Force -Path $etcHelpDir | Out-Null
Get-ChildItem $helpSrc -File | ForEach-Object {
Copy-Item $_.FullName (Join-Path $etcHelpDir $_.Name) -Force
$d = Join-Path $etcHelpDir $_.Name
$c = Get-Content -Raw -LiteralPath $_.FullName
$c = $c -replace "`r`n", "`n"
Set-Content -NoNewline -LiteralPath $d -Value $c -Encoding UTF8
}
}
@@ -64,4 +102,9 @@ Remove-Item -Recurse -Force $staging
Write-Host "Rootfs package: " (Join-Path $dist $pkgName)
Write-Host "On router: wget -O- <URL/$pkgName> | tar -xz -C /"
Write-Host "Then: chmod +x /etc/init.d/wakey && /etc/init.d/wakey enable && /etc/init.d/wakey start"
if ($NoBin) {
Write-Host "Note: -NoBin used, binary not included. Only scripts/configs were packaged." -ForegroundColor Yellow
}
Write-Host "Helper scripts: /etc/ldlda_help/* (e.g., update_wakey.sh, update_tailscale.sh). Mark executable if needed: chmod +x /etc/ldlda_help/*.sh"
Write-Host "Kill helper: /root/.bin/kill_wakey.sh (make it executable: chmod +x /root/.bin/kill_wakey.sh)"
<# if (Test-Path $deploySrc) { #> Write-Host "Deploy helper: /root/.bin/remote_deploy_wakey.sh (chmod +x /root/.bin/remote_deploy_wakey.sh)" # }
Regular → Executable
+53 -5
View File
@@ -1,3 +1,4 @@
#!/usr/bin/env pwsh
# Publish to registry and tag
# Usage: ./scripts/publish.ps1 -Version 0.1.0 -Tag
# Publish to registry and tag (manual; CI does not use this)
@@ -9,20 +10,38 @@
param(
[string]$Version,
[switch]$Tag,
[switch]$Publish
[switch]$ForceTag,
[string]$Registry
[switch]$Publish,
[string]$Registry,
[ValidateSet('wsl', 'windows', 'none')]
[string]$RunnerMode = 'wsl',
[string]$WslDistro = $(if ($env:WAKEY_WSL_DISTRO) { $env:WAKEY_WSL_DISTRO } else { 'FedoraLinux-43' })
)
$ErrorActionPreference = 'Stop'
if ($Version) {
# If no version provided, deduce from Cargo.toml
$versionProvided = [bool]$Version
if (-not $Version) {
$cargoToml = Get-Content Cargo.toml -Raw
if ($cargoToml -match 'version\s*=\s*"([^"]+)"') {
$Version = $matches[1]
Write-Host "Deduced version from Cargo.toml: $Version"
}
else {
Write-Error "Could not deduce version from Cargo.toml. Please specify -Version."
exit 1
}
}
# Only write version if it was explicitly provided (not deduced)
if ($versionProvided) {
$pattern = '(?m)^version\s*=\s*"[^"]+"'
$replacement = ('version = "{0}"' -f $Version)
(Get-Content Cargo.toml -Raw) -replace $pattern, $replacement | Set-Content Cargo.toml -Encoding UTF8
(Get-Content Cargo.toml -Raw) -replace $pattern, $replacement | Set-Content Cargo.toml -Encoding UTF8 -NoNewline
}
# Ensure build ok
cargo fmt
cargo build --release
# Publish crate (only if -Publish and registry auth is available)
@@ -32,6 +51,8 @@ if ($Publish) {
$pubArgs = @('publish')
if ($Registry) { $pubArgs += @('--registry', $Registry) }
cargo @pubArgs
Write-Host ("published. ran cargo {0}" -f ($pubArgs -join " "))
}
catch {
Write-Warning ("cargo publish failed: {0}" -f $_)
@@ -41,4 +62,31 @@ if ($Publish) {
if ($Tag -and $Version) {
git tag -f "v$Version"
git push -f origin "v$Version"
Write-Host "Pushed tag: v$Version"
switch ($RunnerMode) {
'wsl' {
$runnerScript = Join-Path $PSScriptRoot 'act_runner_wsl.ps1'
if (Test-Path $runnerScript) {
Write-Host "Starting Fedora/WSL act_runner (once mode) to process release job..."
& $runnerScript -Distro $WslDistro -Action start -Once
}
else {
Write-Warning "act_runner_wsl.ps1 not found at $runnerScript. Skipping runner."
}
}
'windows' {
$runnerScript = Join-Path $PSScriptRoot 'act_runner.ps1'
if (Test-Path $runnerScript) {
Write-Host "Starting Windows act_runner (once mode) to process release job..."
& $runnerScript -Once
}
else {
Write-Warning "act_runner.ps1 not found at $runnerScript. Skipping runner."
}
}
'none' {
Write-Host "RunnerMode=none; not starting a local runner helper."
}
}
}
-14
View File
@@ -1,14 +0,0 @@
# One-shot: build, package, and print artifact location (no cross required).
# Usage: ./scripts/release.ps1 -Version 0.1.0 -Targets armv7-unknown-linux-musleabihf
param(
[Parameter(Mandatory = $true)][string]$Version,
[string[]]$Targets = @("armv7-unknown-linux-musleabihf")
)
$ErrorActionPreference = 'Stop'
foreach ($t in $Targets) {
rustup target add $t
cargo build --release --target $t
}
./scripts/package.ps1 -Version $Version -Targets $Targets
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
# Usage: remote_deploy_wakey.sh <bin_tmp> <dest_path> [restart_flag]
# restart_flag: 1 (default) to stop/start, 0 to only replace file.
set -e
BIN_TMP="$1"
DEST="$2"
RESTART="${3:-1}"
INIT="/etc/init.d/wakey"
KILL="/root/.bin/kill_wakey.sh"
if [ -z "$BIN_TMP" ] || [ -z "$DEST" ]; then
echo "usage: $0 <bin_tmp> <dest_path> [restart_flag]" >&2
exit 2
fi
[ -f "$BIN_TMP" ] || {
echo "tmp binary not found: $BIN_TMP" >&2
exit 1
}
chmod +x "$BIN_TMP" || true
if [ "$RESTART" = "1" ]; then
if [ -x "$INIT" ]; then
"$INIT" stop || true
elif [ -x "$KILL" ]; then
sh "$KILL" || true
fi
fi
mv -f "$BIN_TMP" "$DEST"
if [ "$RESTART" = "1" ]; then
if [ -x "$INIT" ]; then
"$INIT" start || "$INIT" restart || true
else
nohup "$DEST" >/dev/null 2>&1 &
fi
fi
exit 0
-15
View File
@@ -1,15 +0,0 @@
[Unit]
Description=Wakey WOL+ARP service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/wakey
Restart=on-failure
RestartSec=2
User=wakey
Group=wakey
[Install]
WantedBy=multi-user.target
+258
View File
@@ -0,0 +1,258 @@
#!/usr/bin/env pwsh
# Test ipjs on remote ARM device
param(
[string]$Package = "lda-ipjs",
[switch]$AllPackages,
[string]$BinaryFilter = "",
[string]$Filter = "",
[switch]$Exact,
[switch]$List,
[ValidateSet("debug", "release")]
[string]$BuildProfile = "release",
[string]$password,
[switch]$Verbose,
[string]$RemoteTestPath = "/tmp/tmp/wakey-test",
[string]$RemoteHost = "[email protected]",
[int]$RemotePort = 2222,
[switch]$ForcePassword,
[switch]$Ignored,
[switch]$IncludeIgnored,
[switch]$NoCapture,
[switch]$ShowOutput,
[int]$Threads = 0
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot/lib.ps1"
$password = Get-DefaultPassword $password
$sshRemote = $RemoteHost
$hostPort = Split-HostPort -HostName $sshRemote -DefaultPort $RemotePort
$sshRemote = $hostPort.Host
$RemotePort = $hostPort.Port
function Get-WorkspacePackages {
$metadata = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json
$workspaceMembers = [System.Collections.Generic.HashSet[string]]::new()
foreach ($member in $metadata.workspace_members) {
[void]$workspaceMembers.Add($member)
}
@(
$metadata.packages |
Where-Object { $workspaceMembers.Contains($_.id) } |
Select-Object -ExpandProperty name
)
}
function Get-TestBinaryPaths {
param([string[]]$CargoOutput)
$paths = New-Object System.Collections.Generic.List[string]
foreach ($line in $CargoOutput) {
if ([string]::IsNullOrWhiteSpace($line)) {
continue
}
try {
$msg = $line | ConvertFrom-Json -ErrorAction Stop
}
catch {
continue
}
if ($msg.reason -ne "compiler-artifact") {
continue
}
if (-not $msg.executable) {
continue
}
$isTestProfile = $false
if ($null -ne $msg.profile -and $null -ne $msg.profile.test) {
$isTestProfile = [bool]$msg.profile.test
}
if (-not $isTestProfile) {
continue
}
$paths.Add([string]$msg.executable)
}
@($paths)
}
function Build-RemoteExecCommand {
param(
[string]$RemotePath,
[string[]]$Arguments
)
$quotedPath = Quote-ShArg $RemotePath
$quotedArgs = @($Arguments | ForEach-Object { Quote-ShArg "$_" })
$exec = (@($quotedPath) + $quotedArgs) -join " "
"chmod +x $quotedPath && $exec"
}
function New-RemoteRunDir {
param(
[string]$BasePath,
[string]$PackageName
)
$baseNorm = Normalize-PosixPath $BasePath
$leaf = [IO.Path]::GetFileName($baseNorm)
$parent = Normalize-PosixPath ([IO.Path]::GetDirectoryName($baseNorm))
if ([string]::IsNullOrWhiteSpace($parent)) {
$parent = "/tmp"
}
if ([string]::IsNullOrWhiteSpace($leaf)) {
$leaf = "wakey-test"
}
$random = [System.Guid]::NewGuid().ToString("N").Substring(0, 10)
$safePackage = ($PackageName -replace '[^A-Za-z0-9._-]', '_')
return (Join-PosixPath $parent "$leaf-$safePackage-$random")
}
function New-RemoteBinaryPath {
param(
[string]$RemoteRunDir,
[System.IO.FileInfo]$TestBinary
)
$safeName = ($TestBinary.Name -replace '[^A-Za-z0-9._-]', '_')
Join-PosixPath $RemoteRunDir $safeName
}
function Ensure-RemoteParentDir {
param(
[string]$RemoteDirPath,
[string]$RemoteHost,
[string]$Password,
[int]$Port,
[switch]$ForcePassword
)
$dir = Normalize-PosixPath $RemoteDirPath
if ([string]::IsNullOrWhiteSpace($dir)) {
return
}
Invoke-Ssh -Cmd ("mkdir -p " + (Quote-ShArg $dir)) -Remote $RemoteHost -Pass $Password -Port $Port -Quiet -ForcePassword:$ForcePassword
}
$packages = if ($AllPackages) { Get-WorkspacePackages } else { @($Package) }
$failures = New-Object System.Collections.Generic.List[string]
foreach ($packageName in $packages) {
Write-Host "Building tests for $packageName..." -ForegroundColor Cyan
# Stream cargo output and convert to text
$cargoOutput = cargo test --no-run -p $packageName --target armv7-unknown-linux-musleabihf --message-format json $(if ($BuildProfile -eq "release") { "-r" }) 2>&1 |
ForEach-Object {
$line = if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.ToString() } else { $_ }
# if ($Verbose) {
# Write-Host $line # this thing fills QUICK im not printing any
# }
$line
}
if ($LASTEXITCODE -ne 0) {
$failures.Add("$packageName (build failed)")
Write-Warning "Build failed for $packageName"
continue
}
$testBinaryPaths = Get-TestBinaryPaths $cargoOutput
$testBinaries = @($testBinaryPaths | Get-Item)
if ($BinaryFilter) {
$testBinaries = @(
$testBinaries |
Where-Object {
$_.Name -like "*$BinaryFilter*" -or $_.BaseName -like "*$BinaryFilter*"
}
)
}
if ($testBinaries.Count -eq 0) {
$reason = if ($BinaryFilter) {
"no test executables matched binary filter '$BinaryFilter'"
}
else {
"no test executables"
}
Write-Host "Skipping ${packageName}: $reason" -ForegroundColor DarkYellow
continue
}
Write-Host "Found $($testBinaries.Count) test $($testBinaries.Count -eq 1 ? "binary" : "binaries") for $packageName" -ForegroundColor Green
$remoteRunDir = New-RemoteRunDir -BasePath $RemoteTestPath -PackageName $packageName
Ensure-RemoteParentDir -RemoteDirPath $remoteRunDir -RemoteHost $sshRemote -Password $password -Port $RemotePort -ForcePassword:$ForcePassword
try {
$remoteUploadDir = (Normalize-PosixPath $remoteRunDir).TrimEnd('/') + '/'
# Convert local file paths to WSL paths if running in WSL
$localFilePaths = @($testBinaries | ForEach-Object {
if (Test-IsWsl) {
ConvertTo-WslPath $_.FullName
}
else {
$_.FullName
}
})
Invoke-Scp -Local $localFilePaths -Dest "${sshRemote}:$remoteUploadDir" -Pass $password -Port $RemotePort -Quiet -ForcePassword:$ForcePassword
foreach ($testBinary in $testBinaries) {
Write-Host "`nTesting: $packageName / $($testBinary.Name)" -ForegroundColor Cyan
$remoteBinaryPath = New-RemoteBinaryPath -RemoteRunDir $remoteRunDir -TestBinary $testBinary
# Build test args
$parts = @()
if ($Filter) { $parts += $Filter }
if ($Exact) { $parts += "--exact" }
if ($List) { $parts += "--list" }
if ($Ignored) { $parts += "--ignored" }
if ($IncludeIgnored) { $parts += "--include-ignored" }
if ($ShowOutput -or $Verbose) { $parts += "--show-output" }
if ($NoCapture -or $Verbose) { $parts += "--nocapture" }
if ($Threads -gt 0) { $parts += "--test-threads"; $parts += $Threads }
$remoteCmd = Build-RemoteExecCommand -RemotePath $remoteBinaryPath -Arguments $parts
# Run test binary (with chmod to ensure executable)
try {
Invoke-Ssh -Cmd $remoteCmd -Remote $sshRemote -Pass $password -Port $RemotePort -ForcePassword:$ForcePassword
}
catch {
$failures.Add("$packageName / $($testBinary.Name)")
Write-Warning "Test binary failed: $packageName / $($testBinary.Name)"
Write-Warning $_.Exception.Message
continue
}
}
}
finally {
try {
Invoke-Ssh -Cmd ("rm -rf " + (Quote-ShArg $remoteRunDir)) -Remote $sshRemote -Pass $password -Port $RemotePort -Quiet -ForcePassword:$ForcePassword
}
catch {
Write-Warning "Failed to remove remote test dir: $remoteRunDir"
}
}
}
if ($failures.Count -gt 0) {
Write-Error ("One or more test binaries failed: {0}" -f ($failures -join ", "))
exit 1
}
Write-Host "`nDone!" -ForegroundColor Green
-261
View File
@@ -1,261 +0,0 @@
// struct arp;
// async fn read_arp() -> io::Result<()> {
// let arp_file = tokio::fs::File::open("/proc/net/arp").await?;
// let arp_read = BufReader::new(arp_file);
// Ok(())
// }
//! ip neigh pass
use std::{net::IpAddr, str::FromStr};
use macaddr::MacAddr;
use serde::{Deserialize, Serialize, Serializer, de};
use serde_with::skip_serializing_none;
use strum::{Display, EnumString};
use crate::arpparse::error::IPNeighParseError;
mod error;
mod r#impl; // custom (de)serialization impls
/// ip neigh has some cool shit.
///
/// IP
/// dev DEV | None
/// lladdr MAC | None
/// status { permanent | noarp | stale | reachable | none | incomplete | delay | probe | failed } (ip neigh help)
///
/// so you can see its damn good
#[skip_serializing_none]
#[derive(Debug, PartialEq, Eq, Clone, Hash, serde::Serialize)]
pub struct IpNeighLine {
pub ip: IpAddr,
pub dev: Option<String>,
/// link layer address
#[serde(serialize_with = "ser_opm")]
pub mac: Option<MacAddr>,
/// Neighbour Unreachability Detection
pub state: NUDState,
}
/// serialize an [`Option<MacAddr>`]
pub fn ser_opm<S: Serializer>(bro: &Option<MacAddr>, ser: S) -> Result<S::Ok, S::Error> {
Option::<String>::serialize(&bro.as_ref().map(ToString::to_string), ser)
}
/// deserialize an [`Option<MacAddr>`]
pub fn _des_opm<'de, D>(des: D) -> Result<Option<MacAddr>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<&str>::deserialize(des)?
.map(str::parse)
.transpose()
.map_err(de::Error::custom)
}
// NUDState custom Deserialize now lives in arpparse/impl.rs; use serde_with OneOrMany for Vec
#[derive(Debug, PartialEq, Eq, EnumString, Display, Clone, Copy, Hash, serde::Serialize)]
#[strum(serialize_all = "UPPERCASE", ascii_case_insensitive)]
#[serde(rename_all = "UPPERCASE")]
pub enum NUDState {
/// the neighbour entry is valid forever and can
/// be only be removed administratively.
Permanent,
/// the neighbour entry is valid. No attempts to
/// validate this entry will be made but it can
/// be removed when its lifetime expires.
Noarp,
/// the neighbour entry is valid until the
/// reachability timeout expires.
Reachable,
/// the neighbour entry is valid but suspicious.
/// This option to ip neigh does not change the
/// neighbour state if it was valid and the
/// address is not changed by this command.
Stale,
/// this is a pseudo state used when initially
/// creating a neighbour entry or after trying to
/// remove it before it becomes free to do so.
None,
/// the neighbour entry has not (yet) been
/// validated/resolved.
Incomplete,
/// neighbor entry validation is currently
/// delayed.
Delay,
/// neighbor is being probed.
Probe,
/// max number of probes exceeded without
/// success, neighbor validation has ultimately
/// failed.
Failed,
}
impl NUDState {
/// Argument form expected by `ip neigh ... nud <state>` (lowercase)
pub const fn as_ip_neigh_arg(self) -> &'static str {
match self {
NUDState::Permanent => "permanent",
NUDState::Reachable => "reachable",
NUDState::Stale => "stale",
NUDState::Delay => "delay",
NUDState::Probe => "probe",
NUDState::Incomplete => "incomplete",
NUDState::Noarp => "noarp",
NUDState::None => "none",
NUDState::Failed => "failed",
}
}
/// dumb UI label
pub fn dumber_state(&self) -> &'static str {
match self {
NUDState::Permanent | NUDState::Reachable => "online",
NUDState::Stale => "maybe online",
NUDState::Delay | NUDState::Probe | NUDState::Incomplete => "resolving",
NUDState::Noarp => "static",
NUDState::None => "unknown",
NUDState::Failed => "offline",
}
}
/// dumb boolean: Some(true)=on, Some(false)=off, None=shrug
pub fn _dumber_state_this_way(&self) -> Option<bool> {
match self {
NUDState::Permanent | NUDState::Reachable => Some(true),
NUDState::Failed => Some(false),
_ => None,
}
}
}
// thanks copilot for the PEAK
pub fn parse_ip_neigh_line(s: &str) -> Result<IpNeighLine, IPNeighParseError> {
let mut it = s.split_whitespace();
let ip: IpAddr = it.next().ok_or(IPNeighParseError::IpWhere)?.parse()?;
let mut dev: Option<String> = None;
let mut mac: Option<MacAddr> = None;
let mut state: Option<NUDState> = None;
let mut last_tok: Option<&str> = None;
while let Some(tok) = it.next() {
match tok {
"dev" => dev = it.next().map(str::to_string),
"lladdr" => {
mac = it.next().map(|m| m.parse()).transpose()?;
}
"nud" => {
// we know this aint happening
state = it.next().map(|st| st.parse()).transpose()?;
}
other => last_tok = Some(other),
}
}
// If no explicit "nud", many outputs end with STATE
if state.is_none()
&& let Some(st) = last_tok
{
state = Some(st.parse()?);
}
Ok(IpNeighLine {
ip,
dev,
mac,
state: state.ok_or(IPNeighParseError::StateWhere)?,
})
}
impl FromStr for IpNeighLine {
type Err = IPNeighParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
parse_ip_neigh_line(s)
}
}
/// pls dont touch ts
impl IpNeighLine {
/* // these are some not needed fns
pub fn set_ip(&mut self, ip: IpAddr) {
self.ip = ip;
}
pub fn set_state(&mut self, state: NUDState) {
self.state = state;
}
pub fn ip(self, ip: IpAddr) -> Self {
Self { ip, ..self }
}
pub fn state(self, state: NUDState) -> Self {
Self { state, ..self }
}
*/
pub fn with_dev(dev: impl Into<String>) -> impl FnMut(Self) -> Self {
let dev = dev.into();
move |self_| Self {
dev: Some(dev.clone()),
..self_
}
}
pub fn _with_mac(mac: MacAddr) -> impl FnMut(Self) -> Self {
move |self_| Self {
mac: Some(mac),
..self_
}
}
}
// ideas from copilot:
impl NUDState {
// higher is "better"/more online
pub const fn rank(self) -> u8 {
match self {
NUDState::Permanent | NUDState::Reachable => 5,
NUDState::Stale => 4,
NUDState::Delay | NUDState::Probe | NUDState::Incomplete => 3,
NUDState::Noarp => 2,
NUDState::None => 1,
NUDState::Failed => 0,
}
}
}
impl PartialOrd for NUDState {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for NUDState {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.rank().cmp(&other.rank())
}
}
impl IpNeighLine {
// score for “local and online”: state, has-mac, v4, iface preference
pub fn _score(&self) -> (u8, u8, u8, u8) {
let iface = self
.dev
.as_deref()
.map(|d| {
if d.starts_with("br") || d.starts_with("lan") || d.starts_with("eth") {
2
} else if d.starts_with("wlan") || d.starts_with("wl") {
1
} else {
0
}
})
.unwrap_or(0);
(
self.state.rank(),
self.mac.is_some() as u8,
matches!(self.ip, IpAddr::V4(_)) as u8,
iface,
)
}
}
-30
View File
@@ -1,30 +0,0 @@
use std::net::AddrParseError;
use strum::Display;
use thiserror::Error;
#[derive(Debug, Display, Error)]
pub enum IPNeighParseError {
IpWhere, // i never seen a ip neigh where the first thing aint an ip
IpParseError(AddrParseError),
// DevWhere,
MacParseError(macaddr::ParseError),
StateWhere, // i never seen a ip neigh without the big FAILED at the end
StateParseError(strum::ParseError),
}
impl From<AddrParseError> for IPNeighParseError {
fn from(value: AddrParseError) -> Self {
Self::IpParseError(value)
}
}
impl From<macaddr::ParseError> for IPNeighParseError {
fn from(value: macaddr::ParseError) -> Self {
Self::MacParseError(value)
}
}
impl From<strum::ParseError> for IPNeighParseError {
fn from(value: strum::ParseError) -> Self {
Self::StateParseError(value)
}
}
-14
View File
@@ -1,14 +0,0 @@
use serde::{Deserialize, Deserializer, de};
use super::NUDState;
// Case-insensitive parsing for NUDState via manual Deserialize
impl<'de> Deserialize<'de> for NUDState {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s: &str = <&str as Deserialize>::deserialize(deserializer)?;
s.parse().map_err(de::Error::custom)
}
}
+345
View File
@@ -0,0 +1,345 @@
//! CLI argument parsing, dispatch, rendering, and tracing defaults.
pub mod table;
use std::net::IpAddr;
use anyhow::Result;
use clap::{ArgAction, Args, Parser, Subcommand};
use tracing::debug;
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
use wakey_core::{DeviceFilters, DeviceQuery, InterfaceSummary, WakeResult};
#[derive(Parser)]
#[command(name = "wakey")]
#[command(version, about = "Operator CLI for Wakey service actions")]
pub struct Cli {
/// Increase log verbosity. Use `-v` for debug and `-vv` for trace.
#[arg(short = 'v', long = "verbose", action = ArgAction::Count, global = true)]
pub verbose: u8,
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand)]
pub enum Command {
/// Show device status rows from neighbor/device data.
Status(StatusArgs),
/// Show DHCP leases, optionally enriched with current neighbor state.
Leases(LeasesArgs),
/// Send Wake-on-LAN packets from a query or explicit MAC/IP pair.
Wake(WakeArgs),
/// Show condensed network interface summaries.
Devs(DevsArgs),
}
#[derive(Args)]
pub struct LeasesArgs {
/// Include best-known current neighbor state for each lease IP.
#[arg(long)]
pub include_state: bool,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
pub json: bool,
}
#[derive(Args)]
#[command(after_long_help = "Examples:
wakey wake bedroom-pc
wakey wake --mac aa:bb:cc:dd:ee:ff
wakey wake --mac aa:bb:cc:dd:ee:ff --ip 192.168.1.255
Rules:
- query mode and explicit --mac/--ip mode are mutually exclusive
- --ip requires --mac
- --mac without --ip fans out to interface broadcast targets")]
pub struct WakeArgs {
/// Free-form device query, for example a hostname, IP, MAC, interface, or NUD state.
pub query: Option<String>,
/// Explicit MAC address for manual wake mode.
#[arg(long)]
pub mac: Option<macaddr::MacAddr>,
/// Explicit destination IP or broadcast address for manual wake mode.
#[arg(long)]
pub ip: Option<IpAddr>,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
pub json: bool,
}
#[derive(Args)]
#[command(after_long_help = "Examples:
wakey status bedroom-pc
wakey status --mac aa:bb:cc:dd:ee:ff
wakey status --dev br-lan --nud reachable
If only the positional query is provided, it is treated as free-form input and resolved through the smart selector path.")]
pub struct StatusArgs {
/// Free-form device query.
pub query: Option<String>,
/// Explicit name/text filter.
#[arg(long)]
pub name: Option<String>,
/// Explicit IP filters.
#[arg(long = "ip")]
pub ips: Vec<std::net::IpAddr>,
/// Explicit interface-name filters.
#[arg(long = "dev")]
pub devs: Vec<String>,
/// Explicit neighbor-state filters.
#[arg(long = "nud")]
pub nuds: Vec<wakey_core::NeighborState>,
/// Explicit MAC-address filters.
#[arg(long = "mac")]
pub macs: Vec<macaddr::MacAddr>,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
pub json: bool,
}
#[derive(Args)]
#[command(after_long_help = "Examples:
wakey devs
wakey devs br-lan
wakey devs --up
wakey devs --json")]
pub struct DevsArgs {
/// Optional interface name to show.
pub dev: Option<String>,
/// Show only interfaces whose operstate is `up`.
#[arg(long)]
pub up: bool,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
pub json: bool,
}
pub fn init_tracing(verbose: u8) {
let filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(default_filter_for_verbosity(verbose)))
.expect("static tracing filter should parse");
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer())
.init();
}
pub fn default_filter_for_verbosity(verbose: u8) -> &'static str {
match verbose {
0 => "wakey=info",
1 => "wakey=debug",
_ => "wakey=trace",
}
}
pub async fn run(cli: Cli) -> Result<()> {
init_tracing(cli.verbose);
match cli.command {
Command::Status(args) => {
let as_json = args.json;
let query = status_args_to_query(args);
debug!(?query, json = as_json, "dispatching status command");
let status = if query.name.is_some()
&& query.filter.ips.is_empty()
&& query.filter.devs.is_empty()
&& query.filter.nuds.is_empty()
&& query.filter.macs.is_empty()
{
wakey::get_status_for_input(query.name.clone().unwrap_or_default()).await?
} else {
wakey::get_status(query).await?
};
if as_json {
println!("{}", serde_json::to_string_pretty(&status)?);
} else {
if let Some(name) = &status.name {
println!("name: {name}");
}
println!("{}", table::render_status_table(&status));
}
}
Command::Leases(args) => {
debug!(
include_state = args.include_state,
json = args.json,
"dispatching leases command"
);
let leases = wakey::get_leases(wakey_core::LeaseQuery {
include_state: args.include_state,
})
.await?;
if args.json {
println!("{}", serde_json::to_string_pretty(&leases)?);
} else {
println!("{}", table::render_leases_table(&leases));
}
}
Command::Wake(args) => {
let as_json = args.json;
debug!(
has_query = args.query.is_some(),
has_mac = args.mac.is_some(),
has_ip = args.ip.is_some(),
json = as_json,
"dispatching wake command"
);
let result = run_wake(args).await?;
if as_json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
println!("{}", table::render_wake_table(&result));
}
}
Command::Devs(args) => {
debug!(dev = ?args.dev, up = args.up, json = args.json, "dispatching devs command");
let devs = if let Some(name) = &args.dev {
wakey::get_interface_summary(name)
.await?
.into_iter()
.collect()
} else {
wakey::get_interface_summaries().await?
};
let devs = filter_interface_summaries(devs, &args);
if args.json {
println!("{}", serde_json::to_string_pretty(&devs)?);
} else {
println!("{}", table::render_devs_table(&devs));
}
}
}
Ok(())
}
fn status_args_to_query(args: StatusArgs) -> DeviceQuery {
if let Some(query) = args.query.as_ref()
&& args.name.is_none()
&& args.ips.is_empty()
&& args.devs.is_empty()
&& args.nuds.is_empty()
&& args.macs.is_empty()
{
return DeviceQuery {
name: Some(query.clone()),
..Default::default()
};
}
DeviceQuery {
name: args.name.or(args.query),
filter: DeviceFilters {
ips: args.ips,
devs: args.devs,
nuds: args.nuds,
macs: args.macs,
},
}
}
fn validate_wake_args(args: &WakeArgs) -> Result<()> {
let has_query = args.query.is_some();
let has_mac = args.mac.is_some();
let has_ip = args.ip.is_some();
if has_ip && !has_mac {
anyhow::bail!("`wakey wake --ip` needs `--mac`");
}
if has_query && (has_mac || has_ip) {
anyhow::bail!("query mode and explicit `--mac/--ip` mode are mutually exclusive");
}
if !has_query && !has_mac {
anyhow::bail!("provide either a query or `--mac`");
}
Ok(())
}
async fn run_wake(args: WakeArgs) -> Result<WakeResult> {
validate_wake_args(&args)?;
match (args.query, args.mac, args.ip) {
(Some(query), None, None) => wakey::wake_from_query(query).await,
(None, Some(mac), ip) => wakey::wake_explicit(mac, ip).await,
_ => unreachable!("wake args validated before dispatch"),
}
}
fn filter_interface_summaries(
mut devs: Vec<InterfaceSummary>,
args: &DevsArgs,
) -> Vec<InterfaceSummary> {
if args.up {
devs.retain(|dev| dev.operstate == "up");
}
if let Some(name) = &args.dev {
devs.retain(|dev| &dev.ifname == name);
}
devs
}
#[cfg(test)]
mod tests {
use super::{WakeArgs, default_filter_for_verbosity};
#[test]
fn wake_rejects_ip_without_mac() {
let err = super::validate_wake_args(&WakeArgs {
query: None,
mac: None,
ip: Some("192.168.1.10".parse().expect("ip")),
json: false,
})
.expect_err("ip-only wake should be rejected");
assert!(err.to_string().contains("--ip"));
}
#[test]
fn wake_rejects_mixed_query_and_explicit_mode() {
let err = super::validate_wake_args(&WakeArgs {
query: Some("pc".into()),
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
ip: None,
json: false,
})
.expect_err("mixed wake mode should be rejected");
assert!(err.to_string().contains("mutually exclusive"));
}
#[test]
fn wake_accepts_query_mode() {
super::validate_wake_args(&WakeArgs {
query: Some("pc".into()),
mac: None,
ip: None,
json: false,
})
.expect("query mode should be accepted");
}
#[test]
fn wake_accepts_manual_mac_mode() {
super::validate_wake_args(&WakeArgs {
query: None,
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
ip: None,
json: false,
})
.expect("manual mac mode should be accepted");
}
#[test]
fn verbosity_maps_to_expected_default_filters() {
assert_eq!(default_filter_for_verbosity(0), "wakey=info");
assert_eq!(default_filter_for_verbosity(1), "wakey=debug");
assert_eq!(default_filter_for_verbosity(2), "wakey=trace");
assert_eq!(default_filter_for_verbosity(9), "wakey=trace");
}
}
+112
View File
@@ -0,0 +1,112 @@
use chrono::{DateTime, Local};
use comfy_table::{Cell, ContentArrangement, Table, presets::UTF8_FULL};
use wakey_core::{DhcpLeaseWithState, InterfaceSummary, Status, WakeResult};
pub fn render_status_table(status: &Status<wakey_core::NeighborEntry>) -> Table {
let mut table = base_table();
table.set_header(vec!["IP", "MAC", "Dev", "State"]);
for row in &status.table {
table.add_row(vec![
Cell::new(row.ip.to_string()),
Cell::new(row.mac.map(|v| v.to_string()).unwrap_or_default()),
Cell::new(row.dev.clone().unwrap_or_default()),
Cell::new(row.state.to_string()),
]);
}
table
}
pub fn render_leases_table(leases: &[DhcpLeaseWithState]) -> Table {
let mut table = base_table();
table.set_header(vec!["Expires", "IP", "MAC", "Name", "State"]);
for lease in leases {
table.add_row(vec![
Cell::new(format_epoch(lease.lease_line.expires_epoch)),
Cell::new(lease.lease_line.ip.to_string()),
Cell::new(lease.lease_line.mac.to_string()),
Cell::new(lease.lease_line.name.clone().unwrap_or_default()),
Cell::new(
lease
.nud_state
.map(|v| v.to_string())
.unwrap_or_default(),
),
]);
}
table
}
pub fn render_wake_table(result: &WakeResult) -> Table {
let mut table = base_table();
table.set_header(vec!["IP", "MAC", "Status"]);
for row in &result.result {
table.add_row(vec![
Cell::new(row.target.ip.map(|v| v.to_string()).unwrap_or_default()),
Cell::new(row.target.mac.map(|v| v.to_string()).unwrap_or_default()),
Cell::new(format!("{:?}", row.status)),
]);
}
table
}
pub fn render_devs_table(devs: &[InterfaceSummary]) -> Table {
let mut table = base_table();
table.set_header(vec!["Ifname", "State", "MAC", "Family", "CIDR", "Broadcast", "Scope/Label"]);
for dev in devs {
if dev.addrs.is_empty() {
table.add_row(vec![
Cell::new(&dev.ifname),
Cell::new(&dev.operstate),
Cell::new(dev.mac.map(|v| v.to_string()).unwrap_or_default()),
Cell::new(""),
Cell::new(""),
Cell::new(""),
Cell::new(""),
]);
continue;
}
for (idx, addr) in dev.addrs.iter().enumerate() {
let lead = idx == 0;
table.add_row(vec![
Cell::new(if lead { dev.ifname.as_str() } else { "" }),
Cell::new(if lead { dev.operstate.as_str() } else { "" }),
Cell::new(if lead {
dev.mac.map(|v| v.to_string()).unwrap_or_default()
} else {
String::new()
}),
Cell::new(addr.family.clone().unwrap_or_default()),
Cell::new(addr.cidr.clone().unwrap_or_default()),
Cell::new(addr.broadcast.map(|v| v.to_string()).unwrap_or_default()),
Cell::new(match (&addr.scope, &addr.label) {
(Some(scope), Some(label)) => format!("{scope} / {label}"),
(Some(scope), None) => scope.clone(),
(None, Some(label)) => label.clone(),
(None, None) => String::new(),
}),
]);
}
}
table
}
fn base_table() -> Table {
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic);
table
}
fn format_epoch(epoch: u64) -> String {
DateTime::from_timestamp(epoch as i64, 0)
.map(|dt| {
dt.with_timezone(&Local)
.format("%Y-%m-%d %H:%M:%S")
.to_string()
})
.unwrap_or_else(|| epoch.to_string())
}
+101
View File
@@ -0,0 +1,101 @@
pub mod service;
pub mod utils;
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,
leases_without_state, list_interfaces, merge_devices, query_to_device_query, resolve_devices,
resolve_query, resolve_selector, resolve_wake_targets, wake_explicit, wake_from_query,
wake_targets,
};
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr};
use wakey_core::{
DhcpLease, NeighborEntry, NeighborState, Presence, Query, WakeStatus, WakeTarget,
};
#[tokio::test]
async fn resolve_query_parses_ip() {
let query = resolve_query("192.168.1.10").await.expect("resolve query");
assert_eq!(
query.filter.ips,
vec![IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10))]
);
}
#[tokio::test]
async fn resolve_query_parses_mac() {
let query = resolve_query("aa:bb:cc:dd:ee:ff")
.await
.expect("resolve query");
assert_eq!(query.filter.macs.len(), 1);
}
#[tokio::test]
async fn resolve_query_parses_nud() {
let query = resolve_query("reachable").await.expect("resolve query");
assert_eq!(query.filter.nuds, vec![NeighborState::Reachable]);
}
#[tokio::test]
async fn resolve_selector_keeps_text_vs_structured() {
let selector = resolve_selector("reachable")
.await
.expect("resolve selector");
match selector {
Query::NeighborState(NeighborState::Reachable) => {}
_ => panic!("expected neighbor-state selector"),
}
}
#[test]
fn leases_without_state_clears_nud_state() {
let leases = vec![DhcpLease {
expires_epoch: 1,
ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
mac: "aa:bb:cc:dd:ee:ff".parse().expect("mac"),
name: Some("pc".into()),
}];
let out = leases_without_state(leases);
assert_eq!(out.len(), 1);
assert!(out[0].nud_state.is_none());
}
#[test]
fn merge_devices_combines_lease_and_neighbor() {
let neighbors = vec![NeighborEntry {
ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)),
dev: Some("br-lan".into()),
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
state: NeighborState::Reachable,
}];
let leases = vec![wakey_core::DhcpLeaseWithState {
lease_line: DhcpLease {
expires_epoch: 1,
ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)),
mac: "aa:bb:cc:dd:ee:ff".parse().expect("mac"),
name: Some("pc".into()),
},
nud_state: None,
}];
let devices = merge_devices(neighbors, leases, &wakey_core::DeviceQuery::default());
assert_eq!(devices.len(), 1);
assert_eq!(devices[0].presence, Presence::Online);
assert_eq!(devices[0].names, vec!["pc".to_string()]);
}
#[tokio::test]
async fn wake_targets_marks_incomplete() {
let out = wake_targets(vec![WakeTarget {
ip: Some(IpAddr::V4(Ipv4Addr::LOCALHOST)),
mac: None,
}])
.await
.expect("wake");
assert_eq!(out.result.len(), 1);
assert_eq!(out.result[0].status, WakeStatus::Incomplete);
}
}
+7 -127
View File
@@ -1,136 +1,16 @@
use axum::{
Router,
extract::Query,
http::StatusCode,
response::{Html, IntoResponse},
routing::{get, post},
};
use tokio::net::TcpListener;
mod arpparse;
mod route;
mod r#static;
mod utils;
use crate::{
route::DeviceQuery,
utils::{ping::ping_ip, query::get_macs_2_1, wake::wake},
};
use r#static as st;
use std::io;
mod cli;
const MACHINE_NAME: &str = "lda.lan";
async fn home() -> Html<String> {
Html(format!(
r#"
<html>
<body>
<p><a href="/home_2">Alternate UI</a></p>
<p>the machine is {}! <a href="/status">Status</a></p>
<form method="POST" action="/wake">
<button type="submit">Wake LDA</button>
</form>
</body>
</html>
"#,
if ping_ip((MACHINE_NAME, 22)).await {
"on"
} else {
"off"
} // match get_ips(MACHINE_NAME).await {
// Ok(ips) => {
// // let addrs: Vec<SocketAddr> = ips.into_iter().map(|ip|(ip, 22).into()).collect();
// }
// Err(_) => "off",
// }
))
}
async fn wake_handler(
Query(DeviceQuery { name, .. }): Query<DeviceQuery>,
) -> axum::response::Result<impl IntoResponse> {
match wake(name.as_deref().unwrap_or(MACHINE_NAME)).await {
Ok(0) => Err((StatusCode::NOT_FOUND, "No packets sent!").into()),
Ok(x) => Ok((
StatusCode::ACCEPTED,
format!("{x} packet{s} sent!", s = if x > 1 { "s" } else { "" }),
)),
_ => Err((StatusCode::GATEWAY_TIMEOUT, "Wake failed").into()),
}
}
pub async fn status_build(machine_name: &str) -> String {
let formatted_macs = match get_macs_2_1(machine_name).await {
Ok(table) => {
let the: String = table
.iter()
.map(|(ip, mac, state)| {
let mac_str = // if let Some(mac) = mac {
mac.to_string()
// } else {
// "None".into()
// }
;
format!(
"<tr><td>{ip}</td><td>{mac_str}</td><td>{state}</td></tr>",
state = state.dumber_state()
)
})
.collect();
format!(
r#"<p>info of {machine_name}:</p>
<table>
<tr><th>IP</th><th>MAC</th><th>State</th></tr>
{the}
</table>"#
)
}
Err(e) => format!("<p>errors getting table for {machine_name}: {e}</p>"),
};
format!(
r#"
<html>
<body>
{formatted_macs}
</body>
</html>
"#,
)
}
#[cfg(target_os = "linux")]
#[tokio::main]
async fn entry() -> io::Result<()> {
use crate::route::{api_status, devs_router, get_status_2, home_2, home_2_route};
let app = Router::new()
.route("/home", get(home))
.route("/", get(home_2))
.merge(home_2_route())
.route("/wake", post(wake_handler))
.route("/status", get(get_status_2))
.merge(api_status())
.route("/api/devs", get(devs_router));
let port = TcpListener::bind("0.0.0.0:12012").await?;
axum::serve(port, app.into_make_service()).await?;
Ok(())
}
use clap::Parser;
#[cfg(not(target_os = "linux"))]
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
use std::net::ToSocketAddrs;
// use crate::arpparse::NUDState;
// println!("{}", NUDState::Reachable.to_string().to_lowercase());
println!("{:?}", "svuhuvshdv:331".to_socket_addrs());
// Err(Os { code: 11001, kind: Uncategorized, message: "No such host is known." })
Err(color_eyre::eyre::eyre!(
fn main() -> anyhow::Result<()> {
Err(anyhow::anyhow!(
"OS not supported! run this on your ahh router!"
))
}
#[cfg(target_os = "linux")]
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
Ok(entry()?)
#[tokio::main]
async fn main() -> anyhow::Result<()> {
cli::run(cli::Cli::parse()).await
}
-252
View File
@@ -1,252 +0,0 @@
use std::collections::HashSet;
use std::net::IpAddr;
use axum::{
Json, Router,
extract::Path,
http::{StatusCode, header},
response::{Html, IntoResponse, Redirect},
routing::get,
};
use axum_extra::extract::Query;
use macaddr::MacAddr;
use serde::ser::Serializer;
use serde_with::skip_serializing_none;
use crate::{
MACHINE_NAME,
arpparse::{self, NUDState},
st, status_build,
utils::{
de_many,
query::{
dev::{self, has_dev},
get_macs,
},
},
};
pub async fn home_2() -> Html<&'static str> {
Html(st::HOME_2)
}
async fn home_2_css() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "text/css; charset=utf-8"),
(header::CACHE_CONTROL, "public, max-age=300"),
],
st::HOME_2_CSS,
)
}
async fn home_2_js() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "public, max-age=300"),
],
st::HOME_2_JS,
)
}
/// all the pages related to home_2
pub fn home_2_route() -> Router {
Router::new()
.route("/home_2", get(home_2))
.route("/home_2.css", get(home_2_css))
.route("/home_2.js", get(home_2_js)) //js
}
#[derive(Debug, Default, Clone, Hash, serde::Deserialize)]
pub struct DeviceQuery {
pub name: Option<String>,
// Accept single or many; ignore blanks
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
ip: Vec<IpAddr>,
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
mac: Vec<MacAddr>,
/// optional interface filter (e.g., br-lan)
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
pub dev: Vec<String>,
/// optional NUD state filter; accepts any case (e.g., reachable, REACHABLE)
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
pub nud: Vec<NUDState>,
}
#[derive(Debug, Default, Clone, Hash, serde::Deserialize)]
pub struct NamePath {
name: String,
}
#[skip_serializing_none]
#[derive(Debug, Default, serde::Serialize)]
pub struct Status {
name: Option<String>,
table: Vec<arpparse::IpNeighLine>,
filters: Filters,
}
#[derive(Debug, Default, serde::Serialize)]
pub struct Filters {
#[serde(skip_serializing_if = "Vec::is_empty")]
ip: Vec<IpAddr>,
#[serde(skip_serializing_if = "Vec::is_empty")]
dev: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
nud: Vec<NUDState>,
#[serde(
skip_serializing_if = "Vec::is_empty",
serialize_with = "serialize_macs"
)]
mac: Vec<MacAddr>,
}
fn serialize_macs<S>(macs: &[MacAddr], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let strings: Vec<String> = macs.iter().map(|m| m.to_string()).collect();
serde::Serialize::serialize(&strings, serializer)
}
#[skip_serializing_none]
#[derive(Debug, serde::Serialize)]
pub struct StatusError {
name: Option<String>,
error: String,
}
pub async fn get_status_json(
// p: Option<Path<NamePath>>,
Query(DeviceQuery {
name,
ip,
dev,
nud,
mac,
..
}): Query<DeviceQuery>,
) -> impl IntoResponse {
fn to_opts<T: Clone>(slice: &[T]) -> Vec<Option<T>> {
if slice.is_empty() {
vec![None]
} else {
slice.iter().cloned().map(Some).collect()
}
}
/* let name = /* p
.map(|Path(n)| n.name)
.or */(name)
// .unwrap_or_else(|| MACHINE_NAME.to_owned())
; */
// ip/dev/nud already parsed; assemble options
let ips_opt = if ip.is_empty() {
None
} else {
Some(ip.clone())
};
let dev_opts: Vec<Option<String>> = to_opts(&dev);
let nud_opts: Vec<Option<NUDState>> = to_opts(&nud);
let filters = Filters {
ip,
dev,
nud,
mac: mac.clone(),
};
// Run combinations of dev/nud and merge results
let mut tasks = Vec::new();
for d in &dev_opts {
for n in &nud_opts {
tasks.push(get_macs(
name.as_deref(),
ips_opt.as_deref(),
d.as_deref(),
*n,
));
}
}
match futures::future::try_join_all(tasks)
.await
.map(|v| v.into_iter().flatten().collect::<Vec<_>>())
{
Ok(mut table) => {
// Optional MAC post-filtering if provided
if !mac.is_empty() {
let wanted: HashSet<MacAddr> = mac.into_iter().collect();
table.retain(|row| row.mac.map(|m| wanted.contains(&m)).unwrap_or(false));
}
// let canonical = format!("/api/status?name={name}");
(
StatusCode::OK,
// [(header::LINK, format!("<{canonical}>; rel=\"canonical\""))],
Json(Status {
name,
table,
filters,
}),
)
.into_response()
}
Err(error) => (
StatusCode::BAD_GATEWAY,
Json(StatusError {
name,
error: error.to_string(),
}),
)
.into_response(), // holy clutch. Couldve been disasterous
}
}
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 fn api_status() -> Router {
Router::new()
.route("/api/status/{name}", get(status_redirect)) // api/status should be like the entire ip neigh br lan like idk like
.route("/api/status", get(get_status_json))
.route("/api/smart/{q}", get(status_smart_redirect))
}
pub async fn get_status_2(q: Query<DeviceQuery>) -> Html<String> {
let name = match q {
Query(DeviceQuery {
name: Some(name), ..
}) => name,
_ => MACHINE_NAME.to_string(),
};
Html(status_build(&name).await)
}
// 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>) -> Redirect {
let s = q.trim();
// 1) IP
if let Ok(ip) = s.parse::<IpAddr>() {
return Redirect::to(&format!("/api/status?ip={ip}"));
}
// 2) MAC
if let Ok(mac) = s.parse::<MacAddr>() {
return Redirect::to(&format!("/api/status?mac={mac}"));
}
// 3) NUD state (reachable, stale, ...)
if let Ok(state) = s.parse::<NUDState>() {
return Redirect::to(&format!("/api/status?nud={state}"));
}
// 4) Known device? prefer dev first
if has_dev(s) {
return Redirect::to(&format!("/api/status?dev={}", urlencoding::encode(s)));
}
// 5) Try DNS: if it resolves, treat as name
if tokio::net::lookup_host((s, 0)).await.is_ok() {
return Redirect::to(&format!("/api/status?name={}", urlencoding::encode(s)));
}
// Default: name last
Redirect::to(&format!("/api/status?name={}", urlencoding::encode(s)))
}
pub async fn devs_router() -> Json<Vec<String>> {
dev::devs_sorted().into()
}
+36
View File
@@ -0,0 +1,36 @@
use anyhow::Result;
use tracing::{debug, instrument};
use wakey_core::InterfaceSummary;
/// Return interface names only.
///
/// This is the old, lightweight interface listing surface kept for compatibility.
pub async fn list_interfaces() -> Result<Vec<String>> {
Ok(wakey_linux::devices::devs_sorted().await)
}
/// Return condensed interface summaries useful for CLI and wake routing.
#[instrument(skip_all)]
pub async fn get_interface_summaries() -> Result<Vec<InterfaceSummary>> {
let summaries = wakey_linux::devices::list_interface_summaries().await?;
debug!(count = summaries.len(), "loaded interface summaries");
Ok(summaries)
}
/// Return one named interface summary when present.
#[instrument(skip_all, fields(ifname = name))]
pub async fn get_interface_summary(name: &str) -> Result<Option<InterfaceSummary>> {
let summary = get_interface_summaries()
.await?
.into_iter()
.find(|iface| iface.ifname == name);
debug!(found = summary.is_some(), "resolved interface summary");
Ok(summary)
}
/// Resolve a hostname through the local resolver and collect all returned IPs.
pub async fn get_ips(name: impl AsRef<str>) -> Result<Vec<std::net::IpAddr>> {
Ok(wakey_linux::devices::get_ips(name.as_ref())
.await?
.collect())
}
+107
View File
@@ -0,0 +1,107 @@
use anyhow::Result;
use wakey_core::{
Device, DeviceInventory, DeviceQuery, DhcpLease, DhcpLeaseWithState, NeighborEntry, Presence,
};
use crate::service::leases::get_leases;
use crate::service::query::resolve_query;
/// Resolve free-form input and return merged devices rather than raw source rows.
pub async fn resolve_devices(input: impl Into<String>) -> Result<Vec<Device>> {
let query = resolve_query(input).await?;
inventory(query).await.map(|inventory| inventory.devices)
}
/// Build a merged device inventory from neighbor-table and DHCP-lease sources.
///
/// This is the current center of gravity for the service layer. Higher-level
/// status and wake flows should prefer deriving from inventory rather than
/// directly from raw Linux source rows.
pub async fn inventory(query: DeviceQuery) -> Result<DeviceInventory> {
let neighbors = wakey_linux::devices::query_status(&query).await?;
let leases = get_leases(wakey_core::LeaseQuery {
include_state: false,
})
.await?;
Ok(DeviceInventory {
devices: merge_devices(neighbors, leases, &query),
})
}
/// Merge raw neighbor entries and DHCP leases into device aggregates.
///
/// Identity is currently MAC-first, with an IP-based fallback when a neighbor
/// row does not include a MAC address.
pub fn merge_devices(
neighbors: Vec<NeighborEntry>,
leases: Vec<DhcpLeaseWithState>,
query: &DeviceQuery,
) -> Vec<Device> {
use std::collections::BTreeMap;
let mut by_mac: BTreeMap<String, (Vec<NeighborEntry>, Vec<DhcpLease>)> = BTreeMap::new();
for row in neighbors {
let key = row
.mac
.map(|m| m.to_string())
.unwrap_or_else(|| format!("ip:{}", row.ip));
by_mac.entry(key).or_default().0.push(row);
}
for lease in leases {
let key = lease.lease_line.mac.to_string();
by_mac.entry(key).or_default().1.push(lease.lease_line);
}
let mut devices: Vec<Device> = by_mac
.into_values()
.map(|(neighbors, leases)| Device::from_parts(neighbors, leases))
.collect();
if let Some(name) = &query.name {
devices.retain(|device| device.names.iter().any(|n| n == name));
}
if !query.filter.devs.is_empty() {
devices.retain(|device| {
device
.interfaces
.iter()
.any(|iface| query.filter.devs.contains(iface))
});
}
if !query.filter.ips.is_empty() {
devices.retain(|device| device.ips.iter().any(|ip| query.filter.ips.contains(ip)));
}
if !query.filter.macs.is_empty() {
devices.retain(|device| {
device
.macs
.iter()
.any(|mac| query.filter.macs.contains(mac))
});
}
if !query.filter.nuds.is_empty() {
devices.retain(|device| {
device
.neighbors
.iter()
.any(|neighbor| query.filter.nuds.contains(&neighbor.state))
});
}
devices.sort_by(|a, b| {
presence_rank(b.presence)
.cmp(&presence_rank(a.presence))
.then_with(|| a.names.first().cmp(&b.names.first()))
});
devices
}
const fn presence_rank(presence: Presence) -> u8 {
match presence {
Presence::Online => 3,
Presence::LikelyOnline => 2,
Presence::Unknown => 1,
Presence::Offline => 0,
}
}
+25
View File
@@ -0,0 +1,25 @@
use anyhow::{Context, Result};
use wakey_core::{DhcpLease, DhcpLeaseWithState, LeaseQuery};
/// Read DHCP leases and optionally enrich them with current neighbor-state data.
pub async fn get_leases(query: LeaseQuery) -> Result<Vec<DhcpLeaseWithState>> {
let leases = wakey_linux::dhcp::read_dhcp_leases_with_names()
.await
.context("failed to read DHCP leases")?;
if query.include_state {
Ok(wakey_linux::dhcp::enrich_leases_with_nud_state(leases).await)
} else {
Ok(leases_without_state(leases))
}
}
/// Wrap raw DHCP leases in the current service output shape without neighbor state.
pub fn leases_without_state(leases: Vec<DhcpLease>) -> Vec<DhcpLeaseWithState> {
leases
.into_iter()
.map(|lease_line| DhcpLeaseWithState {
lease_line,
nud_state: None,
})
.collect()
}
+15
View File
@@ -0,0 +1,15 @@
pub mod interfaces;
pub mod inventory;
pub mod leases;
pub mod query;
pub mod status;
pub mod wake;
pub use interfaces::{get_interface_summaries, get_interface_summary, get_ips, list_interfaces};
pub use inventory::{inventory, merge_devices, resolve_devices};
pub use leases::{get_leases, leases_without_state};
pub use query::{query_to_device_query, resolve_query, resolve_selector};
pub use status::{StatusResponse, device_to_status_rows, get_status, get_status_for_input};
pub use wake::{
broadcast_wake_targets, resolve_wake_targets, wake_explicit, wake_from_query, wake_targets,
};
+67
View File
@@ -0,0 +1,67 @@
use anyhow::Result;
use wakey_core::{DeviceFilters, DeviceQuery, Query, QueryInput};
/// Resolve free-form user input into the legacy `DeviceQuery` filter shape.
///
/// This is the compatibility entrypoint used by CLI and HTTP paths that still
/// speak in terms of `DeviceQuery`.
pub async fn resolve_query(input: impl Into<String>) -> Result<DeviceQuery> {
query_to_device_query(resolve_selector(input).await?)
}
/// Classify one piece of free-form user input into a typed selector.
///
/// The Linux adapter decides whether the input looks like an IP address, MAC,
/// interface name, neighbor state, or plain text.
pub async fn resolve_selector(input: impl Into<String>) -> Result<Query> {
Ok(
match wakey_linux::devices::classify_query(input.into()).await {
QueryInput::Ip(ip_addr) => Query::Ip(ip_addr),
QueryInput::Mac(mac_addr) => Query::Mac(mac_addr),
QueryInput::Dev(dev) => Query::Interface(dev),
QueryInput::Nud(state) => Query::NeighborState(state),
QueryInput::Name(name) => Query::Text(name),
},
)
}
/// Convert the newer selector-oriented `Query` model into a `DeviceQuery`.
///
/// This keeps the old filter-based service and HTTP surfaces working while the
/// internals migrate toward selector- and device-oriented APIs.
pub fn query_to_device_query(query: Query) -> Result<DeviceQuery> {
Ok(match query {
Query::Ip(ip_addr) => DeviceQuery {
filter: DeviceFilters {
ips: vec![ip_addr],
..Default::default()
},
..Default::default()
},
Query::Mac(mac_addr) => DeviceQuery {
filter: DeviceFilters {
macs: vec![mac_addr],
..Default::default()
},
..Default::default()
},
Query::Interface(dev) => DeviceQuery {
filter: DeviceFilters {
devs: vec![dev],
..Default::default()
},
..Default::default()
},
Query::NeighborState(state) => DeviceQuery {
filter: DeviceFilters {
nuds: vec![state],
..Default::default()
},
..Default::default()
},
Query::Text(name) => DeviceQuery {
name: Some(name),
..Default::default()
},
})
}
+67
View File
@@ -0,0 +1,67 @@
use anyhow::Result;
use tracing::{debug, instrument};
use wakey_core::{Device, DeviceQuery, NeighborEntry, Presence, Status};
use crate::service::inventory::inventory;
use crate::service::query::resolve_query;
/// Service status payload, still expressed in terms of legacy neighbor rows.
pub type StatusResponse = Status<NeighborEntry>;
/// Return status rows derived from the merged device inventory.
///
/// This keeps the old status response shape alive while the underlying model is
/// increasingly device-centered.
#[instrument(skip_all, fields(name = ?query.name))]
pub async fn get_status(query: DeviceQuery) -> Result<StatusResponse> {
let inventory = inventory(query.clone()).await?;
let table: Vec<NeighborEntry> = inventory
.devices
.iter()
.flat_map(device_to_status_rows)
.collect();
debug!(rows = table.len(), devices = inventory.devices.len(), "built status response");
Ok(Status {
name: query.name,
table,
filters: query.filter,
})
}
/// Convenience wrapper around [`get_status`] for free-form user input.
#[instrument(skip_all)]
pub async fn get_status_for_input(input: impl Into<String>) -> Result<StatusResponse> {
let query = resolve_query(input).await?;
get_status(query).await
}
/// Project a device aggregate back into legacy status rows.
///
/// If the device already has neighbor rows they are reused directly; otherwise a
/// fallback row is synthesized from the best available device data.
pub fn device_to_status_rows(device: &Device) -> Vec<NeighborEntry> {
if !device.neighbors.is_empty() {
return device.neighbors.clone();
}
let fallback_mac = device.macs.first().copied();
let fallback_dev = device.interfaces.first().cloned();
let fallback_state = match device.presence {
Presence::Online => wakey_core::NeighborState::Reachable,
Presence::LikelyOnline => wakey_core::NeighborState::Stale,
Presence::Offline => wakey_core::NeighborState::Failed,
Presence::Unknown => wakey_core::NeighborState::None,
};
device
.ips
.iter()
.copied()
.map(|ip| NeighborEntry {
ip,
dev: fallback_dev.clone(),
mac: fallback_mac,
state: fallback_state,
})
.collect()
}
+177
View File
@@ -0,0 +1,177 @@
use anyhow::{Context, Result};
use macaddr::MacAddr;
use std::net::IpAddr;
use tracing::{debug, instrument};
use wakey_core::{InterfaceSummary, WakeResult, WakeTarget};
use crate::service::interfaces::get_interface_summaries;
use crate::service::inventory::resolve_devices;
/// Send Wake-on-LAN packets for already-concrete wake targets.
#[instrument(skip_all, fields(targets = targets.len()))]
pub async fn wake_targets(targets: Vec<WakeTarget>) -> Result<WakeResult> {
let result = wakey_linux::wake::wake_many(targets)
.await
.context("failed to send wake packets")?;
debug!(results = result.len(), "wake packets sent");
Ok(WakeResult { result })
}
/// Resolve free-form input into wake targets and send the packets.
#[instrument(skip_all)]
pub async fn wake_from_query(input: impl Into<String>) -> Result<WakeResult> {
let targets = resolve_wake_targets(input).await?;
wake_targets(targets).await
}
/// Build broadcast wake targets for every broadcast-capable interface.
///
/// This is used by explicit manual wake mode when only a MAC address is supplied.
#[instrument(skip_all)]
pub async fn broadcast_wake_targets(mac: MacAddr) -> Result<Vec<WakeTarget>> {
let interfaces = get_interface_summaries().await?;
broadcast_wake_targets_from_interfaces(&interfaces, mac)
}
/// Wake a device explicitly by MAC, optionally targeting a specific IP/broadcast.
#[instrument(skip_all, fields(has_ip = ip.is_some()))]
pub async fn wake_explicit(mac: MacAddr, ip: Option<IpAddr>) -> Result<WakeResult> {
let targets = match ip {
Some(ip) => explicit_wake_targets_for_ip(mac, ip),
None => broadcast_wake_targets(mac).await?,
};
wake_targets(targets).await
}
/// Resolve free-form input into concrete wake targets.
///
/// The current resolution strategy fans out one wake target per resolved device IP,
/// using the first known MAC address for that device.
#[instrument(skip_all)]
pub async fn resolve_wake_targets(input: impl Into<String>) -> Result<Vec<WakeTarget>> {
let devices = resolve_devices(input).await?;
let targets: Vec<WakeTarget> = devices
.into_iter()
.flat_map(|device| {
let mac = device.macs.first().copied();
device
.ips
.into_iter()
.map(move |ip| WakeTarget { ip: Some(ip), mac })
})
.collect();
debug!(targets = targets.len(), "resolved wake targets");
Ok(targets)
}
fn explicit_wake_targets_for_ip(mac: MacAddr, ip: IpAddr) -> Vec<WakeTarget> {
vec![WakeTarget {
ip: Some(ip),
mac: Some(mac),
}]
}
fn broadcast_wake_targets_from_interfaces(
interfaces: &[InterfaceSummary],
mac: MacAddr,
) -> Result<Vec<WakeTarget>> {
let targets: Vec<WakeTarget> = interfaces
.iter()
.flat_map(|iface| iface.addrs.iter())
.filter_map(|addr| addr.broadcast)
.map(|ip| WakeTarget {
ip: Some(IpAddr::V4(ip)),
mac: Some(mac),
})
.collect();
if targets.is_empty() {
anyhow::bail!("no broadcast-capable interfaces found");
}
debug!(targets = targets.len(), interfaces = interfaces.len(), "built broadcast wake targets");
Ok(targets)
}
#[cfg(test)]
mod tests {
use std::net::{IpAddr, Ipv4Addr};
use super::{broadcast_wake_targets_from_interfaces, explicit_wake_targets_for_ip};
use wakey_core::{InterfaceAddr, InterfaceSummary};
#[test]
fn explicit_wake_target_for_ip_builds_one_complete_target() {
let mac: macaddr::MacAddr = "aa:bb:cc:dd:ee:ff".parse().expect("mac");
let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 255));
let targets = explicit_wake_targets_for_ip(mac, ip);
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].ip, Some(ip));
assert_eq!(targets[0].mac, Some(mac));
}
#[test]
fn broadcast_wake_targets_from_interfaces_errors_when_no_broadcast_exists() {
let mac: macaddr::MacAddr = "aa:bb:cc:dd:ee:ff".parse().expect("mac");
let interfaces = vec![InterfaceSummary {
ifindex: 1,
ifname: "eth0".into(),
operstate: "up".into(),
mac: None,
addrs: vec![InterfaceAddr {
family: Some("inet".into()),
cidr: Some("192.168.1.10/24".into()),
broadcast: None,
scope: Some("global".into()),
label: None,
}],
}];
let err = broadcast_wake_targets_from_interfaces(&interfaces, mac)
.expect_err("should error without broadcast-capable interfaces");
assert!(
err.to_string()
.contains("no broadcast-capable interfaces found")
);
}
#[test]
fn broadcast_wake_targets_from_interfaces_builds_targets_from_broadcast_rows() {
let mac: macaddr::MacAddr = "aa:bb:cc:dd:ee:ff".parse().expect("mac");
let interfaces = vec![InterfaceSummary {
ifindex: 2,
ifname: "br-lan".into(),
operstate: "up".into(),
mac: None,
addrs: vec![
InterfaceAddr {
family: Some("inet".into()),
cidr: Some("192.168.1.1/24".into()),
broadcast: Some(Ipv4Addr::new(192, 168, 1, 255)),
scope: Some("global".into()),
label: None,
},
InterfaceAddr {
family: Some("inet6".into()),
cidr: Some("fe80::1/64".into()),
broadcast: None,
scope: Some("link".into()),
label: None,
},
],
}];
let targets = broadcast_wake_targets_from_interfaces(&interfaces, mac)
.expect("broadcast target resolution should succeed");
assert_eq!(targets.len(), 1);
assert_eq!(
targets[0].ip,
Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 255)))
);
assert_eq!(targets[0].mac, Some(mac));
}
}
-3
View File
@@ -1,3 +0,0 @@
pub const HOME_2: &str = include_str!("../static/home_2.html");
pub const HOME_2_CSS: &str = include_str!("../static/assets/home_2.css");
pub const HOME_2_JS: &str = include_str!("../static/assets/home_2.js");
-73
View File
@@ -1,73 +0,0 @@
// pub const LDA_MACS: [[u8; 6]; 2] = [
// // ether
// [0x04, 0x7c, 0x16, 0x79, 0x6d, 0xee],
// // wifi
// [0xbc, 0x09, 0x1b, 0xec, 0x65, 0xd0],
// ];
/// is it time to lookup host lda.lan for this...
// pub static LDA_MACS_2: LazyLock<[MacAddr; 2]> = LazyLock::new(|| LDA_MACS.map(MacAddr::from));
pub mod wake;
use std::net::IpAddr;
pub mod error;
/// generic so you can do "123.45.67.89:22" or "lda.lan:22" as an input
// this is so bad
pub mod ping;
/// this is because i like [`IpAddr`] more than [`SocketAddr`](std::net::SocketAddr)
pub async fn get_ips(machine_name: &str) -> error::Result<Vec<IpAddr>> {
let it = tokio::net::lookup_host((machine_name, 0))
.await
.map_err(|e| error::Error::DnsResolve {
name: machine_name.to_string(),
source: e,
})?;
Ok(it.map(|c| c.ip()).collect())
}
pub mod cmd;
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 mod de_many {
use serde::Deserialize;
use serde::de;
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany<T> {
One(T),
Many(Vec<T>),
}
pub fn vec_from_strs<'de, D, T>(des: D) -> Result<Vec<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: std::str::FromStr,
T::Err: std::fmt::Display,
{
let raw: OneOrMany<String> = OneOrMany::<String>::deserialize(des)?;
let mut out = Vec::new();
match raw {
OneOrMany::One(s) => {
let t = s.trim();
if !t.is_empty() {
out.push(t.parse().map_err(de::Error::custom)?);
}
}
OneOrMany::Many(vs) => {
for s in vs {
let t = s.trim();
if t.is_empty() {
continue;
}
out.push(t.parse().map_err(de::Error::custom)?);
}
}
}
Ok(out)
}
}
-10
View File
@@ -1,10 +0,0 @@
use std::io;
pub(crate) async fn exec_command<S: AsRef<std::ffi::OsStr>>(
cmd: S,
args: impl IntoIterator<Item = S>,
) -> io::Result<std::process::Output> {
let mut u = tokio::process::Command::new(cmd);
u.args(args);
u.output().await
}
-56
View File
@@ -1,56 +0,0 @@
use std::{fmt, io};
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug)]
pub enum Error {
/// Failure to resolve a host name to IPs.
DnsResolve { name: String, source: io::Error },
/// External command failed (e.g., ip neigh)
CommandFailed {
cmd: &'static str,
args: Vec<String>,
status: Option<i32>,
stderr: String,
},
/// Generic IO error fallback
Io(io::Error),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::DnsResolve { name, source } => {
write!(f, "DNS resolve failed for {name}: {source}")
}
Error::CommandFailed {
cmd,
args,
status,
stderr,
} => {
let code = status
.map(|c| c.to_string())
.unwrap_or_else(|| "signal".into());
write!(f, "{cmd} {args:?} failed (status: {code}): {stderr}",)
}
Error::Io(e) => write!(f, "IO error: {e}"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::DnsResolve { source, .. } => Some(source),
Error::Io(e) => Some(e),
_ => None,
}
}
}
impl From<io::Error> for Error {
fn from(e: io::Error) -> Self {
Error::Io(e)
}
}
+14
View File
@@ -0,0 +1,14 @@
// pub const LDA_MACS: [[u8; 6]; 2] = [
// // ether
// [0x04, 0x7c, 0x16, 0x79, 0x6d, 0xee],
// // wifi
// [0xbc, 0x09, 0x1b, 0xec, 0x65, 0xd0],
// ];
// is it time to lookup host lda.lan for this...
// pub static LDA_MACS_2: LazyLock<[MacAddr; 2]> = LazyLock::new(|| LDA_MACS.map(MacAddr::from));
/// generic so you can do "123.45.67.89:22" or "lda.lan:22" as an input
// this is so bad
pub mod ping;
pub mod query;
+17 -2
View File
@@ -1,13 +1,16 @@
// this ENTIRE file is redundant... or?
use std::time::Duration;
use std::{net::IpAddr, time::Duration};
use tokio::{
net::{TcpStream, ToSocketAddrs},
time::timeout,
};
use wakey_core::NeighborState;
pub async fn ping_ip<T: ToSocketAddrs>(addr: T) -> bool {
use crate::utils::query::get_mac;
pub async fn _ping_ip<T: ToSocketAddrs>(addr: T) -> bool {
timeout(Duration::from_secs(1), TcpStream::connect(addr))
.await
.is_ok()
@@ -15,3 +18,15 @@ pub async fn ping_ip<T: ToSocketAddrs>(addr: T) -> bool {
pub async fn _ping_ip_2<T: ToSocketAddrs>(_addr: T) -> bool {
todo!("use icmp")
}
pub async fn _ping_ip_3<T: Into<IpAddr>>(addr: T) -> u8 {
match get_mac(Some(addr.into()), None, &[] as &[NeighborState]).await {
Err(_) => 0,
Ok(l) => l
.into_iter()
.map(|e| e.state)
.max()
.map(NeighborState::rank)
.unwrap_or_default(),
}
}
+36 -175
View File
@@ -1,183 +1,44 @@
use std::{collections::HashSet, net::IpAddr};
use macaddr::MacAddr;
// use tokio::io;
use crate::{
arpparse::{self, IpNeighLine, NUDState},
utils::{
cmd::exec_command,
error::{self, Error, Result},
get_ips,
},
};
pub async fn get_macs_2_1(machine_name: &str) -> Result<HashSet<(IpAddr, MacAddr, NUDState)>> {
Ok(get_macs_1(machine_name)
.await?
.into_iter()
.filter_map(
|IpNeighLine {
ip,
dev: _,
mac,
state,
}| mac.map(|mac| (ip, mac, state)),
)
.collect())
}
pub async fn get_macs_2_mac(machine_name: &str) -> Result<HashSet<MacAddr>> {
Ok(get_macs_1(machine_name)
.await?
.into_iter()
.filter_map(
|IpNeighLine {
ip: _,
dev: _,
mac,
state: _,
}| mac,
)
.collect())
}
pub async fn get_macs_1(machine_name: &str) -> Result<Vec<arpparse::IpNeighLine>> {
let dev = "br-lan";
let ips = get_ips(machine_name).await?;
let futures = ips.iter().map(|ip| {
let ip = ip.to_canonical();
async move {
let cmd = "ip";
let args = ["neigh", "show", "to", &ip.to_string(), "dev", dev];
let o = exec_command(cmd, args).await?;
if !o.status.success() {
return Err(Error::CommandFailed {
cmd,
args: args.iter().map(ToString::to_string).collect(),
status: o.status.code(),
stderr: String::from_utf8_lossy(&o.stderr).into(),
});
};
Ok(String::from_utf8_lossy(&o.stdout)
.lines()
.flat_map(arpparse::parse_ip_neigh_line)
// .map(IpNeighLine::with_dev(dev)) // this could be after flatmap up there
.collect::<Vec<_>>())
}
});
let res = futures::future::try_join_all(futures).await?; // async move block errs.
Ok(res
.into_iter()
.flatten() /* resolve double vec */
// .flatten() /* drop parse errors (flat_map cleared) */
.collect())
}
pub async fn get_macs(
machine_name: Option<&str>,
ips: Option<&[IpAddr]>,
dev: Option<&str>,
state: Option<NUDState>,
) -> Result<Vec<IpNeighLine>> {
// Collect IPs early (before any await) to avoid holding generics across await points
let ip_list: Option<Vec<IpAddr>> =
ips.map(|slice| slice.iter().copied().map(|ip| ip.to_canonical()).collect());
// Resolve by machine name if no IPs provided but we have a name
let ip_list = match (ip_list, machine_name) {
(Some(list), _) => list,
(None, Some(name)) => get_ips(name).await?.into_iter().collect(),
(None, None) => Vec::new(),
};
// Helper to convert NUDState to the string expected by `ip neigh`
let nud_arg = state.map(NUDState::as_ip_neigh_arg);
// let nud_arg = Rc::new(state.map(|s| s.to_string().to_lowercase()));
// Build a closure to run one `ip neigh` invocation and parse results
let run_one = |to_ip: Option<IpAddr>| async move {
let mut args: Vec<String> = vec!["neigh".into(), "show".into()];
if let Some(ip) = to_ip {
args.push("to".into());
args.push(ip.to_string());
}
if let Some(d) = dev {
args.push("dev".into());
args.push(d.to_string());
}
if let Some(nud) = nud_arg {
args.push("nud".into());
args.push(nud.to_string());
}
let o = exec_command("ip", args.iter().map(String::as_str).collect::<Vec<_>>()).await?; // hope to rustc that it knows how to unfuck ts
if !o.status.success() {
return Err(Error::CommandFailed {
cmd: "ip",
args,
status: o.status.code(),
stderr: String::from_utf8_lossy(&o.stderr).into(),
});
}
let lines = String::from_utf8_lossy(&o.stdout);
// Parse lines and, if a specific dev filter was used, stamp that dev onto rows
let parsed = lines.lines().flat_map(arpparse::parse_ip_neigh_line);
let rows: Vec<IpNeighLine> = if let Some(d) = dev {
parsed.map(IpNeighLine::with_dev(d)).collect()
} else {
parsed.collect()
};
Ok::<Vec<IpNeighLine>, error::Error>(rows)
};
// If we have specific IPs, query each; otherwise query the whole table once
if !ip_list.is_empty() {
let futures = ip_list.into_iter().map(|ip| run_one(Some(ip)));
let res = futures::future::try_join_all(futures).await?;
Ok(res.into_iter().flatten().collect())
} else {
run_one(None).await
}
}
pub mod dev {
use std::{collections::HashSet, sync::LazyLock};
pub use wakey_linux::devices::devs_sorted;
}
pub mod leases {
pub use wakey_linux::dhcp::enrich_leases_with_nud_state;
}
pub fn get_dev() -> HashSet<String> {
// Prefer /sys/class/net, fallback to /proc/net/dev; filter out loopback
let mut devs: HashSet<String> = HashSet::new();
if let Ok(rd) = std::fs::read_dir("/sys/class/net") {
for e in rd.flatten() {
if let Ok(name) = e.file_name().into_string()
&& name != "lo"
&& !name.is_empty()
{
devs.insert(name);
}
}
} else if let Ok(txt) = std::fs::read_to_string("/proc/net/dev") {
for line in txt.lines().skip(2) {
// skip headers
if let Some((name, _rest)) = line.split_once(':') {
let n = name.trim().to_string();
if n != "lo" && !n.is_empty() {
devs.insert(n);
}
}
}
}
devs
pub mod parser {
pub use wakey_core::QueryInput as QueryType;
pub use wakey_linux::devices::classify_query as parse_query;
}
pub use leases::*;
pub use macs::*;
pub mod macs {
use anyhow::Result;
use std::net::IpAddr;
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
}
pub static DEVS: LazyLock<HashSet<String>> = LazyLock::new(get_dev);
pub fn devs_sorted() -> Vec<String> {
let mut v: Vec<String> = DEVS.iter().cloned().collect();
v.sort();
v
pub async fn get_macs(
machine_names: &[impl AsRef<str>],
ips: &[IpAddr],
devs: &[impl AsRef<str>],
state: &[NeighborState],
macs: &[macaddr::MacAddr],
) -> Result<Vec<NeighborEntry>> {
wakey_linux::devices::get_neighbors(machine_names, ips, devs, state, macs).await
}
pub fn has_dev(name: &str) -> bool {
DEVS.contains(name)
pub async fn get_mac(
ip: Option<IpAddr>,
dev: Option<&str>,
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
}
}
-32
View File
@@ -1,32 +0,0 @@
use std::{io, net::IpAddr};
use tokio::net::UdpSocket;
use crate::utils::query::get_macs_2_mac;
pub async fn wake(machine_name: &str) -> io::Result<u32> {
let suh = UdpSocket::bind("0.0.0.0:0").await?;
suh.set_broadcast(true)?;
let /* mut */ macs = get_macs_2_mac(machine_name).await.unwrap_or_default();
// macs.extend(*LDA_MACS_2);
let mut sent_ok = 0;
for mac in macs {
let mb = mac.as_bytes();
let mut pac = [0; 6 + 6 * 16]; // 6x FF + 16x mac6
pac[..6].fill(0xff);
for i in 1..=16 {
pac[i * 6..(i + 1) * 6].copy_from_slice(mb);
}
match suh
.send_to(&pac, (IpAddr::from([192, 168, 100, 255]), 9))
.await
{
Ok(n) if n == pac.len() => sent_ok += 1,
Ok(n) => eprintln!("partial send ({n}/{})", pac.len()),
Err(e) => eprintln!("send error: {e}"),
}
}
Ok(sent_ok)
}
-128
View File
@@ -1,128 +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;
}
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;
}
#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;
}
-182
View File
@@ -1,182 +0,0 @@
const qs = new URLSearchParams(location.search);
const $ = (id) => document.getElementById(id);
const elName = $("name");
const elCheck = $("check");
const elWake = $("wake");
const elLog = $("log");
const elHtml = $("html");
const pill = $("status-pill");
const link = $("permalink");
function setPill(kind, text) {
pill.className = `pill ${kind}`;
pill.textContent = text;
}
function setLink(name) {
const url = new URL(location.href);
if (name) url.searchParams.set("name", name);
else url.searchParams.delete("name");
history.replaceState(null, "", url);
link.href = url.toString();
}
function getName() {
return (elName.value || "").trim();
}
function saveName(name) {
try {
localStorage.setItem("wakey:name", name);
} catch {}
}
function loadName() {
return qs.get("name") || localStorage.getItem("wakey:name") || "";
}
/** @param {String} s NUD state */
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
);
}
function buildStatusUrl(name) {
const u = new URL("/api/status", location.origin);
if (name) u.searchParams.set("name", name);
// forward multi-value filters from current page URL
for (const k of ["ip", "dev", "nud", "mac"]) {
const vals = qs.getAll(k);
for (const v of vals) u.searchParams.append(k, v);
}
return u;
}
/** @param {{ name?: String, table: Array<{ ip, dev, mac, state }>, filters: {ip, dev, nud, mac}} | { name?: String, error }} data from /api/status */
function renderStatus(data) {
const tbl = document.createElement("table");
tbl.innerHTML = `<tr><th>IP</th><th>MAC</th><th>State</th><th>IF</th></tr>`;
for (const row of data.table || []) {
const tr = document.createElement("tr");
const mac = row.mac ?? "";
const dev = row.dev ?? "";
tr.innerHTML = `<td>${row.ip}</td><td>${mac}</td><td>${row.state}</td><td>${dev}</td>`;
tbl.appendChild(tr);
}
elHtml.innerHTML = "";
// Optional: show applied filters
if (data.filters) {
const parts = [];
if (Array.isArray(data.filters.ip) && data.filters.ip.length)
parts.push(`ip=[${data.filters.ip.join(", ")}]`);
if (Array.isArray(data.filters.dev) && data.filters.dev.length)
parts.push(`dev=[${data.filters.dev.join(", ")}]`);
if (Array.isArray(data.filters.nud) && data.filters.nud.length)
parts.push(`nud=[${data.filters.nud.join(", ")}]`);
if (Array.isArray(data.filters.mac) && data.filters.mac.length)
parts.push(`mac=[${data.filters.mac.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");
} else {
setPill("warn", "unknown");
}
}
async function fetchStatus(name) {
setPill("warn", "checking…");
const u = buildStatusUrl(name);
elLog.textContent = "GET " + u.pathname + u.search;
elHtml.innerHTML = "";
try {
const r = await fetch(u);
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 = `status error: ${msg}`;
// elHtml.textContent = msg;
setPill("bad", "error");
return;
}
const data = await r.json();
renderStatus(data);
} catch (e) {
elLog.textContent = "status error: " + e;
setPill("bad", "error");
}
}
async function sendWake(name) {
setPill("warn", "waking…");
elLog.textContent = "POST /wake?name=" + name;
try {
const r = await fetch(`/wake?name=${encodeURIComponent(name)}`, {
method: "POST",
// headers: { "X-Target-Name": name },
});
const t = await r.text();
elLog.textContent = t || "ok";
// Optional: recheck after medium delay
setTimeout(() => fetchStatus(name), 1500);
} catch (e) {
elLog.textContent = "wake error: " + e;
setPill("bad", "error");
}
}
elCheck.addEventListener("click", () => {
const name = getName();
if (!name) return;
saveName(name);
setLink(name);
fetchStatus(name);
});
elWake.addEventListener("click", () => {
const name = getName();
if (!name) return;
saveName(name);
setLink(name);
sendWake(name);
});
elName.addEventListener("keydown", (e) => {
if (e.key === "Enter") elCheck.click();
});
// init
const initial = loadName();
if (initial) {
elName.value = initial;
setLink(initial); // when the permalink doing YOUR job
// auto-check on load in this A/B page
fetchStatus(initial);
} else {
setPill("warn", "unknown");
}
-42
View File
@@ -1,42 +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_2</title>
<link rel="stylesheet" href="/home_2.css" />
<script type="module" src="/home_2.js" defer></script>
</head>
<body>
<div class="wrap">
<header>
<h1>home_2 <span class="muted">A/B test page</span></h1>
<span id="status-pill" class="pill warn">unknown</span>
</header>
<div class="row">
<input
id="name"
type="text"
placeholder="target name e.g. lda.lan"
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, ip, dev, nud">(?name=...)</span> on this page to view the
status.<!-- and header (X-Target-Name) so either extractor
path works. --></span
>
<a id="permalink" href="#">permalink</a>
</div>
<section id="out" class="card">
<pre id="log">ready.</pre>
<div id="html"></div>
</section>
</div>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
use wakey::inventory;
use wakey_core::DeviceQuery;
#[tokio::test]
#[ignore = "runs against live router data; use on-device or via scripts/test_remote.ps1"]
async fn inventory_real_router_prints_device_inventory() -> anyhow::Result<()> {
let inventory = inventory(DeviceQuery::default()).await?;
println!("{}", serde_json::to_string_pretty(&inventory)?);
Ok(())
}
+83
View File
@@ -0,0 +1,83 @@
use std::net::IpAddr;
use wakey::{broadcast_wake_targets, get_interface_summaries, get_status, get_status_for_input};
use wakey_core::{DeviceFilters, DeviceQuery};
#[tokio::test]
#[ignore = "runs against live router data; use on-device or via scripts/test_remote.ps1"]
async fn interfaces_real_router_prints_interface_summaries() -> anyhow::Result<()> {
let interfaces = get_interface_summaries().await?;
assert!(
!interfaces.is_empty(),
"expected at least one non-loopback interface summary"
);
println!("{}", serde_json::to_string_pretty(&interfaces)?);
Ok(())
}
#[tokio::test]
#[ignore = "runs against live router data; use on-device or via scripts/test_remote.ps1"]
async fn status_real_router_default_query_returns_rows_or_empty_cleanly() -> anyhow::Result<()> {
let status = get_status(DeviceQuery::default()).await?;
println!("{}", serde_json::to_string_pretty(&status)?);
Ok(())
}
#[tokio::test]
#[ignore = "runs against live router data; use on-device or via scripts/test_remote.ps1"]
async fn status_real_router_for_interface_filter_succeeds() -> anyhow::Result<()> {
let interfaces = get_interface_summaries().await?;
let first = interfaces
.first()
.map(|iface| iface.ifname.clone())
.expect("expected at least one interface");
let status = get_status(DeviceQuery {
name: None,
filter: DeviceFilters {
devs: vec![first.clone()],
..Default::default()
},
})
.await?;
println!("filtered dev: {first}");
println!("{}", serde_json::to_string_pretty(&status)?);
Ok(())
}
#[tokio::test]
#[ignore = "runs against live router data; use on-device or via scripts/test_remote.ps1"]
async fn status_real_router_string_input_for_interface_succeeds() -> anyhow::Result<()> {
let interfaces = get_interface_summaries().await?;
let first = interfaces
.first()
.map(|iface| iface.ifname.clone())
.expect("expected at least one interface");
let status = get_status_for_input(first.clone()).await?;
println!("selector: {first}");
println!("{}", serde_json::to_string_pretty(&status)?);
Ok(())
}
#[tokio::test]
#[ignore = "runs against live router data; use on-device or via scripts/test_remote.ps1"]
async fn broadcast_wake_targets_real_router_resolve_from_interfaces() -> anyhow::Result<()> {
let mac: macaddr::MacAddr = "aa:bb:cc:dd:ee:ff".parse()?;
let targets = broadcast_wake_targets(mac).await?;
assert!(
targets.iter().all(|target| target.mac == Some(mac)),
"all broadcast targets should preserve the requested MAC"
);
assert!(
targets
.iter()
.all(|target| matches!(target.ip, Some(IpAddr::V4(_)))),
"broadcast targets should be IPv4 broadcast destinations"
);
println!("{}", serde_json::to_string_pretty(&targets)?);
Ok(())
}
+33
View File
@@ -0,0 +1,33 @@
[package]
name = "wakey-agent"
version = "0.0.1"
edition = "2024"
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
futures-util = "0.3"
http = "1"
macaddr = { version = "1", features = ["serde", "serde_std"] }
nix = { version = "0.30", default-features = false, features = ["signal", "process"] }
reqwest = { version = "0.12", default-features = false, features = [
"json",
"rustls-tls",
] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = [
"fs",
"macros",
"rt-multi-thread",
"time",
"signal",
"sync",
] }
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
toml = "0.8"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
url = "2"
wakey = { path = "..", registry = "gitea" }
wakey-core = { path = "../wakey-core", registry = "gitea" }
+93
View File
@@ -0,0 +1,93 @@
use std::path::PathBuf;
use clap::{ArgAction, Args, Parser, Subcommand};
use crate::config;
pub const DEFAULT_PID_FILE: &str = "/var/run/wakey-agent.pid";
#[derive(Parser)]
#[command(name = "wakey-agent")]
#[command(version, about = "Outbound control-plane agent for Wakey")]
pub struct Cli {
/// Increase log verbosity. Use `-v` for debug and `-vv` for trace.
#[arg(short = 'v', long = "verbose", action = ArgAction::Count, global = true)]
pub verbose: u8,
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand)]
pub enum Command {
/// Start the agent daemon and maintain the outbound control-plane session.
Serve(ServeArgs),
/// Enroll this router with a control plane and write agent config.
Enroll(EnrollArgs),
/// Create a local config scaffold for manual bootstrap.
InitConfig(InitConfigArgs),
/// Reload a running agent daemon by sending SIGHUP.
Reload(ReloadArgs),
}
#[derive(Args)]
pub struct ServeArgs {
/// Path to the agent config file.
#[arg(long, default_value = config::DEFAULT_CONFIG_PATH)]
pub config: PathBuf,
/// Path to pid file for reload signaling.
#[arg(long, default_value = DEFAULT_PID_FILE)]
pub pid_file: PathBuf,
}
#[derive(Args)]
pub struct EnrollArgs {
/// Base HTTPS URL of the control plane.
#[arg(long)]
pub server_url: String,
/// One-time or short-lived enroll token provided by the control plane.
#[arg(long)]
pub enroll_token: String,
/// Path to the agent config file to write.
#[arg(long, default_value = config::DEFAULT_CONFIG_PATH)]
pub config: PathBuf,
/// Reload a running daemon after writing config.
#[arg(long)]
pub reload_running: bool,
/// Path to pid file used when `--reload-running` is enabled.
#[arg(long, default_value = DEFAULT_PID_FILE)]
pub pid_file: PathBuf,
}
#[derive(Args)]
pub struct InitConfigArgs {
/// Path to the agent config file to write.
#[arg(long, default_value = config::DEFAULT_CONFIG_PATH)]
pub config: PathBuf,
/// Base HTTPS URL of the control plane.
#[arg(long)]
pub server_url: Option<String>,
/// Persistent agent id obtained from enroll flow.
#[arg(long)]
pub agent_id: Option<String>,
/// Persistent agent token obtained from enroll flow.
#[arg(long)]
pub agent_token: Option<String>,
/// Replace an existing config file.
#[arg(long)]
pub force: bool,
}
#[derive(Args)]
pub struct ReloadArgs {
/// Path to pid file for reload signaling.
#[arg(long, default_value = DEFAULT_PID_FILE)]
pub pid_file: PathBuf,
}
+89
View File
@@ -0,0 +1,89 @@
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(Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AgentConfig {
pub server_url: String,
pub agent_id: String,
pub agent_token: String,
#[serde(default = "default_reconnect_base_ms")]
pub reconnect_base_ms: u64,
#[serde(default = "default_reconnect_max_ms")]
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
}
const fn default_reconnect_max_ms() -> u64 {
30_000
}
pub fn load_config(path: &Path) -> Result<AgentConfig> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read agent config {}", path.display()))?;
toml::from_str(&content)
.with_context(|| format!("failed to parse agent config {}", path.display()))
}
pub fn save_config(path: &Path, config: &AgentConfig) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create config dir {}", parent.display()))?;
}
let content = toml::to_string_pretty(config).context("failed to serialize agent config")?;
let tmp = path.with_extension("toml.tmp");
std::fs::write(&tmp, content)
.with_context(|| format!("failed to write temp config {}", tmp.display()))?;
std::fs::rename(&tmp, path).with_context(|| {
format!(
"failed to move temp config {} into {}",
tmp.display(),
path.display()
)
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_roundtrip() {
let dir = std::env::temp_dir().join(format!("wakey-agent-config-{}", std::process::id()));
let path = dir.join("config.toml");
let config = AgentConfig {
server_url: "https://example.com".into(),
agent_id: "agent-1".into(),
agent_token: "secret".into(),
reconnect_base_ms: 123,
reconnect_max_ms: 456,
};
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);
}
}
+130
View File
@@ -0,0 +1,130 @@
use anyhow::Result;
use tracing::{debug, info, instrument};
use crate::protocol::{
AgentCommand, CommandResult, DevsRequest, InventoryRequest, LeasesRequest, StatusRequest,
WakeRequest,
};
#[instrument(skip_all)]
pub async fn dispatch_command(command: AgentCommand) -> Result<CommandResult> {
let kind = command_kind(&command);
info!(command = %kind, "dispatching command into local wakey services");
match command {
AgentCommand::Status(req) => dispatch_status(req).await,
AgentCommand::Leases(req) => dispatch_leases(req).await,
AgentCommand::Devs(req) => dispatch_devs(req).await,
AgentCommand::Inventory(req) => dispatch_inventory(req).await,
AgentCommand::Wake(req) => dispatch_wake(req).await,
}
}
async fn dispatch_status(req: StatusRequest) -> Result<CommandResult> {
let query = req.into_device_query();
let status = if query.name.is_some()
&& query.filter.ips.is_empty()
&& query.filter.devs.is_empty()
&& query.filter.nuds.is_empty()
&& query.filter.macs.is_empty()
{
wakey::get_status_for_input(query.name.clone().unwrap_or_default()).await?
} else {
wakey::get_status(query).await?
};
debug!(rows = status.table.len(), "dispatched status command");
Ok(CommandResult::Status(status))
}
async fn dispatch_leases(req: LeasesRequest) -> Result<CommandResult> {
let leases = wakey::get_leases(wakey_core::LeaseQuery {
include_state: req.include_state,
})
.await?;
debug!(rows = leases.len(), include_state = req.include_state, "dispatched leases command");
Ok(CommandResult::Leases(leases))
}
async fn dispatch_devs(req: DevsRequest) -> Result<CommandResult> {
let mut devs = if let Some(name) = &req.dev {
wakey::get_interface_summary(name).await?.into_iter().collect()
} else {
wakey::get_interface_summaries().await?
};
if req.up_only {
devs.retain(|dev| dev.operstate == "up");
}
debug!(rows = devs.len(), up_only = req.up_only, "dispatched devs command");
Ok(CommandResult::Devs(devs))
}
async fn dispatch_inventory(req: InventoryRequest) -> Result<CommandResult> {
let inventory = wakey::inventory(req.into_device_query()).await?;
debug!(rows = inventory.devices.len(), "dispatched inventory command");
Ok(CommandResult::Inventory(inventory))
}
async fn dispatch_wake(req: WakeRequest) -> Result<CommandResult> {
validate_wake_request(&req)?;
let result = match (req.query, req.mac, req.ip) {
(Some(query), None, None) => wakey::wake_from_query(query).await?,
(None, Some(mac), ip) => wakey::wake_explicit(mac, ip).await?,
_ => unreachable!("wake request validated before dispatch"),
};
debug!("dispatched wake command");
Ok(CommandResult::Wake(result))
}
fn command_kind(command: &AgentCommand) -> &'static str {
match command {
AgentCommand::Status(_) => "status",
AgentCommand::Leases(_) => "leases",
AgentCommand::Devs(_) => "devs",
AgentCommand::Inventory(_) => "inventory",
AgentCommand::Wake(_) => "wake",
}
}
pub fn validate_wake_request(req: &WakeRequest) -> Result<()> {
let has_query = req.query.is_some();
let has_mac = req.mac.is_some();
let has_ip = req.ip.is_some();
if has_ip && !has_mac {
anyhow::bail!("wake request `ip` requires `mac`");
}
if has_query && (has_mac || has_ip) {
anyhow::bail!("wake query mode and explicit mode are mutually exclusive");
}
if !has_query && !has_mac {
anyhow::bail!("wake request requires either `query` or `mac`");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::WakeRequest;
#[test]
fn wake_rejects_ip_without_mac() {
let err = validate_wake_request(&WakeRequest {
query: None,
mac: None,
ip: Some("192.168.1.1".parse().expect("ip")),
})
.expect_err("should reject");
assert!(err.to_string().contains("requires `mac`"));
}
#[test]
fn wake_rejects_mixed_mode() {
let err = validate_wake_request(&WakeRequest {
query: Some("pc".into()),
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
ip: None,
})
.expect_err("should reject");
assert!(err.to_string().contains("mutually exclusive"));
}
}
+139
View File
@@ -0,0 +1,139 @@
use anyhow::{Context, Result};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use std::path::Path;
use tracing::{info, warn};
use crate::config::{AgentConfig, save_config};
#[derive(Debug, Serialize)]
struct EnrollRequest<'a> {
enroll_token: &'a str,
}
#[derive(Debug, Deserialize)]
struct EnrollResponse {
agent_id: String,
agent_token: String,
server_url: Option<String>,
}
pub async fn enroll(server_url: &str, enroll_token: &str, config_path: &Path) -> Result<AgentConfig> {
let server_url = normalize_server_url(server_url);
let endpoint = format!("{server_url}/api/v1/agents/enroll");
info!(endpoint = %endpoint, config_path = %config_path.display(), "starting agent enrollment");
let client = reqwest::Client::new();
let response = client
.post(endpoint)
.json(&EnrollRequest { enroll_token })
.send()
.await
.context("failed to call enrollment endpoint")?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response
.text()
.await
.unwrap_or_else(|_| "<unable to read enrollment error body>".into());
warn!(status = %status, "agent enrollment request rejected by control-plane");
anyhow::bail!("enrollment failed with {status}: {body}");
}
let payload: EnrollResponse = response
.json()
.await
.context("failed to decode enrollment response")?;
let config = AgentConfig {
server_url: payload.server_url.unwrap_or(server_url),
agent_id: payload.agent_id,
agent_token: payload.agent_token,
reconnect_base_ms: 1_000,
reconnect_max_ms: 30_000,
};
save_config(config_path, &config)?;
info!(agent_id = %config.agent_id, config_path = %config_path.display(), "agent enrollment succeeded and config was written");
Ok(config)
}
pub fn normalize_server_url(server_url: &str) -> String {
server_url.trim_end_matches('/').to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener};
use std::thread;
fn spawn_enroll_server(response_body: &'static str, status: &'static str) -> (String, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener");
let addr: SocketAddr = listener.local_addr().expect("local addr");
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept connection");
// Read until end-of-headers; body content is irrelevant for this test.
let mut buf = [0u8; 4096];
let mut req = Vec::new();
loop {
let n = stream.read(&mut buf).expect("read request");
if n == 0 {
break;
}
req.extend_from_slice(&buf[..n]);
if req.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response_body.len(),
response_body
);
stream
.write_all(response.as_bytes())
.expect("write response");
});
(format!("http://{}", addr), handle)
}
#[test]
fn normalize_server_url_trims_slash() {
assert_eq!(normalize_server_url("https://example.com/"), "https://example.com");
}
#[tokio::test]
async fn enroll_response_persists_config() {
let response = r#"{"agent_id":"agent-123","agent_token":"token-xyz","server_url":"https://control.example.com"}"#;
let (server_url, handle) = spawn_enroll_server(response, "200 OK");
let dir = std::env::temp_dir().join(format!(
"wakey-agent-enroll-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos()
));
let path = dir.join("config.toml");
let config = enroll(&server_url, "enroll-abc", &path)
.await
.expect("enroll should succeed");
assert_eq!(config.agent_id, "agent-123");
assert_eq!(config.agent_token, "token-xyz");
assert_eq!(config.server_url, "https://control.example.com");
let persisted = crate::config::load_config(&path).expect("load persisted config");
assert_eq!(persisted, config);
handle.join().expect("server thread joined");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir_all(&dir);
}
}
+1
View File
@@ -0,0 +1 @@
pub mod protocol;
+73
View File
@@ -0,0 +1,73 @@
mod cli;
mod config;
mod dispatch;
mod enroll;
mod protocol;
mod session;
mod serve;
mod tracing;
use anyhow::Result;
use clap::Parser;
use cli::{Cli, Command, InitConfigArgs};
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
tracing::init(cli.verbose);
match cli.command {
Command::Serve(args) => {
::tracing::info!("wakey-agent command: serve");
serve::serve(args).await?
}
Command::Enroll(args) => {
::tracing::info!(server_url = %args.server_url, config = %args.config.display(), "wakey-agent command: enroll");
let config = enroll::enroll(&args.server_url, &args.enroll_token, &args.config).await?;
println!("agent_id={}", config.agent_id);
println!("config={}", args.config.display());
if args.reload_running {
match serve::reload_daemon(&args.pid_file) {
Ok(()) => println!("reload=signaled pid_file={}", args.pid_file.display()),
Err(err) => ::tracing::warn!(error = %err, pid_file = %args.pid_file.display(), "enroll completed but daemon reload failed"),
}
}
}
Command::InitConfig(args) => {
::tracing::info!(config = %args.config.display(), force = args.force, "wakey-agent command: init-config");
init_config(args)?
}
Command::Reload(args) => {
::tracing::info!(pid_file = %args.pid_file.display(), "wakey-agent command: reload");
serve::reload_daemon(&args.pid_file)?
}
}
Ok(())
}
fn init_config(args: InitConfigArgs) -> Result<()> {
if args.config.exists() && !args.force {
anyhow::bail!(
"config {} already exists; re-run with --force to overwrite",
args.config.display()
);
}
let cfg = config::AgentConfig {
server_url: args
.server_url
.unwrap_or_else(|| "https://control-plane.example.com".to_string()),
agent_id: args.agent_id.unwrap_or_else(|| "REPLACE_ME_AGENT_ID".to_string()),
agent_token: args
.agent_token
.unwrap_or_else(|| "REPLACE_ME_AGENT_TOKEN".to_string()),
reconnect_base_ms: 1_000,
reconnect_max_ms: 30_000,
};
config::save_config(&args.config, &cfg)?;
println!("config={}", args.config.display());
println!("next=wakey-agent serve --config {}", args.config.display());
Ok(())
}
+239
View File
@@ -0,0 +1,239 @@
use macaddr::MacAddr;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::net::IpAddr;
use wakey_core::{
DeviceFilters, DeviceInventory, DeviceQuery, DhcpLeaseWithState, InterfaceSummary, NeighborEntry,
Status, WakeResult,
};
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>,
pub name: Option<String>,
#[serde(default)]
pub ips: Vec<IpAddr>,
#[serde(default)]
pub devs: Vec<String>,
#[serde(default)]
pub nuds: Vec<wakey_core::NeighborState>,
#[serde(default)]
#[serde(with = "mac::vec_mac")]
pub macs: Vec<MacAddr>,
}
impl StatusRequest {
pub fn into_device_query(self) -> DeviceQuery {
if let Some(query) = self.query.as_ref()
&& self.name.is_none()
&& self.ips.is_empty()
&& self.devs.is_empty()
&& self.nuds.is_empty()
&& self.macs.is_empty()
{
return DeviceQuery {
name: Some(query.clone()),
..Default::default()
};
}
DeviceQuery {
name: self.name.or(self.query),
filter: DeviceFilters {
ips: self.ips,
devs: self.devs,
nuds: self.nuds,
macs: self.macs,
},
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeasesRequest {
#[serde(default)]
pub include_state: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DevsRequest {
pub dev: Option<String>,
#[serde(default)]
pub up_only: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InventoryRequest {
pub query: Option<String>,
pub name: Option<String>,
#[serde(default)]
pub ips: Vec<IpAddr>,
#[serde(default)]
pub devs: Vec<String>,
#[serde(default)]
pub nuds: Vec<wakey_core::NeighborState>,
#[serde(default)]
#[serde(with = "mac::vec_mac")]
pub macs: Vec<MacAddr>,
}
impl InventoryRequest {
pub fn into_device_query(self) -> DeviceQuery {
StatusRequest {
query: self.query,
name: self.name,
ips: self.ips,
devs: self.devs,
nuds: self.nuds,
macs: self.macs,
}
.into_device_query()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WakeRequest {
pub query: Option<String>,
#[serde(default)]
#[serde(with = "mac::option_mac")]
pub mac: Option<MacAddr>,
#[serde(default)]
pub ip: Option<IpAddr>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AgentCommand {
Status(StatusRequest),
Leases(LeasesRequest),
Devs(DevsRequest),
Inventory(InventoryRequest),
Wake(WakeRequest),
}
#[derive(Debug, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CommandResult {
Status(Status<NeighborEntry>),
Leases(Vec<DhcpLeaseWithState>),
Devs(Vec<InterfaceSummary>),
Inventory(DeviceInventory),
Wake(WakeResult),
}
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage {
Hello {
agent_id: String,
},
Auth {
agent_id: String,
agent_token: String,
},
Heartbeat {
agent_id: String,
},
Result {
request_id: RequestId,
result: CommandResult,
},
Error {
request_id: RequestId,
error: ErrorPayload,
},
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMessage {
Command {
request_id: RequestId,
command: AgentCommand,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_serialization_is_stable() {
let msg = ServerMessage::Command {
request_id: RequestId::try_from("req-1".to_string()).expect("request id"),
command: AgentCommand::Leases(LeasesRequest {
include_state: true,
}),
};
let json = serde_json::to_string(&msg).expect("serialize");
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"));
}
}
+126
View File
@@ -0,0 +1,126 @@
use std::path::Path;
use anyhow::{Context, Result};
use tracing::{info, warn};
use crate::cli::ServeArgs;
use crate::{config, session};
pub async fn serve(args: ServeArgs) -> Result<()> {
if !args.config.exists() {
anyhow::bail!(
"agent config {} not found. Run `wakey-agent enroll --server-url <url> --enroll-token <token>` or `wakey-agent init-config --config {}` first",
args.config.display(),
args.config.display()
);
}
write_pid_file(&args.pid_file)?;
info!(pid_file = %args.pid_file.display(), "wrote wakey-agent pid file");
let mut cfg = config::load_config(&args.config)?;
info!(config_path = %args.config.display(), agent_id = %cfg.agent_id, "starting wakey-agent");
let mut worker = tokio::spawn(session::run(cfg.clone()));
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
let mut hup = signal(SignalKind::hangup()).context("failed to install SIGHUP handler")?;
loop {
tokio::select! {
_ = tokio::signal::ctrl_c() => {
info!("ctrl-c received; shutting down wakey-agent");
worker.abort();
break;
}
_ = hup.recv() => {
match config::load_config(&args.config) {
Ok(new_cfg) => {
cfg = new_cfg;
info!(config_path = %args.config.display(), agent_id = %cfg.agent_id, "reload requested; restarting session with updated config");
worker.abort();
worker = tokio::spawn(session::run(cfg.clone()));
}
Err(err) => {
warn!(error = %err, config_path = %args.config.display(), "reload requested but config reload failed; keeping current session");
}
}
}
join = &mut worker => {
warn!("agent worker task exited; shutting down daemon");
let _ = remove_pid_file(&args.pid_file);
return join.context("agent session join failed")?;
}
}
}
}
#[cfg(not(unix))]
{
tokio::signal::ctrl_c()
.await
.context("failed waiting for ctrl-c")?;
worker.abort();
}
let _ = remove_pid_file(&args.pid_file);
Ok(())
}
pub fn reload_daemon(pid_file: &Path) -> Result<()> {
let pid = read_pid(pid_file)?;
info!(pid, pid_file = %pid_file.display(), "sending wakey-agent reload signal");
send_hup(pid)
}
fn write_pid_file(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create pid dir {}", parent.display()))?;
}
std::fs::write(path, format!("{}\n", std::process::id()))
.with_context(|| format!("failed to write pid file {}", path.display()))
}
fn remove_pid_file(path: &Path) -> Result<()> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => {
Err(err).with_context(|| format!("failed to remove pid file {}", path.display()))
}
}
}
fn read_pid(path: &Path) -> Result<i32> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("failed to read pid file {}", path.display()))?;
let pid = raw
.trim()
.parse::<i32>()
.with_context(|| format!("invalid pid in {}", path.display()))?;
if pid <= 0 {
anyhow::bail!("invalid non-positive pid {pid}");
}
Ok(pid)
}
fn send_hup(pid: i32) -> Result<()> {
#[cfg(unix)]
{
use nix::sys::signal::{Signal, kill};
use nix::unistd::Pid;
kill(Pid::from_raw(pid), Signal::SIGHUP)
.with_context(|| format!("failed to send SIGHUP to pid {pid}"))?;
Ok(())
}
#[cfg(not(unix))]
{
let _ = pid;
anyhow::bail!("reload is only supported on Unix (SIGHUP unavailable on this platform)")
}
}
+227
View File
@@ -0,0 +1,227 @@
use anyhow::{Context, Result};
use futures_util::{SinkExt, StreamExt};
use tokio::time::{Duration, MissedTickBehavior, interval, sleep};
use tokio_tungstenite::{connect_async, tungstenite::Message};
use tracing::{debug, error, info, info_span, warn};
use crate::config::AgentConfig;
use crate::dispatch::dispatch_command;
use crate::protocol::{AgentCommand, ClientMessage, ErrorPayload, ServerMessage};
pub async fn run(config: AgentConfig) -> Result<()> {
let mut backoff = config.reconnect_base_ms.max(100);
loop {
match run_once(&config).await {
Ok(()) => {
backoff = config.reconnect_base_ms.max(100);
}
Err(err) => {
warn!(error = %err, backoff_ms = backoff, "agent session ended; reconnecting");
sleep(Duration::from_millis(backoff)).await;
backoff = next_backoff_ms(backoff, config.reconnect_max_ms);
}
}
}
}
async fn run_once(config: &AgentConfig) -> Result<()> {
let session_id = format!(
"{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or_default()
);
let span = info_span!("agent_session", session_id = %session_id, agent_id = %config.agent_id);
let _span_guard = span.enter();
let ws_url = websocket_url(&config.server_url)?;
info!(%ws_url, agent_id = %config.agent_id, "connecting agent websocket");
let (stream, _) = connect_async(ws_url.as_str())
.await
.context("failed to connect websocket")?;
let (mut sink, mut source) = stream.split();
send_json(&mut sink, &ClientMessage::Hello {
agent_id: config.agent_id.clone(),
})
.await?;
send_json(
&mut sink,
&ClientMessage::Auth {
agent_id: config.agent_id.clone(),
agent_token: config.agent_token.clone(),
},
)
.await?;
info!(agent_id = %config.agent_id, "agent websocket session authenticated");
let mut heartbeat = interval(Duration::from_secs(30));
heartbeat.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop {
tokio::select! {
_ = heartbeat.tick() => {
send_json(&mut sink, &ClientMessage::Heartbeat {
agent_id: config.agent_id.clone(),
}).await?;
debug!(agent_id = %config.agent_id, "heartbeat sent");
}
maybe_msg = source.next() => {
let msg = match maybe_msg {
Some(msg) => msg.context("websocket frame failed")?,
None => {
info!("websocket stream closed by server");
anyhow::bail!("websocket closed by server");
}
};
match msg {
Message::Text(text) => {
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")?;
}
Message::Pong(_) => {}
Message::Close(frame) => {
info!(close = ?frame, "received close frame from server");
anyhow::bail!("websocket closed: {:?}", frame);
}
Message::Binary(_) => {
debug!("ignoring unexpected binary websocket frame");
}
Message::Frame(_) => {}
}
}
}
}
}
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,
<S as futures_util::Sink<Message>>::Error: std::error::Error + Send + Sync + 'static,
{
match message {
ServerMessage::Command {
request_id,
command,
} => {
let kind = command_kind(&command);
info!(request_id = %request_id, command = %kind, "received command from control-plane");
match dispatch_command(command).await {
Ok(result) => {
info!(request_id = %request_id, command = %kind, "command execution completed");
send_json(sink, &ClientMessage::Result { request_id, result }).await?;
}
Err(err) => {
error!(request_id = %request_id, command = %kind, error = %err, "command dispatch failed");
send_json(
sink,
&ClientMessage::Error {
request_id,
error: ErrorPayload {
code: "command_dispatch_failed".into(),
message: err.to_string(),
retryable: None,
},
},
)
.await?;
}
}
}
}
Ok(())
}
async fn send_json<S>(sink: &mut S, message: &ClientMessage) -> Result<()>
where
S: SinkExt<Message> + Unpin,
<S as futures_util::Sink<Message>>::Error: std::error::Error + Send + Sync + 'static,
{
let payload = serde_json::to_string(message).context("failed to serialize websocket message")?;
debug!(message_type = %client_message_kind(message), "sending websocket message");
sink.send(Message::Text(payload))
.await
.context("failed to send websocket message")?;
Ok(())
}
fn command_kind(command: &AgentCommand) -> &'static str {
match command {
AgentCommand::Status(_) => "status",
AgentCommand::Leases(_) => "leases",
AgentCommand::Devs(_) => "devs",
AgentCommand::Inventory(_) => "inventory",
AgentCommand::Wake(_) => "wake",
}
}
fn client_message_kind(message: &ClientMessage) -> &'static str {
match message {
ClientMessage::Hello { .. } => "hello",
ClientMessage::Auth { .. } => "auth",
ClientMessage::Heartbeat { .. } => "heartbeat",
ClientMessage::Result { .. } => "result",
ClientMessage::Error { .. } => "error",
}
}
pub fn websocket_url(server_url: &str) -> Result<url::Url> {
let base = url::Url::parse(server_url).context("invalid server_url")?;
let scheme = match base.scheme() {
"http" => "ws",
"https" => "wss",
"ws" => "ws",
"wss" => "wss",
other => anyhow::bail!("unsupported server_url scheme `{other}`"),
};
let mut url = base;
url.set_scheme(scheme)
.map_err(|_| anyhow::anyhow!("failed to convert server_url scheme"))?;
url.set_path("/api/v1/agent/ws");
url.set_query(None);
url.set_fragment(None);
Ok(url)
}
#[cfg(test)]
mod tests {
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);
}
}
+20
View File
@@ -0,0 +1,20 @@
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
pub fn init(verbose: u8) {
let filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(default_filter(verbose)))
.expect("static tracing filter should parse");
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer())
.init();
}
fn default_filter(verbose: u8) -> &'static str {
match verbose {
0 => "wakey_agent=info,wakey=info",
1 => "wakey_agent=debug,wakey=debug",
_ => "wakey_agent=trace,wakey=debug",
}
}
+32
View File
@@ -0,0 +1,32 @@
[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"
nix = { version = "0.30", default-features = false, features = ["signal", "process"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sled = "0.34"
toml = "0.8"
tokio = { version = "1", features = [
"macros",
"rt-multi-thread",
"fs",
"sync",
"time",
"signal",
] }
tracing = "0.1"
tracing-opentelemetry = "0.32"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] }
opentelemetry = "0.31"
opentelemetry-otlp = { version = "0.31", features = ["grpc-tonic", "trace"] }
opentelemetry_sdk = { version = "0.31", features = ["rt-tokio"] }
uuid = { version = "1", features = ["v4", "serde"] }
wakey-agent = { path = "../wakey-agent", registry = "gitea" }
+165
View File
@@ -0,0 +1,165 @@
use axum::Json;
use axum::extract::{Path as AxumPath, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use tracing::{info, info_span, warn};
use uuid::Uuid;
use wakey_agent::protocol::{AgentCommand, ErrorPayload, RequestId, ServerMessage};
use crate::api::json_error;
use crate::runtime::{AgentReply, AppState};
#[derive(Debug, Serialize)]
pub struct AgentStatus {
pub agent_id: String,
pub connected: bool,
}
#[derive(Debug, Deserialize)]
pub struct RelayCommandRequest {
pub command: AgentCommand,
pub timeout_ms: Option<u64>,
}
#[derive(Debug, Serialize)]
pub struct RelayCommandResponse {
pub request_id: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<ErrorPayload>,
}
pub 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)))
}
pub async fn run_command(
State(state): State<AppState>,
AxumPath(agent_id): AxumPath<String>,
Json(req): Json<RelayCommandRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let request_id_string = format!("req-{}", Uuid::new_v4());
let command = command_kind(&req.command);
let span = info_span!(
"relay_command",
agent_id = %agent_id,
request_id = %request_id_string,
command = %command,
);
let _span_guard = span.enter();
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(|| {
warn!("command rejected: agent not connected");
json_error(
StatusCode::NOT_FOUND,
"agent_not_connected",
"agent is not connected",
)
})?;
let (pending_tx, pending_rx) = tokio::sync::oneshot::channel();
state
.pending
.lock()
.await
.insert(request_id_string.clone(), pending_tx);
info!("dispatching command to agent");
if let Err(err) = tx.send(ServerMessage::Command {
request_id,
command: req.command,
}) {
state.pending.lock().await.remove(&request_id_string);
warn!(error = %err, "failed sending command to agent session");
return Err(json_error(
StatusCode::BAD_GATEWAY,
"agent_send_failed",
&format!("failed to send command to agent: {err}"),
));
}
let timeout = std::time::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))) => {
info!("agent command completed");
RelayCommandResponse {
request_id: request_id_string,
status: "ok".into(),
result: Some(result),
error: None,
}
}
Ok(Ok(AgentReply::Error(error))) => {
warn!(code = %error.code, "agent command returned error");
RelayCommandResponse {
request_id: request_id_string,
status: "error".into(),
result: None,
error: Some(error),
}
}
Ok(Err(_)) => {
warn!("agent response channel dropped");
return Err(json_error(
StatusCode::BAD_GATEWAY,
"agent_response_dropped",
"agent response channel dropped",
));
}
Err(_) => {
state.pending.lock().await.remove(&request_id_string);
warn!(timeout_ms = timeout.as_millis() as u64, "agent command timed out");
return Err(json_error(
StatusCode::GATEWAY_TIMEOUT,
"agent_timeout",
"agent did not answer before timeout",
));
}
};
Ok((StatusCode::OK, Json(response)))
}
fn command_kind(command: &AgentCommand) -> &'static str {
match command {
AgentCommand::Status(_) => "status",
AgentCommand::Leases(_) => "leases",
AgentCommand::Devs(_) => "devs",
AgentCommand::Inventory(_) => "inventory",
AgentCommand::Wake(_) => "wake",
}
}
+199
View File
@@ -0,0 +1,199 @@
use axum::Json;
use axum::extract::{Path as AxumPath, Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::api::json_error;
use crate::runtime::AppState;
#[derive(Debug, Deserialize)]
pub struct EnrollRequest {
pub enroll_token: String,
}
#[derive(Debug, Serialize)]
pub struct EnrollResponse {
pub agent_id: String,
pub agent_token: String,
pub server_url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct IssueEnrollTokenResponse {
pub enroll_token: String,
pub expires_at_unix: u64,
}
#[derive(Debug, Deserialize)]
pub struct IssueEnrollTokenQuery {
pub ttl_seconds: Option<u64>,
}
#[derive(Debug, Deserialize)]
pub struct ListEnrollTokenQuery {
pub include_expired: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct EnrollTokenStatus {
pub enroll_token: String,
pub expires_at_unix: u64,
pub expired: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RevokeEnrollTokenResponse {
pub token: String,
pub revoked: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct StateStatsResponse {
pub db_path: String,
pub schema_version: u32,
pub agent_count: usize,
pub enroll_token_count: usize,
pub expired_enroll_token_count: usize,
}
pub async fn healthz() -> &'static str {
"ok"
}
pub 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) => {
info!(agent_id = %issued.agent_id, "agent enrollment accepted");
Ok((
StatusCode::OK,
Json(EnrollResponse {
agent_id: issued.agent_id,
agent_token: issued.agent_token,
server_url: state.public_url,
}),
))
}
Err(err) => {
warn!(error = %err, "agent enrollment rejected");
Err(json_error(
StatusCode::UNAUTHORIZED,
"enrollment_rejected",
&err.to_string(),
))
}
}
}
pub async fn issue_enroll_token(
State(state): State<AppState>,
Query(query): Query<IssueEnrollTokenQuery>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let ttl = std::time::Duration::from_secs(
query
.ttl_seconds
.unwrap_or(state.enroll_token_ttl.as_secs())
.max(1),
);
match state.store.issue_enroll_token(ttl).await {
Ok(issued) => {
info!(
expires_at_unix = issued.expires_at_unix,
"issued enroll token"
);
Ok((
StatusCode::OK,
Json(IssueEnrollTokenResponse {
enroll_token: issued.enroll_token,
expires_at_unix: issued.expires_at_unix,
}),
))
}
Err(err) => {
warn!(error = %err, "failed to issue enroll token");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"issue_enroll_token_failed",
&err.to_string(),
))
}
}
}
pub async fn list_enroll_tokens(
State(state): State<AppState>,
Query(query): Query<ListEnrollTokenQuery>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let include_expired = query.include_expired.unwrap_or(false);
match state.store.list_enroll_tokens(include_expired).await {
Ok(tokens) => {
let body = tokens
.into_iter()
.map(|t| EnrollTokenStatus {
enroll_token: t.enroll_token,
expires_at_unix: t.expires_at_unix,
expired: t.expired,
})
.collect::<Vec<_>>();
Ok((StatusCode::OK, Json(body)))
}
Err(err) => {
warn!(error = %err, "failed to list enroll tokens");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"list_enroll_tokens_failed",
&err.to_string(),
))
}
}
}
pub async fn revoke_enroll_token(
State(state): State<AppState>,
AxumPath(token): AxumPath<String>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state.store.revoke_enroll_token(&token).await {
Ok(revoked) => Ok((
StatusCode::OK,
Json(RevokeEnrollTokenResponse { token, revoked }),
)),
Err(err) => {
warn!(error = %err, "failed to revoke enroll token");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"revoke_enroll_token_failed",
&err.to_string(),
))
}
}
}
pub async fn state_stats(
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
match state.store.stats().await {
Ok(stats) => Ok((
StatusCode::OK,
Json(StateStatsResponse {
db_path: stats.db_path.display().to_string(),
schema_version: stats.schema_version,
agent_count: stats.agent_count,
enroll_token_count: stats.enroll_token_count,
expired_enroll_token_count: stats.expired_enroll_token_count,
}),
)),
Err(err) => {
warn!(error = %err, "failed to read state stats");
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"state_stats_failed",
&err.to_string(),
))
}
}
}
+27
View File
@@ -0,0 +1,27 @@
use axum::Json;
use axum::http::StatusCode;
mod commands;
mod control;
pub use commands::{list_agents, run_command};
pub use control::{
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeEnrollTokenResponse, StateStatsResponse,
enroll, healthz, issue_enroll_token, list_enroll_tokens, revoke_enroll_token, state_stats,
};
pub fn json_error(
status: StatusCode,
code: &str,
message: &str,
) -> (StatusCode, Json<serde_json::Value>) {
(
status,
Json(serde_json::json!({
"error": {
"code": code,
"message": message,
}
})),
)
}
+196
View File
@@ -0,0 +1,196 @@
use std::net::SocketAddr;
use std::path::PathBuf;
use clap::{ArgAction, Args, Parser, Subcommand};
pub const DEFAULT_PID_FILE: &str = "/var/lib/wakey-control-plane/wakey-control-plane.pid";
pub const DEFAULT_DATA_DIR: &str = "/var/lib/wakey-control-plane";
pub const DEFAULT_CONFIG_FILE: &str = "/etc/wakey-control-plane/config.toml";
#[derive(Parser)]
#[command(name = "wakey-control-plane")]
#[command(version, about = "Control plane server for wakey-agent fleets")]
pub struct Cli {
#[arg(short = 'v', long = "verbose", action = ArgAction::Count, global = true)]
pub verbose: u8,
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand)]
pub enum Command {
/// Run the control-plane daemon.
Serve(ServeArgs),
/// Write a control-plane config scaffold.
InitConfig(InitConfigArgs),
/// Create a new enroll token for provisioning a router.
IssueEnrollToken(IssueEnrollTokenArgs),
/// List current enroll tokens and their expiration status.
ListEnrollTokens(ListEnrollTokensArgs),
/// Revoke a specific enroll token.
RevokeEnrollToken(RevokeEnrollTokenArgs),
/// Print state backend stats.
StateStats(StateStatsArgs),
/// Send SIGHUP to an already-running daemon.
Reload(ReloadArgs),
}
#[derive(Args, Clone)]
pub struct ServeArgs {
#[arg(long)]
pub data_dir: Option<PathBuf>,
#[arg(long)]
pub bind: Option<SocketAddr>,
#[arg(long)]
pub public_url: Option<String>,
#[arg(long)]
pub state_file: Option<PathBuf>,
#[arg(long = "enroll-token")]
pub enroll_tokens: Vec<String>,
#[arg(long)]
pub command_timeout_ms: Option<u64>,
#[arg(long)]
pub enroll_token_ttl_seconds: Option<u64>,
#[arg(long)]
pub pid_file: Option<PathBuf>,
#[arg(long, default_value = DEFAULT_CONFIG_FILE)]
pub config_file: PathBuf,
#[arg(long)]
pub bootstrap_config: bool,
}
#[derive(Args, Clone)]
pub struct InitConfigArgs {
#[arg(long, default_value = DEFAULT_CONFIG_FILE)]
pub config_file: PathBuf,
#[arg(long)]
pub data_dir: Option<PathBuf>,
#[arg(long)]
pub bind: Option<SocketAddr>,
#[arg(long)]
pub public_url: Option<String>,
#[arg(long)]
pub state_file: Option<PathBuf>,
#[arg(long)]
pub pid_file: Option<PathBuf>,
#[arg(long)]
pub command_timeout_ms: Option<u64>,
#[arg(long)]
pub enroll_token_ttl_seconds: Option<u64>,
#[arg(long = "enroll-token")]
pub enroll_tokens: Vec<String>,
#[arg(long)]
pub telemetry_otlp_endpoint: Option<String>,
#[arg(long)]
pub telemetry_service_name: Option<String>,
#[arg(long)]
pub telemetry_json_logs: bool,
#[arg(long)]
pub force: bool,
}
#[derive(Args)]
pub struct IssueEnrollTokenArgs {
#[arg(long, default_value = DEFAULT_CONFIG_FILE)]
pub config_file: PathBuf,
#[arg(long)]
pub state_file: Option<PathBuf>,
#[arg(long)]
pub data_dir: Option<PathBuf>,
#[arg(long = "enroll-token")]
pub enroll_tokens: Vec<String>,
#[arg(long)]
pub public_url: Option<String>,
#[arg(long)]
pub ttl_seconds: Option<u64>,
}
#[derive(Args)]
pub struct ListEnrollTokensArgs {
#[arg(long, default_value = DEFAULT_CONFIG_FILE)]
pub config_file: PathBuf,
#[arg(long)]
pub state_file: Option<PathBuf>,
#[arg(long)]
pub data_dir: Option<PathBuf>,
#[arg(long)]
pub public_url: Option<String>,
#[arg(long)]
pub include_expired: bool,
#[arg(long)]
pub json: bool,
}
#[derive(Args)]
pub struct RevokeEnrollTokenArgs {
#[arg(long, default_value = DEFAULT_CONFIG_FILE)]
pub config_file: PathBuf,
#[arg(long)]
pub state_file: Option<PathBuf>,
#[arg(long)]
pub data_dir: Option<PathBuf>,
#[arg(long)]
pub public_url: Option<String>,
#[arg(long)]
pub token: String,
}
#[derive(Args)]
pub struct StateStatsArgs {
#[arg(long, default_value = DEFAULT_CONFIG_FILE)]
pub config_file: PathBuf,
#[arg(long)]
pub state_file: Option<PathBuf>,
#[arg(long)]
pub data_dir: Option<PathBuf>,
#[arg(long)]
pub public_url: Option<String>,
#[arg(long)]
pub json: bool,
}
#[derive(Args)]
pub struct ReloadArgs {
#[arg(long, default_value = DEFAULT_PID_FILE)]
pub pid_file: PathBuf,
}
+98
View File
@@ -0,0 +1,98 @@
use std::path::PathBuf;
use anyhow::{Context, Result};
use crate::cli::{InitConfigArgs, ServeArgs};
use crate::config::resolve::{normalize_public_url, resolve_path};
use crate::config::types::{WritableConfig, WritableTelemetry};
pub fn write_init_config(args: &InitConfigArgs) -> Result<()> {
if args.config_file.exists() && !args.force {
anyhow::bail!(
"config {} already exists; re-run with --force to overwrite",
args.config_file.display()
);
}
let bind = args.bind.unwrap_or_else(|| {
"0.0.0.0:8080"
.parse()
.expect("static default bind should parse")
});
let public_url = normalize_public_url(
args.public_url
.as_deref()
.unwrap_or("http://127.0.0.1:8080"),
);
let data_dir = args
.data_dir
.clone()
.unwrap_or_else(|| PathBuf::from(crate::cli::DEFAULT_DATA_DIR));
let state_file_raw = args
.state_file
.clone()
.unwrap_or_else(|| PathBuf::from("state.db"));
let state_file = resolve_path(&data_dir, state_file_raw);
let pid_file_raw = args
.pid_file
.clone()
.unwrap_or_else(|| PathBuf::from("wakey-control-plane.pid"));
let pid_file = resolve_path(&data_dir, pid_file_raw);
let body = WritableConfig {
data_dir,
bind: bind.to_string(),
public_url,
state_file,
command_timeout_ms: args.command_timeout_ms.unwrap_or(30_000).max(1),
enroll_token_ttl_seconds: args.enroll_token_ttl_seconds.unwrap_or(86_400).max(1),
pid_file,
enroll_tokens: args.enroll_tokens.clone(),
telemetry: WritableTelemetry {
otlp_endpoint: args.telemetry_otlp_endpoint.clone(),
service_name: args
.telemetry_service_name
.clone()
.unwrap_or_else(|| "wakey-control-plane".to_string()),
json_logs: args.telemetry_json_logs,
},
};
if let Some(parent) = args.config_file.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create config dir {}", parent.display()))?;
}
let rendered = toml::to_string_pretty(&body).context("failed to render config template")?;
std::fs::write(&args.config_file, rendered)
.with_context(|| format!("failed to write config {}", args.config_file.display()))?;
Ok(())
}
pub fn bootstrap_config_if_missing(args: &ServeArgs) -> Result<bool> {
if args.config_file.exists() {
return Ok(false);
}
let init = InitConfigArgs {
config_file: args.config_file.clone(),
data_dir: args.data_dir.clone(),
bind: args.bind,
public_url: args.public_url.clone(),
state_file: args.state_file.clone(),
pid_file: args.pid_file.clone(),
command_timeout_ms: args.command_timeout_ms,
enroll_token_ttl_seconds: args.enroll_token_ttl_seconds,
enroll_tokens: args.enroll_tokens.clone(),
telemetry_otlp_endpoint: None,
telemetry_service_name: None,
telemetry_json_logs: false,
force: false,
};
write_init_config(&init)?;
Ok(true)
}
+11
View File
@@ -0,0 +1,11 @@
mod init;
mod resolve;
mod types;
pub use init::{bootstrap_config_if_missing, write_init_config};
pub use resolve::{
issue_token_endpoint, normalize_public_url, resolve_issue_token_settings,
resolve_list_enroll_token_settings, resolve_revoke_enroll_token_settings,
resolve_state_stats_settings,
};
pub use types::{DaemonConfig, TelemetryConfig};
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more