53 Commits
78 changed files with 3209 additions and 1833 deletions

No files matched your search

+3 -3
View File
@@ -1,9 +1,9 @@
[build]
target = "armv7-unknown-linux-musleabihf"
[target.armv7-unknown-linux-musleabihf]
linker = "rust-lld"
[build]
target = "armv7-unknown-linux-musleabihf"
[alias]
ldabr = "b -r --target=target.armv7-unknown-linux-musleabihf"
t = "test -- --nocapture --test-threads=1"
+1 -1
View File
@@ -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
+569 -227
View File
File diff suppressed because it is too large. Load diff
+25 -14
View File
@@ -1,28 +1,31 @@
[package]
name = "wakey"
version = "0.1.6"
version = "0.1.7"
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 = [
anyhow = "1"
axum = { version = "0", features = ["macros"] }
axum-extra = { version = "0", features = ["query"] }
clap = { version = "4", features = ["derive"] }
color-eyre = "0"
futures = "0"
macaddr = { version = "1", features = ["serde", "serde_std"] }
serde = { version = "1", features = ["derive"] }
serde_html_form = "0"
serde_json = "1"
tokio = { version = "1", features = [
"fs",
"process",
"rt-multi-thread",
"io-util",
"macros",
] }
urlencoding = "2.1.3"
tower-http = { version = "0", features = ["fs"] }
urlencoding = "2"
wakey-core = { path = "wakey-core" }
wakey-linux = { path = "wakey-linux" }
[profile.release]
opt-level = "z"
@@ -32,3 +35,11 @@ strip = true
default = ["very-smart-parsing"]
very-smart-parsing = [
] # this is the a-bit-redundant parse thing that copilot made
[workspace]
members = ["ipjs", "wakey-core", "wakey-linux"]
[dependencies.lda-ipjs]
path = "ipjs"
registry = "gitea"
version = "*"
+10 -13
View File
@@ -2,19 +2,16 @@
router shit
## usage
## whatever i did must be BEAUTIFUL
- 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
read [in vietnamese](https://ldlda.com/blogs/else/ssh-ax3000cv2-update-firmware-0-10-2)
Optional updater (fetch latest automatically on OpenWrt):
## now i NEED the split arch to be on
```sh
WAKEY_HOST=git.ldlda.com WAKEY_OWNER=lda WAKEY_REPO=wakey sh /etc/ldlda_help/update_wakey.sh
```
like RN rn rn so i can have web outside and the lil agent can be sitting here configuring itself (Parse/append to rc.local files? add to init.d? fuhh)
I NEED IT
I NEEED IT
I NEEEEEED it.
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "lda-ipjs"
description = "ip -j show schemas"
version = "0.0.2"
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 }
+3
View File
@@ -0,0 +1,3 @@
trait IpCommand {
}
View File
Whitespace-only changes.
+19
View File
@@ -0,0 +1,19 @@
//! low
//!
//! # lda-ipjs
//!
//! this package will represent all my needs with the all the subcommands of ip -j.
//!
//! ## what i need
//!
//! ```console
//! ip -j neigh show
//! ```
//!
//! i also need to see devices and idk MAYBE maybe not MAYBE UHHHHHH maybe broadcast
//!
//! LOWK if this were to be calls to kernel or some bullshit then PLEASE because doing ts parsing its hell cuh
pub mod subcommands;
pub mod utils;
// i want a generalized way to build and call
+46
View File
@@ -0,0 +1,46 @@
// ip address [ show [ dev IFNAME ] [ scope SCOPE-ID ] [ master DEVICE ]
// [ type TYPE ] [ to PREFIX ] [ FLAG-LIST ]
// [ label LABEL ] [up] [ vrf NAME ] ]
// fuck is this mean
// do i need all this? do i need anything but `ip -j a show dev br-lan`?
// TYPE := { vlan | veth | vcan | vxcan | dummy | ifb | macvlan | macvtap |
// bridge | bond | ipoib | ip6tnl | ipip | sit | vxlan | lowpan |
// gre | gretap | erspan | ip6gre | ip6gretap | ip6erspan | vti |
// nlmon | can | bond_slave | ipvlan | geneve | bridge_slave |
// hsr | macsec | netdevsim }
// FLAG-LIST := [ FLAG-LIST ] FLAG
// FLAG := [ permanent | dynamic | secondary | primary |
// [-]tentative | [-]deprecated | [-]dadfailed | temporary |
// CONFFLAG-LIST ]
// CONFFLAG-LIST := [ CONFFLAG-LIST ] CONFFLAG
// CONFFLAG := [ home | nodad | mngtmpaddr | noprefixroute | autojoin ]
// prefix seems to be a cidr. both 6 and 4 works. idfk dog
use std::{io, process::Output};
use anyhow::Context;
use super::AddrOutput;
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<AddrOutput>> {
let output = _get(dev).await.context("Can not run command")?;
if !output.status.success() {
anyhow::bail!(String::from_utf8_lossy(&output.stderr).into_owned());
}
serde_json::from_slice(&output.stdout).context("Deserialize failed")
}
pub async fn _get(dev: Option<&str>) -> io::Result<Output> {
let mut cmd = tokio::process::Command::new("ip");
cmd.args(["-j", "address", "show"]);
if let Some(d) = dev {
cmd.args(["dev", d]);
}
cmd.output().await
}
+59
View File
@@ -0,0 +1,59 @@
//! ts
//!
//! deals with both ip a (addroutput) and ip l (commonoutput)
//!
//! lowk why its free but its indirection and its ass
pub mod json;
#[cfg(all(unix, feature = "experimental-nl"))]
pub mod nl;
pub use crate::subcommands::Backend;
use crate::utils::serialize::mac::option_mac;
use macaddr::MacAddr;
use serde::{Deserialize, Serialize};
/// i dont include what i dont know about (almost all ts)
#[derive(Serialize, Debug, Deserialize)]
pub struct AddrOutput {
pub ifindex: u32,
pub ifname: String,
/// i imagine UP or DOWN, unknown
pub operstate: String,
// 6 has a serde and the enum doesnt? why. (serializing ts is ass although... im not given an array. they string formatted ts)
#[serde(with = "option_mac", default)]
pub address: Option<MacAddr>,
#[serde(default)] // i wish we have intellisense for this... fuck you metaprogramming
pub addr_info: Vec<AddrInfo>,
}
// i be copying
// Raw JSON shape from ip -j -4 address show
#[derive(Debug, Deserialize, Serialize)]
pub struct AddrInfo {
pub family: Option<String>,
pub local: Option<String>,
pub prefixlen: Option<u8>,
pub broadcast: Option<String>,
pub scope: Option<String>,
pub label: Option<String>,
// many more exist; we only take what we need
}
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<AddrOutput>> {
get_with_backend(Backend::Json, dev).await
}
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,
}
}
+22
View File
@@ -0,0 +1,22 @@
//! i said i aint doing ts no more why am i still here
#![cfg(unix)]
use futures::TryStreamExt;
use crate::subcommands::address::AddrOutput;
// shit this one is even worse you needa collect info from two places
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<AddrOutput>> {
let (conn, handle, _) = rtnetlink::new_connection()?;
tokio::spawn(conn); // every time?
let mut address = handle.address().get();
let mut link = handle.link().get();
if let Some(dev) = dev {
link = link.match_name(dev.to_owned());
if let Some(ind) = link.execute().try_next().await?.map(|a| a.header.index) {
address = address.set_link_index_filter(ind);
};
address.execute().try_next().await?;
} else {
};
todo!();
}
+1
View File
@@ -0,0 +1 @@
//! this is for link. You need link; at least to build an index -> name map. i Need It. sometimes.
+9
View File
@@ -0,0 +1,9 @@
pub mod address;
pub mod neighbor;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Backend {
Json,
#[cfg(all(unix, feature = "experimental-nl"))]
Netlink,
}
+60
View File
@@ -0,0 +1,60 @@
//! idk what to put here
use std::{io, net::IpAddr, process::Output};
use anyhow::{Context, bail};
use super::{NUDState, NeighborItem};
// loose translation of [wakey::utils::query::macs::get_mac]
// i think ill write tokio::process every time tho (for this if let thing) because iterate through all ts youll have to as str and all the hooplas.
// it all turns to live osstr tho so ts just for my own sanity
// thiserror? anyhow
// what is vro sayin
// ahh. instead of using [wakey::utils::cmd::exec_command] which is ass we jus write everything out. so i dont have to .as_str() so often.
pub async fn get(
ip: Option<IpAddr>,
dev: Option<&str>,
nud: &[NUDState],
) -> anyhow::Result<Vec<NeighborItem>> {
let output = _get(ip, dev, nud).await.context("Can not run command")?;
if !output.status.success() {
bail!(String::from_utf8_lossy(&output.stderr).into_owned())
} else {
let mut fuckass: Vec<NeighborItem> =
serde_json::from_slice(&output.stdout).context("Deserialize failed")?;
// i hate ts.
if let Some(dev) = dev {
for item in &mut fuckass {
if item.dev.is_none() {
item.dev = Some(dev.to_owned());
}
}
};
Ok(fuckass)
}
}
pub async fn _get(ip: Option<IpAddr>, dev: Option<&str>, nud: &[NUDState]) -> io::Result<Output> {
let mut cmd = tokio::process::Command::new("ip");
cmd.args(["-j", "neigh", "show"]);
if let Some(ip) = ip {
// cmd.arg("to");
cmd.arg(ip.to_canonical().to_string());
};
if let Some(dev) = dev {
cmd.args(["dev", dev]);
}
for nud in nud {
cmd.arg("nud");
cmd.arg(nud.to_string());
}
cmd.output().await
}
+109
View File
@@ -0,0 +1,109 @@
//! ```bash
//! ip -j n s
//! ```
//!
//! yes. this is a real call.
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};
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
pub struct NeighborInput {
/// supports only the last item (ignore), `to` keyword is optional
pub to: Option<IpAddr>,
/// supports only one item (it complains if multiple)
pub dev: Option<String>, // im all for simplicity
/// takes multiple, has to have `nud` before bro or it will think you `to`
pub nud: Vec<NUDState>,
}
// as input this must be lowercase. as output it is uppercase
/// docs for items come from a random ahh man website idk
#[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),
}
/// everything i see
#[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>,
}
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
}
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
}
}
}
+145
View File
@@ -0,0 +1,145 @@
//! 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::packet_route::{
AddressFamily,
link::LinkAttribute,
neighbour::{NeighbourAddress, NeighbourAttribute, NeighbourState},
};
use super::{NUDState, NeighborItem};
/// 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);
let mut neighbor_data = handle.neighbours().get().execute();
// Build filter sets (empty = match all)
let ip_set: HashSet<IpAddr> = ips.iter().copied().collect();
let dev_set: HashSet<&str> = devs.iter().map(AsRef::as_ref).collect();
let nud_set: HashSet<&NUDState> = nuds.iter().collect();
let mac_set: HashSet<MacAddr> = macs.iter().copied().collect();
// Cache ifindex -> name
let mut ifname_cache: HashMap<u32, String> = HashMap::new();
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 = match ifname_cache.get(&msg.header.ifindex) {
Some(name) => Some(name.clone()),
None => {
let name = handle
.link()
.get()
.match_index(msg.header.ifindex)
.execute()
.try_next()
.await?
.and_then(|link| {
link.attributes.into_iter().find_map(|a| match a {
LinkAttribute::IfName(n) => Some(n),
_ => None,
})
});
if let Some(ref n) = name {
ifname_cache.insert(msg.header.ifindex, n.clone());
}
name
}
};
let (Some(ip), Some(dev)) = (ip, dev) else {
continue 'row;
};
// Apply filters (empty set = match all)
if !ip_set.is_empty() && !ip_set.contains(&ip) {
continue 'row;
}
if !dev_set.is_empty() && !dev_set.contains(dev.as_str()) {
continue 'row;
}
if !nud_set.is_empty() && !nud_set.contains(&state) {
continue 'row;
}
if !mac_set.is_empty() && !mac.is_some_and(|m| mac_set.contains(&m)) {
continue 'row;
}
result.push(NeighborItem {
ip,
dev: Some(dev),
mac,
state: vec![state],
});
}
Ok(result)
}
impl TryFrom<NeighbourState> for NUDState {
fn try_from(value: NeighbourState) -> Result<Self, Self::Error> {
match value {
NeighbourState::Incomplete => Ok(Self::Incomplete),
NeighbourState::Reachable => Ok(Self::Reachable),
NeighbourState::Stale => Ok(Self::Stale),
NeighbourState::Delay => Ok(Self::Delay),
NeighbourState::Probe => Ok(Self::Probe),
NeighbourState::Failed => Ok(Self::Failed),
NeighbourState::Noarp => Ok(Self::Noarp),
NeighbourState::Permanent => Ok(Self::Permanent),
NeighbourState::None => Ok(Self::None),
NeighbourState::Other(e) => Ok(Self::Other(e)),
_ => Err(u16::MAX),
}
}
type Error = u16;
}
+35
View File
@@ -0,0 +1,35 @@
use std::iter::Fuse;
/// wrap ts into a [Fuse][std::iter::Fuse] or something
pub struct Real<A: Clone, B: Iterator<Item = A>> {
prepend: A,
iter: B,
my_turn: bool,
}
impl<A: Clone, B: Iterator<Item = A>> Real<A, B> {
pub fn new(prepend: A, iter: B) -> Self {
Self {
prepend,
iter,
my_turn: true,
}
}
pub fn fuse(prepend: A, iter: B) -> Fuse<Self> {
Self::new(prepend, iter).fuse()
}
}
impl<A: Clone, B: Iterator<Item = A>> Iterator for Real<A, B> {
type Item = A;
fn next(&mut self) -> Option<Self::Item> {
let a = if self.my_turn {
Some(self.prepend.clone())
} else {
self.iter.next()
};
self.my_turn = !self.my_turn;
a
}
}
+2
View File
@@ -0,0 +1,2 @@
// pub mod iter;
pub mod serialize;
+50
View File
@@ -0,0 +1,50 @@
use macaddr::MacAddr;
use serde::{self, Deserialize, Deserializer, de::Error as DeError};
use serde::{Serialize, Serializer};
pub fn serialize_macs<S>(macs: &[MacAddr], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let strings: Vec<String> = macs.iter().map(|m| m.to_string()).collect();
serde::Serialize::serialize(&strings, serializer)
}
/// Serialize a MacAddr as a string
pub fn serialize<S>(mac: &MacAddr, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&mac.to_string())
}
/// Deserialize a MacAddr from a string
pub fn deserialize<'de, D>(deserializer: D) -> Result<MacAddr, D::Error>
where
D: Deserializer<'de>,
{
let s = <String as serde::Deserialize>::deserialize(deserializer)?;
s.parse::<MacAddr>().map_err(DeError::custom)
}
pub mod option_mac {
use super::*;
/// serialize an [`Option<MacAddr>`]
pub fn serialize<S: Serializer>(bro: &Option<MacAddr>, ser: S) -> Result<S::Ok, S::Error> {
Option::<String>::serialize(&bro.as_ref().map(ToString::to_string), ser)
}
/// deserialize an [`Option<MacAddr>`], returns None for invalid input (::, 0.0.0.0)
pub fn deserialize<'de, D>(des: D) -> Result<Option<MacAddr>, D::Error>
where
D: serde::Deserializer<'de>,
{
let s: Option<&str> = Option::<&str>::deserialize(des)?;
match s {
Some(val) => match val.parse::<MacAddr>() {
Ok(mac) => Ok(Some(mac)),
Err(_) => Ok(None),
},
None => Ok(None),
}
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod mac;
// pub mod vec;
+37
View File
@@ -0,0 +1,37 @@
use serde::Deserialize;
use serde::de;
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany<T> {
One(T),
Many(Vec<T>),
}
pub fn deserialize<'de, D, T>(des: D) -> Result<Vec<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: std::str::FromStr,
T::Err: std::fmt::Display,
{
let raw: OneOrMany<String> = OneOrMany::<String>::deserialize(des)?;
let mut out = Vec::new();
match raw {
OneOrMany::One(s) => {
let t = s.trim();
if !t.is_empty() {
out.push(t.parse().map_err(de::Error::custom)?);
}
}
OneOrMany::Many(vs) => {
for s in vs {
let t = s.trim();
if t.is_empty() {
continue;
}
out.push(t.parse().map_err(de::Error::custom)?);
}
}
}
Ok(out)
}
+144
View File
@@ -0,0 +1,144 @@
use std::collections::HashSet;
use lda_ipjs::subcommands::{address, neighbor};
#[cfg(all(unix, feature = "experimental-nl"))]
#[tokio::test] // ← Use tokio::test instead of manual #[tokio::main]
async fn ball1() -> anyhow::Result<()> {
let result = neighbor::get_with_backend(neighbor::Backend::Netlink, None, None, &[]).await?;
println!("netlink results: {:?}", result);
Ok(()) // ← Don't force error, let it succeed
}
#[tokio::test]
async fn ball2() -> anyhow::Result<()> {
let result = neighbor::get_with_backend(neighbor::Backend::Json, None, None, &[]).await?;
println!("json results: {:?}", result);
Ok(())
}
// Add this to debug the raw JSON
#[tokio::test]
async fn ball_raw_json() -> anyhow::Result<()> {
let output = tokio::process::Command::new("ip")
.args(["-j", "neigh", "show"])
.output()
.await?;
let json = String::from_utf8_lossy(&output.stdout);
println!("Raw JSON:\n{}", json);
// Try to parse it
let parsed: Result<Vec<neighbor::NeighborItem>, _> = serde_json::from_slice(&output.stdout);
match parsed {
Ok(items) => println!("Parsed {} items", items.len()),
Err(e) => println!("Parse error: {}", e),
}
Ok(())
}
// Check what fields actually exist in the JSON
#[tokio::test]
async fn ball_field_analysis() -> anyhow::Result<()> {
let output = tokio::process::Command::new("ip")
.args(["-j", "neigh", "show"])
.output()
.await?;
let raw: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout)?;
println!("Found {} neighbor entries", raw.len());
// Collect all unique field names across all entries
let mut all_fields = std::collections::HashSet::new();
for (i, entry) in raw.iter().enumerate() {
if let Some(obj) = entry.as_object() {
println!("\nEntry {}: {} fields", i, obj.len());
for (key, value) in obj {
all_fields.insert(key.clone());
println!(" {}: {} = {:?}", key, value.type_name(), value);
}
}
}
println!("\n=== All unique fields seen ===");
for field in &all_fields {
println!(" - {}", field);
}
Ok(())
}
// Helper trait to get type name for JSON values
trait TypeName {
fn type_name(&self) -> &str;
}
impl TypeName for serde_json::Value {
fn type_name(&self) -> &str {
match self {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "bool",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}
}
// Test filtering logic
#[tokio::test]
async fn ball_compare_backends() -> anyhow::Result<()> {
println!("=== JSON Backend ===");
let json_result = neighbor::get_with_backend(neighbor::Backend::Json, None, None, &[]).await?;
println!("Got {} entries from JSON", json_result.len());
#[cfg(all(unix, feature = "experimental-nl"))]
{
println!("\n=== Netlink Backend ===");
let nl_result =
neighbor::get_with_backend(neighbor::Backend::Netlink, None, None, &[]).await?;
println!("Got {} entries from netlink", nl_result.len());
// Compare counts
if json_result.len() != nl_result.len() {
println!(
"\n⚠️ Count mismatch! JSON: {}, Netlink: {}",
json_result.len(),
nl_result.len()
);
} else {
println!("\n✅ Both backends returned same count");
}
let a: HashSet<neighbor::NeighborItem> = HashSet::from_iter(json_result);
let b: HashSet<neighbor::NeighborItem> = HashSet::from_iter(nl_result);
println!(
"istg {len1} == {len2} or else",
len1 = a.len(),
len2 = b.len()
);
}
Ok(())
}
// #[tokio::test]
// async fn cidr_filter() {
// unimplemented!("never. i aint add what i dont need")
// }
#[tokio::test]
async fn ipjas() -> anyhow::Result<()> {
let cuh = address::get_with_backend(address::Backend::Json, None).await?;
println!("{cuh:#?}");
Ok(())
}
#[tokio::test]
async fn ipjas_raw() -> anyhow::Result<()> {
let cuh = address::json::_get(None).await?;
println!("{cuh:#?}");
Ok(())
}
+18
View File
@@ -0,0 +1,18 @@
# this does one thing
param(
[string]$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
$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"
$remote = $files.ForEach({ "/etc/init.d/$_" })
$remote | ForEach-Object {
pscp.exe -l root -scp -pw $PASSWD "192.168.100.1:$_" "$initDir\"
}
+8 -4
View File
@@ -10,7 +10,9 @@ param(
# 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'
@@ -50,11 +52,13 @@ if (-not (Test-Path $configFile)) {
}
}
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 {
+19 -54
View File
@@ -1,4 +1,5 @@
[System.Diagnostics.CodeAnalysis.SuppressMessage('PSAvoidDefaultValueSwitchParameter', 'Default true is intentional for fast dev loop')]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidDefaultValueSwitchParameter', "",
Justification = 'Default true is intentional for fast dev loop')]
param(
[string]$Pass,
[string]$HostName = "192.168.100.1",
@@ -14,23 +15,9 @@ 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
function Get-DeployScript {
param($DeployPreferred, $DeployTmp, $RemoteTmp, $RemotePath, $RestartFlag)
@@ -42,43 +29,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 ----
@@ -102,6 +53,20 @@ try {
# Push main binary
Invoke-Scp -Local $localBin -Dest $destTmp -Pass $Pass -HostKey $HostKey -Quiet:$Quiet
# Push static assets
$localStatic = Join-Path $repoRoot "static"
if (Test-Path $localStatic) {
# Assuming RemotePath is like /root/.bin/wakey, we want /root/.bin/static
# So we push 'static' directory to /root/.bin/
$remoteDir = (Split-Path $RemotePath -Parent) -replace '\\', '/'
# Ensure remote dir exists (ssh mkdir -p)
Invoke-Ssh -Cmd "mkdir -p $remoteDir" -User $User -Remote $HostName -Pass $Pass -Quiet:$Quiet
# SCP -r static user@host:/root/.bin/
# Note: pscp/scp behavior: if dest is a dir, it copies the source dir INTO it.
Invoke-Scp -Local $localStatic -Dest "$User@${HostName}:$remoteDir/" -Pass $Pass -HostKey $HostKey -Quiet:$Quiet -Recurse
}
# Push deploy helper if exists
if (Test-Path $localDeploy) {
Invoke-Scp -Local $localDeploy -Dest "$User@${HostName}:$deployTmp" -Pass $Pass -HostKey $HostKey -Quiet:$Quiet
+16 -17
View File
@@ -9,23 +9,22 @@
START=50 # Run before Tailscale (typically START~80)
start() {
LOGFILE=/var/log/update_tailscale.log
do_update() {
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 &
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 &
}
+18 -17
View File
@@ -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 &
}
+1 -1
View File
@@ -9,7 +9,7 @@ BIN=/root/.bin/wakey
start_service() {
procd_open_instance
procd_set_param command "$BIN"
procd_set_param command "$BIN" http --host :: --port 12012
procd_set_param respawn 5 1 0
procd_set_param stdout 1
procd_set_param stderr 1
+2 -2
View File
@@ -69,10 +69,10 @@ latest_asset_url() {
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)
# Extract the LAST browser_download_url ending with our ARCH .tgz (BusyBox-friendly)
echo "$RESP" |
grep -o '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*'"$ARCH"'\.tgz"' |
head -n1 |
tail -n1 |
cut -d '"' -f 4
}
+73
View File
@@ -0,0 +1,73 @@
# Shared functions for wakey scripts
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 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
$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
}
function Invoke-Scp {
param($Local, $Dest, $Pass, $HostKey, [switch]$Quiet, [switch]$Recurse)
if ($pscp = Get-Command pscp.exe -ErrorAction SilentlyContinue) {
$arguments = @('-scp')
if ($Quiet) { $arguments += '-q' }
if ($Recurse) { $arguments += '-r' }
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' }
if ($Recurse) { $arguments += '-r' }
$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) }
$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' }
$dest = if ($User) { "$User@$Remote" } else { $Remote }
$arguments += $dest, $Cmd
Invoke-Ext -Exe 'ssh' -Arguments $arguments -Label 'ssh'
}
}
+186 -39
View File
@@ -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)}")
+6
View File
@@ -52,6 +52,12 @@ if (Test-Path $deploySrc) {
Set-Content -NoNewline -LiteralPath (Join-Path $rootDir "remote_deploy_wakey.sh") -Value $deployContent -Encoding UTF8
}
# Copy static assets
$staticSrc = Join-Path $root "static"
if (Test-Path $staticSrc) {
Copy-Item -Recurse $staticSrc (Join-Path $rootDir "static") -Force
}
# Copy all OpenWrt init scripts present in repo
Get-ChildItem (Join-Path $root 'scripts/init/openwrt') -File | ForEach-Object {
$dest = Join-Path $etcDir $_.Name
+13 -11
View File
@@ -16,6 +16,7 @@ param(
$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 +29,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 +56,17 @@ 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."
# Run act_runner in --once mode to process the release job
$actRunnerScript = Join-Path $PSScriptRoot 'act_runner.ps1'
if (Test-Path $actRunnerScript) {
Write-Host "Starting act_runner (once mode) to process release job..."
& $actRunnerScript -Once
}
else {
Write-Warning "act_runner.ps1 not found at $actRunnerScript. Skipping runner."
}
}
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env pwsh
# Test ipjs on remote ARM device
param(
[string]$Package = "lda-ipjs",
[string]$TestName = "",
[ValidateSet("debug", "release")]
[string]$BuildProfile = "debug",
[string]$password,
[switch]$Verbose,
[string]$RemoteTestPath = "/root/.bin/test",
[string]$RemoteHost = "[email protected]"
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot/lib.ps1"
$password = Get-DefaultPassword $password
# Build tests and capture output
Write-Host "Building tests for $Package..." -ForegroundColor Cyan
# Stream cargo output anc convert to text
$cargoOutput = cargo test --no-run -p $Package --target armv7-unknown-linux-musleabihf $(if ($BuildProfile -eq "release") { "-r" }) 2>&1 |
ForEach-Object {
$line = if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.ToString() } else { $_ }
if ($Verbose) {
Write-Host $line
}
$line # Pass through to capture
}
# Parse test binary paths from cargo output
$testBinaries = $cargoOutput |
Select-String -Pattern "Executable.*\((.+)\)" |
ForEach-Object { $_.Matches.Groups[1].Value } |
Get-Item
if ($testBinaries.Count -eq 0) {
Write-Error "No test binaries found! Exiting..."
# Write-Host "Cargo output:" -ForegroundColor Yellow
# $cargoOutput | ForEach-Object { Write-Host $_ }
exit 1
}
Write-Host "Found $($testBinaries.Count) test $($testBinaries.Count -eq 1 ? "binary" : "binaries")" -ForegroundColor Green
# Run each test binary
foreach ($testBinary in $testBinaries) {
Write-Host "`nTesting: $($testBinary.Name)" -ForegroundColor Cyan
# Copy to target
Invoke-Scp -Local $testBinary.FullName -Dest "${RemoteHost}:$RemoteTestPath" -Pass $password -Quiet
# Run on target
$testArgs = "$(if ($Verbose) {"--nocapture --show-output"})"
if ($TestName) {
$testArgs = "$TestName $testArgs"
}
Invoke-Ssh -Cmd "chmod +x $RemoteTestPath && $RemoteTestPath $testArgs" -Remote $RemoteHost -Pass $password
}
Write-Host "`nDone!" -ForegroundColor Green
+2
View File
@@ -0,0 +1,2 @@
pub use wakey_core::NeighborEntry as IpNeighLine;
pub use wakey_core::NeighborState as NUDState;
-30
View File
@@ -1,30 +0,0 @@
use std::net::AddrParseError;
use strum::Display;
use thiserror::Error;
#[derive(Debug, Display, Error)]
pub enum IPNeighParseError {
IpWhere, // i never seen a ip neigh where the first thing aint an ip
IpParseError(AddrParseError),
// DevWhere,
MacParseError(macaddr::ParseError),
StateWhere, // i never seen a ip neigh without the big FAILED at the end
StateParseError(strum::ParseError),
}
impl From<AddrParseError> for IPNeighParseError {
fn from(value: AddrParseError) -> Self {
Self::IpParseError(value)
}
}
impl From<macaddr::ParseError> for IPNeighParseError {
fn from(value: macaddr::ParseError) -> Self {
Self::MacParseError(value)
}
}
impl From<strum::ParseError> for IPNeighParseError {
fn from(value: strum::ParseError) -> Self {
Self::StateParseError(value)
}
}
-19
View File
@@ -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};
-245
View File
@@ -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,
)
}
}
-12
View File
@@ -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");
}
+145
View File
@@ -0,0 +1,145 @@
use serde::Serialize;
use wakey_core::parse::mac;
use wakey_core::{
DeviceFilters, DhcpLeaseWithState, NeighborEntry, Status, WakeResult, WakeTargetResult,
};
#[derive(Debug, Clone, Serialize)]
pub struct LegacyStatusRow {
pub ip: std::net::IpAddr,
pub dev: Option<String>,
#[serde(with = "mac::option_mac")]
pub mac: Option<macaddr::MacAddr>,
pub state: wakey_core::NeighborState,
}
#[derive(Debug, Clone, Serialize)]
pub struct LegacyStatusResponse {
pub name: Option<String>,
pub table: Vec<LegacyStatusRow>,
pub filters: DeviceFilters,
}
#[derive(Debug, Clone, Serialize)]
pub struct LegacyLeaseRow {
pub expires_epoch: u64,
pub ip: std::net::IpAddr,
#[serde(with = "mac")]
pub mac: macaddr::MacAddr,
pub name: Option<String>,
pub nud_state: Option<wakey_core::NeighborState>,
}
#[derive(Debug, Clone, Serialize)]
pub struct LegacyWakeResult {
pub result: Vec<LegacyWakeResultRow>,
}
#[derive(Debug, Clone, Copy, Serialize)]
pub struct LegacyWakeResultRow {
#[serde(flatten)]
pub target: wakey_core::WakeTarget,
pub status: wakey_core::WakeStatus,
}
pub fn legacy_status_from_domain(status: Status<NeighborEntry>) -> LegacyStatusResponse {
LegacyStatusResponse {
name: status.name,
table: status.table.into_iter().map(legacy_status_row).collect(),
filters: status.filters,
}
}
pub fn legacy_status_row(row: NeighborEntry) -> LegacyStatusRow {
LegacyStatusRow {
ip: row.ip,
dev: row.dev,
mac: row.mac,
state: row.state,
}
}
pub fn legacy_leases_from_domain(leases: Vec<DhcpLeaseWithState>) -> Vec<LegacyLeaseRow> {
leases.into_iter().map(legacy_lease_row).collect()
}
pub fn legacy_lease_row(lease: DhcpLeaseWithState) -> LegacyLeaseRow {
LegacyLeaseRow {
expires_epoch: lease.lease_line.expires_epoch,
ip: lease.lease_line.ip,
mac: lease.lease_line.mac,
name: lease.lease_line.name,
nud_state: lease.nud_state,
}
}
pub fn legacy_wake_from_domain(result: WakeResult) -> LegacyWakeResult {
LegacyWakeResult {
result: result.result.into_iter().map(legacy_wake_row).collect(),
}
}
pub fn legacy_wake_row(row: WakeTargetResult) -> LegacyWakeResultRow {
LegacyWakeResultRow {
target: row.target,
status: row.status,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr};
use wakey_core::{DhcpLease, NeighborState, WakeStatus, WakeTarget};
#[test]
fn maps_status_to_legacy_shape() {
let status = Status {
name: Some("pc".into()),
table: vec![NeighborEntry {
ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)),
dev: Some("br-lan".into()),
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
state: NeighborState::Reachable,
}],
filters: DeviceFilters::default(),
};
let legacy = legacy_status_from_domain(status);
assert_eq!(legacy.name.as_deref(), Some("pc"));
assert_eq!(legacy.table.len(), 1);
assert_eq!(legacy.table[0].state, NeighborState::Reachable);
}
#[test]
fn maps_leases_to_legacy_shape() {
let leases = vec![DhcpLeaseWithState {
lease_line: DhcpLease {
expires_epoch: 42,
ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
mac: "aa:bb:cc:dd:ee:ff".parse().expect("mac"),
name: Some("pc".into()),
},
nud_state: Some(NeighborState::Reachable),
}];
let legacy = legacy_leases_from_domain(leases);
assert_eq!(legacy.len(), 1);
assert_eq!(legacy[0].name.as_deref(), Some("pc"));
assert_eq!(legacy[0].nud_state, Some(NeighborState::Reachable));
}
#[test]
fn maps_wake_to_legacy_shape() {
let result = WakeResult {
result: vec![WakeTargetResult {
target: WakeTarget {
ip: Some(IpAddr::V4(Ipv4Addr::LOCALHOST)),
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
},
status: WakeStatus::Succeed,
}],
};
let legacy = legacy_wake_from_domain(result);
assert_eq!(legacy.result.len(), 1);
assert_eq!(legacy.result[0].status, WakeStatus::Succeed);
}
}
+1 -88
View File
@@ -1,88 +1 @@
/// MAC->name cache location (ephemeral)
const MAC_NAME_CACHE: &str = "/tmp/wakey_mac_names.json";
/// Load MAC->name cache from disk
pub(crate) async fn load_mac_name_cache() -> io::Result<std::collections::BTreeMap<String, String>>
{
match tokio::fs::read_to_string(MAC_NAME_CACHE).await {
Ok(s) => serde_json::from_str(&s).map_err(io::Error::other),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Default::default()),
Err(e) => Err(e),
}
}
/// Save MAC->name cache to disk
async fn save_mac_name_cache(map: &std::collections::BTreeMap<String, String>) -> io::Result<()> {
let s = serde_json::to_string(map).map_err(io::Error::other)?;
let _ = tokio::fs::write(MAC_NAME_CACHE, s).await;
Ok(())
}
/// Read all leases, filling names from MAC->name cache if missing
pub async fn read_dhcp_leases_with_names() -> io::Result<Vec<DhcpLeaseLine>> {
let leases = read_dhcp_leases().await?;
let mut cache = load_mac_name_cache().await.unwrap_or_default();
let mut changed = false;
let mut leases_with_names = Vec::with_capacity(leases.len());
for mut l in leases {
let mac_s = l.mac.to_string();
if let Some(ref name) = l.name {
// if no name in cache file or it changed
if cache.get(&mac_s).map(|v| v != name).unwrap_or(true) {
cache.insert(mac_s, name.clone());
changed = true;
}
} else if let Some(prev) = cache.get(&mac_s) {
l.name = Some(prev.clone());
}
leases_with_names.push(l);
}
if changed {
let _ = save_mac_name_cache(&cache).await;
}
Ok(leases_with_names)
}
use macaddr::MacAddr;
use serde::Serializer;
use std::io::{self, ErrorKind};
use std::net::IpAddr;
/// A single line from /tmp/dhcp.leases
#[derive(Debug, Clone, serde::Serialize)]
pub struct DhcpLeaseLine {
/// Epoch seconds when the lease expires
pub expires_epoch: u64,
pub ip: IpAddr,
#[serde(serialize_with = "ser_mac")]
pub mac: MacAddr,
pub name: Option<String>,
}
fn ser_mac<S: Serializer>(m: &MacAddr, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&m.to_string())
}
/// Parse one line of /tmp/dhcp.leases
pub fn parse_dhcp_lease_line(line: &str) -> Option<DhcpLeaseLine> {
let mut c = line.split_whitespace();
let expires_epoch: u64 = c.next()?.parse().ok()?;
let mac = c.next()?.parse().ok()?;
let ip = c.next()?.parse().ok()?;
let name = c.next().filter(|c| *c != "*").map(str::to_string);
// ignore any remaining columns (e.g., client-id)
Some(DhcpLeaseLine {
expires_epoch,
ip,
mac,
name,
})
}
/// Read all leases from /tmp/dhcp.leases (simple and fast)
pub async fn read_dhcp_leases() -> io::Result<Vec<DhcpLeaseLine>> {
match tokio::fs::read_to_string("/tmp/dhcp.leases").await {
Ok(file) => Ok(file.lines().filter_map(parse_dhcp_lease_line).collect()),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e),
}
}
pub use wakey_linux::dhcp::{load_mac_name_cache, read_dhcp_leases_with_names};
+209
View File
@@ -0,0 +1,209 @@
pub mod arpparse;
pub mod compat;
pub mod dhcpparse;
pub mod route;
pub mod utils;
use std::{io, net::SocketAddr};
use anyhow::{Context, Result};
use axum::Router;
use tokio::net::TcpListener;
use tower_http::services::ServeDir;
use wakey_core::{
DeviceFilters, DeviceQuery, DhcpLease, DhcpLeaseWithState, NeighborEntry, QueryInput, Status,
WakeResult, WakeTarget,
};
pub type StatusResponse = Status<NeighborEntry>;
pub async fn resolve_query(input: impl Into<String>) -> Result<DeviceQuery> {
Ok(
match wakey_linux::devices::classify_query(input.into()).await {
QueryInput::Ip(ip_addr) => DeviceQuery {
filter: DeviceFilters {
ips: vec![ip_addr],
..Default::default()
},
..Default::default()
},
QueryInput::Mac(mac_addr) => DeviceQuery {
filter: DeviceFilters {
macs: vec![mac_addr],
..Default::default()
},
..Default::default()
},
QueryInput::Dev(dev) => DeviceQuery {
filter: DeviceFilters {
devs: vec![dev],
..Default::default()
},
..Default::default()
},
QueryInput::Nud(state) => DeviceQuery {
filter: DeviceFilters {
nuds: vec![state],
..Default::default()
},
..Default::default()
},
QueryInput::Name(name) => DeviceQuery {
name: Some(name),
..Default::default()
},
},
)
}
pub async fn get_status(query: DeviceQuery) -> Result<StatusResponse> {
let table = wakey_linux::devices::query_status(&query).await?;
Ok(Status {
name: query.name,
table,
filters: query.filter,
})
}
pub async fn get_status_for_input(input: impl Into<String>) -> Result<StatusResponse> {
let query = resolve_query(input).await?;
get_status(query).await
}
pub async fn get_leases(include_state: bool) -> Result<Vec<DhcpLeaseWithState>> {
let leases = wakey_linux::dhcp::read_dhcp_leases_with_names()
.await
.context("failed to read DHCP leases")?;
if include_state {
Ok(wakey_linux::dhcp::enrich_leases_with_nud_state(leases).await)
} else {
Ok(leases
.into_iter()
.map(|lease_line| DhcpLeaseWithState {
lease_line,
nud_state: None,
})
.collect())
}
}
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")?;
Ok(WakeResult { result })
}
pub async fn wake_from_query(input: impl Into<String>) -> Result<WakeResult> {
let status = get_status_for_input(input).await?;
let targets = status
.table
.into_iter()
.map(|entry| WakeTarget {
ip: Some(entry.ip),
mac: entry.mac,
})
.collect();
wake_targets(targets).await
}
pub async fn list_interfaces() -> Result<Vec<String>> {
Ok(wakey_linux::devices::devs_sorted().await)
}
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())
}
pub fn http_app(static_root: std::path::PathBuf) -> Router {
Router::new()
.nest("/api", route::api_router())
.fallback_service(axum::routing::get_service(
ServeDir::new(static_root)
.append_index_html_on_directories(true)
.precompressed_br()
.precompressed_deflate()
.precompressed_gzip()
.precompressed_zstd(),
))
}
pub async fn serve_http(addr: SocketAddr, static_root: std::path::PathBuf) -> io::Result<()> {
let listener = TcpListener::bind(addr).await?;
axum::serve(listener, http_app(static_root).into_make_service()).await
}
pub async fn serve_http_from_current_exe(addr: SocketAddr) -> io::Result<()> {
let exe = std::env::current_exe()?;
let root = exe
.parent()
.ok_or_else(|| io::Error::other("no parent dir"))?;
serve_http(addr, root.join("static")).await
}
pub fn leases_without_state(leases: Vec<DhcpLease>) -> Vec<DhcpLeaseWithState> {
leases
.into_iter()
.map(|lease_line| DhcpLeaseWithState {
lease_line,
nud_state: None,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr};
use wakey_core::{NeighborState, WakeStatus};
#[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]);
}
#[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());
}
#[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);
}
}
+113 -40
View File
@@ -1,53 +1,126 @@
//! 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;
use std::net::{IpAddr, SocketAddr};
use axum::{Router, routing::get};
use tokio::net::TcpListener;
mod arpparse;
pub mod assets;
mod dhcpparse;
mod route;
mod utils;
use std::io;
use clap::{Args, Parser, Subcommand};
use wakey_core::{DeviceFilters, DeviceQuery};
#[cfg(target_os = "linux")]
#[tokio::main]
async fn entry() -> io::Result<()> {
use crate::route::{api_router, home_2, home_2_route};
#[derive(Parser)]
#[command(name = "wakey")]
#[command(version, about = "Wakey service CLI and HTTP adapter")]
struct Cli {
#[command(subcommand)]
command: Command,
}
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());
#[derive(Subcommand)]
enum Command {
Http(HttpArgs),
Status(StatusArgs),
Leases(LeasesArgs),
Wake(WakeArgs),
Devs,
}
let port = TcpListener::bind("0.0.0.0:12012").await?;
axum::serve(port, app.into_make_service()).await?;
Ok(())
#[derive(Args)]
struct HttpArgs {
#[arg(long, default_value = "::")]
host: IpAddr,
#[arg(long, default_value_t = 12012)]
port: u16,
}
#[derive(Args)]
struct LeasesArgs {
#[arg(long)]
include_state: bool,
}
#[derive(Args)]
struct WakeArgs {
query: String,
}
#[derive(Args)]
struct StatusArgs {
query: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long = "ip")]
ips: Vec<std::net::IpAddr>,
#[arg(long = "dev")]
devs: Vec<String>,
#[arg(long = "nud")]
nuds: Vec<wakey_core::NeighborState>,
#[arg(long = "mac")]
macs: Vec<macaddr::MacAddr>,
}
fn status_args_to_query(args: StatusArgs) -> wakey_core::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,
},
}
}
#[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<()> {
let cli = Cli::parse();
match cli.command {
Command::Http(args) => {
let addr = SocketAddr::new(args.host, args.port);
wakey::serve_http_from_current_exe(addr).await?;
}
Command::Status(args) => {
let query = status_args_to_query(args);
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?
};
println!("{}", serde_json::to_string_pretty(&status)?);
}
Command::Leases(args) => {
let leases = wakey::get_leases(args.include_state).await?;
println!("{}", serde_json::to_string_pretty(&leases)?);
}
Command::Wake(args) => {
let result = wakey::wake_from_query(args.query).await?;
println!("{}", serde_json::to_string_pretty(&result)?);
}
Command::Devs => {
let devs = wakey::list_interfaces().await?;
println!("{}", serde_json::to_string_pretty(&devs)?);
}
}
Ok(())
}
+17 -51
View File
@@ -1,60 +1,31 @@
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 wakey_core::DeviceQuery;
use crate::route::status::{DeviceQuery, Filters, NamePath};
use crate::utils::query::get_ips;
use crate::route::status::NamePath;
// Smart redirect: accept IP, MAC, dev, or NUD state and redirect to /api/status accordingly
pub async fn status_smart_redirect(
Path(q): Path<String>,
) -> axum::response::Result<Redirect, impl IntoResponse> {
// 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()
},
let query: DeviceQuery = match crate::resolve_query(q).await {
Ok(query) => query,
Err(e) => {
return Err(ApiError {
error: e.to_string(),
code: StatusCode::BAD_GATEWAY,
});
}
};
match serde_html_form::to_string(query) {
Ok(e) => Ok(Redirect::to(&format!("/api/status?{e}"))),
Err(e) => Err((
StatusCode::BAD_GATEWAY,
Json(ApiError {
error: e.to_string(),
}),
)),
Err(e) => Err(ApiError {
error: e.to_string(),
code: StatusCode::BAD_GATEWAY,
}),
}
}
@@ -66,13 +37,8 @@ pub async fn status_redirect(Path(NamePath { name }): Path<NamePath>) -> Redirec
}
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(),
crate::get_ips(&name).await.map_or_else(
|e| ApiError::ise(e.to_string()).into_response(),
|ips| Json(ips).into_response(),
)
}
+7 -4
View File
@@ -1,7 +1,10 @@
use crate::utils::query::dev;
use axum::Json;
use crate::route::error::ApiError;
use axum::{Json, response::IntoResponse};
pub async fn devs_router() -> Json<Vec<String>> {
dev::devs_sorted().into()
pub async fn devs_router() -> impl IntoResponse {
match crate::list_interfaces().await {
Ok(devs) => Json(devs).into_response(),
Err(e) => ApiError::ise(e.to_string()).into_response(),
}
}
// Device listing endpoints
+14 -24
View File
@@ -1,8 +1,4 @@
use crate::{
dhcpparse::read_dhcp_leases_with_names,
route::error::ApiError,
utils::{parse::boolish_str, query::enrich_leases_with_nud_state},
};
use crate::{route::error::ApiError, utils::parse::boolish_str};
use axum::{Json, extract::Query, http::StatusCode, response::IntoResponse};
// DHCP lease endpoints
@@ -11,27 +7,21 @@ 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);
pub async fn get_dhcp_leases(
Query(DhcpLeasesQueryRaw { include_state }): Query<DhcpLeasesQueryRaw>,
) -> impl IntoResponse {
let include_state = include_state.as_deref().map(boolish_str).unwrap_or(false);
match 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(),
}),
match crate::get_leases(include_state).await {
Ok(leases) => (
StatusCode::OK,
Json(crate::compat::legacy_leases_from_domain(leases)),
)
.into_response(),
Err(e) => ApiError {
error: e.to_string(),
code: StatusCode::BAD_GATEWAY,
}
.into_response(),
}
}
+17 -5
View File
@@ -6,12 +6,24 @@ use axum::{
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct ApiError {
pub error: String,
pub struct ApiError<T: Serialize> {
#[serde(skip_serializing, skip_deserializing)]
pub code: StatusCode,
pub error: T,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(StatusCode::INTERNAL_SERVER_ERROR, Json(self)).into_response()
impl<T: Serialize> ApiError<T> {
/// [StatusCode::INTERNAL_SERVER_ERROR] shortcut
pub const fn ise(error: T) -> Self {
Self {
code: StatusCode::INTERNAL_SERVER_ERROR,
error,
}
}
}
impl<T: Serialize> IntoResponse for ApiError<T> {
fn into_response(self) -> Response {
(self.code, Json(self)).into_response()
}
}
+21 -37
View File
@@ -5,50 +5,35 @@ pub mod error;
pub mod status;
pub mod wake;
use crate::assets;
// use crate::assets;
use crate::dhcpparse::load_mac_name_cache;
use crate::route::api::ip;
use crate::route::api::status_redirect;
use crate::route::api::status_smart_redirect;
use crate::route::devs::devs_router;
use crate::route::dhcp::get_dhcp_leases;
use crate::route::error::ApiError;
use crate::route::status::get_status_json;
use crate::route::wake::wake_multi;
use axum::Json;
use axum::response::IntoResponse;
use axum::routing::post;
use axum::{Router, http::header, response::Html, routing::get};
use axum::Router;
use axum::body::Body;
use axum::http::Request;
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use std::time::Instant;
use crate::utils::route::serve_js;
async fn add_performance_header(req: Request<Body>, next: Next) -> Response {
let start = Instant::now();
let mut response = next.run(req).await;
let elapsed = start.elapsed();
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)))
if let Ok(val) = format!("work-time={}us", elapsed.as_micros()).parse() {
response.headers_mut().insert("Lda-Performance", val);
}
response
}
pub fn api_router() -> Router {
@@ -62,11 +47,10 @@ pub fn api_router() -> Router {
.route("/ips/{name}", get(ip))
.route(
"/mac-cache",
get(|| async {
match load_mac_name_cache().await {
Ok(h) => Json(h).into_response(),
Err(e) => e.to_string().into_response(),
}
get(async || match load_mac_name_cache().await {
Ok(h) => Json(h).into_response(),
Err(e) => ApiError::ise(e.to_string()).into_response(),
}),
)
.layer(middleware::from_fn(add_performance_header))
}
+15 -89
View File
@@ -1,98 +1,24 @@
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;
pub use wakey_core::{DeviceQuery, NamePath};
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>,
}
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(),
}),
pub async fn get_status_json(Query(query): Query<DeviceQuery>) -> impl IntoResponse {
match crate::get_status(query).await {
Ok(status) => (
StatusCode::OK,
Json(crate::compat::legacy_status_from_domain(status)),
)
.into_response(),
Err(error) => ApiError {
code: StatusCode::BAD_GATEWAY,
error: error
.chain()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(": "),
}
.into_response(),
}
}
// Status endpoints
+9 -104
View File
@@ -1,112 +1,17 @@
//! 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 crate::route::error::ApiError;
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 use wakey_core::WakeTarget;
pub async fn wake_multi(Json(req): Json<Vec<WakeTarget>>) -> impl IntoResponse {
match wake_multi_split(req).await {
Ok(results) => (
match crate::wake_targets(req).await {
Ok(result) => (
StatusCode::OK,
Json(WakeResult {
success: true,
result: Some(results),
..Default::default()
}),
),
Json(crate::compat::legacy_wake_from_domain(result)),
)
.into_response(),
Err(error) => {
let error = Some(format!("Error: {}", error));
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(WakeResult {
success: false,
error,
..Default::default()
}),
)
let error = format!("Error: {}", error);
ApiError::ise(error).into_response()
}
}
}
/// 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 {
// }
-10
View File
@@ -1,10 +0,0 @@
use std::io;
pub(crate) async fn exec_command<S: AsRef<std::ffi::OsStr>>(
cmd: S,
args: impl IntoIterator<Item = S>,
) -> io::Result<std::process::Output> {
let mut u = tokio::process::Command::new(cmd);
u.args(args);
u.output().await
}
-56
View File
@@ -1,56 +0,0 @@
use std::{fmt, io};
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug)]
pub enum Error {
/// Failure to resolve a host name to IPs.
DnsResolve { name: String, source: io::Error },
/// External command failed (e.g., ip neigh)
CommandFailed {
cmd: &'static str,
args: Vec<String>,
status: Option<i32>,
stderr: String,
},
/// Generic IO error fallback
Io(io::Error),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::DnsResolve { name, source } => {
write!(f, "DNS resolve failed for {name}: {source}")
}
Error::CommandFailed {
cmd,
args,
status,
stderr,
} => {
let code = status
.map(|c| c.to_string())
.unwrap_or_else(|| "signal".into());
write!(f, "{cmd} {args:?} failed (status: {code}): {stderr}",)
}
Error::Io(e) => write!(f, "IO error: {e}"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::DnsResolve { source, .. } => Some(source),
Error::Io(e) => Some(e),
_ => None,
}
}
}
impl From<io::Error> for Error {
fn from(e: io::Error) -> Self {
Error::Io(e)
}
}
-4
View File
@@ -9,14 +9,10 @@
// 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
-149
View File
@@ -1,63 +1,3 @@
/// 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
@@ -99,92 +39,3 @@ pub fn boolish_str(s: &str) -> bool {
|| (!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::*;
-45
View File
@@ -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)
}
-45
View File
@@ -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()
}
+9 -165
View File
@@ -1,184 +1,28 @@
use macaddr::MacAddr;
use crate::arpparse::{self, IpNeighLine, NUDState};
use crate::utils::{
cmd::exec_command,
error::{self, Result},
};
use std::collections::HashSet;
use anyhow::Result;
use std::net::IpAddr;
use crate::arpparse::{IpNeighLine, NUDState};
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()))
wakey_linux::devices::get_ips(machine_name).await
}
// #[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],
macs: &[macaddr::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)
wakey_linux::devices::get_neighbors(machine_names, ips, devs, state, macs).await
}
/// 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)
let ips: Vec<IpAddr> = ip.into_iter().collect();
let devs: Vec<&str> = dev.into_iter().collect();
get_macs(&[] as &[&str], &ips, &devs, state, &[]).await
}
/*
/// 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()
} */
+13 -2
View File
@@ -1,6 +1,17 @@
pub mod dev;
pub mod leases;
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 macs;
pub mod parser {
pub use wakey_core::QueryInput as QueryType;
pub async fn parse_query(q: String) -> QueryType {
wakey_linux::devices::classify_query(q).await
}
}
pub use leases::*;
pub use macs::*;
-44
View File
@@ -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())
}
-11
View File
@@ -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,
)
}
+6
View File
@@ -0,0 +1,6 @@
pub async fn wake_one(
sock: &tokio::net::UdpSocket,
t: wakey_linux::wake::CompleteWakeTarget,
) -> wakey_core::WakeTargetResult {
wakey_linux::wake::wake_one(sock, t).await
}
-69
View File
@@ -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,
}
}
}
-73
View File
@@ -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();
+2 -2
View File
@@ -1,4 +1,4 @@
import { filter_array } from "./status.js";
// import { filter_array } from "./status.js";
export const qs = new URLSearchParams(location.search);
@@ -21,7 +21,7 @@ export function setPill(kind, text) {
export function setLink(name, clear) {
const url = new URL(location.href);
if (clear) url.searchParams.forEach((_, k) => url.searchParams.delete(k)); // FUCK
if (clear) url.search = ""; // yo
/* const hasExtraFilters = filter_array.some(
(k) => url.searchParams.getAll(k).length
);
+9 -4
View File
@@ -1,7 +1,8 @@
import { elLeases } from "./dom.js";
import { rankState } from "./utils.js";
/**
*
*
* @param {{
* expires_epoch: Number
* rank?: Number
@@ -9,8 +10,8 @@ import { elLeases } from "./dom.js";
* mac: String
* nud_state?: String
* name?: String
* }[]} leases
* @returns
* }[]} leases
* @returns
*/
export function renderLeases(leases) {
if (!elLeases) return;
@@ -36,7 +37,11 @@ export function renderLeases(leases) {
let dotClass = "dot ok";
if (expired) {
dotClass = "dot bad";
} /* rank from NUDState */ else if (typeof l?.rank === "number") {
} else if (l?.nud_state) {
if (rankState(l.nud_state) >= 5) dotClass = "dot ok";
else if (rankState(l.nud_state) >= 2) dotClass = "dot warn";
else dotClass = "dot bad";
} else if (typeof l?.rank === "number") {
if (l.rank >= 5) dotClass = "dot ok";
else if (l.rank >= 2) dotClass = "dot warn";
else dotClass = "dot bad";
+1
View File
@@ -34,6 +34,7 @@ function updatePreview() {
const raw = getName(elName);
const host = extractHostLikeBackend(raw);
elPreview.textContent = host && host !== raw ? `→ ${host}` : "";
return host;
}
function pickTarget(value) {
+1 -1
View File
@@ -33,7 +33,7 @@ export async function sendWake(name) {
const j = await r.json();
if (!j.success) {
if (j.error) {
elLog.textContent = `wake error: ${j.error}`;
setPill("bad", "error");
return;
+1 -2
View File
@@ -30,8 +30,7 @@
<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
on this page to view the status.</span
><span id="preview" class="tiny"></span
><a id="permalink" href="#">permalink</a>
</div>
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "wakey-core"
version = "0.1.0"
edition = "2024"
[dependencies]
macaddr = { version = "1", features = ["serde", "serde_std"] }
serde = { version = "1", features = ["derive"] }
serde_with = { version = "3", features = ["json"] }
strum = { version = "0", features = ["derive", "strum_macros"] }
thiserror = "2"
+4
View File
@@ -0,0 +1,4 @@
pub mod model;
pub mod parse;
pub use model::*;
+244
View File
@@ -0,0 +1,244 @@
use macaddr::MacAddr;
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
use serde_with::{DisplayFromStr, OneOrMany, serde_as};
use std::{net::IpAddr, str::FromStr};
use strum::{Display, EnumString};
use crate::parse::mac;
#[skip_serializing_none]
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize)]
pub struct NeighborEntry {
pub ip: IpAddr,
pub dev: Option<String>,
#[serde(with = "mac::option_mac")]
pub mac: Option<MacAddr>,
pub state: NeighborState,
}
#[derive(
Debug, PartialEq, Eq, EnumString, Display, Clone, Copy, Hash, Serialize, Deserialize, Default,
)]
#[strum(serialize_all = "UPPERCASE", ascii_case_insensitive)]
#[serde(rename_all = "UPPERCASE")]
pub enum NeighborState {
Permanent,
Noarp,
Reachable,
Stale,
Incomplete,
Delay,
Probe,
Failed,
#[serde(other)]
#[default]
None,
}
impl NeighborState {
pub const fn as_ip_neigh_arg(self) -> &'static str {
match self {
NeighborState::Permanent => "permanent",
NeighborState::Reachable => "reachable",
NeighborState::Stale => "stale",
NeighborState::Delay => "delay",
NeighborState::Probe => "probe",
NeighborState::Incomplete => "incomplete",
NeighborState::Noarp => "noarp",
NeighborState::None => "none",
NeighborState::Failed => "failed",
}
}
pub const fn rank(self) -> u8 {
match self {
NeighborState::Permanent | NeighborState::Reachable => 5,
NeighborState::Stale => 4,
NeighborState::Delay | NeighborState::Probe | NeighborState::Incomplete => 3,
NeighborState::Noarp => 2,
NeighborState::None => 1,
NeighborState::Failed => 0,
}
}
}
impl PartialOrd for NeighborState {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for NeighborState {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.rank().cmp(&other.rank())
}
}
#[derive(Debug, Default, Clone, Hash, Deserialize, Serialize)]
pub struct DeviceQuery {
pub name: Option<String>,
#[serde(flatten)]
pub filter: DeviceFilters,
}
#[serde_as]
#[derive(Debug, Default, Clone, Hash, Serialize, Deserialize)]
pub struct DeviceFilters {
#[serde_as(as = "OneOrMany<_>")]
#[serde(default)]
pub ips: Vec<IpAddr>,
#[serde_as(as = "OneOrMany<_>")]
#[serde(default)]
pub devs: Vec<String>,
#[serde_as(as = "OneOrMany<_>")]
#[serde(default)]
pub nuds: Vec<NeighborState>,
#[serde_as(as = "OneOrMany<DisplayFromStr>")]
#[serde(default)]
pub macs: Vec<MacAddr>,
}
#[skip_serializing_none]
#[derive(Debug, Default, Serialize)]
pub struct Status<T> {
pub name: Option<String>,
pub table: Vec<T>,
pub filters: DeviceFilters,
}
#[derive(Debug, Default, Clone, Hash, Deserialize)]
pub struct NamePath {
pub name: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct DhcpLease {
pub expires_epoch: u64,
pub ip: IpAddr,
#[serde(with = "mac")]
pub mac: MacAddr,
pub name: Option<String>,
}
#[skip_serializing_none]
#[derive(Debug, Clone, Serialize)]
pub struct DhcpLeaseWithState {
#[serde(flatten)]
pub lease_line: DhcpLease,
pub nud_state: Option<NeighborState>,
}
#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, Clone, Copy, Hash, PartialEq, Eq)]
pub struct WakeTarget {
#[serde(default)]
pub ip: Option<IpAddr>,
#[serde(default, with = "mac::option_mac")]
pub mac: Option<MacAddr>,
}
impl WakeTarget {
pub const fn is_complete(&self) -> bool {
matches!(
self,
Self {
ip: Some(_),
mac: Some(_)
}
)
}
}
#[derive(Debug, Serialize, Clone)]
pub struct WakeResult {
pub result: Vec<WakeTargetResult>,
}
#[skip_serializing_none]
#[derive(Debug, Serialize, Clone, Copy)]
pub struct WakeTargetResult {
#[serde(flatten)]
pub target: WakeTarget,
pub status: WakeStatus,
}
#[derive(Debug, Serialize, Clone, Copy, Hash, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WakeStatus {
Succeed,
NonexistentAddress,
WrongSize,
Incomplete,
}
impl WakeTargetResult {
pub const fn incomplete(target: WakeTarget) -> Self {
Self {
target,
status: WakeStatus::Incomplete,
}
}
}
#[derive(Debug)]
pub enum QueryInput {
Ip(IpAddr),
Mac(MacAddr),
Dev(String),
Nud(NeighborState),
Name(String),
}
#[derive(Debug, Display, thiserror::Error)]
pub enum NeighborParseError {
IpWhere,
IpParseError(#[from] std::net::AddrParseError),
MacParseError(#[from] macaddr::ParseError),
StateWhere,
StateParseError(#[from] strum::ParseError),
}
pub fn parse_neighbor_line(s: &str) -> Result<NeighborEntry, NeighborParseError> {
let mut it = s.split_whitespace();
let ip: IpAddr = it.next().ok_or(NeighborParseError::IpWhere)?.parse()?;
let mut dev: Option<String> = None;
let mut mac: Option<MacAddr> = None;
let mut state: Option<NeighborState> = 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" => {
state = it.next().map(|st| st.parse()).transpose()?;
}
other => last_tok = Some(other),
}
}
if state.is_none()
&& let Some(st) = last_tok
{
state = Some(st.parse()?);
}
Ok(NeighborEntry {
ip,
dev,
mac,
state: state.ok_or(NeighborParseError::StateWhere)?,
})
}
impl FromStr for NeighborEntry {
type Err = NeighborParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
parse_neighbor_line(s)
}
}
+105
View File
@@ -0,0 +1,105 @@
pub fn parse_numeric_ipv4(s: &str) -> Option<std::net::IpAddr> {
let s = s.trim();
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)));
}
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)));
}
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
}
pub fn extract_host(input: &str) -> &str {
let mut s = input.trim();
if let Some(idx) = s.find("://") {
s = &s[idx + 3..];
} else if let Some(rest) = s.strip_prefix("//") {
s = rest;
}
if let Some((_, host)) = s.rsplit_once('@') {
s = host;
}
if let Some(host) = s.strip_prefix('[') {
if let Some(end) = host.find(']') {
s = &host[..end];
}
} else {
if let Some(pos) = s.find('/') {
s = &s[..pos];
}
if let Some((host, port)) = s.rsplit_once(':')
&& s.matches(':').count() == 1
&& port.chars().all(|c| c.is_ascii_digit())
{
s = host;
}
}
s.trim()
}
pub fn boolish_str(s: &str) -> bool {
let t = s.trim().to_ascii_lowercase();
if t.is_empty() {
return true;
}
matches!(t.as_str(), "1" | "true" | "yes" | "on" | "y")
|| (!matches!(t.as_str(), "0" | "false" | "no" | "off" | "n")
&& t.parse::<u64>().map(|n| n != 0).unwrap_or(false))
}
pub mod mac {
use macaddr::MacAddr;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S>(m: &MacAddr, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&m.to_string())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<MacAddr, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
s.parse().map_err(serde::de::Error::custom)
}
pub mod option_mac {
use macaddr::MacAddr;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S>(m: &Option<MacAddr>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match m {
Some(mac) => serializer.serialize_some(&mac.to_string()),
None => serializer.serialize_none(),
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<MacAddr>, D::Error>
where
D: Deserializer<'de>,
{
let s = Option::<String>::deserialize(deserializer)?;
s.map(|x| x.parse())
.transpose()
.map_err(serde::de::Error::custom)
}
}
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "wakey-linux"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1"
futures = "0"
macaddr = { version = "1", features = ["serde", "serde_std"] }
serde_json = "1"
tokio = { version = "1", features = ["fs", "net", "rt", "sync"] }
wakey-core = { path = "../wakey-core" }
[dependencies.lda-ipjs]
path = "../ipjs"
registry = "gitea"
version = "*"
features = ["experimental-nl"]
+230
View File
@@ -0,0 +1,230 @@
use anyhow::{Context, Result};
use futures::future::try_join_all;
use std::collections::HashSet;
use std::net::IpAddr;
use wakey_core::{DeviceQuery, NeighborEntry, NeighborState, QueryInput, parse};
use lda_ipjs::subcommands::neighbor;
pub async fn get_ips(machine_name: &str) -> Result<impl Iterator<Item = IpAddr>> {
Ok(tokio::net::lookup_host((machine_name, 0))
.await
.with_context(|| format!("DNS resolve failed for {machine_name}"))?
.map(|c| c.ip()))
}
pub async fn get_neighbors(
machine_names: &[impl AsRef<str>],
ips: &[IpAddr],
devs: &[impl AsRef<str>],
state: &[NeighborState],
macs: &[macaddr::MacAddr],
) -> Result<Vec<NeighborEntry>> {
let resolved_ips: HashSet<IpAddr> = if !machine_names.is_empty() {
try_join_all(machine_names.iter().map(|n| get_ips(n.as_ref())))
.await?
.into_iter()
.flatten()
.collect()
} else {
HashSet::new()
};
let ip_filter: Vec<IpAddr> = if ips.is_empty() && resolved_ips.is_empty() {
vec![]
} else if ips.is_empty() {
resolved_ips.into_iter().collect()
} else if resolved_ips.is_empty() {
ips.iter().map(|ip| ip.to_canonical()).collect()
} else {
ips.iter()
.map(|ip| ip.to_canonical())
.filter(|ip| resolved_ips.contains(ip))
.collect()
};
let nud_filter: Vec<neighbor::NUDState> = state.iter().copied().map(to_ipjs_state).collect();
let dev_strs: Vec<&str> = devs.iter().map(AsRef::as_ref).collect();
#[cfg(unix)]
{
let results = neighbor::nl::get(&ip_filter, &dev_strs, &nud_filter, macs)
.await
.context("rtnetlink failed")?
.into_iter()
.map(map_neighbor_item)
.collect();
Ok(results)
}
#[cfg(not(unix))]
{
let mut results = Vec::new();
if ip_filter.is_empty() {
let dev = dev_strs.first().copied();
results = neighbor::get_with_backend(neighbor::Backend::Json, None, dev, &nud_filter)
.await
.context("ip -j neigh failed")?
.into_iter()
.map(map_neighbor_item)
.collect();
} else {
for ip in &ip_filter {
let dev = dev_strs.first().copied();
let mut rows = neighbor::get_with_backend(
neighbor::Backend::Json,
Some(*ip),
dev,
&nud_filter,
)
.await
.with_context(|| format!("ip -j neigh failed for {ip}"))?
.into_iter()
.map(map_neighbor_item)
.collect::<Vec<_>>();
results.append(&mut rows);
}
}
if !dev_strs.is_empty() {
results.retain(|row| row.dev.as_deref().is_some_and(|d| dev_strs.contains(&d)));
}
if !macs.is_empty() {
let mac_set: HashSet<macaddr::MacAddr> = macs.iter().copied().collect();
results.retain(|row| row.mac.is_some_and(|m| mac_set.contains(&m)));
}
if !ip_filter.is_empty() {
let ip_set: HashSet<IpAddr> = ip_filter.iter().copied().collect();
results.retain(|row| ip_set.contains(&row.ip));
}
Ok(results)
}
}
pub async fn query_status(query: &DeviceQuery) -> Result<Vec<NeighborEntry>> {
get_neighbors(
query.name.as_slice(),
&query.filter.ips,
&query.filter.devs,
&query.filter.nuds,
&query.filter.macs,
)
.await
}
pub async fn list_devs() -> HashSet<String> {
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() {
std::fs::metadata(e.path())
.map(|m| m.is_dir())
.unwrap_or(false)
} else {
ft.is_dir()
}
})
.unwrap_or(false)
&& let Ok(name) = e.file_name().into_string()
&& name != "lo"
&& !name.is_empty()
{
devs.insert(name);
}
}
} else if let Ok(txt) = std::fs::read_to_string("/proc/net/dev") {
for line in txt.lines().skip(2) {
if let Some((name, _rest)) = line.split_once(':') {
let n = name.trim().to_string();
if n != "lo" && !n.is_empty() {
devs.insert(n);
}
}
}
}
devs
}
tokio::task::spawn_blocking(get_dev)
.await
.unwrap_or_default()
}
pub async fn devs_sorted() -> Vec<String> {
let mut v: Vec<String> = list_devs().await.into_iter().collect();
v.sort();
v
}
pub async fn has_dev(name: &str) -> bool {
list_devs().await.contains(name)
}
pub async fn classify_query(q: String) -> QueryInput {
let s = parse::extract_host(&q);
if let Some(ip) = parse::parse_numeric_ipv4(s).or_else(|| s.parse::<IpAddr>().ok()) {
return QueryInput::Ip(ip);
}
if let Ok(mac) = s.parse::<macaddr::MacAddr>() {
return QueryInput::Mac(mac);
}
if let Ok(state) = s.parse::<NeighborState>() {
return QueryInput::Nud(state);
}
if has_dev(s).await {
return QueryInput::Dev(s.to_string());
}
QueryInput::Name(s.to_string())
}
fn to_ipjs_state(value: NeighborState) -> lda_ipjs::subcommands::neighbor::NUDState {
match value {
NeighborState::Permanent => lda_ipjs::subcommands::neighbor::NUDState::Permanent,
NeighborState::Noarp => lda_ipjs::subcommands::neighbor::NUDState::Noarp,
NeighborState::Reachable => lda_ipjs::subcommands::neighbor::NUDState::Reachable,
NeighborState::Stale => lda_ipjs::subcommands::neighbor::NUDState::Stale,
NeighborState::None => lda_ipjs::subcommands::neighbor::NUDState::None,
NeighborState::Incomplete => lda_ipjs::subcommands::neighbor::NUDState::Incomplete,
NeighborState::Delay => lda_ipjs::subcommands::neighbor::NUDState::Delay,
NeighborState::Probe => lda_ipjs::subcommands::neighbor::NUDState::Probe,
NeighborState::Failed => lda_ipjs::subcommands::neighbor::NUDState::Failed,
}
}
fn from_ipjs_state(value: lda_ipjs::subcommands::neighbor::NUDState) -> NeighborState {
match value {
lda_ipjs::subcommands::neighbor::NUDState::Permanent => NeighborState::Permanent,
lda_ipjs::subcommands::neighbor::NUDState::Noarp => NeighborState::Noarp,
lda_ipjs::subcommands::neighbor::NUDState::Reachable => NeighborState::Reachable,
lda_ipjs::subcommands::neighbor::NUDState::Stale => NeighborState::Stale,
lda_ipjs::subcommands::neighbor::NUDState::None => NeighborState::None,
lda_ipjs::subcommands::neighbor::NUDState::Incomplete => NeighborState::Incomplete,
lda_ipjs::subcommands::neighbor::NUDState::Delay => NeighborState::Delay,
lda_ipjs::subcommands::neighbor::NUDState::Probe => NeighborState::Probe,
lda_ipjs::subcommands::neighbor::NUDState::Failed => NeighborState::Failed,
lda_ipjs::subcommands::neighbor::NUDState::Other(_) => NeighborState::None,
}
}
fn map_neighbor_item(
lda_ipjs::subcommands::neighbor::NeighborItem {
ip,
dev,
mac,
state,
}: lda_ipjs::subcommands::neighbor::NeighborItem,
) -> NeighborEntry {
NeighborEntry {
ip,
dev,
mac,
state: state
.into_iter()
.map(from_ipjs_state)
.max()
.unwrap_or(NeighborState::None),
}
}
+93
View File
@@ -0,0 +1,93 @@
use std::io::{self, ErrorKind};
use std::net::IpAddr;
use wakey_core::{DhcpLease, DhcpLeaseWithState};
const MAC_NAME_CACHE: &str = "/tmp/wakey_mac_names.json";
pub async fn load_mac_name_cache() -> io::Result<std::collections::BTreeMap<String, String>> {
match tokio::fs::read_to_string(MAC_NAME_CACHE).await {
Ok(s) => serde_json::from_str(&s).map_err(io::Error::other),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Default::default()),
Err(e) => Err(e),
}
}
async fn save_mac_name_cache(map: &std::collections::BTreeMap<String, String>) -> io::Result<()> {
let s = serde_json::to_string(map).map_err(io::Error::other)?;
let _ = tokio::fs::write(MAC_NAME_CACHE, s).await;
Ok(())
}
pub fn parse_dhcp_lease_line(line: &str) -> Option<DhcpLease> {
let mut c = line.split_whitespace();
let expires_epoch: u64 = c.next()?.parse().ok()?;
let mac = c.next()?.parse().ok()?;
let ip = c.next()?.parse().ok()?;
let name = c.next().filter(|c| *c != "*").map(str::to_string);
Some(DhcpLease {
expires_epoch,
ip,
mac,
name,
})
}
pub async fn read_dhcp_leases() -> io::Result<Vec<DhcpLease>> {
match tokio::fs::read_to_string("/tmp/dhcp.leases").await {
Ok(file) => Ok(file.lines().filter_map(parse_dhcp_lease_line).collect()),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e),
}
}
pub async fn read_dhcp_leases_with_names() -> io::Result<Vec<DhcpLease>> {
let leases = read_dhcp_leases().await?;
let mut cache = load_mac_name_cache().await.unwrap_or_default();
let mut changed = false;
let mut leases_with_names = Vec::with_capacity(leases.len());
for mut l in leases {
let mac_s = l.mac.to_string();
if let Some(ref name) = l.name {
if cache.get(&mac_s).map(|v| v != name).unwrap_or(true) {
cache.insert(mac_s, name.clone());
changed = true;
}
} else if let Some(prev) = cache.get(&mac_s) {
l.name = Some(prev.clone());
}
leases_with_names.push(l);
}
if changed {
let _ = save_mac_name_cache(&cache).await;
}
Ok(leases_with_names)
}
pub async fn enrich_leases_with_nud_state(leases: Vec<DhcpLease>) -> Vec<DhcpLeaseWithState> {
let ips: Vec<IpAddr> = leases.iter().map(|l| l.ip).collect();
let mut map: std::collections::HashMap<IpAddr, wakey_core::NeighborState> =
std::collections::HashMap::new();
if let Ok(rows) =
crate::devices::get_neighbors(&[] 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.rank() {
*e = state
}
})
.or_insert(state);
}
}
leases
.into_iter()
.map(|lease_line| DhcpLeaseWithState {
nud_state: map.get(&lease_line.ip).copied(),
lease_line,
})
.collect()
}
+7
View File
@@ -0,0 +1,7 @@
pub mod devices;
pub mod dhcp;
pub mod wake;
pub use devices::*;
pub use dhcp::*;
pub use wake::*;
+78
View File
@@ -0,0 +1,78 @@
use std::{io, net::IpAddr};
use futures::TryFutureExt;
use macaddr::MacAddr;
use tokio::net::UdpSocket;
use wakey_core::{WakeStatus, WakeTarget, WakeTargetResult};
#[derive(Debug, Clone, Copy, Hash)]
pub struct CompleteWakeTarget {
pub ip: IpAddr,
pub mac: MacAddr,
}
impl TryFrom<WakeTarget> for CompleteWakeTarget {
type Error = ();
fn try_from(value: WakeTarget) -> Result<Self, Self::Error> {
if let WakeTarget {
ip: Some(ip),
mac: Some(mac),
} = value
{
Ok(Self { ip, mac })
} else {
Err(())
}
}
}
pub async fn wake_one(sock: &UdpSocket, t: CompleteWakeTarget) -> 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);
}
match sock.send_to(&pac, (t.ip, 9)).await {
Ok(n) if n == pac.len() => WakeTargetResult {
target: WakeTarget {
ip: Some(t.ip),
mac: Some(t.mac),
},
status: WakeStatus::Succeed,
},
Ok(_) => WakeTargetResult {
target: WakeTarget {
ip: Some(t.ip),
mac: Some(t.mac),
},
status: WakeStatus::WrongSize,
},
Err(_) => WakeTargetResult {
target: WakeTarget {
ip: Some(t.ip),
mac: Some(t.mac),
},
status: WakeStatus::NonexistentAddress,
},
}
}
pub async fn wake_many(
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 |target| match CompleteWakeTarget::try_from(target) {
Ok(target) => wake_one(&sock, target).await,
Err(()) => WakeTargetResult::incomplete(target),
});
Ok(futures::future::join_all(iter).await)
}