52 Commits
82 changed files with 3309 additions and 1582 deletions

No files matched your search

+7 -2
View File
@@ -1,5 +1,10 @@
[target.armv7-unknown-linux-musleabihf]
linker = "rust-lld"
[build] [build]
target = "armv7-unknown-linux-musleabihf" target = "armv7-unknown-linux-musleabihf"
[target.armv7-unknown-linux-musleabihf] [alias]
linker = "rust-lld" ldabr = "b -r --target=target.armv7-unknown-linux-musleabihf"
t = "test -- --nocapture --test-threads=1"
tdebug = "test -- --nocapture --test-threads=1 --show-output"
File renamed without changes.
+7 -2
View File
@@ -1,5 +1,10 @@
you can use block code as so: you should use block code to refence code, like so:
```rust ```rust
hello_world!(println); hello_world!(println);
// indented lines
``` ```
in the chat.
in the chat. Else it fucks up formatting.
We work towards clean, maintainable design. im not the best at this thing.
+3 -2
View File
@@ -1,3 +1,4 @@
{ {
"rust-analyzer.cargo.target": "armv7-unknown-linux-musleabihf" "rust-analyzer.cargo.target": "armv7-unknown-linux-musleabihf",
} // "rust-analyzer.diagnostics.disabled": ["unlinked-file"]
}
Generated
+199 -3
View File
@@ -32,6 +32,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "anyhow"
version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@@ -189,6 +195,12 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.41" version = "0.4.41"
@@ -470,6 +482,12 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]] [[package]]
name = "httparse" name = "httparse"
version = "1.10.1" version = "1.10.1"
@@ -610,6 +628,22 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "lda-ipjs"
version = "0.0.1"
dependencies = [
"anyhow",
"futures",
"macaddr",
"rtnetlink",
"serde",
"serde_json",
"serde_with",
"strum",
"thiserror 2.0.16",
"tokio",
]
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.175" version = "0.2.175"
@@ -649,6 +683,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -669,6 +713,66 @@ dependencies = [
"windows-sys", "windows-sys",
] ]
[[package]]
name = "netlink-packet-core"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4"
dependencies = [
"paste",
]
[[package]]
name = "netlink-packet-route"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ec2f5b6839be2a19d7fa5aab5bc444380f6311c2b693551cb80f45caaa7b5ef"
dependencies = [
"bitflags",
"libc",
"log",
"netlink-packet-core",
]
[[package]]
name = "netlink-proto"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128"
dependencies = [
"bytes",
"futures",
"log",
"netlink-packet-core",
"netlink-sys",
"thiserror 2.0.16",
]
[[package]]
name = "netlink-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16c903aa70590cb93691bf97a767c8d1d6122d2cc9070433deb3bbf36ce8bd23"
dependencies = [
"bytes",
"futures",
"libc",
"log",
"tokio",
]
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.1.0"
@@ -705,6 +809,12 @@ version = "4.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e"
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.1" version = "2.3.1"
@@ -767,6 +877,23 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "rtnetlink"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08fd15aa4c64c34d0b3178e45ec6dad313a9f02b193376d501668a7950264bb7"
dependencies = [
"futures",
"log",
"netlink-packet-core",
"netlink-packet-route",
"netlink-proto",
"netlink-sys",
"nix",
"thiserror 1.0.69",
"tokio",
]
[[package]] [[package]]
name = "rustc-demangle" name = "rustc-demangle"
version = "0.1.26" version = "0.1.26"
@@ -999,13 +1126,33 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.16" version = "2.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl 2.0.16",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
] ]
[[package]] [[package]]
@@ -1089,6 +1236,19 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "tokio-util"
version = "0.7.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.5.2" version = "0.5.2"
@@ -1105,6 +1265,32 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "tower-http"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"bitflags",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
]
[[package]] [[package]]
name = "tower-layer" name = "tower-layer"
version = "0.3.3" version = "0.3.3"
@@ -1159,6 +1345,12 @@ dependencies = [
"tracing-core", "tracing-core",
] ]
[[package]]
name = "unicase"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.18" version = "1.0.18"
@@ -1179,19 +1371,23 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]] [[package]]
name = "wakey" name = "wakey"
version = "0.1.1" version = "0.2.0-alpha"
dependencies = [ dependencies = [
"anyhow",
"axum", "axum",
"axum-extra", "axum-extra",
"color-eyre", "color-eyre",
"futures", "futures",
"lda-ipjs",
"macaddr", "macaddr",
"serde", "serde",
"serde_html_form",
"serde_json", "serde_json",
"serde_with", "serde_with",
"strum", "strum",
"thiserror", "thiserror 2.0.16",
"tokio", "tokio",
"tower-http",
"urlencoding", "urlencoding",
] ]
+19 -3
View File
@@ -1,21 +1,30 @@
[package] [package]
name = "wakey" name = "wakey"
version = "0.1.1" version = "0.2.0-alpha"
edition = "2024" edition = "2024"
publish = ["gitea"] publish = ["gitea"]
[dependencies] [dependencies]
anyhow = "1.0.100"
axum = { version = "0.8.4", features = ["macros"] } axum = { version = "0.8.4", features = ["macros"] }
axum-extra = { version = "0.10.1", features = ["query"] } axum-extra = { version = "0.10.1", features = ["query"] }
color-eyre = "0.6.5" color-eyre = "0.6.5"
futures = "0.3.31" futures = "0.3.31"
macaddr = { version = "1.0.1", features = ["serde", "serde_std"] } macaddr = { version = "1.0.1", features = ["serde", "serde_std"] }
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_html_form = "0.2.7"
serde_json = "1.0.143" serde_json = "1.0.143"
serde_with = { version = "3.14.0", features = ["json"] } serde_with = { version = "3.14.0", features = ["json"] }
strum = { version = "0.27.2", features = ["derive", "strum_macros"] } strum = { version = "0.27.2", features = ["derive", "strum_macros"] }
thiserror = "2.0.16" thiserror = "2.0.16"
tokio = { version = "1.47.1", features = ["fs", "process", "rt-multi-thread", "io-util"] } tokio = { version = "1.47.1", features = [
"fs",
"process",
"rt-multi-thread",
"io-util",
"macros",
] }
tower-http = { version = "0.6.8", features = ["fs"] }
urlencoding = "2.1.3" urlencoding = "2.1.3"
[profile.release] [profile.release]
@@ -24,4 +33,11 @@ strip = true
[features] [features]
default = ["very-smart-parsing"] default = ["very-smart-parsing"]
very-smart-parsing = [] # this is the a-bit-redundant parse thing that copilot made very-smart-parsing = [
] # this is the a-bit-redundant parse thing that copilot made
[workspace]
members = ["ipjs"]
[dependencies.lda-ipjs]
path = "ipjs"
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "lda-ipjs"
description = "ip -j show schemas"
version = "0.0.1"
edition = "2024"
[dependencies]
macaddr = { version = "1.0.1", features = ["serde", "serde_std"] }
strum = { version = "0.27.2", features = ["derive", "strum_macros"] }
serde_json = "1.0.143"
serde = { version = "1.0.219", features = ["derive"] }
serde_with = { version = "3.14.0", features = ["json"] }
thiserror = "2.0.16"
anyhow = "1.0.100"
tokio = { version = "1.47.1", features = ["fs", "process", "rt-multi-thread", "io-util", "macros"] }
rtnetlink = "0.18.1"
futures = "0.3.31"
+3
View File
@@ -0,0 +1,3 @@
trait IpCommand {
}
View File
Whitespace-only changes.
+20
View File
@@ -0,0 +1,20 @@
//! 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
}
+41
View File
@@ -0,0 +1,41 @@
//! ts
//!
//! deals with both ip a (addroutput) and ip l (commonoutput)
//!
//! lowk why its free but its indirection and its ass
pub mod json;
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
}
+2
View File
@@ -0,0 +1,2 @@
pub mod address;
pub mod neighbor;
+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
}
+82
View File
@@ -0,0 +1,82 @@
//! ```bash
//! ip -j n s
//! ```
//!
//! yes. this is a real call.
pub mod json;
pub mod nl;
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>,
}
+145
View File
@@ -0,0 +1,145 @@
//! this is purely experimental. im not doing ts no mo
// hallo
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};
// dont you love https://github.com/rust-netlink/rtnetlink/blob/main/examples/get_neighbours.rs
pub async fn get(
ip: Option<IpAddr>,
dev: Option<&str>,
nud: &[NUDState],
) -> anyhow::Result<Vec<NeighborItem>> {
let (gip, gdev, gnud) = (ip, dev, nud);
let (conn, handle, _) = rtnetlink::new_connection()?;
tokio::spawn(conn); // every time?
let mut neighbor_data = handle.neighbours().get().execute();
let nudset: HashSet<&NUDState> = HashSet::from_iter(gnud);
let mut ball: HashMap<u32, String> = HashMap::new();
let mut result = vec![];
'big: while let Some(neighbour_message_item) = neighbor_data.try_next().await? {
// Filter by address family
if !matches!(
neighbour_message_item.header.family,
AddressFamily::Inet | AddressFamily::Inet6
) || matches!(neighbour_message_item.header.state, NeighbourState::Noarp)
// copilot says this to match ip -j n s
{
continue 'big;
}
let state = vec![
neighbour_message_item
.header
.state
.try_into()
.unwrap_or_default(),
]; // ONE ITEM. why tf ts design json.
let mut ip = None;
let mut mac = None;
for neigh_attr in neighbour_message_item.attributes {
match neigh_attr {
NeighbourAttribute::Destination(neighbour_address) => match neighbour_address {
NeighbourAddress::Inet(ipv4_addr) => ip = Some(ipv4_addr.into()),
NeighbourAddress::Inet6(ipv6_addr) => ip = Some(ipv6_addr.into()),
_ => continue 'big,
},
NeighbourAttribute::LinkLocalAddress(items) => {
mac = match items.len() {
6 => items.first_chunk::<6>().map(|&e| MacAddr::from(e)),
8 => items.first_chunk::<8>().map(|&e| MacAddr::from(e)),
_ => continue 'big,
}
}
_ => continue,
}
}
// exquisite
let dev = if let Some(cached) = ball.get(&neighbour_message_item.header.ifindex) {
Some(cached.clone())
} else {
// Query and cache
let name = handle
.link()
.get()
.match_index(neighbour_message_item.header.ifindex)
.execute()
.try_next()
.await?
.and_then(|a| {
a.attributes.into_iter().find_map(|attr| match attr {
LinkAttribute::IfName(name) => Some(name),
_ => None,
})
});
if let Some(ref n) = name {
ball.insert(neighbour_message_item.header.ifindex, n.clone());
}
name
};
let (Some(ip), Some(dev)) = (ip, dev) else {
continue 'big;
};
{
// low block
if let Some(fip) = gip
&& fip != ip
{
continue 'big;
}
if let Some(fdev) = gdev
&& dev != fdev
{
continue 'big;
}
if !nudset.is_empty() && !nudset.contains(&state[0]) {
continue 'big;
};
}
result.push(NeighborItem {
ip,
dev: Some(dev),
mac,
state,
});
}
Ok(result) // now i need another pass to filter out the uh.
}
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), // idk
}
}
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)
}
+139
View File
@@ -0,0 +1,139 @@
use std::collections::HashSet;
use lda_ipjs::subcommands::{address, neighbor};
#[tokio::test] // ← Use tokio::test instead of manual #[tokio::main]
async fn ball1() -> anyhow::Result<()> {
let result = neighbor::nl::get(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::json::get(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::json::get(None, None, &[]).await?;
println!("Got {} entries from JSON", json_result.len());
println!("\n=== Netlink Backend ===");
let nl_result = neighbor::nl::get(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::json::get(None).await?;
println!("{cuh:#?}");
Ok(())
}
#[tokio::test]
async fn ipjas_raw() -> anyhow::Result<()> {
let cuh = address::json::_get(None).await?;
println!("{cuh:#?}");
Ok(())
}
+60
View File
@@ -0,0 +1,60 @@
# taking back my code AHHHH
this is going to be lengthy. ci is spotless but idk how it works cuz powershell. this is good but i cant afford VPN on the router; we will need to do some bullshit.
## overview
we need a:
- small ahh bin in the router.
- forwards a shell and some functions.
- may be shell calling fns maybe some unsafe c. i cant unsafe c.
- big ahh bin in the vps.
- connect to the small ahh bin thru the wide ass internet.
- forwards some APIs.
- static site
- now need no hosts; apis can be called anywhere anyway.
- do offer these in the big ahh bins tho lets not miss out some shit.
- automation
- gitea
- on the vps or the uh idk.
- ABSOLUTELY can auto build main:latest every day / other day.
- and releases.
- router
- a script to pull the tar. from actions artifacts or from releases. (lua or shell yo)
- a script for the bin and the init.d scripts for these two.
- vps
- a systemd service (ima use ai for ts ngl)
- an auto updater. maybe we overkill and dnf. maybe go easy and git clone + cargo install. maybe we pull from release or nightly. idk
i think idk
## router
### current
everything.
we have some functions that call ip neigh a bajillion times.
we have some other functions that read the same file/folder in /sys or sum shit.
we have a function that broadcast to a set ahh address.
we have some routes; and some data structures acting as schema for them ahh. we dont do posts with json but gets with query strings for some ahh reasons.
these are carryover from the early days.
also from the early days are the js.
static was 100% ai with 0 intervention.
we have a search bar of sort that hooks through a smart endpoint that searches for everything.
we have some bullshit. and two buttons to check and wake. that pings `/api/smart/{query}` and `/wake?name=query`. why.
we have the result of the wake and or status and the errors in the first text box.
the list of cliets with names connected to the router the second box.
thats cool.
### changes?
idk changes.
+25 -21
View File
@@ -6,34 +6,34 @@ This folder has small helpers for build, CI, and router install. Keep it simple;
1. Start your Gitea runner on this Windows PC (if not already running): 1. Start your Gitea runner on this Windows PC (if not already running):
```powershell ```powershell
./scripts/act_runner.ps1 ./scripts/act_runner.ps1
``` ```
If first-time, it prints the exact register command. Run it once, check labels include `windows:host,self-hosted`, then rerun the script. If first-time, it prints the exact register command. Run it once, check labels include `windows:host,self-hosted`, then rerun the script.
2. Publish, tag, and push (recommended): 2. Publish, tag, and push (recommended):
```powershell ```powershell
./scripts/publish.ps1 -Tag ./scripts/publish.ps1 -Tag
``` ```
This will bump the version (if you specify one), build, ensure the runner is started, tag, and push. To also publish to the registry, add `-Publish`. This will bump the version (if you specify one), build, ensure the runner is started, tag, and push. To also publish to the registry, add `-Publish`.
3. Install on the router 3. Install on the router
Use the release asset URL from your Gitea Release page: Use the updater on the router to fetch the latest automatically:
```sh ```sh
wget -O- https://<gitea>/<owner>/<repo>/releases/download/v0.1.0/wakey-rootfs-v0.1.0-armv7-unknown-linux-musleabihf.tgz | tar -xz -C / WAKEY_HOST=git.ldlda.com WAKEY_OWNER=lda WAKEY_REPO=wakey sh /etc/ldlda_help/update_wakey.sh
chmod +x /etc/init.d/wakey && /etc/init.d/wakey enable && /etc/init.d/wakey restart ```
```
Alternatively, use the updater on the router to fetch the latest automatically: Alternatively, use the release asset URL from your Gitea Release page:
```sh ```sh
WAKEY_HOST=git.ldlda.com WAKEY_OWNER=lda WAKEY_REPO=wakey sh /etc/ldlda_help/update_wakey.sh wget -O- https://<gitea>/<owner>/<repo>/releases/download/v0.1.0/wakey-rootfs-v0.1.0-armv7-unknown-linux-musleabihf.tgz | tar -xz -C /
``` chmod +x /etc/init.d/wakey && /etc/init.d/wakey enable && /etc/init.d/wakey restart
```
## Scripts overview ## Scripts overview
@@ -41,9 +41,7 @@ This folder has small helpers for build, CI, and router install. Keep it simple;
- `dev_push.ps1` — Fast dev loop: build + upload to router. Usage: - `dev_push.ps1` — Fast dev loop: build + upload to router. Usage:
- `./scripts/dev_push.ps1 -Pass <password> [-HostName <ip>] [-RemotePath </root/.bin/wakey>] [-Restart] [-Quiet]` - `./scripts/dev_push.ps1 -Pass <password> [-HostName <ip>] [-RemotePath </root/.bin/wakey>] [-Restart] [-Quiet]`
- Uploads to `<RemotePath>.tmp` then atomically moves into place; `-Restart` restarts the service; `-Quiet` silences MOTD. - Uploads to `<RemotePath>.tmp` then atomically moves into place; `-Restart` restarts the service; `-Quiet` silences MOTD.
- `cross_build.ps1` — Simple wrapper for `cargo build` per target (default: armv7-unknown-linux-musleabihf).
- `package_rootfs.ps1` — Produces `dist/wakey-rootfs-<version>-<target>.tgz` with `/root/.bin/wakey` and `/etc/init.d/*`. - `package_rootfs.ps1` — Produces `dist/wakey-rootfs-<version>-<target>.tgz` with `/root/.bin/wakey` and `/etc/init.d/*`.
- `package.ps1` — Dev bundle with binaries + init scripts (not a rootfs layout).
- `publish.ps1` — Optional version bump + build + tag (and `cargo publish` only if you pass `-Publish`). - `publish.ps1` — Optional version bump + build + tag (and `cargo publish` only if you pass `-Publish`).
## CI (Gitea) ## CI (Gitea)
@@ -56,8 +54,8 @@ This folder has small helpers for build, CI, and router install. Keep it simple;
## Local build/install (manual path) ## Local build/install (manual path)
```powershell ```powershell
# Build (requires cross installed) # Build
./scripts/cross_build.ps1 -Release -Targets armv7-unknown-linux-musleabihf cargo build --release --target armv7-unknown-linux-musleabihf
# Package rootfs # Package rootfs
./scripts/package_rootfs.ps1 -Version v0.1.0 -Target armv7-unknown-linux-musleabihf ./scripts/package_rootfs.ps1 -Version v0.1.0 -Target armv7-unknown-linux-musleabihf
@@ -72,4 +70,10 @@ tar -xz -f /tmp/wakey.tgz -C /
chmod +x /etc/init.d/wakey && /etc/init.d/wakey enable && /etc/init.d/wakey restart chmod +x /etc/init.d/wakey && /etc/init.d/wakey enable && /etc/init.d/wakey restart
``` ```
Or you can use the script:
```powershell
./scripts/dev_push.ps1 -Pass <password>
```
That’s it. Keep the flow: start runner → tag push → install. That’s it. Keep the flow: start runner → tag push → install.
+11 -2
View File
@@ -38,7 +38,10 @@ if (-not (Test-Path $configFile)) {
} }
if ($ForceConfigure) { if ($ForceConfigure) {
Write-Host "Config not found. Initializing non-interactively (ForceConfigure)..." Write-Host "Config not found. Initializing non-interactively (ForceConfigure)..."
$configParent = Split-Path -Parent $configFile
Push-Location $configParent
& $RunnerPath register --no-interactive --config $configFile --instance $ServerUrl --token $Token --labels $Labels & $RunnerPath register --no-interactive --config $configFile --instance $ServerUrl --token $Token --labels $Labels
Pop-Location
} }
else { else {
Write-Host "Config not found. Run this manually once (note labels embed host executor on Windows):" Write-Host "Config not found. Run this manually once (note labels embed host executor on Windows):"
@@ -49,9 +52,15 @@ if (-not (Test-Path $configFile)) {
if ($Attach) { if ($Attach) {
Write-Host "Starting act_runner (attached)... Ctrl+C to stop" Write-Host "Starting act_runner (attached)... Ctrl+C to stop"
$configParent = Split-Path -Parent $configFile
Push-Location $configParent
& $RunnerPath daemon --config $configFile & $RunnerPath daemon --config $configFile
Pop-Location
} }
else { else {
Start-Process -FilePath $RunnerPath -ArgumentList @("daemon", "--config", $configFile) -WindowStyle Hidden $configParent = Split-Path -Parent $configFile
Write-Host "act_runner started." Push-Location $configParent
$proc = Start-Process -FilePath $RunnerPath -ArgumentList @("daemon", "--config", $configFile) -WindowStyle Hidden -PassThru
Pop-Location
Write-Host "act_runner started (PID=$($proc.Id))."
} }
-17
View File
@@ -1,17 +0,0 @@
# Build release binaries using cross for Linux targets.
# Requires: cross installed (cargo install cross)
param(
[string[]]$Targets = @("armv7-unknown-linux-musleabihf"),
[switch]$Release
)
$ErrorActionPreference = 'Stop'
$mode = if ($Release) { "--release" } else { "" }
foreach ($t in $Targets) {
Write-Host "Building for $t..."
cross build $mode --target $t
}
Write-Host "Done."
+94 -112
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( param(
[string]$Pass, [string]$Pass,
[string]$HostName = "192.168.100.1", [string]$HostName = "192.168.100.1",
@@ -15,141 +16,122 @@ $ErrorActionPreference = 'Stop'
try { $PSStyle.OutputRendering = 'Host' } catch {} try { $PSStyle.OutputRendering = 'Host' } catch {}
function Invoke-Ext { function Invoke-Ext {
param( param($Exe, $Arguments, $Label)
[Parameter(Mandatory = $true)][string]$Exe, $displayArgs = $Arguments.Clone()
[Parameter(Mandatory = $true)][string[]]$Args,
[Parameter(Mandatory = $true)][string]$Label
)
$displayArgs = @($Args)
for ($i = 0; $i -lt $displayArgs.Count; $i++) { for ($i = 0; $i -lt $displayArgs.Count; $i++) {
if ($displayArgs[$i] -eq '-pw' -and ($i + 1) -lt $displayArgs.Count) { $displayArgs[$i + 1] = '****' } # lowkey leaked my password 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 Write-Host ("[{0}] {1} {2}" -f $Label, $Exe, ($displayArgs -join ' ')) -ForegroundColor Cyan
$out = & $Exe @Args 2>&1
$code = $LASTEXITCODE $out = & $Exe @Arguments 2>&1
if ($code -ne 0) { if ($LASTEXITCODE -ne 0) {
Write-Error ("{0} failed ({1}):`n{2}" -f $Label, $code, ($out -join "`n")) Write-Error ("{0} failed ({1}):`n{2}" -f $Label, $LASTEXITCODE, ($out -join "`n"))
throw ("{0} failed ({1})" -f $Label, $code) throw ("{0} failed ({1})" -f $Label, $LASTEXITCODE)
} }
return $out return $out
} }
function Get-DeployScript {
param($DeployPreferred, $DeployTmp, $RemoteTmp, $RemotePath, $RestartFlag)
return @"
DEPLOY=$DeployPreferred;
if [ ! -x "`$DEPLOY" ] && [ -f $DeployTmp ]; then
sed -i "s/\r$//" $DeployTmp 2>/dev/null || true
chmod +x $DeployTmp
DEPLOY=$DeployTmp
fi
sh "`$DEPLOY" $RemoteTmp $RemotePath $RestartFlag
"@ # -replace "`r", "" -replace "`r`n", "`n"
}
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) }
$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 ----
$repoRoot = Split-Path -Parent $PSScriptRoot $repoRoot = Split-Path -Parent $PSScriptRoot
Push-Location $repoRoot Push-Location $repoRoot
try { try {
Write-Host "[build] cargo build --release --target $Target" -ForegroundColor Cyan Write-Host "[build] cargo build --release --target $Target" -ForegroundColor Cyan
cargo build --release --target $Target cargo build --release --target $Target
if ($LASTEXITCODE -ne 0) { throw "cargo build failed ($LASTEXITCODE)" } if ($LASTEXITCODE -ne 0) { throw "cargo build failed ($LASTEXITCODE)" }
$local = Join-Path $repoRoot ("target/" + $Target + "/release/" + $BinName) $localBin = Join-Path $repoRoot "target/$Target/release/$BinName"
if (-not (Test-Path $local)) { throw "binary not found: $local" } if (-not (Test-Path $localBin)) { throw "binary not found: $localBin" }
$remoteTmp = "$RemotePath.tmp" $remoteTmp = "$RemotePath.tmp"
$destTmp = "{0}@{1}:{2}" -f $User, $HostName, $remoteTmp $destTmp = "$User@${HostName}:$remoteTmp"
$localDeploy = Join-Path $repoRoot 'scripts/remote_deploy_wakey.sh' $localDeploy = Join-Path $repoRoot 'scripts/remote_deploy_wakey.sh'
$deployTmp = '/var/tmp/remote_deploy_wakey.sh' $deployTmp = '/var/tmp/remote_deploy_wakey.sh'
$destDeployTmp = "{0}@{1}:{2}" -f $User, $HostName, $deployTmp
$deployPreferred = '/root/.bin/remote_deploy_wakey.sh' $deployPreferred = '/root/.bin/remote_deploy_wakey.sh'
if ($Pass) { # Push main binary
$pscp = Get-Command pscp.exe -ErrorAction SilentlyContinue Invoke-Scp -Local $localBin -Dest $destTmp -Pass $Pass -HostKey $HostKey -Quiet:$Quiet
if ($pscp) {
$pscpArgs = @()
if ($Quiet) { $pscpArgs += '-q' }
if ($HostKey) { $pscpArgs += @('-batch', '-scp', '-hostkey', $HostKey, '-pw', $Pass, $local, $destTmp) }
else { $pscpArgs += @('-scp', '-pw', $Pass, $local, $destTmp) }
Invoke-Ext -Exe $pscp.Path -Args $pscpArgs -Label 'push'
$plink = Get-Command plink.exe -ErrorAction SilentlyContinue # Push static assets
if (Test-Path $localDeploy) { $localStatic = Join-Path $repoRoot "static"
$pscpArgsD = @() if (Test-Path $localStatic) {
if ($Quiet) { $pscpArgsD += '-q' } # Assuming RemotePath is like /root/.bin/wakey, we want /root/.bin/static
if ($HostKey) { $pscpArgsD += @('-batch', '-scp', '-hostkey', $HostKey, '-pw', $Pass, $localDeploy, $destDeployTmp) } # So we push 'static' directory to /root/.bin/
else { $pscpArgsD += @('-scp', '-pw', $Pass, $localDeploy, $destDeployTmp) } $remoteDir = (Split-Path $RemotePath -Parent) -replace '\\', '/'
Invoke-Ext -Exe $pscp.Path -Args $pscpArgsD -Label 'push-deploy' # Ensure remote dir exists (ssh mkdir -p)
} Invoke-Ssh -Cmd "mkdir -p $remoteDir" -User $User -Remote $HostName -Pass $Pass -Quiet:$Quiet
$remoteCmdCore = @'
DEPLOY=$DEPLOY_PREFERRED # SCP -r static user@host:/root/.bin/
if [ ! -x "$DEPLOY" ] && [ -f $DEPLOY_TMP ]; then # Note: pscp/scp behavior: if dest is a dir, it copies the source dir INTO it.
sed -i "s/\r$//" $DEPLOY_TMP 2>/dev/null || true Invoke-Scp -Local $localStatic -Dest "$User@${HostName}:$remoteDir/" -Pass $Pass -HostKey $HostKey -Quiet:$Quiet -Recurse
chmod +x $DEPLOY_TMP; DEPLOY=$DEPLOY_TMP
fi
sh "$DEPLOY" $REMOTE_TMP $REMOTE_PATH $DO_RESTART
'@
$remoteCmdCore = $remoteCmdCore.Replace('$DEPLOY_PREFERRED', $deployPreferred).Replace('$DEPLOY_TMP', $deployTmp).Replace('$REMOTE_TMP', $remoteTmp).Replace('$REMOTE_PATH', $RemotePath)
$remoteCmdCore = if ($Restart) { $remoteCmdCore.Replace('$DO_RESTART', '1') } else { $remoteCmdCore.Replace('$DO_RESTART', '0') }
$remoteCmdCore = ($remoteCmdCore -replace "`r", "")
$remoteCmd = "sh -lc '$remoteCmdCore'"
if ($Quiet) { $remoteCmd = "$remoteCmd >/dev/null 2>&1" }
if ($plink) {
$plinkArgs = @('-batch', '-ssh', '-pw', $Pass, "$User@$HostName", $remoteCmd)
Invoke-Ext -Exe $plink.Path -Args $plinkArgs -Label 'ssh'
}
else {
$sshArgs = @()
if ($Quiet) { $sshArgs += '-q' }
$sshArgs += @("$User@$HostName", $remoteCmd)
Invoke-Ext -Exe 'ssh' -Args $sshArgs -Label 'ssh'
}
}
else {
Write-Warning "pscp.exe not found. Falling back to scp (you may be prompted for a password)."
$scpArgs = @('-O')
if ($Quiet) { $scpArgs += '-q' }
$scpArgs += @($local, $destTmp)
Invoke-Ext -Exe 'scp' -Args $scpArgs -Label 'push'
if (Test-Path $localDeploy) {
$scpArgsD = @('-O')
if ($Quiet) { $scpArgsD += '-q' }
$scpArgsD += @($localDeploy, $destDeployTmp)
Invoke-Ext -Exe 'scp' -Args $scpArgsD -Label 'push-deploy'
}
$remoteCmdCore = @'
DEPLOY=$DEPLOY_PREFERRED
if [ ! -x "$DEPLOY" ] && [ -f $DEPLOY_TMP ]; then
sed -i "s/\r$//" $DEPLOY_TMP 2>/dev/null || true
chmod +x $DEPLOY_TMP; DEPLOY=$DEPLOY_TMP
fi
sh "$DEPLOY" $REMOTE_TMP $REMOTE_PATH $DO_RESTART
'@
$remoteCmdCore = $remoteCmdCore.Replace('$DEPLOY_PREFERRED', $deployPreferred).Replace('$DEPLOY_TMP', $deployTmp).Replace('$REMOTE_TMP', $remoteTmp).Replace('$REMOTE_PATH', $RemotePath)
$remoteCmdCore = if ($Restart) { $remoteCmdCore.Replace('$DO_RESTART', '1') } else { $remoteCmdCore.Replace('$DO_RESTART', '0') }
$remoteCmdCore = ($remoteCmdCore -replace "`r", "")
$remoteCmd = "sh -lc '$remoteCmdCore'"
if ($Quiet) { $remoteCmd = "$remoteCmd >/dev/null 2>&1" }
$sshArgs = @()
if ($Quiet) { $sshArgs += '-q' }
$sshArgs += @("$User@$HostName", $remoteCmd)
Invoke-Ext -Exe 'ssh' -Args $sshArgs -Label 'ssh'
}
} }
else {
$scpArgs = @('-O') # Push deploy helper if exists
if ($Quiet) { $scpArgs += '-q' } if (Test-Path $localDeploy) {
$scpArgs += @($local, $destTmp) Invoke-Scp -Local $localDeploy -Dest "$User@${HostName}:$deployTmp" -Pass $Pass -HostKey $HostKey -Quiet:$Quiet
Invoke-Ext -Exe 'scp' -Args $scpArgs -Label 'push'
if (Test-Path $localDeploy) {
$scpArgsD = @('-O')
if ($Quiet) { $scpArgsD += '-q' }
$scpArgsD += @($localDeploy, $destDeployTmp)
Invoke-Ext -Exe 'scp' -Args $scpArgsD -Label 'push-deploy'
}
$remoteCmdCore = @'
DEPLOY=$DEPLOY_PREFERRED
if [ ! -x "$DEPLOY" ] && [ -f $DEPLOY_TMP ]; then sed -i "s/\r$//" $DEPLOY_TMP 2>/dev/null || true; chmod +x $DEPLOY_TMP; DEPLOY=$DEPLOY_TMP; fi
sh "$DEPLOY" $REMOTE_TMP $REMOTE_PATH $DO_RESTART
'@
$remoteCmdCore = $remoteCmdCore.Replace('$DEPLOY_PREFERRED', $deployPreferred).Replace('$DEPLOY_TMP', $deployTmp).Replace('$REMOTE_TMP', $remoteTmp).Replace('$REMOTE_PATH', $RemotePath)
$remoteCmdCore = if ($Restart) { $remoteCmdCore.Replace('$DO_RESTART', '1') } else { $remoteCmdCore.Replace('$DO_RESTART', '0') }
$remoteCmdCore = ($remoteCmdCore -replace "`r", "")
$remoteCmd = "sh -lc '$remoteCmdCore'"
if ($Quiet) { $remoteCmd = "$remoteCmd >/dev/null 2>&1" }
$sshArgs = @()
if ($Quiet) { $sshArgs += '-q' }
$sshArgs += @("$User@$HostName", $remoteCmd)
Invoke-Ext -Exe 'ssh' -Args $sshArgs -Label 'ssh'
} }
# Build and run remote deploy command
$restartFlag = $(if ($Restart) { '1' } else { '0' })
$script = Get-DeployScript $deployPreferred $deployTmp $remoteTmp $RemotePath $restartFlag
$remoteCmd = "sh -lc '$($script -replace "`r",'')'"
if ($Quiet) { $remoteCmd += " >/dev/null 2>&1" }
Invoke-Ssh -Cmd $remoteCmd -User $User -Remote $HostName -Pass $Pass -Quiet:$Quiet
Write-Host "done ✔" -ForegroundColor Green Write-Host "done ✔" -ForegroundColor Green
} }
finally { finally {
+19 -12
View File
@@ -7,18 +7,25 @@
# Default-Stop: # Default-Stop:
# Short-Description: Pre-flight Tailscale update # Short-Description: Pre-flight Tailscale update
START=50 # Run before Tailscale (typically START~80) START=50 # Run before Tailscale (typically START~80)
USE_PROCD=1
start_service() { start() {
procd_open_instance LOGFILE=/var/log/update_tailscale.log
procd_set_param command /etc/ldlda_help/update_tailscale.sh MAX_RETRIES=10
procd_set_param respawn 0 0 0 RETRY_COUNT=0
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
stop_service() { # shellcheck disable=SC2016
: # one-shot 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 &
} }
+19 -19
View File
@@ -1,23 +1,23 @@
#!/bin/sh /etc/rc.common #!/bin/sh /etc/rc.common
START=66
START=66 # Run after the resolvconf swap start() {
USE_PROCD=1 LOGFILE=/var/log/update_wakey.log
MAX_RETRIES=10
RETRY_COUNT=0
start_service() { # shellcheck disable=SC2016
procd_open_instance sh -c '
procd_set_param command /etc/ldlda_help/update_wakey.sh while [ "$RETRY_COUNT" -lt "$MAX_RETRIES" ]; do
procd_set_param respawn 0 0 0 if /etc/ldlda_help/update_wakey.sh; then
procd_set_param stdout 1 echo "[update_wakey] Success at $(date)" >>"$LOGFILE"
procd_set_param stderr 1 exit 0
procd_close_instance 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 &
} }
stop_service() {
: # one-shot
}
# Test manually:
# chmod +x /etc/init.d/update_wakey
# /etc/init.d/update_wakey start
# Or enable on boot:
# /etc/init.d/update_wakey enable
+7 -7
View File
@@ -8,14 +8,14 @@ NAME=wakey
BIN=/root/.bin/wakey BIN=/root/.bin/wakey
start_service() { start_service() {
procd_open_instance procd_open_instance
procd_set_param command "$BIN" procd_set_param command "$BIN"
procd_set_param respawn 5 1 0 procd_set_param respawn 5 1 0
procd_set_param stdout 1 procd_set_param stdout 1
procd_set_param stderr 1 procd_set_param stderr 1
procd_close_instance procd_close_instance
} }
stop_service() { stop_service() {
: # procd manages the process; nothing to do here : # procd manages the process; nothing to do here
} }
+3 -3
View File
@@ -6,7 +6,7 @@ pids="$(pidof wakey 2>/dev/null)"
# 2) Fallback: ps | awk (avoid matching awk/grep themselves) # 2) Fallback: ps | awk (avoid matching awk/grep themselves)
if [ -z "$pids" ]; then if [ -z "$pids" ]; then
pids="$(ps w 2>/dev/null | awk '/\/\.bin\/wakey/ && $0 !~ /awk/ {print $1}')" pids="$(ps w 2>/dev/null | awk '/\/\.bin\/wakey/ && $0 !~ /awk/ {print $1}')"
fi fi
[ -n "$pids" ] || exit 0 [ -n "$pids" ] || exit 0
@@ -18,8 +18,8 @@ kill -TERM "$pids" 2>/dev/null || true
usleep 250000 # uhhh sleep is stupid usleep 250000 # uhhh sleep is stupid
remain="" remain=""
for p in $pids; do for p in $pids; do
kill -0 "$p" 2>/dev/null && remain="$remain $p" kill -0 "$p" 2>/dev/null && remain="$remain $p"
done done
[ -z "$remain" ] || kill -KILL "$remain" 2>/dev/null || true [ -z "$remain" ] || kill -KILL "$remain" 2>/dev/null || true
exit 0 exit 0
+11 -7
View File
@@ -3,27 +3,31 @@
set -e set -e
# 0th step from lda is to move the pre assed resolv.conf to the real resolv.conf # 0th step from lda is to move the pre assed resolv.conf to the real resolv.conf
if [ -f /tmp/resolv.conf ]; then if [ -f /tmp/resolv.conf ]; then
cp -af /tmp/resolv.conf /etc/resolv.conf cp -af /tmp/resolv.conf /etc/resolv.conf
elif [ -f /rom/etc/resolv.conf ]; then elif [ -f /rom/etc/resolv.conf ]; then
cp -af /rom/etc/resolv.conf /etc/resolv.conf cp -af /rom/etc/resolv.conf /etc/resolv.conf
fi
if [ -x "$(which tailscale)" ]; then
tailscale update
exit 0
fi fi
# 1. Fetch the latest version number from Tailscale's site # 1. Fetch the latest version number from Tailscale's site
LATEST=$(curl -s https://pkgs.tailscale.com/stable/ | grep -Eo 'tailscale_[0-9\.]+_arm.tgz' | head -n1) LATEST=$(curl -s https://pkgs.tailscale.com/stable/ | grep -Eo 'tailscale_[0-9\.]+_arm.tgz' | head -n1)
[ -z "$LATEST" ] && { [ -z "$LATEST" ] && {
echo "❌ Couldn't find latest ARM binary." echo "❌ Couldn't find latest ARM binary."
exit 1 exit 1
} }
URL="https://pkgs.tailscale.com/stable/${LATEST}" URL="https://pkgs.tailscale.com/stable/${LATEST}"
cd /var/tmp cd /var/tmp
if [ -f "$LATEST" ]; then if [ -f "$LATEST" ]; then
echo "file exists" echo "file exists"
rm "$LATEST" rm "$LATEST"
fi fi
echo "Downloading $LATEST..." echo "Downloading $LATEST..."
+92 -84
View File
@@ -15,114 +15,122 @@
set -eu set -eu
# reboot == new file.
FLAG=/var/wakey_updated.flag
ARCH=${WAKEY_ARCH:-armv7-unknown-linux-musleabihf} ARCH=${WAKEY_ARCH:-armv7-unknown-linux-musleabihf}
TMPDIR="/var/tmp" TMPDIR="/var/tmp"
TMPFILE="$TMPDIR/wakey-rootfs.$$.$ARCH.tgz" TMPFILE="$TMPDIR/wakey-rootfs.$$.$ARCH.tgz"
STAGING="$TMPDIR/wakey-rootfs.$$.$ARCH" STAGING="$TMPDIR/wakey-rootfs.$$.$ARCH"
log() { echo "[update_wakey] $*"; } log() { echo "[update_wakey] $*"; }
fail() { echo "[update_wakey] ERROR: $*" >&2; exit 1; } fail() {
echo "[update_wakey] ERROR: $*" >&2
exit 1
}
http_get() { http_get() {
# http_get <url> # http_get <url>
if command -v uclient-fetch >/dev/null 2>&1; then if command -v uclient-fetch >/dev/null 2>&1; then
# uclient-fetch supports -O -, write to stdout # uclient-fetch supports -O -, write to stdout
uclient-fetch -O - "$1" 2>/dev/null || return 1 uclient-fetch -O - "$1" 2>/dev/null || return 1
elif command -v wget >/dev/null 2>&1; then elif command -v wget >/dev/null 2>&1; then
# shellcheck disable=SC2086 # shellcheck disable=SC2086
wget ${WAKEY_INSECURE:+--no-check-certificate} -qO- "$1" || return 1 wget ${WAKEY_INSECURE:+--no-check-certificate} -qO- "$1" || return 1
elif command -v curl >/dev/null 2>&1; then elif command -v curl >/dev/null 2>&1; then
if [ -n "${WAKEY_INSECURE:-}" ]; then if [ -n "${WAKEY_INSECURE:-}" ]; then
curl -fsSL -k "$1" || return 1 curl -fsSL -k "$1" || return 1
else else
curl -fsSL "$1" || return 1 curl -fsSL "$1" || return 1
fi fi
else else
return 2 return 2
fi fi
} }
fetch() { fetch() {
# fetch <url> <out> # fetch <url> <out>
if command -v uclient-fetch >/dev/null 2>&1; then if command -v uclient-fetch >/dev/null 2>&1; then
uclient-fetch -O "$2" "$1" || return 1 uclient-fetch -O "$2" "$1" || return 1
elif command -v wget >/dev/null 2>&1; then elif command -v wget >/dev/null 2>&1; then
wget ${WAKEY_INSECURE:+--no-check-certificate} -O "$2" "$1" || return 1 wget ${WAKEY_INSECURE:+--no-check-certificate} -O "$2" "$1" || return 1
elif command -v curl >/dev/null 2>&1; then elif command -v curl >/dev/null 2>&1; then
if [ -n "${WAKEY_INSECURE:-}" ]; then if [ -n "${WAKEY_INSECURE:-}" ]; then
curl -fSL -k -o "$2" "$1" || return 1 curl -fSL -k -o "$2" "$1" || return 1
else else
curl -fSL -o "$2" "$1" || return 1 curl -fSL -o "$2" "$1" || return 1
fi fi
else else
return 2 return 2
fi fi
} }
latest_asset_url() { latest_asset_url() {
# prints URL to stdout, returns 0 on success (public release only) # prints URL to stdout, returns 0 on success (public release only)
HOST=$1 OWNER=$2 REPO=$3 ARCH=$4 HOST=$1 OWNER=$2 REPO=$3 ARCH=$4
API="https://$HOST/api/v1/repos/$OWNER/$REPO/releases/latest" API="https://$HOST/api/v1/repos/$OWNER/$REPO/releases/latest"
RESP=$(http_get "$API") || return 1 RESP=$(http_get "$API") || return 1
# Extract the first browser_download_url ending with our ARCH .tgz (BusyBox-friendly) # Extract the first browser_download_url ending with our ARCH .tgz (BusyBox-friendly)
echo "$RESP" \ echo "$RESP" |
| grep -o '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*'"$ARCH"'\.tgz"' \ grep -o '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*'"$ARCH"'\.tgz"' |
| head -n1 \ head -n1 |
| cut -d '"' -f 4 cut -d '"' -f 4
} }
main() { main() {
URL="${WAKEY_TGZ_URL:-}" [ -f "$FLAG" ] && return
if [ -z "$URL" ]; then URL="${WAKEY_TGZ_URL:-}"
HOST=${WAKEY_HOST:-git.ldlda.com} if [ -z "$URL" ]; then
OWNER=${WAKEY_OWNER:-lda} HOST=${WAKEY_HOST:-git.ldlda.com}
REPO=${WAKEY_REPO:-wakey} OWNER=${WAKEY_OWNER:-lda}
if [ -n "${WAKEY_VERSION:-}" ]; then REPO=${WAKEY_REPO:-wakey}
FILE="wakey-rootfs-${WAKEY_VERSION}-${ARCH}.tgz" if [ -n "${WAKEY_VERSION:-}" ]; then
URL="https://$HOST/$OWNER/$REPO/releases/download/${WAKEY_VERSION}/$FILE" FILE="wakey-rootfs-${WAKEY_VERSION}-${ARCH}.tgz"
else URL="https://$HOST/$OWNER/$REPO/releases/download/${WAKEY_VERSION}/$FILE"
URL=$(latest_asset_url "$HOST" "$OWNER" "$REPO" "$ARCH") || fail "unable to resolve latest asset URL" else
[ -n "$URL" ] || fail "no asset URL found for arch $ARCH" URL=$(latest_asset_url "$HOST" "$OWNER" "$REPO" "$ARCH") || fail "unable to resolve latest asset URL"
fi [ -n "$URL" ] || fail "no asset URL found for arch $ARCH"
fi fi
fi
log "fetching $URL" log "fetching $URL"
if ! fetch "$URL" "$TMPFILE"; then if ! fetch "$URL" "$TMPFILE"; then
fail "download failed" fail "download failed"
fi fi
log "installing" log "installing"
mkdir -p "$TMPDIR" "$STAGING" mkdir -p "$TMPDIR" "$STAGING"
tar -xz -f "$TMPFILE" -C "$STAGING" || fail "extract failed" tar -xz -f "$TMPFILE" -C "$STAGING" || fail "extract failed"
# Ensure execute bits on staged files we know should be executable # Ensure execute bits on staged files we know should be executable
for f in \ for f in \
"$STAGING/etc/init.d/"* \ "$STAGING/etc/init.d/"* \
"$STAGING/etc/ldlda_help/"*.sh \ "$STAGING/etc/ldlda_help/"*.sh \
"$STAGING/root/.bin/wakey" \ "$STAGING/root/.bin/wakey" \
"$STAGING/root/.bin/kill_wakey.sh" \ "$STAGING/root/.bin/kill_wakey.sh" \
"$STAGING/root/.bin/remote_deploy_wakey.sh"; do "$STAGING/root/.bin/remote_deploy_wakey.sh"; do
[ -e "$f" ] && chmod +x "$f" 2>/dev/null || true [ -e "$f" ] && chmod +x "$f" 2>/dev/null || true
done done
# Normalize line endings for shell scripts (avoid CRLF issues on OpenWrt) # Normalize line endings for shell scripts (avoid CRLF issues on OpenWrt)
for f in \ for f in \
"$STAGING/etc/init.d/"* \ "$STAGING/etc/init.d/"* \
"$STAGING/etc/ldlda_help/"*.sh; do "$STAGING/etc/ldlda_help/"*.sh \
[ -f "$f" ] && sed -i 's/\r$//' "$f" 2>/dev/null || true "$STAGING/root/.bin/"*.sh; do
done [ -f "$f" ] && sed -i 's/\r$//' "$f" 2>/dev/null || true
done
# Copy staged tree into / # Copy staged tree into /
tar -C "$STAGING" -cf - . | tar -C / -xpf - || fail "install copy failed" tar -C "$STAGING" -cf - . | tar -C / -xpf - || fail "install copy failed"
rm -f "$TMPFILE" rm -f "$TMPFILE"
rm -rf "$STAGING" rm -rf "$STAGING"
if [ -f /etc/init.d/wakey ]; then if [ -f /etc/init.d/wakey ]; then
/etc/init.d/wakey enable || true /etc/init.d/wakey enable || true
/etc/init.d/wakey restart || /etc/init.d/wakey start || true /etc/init.d/wakey restart || /etc/init.d/wakey start || true
fi fi
log "done" touch "$FLAG"
log "done"
} }
main "$@" main "$@"
+212
View File
@@ -0,0 +1,212 @@
"thanks chatgpt"
import os
import textwrap
from abc import ABC, abstractmethod
from collections.abc import Iterable
from pathlib import Path
from typing import Callable
root = Path(__file__).parent.parent
static = root / "static"
src = root / "src"
out_rs = src / "assets.rs"
assert 'name = "wakey"' in (root / "Cargo.toml").read_text(encoding="utf-8"), (
"uhh how do i explain this"
)
def sanitize(name: str) -> str:
# Valid Rust identifiers: letters, digits, underscores; no starting digit
s = "".join(c if c.isalnum() else "_" for c in name)
if s and s[0].isdigit():
s = "_" + s
return s
def indent(text: str, n: int) -> str:
pad = " " * n
return textwrap.indent(text, pad)
class RsAsset(ABC):
path: Path
class RsAssetWithName(ABC):
name: str
@staticmethod
@abstractmethod
def name_gen(path: Path) -> str:
"helper"
class RsAssetFile(RsAsset, RsAssetWithName):
__match_args__ = ("path", "name")
def __init__(self, path: Path, name: str | None = None):
self.path = path
if name is None:
self.name: str = RsAssetFile.name_gen(path)
else:
self.name = name
def path_relative_to(self, path: Path):
"helper"
return self.path.relative_to(path, walk_up=True).as_posix()
@staticmethod
def plain_template(const_name: str, relative_path: str):
return f'pub const {const_name}: &str = include_str!("{relative_path}");'
@staticmethod
def macroed_template(const_name: str, relative_path: str):
return f'file {const_name} "{relative_path}"'
@staticmethod
def name_gen(path: Path) -> str:
return sanitize(path.name).upper()
class RsAssetModule(RsAsset, RsAssetWithName):
__match_args__ = ("path", "name")
def __init__(self, path: Path, name: str | None = None):
self.path = path
if name is None:
self.name: str = RsAssetModule.name_gen(path)
else:
self.name = name
@staticmethod
def plain_template(sanitized_name: str, body: str):
"braindead"
indented_body = indent(body, 4)
return f"pub mod {sanitized_name} {{\n{indented_body}\n}}"
@staticmethod
def macroed_template(sanitized_name: str, body: str):
indented_body = indent(body, 4)
return f"folder {sanitized_name} {{\n{indented_body}\n}}"
@staticmethod
def process_body(path: Path, renderer: Callable[[RsAsset], str]):
"here just cuz. Path has to be a folder... so idk"
body = RsAssetModule.iterate_assets(path)
return "\n".join(map(renderer, body))
@staticmethod
def iterate_assets(path: Path) -> "Iterable[RsAssetFile | RsAssetModule]":
subs = []
for f in path.iterdir():
if f.is_file():
yield RsAssetFile(f)
elif f.is_dir():
subs.append(RsAssetModule(f))
yield from subs
@staticmethod
def name_gen(path: Path) -> str:
return sanitize(path.name).lower()
# i meant plain
def render_pain(ass: RsAsset) -> str:
"""
fym i have to deal with all RsAsset subclasses.
ts like a matrix of ahh.
"""
match ass:
case RsAssetRoot(path):
body = RsAssetModule.process_body(path, render_pain)
return RsAssetRoot.plain_template(body)
case RsAssetModule(path, name):
body = RsAssetModule.process_body(path, render_pain)
return RsAssetModule.plain_template(name, body)
case RsAssetFile(path, name) as file:
return RsAssetFile.plain_template(name, file.path_relative_to(src))
case _:
raise TypeError("who are you?")
def render_macro(ass: RsAsset) -> str:
"""
ts is ridiculous
"""
match ass:
case RsAssetRoot(path):
body = RsAssetModule.process_body(path, render_macro)
return RsAssetRoot.macroed_template(body)
case RsAssetModule(path, name):
body = RsAssetModule.process_body(path, render_macro)
return RsAssetModule.macroed_template(name, body)
case RsAssetFile(path, name) as file:
return RsAssetFile.macroed_template(name, file.path_relative_to(src))
case _:
raise TypeError("who are you?")
# region shit ass
# im deleting this code because its so ass
# endregion
lda_macro = """
macro_rules! hehe {
// Folder
(folder $name:ident { $($children:tt)* } $($rest:tt)*) => {
pub mod $name {
hehe!{$($children)*}
}
hehe!{$($rest)*}
};
// File
(file $name:ident $file:literal $($rest:tt)*) => {
pub const $name: &str = include_str!($file);
hehe!{$($rest)*}
};
// Base case
() => {};
}
"""
header = "// generated with ./scripts/map_static.py"
class RsAssetRoot(RsAsset):
__match_args__ = ("path",)
@staticmethod
def plain_template(body: str):
return f"""{header}
{body}
"""
@staticmethod
def macroed_template(body: str):
indented_body = indent(body, 4)
return f"""{header}
{lda_macro}
hehe! {{
{indented_body}
}}
"""
def __init__(self, path: Path) -> None:
os.scandir(path) # is you a dir?
self.path = path
asset_root = RsAssetRoot(static)
# print(render_pain(asset_root))
# print(render_macro(asset_root))
# rs_code = render_pain(asset_root)
rs_code = render_macro(asset_root)
# generate
out_rs.write_text(rs_code, encoding="utf-8")
print(f"written to {out_rs.relative_to(Path.cwd(), walk_up=True)}")
-53
View File
@@ -1,53 +0,0 @@
# Package a release tarball with binaries and init scripts.
# Usage: ./scripts/package.ps1 -Version 0.1.0 -OutDir dist
param(
[Parameter(Mandatory = $true)][string]$Version,
[string]$OutDir = "dist",
[string[]]$Targets = @("armv7-unknown-linux-musleabihf")
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $MyInvocation.MyCommand.Path | Split-Path -Parent
$dist = Join-Path $root $OutDir
New-Item -ItemType Directory -Force -Path $dist | Out-Null
$pkgName = "wakey-$Version"
$pkgDir = Join-Path $dist $pkgName
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $pkgDir
New-Item -ItemType Directory -Force -Path $pkgDir | Out-Null
# Copy LICENSE/README if present
if (Test-Path "$root/README.md") { Copy-Item "$root/README.md" $pkgDir }
if (Test-Path "$root/LICENSE") { Copy-Item "$root/LICENSE" $pkgDir }
# Binaries per target
foreach ($t in $Targets) {
$binName = if ($t -like "*-windows-*") { "wakey.exe" } else { "wakey" }
$src = Join-Path $root "target/$t/release/$binName"
if (-not (Test-Path $src)) { throw "Missing binary: $src" }
$tDir = Join-Path $pkgDir "bin/$t"
New-Item -ItemType Directory -Force -Path $tDir | Out-Null
Copy-Item $src $tDir
}
# Init scripts
$initDir = Join-Path $pkgDir "init"
New-Item -ItemType Directory -Force -Path $initDir | Out-Null
Copy-Item "$root/scripts/init" $initDir -Recurse
# Systemd service (for Linux hosts)
Copy-Item "$root/scripts/systemd" $initDir -Recurse -ErrorAction SilentlyContinue
# Tarball
Push-Location $dist
if (Get-Command tar -ErrorAction SilentlyContinue) {
tar -czf "$pkgName.tgz" "$pkgName"
}
else {
# Fallback to zip on Windows
Compress-Archive -Path "$pkgName" -DestinationPath "$pkgName.zip" -Force
}
Pop-Location
Write-Host "Packaged: $dist/$pkgName.(tgz|zip)"
+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 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 # Copy all OpenWrt init scripts present in repo
Get-ChildItem (Join-Path $root 'scripts/init/openwrt') -File | ForEach-Object { Get-ChildItem (Join-Path $root 'scripts/init/openwrt') -File | ForEach-Object {
$dest = Join-Path $etcDir $_.Name $dest = Join-Path $etcDir $_.Name
+7 -2
View File
@@ -31,10 +31,11 @@ if (-not $Version) {
if ($Version) { if ($Version) {
$pattern = '(?m)^version\s*=\s*"[^"]+"' $pattern = '(?m)^version\s*=\s*"[^"]+"'
$replacement = ('version = "{0}"' -f $Version) $replacement = ('version = "{0}"' -f $Version)
(Get-Content Cargo.toml -Raw) -replace $pattern, $replacement | Set-Content Cargo.toml -Encoding UTF8 (Get-Content Cargo.toml -Raw) -replace $pattern, $replacement | Set-Content Cargo.toml -Encoding UTF8 -NoNewline
} }
# Ensure build ok # Ensure build ok
cargo fmt
cargo build --release cargo build --release
# Publish crate (only if -Publish and registry auth is available) # Publish crate (only if -Publish and registry auth is available)
@@ -44,6 +45,8 @@ if ($Publish) {
$pubArgs = @('publish') $pubArgs = @('publish')
if ($Registry) { $pubArgs += @('--registry', $Registry) } if ($Registry) { $pubArgs += @('--registry', $Registry) }
cargo @pubArgs cargo @pubArgs
Write-Host ("published. ran cargo {0}" -f ($pubArgs -join " "))
} }
catch { catch {
Write-Warning ("cargo publish failed: {0}" -f $_) Write-Warning ("cargo publish failed: {0}" -f $_)
@@ -52,7 +55,7 @@ if ($Publish) {
if ($Tag -and $Version) { 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) # 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 (Split-Path -Parent $PSScriptRoot) 'act_runner.ps1' $actRunnerScript = Join-Path $PSScriptRoot 'act_runner.ps1'
if (Test-Path $actRunnerScript) { if (Test-Path $actRunnerScript) {
Write-Host "Ensuring act_runner is running..." Write-Host "Ensuring act_runner is running..."
& $actRunnerScript & $actRunnerScript
@@ -62,4 +65,6 @@ if ($Tag -and $Version) {
} }
git tag -f "v$Version" git tag -f "v$Version"
git push -f origin "v$Version" git push -f origin "v$Version"
Write-Host "Pushed tag: $Version."
} }
-14
View File
@@ -1,14 +0,0 @@
# One-shot: build, package, and print artifact location (no cross required).
# Usage: ./scripts/release.ps1 -Version 0.1.0 -Targets armv7-unknown-linux-musleabihf
param(
[Parameter(Mandatory = $true)][string]$Version,
[string[]]$Targets = @("armv7-unknown-linux-musleabihf")
)
$ErrorActionPreference = 'Stop'
foreach ($t in $Targets) {
rustup target add $t
cargo build --release --target $t
}
./scripts/package.ps1 -Version $Version -Targets $Targets
+16 -13
View File
@@ -10,29 +10,32 @@ INIT="/etc/init.d/wakey"
KILL="/root/.bin/kill_wakey.sh" KILL="/root/.bin/kill_wakey.sh"
if [ -z "$BIN_TMP" ] || [ -z "$DEST" ]; then if [ -z "$BIN_TMP" ] || [ -z "$DEST" ]; then
echo "usage: $0 <bin_tmp> <dest_path> [restart_flag]" >&2 echo "usage: $0 <bin_tmp> <dest_path> [restart_flag]" >&2
exit 2 exit 2
fi fi
[ -f "$BIN_TMP" ] || { echo "tmp binary not found: $BIN_TMP" >&2; exit 1; } [ -f "$BIN_TMP" ] || {
echo "tmp binary not found: $BIN_TMP" >&2
exit 1
}
chmod +x "$BIN_TMP" || true chmod +x "$BIN_TMP" || true
if [ "$RESTART" = "1" ]; then if [ "$RESTART" = "1" ]; then
if [ -x "$INIT" ]; then if [ -x "$INIT" ]; then
"$INIT" stop || true "$INIT" stop || true
elif [ -x "$KILL" ]; then elif [ -x "$KILL" ]; then
sh "$KILL" || true sh "$KILL" || true
fi fi
fi fi
mv -f "$BIN_TMP" "$DEST" mv -f "$BIN_TMP" "$DEST"
if [ "$RESTART" = "1" ]; then if [ "$RESTART" = "1" ]; then
if [ -x "$INIT" ]; then if [ -x "$INIT" ]; then
"$INIT" start || "$INIT" restart || true "$INIT" start || "$INIT" restart || true
else else
nohup "$DEST" >/dev/null 2>&1 & nohup "$DEST" >/dev/null 2>&1 &
fi fi
fi fi
exit 0 exit 0
-15
View File
@@ -1,15 +0,0 @@
[Unit]
Description=Wakey WOL+ARP service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/wakey
Restart=on-failure
RestartSec=2
User=wakey
Group=wakey
[Install]
WantedBy=multi-user.target
+61
View File
@@ -0,0 +1,61 @@
#!/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"
# 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
pscp.exe -batch -scp -pw $password $testBinary.FullName ${RemoteHost}:$RemoteTestPath | Out-Null
# Run on target
$testArgs = "$(if ($Verbose) {"--nocapture --show-output"})"
if ($TestName) {
$testArgs = "$TestName $testArgs"
}
plink -batch -ssh $RemoteHost -pw $password "chmod +x $RemoteTestPath && $RemoteTestPath $testArgs"
}
Write-Host "`nDone!" -ForegroundColor Green
-33
View File
@@ -1,33 +0,0 @@
//! r#impl AHHHHHH
use macaddr::MacAddr;
use serde::{Deserialize, Deserializer, Serialize, Serializer, 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)
}
}
/// 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)
}
+75
View File
@@ -0,0 +1,75 @@
//! 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)
}
}
use crate::arpparse::IpNeighLine;
use lda_ipjs::subcommands::neighbor::{self as ipjs_neigh, NeighborItem};
impl From<ipjs_neigh::NUDState> for NUDState {
fn from(value: ipjs_neigh::NUDState) -> Self {
match value {
ipjs_neigh::NUDState::Permanent => NUDState::Permanent,
ipjs_neigh::NUDState::Noarp => NUDState::Noarp,
ipjs_neigh::NUDState::Reachable => NUDState::Reachable,
ipjs_neigh::NUDState::Stale => NUDState::Stale,
ipjs_neigh::NUDState::None => NUDState::None,
ipjs_neigh::NUDState::Incomplete => NUDState::Incomplete,
ipjs_neigh::NUDState::Delay => NUDState::Delay,
ipjs_neigh::NUDState::Probe => NUDState::Probe,
ipjs_neigh::NUDState::Failed => NUDState::Failed,
ipjs_neigh::NUDState::Other(_) => NUDState::None,
}
}
}
impl From<NUDState> for ipjs_neigh::NUDState {
fn from(value: NUDState) -> Self {
match value {
NUDState::Permanent => ipjs_neigh::NUDState::Permanent,
NUDState::Noarp => ipjs_neigh::NUDState::Noarp,
NUDState::Reachable => ipjs_neigh::NUDState::Reachable,
NUDState::Stale => ipjs_neigh::NUDState::Stale,
NUDState::None => ipjs_neigh::NUDState::None,
NUDState::Incomplete => ipjs_neigh::NUDState::Incomplete,
NUDState::Delay => ipjs_neigh::NUDState::Delay,
NUDState::Probe => ipjs_neigh::NUDState::Probe,
NUDState::Failed => ipjs_neigh::NUDState::Failed,
}
}
}
impl From<NeighborItem> for IpNeighLine {
fn from(
NeighborItem {
ip,
dev,
mac,
state,
}: NeighborItem,
) -> Self {
IpNeighLine {
ip,
dev,
mac,
state: state
.first()
.copied()
.map(Into::into)
.unwrap_or(NUDState::None),
}
}
}
+13 -8
View File
@@ -10,14 +10,14 @@
use std::{net::IpAddr, str::FromStr}; use std::{net::IpAddr, str::FromStr};
use r#impl::ser_opm; use crate::utils::parse::mac;
use macaddr::MacAddr; use macaddr::MacAddr;
use serde_with::skip_serializing_none; use serde_with::skip_serializing_none;
use strum::{Display, EnumString}; use strum::{Display, EnumString};
use crate::arpparse::error::IPNeighParseError; use crate::arpparse::error::IPNeighParseError;
mod error; mod error;
mod r#impl; // custom (de)serialization impls mod impls; // custom (de)serialization impls
/// ip neigh has some cool shit. /// ip neigh has some cool shit.
/// ///
@@ -33,7 +33,7 @@ pub struct IpNeighLine {
pub ip: IpAddr, pub ip: IpAddr,
pub dev: Option<String>, pub dev: Option<String>,
/// link layer address /// link layer address
#[serde(serialize_with = "ser_opm")] #[serde(with = "mac::option_mac")]
pub mac: Option<MacAddr>, pub mac: Option<MacAddr>,
/// Neighbour Unreachability Detection /// Neighbour Unreachability Detection
pub state: NUDState, pub state: NUDState,
@@ -41,7 +41,9 @@ pub struct IpNeighLine {
// NUDState custom Deserialize now lives in arpparse/impl.rs; use serde_with OneOrMany for Vec // 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)] #[derive(
Debug, PartialEq, Eq, EnumString, Display, Clone, Copy, Hash, serde::Serialize, Default,
)]
#[strum(serialize_all = "UPPERCASE", ascii_case_insensitive)] #[strum(serialize_all = "UPPERCASE", ascii_case_insensitive)]
#[serde(rename_all = "UPPERCASE")] #[serde(rename_all = "UPPERCASE")]
pub enum NUDState { pub enum NUDState {
@@ -63,10 +65,6 @@ pub enum NUDState {
/// neighbour state if it was valid and the /// neighbour state if it was valid and the
/// address is not changed by this command. /// address is not changed by this command.
Stale, 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 /// the neighbour entry has not (yet) been
/// validated/resolved. /// validated/resolved.
@@ -80,6 +78,13 @@ pub enum NUDState {
/// success, neighbor validation has ultimately /// success, neighbor validation has ultimately
/// failed. /// failed.
Failed, Failed,
/// 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.
#[serde(other)]
#[default]
None,
} }
impl NUDState { impl NUDState {
+31 -3
View File
@@ -1,3 +1,31 @@
pub const HOME_2: &str = include_str!("../static/home_2.html"); // generated with ./scripts/map_static.py
pub const HOME_2_CSS: &str = include_str!("../static/assets/home_2.css");
pub const HOME_2_JS: &str = include_str!("../static/assets/home_2.js"); 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
() => {};
}
hehe! {
file HOME_2_HTML "../static/index.html"
folder home_2 {
file DOM_JS "../static/home_2/dom.js"
file LEASES_JS "../static/home_2/leases.js"
file MAIN_JS "../static/home_2/main.js"
file STATUS_JS "../static/home_2/status.js"
file STYLES_CSS "../static/home_2/styles.css"
file UTILS_JS "../static/home_2/utils.js"
file WAKE_JS "../static/home_2/wake.js"
}
}
+5 -7
View File
@@ -2,7 +2,8 @@
const MAC_NAME_CACHE: &str = "/tmp/wakey_mac_names.json"; const MAC_NAME_CACHE: &str = "/tmp/wakey_mac_names.json";
/// Load MAC->name cache from disk /// Load MAC->name cache from disk
async fn load_mac_name_cache() -> io::Result<std::collections::BTreeMap<String, String>> { 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 { match tokio::fs::read_to_string(MAC_NAME_CACHE).await {
Ok(s) => serde_json::from_str(&s).map_err(io::Error::other), Ok(s) => serde_json::from_str(&s).map_err(io::Error::other),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Default::default()), Err(e) if e.kind() == ErrorKind::NotFound => Ok(Default::default()),
@@ -26,6 +27,7 @@ pub async fn read_dhcp_leases_with_names() -> io::Result<Vec<DhcpLeaseLine>> {
for mut l in leases { for mut l in leases {
let mac_s = l.mac.to_string(); let mac_s = l.mac.to_string();
if let Some(ref name) = l.name { 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) { if cache.get(&mac_s).map(|v| v != name).unwrap_or(true) {
cache.insert(mac_s, name.clone()); cache.insert(mac_s, name.clone());
changed = true; changed = true;
@@ -40,8 +42,8 @@ pub async fn read_dhcp_leases_with_names() -> io::Result<Vec<DhcpLeaseLine>> {
} }
Ok(leases_with_names) Ok(leases_with_names)
} }
use crate::utils::parse::mac;
use macaddr::MacAddr; use macaddr::MacAddr;
use serde::Serializer;
use std::io::{self, ErrorKind}; use std::io::{self, ErrorKind};
use std::net::IpAddr; use std::net::IpAddr;
@@ -51,15 +53,11 @@ pub struct DhcpLeaseLine {
/// Epoch seconds when the lease expires /// Epoch seconds when the lease expires
pub expires_epoch: u64, pub expires_epoch: u64,
pub ip: IpAddr, pub ip: IpAddr,
#[serde(serialize_with = "ser_mac")] #[serde(with = "mac")]
pub mac: MacAddr, pub mac: MacAddr,
pub name: Option<String>, 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 /// Parse one line of /tmp/dhcp.leases
pub fn parse_dhcp_lease_line(line: &str) -> Option<DhcpLeaseLine> { pub fn parse_dhcp_lease_line(line: &str) -> Option<DhcpLeaseLine> {
let mut c = line.split_whitespace(); let mut c = line.split_whitespace();
+25 -13
View File
@@ -1,29 +1,40 @@
use axum::{ //! braindead version v0.1.x
Router, //!
routing::{get, post}, //! # whats next
}; //!
//! for version 2 i hope to have:
//!
//! 1. idk reworked frontend;
//! 2. incorporate ip -j;
//! 3. small 1-5 second caching;
use axum::Router;
use tokio::net::TcpListener; use tokio::net::TcpListener;
mod arpparse; mod arpparse;
pub mod assets; pub mod assets;
mod dhcpparse; mod dhcpparse;
mod route; mod route;
mod utils; mod utils;
use std::io; use std::{env, io};
const MACHINE_NAME: &str = "lda.lan";
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[tokio::main] #[tokio::main]
async fn entry() -> io::Result<()> { async fn entry() -> io::Result<()> {
use crate::route::{api_router, home_2, home_2_route, wake_handler}; use crate::route::api_router;
use axum::routing::get_service;
use tower_http::services::ServeDir;
let exe = env::current_exe()?;
let root = exe
.parent()
.ok_or_else(|| io::Error::other("no parent dir"))?;
let static_dir = ServeDir::new(root.join("static"));
let app = Router::new() let app = Router::new()
// .route("/home", get(home)) // .route("/home", get(home))
.route("/", get(home_2)) // .route("/", get(home_2))
.merge(home_2_route()) // .merge(home_2_route())
.route("/wake", post(wake_handler))
// .route("/status", get(get_status_2)) // .route("/status", get(get_status_2))
.nest("/api", api_router()); .nest("/api", api_router())
.fallback_service(get_service(static_dir));
let port = TcpListener::bind("0.0.0.0:12012").await?; let port = TcpListener::bind("0.0.0.0:12012").await?;
axum::serve(port, app.into_make_service()).await?; axum::serve(port, app.into_make_service()).await?;
@@ -32,6 +43,7 @@ async fn entry() -> io::Result<()> {
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
fn main() -> color_eyre::Result<()> { fn main() -> color_eyre::Result<()> {
use std::net::ToSocketAddrs;
color_eyre::install()?; color_eyre::install()?;
// use crate::arpparse::NUDState; // use crate::arpparse::NUDState;
// println!("{}", NUDState::Reachable.to_string().to_lowercase()); // println!("{}", NUDState::Reachable.to_string().to_lowercase());
-96
View File
@@ -1,96 +0,0 @@
pub mod api;
pub use crate::route::api::{DeviceQuery, api_router};
use crate::{
assets,
utils::{ping::_ping_ip, wake::wake},
};
use axum::{
Router,
http::{StatusCode, header},
response::{Html, IntoResponse},
routing::get,
};
use axum_extra::extract::Query;
use crate::{MACHINE_NAME, utils::_status_build};
pub async fn home_2() -> Html<&'static str> {
Html(assets::HOME_2)
}
async fn home_2_css() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "text/css; charset=utf-8"),
(header::CACHE_CONTROL, "public, max-age=300"),
],
assets::HOME_2_CSS,
)
}
async fn home_2_js() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "public, max-age=300"),
],
assets::HOME_2_JS,
)
}
/// all the pages related to home_2
pub fn home_2_route() -> Router {
Router::new()
.route("/home_2", get(home_2))
.route("/home_2.css", get(home_2_css))
.route("/home_2.js", get(home_2_js)) //js
}
pub async fn wake_handler(
Query(DeviceQuery { name, .. }): Query<DeviceQuery>,
) -> axum::response::Result<impl IntoResponse> {
match wake(name.as_deref().unwrap_or(MACHINE_NAME)).await {
Ok(0) => Err((StatusCode::NOT_FOUND, "No packets sent!").into()),
Ok(x) => Ok((
StatusCode::ACCEPTED,
format!("{x} packet{s} sent!", s = if x > 1 { "s" } else { "" }),
)),
_ => Err((StatusCode::GATEWAY_TIMEOUT, "Wake failed").into()),
}
}
pub async fn _get_status_2(q: Query<DeviceQuery>) -> Html<String> {
let name = match q {
Query(DeviceQuery {
name: Some(name), ..
}) => name,
_ => MACHINE_NAME.to_string(),
};
Html(_status_build(&name).await)
}
pub async fn _home() -> Html<String> {
Html(format!(
r#"
<html>
<body>
<p><a href="/home_2">Alternate UI</a></p>
<p>the machine is {}! <a href="/status">Status</a></p>
<form method="POST" action="/wake">
<button type="submit">Wake LDA</button>
</form>
</body>
</html>
"#,
if _ping_ip((MACHINE_NAME, 22)).await {
"on"
} else {
"off"
} // match get_ips(MACHINE_NAME).await {
// Ok(ips) => {
// // let addrs: Vec<SocketAddr> = ips.into_iter().map(|ip|(ip, 22).into()).collect();
// }
// Err(_) => "off",
// }
))
}
+56 -228
View File
@@ -1,231 +1,61 @@
use crate::utils::parse::{boolish_str, de_many, serialize_macs}; use crate::route::error::ApiError;
use std::collections::HashSet; use crate::utils::query::parser::{QueryType, parse_query};
use std::net::IpAddr; use axum::Json;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::{extract::Path, response::Redirect};
use axum::{ use crate::route::status::{DeviceQuery, Filters, NamePath};
Json, Router, use crate::utils::query::get_ips;
extract::Path,
http::StatusCode,
response::{IntoResponse, Redirect},
routing::get,
};
use axum_extra::extract::Query;
use macaddr::MacAddr;
use serde_with::skip_serializing_none;
use crate::{
arpparse::{self, NUDState},
dhcpparse,
utils::query::{
dev::{self, has_dev},
get_macs,
},
};
// Smart redirect: accept IP, MAC, dev, or NUD state and redirect to /api/status accordingly // Smart redirect: accept IP, MAC, dev, or NUD state and redirect to /api/status accordingly
pub async fn status_smart_redirect(Path(q): Path<String>) -> Redirect { pub async fn status_smart_redirect(
let s = if cfg!(feature = "very-smart-parsing") { Path(q): Path<String>,
crate::utils::parse::extract_host(&q) ) -> axum::response::Result<Redirect, impl IntoResponse> {
} else { // no less bullshit
q.trim() let query = match parse_query(q).await {
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()
},
}; };
// 1) IP match serde_html_form::to_string(query) {
let ip = if cfg!(feature = "very-smart-parsing") { Ok(e) => Ok(Redirect::to(&format!("/api/status?{e}"))),
crate::utils::parse::parse_numeric_ipv4(s).or_else(|| s.parse::<IpAddr>().ok()) Err(e) => Err(ApiError {
} else { error: e.to_string(),
s.parse::<IpAddr>().ok() code: StatusCode::BAD_GATEWAY,
}; }),
if let Some(ip) = ip {
return Redirect::to(&format!("/api/status?ip={ip}"));
}
// 2) MAC
if let Ok(mac) = s.parse::<MacAddr>() {
return Redirect::to(&format!("/api/status?mac={mac}"));
}
// 3) NUD state (reachable, stale, ...)
if let Ok(state) = s.parse::<NUDState>() {
return Redirect::to(&format!("/api/status?nud={state}"));
}
// 4) Known device? prefer dev first
if has_dev(s) {
return Redirect::to(&format!("/api/status?dev={}", urlencoding::encode(s)));
}
// 5) Try DNS: if it resolves, treat as name
if tokio::net::lookup_host((s, 0)).await.is_ok() {
return Redirect::to(&format!("/api/status?name={}", urlencoding::encode(s)));
}
// Default: name last // it will fail also
Redirect::to(&format!("/api/status?name={}", urlencoding::encode(s)))
}
pub async fn devs_router() -> Json<Vec<String>> {
dev::devs_sorted().into()
}
#[derive(Debug, Default, Clone, serde::Deserialize)]
struct DhcpLeasesQueryRaw {
include_state: Option<String>,
}
async fn get_dhcp_leases(Query(raw): Query<DhcpLeasesQueryRaw>) -> impl IntoResponse {
let include_state = raw
.include_state
.as_deref()
.map(boolish_str)
.unwrap_or(false);
match dhcpparse::read_dhcp_leases_with_names().await {
Ok(leases_with_names) => {
if !include_state {
return (StatusCode::OK, Json(leases_with_names)).into_response();
}
let out = crate::utils::query::enrich_leases_with_nud_state(leases_with_names).await;
(StatusCode::OK, Json(out)).into_response()
}
Err(e) => (
StatusCode::BAD_GATEWAY,
Json(StatusError {
name: None,
error: e.to_string(),
}),
)
.into_response(),
} }
} }
#[derive(Debug, Default, Clone, Hash, serde::Deserialize)]
pub struct DeviceQuery {
pub name: Option<String>,
// Accept single or many; ignore blanks
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
ip: Vec<IpAddr>,
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
mac: Vec<MacAddr>,
/// optional interface filter (e.g., br-lan)
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
pub dev: Vec<String>,
/// optional NUD state filter; accepts any case (e.g., reachable, REACHABLE)
#[serde(default, deserialize_with = "de_many::vec_from_strs")]
pub nud: Vec<NUDState>,
}
#[derive(Debug, Default, Clone, Hash, serde::Deserialize)]
pub struct NamePath {
name: String,
}
#[skip_serializing_none]
#[derive(Debug, Default, serde::Serialize)]
pub struct Status {
name: Option<String>,
table: Vec<arpparse::IpNeighLine>,
filters: Filters,
}
#[derive(Debug, Default, serde::Serialize)]
pub struct Filters {
#[serde(skip_serializing_if = "Vec::is_empty")]
ip: Vec<IpAddr>,
#[serde(skip_serializing_if = "Vec::is_empty")]
dev: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
nud: Vec<NUDState>,
#[serde(
skip_serializing_if = "Vec::is_empty",
serialize_with = "serialize_macs"
)]
mac: Vec<MacAddr>,
}
#[skip_serializing_none]
#[derive(Debug, serde::Serialize)]
pub struct StatusError {
name: Option<String>,
error: String,
}
pub async fn get_status_json(
// p: Option<Path<NamePath>>,
Query(DeviceQuery {
name,
ip,
dev,
nud,
mac,
..
}): Query<DeviceQuery>,
) -> impl IntoResponse {
fn to_opts<T: Clone>(slice: &[T]) -> Vec<Option<T>> {
if slice.is_empty() {
vec![None]
} else {
slice.iter().cloned().map(Some).collect()
}
}
/* let name = /* p
.map(|Path(n)| n.name)
.or */(name)
// .unwrap_or_else(|| MACHINE_NAME.to_owned())
; */
// ip/dev/nud already parsed; assemble options
let ips_opt = if ip.is_empty() {
None
} else {
Some(ip.clone())
};
let dev_opts: Vec<Option<String>> = to_opts(&dev);
let nud_opts: Vec<Option<NUDState>> = to_opts(&nud);
let filters = Filters {
ip,
dev,
nud,
mac: mac.clone(),
};
// Run combinations of dev/nud and merge results
let mut tasks = Vec::new();
for d in &dev_opts {
for n in &nud_opts {
tasks.push(get_macs(
name.as_deref(),
ips_opt.as_deref(),
d.as_deref(),
*n,
));
}
}
match futures::future::try_join_all(tasks)
.await
.map(|v| v.into_iter().flatten().collect::<Vec<_>>())
{
Ok(mut table) => {
// Optional MAC post-filtering if provided
if !mac.is_empty() {
let wanted: HashSet<MacAddr> = mac.into_iter().collect();
table.retain(|row| row.mac.map(|m| wanted.contains(&m)).unwrap_or(false));
}
// let canonical = format!("/api/status?name={name}");
(
StatusCode::OK,
// [(header::LINK, format!("<{canonical}>; rel=\"canonical\""))],
Json(Status {
name,
table,
filters,
}),
)
.into_response()
}
Err(error) => (
StatusCode::BAD_GATEWAY,
Json(StatusError {
name,
error: error.to_string(),
}),
)
.into_response(), // holy clutch. Couldve been disasterous
}
}
pub async fn status_redirect(Path(NamePath { name }): Path<NamePath>) -> Redirect { pub async fn status_redirect(Path(NamePath { name }): Path<NamePath>) -> Redirect {
Redirect::permanent(&format!( Redirect::permanent(&format!(
"/api/status?name={name}", "/api/status?name={name}",
@@ -233,11 +63,9 @@ pub async fn status_redirect(Path(NamePath { name }): Path<NamePath>) -> Redirec
)) ))
} }
pub fn api_router() -> Router { pub async fn ip(Path(name): Path<String>) -> impl IntoResponse {
Router::new() get_ips(&name).await.map_or_else(
.route("/status/{name}", get(status_redirect)) |e| ApiError::ise(e.to_string()).into_response(),
.route("/status", get(get_status_json)) |ips| Json(ips.collect::<Vec<_>>()).into_response(),
.route("/dhcp_leases", get(get_dhcp_leases)) )
.route("/smart/{q}", get(status_smart_redirect))
.route("/devs", get(devs_router))
} }
+7
View File
@@ -0,0 +1,7 @@
use crate::utils::query::dev;
use axum::Json;
pub async fn devs_router() -> Json<Vec<String>> {
dev::devs_sorted().await.into()
}
// Device listing endpoints
+35
View File
@@ -0,0 +1,35 @@
use crate::{
dhcpparse::read_dhcp_leases_with_names,
route::error::ApiError,
utils::{parse::boolish_str, query::enrich_leases_with_nud_state},
};
use axum::{Json, extract::Query, http::StatusCode, response::IntoResponse};
// DHCP lease endpoints
#[derive(Debug, Default, Clone, serde::Deserialize)]
pub struct DhcpLeasesQueryRaw {
include_state: Option<String>,
}
pub async fn get_dhcp_leases(Query(raw): Query<DhcpLeasesQueryRaw>) -> impl IntoResponse {
let include_state = raw
.include_state
.as_deref()
.map(boolish_str)
.unwrap_or(false);
match read_dhcp_leases_with_names().await {
Ok(leases_with_names) => {
if !include_state {
return (StatusCode::OK, Json(leases_with_names)).into_response();
}
let out = enrich_leases_with_nud_state(leases_with_names).await;
(StatusCode::OK, Json(out)).into_response()
}
Err(e) => ApiError {
error: e.to_string(),
code: StatusCode::BAD_GATEWAY,
}
.into_response(),
}
}
+29
View File
@@ -0,0 +1,29 @@
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct ApiError {
#[serde(skip_serializing, skip_deserializing)]
pub code: StatusCode,
pub error: String,
}
impl ApiError {
/// [StatusCode::INTERNAL_SERVER_ERROR] shortcut
pub const fn ise(error: String) -> Self {
Self {
code: StatusCode::INTERNAL_SERVER_ERROR,
error,
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.code, Json(self)).into_response()
}
}
+88
View File
@@ -0,0 +1,88 @@
pub mod api;
pub mod devs;
pub mod dhcp;
pub mod error;
pub mod status;
pub mod wake;
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::status::get_status_json;
use crate::route::wake::wake_multi;
use axum::Json;
use axum::Router;
use axum::body::Body;
use axum::http::{Request, header};
use axum::middleware::{self, Next};
use axum::response::{Html, IntoResponse, Response};
use axum::routing::{get, post};
use std::time::Instant;
use crate::utils::route::serve_js;
pub async fn home_2() -> Html<&'static str> {
Html(assets::HOME_2_HTML)
}
pub fn home_2_route() -> Router {
use assets::*;
Router::new()
.route("/home_2", get(|| async { Html(HOME_2_HTML) }))
.route("/home_2/", get(|| async { Html(HOME_2_HTML) }))
.route("/home_2.html", get(|| async { Html(HOME_2_HTML) }))
.route(
"/home_2/styles.css",
get(|| async {
(
[
(header::CONTENT_TYPE, "text/css; charset=utf-8"),
(header::CACHE_CONTROL, "public, max-age=300"),
],
home_2::STYLES_CSS,
)
}),
)
.route("/home_2/main.js", get(|| serve_js(home_2::MAIN_JS)))
.route("/home_2/leases.js", get(|| serve_js(home_2::LEASES_JS)))
.route("/home_2/status.js", get(|| serve_js(home_2::STATUS_JS)))
.route("/home_2/utils.js", get(|| serve_js(home_2::UTILS_JS)))
.route("/home_2/wake.js", get(|| serve_js(home_2::WAKE_JS)))
.route("/home_2/dom.js", get(|| serve_js(home_2::DOM_JS)))
}
async fn add_performance_header(req: Request<Body>, next: Next) -> Response {
let start = Instant::now();
let mut response = next.run(req).await;
let elapsed = start.elapsed();
if let Ok(val) = format!("work-time={}us", elapsed.as_micros()).parse() {
response.headers_mut().insert("Lda-Performance", val);
}
response
}
pub fn api_router() -> Router {
Router::new()
.route("/status/{name}", get(status_redirect))
.route("/status", get(get_status_json))
.route("/dhcp_leases", get(get_dhcp_leases))
.route("/smart/{q}", get(status_smart_redirect))
.route("/devs", get(devs_router))
.route("/wake", post(wake_multi))
.route("/ips/{name}", get(ip))
.route(
"/mac-cache",
get(|| async {
match load_mac_name_cache().await {
Ok(h) => Json(h).into_response(),
Err(e) => e.to_string().into_response(),
}
}),
)
.layer(middleware::from_fn(add_performance_header))
}
+86
View File
@@ -0,0 +1,86 @@
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 serde_with::{DisplayFromStr, OneOrMany, serde_as};
use std::net::IpAddr;
use crate::arpparse::NUDState;
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,
}
#[serde_as]
#[derive(Debug, Default, Clone, Hash, Serialize, Deserialize)]
pub struct Filters {
#[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<NUDState>,
#[serde_as(as = "OneOrMany<DisplayFromStr>")]
#[serde(default)]
pub macs: Vec<MacAddr>,
}
pub async fn get_status_json(
Query(DeviceQuery {
name,
filter: filters,
..
}): Query<DeviceQuery>,
) -> impl IntoResponse {
match get_macs(
name.as_slice(),
&filters.ips,
&filters.devs,
&filters.nuds,
&filters.macs,
)
.await
{
Ok(table) => (
StatusCode::OK,
Json(Status {
name,
table,
filters,
}),
)
.into_response(),
Err(error) => ApiError {
code: StatusCode::BAD_GATEWAY,
error: error
.chain()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(": "),
}
.into_response(),
}
}
// Status endpoints
+112
View File
@@ -0,0 +1,112 @@
//! 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;
use crate::utils::wake::wake_one;
use axum::{extract::Json, http::StatusCode, response::IntoResponse};
use futures::TryFutureExt;
use macaddr::MacAddr;
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
use tokio::net::UdpSocket;
#[skip_serializing_none]
#[derive(Debug, Default, Serialize, Clone)]
pub struct WakeResult {
pub success: bool,
pub result: Option<Vec<WakeTargetResult>>,
pub error: Option<String>,
}
#[skip_serializing_none]
#[derive(Debug, Serialize, Clone, Copy)]
pub struct WakeTargetResult {
#[serde(flatten)]
pub target: WakeTarget,
pub status: WakeTargetStatus,
}
#[derive(Debug, Serialize, Clone, Copy, Hash)]
#[serde(rename_all = "snake_case")]
pub enum WakeTargetStatus {
Succeed,
/// not a real address...
NonexistentAddress,
WrongSize,
/// input is not enough
Incomplete,
}
#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, Clone, Copy)]
pub struct WakeTarget {
#[serde(default)]
pub ip: Option<IpAddr>,
#[serde(default, with = "mac::option_mac")]
pub mac: Option<MacAddr>,
}
pub async fn wake_multi(Json(req): Json<Vec<WakeTarget>>) -> impl IntoResponse {
match wake_multi_split(req).await {
Ok(results) => (
StatusCode::OK,
Json(WakeResult {
success: true,
result: Some(results),
..Default::default()
}),
),
Err(error) => {
let error = Some(format!("Error: {}", error));
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(WakeResult {
success: false,
error,
..Default::default()
}),
)
}
}
}
/// this is so bad
pub async fn wake_multi_split(
targets: impl IntoIterator<Item = WakeTarget>,
) -> io::Result<Vec<WakeTargetResult>> {
let sock = UdpSocket::bind("[::]:0")
.or_else(|_| UdpSocket::bind(":0"))
.await?;
sock.set_broadcast(true)?;
let iter = targets.into_iter().map(async |c| {
if c.is_incomplete() {
c.to_incomplete()
} else {
let t = c.try_into().expect("complete struct failed to try_into");
wake_one(&sock, t).await.into()
}
});
Ok(futures::future::join_all(iter).await)
}
/* #[derive(Debug, Serialize)]
pub struct WakeStatusLine {
#[serde(flatten)]
pub status: IpNeighLine, // most powerful find of the century
pub wake_status: WakeTargetStatus,
}
pub type WakeStatus = Status<WakeStatusLine>; */
// /// return status BUT plus a indicator of i sent a wake.
// pub async fn wake_status(
// Query(DeviceQuery {
// name,
// ip,
// dev,
// nud,
// mac,
// ..
// }): Query<DeviceQuery>,
// ) -> impl IntoResponse {
// }
-3
View File
@@ -1,3 +0,0 @@
pub const HOME_2: &str = include_str!("../static/home_2.html");
pub const HOME_2_CSS: &str = include_str!("../static/assets/home_2.css");
pub const HOME_2_JS: &str = include_str!("../static/assets/home_2.js");
-64
View File
@@ -1,64 +0,0 @@
// pub const LDA_MACS: [[u8; 6]; 2] = [
// // ether
// [0x04, 0x7c, 0x16, 0x79, 0x6d, 0xee],
// // wifi
// [0xbc, 0x09, 0x1b, 0xec, 0x65, 0xd0],
// ];
/// is it time to lookup host lda.lan for this...
// pub static LDA_MACS_2: LazyLock<[MacAddr; 2]> = LazyLock::new(|| LDA_MACS.map(MacAddr::from));
pub mod wake;
use crate::utils::query::_get_macs_2_1;
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 cmd;
pub mod query;
// no custom ip deserializer needed when using axum_extra::extract::Query
// but we add a generic one to ignore blanks and accept OneOrMany
pub(crate) mod parse;
pub async fn _status_build(machine_name: &str) -> String {
let formatted_macs = match _get_macs_2_1(machine_name).await {
Ok(table) => {
let the: String = table
.iter()
.map(|(ip, mac, state)| {
let mac_str = // if let Some(mac) = mac {
mac.to_string()
// } else {
// "None".into()
// }
;
format!(
"<tr><td>{ip}</td><td>{mac_str}</td><td>{state}</td></tr>",
state = state._dumber_state()
)
})
.collect();
format!(
r#"<p>info of {machine_name}:</p>
<table>
<tr><th>IP</th><th>MAC</th><th>State</th></tr>
{the}
</table>"#
)
}
Err(e) => format!("<p>errors getting table for {machine_name}: {e}</p>"),
};
format!(
r#"
<html>
<body>
{formatted_macs}
</body>
</html>
"#,
)
}
-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)
}
}
+20
View File
@@ -0,0 +1,20 @@
// pub const LDA_MACS: [[u8; 6]; 2] = [
// // ether
// [0x04, 0x7c, 0x16, 0x79, 0x6d, 0xee],
// // wifi
// [0xbc, 0x09, 0x1b, 0xec, 0x65, 0xd0],
// ];
/// is it time to lookup host lda.lan for this...
// pub static LDA_MACS_2: LazyLock<[MacAddr; 2]> = LazyLock::new(|| LDA_MACS.map(MacAddr::from));
pub mod wake;
/// 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 route;
// no custom ip deserializer needed when using axum_extra::extract::Query
// but we add a generic one to ignore blanks and accept OneOrMany
pub(crate) mod parse;
+47
View File
@@ -0,0 +1,47 @@
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<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>`]
pub fn deserialize<'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)
}
}
+1 -53
View File
@@ -58,8 +58,6 @@ pub fn extract_host(input: &str) -> &str {
s.trim() s.trim()
} }
use macaddr::MacAddr;
use serde::Serializer;
/// key for yes: "1" | "true" | "yes" | "on" | "y" /// key for yes: "1" | "true" | "yes" | "on" | "y"
/// ///
/// frfr /// frfr
@@ -102,54 +100,4 @@ pub fn boolish_str(s: &str) -> bool {
&& t.parse::<u64>().map(|n| n != 0).unwrap_or(false)) && t.parse::<u64>().map(|n| n != 0).unwrap_or(false))
} }
pub fn serialize_macs<S>(macs: &[MacAddr], serializer: S) -> Result<S::Ok, S::Error> pub mod mac;
where
S: Serializer,
{
let strings: Vec<String> = macs.iter().map(|m| m.to_string()).collect();
serde::Serialize::serialize(&strings, serializer)
}
pub fn serialize_mac<S: serde::Serializer>(m: &macaddr::MacAddr, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&m.to_string())
}
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)
}
}
+2 -2
View File
@@ -7,7 +7,7 @@ use tokio::{
time::timeout, time::timeout,
}; };
use crate::{arpparse::NUDState, utils::query::get_macs}; use crate::{arpparse::NUDState, utils::query::get_mac};
pub async fn _ping_ip<T: ToSocketAddrs>(addr: T) -> bool { pub async fn _ping_ip<T: ToSocketAddrs>(addr: T) -> bool {
timeout(Duration::from_secs(1), TcpStream::connect(addr)) timeout(Duration::from_secs(1), TcpStream::connect(addr))
@@ -19,7 +19,7 @@ pub async fn _ping_ip_2<T: ToSocketAddrs>(_addr: T) -> bool {
} }
pub async fn _ping_ip_3<T: Into<IpAddr>>(addr: T) -> u8 { pub async fn _ping_ip_3<T: Into<IpAddr>>(addr: T) -> u8 {
match get_macs(None, Some(&[addr.into()]), None, None).await { match get_mac(Some(addr.into()), None, &[] as &[NUDState]).await {
Err(_) => 0, Err(_) => 0,
Ok(l) => l Ok(l) => l
.into_iter() .into_iter()
-240
View File
@@ -1,240 +0,0 @@
use crate::arpparse::NUDState;
use crate::dhcpparse::DhcpLeaseLine;
use crate::utils::parse::serialize_mac;
use std::net::IpAddr;
#[skip_serializing_none]
#[derive(Debug, Clone, serde::Serialize)]
pub struct DhcpLeaseOut {
pub expires_epoch: u64,
pub ip: IpAddr,
#[serde(serialize_with = "serialize_mac")]
pub mac: macaddr::MacAddr,
pub name: Option<String>,
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> {
use crate::utils::query::get_macs;
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(None, Some(&ips), None, None).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(|l| DhcpLeaseOut {
expires_epoch: l.expires_epoch,
ip: l.ip,
mac: l.mac,
name: l.name,
nud_state: map.get(&l.ip).map(|(s, _)| *s),
rank: map.get(&l.ip).map(|(_, r)| *r),
})
.collect()
}
use std::collections::HashSet;
use macaddr::MacAddr;
use serde_with::skip_serializing_none;
// use tokio::io;
use crate::{
arpparse::{self, IpNeighLine},
utils::{
cmd::exec_command,
error::{self, Error, Result},
},
};
/// this is because i like [`IpAddr`] more than [`SocketAddr`](std::net::SocketAddr)
pub async fn get_ips(machine_name: &str) -> error::Result<Vec<IpAddr>> {
let it = tokio::net::lookup_host((machine_name, 0))
.await
.map_err(|e| error::Error::DnsResolve {
name: machine_name.to_string(),
source: e,
})?;
Ok(it.map(|c| c.ip()).collect())
}
pub async fn _get_macs_2_1(machine_name: &str) -> Result<HashSet<(IpAddr, MacAddr, NUDState)>> {
Ok(get_macs_1(machine_name)
.await?
.into_iter()
.filter_map(
|IpNeighLine {
ip,
dev: _,
mac,
state,
}| mac.map(|mac| (ip, mac, state)),
)
.collect())
}
pub async fn get_macs_2_mac(machine_name: &str) -> Result<HashSet<MacAddr>> {
Ok(get_macs_1(machine_name)
.await?
.into_iter()
.filter_map(
|IpNeighLine {
ip: _,
dev: _,
mac,
state: _,
}| mac,
)
.collect())
}
pub async fn get_macs_1(machine_name: &str) -> Result<Vec<arpparse::IpNeighLine>> {
let dev = "br-lan";
let ips = get_ips(machine_name).await?;
let futures = ips.iter().map(|ip| {
let ip = ip.to_canonical();
async move {
let cmd = "ip";
let args = ["neigh", "show", "to", &ip.to_string(), "dev", dev];
let o = exec_command(cmd, args).await?;
if !o.status.success() {
return Err(Error::CommandFailed {
cmd,
args: args.iter().map(ToString::to_string).collect(),
status: o.status.code(),
stderr: String::from_utf8_lossy(&o.stderr).into(),
});
};
Ok(String::from_utf8_lossy(&o.stdout)
.lines()
.flat_map(arpparse::parse_ip_neigh_line)
// .map(IpNeighLine::with_dev(dev)) // this could be after flatmap up there
.collect::<Vec<_>>())
}
});
let res = futures::future::try_join_all(futures).await?; // async move block errs.
Ok(res
.into_iter()
.flatten() /* resolve double vec */
// .flatten() /* drop parse errors (flat_map cleared) */
.collect())
}
pub async fn get_macs(
machine_name: Option<&str>,
ips: Option<&[IpAddr]>,
dev: Option<&str>,
state: Option<NUDState>,
) -> Result<Vec<IpNeighLine>> {
// Collect IPs early (before any await) to avoid holding generics across await points
let ip_list: Option<Vec<IpAddr>> =
ips.map(|slice| slice.iter().copied().map(|ip| ip.to_canonical()).collect());
// Resolve by machine name if no IPs provided but we have a name
let ip_list = match (ip_list, machine_name) {
(Some(list), _) => list,
(None, Some(name)) => get_ips(name).await?.into_iter().collect(),
(None, None) => Vec::new(),
};
// Helper to convert NUDState to the string expected by `ip neigh`
let nud_arg = state.map(NUDState::as_ip_neigh_arg);
// let nud_arg = state.map(|s| s.to_string().to_lowercase());
// Build a closure to run one `ip neigh` invocation and parse results
let run_one = |to_ip: Option<IpAddr>| async move {
let mut args: Vec<String> = vec!["neigh".into(), "show".into()];
if let Some(ip) = to_ip {
args.push("to".into());
args.push(ip.to_string());
}
if let Some(d) = dev {
args.push("dev".into());
args.push(d.to_string());
}
if let Some(nud) = nud_arg {
args.push("nud".into());
args.push(nud.to_string());
}
let o = exec_command("ip", args.iter().map(String::as_str).collect::<Vec<_>>()).await?; // hope to rustc that it knows how to unfuck ts
if !o.status.success() {
return Err(Error::CommandFailed {
cmd: "ip",
args,
status: o.status.code(),
stderr: String::from_utf8_lossy(&o.stderr).into(),
});
}
let lines = String::from_utf8_lossy(&o.stdout);
// Parse lines and, if a specific dev filter was used, stamp that dev onto rows
let parsed = lines.lines().flat_map(arpparse::parse_ip_neigh_line);
let rows: Vec<IpNeighLine> = if let Some(d) = dev {
parsed.map(IpNeighLine::with_dev(d)).collect()
} else {
parsed.collect()
};
Ok::<Vec<IpNeighLine>, error::Error>(rows)
};
// If we have specific IPs, query each; otherwise query the whole table once
if !ip_list.is_empty() {
let futures = ip_list.into_iter().map(|ip| run_one(Some(ip)));
let res = futures::future::try_join_all(futures).await?;
Ok(res.into_iter().flatten().collect())
} else {
run_one(None).await
}
}
pub mod dev {
use std::collections::HashSet;
pub fn get_dev() -> HashSet<String> {
// Prefer /sys/class/net, fallback to /proc/net/dev; filter out loopback
let mut devs: HashSet<String> = HashSet::new();
if let Ok(rd) = std::fs::read_dir("/sys/class/net") {
for e in rd.flatten() {
if let Ok(name) = e.file_name().into_string()
&& name != "lo"
&& !name.is_empty()
{
devs.insert(name);
}
}
} else if let Ok(txt) = std::fs::read_to_string("/proc/net/dev") {
for line in txt.lines().skip(2) {
// skip headers
if let Some((name, _rest)) = line.split_once(':') {
let n = name.trim().to_string();
if n != "lo" && !n.is_empty() {
devs.insert(n);
}
}
}
}
devs
}
pub 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)
}
}
+65
View File
@@ -0,0 +1,65 @@
use std::collections::HashSet;
// /// 50ms
// pub async fn get_dev() -> HashSet<String> {
// use lda_ipjs::subcommands::address::json as ipjs_json;
// let mut devs: HashSet<String> = HashSet::new();
// if let Ok(items) = ipjs_json::get(None).await {
// for item in items {
// if item.ifname != "lo" && !item.ifname.is_empty() {
// devs.insert(item.ifname);
// }
// }
// }
// devs
// }
/// 3ms
pub async fn get_dev() -> HashSet<String> {
use std::fs;
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
}
tokio::task::spawn_blocking(get_dev)
.await
.unwrap_or_default()
}
pub async fn devs_sorted() -> Vec<String> {
let mut v: Vec<String> = get_dev().await.into_iter().collect();
v.sort();
v
}
pub async fn has_dev(name: &str) -> bool {
get_dev().await.contains(name)
}
+43
View File
@@ -0,0 +1,43 @@
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>,
}
/// 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> = 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| {
let er = e.rank();
if r > er {
*e = state
}
})
.or_insert(state);
}
}
leases
.into_iter()
.map(|lease_line| {
let nud_state = map.get(&lease_line.ip).copied();
DhcpLeaseOut {
lease_line,
nud_state,
}
})
.collect()
}
+187
View File
@@ -0,0 +1,187 @@
use macaddr::MacAddr;
use crate::arpparse::{self, IpNeighLine, NUDState};
use anyhow::{Context, Result, bail};
use std::collections::HashSet;
use std::net::IpAddr;
pub async fn get_ips(machine_name: &str) -> Result<impl Iterator<Item = IpAddr>> {
Ok(tokio::net::lookup_host((machine_name, 0))
.await
.with_context(|| format!("DNS resolve failed for {machine_name}"))?
.map(|c| c.ip()))
}
// good now
//
// Current logic: When filtering by exactly 1 dev/mac, exclude entries missing that field.
// This is because missing dev/mac usually means the entry is incomplete/transient.
//
// when there is only one MACs (getmac got some), the result will not have them fields.
// so there are three cases:
//
// 1. dont got nothing: take all of them (macset.is_empty())
// 2. exactly one: pre-filtered by ip, everything matches,
// devset.len() != 1 returns false, but then it works????
// OH THIS fuckass code i added it in the get_mac
// 3. devset.len() > 1. if none then absolutely not match,
// if some then check with the set; thats normal
pub async fn get_macs(
machine_names: &[impl AsRef<str>],
ips: &[IpAddr],
devs: &[impl AsRef<str>],
state: &[NUDState],
macs: &[MacAddr],
) -> Result<Vec<IpNeighLine>> {
let mut ip_set: HashSet<IpAddr> = ips.iter().map(|ip| ip.to_canonical()).collect();
let ip_m: HashSet<IpAddr> =
futures::future::try_join_all(machine_names.iter().map(|c| get_ips(c.as_ref())))
.await?
.into_iter()
.flatten()
.collect();
let ip_all = if ip_set.is_empty() && ip_m.is_empty() {
None
} else if ip_set.is_empty() {
Some(ip_m)
} else if ip_m.is_empty() {
Some(ip_set)
} else {
Some({
ip_set.retain(|c| ip_m.contains(c)); // inline AHHH
ip_set
})
};
let opt_dev = if devs.len() > 1 {
None
} else {
devs.iter().next().map(AsRef::as_ref)
};
let run_one = |to_ip: Option<IpAddr>| get_mac(to_ip, opt_dev, state);
let mut ip_filtered = if let Some(something) = ip_all {
if something.len() == 1 {
run_one(something.into_iter().next()).await?
} else {
run_one(None)
.await?
.into_iter()
.filter(|c| something.contains(&c.ip))
.collect()
}
} else {
run_one(None).await?
};
// Apply additional filters if any were provided
if !devs.is_empty() || !macs.is_empty() || !state.is_empty() {
let devset: HashSet<_> = devs.iter().map(AsRef::as_ref).collect();
let macset: HashSet<_> = macs.iter().collect();
ip_filtered.retain(|entry| {
// Dev filter: if we're filtering by dev, entry must have a dev AND it must be in the set
let dev_ok =
devset.is_empty() || entry.dev.as_deref().is_some_and(|d| devset.contains(d));
// MAC filter: if we're filtering by MAC, entry must have a MAC AND it must be in the set
let mac_ok = macset.is_empty() || entry.mac.is_some_and(|m| macset.contains(&m));
dev_ok && mac_ok
})
};
Ok(ip_filtered)
}
// /// the atomic get_macs. handle ONE thing only.
// pub async fn get_mac(
// ip: Option<IpAddr>,
// dev: Option<&str>,
// state: &[NUDState],
// ) -> Result<Vec<IpNeighLine>> {
// use lda_ipjs::subcommands::neighbor as ipjs_neigh;
// let ipjs_states: Vec<ipjs_neigh::NUDState> = state.iter().copied().map(Into::into).collect();
// let items = ipjs_neigh::json::get(ip, dev, &ipjs_states)
// .await
// .context("Calling ip -j neigh failed")?;
// let lines = items.into_iter().map(Into::into).collect();
// Ok(lines)
// }
/// 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 mut u = tokio::process::Command::new(cmd);
u.args(args);
let out = u.output().await?;
if !out.status.success() {
bail!(String::from_utf8_lossy(&out.stderr).into_owned());
}
let lines = String::from_utf8_lossy(&out.stdout);
let parsed = lines.lines().flat_map(arpparse::parse_ip_neigh_line);
let rows: Vec<IpNeighLine> = if let Some(d) = dev {
parsed.map(IpNeighLine::with_dev(d)).collect()
} else {
parsed.collect()
};
Ok(rows)
}
/*
/// get macs where you just run ip neigh then rust handles the filtering (faster than get mac)
pub async fn get_macs_rust(
machine_names: Option<&str>,
ips: Option<&[IpAddr]>,
devs: Option<&[&str]>,
states: Option<&[NUDState]>,
) -> Result<Vec<IpNeighLine>> {
let mut ip_map: HashSet<IpAddr> = HashSet::new();
let mut machine_map = HashSet::new();
if let Some(ips) = ips {
ip_map.extend(ips);
}
if let Some(m) = machine_names {
machine_map.extend(get_ips(m).await.into_iter().flatten());
}
let real = match (ip_map.is_empty(), machine_map.is_empty()) {
(true, true) => HashSet::new(),
(true, false) => machine_map,
(false, true) => ip_map,
(false, false) => ip_map.intersection(&machine_map).copied().collect(),
};
Ok(vec![])
}
pub async fn _get_machines(m: &[&str]) -> Vec<IpAddr> {
let futs = m.iter().map(|c| get_ips(c));
futures::future::join_all(futs)
.await
.into_iter()
.flat_map(|f| f.into_iter().flatten())
.collect()
} */
+7
View File
@@ -0,0 +1,7 @@
pub mod dev;
pub mod leases;
pub mod macs;
pub mod parser;
pub use leases::*;
pub use macs::*;
+44
View File
@@ -0,0 +1,44 @@
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 async 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).await {
return QueryType::Dev(s.to_string());
}
// Default: name last // it will fail also
QueryType::Unknown(s.to_string())
}
+11
View File
@@ -0,0 +1,11 @@
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,
)
}
-32
View File
@@ -1,32 +0,0 @@
use std::{io, net::IpAddr};
use tokio::net::UdpSocket;
use crate::utils::query::get_macs_2_mac;
pub async fn wake(machine_name: &str) -> io::Result<u32> {
let suh = UdpSocket::bind("0.0.0.0:0").await?;
suh.set_broadcast(true)?;
let /* mut */ macs = get_macs_2_mac(machine_name).await.unwrap_or_default();
// macs.extend(*LDA_MACS_2);
let mut sent_ok = 0;
for mac in macs {
let mb = mac.as_bytes();
let mut pac = [0; 6 + 6 * 16]; // 6x FF + 16x mac6
pac[..6].fill(0xff);
for i in 1..=16 {
pac[i * 6..(i + 1) * 6].copy_from_slice(mb);
}
match suh
.send_to(&pac, (IpAddr::from([192, 168, 100, 255]), 9))
.await
{
Ok(n) if n == pac.len() => sent_ok += 1,
Ok(n) => eprintln!("partial send ({n}/{})", pac.len()),
Err(e) => eprintln!("send error: {e}"),
}
}
Ok(sent_ok)
}
+69
View File
@@ -0,0 +1,69 @@
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 const fn to_incomplete(self) -> RouteWakeResult {
RouteWakeResult {
target: self,
status: RouteWakeStatus::Incomplete,
}
}
pub const 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,
}
}
}
+75
View File
@@ -0,0 +1,75 @@
//! why did my Head Ass split these into two.
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 {
const fn _new(ip: IpAddr, mac: MacAddr) -> Self {
Self { ip, mac }
}
const fn good(self) -> WakeTargetResult {
WakeTargetResult::new(self, WakeStatus::Success)
}
const fn bad(self) -> WakeTargetResult {
WakeTargetResult::new(self, WakeStatus::WrongSize)
}
const fn errored(self) -> WakeTargetResult {
WakeTargetResult::new(self, WakeStatus::NonexistentAddress)
}
}
impl WakeTargetResult {
const 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();
-336
View File
@@ -1,336 +0,0 @@
const qs = new URLSearchParams(location.search);
const $ = (id) => document.getElementById(id);
const elName = $("name");
const elCheck = $("check");
const elWake = $("wake");
const elLog = $("log");
const elHtml = $("html");
const elLeases = $("leases_html");
const pill = $("status-pill");
const link = $("permalink");
const elPreview = $("preview");
// One-time delegated click handler for all pickable links
function handlePickClick(e) {
const a = e.target && /** @type {HTMLElement} */ (e.target).closest("a.pick");
if (!a) return;
e.preventDefault();
const v = a.getAttribute("data-value");
pickTarget(v);
}
if (elHtml) elHtml.addEventListener("click", handlePickClick);
if (elLeases) elLeases.addEventListener("click", handlePickClick);
function setPill(kind, text) {
pill.className = `pill ${kind}`;
pill.textContent = text;
}
function setLink(name) {
const url = new URL(location.href);
if (name) url.searchParams.set("name", name);
else url.searchParams.delete("name");
history.replaceState(null, "", url);
link.href = url.toString();
}
function getName() {
return (elName.value || "").trim();
}
function extractHostLikeBackend(input) {
let s = String(input || "").trim();
if (!s) return "";
// strip scheme or network-path reference
const schemeIdx = s.indexOf("://");
if (schemeIdx >= 0) s = s.slice(schemeIdx + 3);
else if (s.startsWith("//")) s = s.slice(2);
// strip userinfo
const at = s.lastIndexOf("@");
if (at >= 0) s = s.slice(at + 1);
// bracketed IPv6
if (s.startsWith("[")) {
const end = s.indexOf("]");
if (end > 1) s = s.slice(1, end);
} else {
const slash = s.indexOf("/");
if (slash >= 0) s = s.slice(0, slash);
const colon = s.lastIndexOf(":");
if (colon > 0 && (s.match(/:/g) || []).length === 1) {
const port = s.slice(colon + 1);
if (/^\d+$/.test(port)) s = s.slice(0, colon);
}
}
return s.trim();
}
function updatePreview() {
if (!elPreview) return;
const raw = getName();
const host = extractHostLikeBackend(raw);
elPreview.textContent = host && host !== raw ? `→ ${host}` : "";
}
function pickTarget(value) {
const v = String(value || "").trim();
if (!v) return;
elName.value = v;
updatePreview();
saveName(v);
setLink(v);
fetchStatus(v);
}
function saveName(name) {
try {
localStorage.setItem("wakey:name", name);
} catch {}
}
function loadName() {
return qs.get("name") || localStorage.getItem("wakey:name") || "";
}
/** @param {String} s NUD state */
function rankState(s) {
const key = String(s || "")
.trim()
.toUpperCase();
return (
{
PERMANENT: 5,
REACHABLE: 5,
STALE: 4,
DELAY: 3,
PROBE: 3,
INCOMPLETE: 3,
NOARP: 2,
NONE: 1,
FAILED: 0,
}[key] ?? 0
);
}
function buildStatusUrl(name) {
// If no extra filters, use smart redirect for name/ip/mac/dev/nud detection
const hasExtraFilters = ["ip", "dev", "nud", "mac"].some(
(k) => qs.getAll(k).length
);
if (name && !hasExtraFilters) {
return new URL(`/api/smart/${encodeURIComponent(name)}`, location.origin);
}
const u = new URL("/api/status", location.origin);
if (name) u.searchParams.set("name", name);
for (const k of ["ip", "dev", "nud", "mac"]) {
const vals = qs.getAll(k);
for (const v of vals) u.searchParams.append(k, v);
}
return u;
}
/** @param {{ name?: String, table: Array<{ ip, dev, mac, state }>, filters: {ip, dev, nud, mac}} | { name?: String, error }} data from /api/status */
function renderStatus(data) {
const tbl = document.createElement("table");
tbl.className = "table";
tbl.innerHTML = `<tr><th>IP</th><th>MAC</th><th>State</th><th>IF</th></tr>`;
for (const row of data.table || []) {
const tr = document.createElement("tr");
const ip = row.ip || "";
const mac = row.mac || "";
const dev = row.dev || "";
const state = row.state || "";
tr.innerHTML = `
<td>${
ip
? `<a href="#" class="pick" data-value="${ip}" title="filter by ip">${ip}</a>`
: ""
}</td>
<td>${
mac
? `<a href="#" class="pick" data-value="${mac}" title="filter by mac">${mac}</a>`
: ""
}</td>
<td>${
state
? `<a href="#" class="pick" data-value="${state}" title="filter by state">${state}</a>`
: ""
}</td>
<td>${
dev
? `<a href="#" class="pick" data-value="${dev}" title="filter by interface">${dev}</a>`
: ""
}</td>`;
tbl.appendChild(tr);
}
elHtml.innerHTML = "";
// Optional: show applied filters
if (data.filters) {
const parts = [];
if (Array.isArray(data.filters.ip) && data.filters.ip.length)
parts.push(`ip=[${data.filters.ip.join(", ")}]`);
if (Array.isArray(data.filters.dev) && data.filters.dev.length)
parts.push(`dev=[${data.filters.dev.join(", ")}]`);
if (Array.isArray(data.filters.nud) && data.filters.nud.length)
parts.push(`nud=[${data.filters.nud.join(", ")}]`);
if (Array.isArray(data.filters.mac) && data.filters.mac.length)
parts.push(`mac=[${data.filters.mac.join(", ")}]`);
if (parts.length) {
const info = document.createElement("div");
info.className = "filters";
info.textContent = `Filters: ${parts.join("; ")}`;
elHtml.appendChild(info);
}
}
elHtml.appendChild(tbl);
// click-to-filter handled by a single, persistent delegated listener (set once at load)
if ((data.table || []).length > 0) {
const best = data.table.reduce((a, b) =>
rankState(b.state) > rankState(a.state) ? b : a
);
const r = rankState(best.state);
if (r >= 5) setPill("ok", "online");
else if (r >= 2) setPill("warn", "maybe");
else setPill("bad", "offline");
} else {
setPill("warn", "unknown");
}
}
async function fetchStatus(name) {
setPill("warn", "checking…");
const u = buildStatusUrl(name);
elLog.textContent = "GET " + u.pathname + u.search;
elHtml.innerHTML = "";
try {
const r = await fetch(u);
if (!r.ok) {
let msg = String(r.status);
try {
const err = await r.clone().json();
msg = err.error || JSON.stringify(err);
} catch {
msg = await r.text();
}
elLog.textContent = `status error: ${msg}`;
// elHtml.textContent = msg;
setPill("bad", "error");
return;
}
const data = await r.json();
renderStatus(data);
} catch (e) {
elLog.textContent = "status error: " + e;
setPill("bad", "error");
}
}
function renderLeases(leases) {
if (!elLeases) return;
if (!Array.isArray(leases) || leases.length === 0) {
elLeases.textContent = "no leases";
return;
}
const tbl = document.createElement("table");
tbl.className = "table";
tbl.innerHTML = `<tr><th></th><th>IP</th><th>MAC</th><th>Name</th><th>Expires</th></tr>`;
const nowSec = Math.floor(Date.now() / 1000);
for (const l of leases) {
const tr = document.createElement("tr");
const exp = Number(l.expires_epoch || 0);
const expired = exp > 0 && exp <= nowSec;
const when = exp > 0 ? new Date(exp * 1000) : null;
const whenText = when ? when.toLocaleString() : "";
let dotClass = "dot ok";
if (expired) {
dotClass = "dot bad";
} else if (typeof l?.rank === "number") {
if (l.rank >= 5) dotClass = "dot ok";
else if (l.rank >= 2) dotClass = "dot warn";
else dotClass = "dot bad";
}
const title = expired
? "expired"
: l?.nud_state
? String(l.nud_state).toLowerCase()
: "unknown";
const ip = l.ip || "";
const mac = l.mac || "";
const name = l.name || "";
tr.innerHTML = `
<td><span class="${dotClass}" title="${title}"></span></td>
<td>${
ip
? `<a href="#" class="pick" data-value="${ip}" title="filter by ip">${ip}</a>`
: ""
}</td>
<td>${
mac
? `<a href="#" class="pick" data-value="${mac}" title="filter by mac">${mac}</a>`
: ""
}</td>
<td>${
name
? `<a href="#" class="pick" data-value="${name}" title="filter by name">${name}</a>`
: ""
}</td>
<td><span class="tiny">${whenText}</span></td>`;
tbl.appendChild(tr);
}
elLeases.innerHTML = "";
elLeases.appendChild(tbl);
// click-to-filter handled by a single, persistent delegated listener (set once at load)
}
async function fetchLeases() {
try {
const r = await fetch("/api/dhcp_leases?include_state=1");
if (!r.ok) return;
const leases = await r.json();
renderLeases(leases);
} catch {}
}
async function sendWake(name) {
setPill("warn", "waking…");
elLog.textContent = "POST /wake?name=" + name;
try {
const r = await fetch(`/wake?name=${encodeURIComponent(name)}`, {
method: "POST",
// headers: { "X-Target-Name": name },
});
const t = await r.text();
elLog.textContent = t || "ok";
// Optional: recheck after medium delay
setTimeout(() => fetchStatus(name), 1500);
} catch (e) {
elLog.textContent = "wake error: " + e;
setPill("bad", "error");
}
}
elCheck.addEventListener("click", () => {
const name = getName();
if (!name) return;
saveName(name);
setLink(name);
fetchStatus(name);
});
elWake.addEventListener("click", () => {
const name = getName();
if (!name) return;
saveName(name);
setLink(name);
sendWake(name);
});
elName.addEventListener("keydown", (e) => {
if (e.key === "Enter") elCheck.click();
});
elName.addEventListener("input", updatePreview);
// init
const initial = loadName();
if (initial) {
elName.value = initial;
updatePreview();
setLink(initial); // when the permalink doing YOUR job
// auto-check on load in this A/B page
fetchStatus(initial);
fetchLeases();
} else {
setPill("warn", "unknown");
fetchLeases();
}
+36
View File
@@ -0,0 +1,36 @@
// import { filter_array } from "./status.js";
export const qs = new URLSearchParams(location.search);
// $ is normally queryselector are we fr
export const $ = (id) => document.getElementById(id);
export const elName = $("name");
export const elCheck = $("check");
export const elWake = $("wake");
export const elLog = $("log");
export const elHtml = $("html");
export const elLeases = $("leases_html");
export const pill = $("status-pill");
export const link = $("permalink");
export const elPreview = $("preview");
export function setPill(kind, text) {
pill.className = `pill ${kind}`;
pill.textContent = text;
}
export function setLink(name, clear) {
const url = new URL(location.href);
if (clear) url.searchParams.forEach((_, k) => url.searchParams.delete(k)); // FUCK
/* const hasExtraFilters = filter_array.some(
(k) => url.searchParams.getAll(k).length
);
if (hasExtraFilters) {
// jus returns whatever; they are specifying further
} else */ {
if (name) url.searchParams.set("name", name);
else url.searchParams.delete("name");
}
history.replaceState(null, "", url);
link.href = url.toString();
}
+82
View File
@@ -0,0 +1,82 @@
import { elLeases } from "./dom.js";
import { rankState } from "./utils.js";
/**
*
* @param {{
* expires_epoch: Number
* rank?: Number
* ip: String
* mac: String
* nud_state?: String
* name?: String
* }[]} leases
* @returns
*/
export function renderLeases(leases) {
if (!elLeases) return;
if (!Array.isArray(leases) || leases.length === 0) {
elLeases.textContent = "no leases";
return;
}
const tbl = document.createElement("table");
tbl.className = "table";
(
tbl.tHead || tbl.createTHead()
).innerHTML = `<tr><th></th><th>IP</th><th>MAC</th><th>Name</th><th>Expires</th></tr>`;
const tbd = tbl.tBodies.item(0) || tbl.createTBody();
const nowSec = Math.floor(Date.now() / 1000);
for (const l of leases) {
const tr = document.createElement("tr");
const exp = Number(l.expires_epoch || 0);
const expired = exp > 0 && exp <= nowSec;
const when = exp > 0 ? new Date(exp * 1000) : null;
const whenText = when ? when.toLocaleString() : "";
let dotClass = "dot ok";
if (expired) {
dotClass = "dot bad";
} else if (l?.nud_state) {
if (rankState(l.nud_state) >= 5) dotClass = "dot ok";
else if (rankState(l.nud_state) >= 2) dotClass = "dot warn";
else dotClass = "dot bad";
} else if (typeof l?.rank === "number") {
if (l.rank >= 5) dotClass = "dot ok";
else if (l.rank >= 2) dotClass = "dot warn";
else dotClass = "dot bad";
}
const title = expired
? "expired"
: l?.nud_state
? String(l.nud_state).toLowerCase()
: "unknown";
const ip = l.ip || "";
const mac = l.mac || "";
const name = l.name || "";
tr.innerHTML = `
<td><span class="${dotClass}" title="${title}"></span></td>
${Object.entries({ ip, mac, name })
.map(
([name, value]) =>
`<td>${
value &&
`<a href="#" class="pick" data-value="${value}" title="filter by ${name}">${value}</a>`
}</td>`
)
.join("\n")}
<td><span class="tiny">${whenText}</span></td>`;
tbd.appendChild(tr);
}
elLeases.innerHTML = "";
elLeases.appendChild(tbl);
}
export async function fetchLeases() {
try {
const r = await fetch("/api/dhcp_leases?include_state=1");
if (!r.ok) return;
const leases = await r.json();
renderLeases(leases);
} catch {}
}
+90
View File
@@ -0,0 +1,90 @@
import {
elName,
elCheck,
elWake,
elHtml,
elLeases,
elPreview,
setLink,
setPill,
qs,
} from "./dom.js";
import {
getName,
saveName,
loadName,
extractHostLikeBackend,
} from "./utils.js";
import { fetchStatus } from "./status.js";
import { fetchLeases } from "./leases.js";
import { sendWake } from "./wake.js";
// delegated click handler
function handlePickClick(e) {
const a = e.target && e.target.closest("a.pick");
if (!a) return;
e.preventDefault();
const v = a.getAttribute("data-value");
pickTarget(v);
}
if (elHtml) elHtml.addEventListener("click", handlePickClick);
if (elLeases) elLeases.addEventListener("click", handlePickClick);
function updatePreview() {
const raw = getName(elName);
const host = extractHostLikeBackend(raw);
elPreview.textContent = host && host !== raw ? `→ ${host}` : "";
return host;
}
function pickTarget(value) {
const v = String(value || "").trim();
if (!v) return;
elName.value = v;
updatePreview();
saveName(v);
setLink(v, true);
fetchStatus(v);
fetchLeases();
}
// events
elCheck.addEventListener("click", () => {
const name = getName(elName);
if (!name) return;
saveName(name);
setLink(name);
fetchStatus(name);
fetchLeases();
});
elWake.addEventListener("click", () => {
const name = getName(elName);
if (!name) return;
saveName(name);
setLink(name);
sendWake(name);
fetchLeases();
});
elName.addEventListener("keydown", (e) => {
if (e.key === "Enter") elCheck.click();
});
elName.addEventListener("input", updatePreview);
// init
const initial = loadName(qs);
if (initial) {
elName.value = initial;
updatePreview();
if (qs.has("name")) {
setLink(initial);
fetchStatus(initial);
} else {
fetchStatus();
}
fetchLeases();
} else {
setPill("warn", "unknown");
updatePreview();
fetchStatus();
fetchLeases();
}
+207
View File
@@ -0,0 +1,207 @@
import { elHtml, elLog, setPill, qs, pill } from "./dom.js";
import { rankState } from "./utils.js";
import { merge_wake_data, translate_wake_message } from "./wake.js";
// IpNeighLine
const status_map = {
ip: "ip",
mac: "mac",
state: "state",
dev: "interface",
};
export const status_array = Object.keys(status_map);
// Filters
export const filter_array = ["ips", "devs", "nuds", "macs"];
/**
*
* @param {String} name
* @returns {URL}
*/
function buildStatusUrl(name) {
const hasExtraFilters = filter_array.some((k) => qs.getAll(k).length);
if (name && !hasExtraFilters) {
return new URL(`/api/smart/${encodeURIComponent(name)}`, location.origin);
}
const u = new URL("/api/status", location.origin);
if (name) u.searchParams.set("name", name);
for (const k of filter_array) {
const vals = qs.getAll(k);
for (const v of vals) u.searchParams.append(k, v);
}
return u;
}
/**
*
* @param {{
* has_wake?: true
* table: {
* wake_status?: Boolean
* ips: String
* dev: String
* mac: String
* state: String
* }[]
* filters: {
* ips?: String[]
* devs?: String[]
* nuds?: String[]
* macs?: String[]
* }
* }} data
*/
export function renderStatus(data) {
const tbl = document.createElement("table");
tbl.className = "table";
tbl.innerHTML = `<thead><tr>${
data.has_wake ? "<th>Wake status</th>" : ""
}<th>IP</th><th>MAC</th><th>State</th><th>IF</th></tr></thead>`;
// sum hax
const tbd = tbl.tBodies.item(0) || tbl.createTBody();
for (const row of data.table) {
if (data.has_wake && !row.wake_status)
throw TypeError("specified has wake but no wake stats");
const tr = document.createElement("tr");
tr.innerHTML = `${
row.wake_status
? `<td><span class="dot ${
row.wake_status == "succeed" ? "ok" : "bad"
}" title="${translate_wake_message(row.wake_status)}"></span></td>`
: ""
}${Object.entries(status_map)
.map(([field, description]) => {
const value = row[field];
return `<td>${
value
? `<a href="#" class="pick" data-value="${value}" title="filter by ${description}">${value}</a>`
: ""
}</td>`;
})
.join("")}`;
tbd.appendChild(tr);
}
elHtml.innerHTML = "";
if (data.filters) {
const parts = [];
filter_array.forEach((field) => {
if (Array.isArray(data.filters[field]) && data.filters[field].length)
parts.push(`${field}=[${data.filters[field].join(", ")}]`);
});
if (parts.length) {
const info = document.createElement("div");
info.className = "filters";
info.textContent = `Filters: ${parts.join("; ")}`;
elHtml.appendChild(info);
}
}
elHtml.appendChild(tbl);
if ((data.table || []).length > 0) {
const best = data.table.reduce((a, b) =>
rankState(b.state) > rankState(a.state) ? b : a
);
const r = rankState(best.state);
if (r >= 5) setPill("ok", "online");
else if (r >= 2) setPill("warn", "maybe");
else setPill("bad", "offline");
if (data.filters.nuds?.length > 0) pill.textContent += " (filtered)";
} else {
setPill("warn", "unknown");
}
}
/**
* @param {String} name
* @param {Boolean} render
* @param {{
* ip: String,
* mac: String,
* status:
* "incomplete" | "succeed" | "nonexistent_address" | "wrong_size"
* }} data_wake
* @returns {{
* has_wake: false
* table: {
* ip: String
* dev: String
* mac: String
* state: String
* }[]
* filters: {
* ips?: String[]
* devs?: String[]
* nuds?: String[]
* macs?: String[]
* }
* } | {
* has_wake: true
* table: {
* wake_status: Boolean
* ips: String
* dev: String
* mac: String
* state: String
* }[]
* filters: {
* ips?: String[]
* devs?: String[]
* nuds?: String[]
* macs?: String[]
* }
* }}
*/
export async function fetchStatus(name, render = true, data_wake) {
setPill("warn", "checking…");
const u = buildStatusUrl(name);
elLog.textContent = "GET " + u.pathname + u.search;
try {
const r = await fetch(u);
if (!r.ok) {
let msg = String(r.status);
try {
/** @type {{error: string}} */
const err = await r.clone().json();
msg = err.error || JSON.stringify(err);
} catch {
msg = await r.text();
}
elLog.textContent = `status error: ${msg}`;
setPill("bad", "error");
return;
}
/**
* this one does not have data wake
* @type {{
* table: {
* ip: String
* dev: String
* mac: String
* state: String
* wake_status?: String
* }[]
* filters: {
* ips?: String[]
* devs?: String[]
* nuds?: String[]
* macs?: String[]
* }
* has_wake?: true
* }}
*/
const data = await r.json();
if (data_wake) {
data.table = merge_wake_data(data.table, data_wake);
data.has_wake = true;
}
if (render) renderStatus(data);
return data;
} catch (e) {
elLog.textContent = "status error: " + e;
setPill("bad", "error");
}
}
@@ -15,6 +15,13 @@ body {
padding: 0; padding: 0;
font-family: system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif; font-family: system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
} }
@media screen and (max-width: 768px) {
wrap {
margin-inline: 8px;
}
}
body { body {
display: flex; display: flex;
min-height: 100dvh; min-height: 100dvh;
+55
View File
@@ -0,0 +1,55 @@
export function getName(elName) {
return (elName.value || "").trim();
}
export function saveName(name) {
try {
localStorage.setItem("wakey:name", name);
} catch {}
}
export function loadName(qs) {
return qs.get("name") || localStorage.getItem("wakey:name") || "";
}
export function extractHostLikeBackend(input) {
let s = String(input || "").trim();
if (!s) return "";
const schemeIdx = s.indexOf("://");
if (schemeIdx >= 0) s = s.slice(schemeIdx + 3);
else if (s.startsWith("//")) s = s.slice(2);
const at = s.lastIndexOf("@");
if (at >= 0) s = s.slice(at + 1);
if (s.startsWith("[")) {
const end = s.indexOf("]");
if (end > 1) s = s.slice(1, end);
} else {
const slash = s.indexOf("/");
if (slash >= 0) s = s.slice(0, slash);
const colon = s.lastIndexOf(":");
if (colon > 0 && (s.match(/:/g) || []).length === 1) {
const port = s.slice(colon + 1);
if (/^\d+$/.test(port)) s = s.slice(0, colon);
}
}
return s.trim();
}
export function rankState(s) {
const key = String(s || "")
.trim()
.toUpperCase();
return (
{
PERMANENT: 5,
REACHABLE: 5,
STALE: 4,
DELAY: 3,
PROBE: 3,
INCOMPLETE: 3,
NOARP: 2,
NONE: 1,
FAILED: 0,
}[key] ?? 0
);
}
+135
View File
@@ -0,0 +1,135 @@
import { elHtml, elLog, setPill } from "./dom.js";
import { fetchStatus, renderStatus } from "./status.js";
/**
* @param {String} name just plain name
*/
export async function sendWake(name) {
const data = await fetchStatus(name, false);
if (!data) return; // can not proceed; theres nothing.
const wake_targets = data.table.map(({ ip, mac }) => {
return { ip, mac };
});
setPill("warn", "waking…");
elLog.textContent = "POST /api/wake";
try {
const r = await fetch(`/api/wake`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(wake_targets),
});
if (!r.ok) {
let msg = String(r.status);
try {
const err = await r.clone().json();
msg = err.error || JSON.stringify(err);
} catch {
msg = await r.text();
}
elLog.textContent = `wake error: ${msg}`;
setPill("bad", "error");
return;
}
const j = await r.json();
if (!j.success) {
elLog.textContent = `wake error: ${j.error}`;
setPill("bad", "error");
return;
}
const result = j.result;
data.table = merge_wake_data(data.table, result);
data.has_wake = true;
renderStatus(data);
setTimeout(() => fetchStatus(name, undefined, result), 2000); // long ass timeout
} catch (e) {
elLog.textContent = "wake error: " + e;
setPill("bad", "error");
}
}
/**
*
* @param {{
* ip: String
* dev: String
* mac: String
* state: String
* }[]} table
* @param {{
* ip: String,
* mac: String,
* status: "incomplete" | "succeed" | "nonexistent_address" | "wrong_size"
* }[]} wake
* @returns {{
* ip: String
* dev: String
* mac: String
* state: String
* }[] | {
* ip: String
* dev: String
* mac: String
* state: String
* wake_status: boolean
* }[]}
*/
export function merge_wake_data(table, wake) {
if (!wake) return table;
if (wake.length != table.length)
throw TypeError(
"wake status table and status table not of the same length"
);
const return_array = [];
let linear_failed = false;
for (const [index, entry] of table.entries()) {
if (wake[index].ip != entry.ip || wake[index].mac != entry.mac) {
linear_failed = true;
break;
} // use alternative method
return_array.push({ wake_status: wake[index].status, ...entry });
}
// never happening AHH
if (linear_failed) {
return_array = [];
const wake_map = new Map();
wake.forEach(({ ip, mac, status }) => {
wake_map.set(JSON.stringify({ ip, mac }), status);
});
return_array = table.map((entry) => {
const { ip, mac } = entry;
return {
wake_status: wake_map.get(JSON.stringify({ ip, mac })),
...entry,
};
});
}
//
return return_array;
}
/**
*
* @param {"incomplete" | "succeed" | "nonexistent_address" | "wrong_size" | any} wake_msg
* @returns {string}
*/
export function translate_wake_message(wake_msg) {
switch (wake_msg) {
case "incomplete":
return "Incomplete address (both ip and MAC required)"
case "succeed":
return "Wake request sent successfully"
case "nonexistent_address":
return "Errored pinging this address (nonexistent address)"
case "wrong_size":
return "Wake request malformed"
default:
return "Unknown"
}
}
+4 -5
View File
@@ -4,8 +4,8 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>wakey • home</title> <title>wakey • home</title>
<link rel="stylesheet" href="/home_2.css" /> <link rel="stylesheet" href="home_2/styles.css" />
<script type="module" src="/home_2.js" defer></script> <script type="module" src="home_2/main.js" defer></script>
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
@@ -27,11 +27,10 @@
<div class="row muted" style="gap: 16px"> <div class="row muted" style="gap: 16px">
<span <span
>Uses query >Uses query
<span title="available keys: name, ip, mac, dev, nud" <span title="available keys: name, ips, macs, devs, nuds"
>(?name=...)</span >(?name=...)</span
> >
on this page to view the status.<!-- and header (X-Target-Name) so either extractor on this page to view the status.</span
path works. --></span
><span id="preview" class="tiny"></span ><span id="preview" class="tiny"></span
><a id="permalink" href="#">permalink</a> ><a id="permalink" href="#">permalink</a>
</div> </div>