6 Commits
37 changed files with 967 additions and 1148 deletions

No files matched your search

Generated
+27 -4
View File
@@ -627,7 +627,7 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "lda-ipjs"
version = "0.0.1"
version = "0.0.2"
dependencies = [
"anyhow",
"futures",
@@ -1389,12 +1389,35 @@ dependencies = [
"serde",
"serde_html_form",
"serde_json",
"serde_with",
"strum",
"thiserror 2.0.18",
"tokio",
"tower-http",
"urlencoding",
"wakey-core",
"wakey-linux",
]
[[package]]
name = "wakey-core"
version = "0.1.0"
dependencies = [
"macaddr",
"serde",
"serde_with",
"strum",
"thiserror 2.0.18",
]
[[package]]
name = "wakey-linux"
version = "0.1.0"
dependencies = [
"anyhow",
"futures",
"lda-ipjs",
"macaddr",
"serde_json",
"tokio",
"wakey-core",
]
[[package]]
+3 -4
View File
@@ -14,9 +14,6 @@ macaddr = { version = "1", features = ["serde", "serde_std"] }
serde = { version = "1", features = ["derive"] }
serde_html_form = "0"
serde_json = "1"
serde_with = { version = "3", features = ["json"] }
strum = { version = "0", features = ["derive", "strum_macros"] }
thiserror = "2"
tokio = { version = "1", features = [
"fs",
"process",
@@ -26,6 +23,8 @@ tokio = { version = "1", features = [
] }
tower-http = { version = "0", features = ["fs"] }
urlencoding = "2"
wakey-core = { path = "wakey-core" }
wakey-linux = { path = "wakey-linux" }
[profile.release]
opt-level = "z"
@@ -37,7 +36,7 @@ very-smart-parsing = [
] # this is the a-bit-redundant parse thing that copilot made
[workspace]
members = ["ipjs"]
members = ["ipjs", "wakey-core", "wakey-linux"]
[dependencies.lda-ipjs]
path = "ipjs"
+10 -13
View File
@@ -2,19 +2,16 @@
router shit
## usage
## whatever i did must be BEAUTIFUL
- Build (Windows host OK):
- cargo build --release --target armv7-unknown-linux-musleabihf
- Run on a Linux box/router:
- copy target/armv7-unknown-linux-musleabihf/release/wakey to the device
- chmod +x wakey && ./wakey
- Install via release asset (OpenWrt):
- wget -O- `https://your.gitea/owner/repo/releases/download/vX.Y.Z/wakey-rootfs-vX.Y.Z-armv7-unknown-linux-musleabihf.tgz` | tar -xz -C /
- /etc/init.d/wakey enable && /etc/init.d/wakey start
read [in vietnamese](https://ldlda.com/blogs/else/ssh-ax3000cv2-update-firmware-0-10-2)
Optional updater (fetch latest automatically on OpenWrt):
## now i NEED the split arch to be on
```sh
WAKEY_HOST=git.ldlda.com WAKEY_OWNER=lda WAKEY_REPO=wakey sh /etc/ldlda_help/update_wakey.sh
```
like RN rn rn so i can have web outside and the lil agent can be sitting here configuring itself (Parse/append to rc.local files? add to init.d? fuhh)
I NEED IT
I NEEED IT
I NEEEEEED it.
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "lda-ipjs"
description = "ip -j show schemas"
version = "0.0.1"
version = "0.0.2"
edition = "2024"
publish = ["gitea"]
+3 -2
View File
@@ -14,8 +14,9 @@ pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<AddrOutput>> {
link = link.match_name(dev.to_owned());
if let Some(ind) = link.execute().try_next().await?.map(|a| a.header.index) {
address = address.set_link_index_filter(ind);
}
};
address.execute().try_next().await?;
todo!()
} else {
};
todo!();
}
+1
View File
@@ -0,0 +1 @@
//! this is for link. You need link; at least to build an index -> name map. i Need It. sometimes.
+63 -66
View File
@@ -1,6 +1,5 @@
//! this is purely experimental. im not doing ts no mo
//! rtnetlink-based neighbor table query. One syscall, filter in userspace.
// hallo
use std::{
collections::{HashMap, HashSet},
net::IpAddr,
@@ -16,115 +15,113 @@ use rtnetlink::packet_route::{
use super::{NUDState, NeighborItem};
// dont you love https://github.com/rust-netlink/rtnetlink/blob/main/examples/get_neighbours.rs
// NeighborItem.state guarantees to be a single thing.
/// Fetch neighbors via rtnetlink. Empty slice = no filter (match all).
/// Non-empty slice = match ANY in the set.
// https://github.com/rust-netlink/rtnetlink/blob/main/examples/get_neighbours.rs
pub async fn get(
ip: Option<IpAddr>,
dev: Option<&str>,
nud: &[NUDState],
ips: &[IpAddr],
devs: &[impl AsRef<str>],
nuds: &[NUDState],
macs: &[MacAddr],
) -> 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);
tokio::spawn(conn);
// map ifindex to name
let mut ball: HashMap<u32, String> = HashMap::new();
let mut neighbor_data = handle.neighbours().get().execute();
// Build filter sets (empty = match all)
let ip_set: HashSet<IpAddr> = ips.iter().copied().collect();
let dev_set: HashSet<&str> = devs.iter().map(AsRef::as_ref).collect();
let nud_set: HashSet<&NUDState> = nuds.iter().collect();
let mac_set: HashSet<MacAddr> = macs.iter().copied().collect();
// Cache ifindex -> name
let mut ifname_cache: HashMap<u32, String> = HashMap::new();
let mut result = vec![];
'big: while let Some(neighbour_message_item) = neighbor_data.try_next().await? {
// Filter by address family
'row: while let Some(msg) = neighbor_data.try_next().await? {
// Only IPv4/IPv6, skip NOARP
if !matches!(
neighbour_message_item.header.family,
msg.header.family,
AddressFamily::Inet | AddressFamily::Inet6
) || matches!(neighbour_message_item.header.state, NeighbourState::Noarp)
// copilot says this to match ip -j n s
) || matches!(msg.header.state, NeighbourState::Noarp)
{
continue 'big;
continue 'row;
}
let state = vec![
neighbour_message_item
.header
.state
.try_into()
.unwrap_or_default(),
]; // ONE ITEM. why tf ts design json.
let state: NUDState = msg.header.state.try_into().unwrap_or_default();
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,
for attr in msg.attributes {
match attr {
NeighbourAttribute::Destination(addr) => match addr {
NeighbourAddress::Inet(v4) => ip = Some(IpAddr::from(v4)),
NeighbourAddress::Inet6(v6) => ip = Some(IpAddr::from(v6)),
_ => continue 'row,
},
NeighbourAttribute::LinkLocalAddress(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,
NeighbourAttribute::LinkLocalAddress(bytes) => {
mac = match bytes.len() {
6 => bytes.first_chunk::<6>().map(|&b| MacAddr::from(b)),
8 => bytes.first_chunk::<8>().map(|&b| MacAddr::from(b)),
_ => continue 'row,
}
}
_ => continue,
_ => {}
}
}
// exquisite
let dev = if let Some(cached) = ball.get(&neighbour_message_item.header.ifindex) {
Some(cached.clone())
} else {
// Query and cache
// Resolve ifindex -> name (cached)
let dev = match ifname_cache.get(&msg.header.ifindex) {
Some(name) => Some(name.clone()),
None => {
let name = handle
.link()
.get()
.match_index(neighbour_message_item.header.ifindex)
.match_index(msg.header.ifindex)
.execute()
.try_next()
.await?
.and_then(|a| {
a.attributes.into_iter().find_map(|attr| match attr {
LinkAttribute::IfName(name) => Some(name),
.and_then(|link| {
link.attributes.into_iter().find_map(|a| match a {
LinkAttribute::IfName(n) => Some(n),
_ => None,
})
});
if let Some(ref n) = name {
ball.insert(neighbour_message_item.header.ifindex, n.clone());
ifname_cache.insert(msg.header.ifindex, n.clone());
}
name
}
};
let (Some(ip), Some(dev)) = (ip, dev) else {
continue 'big;
continue 'row;
};
{
// low block
if let Some(fip) = gip
&& fip != ip
{
continue 'big;
// Apply filters (empty set = match all)
if !ip_set.is_empty() && !ip_set.contains(&ip) {
continue 'row;
}
if let Some(fdev) = gdev
&& dev != fdev
{
continue 'big;
if !dev_set.is_empty() && !dev_set.contains(dev.as_str()) {
continue 'row;
}
if !nudset.is_empty() && !nudset.contains(&state[0]) {
continue 'big;
};
if !nud_set.is_empty() && !nud_set.contains(&state) {
continue 'row;
}
if !mac_set.is_empty() && !mac.is_some_and(|m| mac_set.contains(&m)) {
continue 'row;
}
result.push(NeighborItem {
ip,
dev: Some(dev),
mac,
state,
state: vec![state],
});
}
Ok(result) // now i need another pass to filter out the uh.
Ok(result)
}
impl TryFrom<NeighbourState> for NUDState {
@@ -140,7 +137,7 @@ impl TryFrom<NeighbourState> for NUDState {
NeighbourState::Permanent => Ok(Self::Permanent),
NeighbourState::None => Ok(Self::None),
NeighbourState::Other(e) => Ok(Self::Other(e)),
_ => Err(u16::MAX), // idk
_ => Err(u16::MAX),
}
}
+2 -2
View File
@@ -4,7 +4,7 @@ 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?;
let result = neighbor::nl::get(&[], &[] as &[&str], &[], &[]).await?;
println!("netlink results: {:?}", result);
Ok(()) // ← Don't force error, let it succeed
}
@@ -95,7 +95,7 @@ async fn ball_compare_backends() -> anyhow::Result<()> {
println!("Got {} entries from JSON", json_result.len());
println!("\n=== Netlink Backend ===");
let nl_result = neighbor::nl::get(None, None, &[]).await?;
let nl_result = neighbor::nl::get(&[], &[] as &[&str], &[], &[]).await?;
println!("Got {} entries from netlink", nl_result.len());
// Compare counts
+17
View File
@@ -0,0 +1,17 @@
# this does one thing
param(
[string]$PASSWD
)
# idk what this does it works like that then thats how it is
pscp.exe -l root -scp -pw $PASSWD -r 192.168.100.1:/etc/ldlda_help $PSScriptRoot
$initDir = Join-Path $PSScriptRoot 'init.d'
New-Item -ItemType Directory -Force -Path $initDir | Out-Null
# these are sum
$files = "update_wakey" , "wakey" , "update_tailscale" , "wireguard_setup"
$remote = $files.ForEach({ "/etc/init.d/$_" })
$remote | ForEach-Object {
pscp.exe -l root -scp -pw $PASSWD "192.168.100.1:$_" "$initDir\"
}
+2 -2
View File
@@ -69,10 +69,10 @@ latest_asset_url() {
HOST=$1 OWNER=$2 REPO=$3 ARCH=$4
API="https://$HOST/api/v1/repos/$OWNER/$REPO/releases/latest"
RESP=$(http_get "$API") || return 1
# Extract the first browser_download_url ending with our ARCH .tgz (BusyBox-friendly)
# Extract the LAST browser_download_url ending with our ARCH .tgz (BusyBox-friendly)
echo "$RESP" |
grep -o '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*'"$ARCH"'\.tgz"' |
head -n1 |
tail -n1 |
cut -d '"' -f 4
}
-30
View File
@@ -1,30 +0,0 @@
use std::net::AddrParseError;
use strum::Display;
use thiserror::Error;
#[derive(Debug, Display, Error)]
pub enum IPNeighParseError {
IpWhere, // i never seen a ip neigh where the first thing aint an ip
IpParseError(AddrParseError),
// DevWhere,
MacParseError(macaddr::ParseError),
StateWhere, // i never seen a ip neigh without the big FAILED at the end
StateParseError(strum::ParseError),
}
impl From<AddrParseError> for IPNeighParseError {
fn from(value: AddrParseError) -> Self {
Self::IpParseError(value)
}
}
impl From<macaddr::ParseError> for IPNeighParseError {
fn from(value: macaddr::ParseError) -> Self {
Self::MacParseError(value)
}
}
impl From<strum::ParseError> for IPNeighParseError {
fn from(value: strum::ParseError) -> Self {
Self::StateParseError(value)
}
}
-75
View File
@@ -1,75 +0,0 @@
//! r#impl AHHHHHH
use serde::{Deserialize, Deserializer, de};
use super::NUDState;
// Case-insensitive parsing for NUDState via manual Deserialize
impl<'de> Deserialize<'de> for NUDState {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s: &str = <&str as Deserialize>::deserialize(deserializer)?;
s.parse().map_err(de::Error::custom)
}
}
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
.into_iter()
.map(Into::into)
.max()
.unwrap_or(NUDState::None),
}
}
}
+2 -250
View File
@@ -1,250 +1,2 @@
// struct arp;
// async fn read_arp() -> io::Result<()> {
// let arp_file = tokio::fs::File::open("/proc/net/arp").await?;
// let arp_read = BufReader::new(arp_file);
// Ok(())
// }
//! ip neigh pass
use std::{net::IpAddr, str::FromStr};
use crate::utils::parse::mac;
use macaddr::MacAddr;
use serde_with::skip_serializing_none;
use strum::{Display, EnumString};
use crate::arpparse::error::IPNeighParseError;
mod error;
mod impls; // custom (de)serialization impls
/// ip neigh has some cool shit.
///
/// IP
/// dev DEV | None
/// lladdr MAC | None
/// status { permanent | noarp | stale | reachable | none | incomplete | delay | probe | failed } (ip neigh help)
///
/// so you can see its damn good
#[skip_serializing_none]
#[derive(Debug, PartialEq, Eq, Clone, Hash, serde::Serialize)]
pub struct IpNeighLine {
pub ip: IpAddr,
pub dev: Option<String>,
/// link layer address
#[serde(with = "mac::option_mac")]
pub mac: Option<MacAddr>,
/// Neighbour Unreachability Detection
pub state: NUDState,
}
// NUDState custom Deserialize now lives in arpparse/impl.rs; use serde_with OneOrMany for Vec
#[derive(
Debug, PartialEq, Eq, EnumString, Display, Clone, Copy, Hash, serde::Serialize, Default,
)]
#[strum(serialize_all = "UPPERCASE", ascii_case_insensitive)]
#[serde(rename_all = "UPPERCASE")]
pub enum NUDState {
/// the neighbour entry is valid forever and can
/// be only be removed administratively.
Permanent,
/// the neighbour entry is valid. No attempts to
/// validate this entry will be made but it can
/// be removed when its lifetime expires.
Noarp,
/// the neighbour entry is valid until the
/// reachability timeout expires.
Reachable,
/// the neighbour entry is valid but suspicious.
/// This option to ip neigh does not change the
/// neighbour state if it was valid and the
/// address is not changed by this command.
Stale,
/// 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,
/// 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 {
/// Argument form expected by `ip neigh ... nud <state>` (lowercase)
pub const fn as_ip_neigh_arg(self) -> &'static str {
match self {
NUDState::Permanent => "permanent",
NUDState::Reachable => "reachable",
NUDState::Stale => "stale",
NUDState::Delay => "delay",
NUDState::Probe => "probe",
NUDState::Incomplete => "incomplete",
NUDState::Noarp => "noarp",
NUDState::None => "none",
NUDState::Failed => "failed",
}
}
/// dumb UI label
pub fn _dumber_state(&self) -> &'static str {
match self {
NUDState::Permanent | NUDState::Reachable => "online",
NUDState::Stale => "maybe online",
NUDState::Delay | NUDState::Probe | NUDState::Incomplete => "resolving",
NUDState::Noarp => "static",
NUDState::None => "unknown",
NUDState::Failed => "offline",
}
}
/// dumb boolean: Some(true)=on, Some(false)=off, None=shrug
pub fn _dumber_state_this_way(&self) -> Option<bool> {
match self {
NUDState::Permanent | NUDState::Reachable => Some(true),
NUDState::Failed => Some(false),
_ => None,
}
}
}
// thanks copilot for the PEAK
pub fn parse_ip_neigh_line(s: &str) -> Result<IpNeighLine, IPNeighParseError> {
let mut it = s.split_whitespace();
let ip: IpAddr = it.next().ok_or(IPNeighParseError::IpWhere)?.parse()?;
let mut dev: Option<String> = None;
let mut mac: Option<MacAddr> = None;
let mut state: Option<NUDState> = None;
let mut last_tok: Option<&str> = None;
while let Some(tok) = it.next() {
match tok {
"dev" => dev = it.next().map(str::to_string),
"lladdr" => {
mac = it.next().map(|m| m.parse()).transpose()?;
}
"nud" => {
// we know this aint happening
state = it.next().map(|st| st.parse()).transpose()?;
}
other => last_tok = Some(other),
}
}
// If no explicit "nud", many outputs end with STATE
if state.is_none()
&& let Some(st) = last_tok
{
state = Some(st.parse()?);
}
Ok(IpNeighLine {
ip,
dev,
mac,
state: state.ok_or(IPNeighParseError::StateWhere)?,
})
}
impl FromStr for IpNeighLine {
type Err = IPNeighParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
parse_ip_neigh_line(s)
}
}
/// pls dont touch ts
impl IpNeighLine {
/* // these are some not needed fns
pub fn set_ip(&mut self, ip: IpAddr) {
self.ip = ip;
}
pub fn set_state(&mut self, state: NUDState) {
self.state = state;
}
pub fn ip(self, ip: IpAddr) -> Self {
Self { ip, ..self }
}
pub fn state(self, state: NUDState) -> Self {
Self { state, ..self }
}
*/
pub fn _with_dev(dev: impl Into<String>) -> impl FnMut(Self) -> Self {
let dev = dev.into();
move |self_| Self {
dev: Some(dev.clone()),
..self_
}
}
pub fn _with_mac(mac: MacAddr) -> impl FnMut(Self) -> Self {
move |self_| Self {
mac: Some(mac),
..self_
}
}
}
// ideas from copilot:
impl NUDState {
// higher is "better"/more online
pub const fn rank(self) -> u8 {
match self {
NUDState::Permanent | NUDState::Reachable => 5,
NUDState::Stale => 4,
NUDState::Delay | NUDState::Probe | NUDState::Incomplete => 3,
NUDState::Noarp => 2,
NUDState::None => 1,
NUDState::Failed => 0,
}
}
}
impl PartialOrd for NUDState {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for NUDState {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.rank().cmp(&other.rank())
}
}
impl IpNeighLine {
// score for “local and online”: state, has-mac, v4, iface preference
pub fn _score(&self) -> (u8, u8, u8, u8) {
let iface = self
.dev
.as_deref()
.map(|d| {
if d.starts_with("br") || d.starts_with("lan") || d.starts_with("eth") {
2
} else if d.starts_with("wlan") || d.starts_with("wl") {
1
} else {
0
}
})
.unwrap_or(0);
(
self.state.rank(),
self.mac.is_some() as u8,
matches!(self.ip, IpAddr::V4(_)) as u8,
iface,
)
}
}
pub use wakey_core::{NeighborEntry as IpNeighLine, NeighborParseError as IPNeighParseError};
pub use wakey_core::{NeighborState as NUDState, parse_neighbor_line};
+4 -84
View File
@@ -1,84 +1,4 @@
/// MAC->name cache location (ephemeral)
const MAC_NAME_CACHE: &str = "/tmp/wakey_mac_names.json";
/// Load MAC->name cache from disk
pub(crate) async fn load_mac_name_cache() -> io::Result<std::collections::BTreeMap<String, String>>
{
match tokio::fs::read_to_string(MAC_NAME_CACHE).await {
Ok(s) => serde_json::from_str(&s).map_err(io::Error::other),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Default::default()),
Err(e) => Err(e),
}
}
/// Save MAC->name cache to disk
async fn save_mac_name_cache(map: &std::collections::BTreeMap<String, String>) -> io::Result<()> {
let s = serde_json::to_string(map).map_err(io::Error::other)?;
let _ = tokio::fs::write(MAC_NAME_CACHE, s).await;
Ok(())
}
/// Read all leases, filling names from MAC->name cache if missing
pub async fn read_dhcp_leases_with_names() -> io::Result<Vec<DhcpLeaseLine>> {
let leases = read_dhcp_leases().await?;
let mut cache = load_mac_name_cache().await.unwrap_or_default();
let mut changed = false;
let mut leases_with_names = Vec::with_capacity(leases.len());
for mut l in leases {
let mac_s = l.mac.to_string();
if let Some(ref name) = l.name {
// if no name in cache file or it changed
if cache.get(&mac_s).map(|v| v != name).unwrap_or(true) {
cache.insert(mac_s, name.clone());
changed = true;
}
} else if let Some(prev) = cache.get(&mac_s) {
l.name = Some(prev.clone());
}
leases_with_names.push(l);
}
if changed {
let _ = save_mac_name_cache(&cache).await;
}
Ok(leases_with_names)
}
use crate::utils::parse::mac;
use macaddr::MacAddr;
use std::io::{self, ErrorKind};
use std::net::IpAddr;
/// A single line from /tmp/dhcp.leases
#[derive(Debug, Clone, serde::Serialize)]
pub struct DhcpLeaseLine {
/// Epoch seconds when the lease expires
pub expires_epoch: u64,
pub ip: IpAddr,
#[serde(with = "mac")]
pub mac: MacAddr,
pub name: Option<String>,
}
/// Parse one line of /tmp/dhcp.leases
pub fn parse_dhcp_lease_line(line: &str) -> Option<DhcpLeaseLine> {
let mut c = line.split_whitespace();
let expires_epoch: u64 = c.next()?.parse().ok()?;
let mac = c.next()?.parse().ok()?;
let ip = c.next()?.parse().ok()?;
let name = c.next().filter(|c| *c != "*").map(str::to_string);
// ignore any remaining columns (e.g., client-id)
Some(DhcpLeaseLine {
expires_epoch,
ip,
mac,
name,
})
}
/// Read all leases from /tmp/dhcp.leases (simple and fast)
pub async fn read_dhcp_leases() -> io::Result<Vec<DhcpLeaseLine>> {
match tokio::fs::read_to_string("/tmp/dhcp.leases").await {
Ok(file) => Ok(file.lines().filter_map(parse_dhcp_lease_line).collect()),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e),
}
}
pub use wakey_core::DhcpLease as DhcpLeaseLine;
pub use wakey_linux::dhcp::{
load_mac_name_cache, parse_dhcp_lease_line, read_dhcp_leases, read_dhcp_leases_with_names,
};
+3 -2
View File
@@ -1,11 +1,12 @@
use crate::route::error::ApiError;
use crate::utils::query::parser::{QueryType, parse_query};
use wakey_core::{DeviceFilters as Filters, DeviceQuery};
use axum::Json;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::{extract::Path, response::Redirect};
use crate::route::status::{DeviceQuery, Filters, NamePath};
use crate::route::status::NamePath;
use crate::utils::query::get_ips;
// Smart redirect: accept IP, MAC, dev, or NUD state and redirect to /api/status accordingly
@@ -42,7 +43,7 @@ pub async fn status_smart_redirect(
},
..Default::default()
},
QueryType::Unknown(n) => DeviceQuery {
QueryType::Name(n) => DeviceQuery {
name: Some(n),
..Default::default()
},
+5 -5
View File
@@ -6,15 +6,15 @@ use axum::{
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct ApiError {
pub struct ApiError<T: Serialize> {
#[serde(skip_serializing, skip_deserializing)]
pub code: StatusCode,
pub error: String,
pub error: T,
}
impl ApiError {
impl<T: Serialize> ApiError<T> {
/// [StatusCode::INTERNAL_SERVER_ERROR] shortcut
pub const fn ise(error: String) -> Self {
pub const fn ise(error: T) -> Self {
Self {
code: StatusCode::INTERNAL_SERVER_ERROR,
error,
@@ -22,7 +22,7 @@ impl ApiError {
}
}
impl IntoResponse for ApiError {
impl<T: Serialize> IntoResponse for ApiError<T> {
fn into_response(self) -> Response {
(self.code, Json(self)).into_response()
}
+3 -4
View File
@@ -12,6 +12,7 @@ use crate::route::api::status_redirect;
use crate::route::api::status_smart_redirect;
use crate::route::devs::devs_router;
use crate::route::dhcp::get_dhcp_leases;
use crate::route::error::ApiError;
use crate::route::status::get_status_json;
use crate::route::wake::wake_multi;
@@ -46,11 +47,9 @@ pub fn api_router() -> Router {
.route("/ips/{name}", get(ip))
.route(
"/mac-cache",
get(|| async {
match load_mac_name_cache().await {
get(async || match load_mac_name_cache().await {
Ok(h) => Json(h).into_response(),
Err(e) => e.to_string().into_response(),
}
Err(e) => ApiError::ise(e.to_string()).into_response(),
}),
)
.layer(middleware::from_fn(add_performance_header))
+1 -44
View File
@@ -1,51 +1,8 @@
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 use wakey_core::{DeviceFilters as Filters, DeviceQuery, NamePath, Status};
pub async fn get_status_json(
Query(DeviceQuery {
-112
View File
@@ -1,112 +0,0 @@
//! impls are at [`utils::wake::impl`](crate::utils::wake::r#impl) for some reason
use std::io;
use std::net::IpAddr;
/* use crate::arpparse::IpNeighLine;
use crate::route::api::Status; */
use crate::utils::parse::mac;
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 {
// }
+60
View File
@@ -0,0 +1,60 @@
//! impls are at [`utils::wake::impl`](crate::utils::wake::r#impl) for some reason
use std::io;
/* use crate::arpparse::IpNeighLine;
use crate::route::api::Status; */
use crate::utils::wake::wake_one;
use crate::route::error::ApiError;
use axum::{extract::Json, http::StatusCode, response::IntoResponse};
use futures::TryFutureExt;
use tokio::net::UdpSocket;
pub use wakey_core::{WakeResult, WakeStatus as WakeTargetStatus, WakeTarget, WakeTargetResult};
pub async fn wake_multi(Json(req): Json<Vec<WakeTarget>>) -> impl IntoResponse {
match wake_multi_split(req).await {
Ok(result) => (StatusCode::OK, Json(WakeResult { result })).into_response(),
Err(error) => {
let error = format!("Error: {}", error);
ApiError::ise(error).into_response()
}
}
}
/// this is so bad
pub async fn wake_multi_split(
targets: impl IntoIterator<Item = WakeTarget>,
) -> io::Result<Vec<WakeTargetResult>> {
let sock = UdpSocket::bind("[::]:0")
.or_else(|_| UdpSocket::bind(":0"))
.await?;
sock.set_broadcast(true)?;
let iter = targets.into_iter().map(async |c| {
if !c.is_complete() {
WakeTargetResult::incomplete(c)
} 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 {
// }
+1 -65
View File
@@ -1,65 +1 @@
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)
}
pub use wakey_linux::devices::{devs_sorted, has_dev};
+2 -43
View File
@@ -1,43 +1,2 @@
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()
}
pub use wakey_core::DhcpLeaseWithState as DhcpLeaseOut;
pub use wakey_linux::dhcp::enrich_leases_with_nud_state;
+9 -154
View File
@@ -1,173 +1,28 @@
use lda_ipjs::subcommands::neighbor;
use macaddr::MacAddr;
use crate::arpparse::{self, IpNeighLine, NUDState};
use anyhow::{Context, Result, bail};
use std::collections::HashSet;
use anyhow::Result;
use std::net::IpAddr;
use crate::arpparse::{IpNeighLine, NUDState};
pub async fn get_ips(machine_name: &str) -> Result<impl Iterator<Item = IpAddr>> {
Ok(tokio::net::lookup_host((machine_name, 0))
.await
.with_context(|| format!("DNS resolve failed for {machine_name}"))?
.map(|c| c.ip()))
wakey_linux::devices::get_ips(machine_name).await
}
// good now
//
// Current logic: When filtering by exactly 1 dev/mac, exclude entries missing that field.
// This is because missing dev/mac usually means the entry is incomplete/transient.
//
// when there is only one MACs (getmac got some), the result will not have them fields.
// so there are three cases:
//
// 1. dont got nothing: take all of them (macset.is_empty())
// 2. exactly one: pre-filtered by ip, everything matches,
// devset.len() != 1 returns false, but then it works????
// OH THIS fuckass code i added it in the get_mac
// 3. devset.len() > 1. if none then absolutely not match,
// if some then check with the set; thats normal
pub async fn get_macs(
machine_names: &[impl AsRef<str>],
ips: &[IpAddr],
devs: &[impl AsRef<str>],
state: &[NUDState],
macs: &[MacAddr],
macs: &[macaddr::MacAddr],
) -> Result<Vec<IpNeighLine>> {
let mut ip_set: HashSet<IpAddr> = ips.iter().map(|ip| ip.to_canonical()).collect();
let ip_m: HashSet<IpAddr> = if !machine_names.is_empty() {
futures::future::try_join_all(machine_names.iter().map(|c| get_ips(c.as_ref())))
.await?
.into_iter()
.flatten()
.collect()
} else {
Default::default()
};
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() {
let devset: HashSet<_> = devs.iter().map(AsRef::as_ref).collect();
let macset: HashSet<_> = macs.iter().collect();
ip_filtered.retain(|entry| {
// Dev filter: if we're filtering by dev, entry must have a dev AND it must be in the set
let dev_ok =
devset.is_empty() || entry.dev.as_deref().is_some_and(|d| devset.contains(d));
// MAC filter: if we're filtering by MAC, entry must have a MAC AND it must be in the set
let mac_ok = macset.is_empty() || entry.mac.is_some_and(|m| macset.contains(&m));
dev_ok && mac_ok
})
};
Ok(ip_filtered)
wakey_linux::devices::get_neighbors(machine_names, ips, devs, state, macs).await
}
// /// the atomic get_macs. handle ONE thing only.
// // this one sucks shit
// 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.
// 17 - 25 ms full
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)
}
// 15 - 20 ms full
pub async fn get_mac(
ip: Option<IpAddr>,
dev: Option<&str>,
state: &[NUDState],
) -> Result<Vec<IpNeighLine>> {
let state2: Vec<neighbor::NUDState> = state.iter().copied().map(Into::into).collect();
Ok(neighbor::nl::get(ip, dev, &state2)
.await
.context("rtnetlink failed")?
.into_iter()
.map(Into::into)
.collect())
// how did i just do that
let ips: Vec<IpAddr> = ip.into_iter().collect();
let devs: Vec<&str> = dev.into_iter().collect();
get_macs(&[] as &[&str], &ips, &devs, state, &[]).await
}
+2 -41
View File
@@ -1,44 +1,5 @@
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 use wakey_core::QueryInput as QueryType;
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())
wakey_linux::devices::classify_query(q).await
}
+9
View File
@@ -0,0 +1,9 @@
pub async fn wake_one(
sock: &tokio::net::UdpSocket,
t: wakey_linux::wake::CompleteWakeTarget,
) -> wakey_core::WakeTargetResult {
wakey_linux::wake::wake_one(sock, t).await
}
pub use wakey_linux::wake::{CompleteWakeTarget as WakeTarget, wake_many as _wake_multi};
pub use wakey_core::{WakeStatus, WakeTargetResult};
-69
View File
@@ -1,69 +0,0 @@
use super::{WakeStatus, WakeTarget, WakeTargetResult};
use crate::route::wake::{
WakeTarget as RouteWakeTarget, WakeTargetResult as RouteWakeResult,
WakeTargetStatus as RouteWakeStatus,
};
#[derive(Debug, Clone, Copy)]
pub struct Incomplete;
impl TryFrom<RouteWakeTarget> for WakeTarget {
type Error = Incomplete;
fn try_from(value: RouteWakeTarget) -> Result<Self, Self::Error> {
if let RouteWakeTarget {
ip: Some(ip),
mac: Some(mac),
} = value
{
Ok(Self { ip, mac })
} else {
Err(Incomplete)
}
}
}
impl From<WakeTarget> for RouteWakeTarget {
fn from(WakeTarget { ip, mac }: WakeTarget) -> Self {
Self {
ip: Some(ip),
mac: Some(mac),
}
}
}
impl From<WakeTargetResult> for RouteWakeResult {
fn from(WakeTargetResult { target, status }: WakeTargetResult) -> Self {
Self {
target: target.into(),
status: status.into(),
}
}
}
impl RouteWakeTarget {
pub 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
@@ -1,75 +0,0 @@
//! 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();
+1 -1
View File
@@ -33,7 +33,7 @@ export async function sendWake(name) {
const j = await r.json();
if (!j.success) {
if (j.error) {
elLog.textContent = `wake error: ${j.error}`;
setPill("bad", "error");
return;
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "wakey-core"
version = "0.1.0"
edition = "2024"
[dependencies]
macaddr = { version = "1", features = ["serde", "serde_std"] }
serde = { version = "1", features = ["derive"] }
serde_with = { version = "3", features = ["json"] }
strum = { version = "0", features = ["derive", "strum_macros"] }
thiserror = "2"
+4
View File
@@ -0,0 +1,4 @@
pub mod model;
pub mod parse;
pub use model::*;
+242
View File
@@ -0,0 +1,242 @@
use macaddr::MacAddr;
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
use serde_with::{DisplayFromStr, OneOrMany, serde_as};
use std::{net::IpAddr, str::FromStr};
use strum::{Display, EnumString};
use crate::parse::mac;
#[skip_serializing_none]
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize)]
pub struct NeighborEntry {
pub ip: IpAddr,
pub dev: Option<String>,
#[serde(with = "mac::option_mac")]
pub mac: Option<MacAddr>,
pub state: NeighborState,
}
#[derive(
Debug, PartialEq, Eq, EnumString, Display, Clone, Copy, Hash, Serialize, Deserialize, Default,
)]
#[strum(serialize_all = "UPPERCASE", ascii_case_insensitive)]
#[serde(rename_all = "UPPERCASE")]
pub enum NeighborState {
Permanent,
Noarp,
Reachable,
Stale,
Incomplete,
Delay,
Probe,
Failed,
#[serde(other)]
#[default]
None,
}
impl NeighborState {
pub const fn as_ip_neigh_arg(self) -> &'static str {
match self {
NeighborState::Permanent => "permanent",
NeighborState::Reachable => "reachable",
NeighborState::Stale => "stale",
NeighborState::Delay => "delay",
NeighborState::Probe => "probe",
NeighborState::Incomplete => "incomplete",
NeighborState::Noarp => "noarp",
NeighborState::None => "none",
NeighborState::Failed => "failed",
}
}
pub const fn rank(self) -> u8 {
match self {
NeighborState::Permanent | NeighborState::Reachable => 5,
NeighborState::Stale => 4,
NeighborState::Delay | NeighborState::Probe | NeighborState::Incomplete => 3,
NeighborState::Noarp => 2,
NeighborState::None => 1,
NeighborState::Failed => 0,
}
}
}
impl PartialOrd for NeighborState {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for NeighborState {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.rank().cmp(&other.rank())
}
}
#[derive(Debug, Default, Clone, Hash, Deserialize, Serialize)]
pub struct DeviceQuery {
pub name: Option<String>,
#[serde(flatten)]
pub filter: DeviceFilters,
}
#[serde_as]
#[derive(Debug, Default, Clone, Hash, Serialize, Deserialize)]
pub struct DeviceFilters {
#[serde_as(as = "OneOrMany<_>")]
#[serde(default)]
pub ips: Vec<IpAddr>,
#[serde_as(as = "OneOrMany<_>")]
#[serde(default)]
pub devs: Vec<String>,
#[serde_as(as = "OneOrMany<_>")]
#[serde(default)]
pub nuds: Vec<NeighborState>,
#[serde_as(as = "OneOrMany<DisplayFromStr>")]
#[serde(default)]
pub macs: Vec<MacAddr>,
}
#[skip_serializing_none]
#[derive(Debug, Default, Serialize)]
pub struct Status<T> {
pub name: Option<String>,
pub table: Vec<T>,
pub filters: DeviceFilters,
}
#[derive(Debug, Default, Clone, Hash, Deserialize)]
pub struct NamePath {
pub name: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct DhcpLease {
pub expires_epoch: u64,
pub ip: IpAddr,
#[serde(with = "mac")]
pub mac: MacAddr,
pub name: Option<String>,
}
#[skip_serializing_none]
#[derive(Debug, Clone, Serialize)]
pub struct DhcpLeaseWithState {
#[serde(flatten)]
pub lease_line: DhcpLease,
pub nud_state: Option<NeighborState>,
}
#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, Clone, Copy, Hash, PartialEq, Eq)]
pub struct WakeTarget {
#[serde(default)]
pub ip: Option<IpAddr>,
#[serde(default, with = "mac::option_mac")]
pub mac: Option<MacAddr>,
}
impl WakeTarget {
pub const fn is_complete(&self) -> bool {
matches!(
self,
Self {
ip: Some(_),
mac: Some(_)
}
)
}
}
#[derive(Debug, Serialize, Clone)]
pub struct WakeResult {
pub result: Vec<WakeTargetResult>,
}
#[skip_serializing_none]
#[derive(Debug, Serialize, Clone, Copy)]
pub struct WakeTargetResult {
#[serde(flatten)]
pub target: WakeTarget,
pub status: WakeStatus,
}
#[derive(Debug, Serialize, Clone, Copy, Hash, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WakeStatus {
Succeed,
NonexistentAddress,
WrongSize,
Incomplete,
}
impl WakeTargetResult {
pub const fn incomplete(target: WakeTarget) -> Self {
Self {
target,
status: WakeStatus::Incomplete,
}
}
}
#[derive(Debug)]
pub enum QueryInput {
Ip(IpAddr),
Mac(MacAddr),
Dev(String),
Nud(NeighborState),
Name(String),
}
#[derive(Debug, Display, thiserror::Error)]
pub enum NeighborParseError {
IpWhere,
IpParseError(#[from] std::net::AddrParseError),
MacParseError(#[from] macaddr::ParseError),
StateWhere,
StateParseError(#[from] strum::ParseError),
}
pub fn parse_neighbor_line(s: &str) -> Result<NeighborEntry, NeighborParseError> {
let mut it = s.split_whitespace();
let ip: IpAddr = it.next().ok_or(NeighborParseError::IpWhere)?.parse()?;
let mut dev: Option<String> = None;
let mut mac: Option<MacAddr> = None;
let mut state: Option<NeighborState> = None;
let mut last_tok: Option<&str> = None;
while let Some(tok) = it.next() {
match tok {
"dev" => dev = it.next().map(str::to_string),
"lladdr" => {
mac = it.next().map(|m| m.parse()).transpose()?;
}
"nud" => {
state = it.next().map(|st| st.parse()).transpose()?;
}
other => last_tok = Some(other),
}
}
if state.is_none() && let Some(st) = last_tok {
state = Some(st.parse()?);
}
Ok(NeighborEntry {
ip,
dev,
mac,
state: state.ok_or(NeighborParseError::StateWhere)?,
})
}
impl FromStr for NeighborEntry {
type Err = NeighborParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
parse_neighbor_line(s)
}
}
+101
View File
@@ -0,0 +1,101 @@
pub fn parse_numeric_ipv4(s: &str) -> Option<std::net::IpAddr> {
let s = s.trim();
if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))
&& hex.chars().all(|c| c.is_ascii_hexdigit())
&& let Ok(n) = u32::from_str_radix(hex, 16)
{
return Some(std::net::IpAddr::V4(std::net::Ipv4Addr::from(n)));
}
if s.chars().all(|c| c.is_ascii_digit()) && let Ok(n) = s.parse::<u32>() {
return Some(std::net::IpAddr::V4(std::net::Ipv4Addr::from(n)));
}
if s.len() > 1
&& s.as_bytes()[0] == b'0'
&& s.chars().all(|c| matches!(c, '0'..='7'))
&& let Ok(n) = u32::from_str_radix(s, 8)
{
return Some(std::net::IpAddr::V4(std::net::Ipv4Addr::from(n)));
}
None
}
pub fn extract_host(input: &str) -> &str {
let mut s = input.trim();
if let Some(idx) = s.find("://") {
s = &s[idx + 3..];
} else if let Some(rest) = s.strip_prefix("//") {
s = rest;
}
if let Some((_, host)) = s.rsplit_once('@') {
s = host;
}
if let Some(host) = s.strip_prefix('[') {
if let Some(end) = host.find(']') {
s = &host[..end];
}
} else {
if let Some(pos) = s.find('/') {
s = &s[..pos];
}
if let Some((host, port)) = s.rsplit_once(':')
&& s.matches(':').count() == 1
&& port.chars().all(|c| c.is_ascii_digit())
{
s = host;
}
}
s.trim()
}
pub fn boolish_str(s: &str) -> bool {
let t = s.trim().to_ascii_lowercase();
if t.is_empty() {
return true;
}
matches!(t.as_str(), "1" | "true" | "yes" | "on" | "y")
|| (!matches!(t.as_str(), "0" | "false" | "no" | "off" | "n")
&& t.parse::<u64>().map(|n| n != 0).unwrap_or(false))
}
pub mod mac {
use macaddr::MacAddr;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S>(m: &MacAddr, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&m.to_string())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<MacAddr, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
s.parse().map_err(serde::de::Error::custom)
}
pub mod option_mac {
use macaddr::MacAddr;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S>(m: &Option<MacAddr>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match m {
Some(mac) => serializer.serialize_some(&mac.to_string()),
None => serializer.serialize_none(),
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<MacAddr>, D::Error>
where
D: Deserializer<'de>,
{
let s = Option::<String>::deserialize(deserializer)?;
s.map(|x| x.parse()).transpose().map_err(serde::de::Error::custom)
}
}
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "wakey-linux"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1"
futures = "0"
macaddr = { version = "1", features = ["serde", "serde_std"] }
serde_json = "1"
tokio = { version = "1", features = ["fs", "net", "rt", "sync"] }
wakey-core = { path = "../wakey-core" }
[dependencies.lda-ipjs]
path = "../ipjs"
registry = "gitea"
version = "*"
+183
View File
@@ -0,0 +1,183 @@
use anyhow::{Context, Result};
use futures::future::try_join_all;
use std::collections::HashSet;
use std::net::IpAddr;
use wakey_core::{DeviceQuery, NeighborEntry, NeighborState, QueryInput, parse};
use lda_ipjs::subcommands::neighbor;
pub async fn get_ips(machine_name: &str) -> Result<impl Iterator<Item = IpAddr>> {
Ok(tokio::net::lookup_host((machine_name, 0))
.await
.with_context(|| format!("DNS resolve failed for {machine_name}"))?
.map(|c| c.ip()))
}
pub async fn get_neighbors(
machine_names: &[impl AsRef<str>],
ips: &[IpAddr],
devs: &[impl AsRef<str>],
state: &[NeighborState],
macs: &[macaddr::MacAddr],
) -> Result<Vec<NeighborEntry>> {
let resolved_ips: HashSet<IpAddr> = if !machine_names.is_empty() {
try_join_all(machine_names.iter().map(|n| get_ips(n.as_ref())))
.await?
.into_iter()
.flatten()
.collect()
} else {
HashSet::new()
};
let ip_filter: Vec<IpAddr> = if ips.is_empty() && resolved_ips.is_empty() {
vec![]
} else if ips.is_empty() {
resolved_ips.into_iter().collect()
} else if resolved_ips.is_empty() {
ips.iter().map(|ip| ip.to_canonical()).collect()
} else {
ips.iter()
.map(|ip| ip.to_canonical())
.filter(|ip| resolved_ips.contains(ip))
.collect()
};
let nud_filter: Vec<neighbor::NUDState> = state.iter().copied().map(to_ipjs_state).collect();
let dev_strs: Vec<&str> = devs.iter().map(AsRef::as_ref).collect();
let results = neighbor::nl::get(&ip_filter, &dev_strs, &nud_filter, macs)
.await
.context("rtnetlink failed")?
.into_iter()
.map(map_neighbor_item)
.collect();
Ok(results)
}
pub async fn query_status(query: &DeviceQuery) -> Result<Vec<NeighborEntry>> {
get_neighbors(
query.name.as_slice(),
&query.filter.ips,
&query.filter.devs,
&query.filter.nuds,
&query.filter.macs,
)
.await
}
pub async fn list_devs() -> HashSet<String> {
fn get_dev() -> HashSet<String> {
let mut devs: HashSet<String> = HashSet::new();
if let Ok(rd) = std::fs::read_dir("/sys/class/net") {
for e in rd.flatten() {
if e.file_type()
.map(|ft| {
if ft.is_symlink() {
std::fs::metadata(e.path()).map(|m| m.is_dir()).unwrap_or(false)
} else {
ft.is_dir()
}
})
.unwrap_or(false)
&& let Ok(name) = e.file_name().into_string()
&& name != "lo"
&& !name.is_empty()
{
devs.insert(name);
}
}
} else if let Ok(txt) = std::fs::read_to_string("/proc/net/dev") {
for line in txt.lines().skip(2) {
if let Some((name, _rest)) = line.split_once(':') {
let n = name.trim().to_string();
if n != "lo" && !n.is_empty() {
devs.insert(n);
}
}
}
}
devs
}
tokio::task::spawn_blocking(get_dev)
.await
.unwrap_or_default()
}
pub async fn devs_sorted() -> Vec<String> {
let mut v: Vec<String> = list_devs().await.into_iter().collect();
v.sort();
v
}
pub async fn has_dev(name: &str) -> bool {
list_devs().await.contains(name)
}
pub async fn classify_query(q: String) -> QueryInput {
let s = parse::extract_host(&q);
if let Some(ip) = parse::parse_numeric_ipv4(s).or_else(|| s.parse::<IpAddr>().ok()) {
return QueryInput::Ip(ip);
}
if let Ok(mac) = s.parse::<macaddr::MacAddr>() {
return QueryInput::Mac(mac);
}
if let Ok(state) = s.parse::<NeighborState>() {
return QueryInput::Nud(state);
}
if has_dev(s).await {
return QueryInput::Dev(s.to_string());
}
QueryInput::Name(s.to_string())
}
fn to_ipjs_state(value: NeighborState) -> lda_ipjs::subcommands::neighbor::NUDState {
match value {
NeighborState::Permanent => lda_ipjs::subcommands::neighbor::NUDState::Permanent,
NeighborState::Noarp => lda_ipjs::subcommands::neighbor::NUDState::Noarp,
NeighborState::Reachable => lda_ipjs::subcommands::neighbor::NUDState::Reachable,
NeighborState::Stale => lda_ipjs::subcommands::neighbor::NUDState::Stale,
NeighborState::None => lda_ipjs::subcommands::neighbor::NUDState::None,
NeighborState::Incomplete => lda_ipjs::subcommands::neighbor::NUDState::Incomplete,
NeighborState::Delay => lda_ipjs::subcommands::neighbor::NUDState::Delay,
NeighborState::Probe => lda_ipjs::subcommands::neighbor::NUDState::Probe,
NeighborState::Failed => lda_ipjs::subcommands::neighbor::NUDState::Failed,
}
}
fn from_ipjs_state(value: lda_ipjs::subcommands::neighbor::NUDState) -> NeighborState {
match value {
lda_ipjs::subcommands::neighbor::NUDState::Permanent => NeighborState::Permanent,
lda_ipjs::subcommands::neighbor::NUDState::Noarp => NeighborState::Noarp,
lda_ipjs::subcommands::neighbor::NUDState::Reachable => NeighborState::Reachable,
lda_ipjs::subcommands::neighbor::NUDState::Stale => NeighborState::Stale,
lda_ipjs::subcommands::neighbor::NUDState::None => NeighborState::None,
lda_ipjs::subcommands::neighbor::NUDState::Incomplete => NeighborState::Incomplete,
lda_ipjs::subcommands::neighbor::NUDState::Delay => NeighborState::Delay,
lda_ipjs::subcommands::neighbor::NUDState::Probe => NeighborState::Probe,
lda_ipjs::subcommands::neighbor::NUDState::Failed => NeighborState::Failed,
lda_ipjs::subcommands::neighbor::NUDState::Other(_) => NeighborState::None,
}
}
fn map_neighbor_item(
lda_ipjs::subcommands::neighbor::NeighborItem {
ip,
dev,
mac,
state,
}: lda_ipjs::subcommands::neighbor::NeighborItem,
) -> NeighborEntry {
NeighborEntry {
ip,
dev,
mac,
state: state
.into_iter()
.map(from_ipjs_state)
.max()
.unwrap_or(NeighborState::None),
}
}
+93
View File
@@ -0,0 +1,93 @@
use std::io::{self, ErrorKind};
use std::net::IpAddr;
use wakey_core::{DhcpLease, DhcpLeaseWithState};
const MAC_NAME_CACHE: &str = "/tmp/wakey_mac_names.json";
pub async fn load_mac_name_cache() -> io::Result<std::collections::BTreeMap<String, String>> {
match tokio::fs::read_to_string(MAC_NAME_CACHE).await {
Ok(s) => serde_json::from_str(&s).map_err(io::Error::other),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Default::default()),
Err(e) => Err(e),
}
}
async fn save_mac_name_cache(map: &std::collections::BTreeMap<String, String>) -> io::Result<()> {
let s = serde_json::to_string(map).map_err(io::Error::other)?;
let _ = tokio::fs::write(MAC_NAME_CACHE, s).await;
Ok(())
}
pub fn parse_dhcp_lease_line(line: &str) -> Option<DhcpLease> {
let mut c = line.split_whitespace();
let expires_epoch: u64 = c.next()?.parse().ok()?;
let mac = c.next()?.parse().ok()?;
let ip = c.next()?.parse().ok()?;
let name = c.next().filter(|c| *c != "*").map(str::to_string);
Some(DhcpLease {
expires_epoch,
ip,
mac,
name,
})
}
pub async fn read_dhcp_leases() -> io::Result<Vec<DhcpLease>> {
match tokio::fs::read_to_string("/tmp/dhcp.leases").await {
Ok(file) => Ok(file.lines().filter_map(parse_dhcp_lease_line).collect()),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e),
}
}
pub async fn read_dhcp_leases_with_names() -> io::Result<Vec<DhcpLease>> {
let leases = read_dhcp_leases().await?;
let mut cache = load_mac_name_cache().await.unwrap_or_default();
let mut changed = false;
let mut leases_with_names = Vec::with_capacity(leases.len());
for mut l in leases {
let mac_s = l.mac.to_string();
if let Some(ref name) = l.name {
if cache.get(&mac_s).map(|v| v != name).unwrap_or(true) {
cache.insert(mac_s, name.clone());
changed = true;
}
} else if let Some(prev) = cache.get(&mac_s) {
l.name = Some(prev.clone());
}
leases_with_names.push(l);
}
if changed {
let _ = save_mac_name_cache(&cache).await;
}
Ok(leases_with_names)
}
pub async fn enrich_leases_with_nud_state(leases: Vec<DhcpLease>) -> Vec<DhcpLeaseWithState> {
let ips: Vec<IpAddr> = leases.iter().map(|l| l.ip).collect();
let mut map: std::collections::HashMap<IpAddr, wakey_core::NeighborState> =
std::collections::HashMap::new();
if let Ok(rows) =
crate::devices::get_neighbors(&[] as &[&str], &ips, &[] as &[&str], &[], &[]).await
{
for row in rows {
let state = row.state;
let r = state.rank();
map.entry(row.ip)
.and_modify(|e| {
if r > e.rank() {
*e = state
}
})
.or_insert(state);
}
}
leases
.into_iter()
.map(|lease_line| DhcpLeaseWithState {
nud_state: map.get(&lease_line.ip).copied(),
lease_line,
})
.collect()
}
+7
View File
@@ -0,0 +1,7 @@
pub mod dhcp;
pub mod devices;
pub mod wake;
pub use dhcp::*;
pub use devices::*;
pub use wake::*;
+78
View File
@@ -0,0 +1,78 @@
use std::{io, net::IpAddr};
use futures::TryFutureExt;
use macaddr::MacAddr;
use tokio::net::UdpSocket;
use wakey_core::{WakeStatus, WakeTarget, WakeTargetResult};
#[derive(Debug, Clone, Copy, Hash)]
pub struct CompleteWakeTarget {
pub ip: IpAddr,
pub mac: MacAddr,
}
impl TryFrom<WakeTarget> for CompleteWakeTarget {
type Error = ();
fn try_from(value: WakeTarget) -> Result<Self, Self::Error> {
if let WakeTarget {
ip: Some(ip),
mac: Some(mac),
} = value
{
Ok(Self { ip, mac })
} else {
Err(())
}
}
}
pub async fn wake_one(sock: &UdpSocket, t: CompleteWakeTarget) -> WakeTargetResult {
let mac = t.mac;
let mb = mac.as_bytes();
let mut pac = [0; 6 + 6 * 16];
pac[..6].fill(0xff);
for i in 1..=16 {
pac[i * 6..(i + 1) * 6].copy_from_slice(mb);
}
match sock.send_to(&pac, (t.ip, 9)).await {
Ok(n) if n == pac.len() => WakeTargetResult {
target: WakeTarget {
ip: Some(t.ip),
mac: Some(t.mac),
},
status: WakeStatus::Succeed,
},
Ok(_) => WakeTargetResult {
target: WakeTarget {
ip: Some(t.ip),
mac: Some(t.mac),
},
status: WakeStatus::WrongSize,
},
Err(_) => WakeTargetResult {
target: WakeTarget {
ip: Some(t.ip),
mac: Some(t.mac),
},
status: WakeStatus::NonexistentAddress,
},
}
}
pub async fn wake_many(
targets: impl IntoIterator<Item = WakeTarget>,
) -> io::Result<Vec<WakeTargetResult>> {
let sock = UdpSocket::bind("[::]:0")
.or_else(|_| UdpSocket::bind(":0"))
.await?;
sock.set_broadcast(true)?;
let iter = targets.into_iter().map(async |target| {
match CompleteWakeTarget::try_from(target) {
Ok(target) => wake_one(&sock, target).await,
Err(()) => WakeTargetResult::incomplete(target),
}
});
Ok(futures::future::join_all(iter).await)
}