Compare commits
88
Commits
v0.1.5
...
fec10a61d4
No files matched your search
+16
-2
@@ -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"
|
||||
@@ -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 }}
|
||||
@@ -1,8 +1,3 @@
|
||||
you should use block code to refence code, like so:
|
||||
```rust
|
||||
hello_world!(println);
|
||||
// indented lines
|
||||
```
|
||||
in the chat. Else it fucks up formatting.
|
||||
We work towards clean, modular, maintainable design, clear documentation... im not the best at this thing, so you help me.
|
||||
|
||||
We work towards clean, maintainable design. im not the best at this thing.
|
||||
DRY: spawn subagents.
|
||||
@@ -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.
|
||||
Vendored
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"rust-analyzer.cargo.target": "armv7-unknown-linux-musleabihf",
|
||||
"rust-analyzer.diagnostics.disabled": ["unlinked-file"]
|
||||
}
|
||||
// "rust-analyzer.diagnostics.disabled": ["unlinked-file"]
|
||||
}
|
||||
Generated
+1995
-313
File diff suppressed because it is too large.
Load diff
+37
-17
@@ -1,28 +1,48 @@
|
||||
[package]
|
||||
name = "wakey"
|
||||
version = "0.1.5"
|
||||
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_html_form = "0.2.7"
|
||||
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", "io-util"] }
|
||||
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
|
||||
|
||||
[features]
|
||||
default = ["very-smart-parsing"]
|
||||
very-smart-parsing = [] # this is the a-bit-redundant parse thing that copilot made
|
||||
[workspace]
|
||||
members = ["ipjs", "wakey-agent", "wakey-control-plane", "wakey-core", "wakey-linux"]
|
||||
|
||||
[dependencies.lda-ipjs]
|
||||
path = "ipjs"
|
||||
registry = "gitea"
|
||||
version = "0"
|
||||
|
||||
[dependencies.wakey-core]
|
||||
path = "wakey-core"
|
||||
registry = "gitea"
|
||||
version = "0"
|
||||
|
||||
[dependencies.wakey-linux]
|
||||
path = "wakey-linux"
|
||||
registry = "gitea"
|
||||
version = "0"
|
||||
@@ -1,20 +1,181 @@
|
||||
# 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
|
||||
|
||||
## 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
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,60 @@
|
||||
# lda-ipjs
|
||||
|
||||
Typed Rust wrappers around Linux `ip -j ...` output, with optional experimental rtnetlink backends.
|
||||
|
||||
## Purpose
|
||||
|
||||
`lda-ipjs` exists so `wakey` can ask Linux networking questions in typed Rust instead of:
|
||||
|
||||
- parsing plain-text shell output
|
||||
- scattering `tokio::process::Command::new("ip")` everywhere
|
||||
- mixing product logic with Linux networking trivia
|
||||
|
||||
## Current contract
|
||||
|
||||
Stable default behavior:
|
||||
|
||||
- `address::get(...)` uses JSON (`ip -j address show`)
|
||||
- `link::get(...)` uses JSON (`ip -j link show`)
|
||||
- `neighbor::get(...)` uses JSON (`ip -j neigh show`)
|
||||
|
||||
Optional experimental behavior:
|
||||
|
||||
- feature: `experimental-nl`
|
||||
- enables rtnetlink-backed implementations
|
||||
- intended for places where one-pass kernel queries are materially better than repeated `ip -j` calls
|
||||
|
||||
This means the public API is:
|
||||
|
||||
- `get(...)` for the default backend
|
||||
- `get_with_backend(Backend::Json | Backend::Netlink)` when backend choice matters
|
||||
|
||||
## Modules
|
||||
|
||||
- `subcommands::address`
|
||||
- typed address/interface-address data
|
||||
- good place for subnet/broadcast derivation later
|
||||
- `subcommands::link`
|
||||
- typed link/interface data
|
||||
- useful for `ifindex -> ifname` mapping and interface metadata
|
||||
- `subcommands::neighbor`
|
||||
- typed neighbor-table data
|
||||
- currently the most useful experimental netlink surface
|
||||
|
||||
## Backend policy
|
||||
|
||||
JSON is the normal path.
|
||||
|
||||
Use netlink only when:
|
||||
|
||||
- the call is hot enough to matter
|
||||
- repeated shelling out is obviously wasteful
|
||||
- the netlink implementation is at least as coherent as the JSON one
|
||||
|
||||
Today that mainly applies to `neighbor::nl`.
|
||||
|
||||
## Relationship to wakey
|
||||
|
||||
`wakey` is the product.
|
||||
|
||||
`lda-ipjs` is a Linux networking adapter crate underneath it.
|
||||
@@ -0,0 +1,3 @@
|
||||
trait IpCommand {
|
||||
|
||||
}
|
||||
Whitespace-only changes.
@@ -0,0 +1,19 @@
|
||||
//! low
|
||||
//!
|
||||
//! # lda-ipjs
|
||||
//!
|
||||
//! this package will represent all my needs with the all the subcommands of ip -j.
|
||||
//!
|
||||
//! ## what i need
|
||||
//!
|
||||
//! ```console
|
||||
//! ip -j neigh show
|
||||
//! ```
|
||||
//!
|
||||
//! i also need to see devices and idk MAYBE maybe not MAYBE UHHHHHH maybe broadcast
|
||||
//!
|
||||
//! LOWK if this were to be calls to kernel or some bullshit then PLEASE because doing ts parsing its hell cuh
|
||||
pub mod subcommands;
|
||||
pub mod utils;
|
||||
|
||||
// i want a generalized way to build and call
|
||||
@@ -0,0 +1,46 @@
|
||||
// ip address [ show [ dev IFNAME ] [ scope SCOPE-ID ] [ master DEVICE ]
|
||||
// [ type TYPE ] [ to PREFIX ] [ FLAG-LIST ]
|
||||
// [ label LABEL ] [up] [ vrf NAME ] ]
|
||||
// fuck is this mean
|
||||
|
||||
// do i need all this? do i need anything but `ip -j a show dev br-lan`?
|
||||
|
||||
// TYPE := { vlan | veth | vcan | vxcan | dummy | ifb | macvlan | macvtap |
|
||||
// bridge | bond | ipoib | ip6tnl | ipip | sit | vxlan | lowpan |
|
||||
// gre | gretap | erspan | ip6gre | ip6gretap | ip6erspan | vti |
|
||||
// nlmon | can | bond_slave | ipvlan | geneve | bridge_slave |
|
||||
// hsr | macsec | netdevsim }
|
||||
// FLAG-LIST := [ FLAG-LIST ] FLAG
|
||||
// FLAG := [ permanent | dynamic | secondary | primary |
|
||||
// [-]tentative | [-]deprecated | [-]dadfailed | temporary |
|
||||
// CONFFLAG-LIST ]
|
||||
// CONFFLAG-LIST := [ CONFFLAG-LIST ] CONFFLAG
|
||||
// CONFFLAG := [ home | nodad | mngtmpaddr | noprefixroute | autojoin ]
|
||||
|
||||
// prefix seems to be a cidr. both 6 and 4 works. idfk dog
|
||||
|
||||
use std::{io, process::Output};
|
||||
|
||||
use anyhow::Context;
|
||||
|
||||
use super::AddrOutput;
|
||||
|
||||
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<AddrOutput>> {
|
||||
let output = _get(dev).await.context("Can not run command")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(String::from_utf8_lossy(&output.stderr).into_owned());
|
||||
}
|
||||
|
||||
serde_json::from_slice(&output.stdout).context("Deserialize failed")
|
||||
}
|
||||
pub async fn _get(dev: Option<&str>) -> io::Result<Output> {
|
||||
let mut cmd = tokio::process::Command::new("ip");
|
||||
cmd.args(["-j", "address", "show"]);
|
||||
|
||||
if let Some(d) = dev {
|
||||
cmd.args(["dev", d]);
|
||||
}
|
||||
|
||||
cmd.output().await
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Typed wrappers for `ip -j address show`.
|
||||
//!
|
||||
//! This module is intentionally close to the Linux output shape while still
|
||||
//! tightening a few fields into more useful Rust types.
|
||||
|
||||
pub mod json;
|
||||
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||
pub mod nl;
|
||||
|
||||
pub use crate::subcommands::Backend;
|
||||
use crate::utils::serialize::mac::option_mac;
|
||||
use macaddr::MacAddr;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use std::fmt;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
use crate::subcommands::link::OperState;
|
||||
|
||||
/// One interface row from `ip -j address show`.
|
||||
#[derive(Serialize, Debug, Deserialize)]
|
||||
pub struct AddrOutput {
|
||||
pub ifindex: u32,
|
||||
pub ifname: String,
|
||||
/// Interface operational state.
|
||||
pub operstate: OperState,
|
||||
#[serde(with = "option_mac", default)]
|
||||
pub address: Option<MacAddr>,
|
||||
/// Per-address entries attached to this interface.
|
||||
#[serde(default)]
|
||||
pub addr_info: Vec<AddrInfo>,
|
||||
}
|
||||
|
||||
/// One address entry nested under an interface row.
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct AddrInfo {
|
||||
pub family: Option<AddressFamily>,
|
||||
|
||||
#[serde(flatten, default)]
|
||||
pub cidr: InterfaceCidr,
|
||||
|
||||
pub broadcast: Option<Ipv4Addr>,
|
||||
|
||||
pub scope: Option<String>,
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
/// Address family used by `ip address` output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AddressFamily {
|
||||
Inet,
|
||||
Inet6,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl AddressFamily {
|
||||
pub fn parse_lossy(value: &str) -> Self {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"inet" => Self::Inet,
|
||||
"inet6" => Self::Inet6,
|
||||
_ => Self::Other,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Inet => "inet",
|
||||
Self::Inet6 => "inet6",
|
||||
Self::Other => "other",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for AddressFamily {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for AddressFamily {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = String::deserialize(deserializer)?;
|
||||
Ok(Self::parse_lossy(&value))
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw parsed local address plus prefix length.
|
||||
///
|
||||
/// This is still a source-shaped type; callers that need a guaranteed usable
|
||||
/// CIDR should validate that both fields are present.
|
||||
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
|
||||
pub struct InterfaceCidr {
|
||||
pub local: Option<IpAddr>,
|
||||
pub prefixlen: Option<u8>,
|
||||
}
|
||||
|
||||
impl InterfaceCidr {
|
||||
/// Return whether both parts needed for a usable CIDR are present.
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.local.is_some() && self.prefixlen.is_some()
|
||||
}
|
||||
|
||||
/// Return a validated complete CIDR when both fields are present.
|
||||
pub fn complete(&self) -> Option<CompleteInterfaceCidr> {
|
||||
Some(CompleteInterfaceCidr {
|
||||
local: self.local?,
|
||||
prefixlen: self.prefixlen?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Format the CIDR as `addr/prefixlen` when both fields are present.
|
||||
pub fn to_cidr_string(&self) -> Option<String> {
|
||||
self.complete().map(|cidr| cidr.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Validated interface CIDR with both local address and prefix length present.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CompleteInterfaceCidr {
|
||||
pub local: IpAddr,
|
||||
pub prefixlen: u8,
|
||||
}
|
||||
|
||||
impl fmt::Display for CompleteInterfaceCidr {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}/{}", self.local, self.prefixlen)
|
||||
}
|
||||
}
|
||||
|
||||
impl AddrInfo {
|
||||
/// Return the parsed local IP address when present.
|
||||
pub fn local_addr(&self) -> Option<IpAddr> {
|
||||
self.cidr.local
|
||||
}
|
||||
|
||||
/// Return the parsed prefix length when present.
|
||||
pub fn prefixlen(&self) -> Option<u8> {
|
||||
self.cidr.prefixlen
|
||||
}
|
||||
|
||||
/// Return whether this row is IPv4.
|
||||
pub fn is_ipv4(&self) -> bool {
|
||||
matches!(self.family, Some(AddressFamily::Inet))
|
||||
}
|
||||
|
||||
/// Return whether this row is IPv6.
|
||||
pub fn is_ipv6(&self) -> bool {
|
||||
matches!(self.family, Some(AddressFamily::Inet6))
|
||||
}
|
||||
}
|
||||
|
||||
impl AddrOutput {
|
||||
/// Iterate IPv4 address entries.
|
||||
pub fn ipv4_addrs(&self) -> impl Iterator<Item = &AddrInfo> {
|
||||
self.addr_info.iter().filter(|info| info.is_ipv4())
|
||||
}
|
||||
|
||||
/// Iterate IPv6 address entries.
|
||||
pub fn ipv6_addrs(&self) -> impl Iterator<Item = &AddrInfo> {
|
||||
self.addr_info.iter().filter(|info| info.is_ipv6())
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch address data using the default backend.
|
||||
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<AddrOutput>> {
|
||||
get_with_backend(Backend::Json, dev).await
|
||||
}
|
||||
|
||||
/// Fetch address data using an explicit backend.
|
||||
pub async fn get_with_backend(
|
||||
backend: Backend,
|
||||
dev: Option<&str>,
|
||||
) -> anyhow::Result<Vec<AddrOutput>> {
|
||||
match backend {
|
||||
Backend::Json => json::get(dev).await,
|
||||
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||
Backend::Netlink => nl::get(dev).await,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
use super::{AddrInfo, AddrOutput, AddressFamily, InterfaceCidr};
|
||||
use crate::subcommands::link::OperState;
|
||||
|
||||
#[test]
|
||||
fn address_family_parses_known_values() {
|
||||
assert_eq!(AddressFamily::parse_lossy("inet"), AddressFamily::Inet);
|
||||
assert_eq!(AddressFamily::parse_lossy("INET6"), AddressFamily::Inet6);
|
||||
assert_eq!(AddressFamily::parse_lossy("weird"), AddressFamily::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn addr_info_deserializes_typed_ip_fields() {
|
||||
let info: AddrInfo = serde_json::from_str(
|
||||
r#"{"family":"inet","local":"192.168.1.1","prefixlen":24,"broadcast":"192.168.1.255","scope":"global"}"#,
|
||||
)
|
||||
.expect("addr_info json should deserialize");
|
||||
|
||||
assert_eq!(info.family, Some(AddressFamily::Inet));
|
||||
assert_eq!(
|
||||
info.local_addr(),
|
||||
Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)))
|
||||
);
|
||||
assert_eq!(info.prefixlen(), Some(24));
|
||||
assert_eq!(info.broadcast, Some(Ipv4Addr::new(192, 168, 1, 255)));
|
||||
assert!(info.cidr.is_complete());
|
||||
assert_eq!(
|
||||
info.cidr.to_cidr_string().as_deref(),
|
||||
Some("192.168.1.1/24")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn addr_output_deserializes_typed_operstate() {
|
||||
let output: AddrOutput = serde_json::from_str(
|
||||
r#"{"ifindex":2,"ifname":"br-lan","operstate":"UP","address":"aa:bb:cc:dd:ee:ff","addr_info":[]}"#,
|
||||
)
|
||||
.expect("addr_output json should deserialize");
|
||||
|
||||
assert_eq!(output.operstate, OperState::Up);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_cidr_complete_formats() {
|
||||
let cidr = InterfaceCidr {
|
||||
local: Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))),
|
||||
prefixlen: Some(16),
|
||||
};
|
||||
|
||||
let complete = cidr.complete().expect("cidr should be complete");
|
||||
assert_eq!(complete.to_string(), "10.0.0.1/16");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use std::{io, process::Output};
|
||||
|
||||
use anyhow::Context;
|
||||
|
||||
use super::LinkOutput;
|
||||
|
||||
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<LinkOutput>> {
|
||||
let output = _get(dev).await.context("Can not run command")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(String::from_utf8_lossy(&output.stderr).into_owned());
|
||||
}
|
||||
|
||||
serde_json::from_slice(&output.stdout).context("Deserialize failed")
|
||||
}
|
||||
|
||||
pub async fn _get(dev: Option<&str>) -> io::Result<Output> {
|
||||
let mut cmd = tokio::process::Command::new("ip");
|
||||
cmd.args(["-j", "link", "show"]);
|
||||
|
||||
if let Some(d) = dev {
|
||||
cmd.arg(d);
|
||||
}
|
||||
|
||||
cmd.output().await
|
||||
}
|
||||
@@ -0,0 +1,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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
#![cfg(unix)]
|
||||
|
||||
use futures::TryStreamExt;
|
||||
use rtnetlink::Handle;
|
||||
use rtnetlink::packet_route::link::LinkAttribute;
|
||||
|
||||
use super::LinkOutput;
|
||||
|
||||
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<LinkOutput>> {
|
||||
let (conn, handle, _) = rtnetlink::new_connection()?;
|
||||
tokio::spawn(conn);
|
||||
|
||||
get_with_handle(&handle, dev).await
|
||||
}
|
||||
|
||||
pub async fn get_with_handle(
|
||||
handle: &Handle,
|
||||
dev: Option<&str>,
|
||||
) -> anyhow::Result<Vec<LinkOutput>> {
|
||||
let mut req = handle.link().get();
|
||||
if let Some(dev) = dev {
|
||||
req = req.match_name(dev.to_owned());
|
||||
}
|
||||
|
||||
let mut stream = req.execute();
|
||||
let mut out = Vec::new();
|
||||
|
||||
while let Some(link) = stream.try_next().await? {
|
||||
let mut ifname = None;
|
||||
let mut operstate = None;
|
||||
let mut address = None;
|
||||
|
||||
for attr in link.attributes {
|
||||
match attr {
|
||||
LinkAttribute::IfName(name) => ifname = Some(name),
|
||||
LinkAttribute::Address(bytes) => {
|
||||
address = match bytes.len() {
|
||||
6 => bytes.first_chunk::<6>().map(|&b| macaddr::MacAddr::from(b)),
|
||||
8 => bytes.first_chunk::<8>().map(|&b| macaddr::MacAddr::from(b)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
LinkAttribute::OperState(state) => operstate = Some(state.into()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ifname) = ifname {
|
||||
out.push(LinkOutput {
|
||||
ifindex: link.header.index,
|
||||
ifname,
|
||||
operstate,
|
||||
address,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
pub mod address;
|
||||
pub mod link;
|
||||
pub mod neighbor;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Backend {
|
||||
Json,
|
||||
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||
Netlink,
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! idk what to put here
|
||||
|
||||
use std::{io, net::IpAddr, process::Output};
|
||||
|
||||
use anyhow::{Context, bail};
|
||||
|
||||
use super::{NUDState, NeighborItem};
|
||||
|
||||
// loose translation of [wakey::utils::query::macs::get_mac]
|
||||
// i think ill write tokio::process every time tho (for this if let thing) because iterate through all ts youll have to as str and all the hooplas.
|
||||
// it all turns to live osstr tho so ts just for my own sanity
|
||||
// thiserror? anyhow
|
||||
|
||||
// what is vro sayin
|
||||
// ahh. instead of using [wakey::utils::cmd::exec_command] which is ass we jus write everything out. so i dont have to .as_str() so often.
|
||||
|
||||
pub async fn get(
|
||||
ip: Option<IpAddr>,
|
||||
dev: Option<&str>,
|
||||
nud: &[NUDState],
|
||||
) -> anyhow::Result<Vec<NeighborItem>> {
|
||||
let output = _get(ip, dev, nud).await.context("Can not run command")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(String::from_utf8_lossy(&output.stderr).into_owned())
|
||||
} else {
|
||||
let mut fuckass: Vec<NeighborItem> =
|
||||
serde_json::from_slice(&output.stdout).context("Deserialize failed")?;
|
||||
|
||||
// i hate ts.
|
||||
if let Some(dev) = dev {
|
||||
for item in &mut fuckass {
|
||||
if item.dev.is_none() {
|
||||
item.dev = Some(dev.to_owned());
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(fuckass)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn _get(ip: Option<IpAddr>, dev: Option<&str>, nud: &[NUDState]) -> io::Result<Output> {
|
||||
let mut cmd = tokio::process::Command::new("ip");
|
||||
cmd.args(["-j", "neigh", "show"]);
|
||||
|
||||
if let Some(ip) = ip {
|
||||
// cmd.arg("to");
|
||||
cmd.arg(ip.to_canonical().to_string());
|
||||
};
|
||||
|
||||
if let Some(dev) = dev {
|
||||
cmd.args(["dev", dev]);
|
||||
}
|
||||
for nud in nud {
|
||||
cmd.arg("nud");
|
||||
cmd.arg(nud.to_string());
|
||||
}
|
||||
|
||||
cmd.output().await
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Typed wrappers for `ip -j neigh show`.
|
||||
|
||||
pub mod json;
|
||||
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||
pub mod nl;
|
||||
|
||||
pub use crate::subcommands::Backend;
|
||||
use crate::utils::serialize::mac::option_mac;
|
||||
use std::net::IpAddr;
|
||||
|
||||
use macaddr::MacAddr;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use strum::{Display, EnumString};
|
||||
|
||||
/// Structured neighbor query input matching the common `ip neigh` flags.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
|
||||
pub struct NeighborInput {
|
||||
/// Destination address filter. The optional `to` keyword in CLI form is implicit here.
|
||||
pub to: Option<IpAddr>,
|
||||
/// Interface-name filter.
|
||||
pub dev: Option<String>,
|
||||
/// Neighbor-state filters.
|
||||
pub nud: Vec<NUDState>,
|
||||
}
|
||||
|
||||
/// Linux neighbor reachability states.
|
||||
#[derive(
|
||||
Debug, PartialEq, Eq, EnumString, Display, Clone, Copy, Hash, Serialize, Deserialize, Default,
|
||||
)]
|
||||
#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
|
||||
#[serde(rename_all = "UPPERCASE")]
|
||||
pub enum NUDState {
|
||||
/// the neighbour entry is valid forever and can
|
||||
/// be only be removed administratively.
|
||||
Permanent,
|
||||
/// the neighbour entry is valid. No attempts to
|
||||
/// validate this entry will be made but it can
|
||||
/// be removed when its lifetime expires.
|
||||
Noarp,
|
||||
/// the neighbour entry is valid until the
|
||||
/// reachability timeout expires.
|
||||
Reachable,
|
||||
/// the neighbour entry is valid but suspicious.
|
||||
/// This option to ip neigh does not change the
|
||||
/// neighbour state if it was valid and the
|
||||
/// address is not changed by this command.
|
||||
Stale,
|
||||
/// this is a pseudo state used when initially
|
||||
/// creating a neighbour entry or after trying to
|
||||
/// remove it before it becomes free to do so.
|
||||
#[default]
|
||||
None,
|
||||
/// the neighbour entry has not (yet) been
|
||||
/// validated/resolved.
|
||||
Incomplete,
|
||||
/// neighbor entry validation is currently
|
||||
/// delayed.
|
||||
Delay,
|
||||
/// neighbor is being probed.
|
||||
Probe,
|
||||
/// max number of probes exceeded without
|
||||
/// success, neighbor validation has ultimately
|
||||
/// failed.
|
||||
Failed,
|
||||
|
||||
Other(u16),
|
||||
}
|
||||
|
||||
/// One neighbor row from `ip -j neigh show`.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
|
||||
pub struct NeighborItem {
|
||||
#[serde(rename(deserialize = "dst"))]
|
||||
pub ip: IpAddr,
|
||||
#[serde(default)]
|
||||
pub dev: Option<String>,
|
||||
#[serde(with = "option_mac", default, rename(deserialize = "lladdr"))]
|
||||
pub mac: Option<MacAddr>,
|
||||
#[serde(default)]
|
||||
pub state: Vec<NUDState>,
|
||||
}
|
||||
|
||||
/// Fetch neighbor rows using the default backend.
|
||||
pub async fn get(
|
||||
ip: Option<IpAddr>,
|
||||
dev: Option<&str>,
|
||||
nud: &[NUDState],
|
||||
) -> anyhow::Result<Vec<NeighborItem>> {
|
||||
get_with_backend(Backend::Json, ip, dev, nud).await
|
||||
}
|
||||
|
||||
/// Fetch neighbor rows using an explicit backend.
|
||||
pub async fn get_with_backend(
|
||||
backend: Backend,
|
||||
ip: Option<IpAddr>,
|
||||
dev: Option<&str>,
|
||||
nud: &[NUDState],
|
||||
) -> anyhow::Result<Vec<NeighborItem>> {
|
||||
match backend {
|
||||
Backend::Json => json::get(ip, dev, nud).await,
|
||||
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||
Backend::Netlink => {
|
||||
let ips: Vec<IpAddr> = ip.into_iter().collect();
|
||||
let devs: Vec<&str> = dev.into_iter().collect();
|
||||
nl::get(&ips, &devs, nud, &[]).await
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! rtnetlink-based neighbor table query. One syscall, filter in userspace.
|
||||
#![cfg(unix)]
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
net::IpAddr,
|
||||
};
|
||||
|
||||
use futures::TryStreamExt;
|
||||
use macaddr::MacAddr;
|
||||
use rtnetlink::Handle;
|
||||
use rtnetlink::packet_route::{
|
||||
AddressFamily,
|
||||
neighbour::{NeighbourAddress, NeighbourAttribute, NeighbourState},
|
||||
};
|
||||
|
||||
use super::{NUDState, NeighborItem};
|
||||
use crate::subcommands::link;
|
||||
|
||||
/// Fetch neighbors via rtnetlink. Empty slice = no filter (match all).
|
||||
/// Non-empty slice = match ANY in the set.
|
||||
// https://github.com/rust-netlink/rtnetlink/blob/main/examples/get_neighbours.rs
|
||||
pub async fn get(
|
||||
ips: &[IpAddr],
|
||||
devs: &[impl AsRef<str>],
|
||||
nuds: &[NUDState],
|
||||
macs: &[MacAddr],
|
||||
) -> anyhow::Result<Vec<NeighborItem>> {
|
||||
let (conn, handle, _) = rtnetlink::new_connection()?;
|
||||
tokio::spawn(conn);
|
||||
|
||||
get_with_handle(&handle, ips, devs, nuds, macs).await
|
||||
}
|
||||
|
||||
pub async fn get_with_handle(
|
||||
handle: &Handle,
|
||||
ips: &[IpAddr],
|
||||
devs: &[impl AsRef<str>],
|
||||
nuds: &[NUDState],
|
||||
macs: &[MacAddr],
|
||||
) -> anyhow::Result<Vec<NeighborItem>> {
|
||||
// Build filter sets (empty = match all)
|
||||
let ip_set: HashSet<IpAddr> = ips.iter().copied().collect();
|
||||
let dev_set: HashSet<&str> = devs.iter().map(AsRef::as_ref).collect();
|
||||
let nud_set: HashSet<&NUDState> = nuds.iter().collect();
|
||||
let mac_set: HashSet<MacAddr> = macs.iter().copied().collect();
|
||||
|
||||
// Prefetch all links once; link lookups were the ugliest and most expensive part.
|
||||
let ifname_cache: HashMap<u32, String> = link::nl::get_with_handle(handle, None)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|link| (link.ifindex, link.ifname))
|
||||
.collect();
|
||||
|
||||
let mut neighbor_data = handle.neighbours().get().execute();
|
||||
let mut result = vec![];
|
||||
|
||||
'row: while let Some(msg) = neighbor_data.try_next().await? {
|
||||
// Only IPv4/IPv6, skip NOARP
|
||||
if !matches!(
|
||||
msg.header.family,
|
||||
AddressFamily::Inet | AddressFamily::Inet6
|
||||
) || matches!(msg.header.state, NeighbourState::Noarp)
|
||||
{
|
||||
continue 'row;
|
||||
}
|
||||
|
||||
let state: NUDState = msg.header.state.try_into().unwrap_or_default();
|
||||
let mut ip = None;
|
||||
let mut mac = None;
|
||||
|
||||
for attr in msg.attributes {
|
||||
match attr {
|
||||
NeighbourAttribute::Destination(addr) => match addr {
|
||||
NeighbourAddress::Inet(v4) => ip = Some(IpAddr::from(v4)),
|
||||
NeighbourAddress::Inet6(v6) => ip = Some(IpAddr::from(v6)),
|
||||
_ => continue 'row,
|
||||
},
|
||||
NeighbourAttribute::LinkLocalAddress(bytes) => {
|
||||
mac = match bytes.len() {
|
||||
6 => bytes.first_chunk::<6>().map(|&b| MacAddr::from(b)),
|
||||
8 => bytes.first_chunk::<8>().map(|&b| MacAddr::from(b)),
|
||||
_ => continue 'row,
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve ifindex -> name (cached)
|
||||
let dev = ifname_cache.get(&msg.header.ifindex).cloned();
|
||||
|
||||
let (Some(ip), Some(dev)) = (ip, dev) else {
|
||||
continue 'row;
|
||||
};
|
||||
|
||||
// Apply filters (empty set = match all)
|
||||
if !ip_set.is_empty() && !ip_set.contains(&ip) {
|
||||
continue 'row;
|
||||
}
|
||||
if !dev_set.is_empty() && !dev_set.contains(dev.as_str()) {
|
||||
continue 'row;
|
||||
}
|
||||
if !nud_set.is_empty() && !nud_set.contains(&state) {
|
||||
continue 'row;
|
||||
}
|
||||
if !mac_set.is_empty() && !mac.is_some_and(|m| mac_set.contains(&m)) {
|
||||
continue 'row;
|
||||
}
|
||||
|
||||
result.push(NeighborItem {
|
||||
ip,
|
||||
dev: Some(dev),
|
||||
mac,
|
||||
state: vec![state],
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
impl TryFrom<NeighbourState> for NUDState {
|
||||
fn try_from(value: NeighbourState) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
NeighbourState::Incomplete => Ok(Self::Incomplete),
|
||||
NeighbourState::Reachable => Ok(Self::Reachable),
|
||||
NeighbourState::Stale => Ok(Self::Stale),
|
||||
NeighbourState::Delay => Ok(Self::Delay),
|
||||
NeighbourState::Probe => Ok(Self::Probe),
|
||||
NeighbourState::Failed => Ok(Self::Failed),
|
||||
NeighbourState::Noarp => Ok(Self::Noarp),
|
||||
NeighbourState::Permanent => Ok(Self::Permanent),
|
||||
NeighbourState::None => Ok(Self::None),
|
||||
NeighbourState::Other(e) => Ok(Self::Other(e)),
|
||||
_ => Err(u16::MAX),
|
||||
}
|
||||
}
|
||||
|
||||
type Error = u16;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
use std::iter::Fuse;
|
||||
|
||||
/// wrap ts into a [Fuse][std::iter::Fuse] or something
|
||||
pub struct Real<A: Clone, B: Iterator<Item = A>> {
|
||||
prepend: A,
|
||||
iter: B,
|
||||
my_turn: bool,
|
||||
}
|
||||
|
||||
impl<A: Clone, B: Iterator<Item = A>> Real<A, B> {
|
||||
pub fn new(prepend: A, iter: B) -> Self {
|
||||
Self {
|
||||
prepend,
|
||||
iter,
|
||||
my_turn: true,
|
||||
}
|
||||
}
|
||||
pub fn fuse(prepend: A, iter: B) -> Fuse<Self> {
|
||||
Self::new(prepend, iter).fuse()
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: Clone, B: Iterator<Item = A>> Iterator for Real<A, B> {
|
||||
type Item = A;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let a = if self.my_turn {
|
||||
Some(self.prepend.clone())
|
||||
} else {
|
||||
self.iter.next()
|
||||
};
|
||||
self.my_turn = !self.my_turn;
|
||||
a
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// pub mod iter;
|
||||
pub mod serialize;
|
||||
@@ -0,0 +1,50 @@
|
||||
use macaddr::MacAddr;
|
||||
use serde::{self, Deserialize, Deserializer, de::Error as DeError};
|
||||
use serde::{Serialize, Serializer};
|
||||
|
||||
pub fn serialize_macs<S>(macs: &[MacAddr], serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let strings: Vec<String> = macs.iter().map(|m| m.to_string()).collect();
|
||||
serde::Serialize::serialize(&strings, serializer)
|
||||
}
|
||||
|
||||
/// Serialize a MacAddr as a string
|
||||
pub fn serialize<S>(mac: &MacAddr, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(&mac.to_string())
|
||||
}
|
||||
|
||||
/// Deserialize a MacAddr from a string
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<MacAddr, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = <String as serde::Deserialize>::deserialize(deserializer)?;
|
||||
s.parse::<MacAddr>().map_err(DeError::custom)
|
||||
}
|
||||
|
||||
pub mod option_mac {
|
||||
use super::*;
|
||||
/// serialize an [`Option<MacAddr>`]
|
||||
pub fn serialize<S: Serializer>(bro: &Option<MacAddr>, ser: S) -> Result<S::Ok, S::Error> {
|
||||
Option::<String>::serialize(&bro.as_ref().map(ToString::to_string), ser)
|
||||
}
|
||||
/// deserialize an [`Option<MacAddr>`], returns None for invalid input (::, 0.0.0.0)
|
||||
pub fn deserialize<'de, D>(des: D) -> Result<Option<MacAddr>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let s: Option<&str> = Option::<&str>::deserialize(des)?;
|
||||
match s {
|
||||
Some(val) => match val.parse::<MacAddr>() {
|
||||
Ok(mac) => Ok(Some(mac)),
|
||||
Err(_) => Ok(None),
|
||||
},
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod mac;
|
||||
// pub mod vec;
|
||||
@@ -0,0 +1,37 @@
|
||||
use serde::Deserialize;
|
||||
use serde::de;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum OneOrMany<T> {
|
||||
One(T),
|
||||
Many(Vec<T>),
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D, T>(des: D) -> Result<Vec<T>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
T: std::str::FromStr,
|
||||
T::Err: std::fmt::Display,
|
||||
{
|
||||
let raw: OneOrMany<String> = OneOrMany::<String>::deserialize(des)?;
|
||||
let mut out = Vec::new();
|
||||
match raw {
|
||||
OneOrMany::One(s) => {
|
||||
let t = s.trim();
|
||||
if !t.is_empty() {
|
||||
out.push(t.parse().map_err(de::Error::custom)?);
|
||||
}
|
||||
}
|
||||
OneOrMany::Many(vs) => {
|
||||
for s in vs {
|
||||
let t = s.trim();
|
||||
if t.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(t.parse().map_err(de::Error::custom)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use lda_ipjs::subcommands::{address, neighbor};
|
||||
|
||||
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||
#[tokio::test] // ← Use tokio::test instead of manual #[tokio::main]
|
||||
async fn ball1() -> anyhow::Result<()> {
|
||||
let result = neighbor::get_with_backend(neighbor::Backend::Netlink, None, None, &[]).await?;
|
||||
println!("netlink results: {:?}", result);
|
||||
Ok(()) // ← Don't force error, let it succeed
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ball2() -> anyhow::Result<()> {
|
||||
let result = neighbor::get_with_backend(neighbor::Backend::Json, None, None, &[]).await?;
|
||||
println!("json results: {:?}", result);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Add this to debug the raw JSON
|
||||
#[tokio::test]
|
||||
async fn ball_raw_json() -> anyhow::Result<()> {
|
||||
let output = tokio::process::Command::new("ip")
|
||||
.args(["-j", "neigh", "show"])
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
let json = String::from_utf8_lossy(&output.stdout);
|
||||
println!("Raw JSON:\n{}", json);
|
||||
|
||||
// Try to parse it
|
||||
let parsed: Result<Vec<neighbor::NeighborItem>, _> = serde_json::from_slice(&output.stdout);
|
||||
match parsed {
|
||||
Ok(items) => println!("Parsed {} items", items.len()),
|
||||
Err(e) => println!("Parse error: {}", e),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Check what fields actually exist in the JSON
|
||||
#[tokio::test]
|
||||
async fn ball_field_analysis() -> anyhow::Result<()> {
|
||||
let output = tokio::process::Command::new("ip")
|
||||
.args(["-j", "neigh", "show"])
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
let raw: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout)?;
|
||||
|
||||
println!("Found {} neighbor entries", raw.len());
|
||||
|
||||
// Collect all unique field names across all entries
|
||||
let mut all_fields = std::collections::HashSet::new();
|
||||
for (i, entry) in raw.iter().enumerate() {
|
||||
if let Some(obj) = entry.as_object() {
|
||||
println!("\nEntry {}: {} fields", i, obj.len());
|
||||
for (key, value) in obj {
|
||||
all_fields.insert(key.clone());
|
||||
println!(" {}: {} = {:?}", key, value.type_name(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n=== All unique fields seen ===");
|
||||
for field in &all_fields {
|
||||
println!(" - {}", field);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Helper trait to get type name for JSON values
|
||||
trait TypeName {
|
||||
fn type_name(&self) -> &str;
|
||||
}
|
||||
|
||||
impl TypeName for serde_json::Value {
|
||||
fn type_name(&self) -> &str {
|
||||
match self {
|
||||
serde_json::Value::Null => "null",
|
||||
serde_json::Value::Bool(_) => "bool",
|
||||
serde_json::Value::Number(_) => "number",
|
||||
serde_json::Value::String(_) => "string",
|
||||
serde_json::Value::Array(_) => "array",
|
||||
serde_json::Value::Object(_) => "object",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test filtering logic
|
||||
#[tokio::test]
|
||||
async fn ball_compare_backends() -> anyhow::Result<()> {
|
||||
println!("=== JSON Backend ===");
|
||||
let json_result = neighbor::get_with_backend(neighbor::Backend::Json, None, None, &[]).await?;
|
||||
println!("Got {} entries from JSON", json_result.len());
|
||||
|
||||
#[cfg(all(unix, feature = "experimental-nl"))]
|
||||
{
|
||||
println!("\n=== Netlink Backend ===");
|
||||
let nl_result =
|
||||
neighbor::get_with_backend(neighbor::Backend::Netlink, None, None, &[]).await?;
|
||||
println!("Got {} entries from netlink", nl_result.len());
|
||||
|
||||
// Compare counts
|
||||
if json_result.len() != nl_result.len() {
|
||||
println!(
|
||||
"\n⚠️ Count mismatch! JSON: {}, Netlink: {}",
|
||||
json_result.len(),
|
||||
nl_result.len()
|
||||
);
|
||||
} else {
|
||||
println!("\n✅ Both backends returned same count");
|
||||
}
|
||||
let a: HashSet<neighbor::NeighborItem> = HashSet::from_iter(json_result);
|
||||
let b: HashSet<neighbor::NeighborItem> = HashSet::from_iter(nl_result);
|
||||
println!(
|
||||
"istg {len1} == {len2} or else",
|
||||
len1 = a.len(),
|
||||
len2 = b.len()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// #[tokio::test]
|
||||
// async fn cidr_filter() {
|
||||
// unimplemented!("never. i aint add what i dont need")
|
||||
// }
|
||||
|
||||
#[tokio::test]
|
||||
async fn ipjas() -> anyhow::Result<()> {
|
||||
let cuh = address::get_with_backend(address::Backend::Json, None).await?;
|
||||
println!("{cuh:#?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ipjas_raw() -> anyhow::Result<()> {
|
||||
let cuh = address::json::_get(None).await?;
|
||||
println!("{cuh:#?}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,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\"
|
||||
}
|
||||
+60
-7
@@ -4,13 +4,13 @@ This folder has small helpers for build, CI, and router install. Keep it simple;
|
||||
|
||||
## Quick start (recommended)
|
||||
|
||||
1. Start your Gitea runner on this Windows PC (if not already running):
|
||||
1. Start your Fedora/WSL Gitea runner (recommended release path):
|
||||
|
||||
```powershell
|
||||
./scripts/act_runner.ps1
|
||||
./scripts/act_runner_wsl.ps1 -Action start -Attach
|
||||
```
|
||||
|
||||
If first-time, it prints the exact register command. Run it once, check labels include `windows:host,self-hosted`, then rerun the script.
|
||||
If first-time, use the runbook in [`WSL_RUNNER.md`](./WSL_RUNNER.md) to update and register the Fedora runner first.
|
||||
|
||||
2. Publish, tag, and push (recommended):
|
||||
|
||||
@@ -18,7 +18,7 @@ This folder has small helpers for build, CI, and router install. Keep it simple;
|
||||
./scripts/publish.ps1 -Tag
|
||||
```
|
||||
|
||||
This will bump the version (if you specify one), build, ensure the runner is started, tag, and push. To also publish to the registry, add `-Publish`.
|
||||
This will bump the version (if you specify one), build locally, tag, push, and start the Fedora/WSL runner in `--once` mode by default. To also publish to the registry, add `-Publish`.
|
||||
|
||||
3. Install on the router
|
||||
|
||||
@@ -38,19 +38,72 @@ This folder has small helpers for build, CI, and router install. Keep it simple;
|
||||
## Scripts overview
|
||||
|
||||
- `act_runner.ps1` — Start/seed the local Gitea runner. Use `-Attach` to see logs, `-ForceConfigure` to register non-interactively.
|
||||
- `act_runner_wsl.ps1` — Windows PowerShell wrapper that controls the Fedora/WSL runner.
|
||||
- `act_runner_fedora.sh` — Linux/Fedora helper used inside WSL for update/register/start/stop/status.
|
||||
- `dev_push.ps1` — Fast dev loop: build + upload to router. Usage:
|
||||
- `./scripts/dev_push.ps1 -Pass <password> [-HostName <ip>] [-RemotePath </root/.bin/wakey>] [-Restart] [-Quiet]`
|
||||
- Uploads to `<RemotePath>.tmp` then atomically moves into place; `-Restart` restarts the service; `-Quiet` silences MOTD.
|
||||
- `package_rootfs.ps1` — Produces `dist/wakey-rootfs-<version>-<target>.tgz` with `/root/.bin/wakey` and `/etc/init.d/*`.
|
||||
- `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
|
||||
@@ -76,4 +129,4 @@ Or you can use the script:
|
||||
./scripts/dev_push.ps1 -Pass <password>
|
||||
```
|
||||
|
||||
That’s it. Keep the flow: start runner → tag push → install.
|
||||
That’s it. Keep the flow: start Fedora/WSL runner → tag push → install.
|
||||
@@ -0,0 +1,131 @@
|
||||
# Fedora / WSL Gitea Runner
|
||||
|
||||
This repo now expects Linux/router release builds to run on a Fedora 43 runner
|
||||
inside WSL, not on the Windows host runner.
|
||||
|
||||
The goal is simple:
|
||||
|
||||
- build `wakey` and `wakey-agent` in a Linux-native environment
|
||||
- stop fighting Windows-host ARM/musl/TLS toolchain issues
|
||||
- keep PowerShell on Windows as the operator front door
|
||||
|
||||
## Runner layout
|
||||
|
||||
Recommended WSL location:
|
||||
|
||||
```text
|
||||
/mnt/c/Users/Admin/Documents/realshit/wakey/ar_data/wsl_runner
|
||||
```
|
||||
|
||||
This matches the default used by `act_runner_wsl.ps1` and
|
||||
`act_runner_fedora.sh`, so WSL runner config is isolated at
|
||||
`ar_data/wsl_runner/config.yaml` in this repo.
|
||||
|
||||
Recommended labels:
|
||||
|
||||
```text
|
||||
self-hosted,linux,fedora,wsl,release:host
|
||||
```
|
||||
|
||||
The Windows runner can stay registered during migration as fallback.
|
||||
|
||||
## Fedora setup
|
||||
|
||||
Install the basics in Fedora 43:
|
||||
|
||||
```bash
|
||||
sudo dnf install -y git curl tar gzip findutils python3
|
||||
sudo dnf install -y powershell
|
||||
```
|
||||
|
||||
Install Rust and the router target:
|
||||
|
||||
```bash
|
||||
rustup target add armv7-unknown-linux-musleabihf
|
||||
```
|
||||
|
||||
Install the native cross toolchain pieces you need for Linux/ARM router builds.
|
||||
The exact package names can vary over time, so confirm the current Fedora names
|
||||
for:
|
||||
|
||||
- ARM Linux GCC
|
||||
- musl development/toolchain support
|
||||
- OpenSSL/ring/TLS build prerequisites if needed by your dependency graph
|
||||
|
||||
## Runner lifecycle
|
||||
|
||||
From Windows PowerShell, using the WSL wrapper:
|
||||
|
||||
```powershell
|
||||
./scripts/act_runner_wsl.ps1 -Action update
|
||||
./scripts/act_runner_wsl.ps1 -Action register -ServerUrl https://git.ldlda.com/ -Token <runner-token>
|
||||
./scripts/act_runner_wsl.ps1 -Action start -Attach
|
||||
./scripts/act_runner_wsl.ps1 -Action start -Once
|
||||
./scripts/act_runner_wsl.ps1 -Action status
|
||||
./scripts/act_runner_wsl.ps1 -Action stop
|
||||
```
|
||||
|
||||
Path control (binary and config are independent):
|
||||
|
||||
```powershell
|
||||
./scripts/act_runner_wsl.ps1 -Action status `
|
||||
-RunnerBin /home/lda/gitea-runner/act_runner `
|
||||
-ConfigPath /mnt/c/Users/Admin/Documents/realshit/wakey/ar_data/wsl_runner/config.yaml
|
||||
```
|
||||
|
||||
If your distro name is not `FedoraLinux-43`, set it explicitly:
|
||||
|
||||
```powershell
|
||||
./scripts/act_runner_wsl.ps1 -Distro FedoraLinux-43 -Action status
|
||||
```
|
||||
|
||||
Or set:
|
||||
|
||||
```powershell
|
||||
$env:WAKEY_WSL_DISTRO = "FedoraLinux-43"
|
||||
```
|
||||
|
||||
Inside Fedora directly, you can also use:
|
||||
|
||||
```bash
|
||||
./scripts/act_runner_fedora.sh update
|
||||
./scripts/act_runner_fedora.sh register --server-url https://git.ldlda.com/ --token <runner-token>
|
||||
./scripts/act_runner_fedora.sh start --attach
|
||||
./scripts/act_runner_fedora.sh start --once
|
||||
./scripts/act_runner_fedora.sh status
|
||||
./scripts/act_runner_fedora.sh stop
|
||||
```
|
||||
|
||||
## Release flow
|
||||
|
||||
The release workflow is expected to run on the Fedora/WSL runner labels:
|
||||
|
||||
```text
|
||||
self-hosted + linux + fedora + wsl + release
|
||||
```
|
||||
|
||||
It builds:
|
||||
|
||||
- `wakey`
|
||||
- `wakey-agent`
|
||||
|
||||
and packages both into the rootfs tarball.
|
||||
|
||||
From Windows, the intended operator flow remains:
|
||||
|
||||
```powershell
|
||||
./scripts/publish.ps1 -Tag
|
||||
```
|
||||
|
||||
That script should trigger the WSL runner in `--once` mode by default.
|
||||
|
||||
## Rollback
|
||||
|
||||
If the Fedora runner path is broken:
|
||||
|
||||
1. stop using the WSL runner labels in the workflow
|
||||
2. temporarily point the workflow back to the Windows runner labels
|
||||
3. use the existing Windows runner as fallback while fixing the Fedora path
|
||||
|
||||
Do not delete the Windows runner until the Fedora path has already produced at
|
||||
least one successful full release and one successful router install.
|
||||
Regular → Executable
+13
-8
@@ -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'
|
||||
@@ -44,17 +47,19 @@ if (-not (Test-Path $configFile)) {
|
||||
Pop-Location
|
||||
}
|
||||
else {
|
||||
Write-Host "Config not found. Run this manually once (note labels embed host executor on Windows):"
|
||||
Write-Host "Config not found. Run this manually once (Windows-host runner labels):"
|
||||
Write-Host "`t$RunnerPath register --no-interactive --config `"$configFile`" --instance `"$ServerUrl`" --token `"$Token`" --labels `"$Labels`""
|
||||
exit 2
|
||||
}
|
||||
}
|
||||
|
||||
if ($Attach) {
|
||||
Write-Host "Starting act_runner (attached)... Ctrl+C to stop"
|
||||
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
|
||||
& $RunnerPath daemon --config $configFile
|
||||
$daemonArgs = @('daemon', '--config', $configFile)
|
||||
if ($Once) { $daemonArgs += '--once' }
|
||||
& $RunnerPath @daemonArgs
|
||||
Pop-Location
|
||||
}
|
||||
else {
|
||||
|
||||
Executable
+212
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
RUNNER_BIN="${RUNNER_BIN:-/home/lda/gitea-runner/act_runner}"
|
||||
CONFIG_PATH="${CONFIG_PATH:-$REPO_ROOT/ar_data/wsl_runner/config.yaml}"
|
||||
RUNNER_HOME="${RUNNER_HOME:-$(dirname "$CONFIG_PATH")}"
|
||||
SERVER_URL="${SERVER_URL:-}"
|
||||
TOKEN="${TOKEN:-}"
|
||||
LABELS="${LABELS:-self-hosted,linux,fedora,wsl,release:host}"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
act_runner_fedora.sh update
|
||||
act_runner_fedora.sh register --server-url URL --token TOKEN [--labels LABELS]
|
||||
act_runner_fedora.sh start [--attach] [--once]
|
||||
act_runner_fedora.sh stop
|
||||
act_runner_fedora.sh status
|
||||
|
||||
Environment overrides:
|
||||
RUNNER_HOME, RUNNER_BIN, CONFIG_PATH, SERVER_URL, TOKEN, LABELS
|
||||
|
||||
Path overrides:
|
||||
--runner-home DIR
|
||||
--runner-bin PATH
|
||||
--config-path PATH
|
||||
EOF
|
||||
}
|
||||
|
||||
ensure_dirs() {
|
||||
mkdir -p "$RUNNER_HOME" "$(dirname "$RUNNER_BIN")"
|
||||
}
|
||||
|
||||
ensure_runner_bin() {
|
||||
if [[ ! -x "$RUNNER_BIN" ]]; then
|
||||
echo "Runner binary not found or not executable at $RUNNER_BIN" >&2
|
||||
exit 2
|
||||
fi
|
||||
}
|
||||
|
||||
runner_pids() {
|
||||
ps -eo pid=,args= | awk -v bin="$RUNNER_BIN" -v config="$CONFIG_PATH" '
|
||||
$2 == bin && $3 == "daemon" && $4 == "--config" && $5 == config { print $1 }
|
||||
'
|
||||
}
|
||||
|
||||
update_runner() {
|
||||
ensure_dirs
|
||||
local api="https://gitea.com/api/v1/repos/gitea/act_runner/releases/latest"
|
||||
local release
|
||||
release="$(curl -fsSL "$api")"
|
||||
local asset_url
|
||||
asset_url="$(
|
||||
python3 - "$(printf '%s' "$release")" <<'PY'
|
||||
import json, sys
|
||||
data = json.loads(sys.argv[1])
|
||||
for asset in data["assets"]:
|
||||
if asset["name"].endswith("linux-amd64"):
|
||||
print(asset["browser_download_url"])
|
||||
break
|
||||
else:
|
||||
raise SystemExit("no linux-amd64 act_runner asset found")
|
||||
PY
|
||||
)"
|
||||
|
||||
local tmp="$RUNNER_HOME/act_runner.tmp"
|
||||
curl -fsSL "$asset_url" -o "$tmp"
|
||||
chmod +x "$tmp"
|
||||
mv -f "$tmp" "$RUNNER_BIN"
|
||||
echo "Updated act_runner -> $RUNNER_BIN"
|
||||
}
|
||||
|
||||
register_runner() {
|
||||
ensure_dirs
|
||||
ensure_runner_bin
|
||||
|
||||
if [[ -z "$SERVER_URL" || -z "$TOKEN" ]]; then
|
||||
echo "register requires SERVER_URL and TOKEN (or --server-url/--token)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
"$RUNNER_BIN" register \
|
||||
--no-interactive \
|
||||
--config "$CONFIG_PATH" \
|
||||
--instance "$SERVER_URL" \
|
||||
--token "$TOKEN" \
|
||||
--labels "$LABELS"
|
||||
}
|
||||
|
||||
start_runner() {
|
||||
local mode="${1:-daemon}"
|
||||
ensure_dirs
|
||||
ensure_runner_bin
|
||||
|
||||
if [[ ! -f "$CONFIG_PATH" ]]; then
|
||||
echo "Runner config not found at $CONFIG_PATH" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
case "$mode" in
|
||||
attach)
|
||||
exec "$RUNNER_BIN" daemon --config "$CONFIG_PATH"
|
||||
;;
|
||||
once)
|
||||
exec "$RUNNER_BIN" daemon --config "$CONFIG_PATH" --once
|
||||
;;
|
||||
daemon)
|
||||
nohup "$RUNNER_BIN" daemon --config "$CONFIG_PATH" >/tmp/act_runner.log 2>&1 &
|
||||
echo "Started act_runner in background"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown start mode: $mode" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
stop_runner() {
|
||||
local pids
|
||||
pids="$(runner_pids)"
|
||||
if [[ -z "$pids" ]]; then
|
||||
return 0
|
||||
fi
|
||||
kill $pids
|
||||
}
|
||||
|
||||
status_runner() {
|
||||
ensure_runner_bin
|
||||
|
||||
if [[ -n "$(runner_pids)" ]]; then
|
||||
echo "act_runner is running"
|
||||
else
|
||||
echo "act_runner is off"
|
||||
fi
|
||||
}
|
||||
|
||||
ACTION="${1:-}"
|
||||
shift || true
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--server-url)
|
||||
SERVER_URL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--token)
|
||||
TOKEN="$2"
|
||||
shift 2
|
||||
;;
|
||||
--labels)
|
||||
LABELS="$2"
|
||||
shift 2
|
||||
;;
|
||||
--runner-home)
|
||||
RUNNER_HOME="$2"
|
||||
if [[ "${RUNNER_BIN:-}" == "/home/lda/gitea-runner/act_runner" ]]; then
|
||||
RUNNER_BIN="$RUNNER_HOME/act_runner"
|
||||
fi
|
||||
if [[ "${CONFIG_PATH:-}" == "$REPO_ROOT/ar_data/wsl_runner/config.yaml" ]]; then
|
||||
CONFIG_PATH="$RUNNER_HOME/config.yaml"
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
--runner-bin)
|
||||
RUNNER_BIN="$2"
|
||||
shift 2
|
||||
;;
|
||||
--config-path)
|
||||
CONFIG_PATH="$2"
|
||||
RUNNER_HOME="$(dirname "$CONFIG_PATH")"
|
||||
shift 2
|
||||
;;
|
||||
--attach)
|
||||
START_MODE="attach"
|
||||
shift
|
||||
;;
|
||||
--once)
|
||||
START_MODE="once"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$ACTION" in
|
||||
update)
|
||||
update_runner
|
||||
;;
|
||||
register)
|
||||
register_runner
|
||||
;;
|
||||
start)
|
||||
start_runner "${START_MODE:-daemon}"
|
||||
;;
|
||||
stop)
|
||||
stop_runner
|
||||
;;
|
||||
status)
|
||||
status_runner
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env pwsh
|
||||
param(
|
||||
[ValidateSet("update", "register", "start", "stop", "status")]
|
||||
[string]$Action = "start",
|
||||
[string]$Distro = $(if ($env:WAKEY_WSL_DISTRO) { $env:WAKEY_WSL_DISTRO } else { "FedoraLinux-43" }),
|
||||
[string]$RunnerHome,
|
||||
[string]$RunnerBin = "/home/lda/gitea-runner/act_runner",
|
||||
[string]$ConfigPath,
|
||||
[string]$ServerUrl,
|
||||
[string]$Token,
|
||||
[string]$Labels = "self-hosted,linux,fedora,wsl,release:host",
|
||||
[switch]$Attach,
|
||||
[switch]$Once
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
. "$PSScriptRoot/lib.ps1"
|
||||
|
||||
$scriptWin = Join-Path $PSScriptRoot "act_runner_fedora.sh"
|
||||
if (-not (Test-Path $scriptWin)) {
|
||||
throw "Missing Fedora runner helper: $scriptWin"
|
||||
}
|
||||
|
||||
$scriptLinux = (& wsl.exe -d $Distro -e wslpath -a "$scriptWin").Trim()
|
||||
if (-not $scriptLinux) {
|
||||
throw "Failed to resolve WSL path for $scriptWin"
|
||||
}
|
||||
|
||||
if (-not $RunnerHome) {
|
||||
$repoRootWin = Split-Path -Parent $PSScriptRoot
|
||||
$defaultRunnerHomeWin = Join-Path $repoRootWin "ar_data/wsl_runner"
|
||||
$RunnerHome = (& wsl.exe -d $Distro -e wslpath -a "$defaultRunnerHomeWin").Trim()
|
||||
if (-not $RunnerHome) {
|
||||
throw "Failed to resolve WSL runner home for $defaultRunnerHomeWin"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $ConfigPath) {
|
||||
$repoRootWin = Split-Path -Parent $PSScriptRoot
|
||||
$defaultConfigWin = Join-Path $repoRootWin "ar_data/wsl_runner/config.yaml"
|
||||
$ConfigPath = (& wsl.exe -d $Distro -e wslpath -a "$defaultConfigWin").Trim()
|
||||
if (-not $ConfigPath) {
|
||||
throw "Failed to resolve WSL config path for $defaultConfigWin"
|
||||
}
|
||||
}
|
||||
|
||||
$parts = @("bash", (Quote-ShArg $scriptLinux), (Quote-ShArg $Action), "--runner-home", (Quote-ShArg $RunnerHome))
|
||||
$parts += @("--runner-bin", (Quote-ShArg $RunnerBin), "--config-path", (Quote-ShArg $ConfigPath))
|
||||
if ($ServerUrl) { $parts += @("--server-url", (Quote-ShArg $ServerUrl)) }
|
||||
if ($Token) { $parts += @("--token", (Quote-ShArg $Token)) }
|
||||
if ($Labels) { $parts += @("--labels", (Quote-ShArg $Labels)) }
|
||||
if ($Attach) { $parts += "--attach" }
|
||||
if ($Once) { $parts += "--once" }
|
||||
|
||||
$cmd = $parts -join " "
|
||||
Write-Host $cmd
|
||||
& wsl.exe -d $Distro -e bash -lc $cmd
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "WSL runner action failed ($LASTEXITCODE)"
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Tag,
|
||||
[Parameter(Mandatory = $true)][string]$AssetPattern,
|
||||
[string]$ServerUrl,
|
||||
[string]$Token,
|
||||
[string]$Owner,
|
||||
[string]$Repo
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Resolve token
|
||||
if (-not $Token) { $Token = $env:GITEA_TOKEN }
|
||||
if (-not $Token) { throw 'Missing Gitea token. Set -Token or $env:GITEA_TOKEN' }
|
||||
|
||||
# Resolve repo owner/name
|
||||
if (-not $Owner -or -not $Repo) {
|
||||
if ($env:GITHUB_REPOSITORY) {
|
||||
$parts = $env:GITHUB_REPOSITORY -split '/'
|
||||
if (-not $Owner -and $parts.Length -ge 1) { $Owner = $parts[0] }
|
||||
if (-not $Repo -and $parts.Length -ge 2) { $Repo = $parts[1] }
|
||||
}
|
||||
else {
|
||||
throw 'Missing Owner/Repo and GITHUB_REPOSITORY not set'
|
||||
}
|
||||
}
|
||||
|
||||
# Resolve API base
|
||||
$api = $env:GITHUB_API_URL
|
||||
if (-not $api -and $env:GITHUB_SERVER_URL) { $api = "$($env:GITHUB_SERVER_URL)/api/v1" }
|
||||
if (-not $api -and $ServerUrl) { $api = "$ServerUrl/api/v1" }
|
||||
if (-not $api) { throw 'Could not determine Gitea API URL. Provide -ServerUrl or set GITHUB_SERVER_URL/GITHUB_API_URL' }
|
||||
|
||||
# Find asset
|
||||
$asset = Get-ChildItem -Path $AssetPattern -ErrorAction Stop | Select-Object -First 1
|
||||
if (-not $asset) { throw "No asset matches pattern: $AssetPattern" }
|
||||
|
||||
$headers = @{ Authorization = "token $Token" }
|
||||
$body = @{ tag_name = $Tag; name = $Tag; draft = $false; prerelease = $false } | ConvertTo-Json
|
||||
|
||||
Write-Host "Creating release $Tag for $Owner/$Repo"
|
||||
try {
|
||||
$release = Invoke-RestMethod -Headers $headers -Uri "$api/repos/$Owner/$Repo/releases" -Method Post -Body $body -ContentType 'application/json'
|
||||
}
|
||||
catch {
|
||||
# If already exists, fetch by tag
|
||||
Write-Host "Create failed; trying to fetch existing release for tag $Tag" -ForegroundColor Yellow
|
||||
$release = Invoke-RestMethod -Headers $headers -Uri "$api/repos/$Owner/$Repo/releases/tags/$Tag" -Method Get
|
||||
}
|
||||
|
||||
$rid = $release.id
|
||||
if (-not $rid) { throw "Failed to resolve release id: $($release | ConvertTo-Json -Depth 5)" }
|
||||
|
||||
$uploadUri = "$api/repos/$Owner/$Repo/releases/$rid/assets?name=$($asset.Name)"
|
||||
Write-Host "Uploading asset $($asset.Name)"
|
||||
Invoke-WebRequest -UseBasicParsing -Headers $headers -Uri $uploadUri -Method Post -InFile $asset.FullName -ContentType 'application/octet-stream' | Out-Null
|
||||
Write-Host "Release published: tag $Tag with asset $($asset.Name)"
|
||||
Regular → Executable
+56
-62
@@ -1,12 +1,22 @@
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage('PSAvoidDefaultValueSwitchParameter', 'Default true is intentional for fast dev loop')]
|
||||
#!/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
|
||||
)
|
||||
@@ -14,23 +24,14 @@ param(
|
||||
$ErrorActionPreference = 'Stop'
|
||||
try { $PSStyle.OutputRendering = 'Host' } catch {}
|
||||
|
||||
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] = '****'
|
||||
}
|
||||
}
|
||||
Write-Host ("[{0}] {1} {2}" -f $Label, $Exe, ($displayArgs -join ' ')) -ForegroundColor Cyan
|
||||
. "$PSScriptRoot/lib.ps1"
|
||||
|
||||
$out = & $Exe @Arguments 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error ("{0} failed ({1}):`n{2}" -f $Label, $LASTEXITCODE, ($out -join "`n"))
|
||||
throw ("{0} failed ({1})" -f $Label, $LASTEXITCODE)
|
||||
}
|
||||
return $out
|
||||
}
|
||||
$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)
|
||||
@@ -42,43 +43,7 @@ if [ ! -x "`$DEPLOY" ] && [ -f $DeployTmp ]; then
|
||||
DEPLOY=$DeployTmp
|
||||
fi
|
||||
sh "`$DEPLOY" $RemoteTmp $RemotePath $RestartFlag
|
||||
"@ # -replace "`r", "" -replace "`r`n", "`n"
|
||||
|
||||
}
|
||||
|
||||
function Invoke-Scp {
|
||||
param($Local, $Dest, $Pass, $HostKey, [switch]$Quiet)
|
||||
if ($pscp = Get-Command pscp.exe -ErrorAction SilentlyContinue) {
|
||||
$arguments = @('-scp')
|
||||
if ($Quiet) { $arguments += '-q' }
|
||||
if ($HostKey) { $arguments += @('-batch', '-hostkey', $HostKey) }
|
||||
if ($Pass) { $arguments += @('-pw', $Pass) }
|
||||
$arguments += @($Local, $Dest)
|
||||
Invoke-Ext -Exe $pscp.Path -Arguments $arguments -Label 'scp'
|
||||
}
|
||||
else {
|
||||
$arguments = @('-O')
|
||||
if ($Quiet) { $arguments += '-q' }
|
||||
$arguments += @($Local, $Dest)
|
||||
Invoke-Ext -Exe 'scp' -Arguments $arguments -Label 'scp'
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Ssh {
|
||||
param($Cmd, $User, $Remote, $Pass, [switch]$Quiet)
|
||||
$Cmd = $Cmd -replace "`r`n", "`n" -replace "`r", ""
|
||||
if ($plink = Get-Command plink.exe -ErrorAction SilentlyContinue) {
|
||||
$arguments = @('-batch', '-ssh')
|
||||
if ($Pass) { $arguments += @('-pw', $Pass) }
|
||||
$arguments += "$User@$Remote", $Cmd
|
||||
Invoke-Ext -Exe $plink.Path -Arguments $arguments -Label 'ssh'
|
||||
}
|
||||
else {
|
||||
$arguments = @()
|
||||
if ($Quiet) { $arguments += '-q' }
|
||||
$arguments += "$User@$Remote", $Cmd
|
||||
Invoke-Ext -Exe 'ssh' -Arguments $arguments -Label 'ssh'
|
||||
}
|
||||
"@
|
||||
}
|
||||
|
||||
#---- Main Flow ----
|
||||
@@ -86,34 +51,63 @@ $repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
Push-Location $repoRoot
|
||||
|
||||
try {
|
||||
Write-Host "[build] cargo build --release --target $Target" -ForegroundColor Cyan
|
||||
cargo build --release --target $Target
|
||||
if ($LASTEXITCODE -ne 0) { throw "cargo build failed ($LASTEXITCODE)" }
|
||||
Write-Host "[build] $Cargo build --release --target $Target -p wakey -p wakey-agent" -ForegroundColor Cyan
|
||||
. "$Cargo" build --release --target $Target -p wakey -p wakey-agent
|
||||
if ($LASTEXITCODE -ne 0) { throw "$Cargo build failed ($LASTEXITCODE)" }
|
||||
|
||||
$localBin = Join-Path $repoRoot "target/$Target/release/$BinName"
|
||||
$localAgentBin = Join-Path $repoRoot "target/$Target/release/$AgentBinName"
|
||||
if (-not (Test-Path $localBin)) { throw "binary not found: $localBin" }
|
||||
if (-not (Test-Path $localAgentBin)) { throw "binary not found: $localAgentBin" }
|
||||
|
||||
$remoteTmp = "$RemotePath.tmp"
|
||||
$agentRemoteTmp = "$AgentRemotePath.tmp"
|
||||
$destTmp = "$User@${HostName}:$remoteTmp"
|
||||
$agentDestTmp = "$User@${HostName}:$agentRemoteTmp"
|
||||
$initTmp = '/var/tmp/wakey.init.tmp'
|
||||
$initDestTmp = "$User@${HostName}:$initTmp"
|
||||
$localDeploy = Join-Path $repoRoot 'scripts/remote_deploy_wakey.sh'
|
||||
$localInit = Join-Path $repoRoot 'scripts/init/openwrt/wakey'
|
||||
$deployTmp = '/var/tmp/remote_deploy_wakey.sh'
|
||||
$deployPreferred = '/root/.bin/remote_deploy_wakey.sh'
|
||||
|
||||
# Push main binary
|
||||
Invoke-Scp -Local $localBin -Dest $destTmp -Pass $Pass -HostKey $HostKey -Quiet:$Quiet
|
||||
# Push binaries
|
||||
Invoke-Scp -Local $localBin -Dest $destTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||
Invoke-Scp -Local $localAgentBin -Dest $agentDestTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||
|
||||
# Push OpenWrt init script unless explicitly skipped
|
||||
if (-not $SkipInitScript -and (Test-Path $localInit)) {
|
||||
Invoke-Scp -Local $localInit -Dest $initDestTmp -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||
}
|
||||
|
||||
# Push deploy helper if exists
|
||||
if (Test-Path $localDeploy) {
|
||||
Invoke-Scp -Local $localDeploy -Dest "$User@${HostName}:$deployTmp" -Pass $Pass -HostKey $HostKey -Quiet:$Quiet
|
||||
Invoke-Scp -Local $localDeploy -Dest "$User@${HostName}:$deployTmp" -Pass $Pass -HostKey $HostKey -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||
}
|
||||
|
||||
# Build and run remote deploy command
|
||||
$restartFlag = $(if ($Restart) { '1' } else { '0' })
|
||||
$script = Get-DeployScript $deployPreferred $deployTmp $remoteTmp $RemotePath $restartFlag
|
||||
$script = @"
|
||||
$(Get-DeployScript $deployPreferred $deployTmp $remoteTmp $RemotePath 0)
|
||||
$(Get-DeployScript $deployPreferred $deployTmp $agentRemoteTmp $AgentRemotePath $restartFlag)
|
||||
$(if (-not $SkipInitScript) {
|
||||
@"
|
||||
if [ -f $initTmp ]; then
|
||||
sed -i "s/\r$//" $initTmp 2>/dev/null || true
|
||||
if command -v install >/dev/null 2>&1; then
|
||||
install -m 0755 $initTmp $RemoteInitPath
|
||||
else
|
||||
cp -f $initTmp $RemoteInitPath
|
||||
chmod 0755 $RemoteInitPath
|
||||
fi
|
||||
fi
|
||||
"@
|
||||
})
|
||||
"@
|
||||
$remoteCmd = "sh -lc '$($script -replace "`r",'')'"
|
||||
if ($Quiet) { $remoteCmd += " >/dev/null 2>&1" }
|
||||
|
||||
Invoke-Ssh -Cmd $remoteCmd -User $User -Remote $HostName -Pass $Pass -Quiet:$Quiet
|
||||
Invoke-Ssh -Cmd $remoteCmd -User $User -Remote $HostName -Pass $Pass -Port $Port -Quiet:$Quiet -ForcePassword:$ForcePassword
|
||||
|
||||
Write-Host "done ✔" -ForegroundColor Green
|
||||
}
|
||||
|
||||
@@ -9,23 +9,22 @@
|
||||
|
||||
START=50 # Run before Tailscale (typically START~80)
|
||||
|
||||
start() {
|
||||
LOGFILE=/var/log/update_tailscale.log
|
||||
MAX_RETRIES=10
|
||||
RETRY_COUNT=0
|
||||
|
||||
# shellcheck disable=SC2016
|
||||
sh -c '
|
||||
while [ "$RETRY_COUNT" -lt "$MAX_RETRIES" ]; do
|
||||
if fn; then
|
||||
echo "[update_tailscale] Success at $(date)" >>"$LOGFILE"
|
||||
exit 0
|
||||
fi
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
echo "[update_tailscale] Retry $RETRY_COUNT at $(date)" >>"$LOGFILE"
|
||||
sleep 5
|
||||
done
|
||||
echo "[update_tailscale] Too many retries. Exiting at $(date)" >>"$LOGFILE"
|
||||
exit 1
|
||||
' >>"$LOGFILE" 2>&1 &
|
||||
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
|
||||
}
|
||||
|
||||
start() {
|
||||
do_update >> /var/log/update_tailscale.log 2>&1 &
|
||||
}
|
||||
@@ -1,23 +1,24 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
START=66
|
||||
|
||||
start() {
|
||||
LOGFILE=/var/log/update_wakey.log
|
||||
# does this work now fam
|
||||
# exposal to wg is separate; so weon care
|
||||
do_update() {
|
||||
MAX_RETRIES=10
|
||||
RETRY_COUNT=0
|
||||
|
||||
# shellcheck disable=SC2016
|
||||
sh -c '
|
||||
while [ "$RETRY_COUNT" -lt "$MAX_RETRIES" ]; do
|
||||
if /etc/ldlda_help/update_wakey.sh; then
|
||||
echo "[update_wakey] Success at $(date)" >>"$LOGFILE"
|
||||
exit 0
|
||||
fi
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
echo "[update_wakey] Retry $RETRY_COUNT at $(date)" >>"$LOGFILE"
|
||||
sleep 5
|
||||
done
|
||||
echo "[update_wakey] Too many retries. Exiting at $(date)" >>"$LOGFILE"
|
||||
exit 1
|
||||
' >>"$LOGFILE" 2>&1 &
|
||||
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
|
||||
}
|
||||
|
||||
start() {
|
||||
do_update >>/var/log/update_wakey.log 2>&1 &
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Regular → Executable
+3
-3
@@ -6,7 +6,7 @@ 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
|
||||
@@ -15,10 +15,10 @@ fi
|
||||
kill -TERM "$pids" 2>/dev/null || true
|
||||
|
||||
# Optional: hard kill if still alive after a short grace
|
||||
usleep 250000 # uhhh sleep is stupid
|
||||
usleep 250000 # uhhh sleep is not found
|
||||
remain=""
|
||||
for p in $pids; do
|
||||
kill -0 "$p" 2>/dev/null && remain="$remain $p"
|
||||
kill -0 "$p" 2>/dev/null && remain="$remain $p"
|
||||
done
|
||||
[ -z "$remain" ] || kill -KILL "$remain" 2>/dev/null || true
|
||||
|
||||
|
||||
@@ -3,22 +3,22 @@
|
||||
set -e
|
||||
# 0th step from lda is to move the pre assed resolv.conf to the real resolv.conf
|
||||
if [ -f /tmp/resolv.conf ]; then
|
||||
cp -af /tmp/resolv.conf /etc/resolv.conf
|
||||
cp -af /tmp/resolv.conf /etc/resolv.conf
|
||||
elif [ -f /rom/etc/resolv.conf ]; then
|
||||
cp -af /rom/etc/resolv.conf /etc/resolv.conf
|
||||
cp -af /rom/etc/resolv.conf /etc/resolv.conf
|
||||
fi
|
||||
|
||||
if [ -x "$(which tailscale)" ]; then
|
||||
tailscale update
|
||||
exit 0
|
||||
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}"
|
||||
@@ -26,8 +26,8 @@ URL="https://pkgs.tailscale.com/stable/${LATEST}"
|
||||
cd /var/tmp
|
||||
|
||||
if [ -f "$LATEST" ]; then
|
||||
echo "file exists"
|
||||
rm "$LATEST"
|
||||
echo "file exists"
|
||||
rm "$LATEST"
|
||||
fi
|
||||
|
||||
echo "Downloading $LATEST..."
|
||||
|
||||
@@ -24,113 +24,113 @@ STAGING="$TMPDIR/wakey-rootfs.$$.$ARCH"
|
||||
|
||||
log() { echo "[update_wakey] $*"; }
|
||||
fail() {
|
||||
echo "[update_wakey] ERROR: $*" >&2
|
||||
exit 1
|
||||
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
|
||||
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() {
|
||||
[ -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
|
||||
[ -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"
|
||||
mkdir -p "$TMPDIR" "$STAGING"
|
||||
tar -xz -f "$TMPFILE" -C "$STAGING" || fail "extract failed"
|
||||
log "installing"
|
||||
mkdir -p "$TMPDIR" "$STAGING"
|
||||
tar -xz -f "$TMPFILE" -C "$STAGING" || fail "extract failed"
|
||||
|
||||
# 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
|
||||
# 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
|
||||
# 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"
|
||||
# Copy staged tree into /
|
||||
tar -C "$STAGING" -cf - . | tar -C / -xpf - || fail "install copy failed"
|
||||
|
||||
rm -f "$TMPFILE"
|
||||
rm -rf "$STAGING"
|
||||
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"
|
||||
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 "$@"
|
||||
Executable
+307
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
+186
-39
@@ -1,22 +1,25 @@
|
||||
"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" # change "assets" to your folder
|
||||
out_rs = root / "src" / "assets.rs"
|
||||
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
|
||||
out = []
|
||||
for c in name:
|
||||
if c.isalnum() or c == "_":
|
||||
out.append(c)
|
||||
else:
|
||||
out.append("_")
|
||||
s = "".join(out)
|
||||
s = "".join(c if c.isalnum() else "_" for c in name)
|
||||
if s and s[0].isdigit():
|
||||
s = "_" + s
|
||||
return s
|
||||
@@ -24,42 +27,186 @@ def sanitize(name: str) -> str:
|
||||
|
||||
def indent(text: str, n: int) -> str:
|
||||
pad = " " * n
|
||||
return "\n".join(pad + line if line.strip() else line for line in text.splitlines())
|
||||
return textwrap.indent(text, pad)
|
||||
|
||||
|
||||
class RsAssetFile:
|
||||
def __init__(self, path: Path):
|
||||
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
|
||||
|
||||
def __str__(self):
|
||||
const_name = sanitize(self.path.name).upper()
|
||||
return (
|
||||
f"pub const {const_name}: &str = "
|
||||
f'include_str!("{self.path.relative_to(out_rs.parent, walk_up=True).as_posix()}");'
|
||||
)
|
||||
# specify walk_up to have .. in yo path
|
||||
|
||||
asset_root = RsAssetRoot(static)
|
||||
|
||||
class RsAssetModule:
|
||||
def __init__(self, folder: Path, body_only: bool = False):
|
||||
self.folder = folder
|
||||
self.full = not body_only
|
||||
|
||||
def __str__(self):
|
||||
files = [str(RsAssetFile(s)) for s in self.folder.iterdir() if s.is_file()]
|
||||
subs = [str(RsAssetModule(s)) for s in self.folder.iterdir() if s.is_dir()]
|
||||
|
||||
body = "\n".join(files + subs)
|
||||
return (
|
||||
f"pub mod {sanitize(self.folder.name).lower()} {{" * self.full
|
||||
+ f"\n{indent(body, 4*self.full)}\n"
|
||||
+ self.full * "}"
|
||||
)
|
||||
# print(render_pain(asset_root))
|
||||
# print(render_macro(asset_root))
|
||||
|
||||
# rs_code = render_pain(asset_root)
|
||||
rs_code = render_macro(asset_root)
|
||||
|
||||
# generate
|
||||
rs_code = "// generated with ./scripts/map_static.py\n" + str(
|
||||
RsAssetModule(static, True)
|
||||
)
|
||||
out_rs.write_text(rs_code, encoding="utf-8")
|
||||
print(f"written to {out_rs}")
|
||||
print(f"written to {out_rs.relative_to(Path.cwd(), walk_up=True)}")
|
||||
Regular → Executable
+10
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Build a rootfs tarball suitable for `wget -O- ... | tar -xz -C /` on OpenWrt.
|
||||
# It lays out files as absolute-root paths inside the archive:
|
||||
# root/.bin/wakey
|
||||
# root/.bin/wakey-agent
|
||||
# etc/init.d/wakey
|
||||
# Usage: ./scripts/package_rootfs.ps1 -Version 0.1.0 -Target armv7-unknown-linux-musleabihf -OutDir dist
|
||||
param(
|
||||
@@ -17,7 +19,9 @@ $dist = Join-Path $root $OutDir
|
||||
New-Item -ItemType Directory -Force -Path $dist | Out-Null
|
||||
|
||||
$binName = if ($Target -like "*-windows-*") { "wakey.exe" } else { "wakey" }
|
||||
$agentBinName = if ($Target -like "*-windows-*") { "wakey-agent.exe" } else { "wakey-agent" }
|
||||
$binSrc = Join-Path $root "target/$Target/release/$binName"
|
||||
$agentBinSrc = Join-Path $root "target/$Target/release/$agentBinName"
|
||||
|
||||
$staging = Join-Path $dist ("rootfs-" + [System.Guid]::NewGuid().ToString("N"))
|
||||
New-Item -ItemType Directory -Force -Path $staging | Out-Null
|
||||
@@ -35,6 +39,12 @@ if (-not $NoBin) {
|
||||
else {
|
||||
throw "Missing binary: $binSrc (build it first or pass -NoBin)"
|
||||
}
|
||||
if (Test-Path $agentBinSrc) {
|
||||
Copy-Item $agentBinSrc (Join-Path $rootDir "wakey-agent") -Force
|
||||
}
|
||||
else {
|
||||
throw "Missing binary: $agentBinSrc (build it first or pass -NoBin)"
|
||||
}
|
||||
}
|
||||
# Normalize kill script line endings and copy
|
||||
$killSrc = Join-Path $root 'scripts/kill_wakey.sh'
|
||||
|
||||
Regular → Executable
+34
-12
@@ -1,3 +1,4 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Publish to registry and tag
|
||||
# Usage: ./scripts/publish.ps1 -Version 0.1.0 -Tag
|
||||
# Publish to registry and tag (manual; CI does not use this)
|
||||
@@ -10,12 +11,16 @@ param(
|
||||
[string]$Version,
|
||||
[switch]$Tag,
|
||||
[switch]$Publish,
|
||||
[string]$Registry
|
||||
[string]$Registry,
|
||||
[ValidateSet('wsl', 'windows', 'none')]
|
||||
[string]$RunnerMode = 'wsl',
|
||||
[string]$WslDistro = $(if ($env:WAKEY_WSL_DISTRO) { $env:WAKEY_WSL_DISTRO } else { 'FedoraLinux-43' })
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# 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*"([^"]+)"') {
|
||||
@@ -28,7 +33,8 @@ if (-not $Version) {
|
||||
}
|
||||
}
|
||||
|
||||
if ($Version) {
|
||||
# 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 -NoNewline
|
||||
@@ -54,17 +60,33 @@ if ($Publish) {
|
||||
}
|
||||
|
||||
if ($Tag -and $Version) {
|
||||
# If you git push a tag, this will trigger the act_runner and the release workflow on your self-hosted runner (see .gitea/workflows/release.yml)
|
||||
$actRunnerScript = Join-Path $PSScriptRoot 'act_runner.ps1'
|
||||
if (Test-Path $actRunnerScript) {
|
||||
Write-Host "Ensuring act_runner is running..."
|
||||
& $actRunnerScript
|
||||
}
|
||||
else {
|
||||
Write-Warning "act_runner.ps1 not found at $actRunnerScript. Skipping runner start."
|
||||
}
|
||||
git tag -f "v$Version"
|
||||
git push -f origin "v$Version"
|
||||
Write-Host "Pushed tag: v$Version"
|
||||
|
||||
Write-Host "Pushed tag: $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."
|
||||
}
|
||||
}
|
||||
}
|
||||
Regular → Executable
+14
-14
@@ -10,32 +10,32 @@ 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
|
||||
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
|
||||
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
|
||||
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
|
||||
if [ -x "$INIT" ]; then
|
||||
"$INIT" start || "$INIT" restart || true
|
||||
else
|
||||
nohup "$DEST" >/dev/null 2>&1 &
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+258
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
//! r#impl AHHHHHH
|
||||
|
||||
use serde::{Deserialize, Deserializer, de};
|
||||
|
||||
use super::NUDState;
|
||||
|
||||
// Case-insensitive parsing for NUDState via manual Deserialize
|
||||
impl<'de> Deserialize<'de> for NUDState {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s: &str = <&str as Deserialize>::deserialize(deserializer)?;
|
||||
s.parse().map_err(de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use crate::utils::parse::mac::{des_opm, ser_opm};
|
||||
@@ -1,245 +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 impls::ser_opm;
|
||||
use macaddr::MacAddr;
|
||||
use serde_with::skip_serializing_none;
|
||||
use strum::{Display, EnumString};
|
||||
|
||||
use crate::arpparse::error::IPNeighParseError;
|
||||
mod error;
|
||||
mod impls; // custom (de)serialization impls
|
||||
|
||||
/// ip neigh has some cool shit.
|
||||
///
|
||||
/// IP
|
||||
/// dev DEV | None
|
||||
/// lladdr MAC | None
|
||||
/// status { permanent | noarp | stale | reachable | none | incomplete | delay | probe | failed } (ip neigh help)
|
||||
///
|
||||
/// so you can see its damn good
|
||||
#[skip_serializing_none]
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Hash, serde::Serialize)]
|
||||
pub struct IpNeighLine {
|
||||
pub ip: IpAddr,
|
||||
pub dev: Option<String>,
|
||||
/// link layer address
|
||||
#[serde(serialize_with = "ser_opm")]
|
||||
pub mac: Option<MacAddr>,
|
||||
/// Neighbour Unreachability Detection
|
||||
pub state: NUDState,
|
||||
}
|
||||
|
||||
// NUDState custom Deserialize now lives in arpparse/impl.rs; use serde_with OneOrMany for Vec
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, EnumString, Display, Clone, Copy, Hash, serde::Serialize)]
|
||||
#[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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
// generated with ./scripts/map_static.py
|
||||
|
||||
pub const HOME_2_HTML: &str = include_str!("../static/home_2.html");
|
||||
pub mod home_2 {
|
||||
pub const DOM_JS: &str = include_str!("../static/home_2/dom.js");
|
||||
pub const LEASES_JS: &str = include_str!("../static/home_2/leases.js");
|
||||
pub const MAIN_JS: &str = include_str!("../static/home_2/main.js");
|
||||
pub const STATUS_JS: &str = include_str!("../static/home_2/status.js");
|
||||
pub const STYLES_CSS: &str = include_str!("../static/home_2/styles.css");
|
||||
pub const UTILS_JS: &str = include_str!("../static/home_2/utils.js");
|
||||
pub const WAKE_JS: &str = include_str!("../static/home_2/wake.js");
|
||||
}
|
||||
+345
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
-44
@@ -1,53 +1,16 @@
|
||||
//! braindead version v0.1.x
|
||||
//!
|
||||
//! # whats next
|
||||
//!
|
||||
//! for version 2 i hope to have:
|
||||
//!
|
||||
//! 1. idk reworked frontend;
|
||||
//! 2. incorporate ip -j;
|
||||
//! 3. small 1-5 second caching;
|
||||
mod cli;
|
||||
|
||||
use axum::{Router, routing::get};
|
||||
use tokio::net::TcpListener;
|
||||
mod arpparse;
|
||||
pub mod assets;
|
||||
mod dhcpparse;
|
||||
mod route;
|
||||
mod utils;
|
||||
use std::io;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::main]
|
||||
async fn entry() -> io::Result<()> {
|
||||
use crate::route::{api_router, home_2, home_2_route};
|
||||
|
||||
let app = Router::new()
|
||||
// .route("/home", get(home))
|
||||
.route("/", get(home_2))
|
||||
.merge(home_2_route())
|
||||
// .route("/status", get(get_status_2))
|
||||
.nest("/api", api_router());
|
||||
|
||||
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 crate::arpparse::NUDState;
|
||||
// println!("{}", NUDState::Reachable.to_string().to_lowercase());
|
||||
println!("{:?}", "svuhuvshdv:331".to_socket_addrs());
|
||||
// Err(Os { code: 11001, kind: Uncategorized, message: "No such host is known." })
|
||||
Err(color_eyre::eyre::eyre!(
|
||||
fn main() -> anyhow::Result<()> {
|
||||
Err(anyhow::anyhow!(
|
||||
"OS not supported! run this on your ahh router!"
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() -> color_eyre::Result<()> {
|
||||
color_eyre::install()?;
|
||||
Ok(entry()?)
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
cli::run(cli::Cli::parse()).await
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
use crate::route::error::ApiError;
|
||||
use crate::utils::query_parser::{QueryType, parse_query};
|
||||
use axum::Json;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{extract::Path, response::Redirect};
|
||||
|
||||
use crate::route::status::{DeviceQuery, Filters, NamePath};
|
||||
use crate::utils::query::get_ips;
|
||||
|
||||
// Smart redirect: accept IP, MAC, dev, or NUD state and redirect to /api/status accordingly
|
||||
pub async fn status_smart_redirect(
|
||||
Path(q): Path<String>,
|
||||
) -> axum::response::Result<Redirect, impl IntoResponse> {
|
||||
// no less bullshit
|
||||
let query = match parse_query(q) {
|
||||
QueryType::Ip(ip_addr) => DeviceQuery {
|
||||
filter: Filters {
|
||||
ips: vec![ip_addr],
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
QueryType::Mac(mac_addr) => DeviceQuery {
|
||||
filter: Filters {
|
||||
macs: vec![mac_addr],
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
QueryType::Dev(s) => DeviceQuery {
|
||||
filter: Filters {
|
||||
devs: vec![s],
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
QueryType::Nud(nudstate) => DeviceQuery {
|
||||
filter: Filters {
|
||||
nuds: vec![nudstate],
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
QueryType::Unknown(n) => DeviceQuery {
|
||||
name: Some(n),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
match serde_html_form::to_string(query) {
|
||||
Ok(e) => Ok(Redirect::to(&format!("/api/status?{e}"))),
|
||||
Err(e) => Err((
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(ApiError {
|
||||
error: e.to_string(),
|
||||
}),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn status_redirect(Path(NamePath { name }): Path<NamePath>) -> Redirect {
|
||||
Redirect::permanent(&format!(
|
||||
"/api/status?name={name}",
|
||||
name = urlencoding::encode(&name) // just for
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn ip(Path(name): Path<String>) -> impl IntoResponse {
|
||||
get_ips(&name).await.map_or_else(
|
||||
|e| {
|
||||
ApiError {
|
||||
error: e.to_string(),
|
||||
}
|
||||
.into_response()
|
||||
},
|
||||
|ips| Json(ips.collect::<Vec<_>>()).into_response(),
|
||||
)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
use crate::utils::query::dev;
|
||||
use axum::Json;
|
||||
|
||||
pub async fn devs_router() -> Json<Vec<String>> {
|
||||
dev::devs_sorted().into()
|
||||
}
|
||||
// Device listing endpoints
|
||||
@@ -1,37 +0,0 @@
|
||||
use crate::{
|
||||
dhcpparse::read_dhcp_leases_with_names,
|
||||
route::error::ApiError,
|
||||
utils::{parse::boolish_str, query::enrich_leases_with_nud_state},
|
||||
};
|
||||
use axum::{Json, extract::Query, http::StatusCode, response::IntoResponse};
|
||||
|
||||
// DHCP lease endpoints
|
||||
#[derive(Debug, Default, Clone, serde::Deserialize)]
|
||||
pub struct DhcpLeasesQueryRaw {
|
||||
include_state: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_dhcp_leases(Query(raw): Query<DhcpLeasesQueryRaw>) -> impl IntoResponse {
|
||||
let include_state = raw
|
||||
.include_state
|
||||
.as_deref()
|
||||
.map(boolish_str)
|
||||
.unwrap_or(false);
|
||||
|
||||
match read_dhcp_leases_with_names().await {
|
||||
Ok(leases_with_names) => {
|
||||
if !include_state {
|
||||
return (StatusCode::OK, Json(leases_with_names)).into_response();
|
||||
}
|
||||
let out = enrich_leases_with_nud_state(leases_with_names).await;
|
||||
(StatusCode::OK, Json(out)).into_response()
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(ApiError {
|
||||
error: e.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ApiError {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(self)).into_response()
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
pub mod api;
|
||||
pub mod devs;
|
||||
pub mod dhcp;
|
||||
pub mod error;
|
||||
pub mod status;
|
||||
pub mod wake;
|
||||
|
||||
use crate::assets;
|
||||
use crate::route::api::ip;
|
||||
use crate::route::api::status_redirect;
|
||||
use crate::route::api::status_smart_redirect;
|
||||
use crate::route::devs::devs_router;
|
||||
use crate::route::dhcp::get_dhcp_leases;
|
||||
use crate::route::status::get_status_json;
|
||||
use crate::route::wake::wake_multi;
|
||||
|
||||
use axum::routing::post;
|
||||
use axum::{Router, http::header, response::Html, routing::get};
|
||||
|
||||
use crate::utils::route::serve_js;
|
||||
|
||||
pub async fn home_2() -> Html<&'static str> {
|
||||
Html(assets::HOME_2_HTML)
|
||||
}
|
||||
pub fn home_2_route() -> Router {
|
||||
use assets::*;
|
||||
Router::new()
|
||||
.route("/home_2", get(|| async { Html(HOME_2_HTML) }))
|
||||
.route("/home_2/", get(|| async { Html(HOME_2_HTML) }))
|
||||
.route("/home_2.html", get(|| async { Html(HOME_2_HTML) }))
|
||||
.route(
|
||||
"/home_2/styles.css",
|
||||
get(|| async {
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "text/css; charset=utf-8"),
|
||||
(header::CACHE_CONTROL, "public, max-age=300"),
|
||||
],
|
||||
home_2::STYLES_CSS,
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route("/home_2/main.js", get(|| serve_js(home_2::MAIN_JS)))
|
||||
.route("/home_2/leases.js", get(|| serve_js(home_2::LEASES_JS)))
|
||||
.route("/home_2/status.js", get(|| serve_js(home_2::STATUS_JS)))
|
||||
.route("/home_2/utils.js", get(|| serve_js(home_2::UTILS_JS)))
|
||||
.route("/home_2/wake.js", get(|| serve_js(home_2::WAKE_JS)))
|
||||
.route("/home_2/dom.js", get(|| serve_js(home_2::DOM_JS)))
|
||||
}
|
||||
|
||||
pub fn api_router() -> Router {
|
||||
Router::new()
|
||||
.route("/status/{name}", get(status_redirect))
|
||||
.route("/status", get(get_status_json))
|
||||
.route("/dhcp_leases", get(get_dhcp_leases))
|
||||
.route("/smart/{q}", get(status_smart_redirect))
|
||||
.route("/devs", get(devs_router))
|
||||
.route("/wake", post(wake_multi))
|
||||
.route("/ips/{name}", get(ip))
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
use crate::route::error::ApiError;
|
||||
use axum::{Json, http::StatusCode, response::IntoResponse};
|
||||
use axum_extra::extract::Query;
|
||||
use macaddr::MacAddr;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::skip_serializing_none;
|
||||
use std::net::IpAddr;
|
||||
|
||||
use crate::arpparse::NUDState;
|
||||
use crate::utils::parse::de_many;
|
||||
use crate::utils::parse::serialize_macs;
|
||||
use crate::utils::query::get_macs;
|
||||
|
||||
#[derive(Debug, Default, Clone, Hash, Deserialize, Serialize)]
|
||||
pub struct DeviceQuery {
|
||||
pub name: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub filter: Filters,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Hash, Deserialize)]
|
||||
pub struct NamePath {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[skip_serializing_none]
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
pub struct Status<T> {
|
||||
pub name: Option<String>,
|
||||
pub table: Vec<T>,
|
||||
pub filters: Filters,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Hash, Serialize, Deserialize)]
|
||||
pub struct Filters {
|
||||
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
|
||||
pub ips: Vec<IpAddr>,
|
||||
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
|
||||
pub devs: Vec<String>,
|
||||
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
|
||||
pub nuds: Vec<NUDState>,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "de_many::vec_from_strs",
|
||||
serialize_with = "serialize_macs"
|
||||
)]
|
||||
pub macs: Vec<MacAddr>,
|
||||
}
|
||||
|
||||
#[deprecated = "use ApiError"]
|
||||
#[skip_serializing_none]
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
pub struct StatusError {
|
||||
pub name: Option<String>,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
pub async fn get_status_json(
|
||||
Query(DeviceQuery {
|
||||
name,
|
||||
filter:
|
||||
Filters {
|
||||
ips,
|
||||
devs,
|
||||
nuds,
|
||||
macs,
|
||||
},
|
||||
..
|
||||
}): Query<DeviceQuery>,
|
||||
) -> impl IntoResponse {
|
||||
match get_macs(
|
||||
&name.iter().collect::<Vec<_>>(),
|
||||
&ips,
|
||||
&devs.iter().collect::<Vec<_>>(),
|
||||
&nuds,
|
||||
&macs,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(table) => {
|
||||
let filters = Filters {
|
||||
ips,
|
||||
devs,
|
||||
nuds,
|
||||
macs,
|
||||
};
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(Status {
|
||||
name,
|
||||
table,
|
||||
filters,
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(error) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(ApiError {
|
||||
error: error.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
// Status endpoints
|
||||
@@ -1,112 +0,0 @@
|
||||
//! impls are at [`utils::wake::impl`](crate::utils::wake::r#impl) for some reason
|
||||
use std::io;
|
||||
use std::net::IpAddr;
|
||||
|
||||
/* use crate::arpparse::IpNeighLine;
|
||||
use crate::route::api::Status; */
|
||||
use crate::utils::parse::mac::{des_opm, ser_opm};
|
||||
use crate::utils::wake::wake_one;
|
||||
use axum::{extract::Json, http::StatusCode, response::IntoResponse};
|
||||
use futures::TryFutureExt;
|
||||
use macaddr::MacAddr;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::skip_serializing_none;
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
#[skip_serializing_none]
|
||||
#[derive(Debug, Default, Serialize, Clone)]
|
||||
pub struct WakeResult {
|
||||
pub success: bool,
|
||||
pub result: Option<Vec<WakeTargetResult>>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
#[skip_serializing_none]
|
||||
#[derive(Debug, Serialize, Clone, Copy)]
|
||||
pub struct WakeTargetResult {
|
||||
#[serde(flatten)]
|
||||
pub target: WakeTarget,
|
||||
pub status: WakeTargetStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone, Copy, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WakeTargetStatus {
|
||||
Succeed,
|
||||
/// not a real address...
|
||||
NonexistentAddress,
|
||||
WrongSize,
|
||||
/// input is not enough
|
||||
Incomplete,
|
||||
}
|
||||
|
||||
#[skip_serializing_none]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Copy)]
|
||||
pub struct WakeTarget {
|
||||
#[serde(default)]
|
||||
pub ip: Option<IpAddr>,
|
||||
#[serde(default, serialize_with = "ser_opm", deserialize_with = "des_opm")]
|
||||
pub mac: Option<MacAddr>,
|
||||
}
|
||||
|
||||
pub async fn wake_multi(Json(req): Json<Vec<WakeTarget>>) -> impl IntoResponse {
|
||||
match wake_multi_split(req).await {
|
||||
Ok(results) => (
|
||||
StatusCode::OK,
|
||||
Json(WakeResult {
|
||||
success: true,
|
||||
result: Some(results),
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
Err(error) => {
|
||||
let error = Some(format!("Error: {}", error));
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(WakeResult {
|
||||
success: false,
|
||||
error,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// this is so bad
|
||||
pub async fn wake_multi_split(
|
||||
targets: impl IntoIterator<Item = WakeTarget>,
|
||||
) -> io::Result<Vec<WakeTargetResult>> {
|
||||
let sock = UdpSocket::bind("[::]:0")
|
||||
.or_else(|_| UdpSocket::bind(":0"))
|
||||
.await?;
|
||||
sock.set_broadcast(true)?;
|
||||
|
||||
let iter = targets.into_iter().map(async |c| {
|
||||
if c.is_incomplete() {
|
||||
c.to_incomplete()
|
||||
} else {
|
||||
let t = c.try_into().expect("complete struct failed to try_into");
|
||||
wake_one(&sock, t).await.into()
|
||||
}
|
||||
});
|
||||
Ok(futures::future::join_all(iter).await)
|
||||
}
|
||||
/* #[derive(Debug, Serialize)]
|
||||
pub struct WakeStatusLine {
|
||||
#[serde(flatten)]
|
||||
pub status: IpNeighLine, // most powerful find of the century
|
||||
pub wake_status: WakeTargetStatus,
|
||||
}
|
||||
pub type WakeStatus = Status<WakeStatusLine>; */
|
||||
// /// return status BUT plus a indicator of i sent a wake.
|
||||
// pub async fn wake_status(
|
||||
// Query(DeviceQuery {
|
||||
// name,
|
||||
// ip,
|
||||
// dev,
|
||||
// nud,
|
||||
// mac,
|
||||
// ..
|
||||
// }): Query<DeviceQuery>,
|
||||
// ) -> impl IntoResponse {
|
||||
// }
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use anyhow::{Context, Result};
|
||||
use wakey_core::{DhcpLease, DhcpLeaseWithState, LeaseQuery};
|
||||
|
||||
/// Read DHCP leases and optionally enrich them with current neighbor-state data.
|
||||
pub async fn get_leases(query: LeaseQuery) -> Result<Vec<DhcpLeaseWithState>> {
|
||||
let leases = wakey_linux::dhcp::read_dhcp_leases_with_names()
|
||||
.await
|
||||
.context("failed to read DHCP leases")?;
|
||||
if query.include_state {
|
||||
Ok(wakey_linux::dhcp::enrich_leases_with_nud_state(leases).await)
|
||||
} else {
|
||||
Ok(leases_without_state(leases))
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap raw DHCP leases in the current service output shape without neighbor state.
|
||||
pub fn leases_without_state(leases: Vec<DhcpLease>) -> Vec<DhcpLeaseWithState> {
|
||||
leases
|
||||
.into_iter()
|
||||
.map(|lease_line| DhcpLeaseWithState {
|
||||
lease_line,
|
||||
nud_state: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,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,
|
||||
};
|
||||
@@ -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()
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+1
-10
@@ -5,19 +5,10 @@
|
||||
// [0xbc, 0x09, 0x1b, 0xec, 0x65, 0xd0],
|
||||
// ];
|
||||
|
||||
/// is it time to lookup host lda.lan for this...
|
||||
// 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;
|
||||
|
||||
pub mod cmd;
|
||||
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;
|
||||
pub mod query;
|
||||
pub mod query_parser;
|
||||
pub mod route;
|
||||
|
||||
// no custom ip deserializer needed when using axum_extra::extract::Query
|
||||
// but we add a generic one to ignore blanks and accept OneOrMany
|
||||
pub(crate) mod parse;
|
||||
@@ -1,190 +0,0 @@
|
||||
/// Parses Chrome-style numeric IPv4 forms: hex (0x...), decimal, octal.
|
||||
pub fn parse_numeric_ipv4(s: &str) -> Option<std::net::IpAddr> {
|
||||
let s = s.trim();
|
||||
// hex
|
||||
if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))
|
||||
&& hex.chars().all(|c| c.is_ascii_hexdigit())
|
||||
&& let Ok(n) = u32::from_str_radix(hex, 16)
|
||||
{
|
||||
return Some(std::net::IpAddr::V4(std::net::Ipv4Addr::from(n)));
|
||||
}
|
||||
// decimal
|
||||
if s.chars().all(|c| c.is_ascii_digit())
|
||||
&& let Ok(n) = s.parse::<u32>()
|
||||
{
|
||||
return Some(std::net::IpAddr::V4(std::net::Ipv4Addr::from(n)));
|
||||
}
|
||||
// octal (leading 0, all octal digits)
|
||||
if s.len() > 1
|
||||
&& s.as_bytes()[0] == b'0'
|
||||
&& s.chars().all(|c| matches!(c, '0'..='7'))
|
||||
&& let Ok(n) = u32::from_str_radix(s, 8)
|
||||
{
|
||||
return Some(std::net::IpAddr::V4(std::net::Ipv4Addr::from(n)));
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Extracts the host portion from a URL-like string, for smart input parsing.
|
||||
pub fn extract_host(input: &str) -> &str {
|
||||
let mut s = input.trim();
|
||||
// Strip scheme (e.g., http://, https://, ssh://) or network-path reference (//host)
|
||||
if let Some(idx) = s.find("://") {
|
||||
s = &s[idx + 3..];
|
||||
} else if let Some(rest) = s.strip_prefix("//") {
|
||||
s = rest;
|
||||
}
|
||||
// Strip potential userinfo (user@host)
|
||||
if let Some((_, host)) = s.rsplit_once('@') {
|
||||
s = host;
|
||||
}
|
||||
// If bracketed IPv6 like [::1]:8080/path -> extract inside brackets
|
||||
if let Some(host) = s.strip_prefix('[') {
|
||||
if let Some(end) = host.find(']') {
|
||||
s = &host[..end];
|
||||
}
|
||||
} else {
|
||||
// Trim path suffix if any
|
||||
if let Some(pos) = s.find('/') {
|
||||
s = &s[..pos];
|
||||
}
|
||||
// Drop trailing :port if present and numeric, but only if there's exactly one ':'
|
||||
if let Some((host, port)) = s.rsplit_once(':')
|
||||
&& s.matches(':').count() == 1
|
||||
&& port.chars().all(|c| c.is_ascii_digit())
|
||||
{
|
||||
s = host;
|
||||
}
|
||||
}
|
||||
s.trim()
|
||||
}
|
||||
|
||||
/// key for yes: "1" | "true" | "yes" | "on" | "y"
|
||||
///
|
||||
/// frfr
|
||||
pub fn _de_boolish<'de, D>(des: D) -> Result<bool, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::Deserialize;
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Boolish {
|
||||
B(bool),
|
||||
I(u8),
|
||||
S(String),
|
||||
}
|
||||
Ok(match Boolish::deserialize(des)? {
|
||||
Boolish::B(b) => b,
|
||||
Boolish::I(i) => i != 0,
|
||||
Boolish::S(s) => {
|
||||
let t = s.trim().to_ascii_lowercase();
|
||||
if t.is_empty() {
|
||||
true // presence implies true
|
||||
} else {
|
||||
matches!(t.as_str(), "1" | "true" | "yes" | "on" | "y")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a tolerant boolean value from a string.
|
||||
/// Accepts: "1", "true", "yes", "on", "y" as true; "0", "false", "no", "off", "n" as false.
|
||||
/// Empty string means true (presence-only query flag).
|
||||
pub fn boolish_str(s: &str) -> bool {
|
||||
let t = s.trim().to_ascii_lowercase();
|
||||
if t.is_empty() {
|
||||
return true;
|
||||
}
|
||||
matches!(t.as_str(), "1" | "true" | "yes" | "on" | "y")
|
||||
|| (!matches!(t.as_str(), "0" | "false" | "no" | "off" | "n")
|
||||
&& t.parse::<u64>().map(|n| n != 0).unwrap_or(false))
|
||||
}
|
||||
|
||||
pub mod 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)
|
||||
}
|
||||
}
|
||||
|
||||
pub mod mac {
|
||||
use macaddr::MacAddr;
|
||||
use serde::{self, Deserialize, Deserializer, de::Error as DeError};
|
||||
use serde::{Serialize, Serializer, de};
|
||||
|
||||
pub fn serialize_macs<S>(macs: &[MacAddr], serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let strings: Vec<String> = macs.iter().map(|m| m.to_string()).collect();
|
||||
serde::Serialize::serialize(&strings, serializer)
|
||||
}
|
||||
|
||||
/// Serialize a MacAddr as a string
|
||||
pub fn _serialize_mac<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_mac<'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)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
pub use mac::*;
|
||||
+4
-3
@@ -6,8 +6,9 @@ use tokio::{
|
||||
net::{TcpStream, ToSocketAddrs},
|
||||
time::timeout,
|
||||
};
|
||||
use wakey_core::NeighborState;
|
||||
|
||||
use crate::{arpparse::NUDState, utils::query::get_mac};
|
||||
use crate::utils::query::get_mac;
|
||||
|
||||
pub async fn _ping_ip<T: ToSocketAddrs>(addr: T) -> bool {
|
||||
timeout(Duration::from_secs(1), TcpStream::connect(addr))
|
||||
@@ -19,13 +20,13 @@ pub async fn _ping_ip_2<T: ToSocketAddrs>(_addr: T) -> bool {
|
||||
}
|
||||
|
||||
pub async fn _ping_ip_3<T: Into<IpAddr>>(addr: T) -> u8 {
|
||||
match get_mac(Some(addr.into()), None, &[] as &[NUDState]).await {
|
||||
match get_mac(Some(addr.into()), None, &[] as &[NeighborState]).await {
|
||||
Err(_) => 0,
|
||||
Ok(l) => l
|
||||
.into_iter()
|
||||
.map(|e| e.state)
|
||||
.max()
|
||||
.map(NUDState::rank)
|
||||
.map(NeighborState::rank)
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
pub mod dev {
|
||||
pub use wakey_linux::devices::devs_sorted;
|
||||
}
|
||||
pub mod leases {
|
||||
pub use wakey_linux::dhcp::enrich_leases_with_nud_state;
|
||||
}
|
||||
|
||||
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 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 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
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
use std::{collections::HashSet, fs};
|
||||
|
||||
pub fn get_dev() -> HashSet<String> {
|
||||
let mut devs: HashSet<String> = HashSet::new();
|
||||
if let Ok(rd) = std::fs::read_dir("/sys/class/net") {
|
||||
for e in rd.flatten() {
|
||||
if e.file_type()
|
||||
.map(|ft| {
|
||||
if ft.is_symlink() {
|
||||
// true
|
||||
fs::metadata(e.path()).map(|m| m.is_dir()).unwrap_or(false)
|
||||
} else {
|
||||
ft.is_dir()
|
||||
}
|
||||
})
|
||||
.unwrap_or(false)
|
||||
&& let Ok(name) = e.file_name().into_string()
|
||||
&& name != "lo"
|
||||
&& !name.is_empty()
|
||||
{
|
||||
devs.insert(name);
|
||||
}
|
||||
}
|
||||
} else if let Ok(txt) = std::fs::read_to_string("/proc/net/dev") {
|
||||
for line in txt.lines().skip(2) {
|
||||
if let Some((name, _rest)) = line.split_once(':') {
|
||||
let n = name.trim().to_string();
|
||||
if n != "lo" && !n.is_empty() {
|
||||
devs.insert(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
devs
|
||||
}
|
||||
|
||||
pub fn devs_sorted() -> Vec<String> {
|
||||
let mut v: Vec<String> = get_dev().into_iter().collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
pub fn has_dev(name: &str) -> bool {
|
||||
get_dev().contains(name)
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
use crate::arpparse::NUDState;
|
||||
use crate::dhcpparse::DhcpLeaseLine;
|
||||
use crate::utils::query::get_macs;
|
||||
use serde_with::skip_serializing_none;
|
||||
use std::net::IpAddr;
|
||||
|
||||
#[skip_serializing_none]
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct DhcpLeaseOut {
|
||||
#[serde(flatten)]
|
||||
pub lease_line: DhcpLeaseLine,
|
||||
pub nud_state: Option<NUDState>,
|
||||
pub rank: Option<u8>,
|
||||
}
|
||||
|
||||
/// Enrich DHCP leases with NUD state and rank using get_macs
|
||||
pub async fn enrich_leases_with_nud_state(leases: Vec<DhcpLeaseLine>) -> Vec<DhcpLeaseOut> {
|
||||
let ips: Vec<IpAddr> = leases.iter().map(|l| l.ip).collect();
|
||||
let mut map: std::collections::HashMap<IpAddr, (NUDState, u8)> =
|
||||
std::collections::HashMap::new();
|
||||
if let Ok(rows) = get_macs(&[] as &[&str], &ips, &[] as &[&str], &[], &[]).await {
|
||||
for row in rows {
|
||||
let state = row.state;
|
||||
let r = state.rank();
|
||||
map.entry(row.ip)
|
||||
.and_modify(|e| {
|
||||
if r > e.1 {
|
||||
*e = (state, r)
|
||||
}
|
||||
})
|
||||
.or_insert((state, r));
|
||||
}
|
||||
}
|
||||
leases
|
||||
.into_iter()
|
||||
.map(|lease_line| {
|
||||
let (nud_state, rank) = map.get(&lease_line.ip).copied().unzip();
|
||||
DhcpLeaseOut {
|
||||
lease_line,
|
||||
nud_state,
|
||||
rank,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
use macaddr::MacAddr;
|
||||
|
||||
use crate::arpparse::{self, IpNeighLine, NUDState};
|
||||
use crate::utils::{
|
||||
cmd::exec_command,
|
||||
error::{self, Result},
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use std::net::IpAddr;
|
||||
|
||||
pub async fn get_ips(machine_name: &str) -> Result<impl Iterator<Item = IpAddr>> {
|
||||
Ok(tokio::net::lookup_host((machine_name, 0))
|
||||
.await
|
||||
.map_err(|e| error::Error::DnsResolve {
|
||||
name: machine_name.to_string(),
|
||||
source: e,
|
||||
})?
|
||||
.map(|c| c.ip()))
|
||||
}
|
||||
|
||||
// #[deprecated(
|
||||
// since = "0.1.5",
|
||||
// note = "just call once everything with get mac
|
||||
// and then filter it bro WHY DO YOU EVEN DO TS"
|
||||
// )]
|
||||
// good now
|
||||
//
|
||||
// Current logic: When filtering by exactly 1 dev/mac, exclude entries missing that field.
|
||||
// This is because missing dev/mac usually means the entry is incomplete/transient.
|
||||
//
|
||||
// when there is only one MACs (getmac got some), the result will not have them fields.
|
||||
// so there are three cases:
|
||||
//
|
||||
// 1. dont got nothing: take all of them (macset.is_empty())
|
||||
// 2. exactly one: pre-filtered by ip, everything matches,
|
||||
// devset.len() != 1 returns false, but then it works????
|
||||
// OH THIS fuckass code i added it in the get_mac
|
||||
// 3. devset.len() > 1. if none then absolutely not match,
|
||||
// if some then check with the set; thats normal
|
||||
pub async fn get_macs(
|
||||
machine_names: &[impl AsRef<str>],
|
||||
ips: &[IpAddr],
|
||||
devs: &[impl AsRef<str>],
|
||||
state: &[NUDState],
|
||||
macs: &[MacAddr],
|
||||
) -> Result<Vec<IpNeighLine>> {
|
||||
let mut ip_set: HashSet<IpAddr> = ips.iter().map(|ip| ip.to_canonical()).collect();
|
||||
let ip_m: HashSet<IpAddr> = futures::future::try_join_all(
|
||||
machine_names
|
||||
.iter()
|
||||
.map(|c| async { get_ips(c.as_ref()).await }),
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
let ip_all = if ip_set.is_empty() && ip_m.is_empty() {
|
||||
None
|
||||
} else if ip_set.is_empty() {
|
||||
Some(ip_m)
|
||||
} else if ip_m.is_empty() {
|
||||
Some(ip_set)
|
||||
} else {
|
||||
Some({
|
||||
ip_set.retain(|c| ip_m.contains(c)); // inline AHHH
|
||||
ip_set
|
||||
})
|
||||
};
|
||||
|
||||
let opt_dev = if devs.len() > 1 {
|
||||
None
|
||||
} else {
|
||||
devs.iter().next().map(AsRef::as_ref)
|
||||
};
|
||||
|
||||
let run_one = |to_ip: Option<IpAddr>| get_mac(to_ip, opt_dev, state);
|
||||
|
||||
let mut ip_filtered = if let Some(something) = ip_all {
|
||||
if something.len() == 1 {
|
||||
run_one(something.into_iter().next()).await?
|
||||
} else {
|
||||
run_one(None)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|c| something.contains(&c.ip))
|
||||
.collect()
|
||||
}
|
||||
} else {
|
||||
run_one(None).await?
|
||||
};
|
||||
|
||||
// Apply additional filters if any were provided
|
||||
if !devs.is_empty() || !macs.is_empty() || !state.is_empty() {
|
||||
let devset: HashSet<_> = devs.iter().map(AsRef::as_ref).collect();
|
||||
let macset: HashSet<_> = macs.iter().collect();
|
||||
|
||||
ip_filtered.retain(|entry| {
|
||||
// Dev filter: if we're filtering by dev, entry must have a dev AND it must be in the set
|
||||
let dev_ok =
|
||||
devset.is_empty() || entry.dev.as_deref().is_some_and(|d| devset.contains(d));
|
||||
|
||||
// MAC filter: if we're filtering by MAC, entry must have a MAC AND it must be in the set
|
||||
let mac_ok = macset.is_empty() || entry.mac.is_some_and(|m| macset.contains(&m));
|
||||
|
||||
dev_ok && mac_ok
|
||||
})
|
||||
};
|
||||
Ok(ip_filtered)
|
||||
}
|
||||
|
||||
/// the atomic get_macs. handle ONE thing only.
|
||||
pub async fn get_mac(
|
||||
ip: Option<IpAddr>,
|
||||
dev: Option<&str>,
|
||||
state: &[NUDState],
|
||||
) -> Result<Vec<IpNeighLine>> {
|
||||
let mut args: Vec<String> = vec!["neigh".into(), "show".into()];
|
||||
if let Some(ip) = ip {
|
||||
args.push("to".into());
|
||||
args.push(ip.to_string());
|
||||
}
|
||||
if let Some(d) = dev {
|
||||
args.push("dev".into());
|
||||
args.push(d.to_string());
|
||||
}
|
||||
for nud in state {
|
||||
args.push("nud".into());
|
||||
args.push(nud.as_ip_neigh_arg().into());
|
||||
}
|
||||
let cmd = "ip";
|
||||
let out = exec_command(cmd, args.iter().map(String::as_str).collect::<Vec<_>>()).await?;
|
||||
if !out.status.success() {
|
||||
return Err(error::Error::CommandFailed {
|
||||
cmd,
|
||||
args,
|
||||
status: out.status.code(),
|
||||
stderr: String::from_utf8_lossy(&out.stderr).into(),
|
||||
});
|
||||
}
|
||||
let lines = String::from_utf8_lossy(&out.stdout);
|
||||
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(rows)
|
||||
}
|
||||
|
||||
/*
|
||||
/// get macs where you just run ip neigh then rust handles the filtering (faster than get mac)
|
||||
pub async fn get_macs_rust(
|
||||
machine_names: Option<&str>,
|
||||
ips: Option<&[IpAddr]>,
|
||||
devs: Option<&[&str]>,
|
||||
states: Option<&[NUDState]>,
|
||||
) -> Result<Vec<IpNeighLine>> {
|
||||
let mut ip_map: HashSet<IpAddr> = HashSet::new();
|
||||
let mut machine_map = HashSet::new();
|
||||
if let Some(ips) = ips {
|
||||
ip_map.extend(ips);
|
||||
}
|
||||
if let Some(m) = machine_names {
|
||||
machine_map.extend(get_ips(m).await.into_iter().flatten());
|
||||
}
|
||||
|
||||
let real = match (ip_map.is_empty(), machine_map.is_empty()) {
|
||||
(true, true) => HashSet::new(),
|
||||
(true, false) => machine_map,
|
||||
(false, true) => ip_map,
|
||||
(false, false) => ip_map.intersection(&machine_map).copied().collect(),
|
||||
};
|
||||
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
pub async fn _get_machines(m: &[&str]) -> Vec<IpAddr> {
|
||||
let futs = m.iter().map(|c| get_ips(c));
|
||||
futures::future::join_all(futs)
|
||||
.await
|
||||
.into_iter()
|
||||
.flat_map(|f| f.into_iter().flatten())
|
||||
.collect()
|
||||
} */
|
||||
@@ -1,6 +0,0 @@
|
||||
pub mod dev;
|
||||
pub mod leases;
|
||||
pub mod macs;
|
||||
|
||||
pub use leases::*;
|
||||
pub use macs::*;
|
||||
@@ -1,44 +0,0 @@
|
||||
use std::net::IpAddr;
|
||||
|
||||
use macaddr::MacAddr;
|
||||
|
||||
use crate::{arpparse::NUDState, utils::query::dev::has_dev};
|
||||
|
||||
pub enum QueryType {
|
||||
Ip(IpAddr),
|
||||
Mac(MacAddr),
|
||||
Dev(String),
|
||||
Nud(NUDState),
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
pub fn parse_query(q: String) -> QueryType {
|
||||
let s = if cfg!(feature = "very-smart-parsing") {
|
||||
crate::utils::parse::extract_host(&q)
|
||||
} else {
|
||||
q.trim()
|
||||
};
|
||||
// 1) IP
|
||||
let ip = if cfg!(feature = "very-smart-parsing") {
|
||||
crate::utils::parse::parse_numeric_ipv4(s).or_else(|| s.parse::<IpAddr>().ok())
|
||||
} else {
|
||||
s.parse::<IpAddr>().ok()
|
||||
};
|
||||
if let Some(ip) = ip {
|
||||
return QueryType::Ip(ip);
|
||||
}
|
||||
// 2) MAC
|
||||
if let Ok(mac) = s.parse::<MacAddr>() {
|
||||
return QueryType::Mac(mac);
|
||||
}
|
||||
// 3) NUD state (reachable, stale, ...)
|
||||
if let Ok(state) = s.parse::<NUDState>() {
|
||||
return QueryType::Nud(state);
|
||||
}
|
||||
// 4) Known device? prefer dev first
|
||||
if has_dev(s) {
|
||||
return QueryType::Dev(s.to_string());
|
||||
}
|
||||
// Default: name last // it will fail also
|
||||
QueryType::Unknown(s.to_string())
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
use axum::{http::header, response::IntoResponse};
|
||||
|
||||
pub async fn serve_js(content: &'static str) -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/javascript"),
|
||||
(header::CACHE_CONTROL, "public, max-age=300"),
|
||||
],
|
||||
content,
|
||||
)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
use super::{WakeStatus, WakeTarget, WakeTargetResult};
|
||||
use crate::route::wake::{
|
||||
WakeTarget as RouteWakeTarget, WakeTargetResult as RouteWakeResult,
|
||||
WakeTargetStatus as RouteWakeStatus,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Incomplete;
|
||||
|
||||
impl TryFrom<RouteWakeTarget> for WakeTarget {
|
||||
type Error = Incomplete;
|
||||
fn try_from(value: RouteWakeTarget) -> Result<Self, Self::Error> {
|
||||
if let RouteWakeTarget {
|
||||
ip: Some(ip),
|
||||
mac: Some(mac),
|
||||
} = value
|
||||
{
|
||||
Ok(Self { ip, mac })
|
||||
} else {
|
||||
Err(Incomplete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WakeTarget> for RouteWakeTarget {
|
||||
fn from(WakeTarget { ip, mac }: WakeTarget) -> Self {
|
||||
Self {
|
||||
ip: Some(ip),
|
||||
mac: Some(mac),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WakeTargetResult> for RouteWakeResult {
|
||||
fn from(WakeTargetResult { target, status }: WakeTargetResult) -> Self {
|
||||
Self {
|
||||
target: target.into(),
|
||||
status: status.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RouteWakeTarget {
|
||||
pub fn to_incomplete(self) -> RouteWakeResult {
|
||||
RouteWakeResult {
|
||||
target: self,
|
||||
status: RouteWakeStatus::Incomplete,
|
||||
}
|
||||
}
|
||||
pub fn is_incomplete(&self) -> bool {
|
||||
!matches!(
|
||||
self,
|
||||
Self {
|
||||
ip: Some(_),
|
||||
mac: Some(_)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WakeStatus> for RouteWakeStatus {
|
||||
fn from(value: WakeStatus) -> Self {
|
||||
match value {
|
||||
WakeStatus::NonexistentAddress => Self::NonexistentAddress,
|
||||
WakeStatus::Success => Self::Succeed,
|
||||
WakeStatus::WrongSize => Self::WrongSize,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
pub mod impls;
|
||||
use std::{io, net::IpAddr};
|
||||
|
||||
use futures::TryFutureExt;
|
||||
use macaddr::MacAddr;
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Hash)]
|
||||
pub struct WakeTarget {
|
||||
pub ip: IpAddr,
|
||||
pub mac: MacAddr,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, Hash)]
|
||||
pub struct WakeTargetResult {
|
||||
pub target: WakeTarget,
|
||||
pub status: WakeStatus,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, Hash)]
|
||||
pub enum WakeStatus {
|
||||
Success,
|
||||
NonexistentAddress,
|
||||
WrongSize,
|
||||
}
|
||||
impl WakeTarget {
|
||||
fn _new(ip: IpAddr, mac: MacAddr) -> Self {
|
||||
Self { ip, mac }
|
||||
}
|
||||
fn good(self) -> WakeTargetResult {
|
||||
WakeTargetResult::new(self, WakeStatus::Success)
|
||||
}
|
||||
fn bad(self) -> WakeTargetResult {
|
||||
WakeTargetResult::new(self, WakeStatus::WrongSize)
|
||||
}
|
||||
fn errored(self) -> WakeTargetResult {
|
||||
WakeTargetResult::new(self, WakeStatus::NonexistentAddress)
|
||||
}
|
||||
}
|
||||
impl WakeTargetResult {
|
||||
fn new(target: WakeTarget, status: WakeStatus) -> Self {
|
||||
Self { target, status }
|
||||
}
|
||||
}
|
||||
|
||||
// its time. we have the ip; the macs. we dont need to send to the uh the broadcast anymore???
|
||||
pub async fn _wake_multi(
|
||||
targets: impl IntoIterator<Item = WakeTarget>,
|
||||
) -> io::Result<Vec<WakeTargetResult>> {
|
||||
let sock = UdpSocket::bind("[::]:0")
|
||||
.or_else(|_| UdpSocket::bind(":0"))
|
||||
.await?;
|
||||
sock.set_broadcast(true)?;
|
||||
let fs = targets.into_iter().map(|t| wake_one(&sock, t));
|
||||
Ok(futures::future::join_all(fs).await)
|
||||
}
|
||||
|
||||
pub async fn wake_one(sock: &UdpSocket, t: WakeTarget) -> WakeTargetResult {
|
||||
let mac = t.mac;
|
||||
let mb = mac.as_bytes();
|
||||
let mut pac = [0; 6 + 6 * 16];
|
||||
pac[..6].fill(0xff);
|
||||
for i in 1..=16 {
|
||||
pac[i * 6..(i + 1) * 6].copy_from_slice(mb);
|
||||
}
|
||||
let ip = t.ip;
|
||||
let port = 9;
|
||||
match sock.send_to(&pac, (ip, port)).await {
|
||||
Ok(n) if n == pac.len() => t.good(),
|
||||
Ok(_) => t.bad(),
|
||||
Err(_) => t.errored(),
|
||||
}
|
||||
}
|
||||
|
||||
// pub async fn wake_query();
|
||||
@@ -1,50 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>wakey • home</title>
|
||||
<link rel="stylesheet" href="home_2/styles.css" />
|
||||
<script type="module" src="home_2/main.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<h1>home <span class="muted">ping your device with a WoL</span></h1>
|
||||
<span id="status-pill" class="pill warn">unknown</span>
|
||||
</header>
|
||||
|
||||
<div class="row">
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="target (name, ip, mac...)"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<button id="check" class="secondary">Check</button>
|
||||
<button id="wake">Wake</button>
|
||||
</div>
|
||||
<div class="row muted" style="gap: 16px">
|
||||
<span
|
||||
>Uses query
|
||||
<span title="available keys: name, ips, macs, devs, nuds"
|
||||
>(?name=...)</span
|
||||
>
|
||||
on this page to view the status.<!-- and header (X-Target-Name) so either extractor
|
||||
path works. --></span
|
||||
><span id="preview" class="tiny"></span
|
||||
><a id="permalink" href="#">permalink</a>
|
||||
</div>
|
||||
|
||||
<section id="out" class="card">
|
||||
<pre id="log">ready.</pre>
|
||||
<div id="html"></div>
|
||||
</section>
|
||||
|
||||
<section id="leases" class="card">
|
||||
<h3 style="margin-top: 0">DHCP leases</h3>
|
||||
<div id="leases_html"></div>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,36 +0,0 @@
|
||||
import { filter_array } from "./status.js";
|
||||
|
||||
export const qs = new URLSearchParams(location.search);
|
||||
|
||||
// $ is normally queryselector are we fr
|
||||
export const $ = (id) => document.getElementById(id);
|
||||
export const elName = $("name");
|
||||
export const elCheck = $("check");
|
||||
export const elWake = $("wake");
|
||||
export const elLog = $("log");
|
||||
export const elHtml = $("html");
|
||||
export const elLeases = $("leases_html");
|
||||
export const pill = $("status-pill");
|
||||
export const link = $("permalink");
|
||||
export const elPreview = $("preview");
|
||||
|
||||
export function setPill(kind, text) {
|
||||
pill.className = `pill ${kind}`;
|
||||
pill.textContent = text;
|
||||
}
|
||||
|
||||
export function setLink(name, clear) {
|
||||
const url = new URL(location.href);
|
||||
if (clear) url.searchParams.forEach((_, k) => url.searchParams.delete(k)); // FUCK
|
||||
/* const hasExtraFilters = filter_array.some(
|
||||
(k) => url.searchParams.getAll(k).length
|
||||
);
|
||||
if (hasExtraFilters) {
|
||||
// jus returns whatever; they are specifying further
|
||||
} else */ {
|
||||
if (name) url.searchParams.set("name", name);
|
||||
else url.searchParams.delete("name");
|
||||
}
|
||||
history.replaceState(null, "", url);
|
||||
link.href = url.toString();
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { elLeases } from "./dom.js";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {{
|
||||
* expires_epoch: Number
|
||||
* rank?: Number
|
||||
* ip: String
|
||||
* mac: String
|
||||
* nud_state?: String
|
||||
* name?: String
|
||||
* }[]} leases
|
||||
* @returns
|
||||
*/
|
||||
export function renderLeases(leases) {
|
||||
if (!elLeases) return;
|
||||
if (!Array.isArray(leases) || leases.length === 0) {
|
||||
elLeases.textContent = "no leases";
|
||||
return;
|
||||
}
|
||||
const tbl = document.createElement("table");
|
||||
tbl.className = "table";
|
||||
|
||||
(
|
||||
tbl.tHead || tbl.createTHead()
|
||||
).innerHTML = `<tr><th></th><th>IP</th><th>MAC</th><th>Name</th><th>Expires</th></tr>`;
|
||||
|
||||
const tbd = tbl.tBodies.item(0) || tbl.createTBody();
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
for (const l of leases) {
|
||||
const tr = document.createElement("tr");
|
||||
const exp = Number(l.expires_epoch || 0);
|
||||
const expired = exp > 0 && exp <= nowSec;
|
||||
const when = exp > 0 ? new Date(exp * 1000) : null;
|
||||
const whenText = when ? when.toLocaleString() : "";
|
||||
let dotClass = "dot ok";
|
||||
if (expired) {
|
||||
dotClass = "dot bad";
|
||||
} /* rank from NUDState */ else if (typeof l?.rank === "number") {
|
||||
if (l.rank >= 5) dotClass = "dot ok";
|
||||
else if (l.rank >= 2) dotClass = "dot warn";
|
||||
else dotClass = "dot bad";
|
||||
}
|
||||
const title = expired
|
||||
? "expired"
|
||||
: l?.nud_state
|
||||
? String(l.nud_state).toLowerCase()
|
||||
: "unknown";
|
||||
const ip = l.ip || "";
|
||||
const mac = l.mac || "";
|
||||
const name = l.name || "";
|
||||
tr.innerHTML = `
|
||||
<td><span class="${dotClass}" title="${title}"></span></td>
|
||||
${Object.entries({ ip, mac, name })
|
||||
.map(
|
||||
([name, value]) =>
|
||||
`<td>${
|
||||
value &&
|
||||
`<a href="#" class="pick" data-value="${value}" title="filter by ${name}">${value}</a>`
|
||||
}</td>`
|
||||
)
|
||||
.join("\n")}
|
||||
<td><span class="tiny">${whenText}</span></td>`;
|
||||
tbd.appendChild(tr);
|
||||
}
|
||||
elLeases.innerHTML = "";
|
||||
elLeases.appendChild(tbl);
|
||||
}
|
||||
|
||||
export async function fetchLeases() {
|
||||
try {
|
||||
const r = await fetch("/api/dhcp_leases?include_state=1");
|
||||
if (!r.ok) return;
|
||||
const leases = await r.json();
|
||||
renderLeases(leases);
|
||||
} catch {}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import {
|
||||
elName,
|
||||
elCheck,
|
||||
elWake,
|
||||
elHtml,
|
||||
elLeases,
|
||||
elPreview,
|
||||
setLink,
|
||||
setPill,
|
||||
qs,
|
||||
} from "./dom.js";
|
||||
import {
|
||||
getName,
|
||||
saveName,
|
||||
loadName,
|
||||
extractHostLikeBackend,
|
||||
} from "./utils.js";
|
||||
import { fetchStatus } from "./status.js";
|
||||
import { fetchLeases } from "./leases.js";
|
||||
import { sendWake } from "./wake.js";
|
||||
|
||||
// delegated click handler
|
||||
function handlePickClick(e) {
|
||||
const a = e.target && e.target.closest("a.pick");
|
||||
if (!a) return;
|
||||
e.preventDefault();
|
||||
const v = a.getAttribute("data-value");
|
||||
pickTarget(v);
|
||||
}
|
||||
if (elHtml) elHtml.addEventListener("click", handlePickClick);
|
||||
if (elLeases) elLeases.addEventListener("click", handlePickClick);
|
||||
|
||||
function updatePreview() {
|
||||
const raw = getName(elName);
|
||||
const host = extractHostLikeBackend(raw);
|
||||
elPreview.textContent = host && host !== raw ? `→ ${host}` : "";
|
||||
}
|
||||
|
||||
function pickTarget(value) {
|
||||
const v = String(value || "").trim();
|
||||
if (!v) return;
|
||||
elName.value = v;
|
||||
updatePreview();
|
||||
saveName(v);
|
||||
setLink(v, true);
|
||||
fetchStatus(v);
|
||||
fetchLeases();
|
||||
}
|
||||
|
||||
// events
|
||||
elCheck.addEventListener("click", () => {
|
||||
const name = getName(elName);
|
||||
if (!name) return;
|
||||
saveName(name);
|
||||
setLink(name);
|
||||
fetchStatus(name);
|
||||
fetchLeases();
|
||||
});
|
||||
elWake.addEventListener("click", () => {
|
||||
const name = getName(elName);
|
||||
if (!name) return;
|
||||
saveName(name);
|
||||
setLink(name);
|
||||
sendWake(name);
|
||||
fetchLeases();
|
||||
});
|
||||
elName.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") elCheck.click();
|
||||
});
|
||||
elName.addEventListener("input", updatePreview);
|
||||
|
||||
// init
|
||||
const initial = loadName(qs);
|
||||
if (initial) {
|
||||
elName.value = initial;
|
||||
updatePreview();
|
||||
if (qs.has("name")) {
|
||||
setLink(initial);
|
||||
fetchStatus(initial);
|
||||
} else {
|
||||
fetchStatus();
|
||||
}
|
||||
fetchLeases();
|
||||
} else {
|
||||
setPill("warn", "unknown");
|
||||
updatePreview();
|
||||
fetchStatus();
|
||||
fetchLeases();
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
import { elHtml, elLog, setPill, qs, pill } from "./dom.js";
|
||||
import { rankState } from "./utils.js";
|
||||
import { merge_wake_data, translate_wake_message } from "./wake.js";
|
||||
|
||||
// IpNeighLine
|
||||
const status_map = {
|
||||
ip: "ip",
|
||||
mac: "mac",
|
||||
state: "state",
|
||||
dev: "interface",
|
||||
};
|
||||
export const status_array = Object.keys(status_map);
|
||||
// Filters
|
||||
export const filter_array = ["ips", "devs", "nuds", "macs"];
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {String} name
|
||||
* @returns {URL}
|
||||
*/
|
||||
function buildStatusUrl(name) {
|
||||
const hasExtraFilters = filter_array.some((k) => qs.getAll(k).length);
|
||||
if (name && !hasExtraFilters) {
|
||||
return new URL(`/api/smart/${encodeURIComponent(name)}`, location.origin);
|
||||
}
|
||||
const u = new URL("/api/status", location.origin);
|
||||
if (name) u.searchParams.set("name", name);
|
||||
for (const k of filter_array) {
|
||||
const vals = qs.getAll(k);
|
||||
for (const v of vals) u.searchParams.append(k, v);
|
||||
}
|
||||
return u;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {{
|
||||
* has_wake?: true
|
||||
* table: {
|
||||
* wake_status?: Boolean
|
||||
* ips: String
|
||||
* dev: String
|
||||
* mac: String
|
||||
* state: String
|
||||
* }[]
|
||||
* filters: {
|
||||
* ips?: String[]
|
||||
* devs?: String[]
|
||||
* nuds?: String[]
|
||||
* macs?: String[]
|
||||
* }
|
||||
* }} data
|
||||
*/
|
||||
export function renderStatus(data) {
|
||||
const tbl = document.createElement("table");
|
||||
tbl.className = "table";
|
||||
tbl.innerHTML = `<thead><tr>${
|
||||
data.has_wake ? "<th>Wake status</th>" : ""
|
||||
}<th>IP</th><th>MAC</th><th>State</th><th>IF</th></tr></thead>`;
|
||||
|
||||
// sum hax
|
||||
const tbd = tbl.tBodies.item(0) || tbl.createTBody();
|
||||
for (const row of data.table) {
|
||||
if (data.has_wake && !row.wake_status)
|
||||
throw TypeError("specified has wake but no wake stats");
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `${
|
||||
row.wake_status
|
||||
? `<td><span class="dot ${
|
||||
row.wake_status == "succeed" ? "ok" : "bad"
|
||||
}" title="${translate_wake_message(row.wake_status)}"></span></td>`
|
||||
: ""
|
||||
}${Object.entries(status_map)
|
||||
.map(([field, description]) => {
|
||||
const value = row[field];
|
||||
return `<td>${
|
||||
value
|
||||
? `<a href="#" class="pick" data-value="${value}" title="filter by ${description}">${value}</a>`
|
||||
: ""
|
||||
}</td>`;
|
||||
})
|
||||
.join("")}`;
|
||||
tbd.appendChild(tr);
|
||||
}
|
||||
|
||||
elHtml.innerHTML = "";
|
||||
if (data.filters) {
|
||||
const parts = [];
|
||||
filter_array.forEach((field) => {
|
||||
if (Array.isArray(data.filters[field]) && data.filters[field].length)
|
||||
parts.push(`${field}=[${data.filters[field].join(", ")}]`);
|
||||
});
|
||||
|
||||
if (parts.length) {
|
||||
const info = document.createElement("div");
|
||||
info.className = "filters";
|
||||
info.textContent = `Filters: ${parts.join("; ")}`;
|
||||
elHtml.appendChild(info);
|
||||
}
|
||||
}
|
||||
elHtml.appendChild(tbl);
|
||||
|
||||
if ((data.table || []).length > 0) {
|
||||
const best = data.table.reduce((a, b) =>
|
||||
rankState(b.state) > rankState(a.state) ? b : a
|
||||
);
|
||||
const r = rankState(best.state);
|
||||
if (r >= 5) setPill("ok", "online");
|
||||
else if (r >= 2) setPill("warn", "maybe");
|
||||
else setPill("bad", "offline");
|
||||
if (data.filters.nuds?.length > 0) pill.textContent += " (filtered)";
|
||||
} else {
|
||||
setPill("warn", "unknown");
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {String} name
|
||||
* @param {Boolean} render
|
||||
* @param {{
|
||||
* ip: String,
|
||||
* mac: String,
|
||||
* status:
|
||||
* "incomplete" | "succeed" | "nonexistent_address" | "wrong_size"
|
||||
* }} data_wake
|
||||
* @returns {{
|
||||
* has_wake: false
|
||||
* table: {
|
||||
* ip: String
|
||||
* dev: String
|
||||
* mac: String
|
||||
* state: String
|
||||
* }[]
|
||||
* filters: {
|
||||
* ips?: String[]
|
||||
* devs?: String[]
|
||||
* nuds?: String[]
|
||||
* macs?: String[]
|
||||
* }
|
||||
* } | {
|
||||
* has_wake: true
|
||||
* table: {
|
||||
* wake_status: Boolean
|
||||
* ips: String
|
||||
* dev: String
|
||||
* mac: String
|
||||
* state: String
|
||||
* }[]
|
||||
* filters: {
|
||||
* ips?: String[]
|
||||
* devs?: String[]
|
||||
* nuds?: String[]
|
||||
* macs?: String[]
|
||||
* }
|
||||
* }}
|
||||
*/
|
||||
export async function fetchStatus(name, render = true, data_wake) {
|
||||
setPill("warn", "checking…");
|
||||
const u = buildStatusUrl(name);
|
||||
elLog.textContent = "GET " + u.pathname + u.search;
|
||||
try {
|
||||
const r = await fetch(u);
|
||||
if (!r.ok) {
|
||||
let msg = String(r.status);
|
||||
try {
|
||||
/** @type {{error: string}} */
|
||||
const err = await r.clone().json();
|
||||
msg = err.error || JSON.stringify(err);
|
||||
} catch {
|
||||
msg = await r.text();
|
||||
}
|
||||
elLog.textContent = `status error: ${msg}`;
|
||||
setPill("bad", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* this one does not have data wake
|
||||
* @type {{
|
||||
* table: {
|
||||
* ip: String
|
||||
* dev: String
|
||||
* mac: String
|
||||
* state: String
|
||||
* wake_status?: String
|
||||
* }[]
|
||||
* filters: {
|
||||
* ips?: String[]
|
||||
* devs?: String[]
|
||||
* nuds?: String[]
|
||||
* macs?: String[]
|
||||
* }
|
||||
* has_wake?: true
|
||||
* }}
|
||||
*/
|
||||
const data = await r.json();
|
||||
|
||||
if (data_wake) {
|
||||
data.table = merge_wake_data(data.table, data_wake);
|
||||
data.has_wake = true;
|
||||
}
|
||||
if (render) renderStatus(data);
|
||||
return data;
|
||||
} catch (e) {
|
||||
elLog.textContent = "status error: " + e;
|
||||
setPill("bad", "error");
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #0b0b0b;
|
||||
--fg: #e6e6e6;
|
||||
--muted: #888;
|
||||
--ok: #17a34a;
|
||||
--warn: #d97706;
|
||||
--bad: #dc2626;
|
||||
--btn: #2563eb;
|
||||
--card: #111827;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
wrap {
|
||||
margin-inline: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
min-height: 100dvh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
.wrap {
|
||||
width: min(900px, 95vw);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
input[type="text"] {
|
||||
flex: 1 1 240px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2a2a2a;
|
||||
background: #0f0f0f;
|
||||
color: var(--fg);
|
||||
outline: none;
|
||||
}
|
||||
button {
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2a2a2a;
|
||||
background: var(--btn);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.secondary {
|
||||
background: #1f2937;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
border: 1px solid #2a2a2a;
|
||||
}
|
||||
.ok {
|
||||
background: #052e1a;
|
||||
border-color: #064e3b;
|
||||
color: #86efac;
|
||||
}
|
||||
.warn {
|
||||
background: #2b1800;
|
||||
border-color: #7c2d12;
|
||||
color: #fbbf24;
|
||||
}
|
||||
.bad {
|
||||
background: #330b0b;
|
||||
border-color: #7f1d1d;
|
||||
color: #fca5a5;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
background: var(--card);
|
||||
}
|
||||
#out {
|
||||
min-height: 100px;
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
margin: 0;
|
||||
}
|
||||
a {
|
||||
color: #93c5fd;
|
||||
}
|
||||
.table a.pick {
|
||||
color: #93c5fd;
|
||||
text-decoration: none;
|
||||
}
|
||||
.table a.pick:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#html {
|
||||
overflow-y: auto;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
#html::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin-bottom: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* simple table styling */
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
font-size: 12px;
|
||||
}
|
||||
.table th {
|
||||
text-align: left;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tiny {
|
||||
font-size: 11px;
|
||||
}
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
}
|
||||
.dot.ok {
|
||||
background: var(--ok);
|
||||
}
|
||||
.dot.bad {
|
||||
background: var(--bad);
|
||||
}
|
||||
.dot.warn {
|
||||
background: var(--warn);
|
||||
}
|
||||
|
||||
#preview:empty {
|
||||
display: none;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
export function getName(elName) {
|
||||
return (elName.value || "").trim();
|
||||
}
|
||||
|
||||
export function saveName(name) {
|
||||
try {
|
||||
localStorage.setItem("wakey:name", name);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function loadName(qs) {
|
||||
return qs.get("name") || localStorage.getItem("wakey:name") || "";
|
||||
}
|
||||
|
||||
export function extractHostLikeBackend(input) {
|
||||
let s = String(input || "").trim();
|
||||
if (!s) return "";
|
||||
const schemeIdx = s.indexOf("://");
|
||||
if (schemeIdx >= 0) s = s.slice(schemeIdx + 3);
|
||||
else if (s.startsWith("//")) s = s.slice(2);
|
||||
const at = s.lastIndexOf("@");
|
||||
if (at >= 0) s = s.slice(at + 1);
|
||||
if (s.startsWith("[")) {
|
||||
const end = s.indexOf("]");
|
||||
if (end > 1) s = s.slice(1, end);
|
||||
} else {
|
||||
const slash = s.indexOf("/");
|
||||
if (slash >= 0) s = s.slice(0, slash);
|
||||
const colon = s.lastIndexOf(":");
|
||||
if (colon > 0 && (s.match(/:/g) || []).length === 1) {
|
||||
const port = s.slice(colon + 1);
|
||||
if (/^\d+$/.test(port)) s = s.slice(0, colon);
|
||||
}
|
||||
}
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
export function rankState(s) {
|
||||
const key = String(s || "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
return (
|
||||
{
|
||||
PERMANENT: 5,
|
||||
REACHABLE: 5,
|
||||
STALE: 4,
|
||||
DELAY: 3,
|
||||
PROBE: 3,
|
||||
INCOMPLETE: 3,
|
||||
NOARP: 2,
|
||||
NONE: 1,
|
||||
FAILED: 0,
|
||||
}[key] ?? 0
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { elHtml, elLog, setPill } from "./dom.js";
|
||||
import { fetchStatus, renderStatus } from "./status.js";
|
||||
|
||||
/**
|
||||
* @param {String} name just plain name
|
||||
*/
|
||||
export async function sendWake(name) {
|
||||
const data = await fetchStatus(name, false);
|
||||
if (!data) return; // can not proceed; theres nothing.
|
||||
const wake_targets = data.table.map(({ ip, mac }) => {
|
||||
return { ip, mac };
|
||||
});
|
||||
setPill("warn", "waking…");
|
||||
elLog.textContent = "POST /api/wake";
|
||||
try {
|
||||
const r = await fetch(`/api/wake`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(wake_targets),
|
||||
});
|
||||
if (!r.ok) {
|
||||
let msg = String(r.status);
|
||||
try {
|
||||
const err = await r.clone().json();
|
||||
msg = err.error || JSON.stringify(err);
|
||||
} catch {
|
||||
msg = await r.text();
|
||||
}
|
||||
elLog.textContent = `wake error: ${msg}`;
|
||||
setPill("bad", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const j = await r.json();
|
||||
|
||||
if (!j.success) {
|
||||
elLog.textContent = `wake error: ${j.error}`;
|
||||
setPill("bad", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = j.result;
|
||||
data.table = merge_wake_data(data.table, result);
|
||||
data.has_wake = true;
|
||||
|
||||
renderStatus(data);
|
||||
|
||||
setTimeout(() => fetchStatus(name, undefined, result), 2000); // long ass timeout
|
||||
} catch (e) {
|
||||
elLog.textContent = "wake error: " + e;
|
||||
setPill("bad", "error");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {{
|
||||
* ip: String
|
||||
* dev: String
|
||||
* mac: String
|
||||
* state: String
|
||||
* }[]} table
|
||||
* @param {{
|
||||
* ip: String,
|
||||
* mac: String,
|
||||
* status: "incomplete" | "succeed" | "nonexistent_address" | "wrong_size"
|
||||
* }[]} wake
|
||||
* @returns {{
|
||||
* ip: String
|
||||
* dev: String
|
||||
* mac: String
|
||||
* state: String
|
||||
* }[] | {
|
||||
* ip: String
|
||||
* dev: String
|
||||
* mac: String
|
||||
* state: String
|
||||
* wake_status: boolean
|
||||
* }[]}
|
||||
*/
|
||||
export function merge_wake_data(table, wake) {
|
||||
if (!wake) return table;
|
||||
if (wake.length != table.length)
|
||||
throw TypeError(
|
||||
"wake status table and status table not of the same length"
|
||||
);
|
||||
const return_array = [];
|
||||
let linear_failed = false;
|
||||
for (const [index, entry] of table.entries()) {
|
||||
if (wake[index].ip != entry.ip || wake[index].mac != entry.mac) {
|
||||
linear_failed = true;
|
||||
break;
|
||||
} // use alternative method
|
||||
|
||||
return_array.push({ wake_status: wake[index].status, ...entry });
|
||||
}
|
||||
|
||||
// never happening AHH
|
||||
if (linear_failed) {
|
||||
return_array = [];
|
||||
const wake_map = new Map();
|
||||
wake.forEach(({ ip, mac, status }) => {
|
||||
wake_map.set(JSON.stringify({ ip, mac }), status);
|
||||
});
|
||||
return_array = table.map((entry) => {
|
||||
const { ip, mac } = entry;
|
||||
return {
|
||||
wake_status: wake_map.get(JSON.stringify({ ip, mac })),
|
||||
...entry,
|
||||
};
|
||||
});
|
||||
}
|
||||
//
|
||||
return return_array;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {"incomplete" | "succeed" | "nonexistent_address" | "wrong_size" | any} wake_msg
|
||||
* @returns {string}
|
||||
*/
|
||||
export function translate_wake_message(wake_msg) {
|
||||
switch (wake_msg) {
|
||||
case "incomplete":
|
||||
return "Incomplete address (both ip and MAC required)"
|
||||
case "succeed":
|
||||
return "Wake request sent successfully"
|
||||
case "nonexistent_address":
|
||||
return "Errored pinging this address (nonexistent address)"
|
||||
case "wrong_size":
|
||||
return "Wake request malformed"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
[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"] }
|
||||
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" }
|
||||
@@ -0,0 +1,85 @@
|
||||
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,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use anyhow::Result;
|
||||
use tracing::{debug, instrument};
|
||||
|
||||
use crate::protocol::{
|
||||
AgentCommand, CommandResult, DevsRequest, InventoryRequest, LeasesRequest, StatusRequest,
|
||||
WakeRequest,
|
||||
};
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub async fn dispatch_command(command: AgentCommand) -> Result<CommandResult> {
|
||||
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?;
|
||||
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");
|
||||
}
|
||||
Ok(CommandResult::Devs(devs))
|
||||
}
|
||||
|
||||
async fn dispatch_inventory(req: InventoryRequest) -> Result<CommandResult> {
|
||||
let inventory = wakey::inventory(req.into_device_query()).await?;
|
||||
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"),
|
||||
};
|
||||
Ok(CommandResult::Wake(result))
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use anyhow::{Context, Result};
|
||||
use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tracing::info;
|
||||
|
||||
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");
|
||||
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());
|
||||
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!(config_path = %config_path.display(), "wrote agent config");
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user