Dihvice. Chat Sweep.

lowk traking devices ts way is TUFF. how do you even try to try.
This commit is contained in:
lda
2026-04-04 23:07:12 +07:00 Unverified
parent 1cfa0d56ba
commit 981da39acf
6 changed files with 354 additions and 54 deletions
+3
View File
@@ -2,6 +2,9 @@
param(
[string]$PASSWD
)
. "$(Split-Path -Parent $PSScriptRoot)/scripts/lib.ps1"
$PASSWD = Get-DefaultPassword $PASSWD
# idk what this does it works like that then thats how it is
pscp.exe -l root -scp -pw $PASSWD -r 192.168.100.1:/etc/ldlda_help $PSScriptRoot
+165 -27
View File
@@ -11,49 +11,63 @@ use axum::Router;
use tokio::net::TcpListener;
use tower_http::services::ServeDir;
use wakey_core::{
DeviceFilters, DeviceQuery, DhcpLease, DhcpLeaseWithState, NeighborEntry, QueryInput, Status,
WakeResult, WakeTarget,
Device, DeviceFilters, DeviceInventory, DeviceQuery, DhcpLease, DhcpLeaseWithState, LeaseQuery,
NeighborEntry, Presence, Query, QueryInput, Status, WakeResult, WakeTarget,
};
pub type StatusResponse = Status<NeighborEntry>;
pub async fn resolve_query(input: impl Into<String>) -> Result<DeviceQuery> {
query_to_device_query(resolve_selector(input).await?)
}
pub async fn resolve_selector(input: impl Into<String>) -> Result<Query> {
Ok(
match wakey_linux::devices::classify_query(input.into()).await {
QueryInput::Ip(ip_addr) => DeviceQuery {
QueryInput::Ip(ip_addr) => Query::Ip(ip_addr),
QueryInput::Mac(mac_addr) => Query::Mac(mac_addr),
QueryInput::Dev(dev) => Query::Interface(dev),
QueryInput::Nud(state) => Query::NeighborState(state),
QueryInput::Name(name) => Query::Text(name),
},
)
}
pub fn query_to_device_query(query: Query) -> Result<DeviceQuery> {
Ok(match query {
Query::Ip(ip_addr) => DeviceQuery {
filter: DeviceFilters {
ips: vec![ip_addr],
..Default::default()
},
..Default::default()
},
QueryInput::Mac(mac_addr) => DeviceQuery {
Query::Mac(mac_addr) => DeviceQuery {
filter: DeviceFilters {
macs: vec![mac_addr],
..Default::default()
},
..Default::default()
},
QueryInput::Dev(dev) => DeviceQuery {
Query::Interface(dev) => DeviceQuery {
filter: DeviceFilters {
devs: vec![dev],
..Default::default()
},
..Default::default()
},
QueryInput::Nud(state) => DeviceQuery {
Query::NeighborState(state) => DeviceQuery {
filter: DeviceFilters {
nuds: vec![state],
..Default::default()
},
..Default::default()
},
QueryInput::Name(name) => DeviceQuery {
Query::Text(name) => DeviceQuery {
name: Some(name),
..Default::default()
},
},
)
})
}
pub async fn get_status(query: DeviceQuery) -> Result<StatusResponse> {
@@ -70,20 +84,14 @@ pub async fn get_status_for_input(input: impl Into<String>) -> Result<StatusResp
get_status(query).await
}
pub async fn get_leases(include_state: bool) -> Result<Vec<DhcpLeaseWithState>> {
pub async fn get_leases(query: LeaseQuery) -> Result<Vec<DhcpLeaseWithState>> {
let leases = wakey_linux::dhcp::read_dhcp_leases_with_names()
.await
.context("failed to read DHCP leases")?;
if include_state {
if query.include_state {
Ok(wakey_linux::dhcp::enrich_leases_with_nud_state(leases).await)
} else {
Ok(leases
.into_iter()
.map(|lease_line| DhcpLeaseWithState {
lease_line,
nud_state: None,
})
.collect())
Ok(leases_without_state(leases))
}
}
@@ -95,15 +103,7 @@ pub async fn wake_targets(targets: Vec<WakeTarget>) -> Result<WakeResult> {
}
pub async fn wake_from_query(input: impl Into<String>) -> Result<WakeResult> {
let status = get_status_for_input(input).await?;
let targets = status
.table
.into_iter()
.map(|entry| WakeTarget {
ip: Some(entry.ip),
mac: entry.mac,
})
.collect();
let targets = resolve_wake_targets(input).await?;
wake_targets(targets).await
}
@@ -117,6 +117,110 @@ pub async fn get_ips(name: impl AsRef<str>) -> Result<Vec<std::net::IpAddr>> {
.collect())
}
pub async fn resolve_devices(input: impl Into<String>) -> Result<Vec<Device>> {
let query = resolve_query(input).await?;
inventory(query).await.map(|inventory| inventory.devices)
}
pub async fn inventory(query: DeviceQuery) -> Result<DeviceInventory> {
let status = get_status(query.clone()).await?;
let leases = get_leases(LeaseQuery {
include_state: false,
})
.await?;
Ok(DeviceInventory {
devices: merge_devices(status.table, leases, &query),
})
}
pub async fn resolve_wake_targets(input: impl Into<String>) -> Result<Vec<WakeTarget>> {
let devices = resolve_devices(input).await?;
Ok(devices
.into_iter()
.flat_map(|device| {
let mac = device.macs.first().copied();
device
.ips
.into_iter()
.map(move |ip| WakeTarget { ip: Some(ip), mac })
})
.collect())
}
fn merge_devices(
neighbors: Vec<NeighborEntry>,
leases: Vec<DhcpLeaseWithState>,
query: &DeviceQuery,
) -> Vec<Device> {
use std::collections::BTreeMap;
let mut by_mac: BTreeMap<String, (Vec<NeighborEntry>, Vec<DhcpLease>)> = BTreeMap::new();
for row in neighbors {
let key = row
.mac
.map(|m| m.to_string())
.unwrap_or_else(|| format!("ip:{}", row.ip));
by_mac.entry(key).or_default().0.push(row);
}
for lease in leases {
let key = lease.lease_line.mac.to_string();
by_mac.entry(key).or_default().1.push(lease.lease_line);
}
let mut devices: Vec<Device> = by_mac
.into_values()
.map(|(neighbors, leases)| Device::from_parts(neighbors, leases))
.collect();
if let Some(name) = &query.name {
devices.retain(|device| device.names.iter().any(|n| n == name));
}
if !query.filter.devs.is_empty() {
devices.retain(|device| {
device
.interfaces
.iter()
.any(|iface| query.filter.devs.contains(iface))
});
}
if !query.filter.ips.is_empty() {
devices.retain(|device| device.ips.iter().any(|ip| query.filter.ips.contains(ip)));
}
if !query.filter.macs.is_empty() {
devices.retain(|device| {
device
.macs
.iter()
.any(|mac| query.filter.macs.contains(mac))
});
}
if !query.filter.nuds.is_empty() {
devices.retain(|device| {
device
.neighbors
.iter()
.any(|neighbor| query.filter.nuds.contains(&neighbor.state))
});
}
devices.sort_by(|a, b| {
presence_rank(b.presence)
.cmp(&presence_rank(a.presence))
.then_with(|| a.names.first().cmp(&b.names.first()))
});
devices
}
const fn presence_rank(presence: Presence) -> u8 {
match presence {
Presence::Online => 3,
Presence::LikelyOnline => 2,
Presence::Unknown => 1,
Presence::Offline => 0,
}
}
pub fn http_app(static_root: std::path::PathBuf) -> Router {
Router::new()
.nest("/api", route::api_router())
@@ -182,6 +286,17 @@ mod tests {
assert_eq!(query.filter.nuds, vec![NeighborState::Reachable]);
}
#[tokio::test]
async fn resolve_selector_keeps_text_vs_structured() {
let selector = resolve_selector("reachable")
.await
.expect("resolve selector");
match selector {
Query::NeighborState(NeighborState::Reachable) => {}
_ => panic!("expected neighbor-state selector"),
}
}
#[test]
fn leases_without_state_clears_nud_state() {
let leases = vec![DhcpLease {
@@ -195,6 +310,29 @@ mod tests {
assert!(out[0].nud_state.is_none());
}
#[test]
fn merge_devices_combines_lease_and_neighbor() {
let neighbors = vec![NeighborEntry {
ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)),
dev: Some("br-lan".into()),
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
state: NeighborState::Reachable,
}];
let leases = vec![DhcpLeaseWithState {
lease_line: DhcpLease {
expires_epoch: 1,
ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)),
mac: "aa:bb:cc:dd:ee:ff".parse().expect("mac"),
name: Some("pc".into()),
},
nud_state: None,
}];
let devices = merge_devices(neighbors, leases, &DeviceQuery::default());
assert_eq!(devices.len(), 1);
assert_eq!(devices[0].presence, Presence::Online);
assert_eq!(devices[0].names, vec!["pc".to_string()]);
}
#[tokio::test]
async fn wake_targets_marks_incomplete() {
let out = wake_targets(vec![WakeTarget {
+4 -1
View File
@@ -110,7 +110,10 @@ async fn main() -> anyhow::Result<()> {
println!("{}", serde_json::to_string_pretty(&status)?);
}
Command::Leases(args) => {
let leases = wakey::get_leases(args.include_state).await?;
let leases = wakey::get_leases(wakey_core::LeaseQuery {
include_state: args.include_state,
})
.await?;
println!("{}", serde_json::to_string_pretty(&leases)?);
}
Command::Wake(args) => {
+1 -1
View File
@@ -12,7 +12,7 @@ pub async fn get_dhcp_leases(
) -> impl IntoResponse {
let include_state = include_state.as_deref().map(boolish_str).unwrap_or(false);
match crate::get_leases(include_state).await {
match crate::get_leases(wakey_core::LeaseQuery { include_state }).await {
Ok(leases) => (
StatusCode::OK,
Json(crate::compat::legacy_leases_from_domain(leases)),
+130
View File
@@ -75,6 +75,117 @@ impl Ord for NeighborState {
}
}
#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum Presence {
Online,
LikelyOnline,
#[default]
Unknown,
Offline,
}
impl From<NeighborState> for Presence {
fn from(value: NeighborState) -> Self {
match value {
NeighborState::Permanent | NeighborState::Reachable => Self::Online,
NeighborState::Stale => Self::LikelyOnline,
NeighborState::Failed => Self::Offline,
NeighborState::Delay
| NeighborState::Probe
| NeighborState::Incomplete
| NeighborState::Noarp
| NeighborState::None => Self::Unknown,
}
}
}
#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize)]
pub struct DeviceId {
#[serde(with = "mac")]
pub mac: MacAddr,
}
#[skip_serializing_none]
#[derive(Debug, Clone, Serialize)]
pub struct Device {
pub id: Option<DeviceId>,
pub names: Vec<String>,
pub ips: Vec<IpAddr>,
#[serde(with = "mac::vec_mac")]
pub macs: Vec<MacAddr>,
pub interfaces: Vec<String>,
pub neighbors: Vec<NeighborEntry>,
pub leases: Vec<DhcpLease>,
pub presence: Presence,
}
impl Device {
pub fn from_parts(neighbors: Vec<NeighborEntry>, leases: Vec<DhcpLease>) -> Self {
use std::collections::BTreeSet;
let mut names = BTreeSet::new();
let mut ips = BTreeSet::new();
let mut macs = BTreeSet::new();
let mut interfaces = BTreeSet::new();
let mut presence = Presence::Unknown;
for lease in &leases {
ips.insert(lease.ip);
macs.insert(lease.mac);
if let Some(name) = &lease.name {
names.insert(name.clone());
}
}
for neighbor in &neighbors {
ips.insert(neighbor.ip);
if let Some(mac) = neighbor.mac {
macs.insert(mac);
}
if let Some(dev) = &neighbor.dev {
interfaces.insert(dev.clone());
}
presence = std::cmp::max(
presence_rank(presence),
presence_rank(neighbor.state.into()),
)
.into();
}
let macs: Vec<MacAddr> = macs.into_iter().collect();
Self {
id: macs.first().copied().map(|mac| DeviceId { mac }),
names: names.into_iter().collect(),
ips: ips.into_iter().collect(),
macs,
interfaces: interfaces.into_iter().collect(),
neighbors,
leases,
presence,
}
}
}
const fn presence_rank(presence: Presence) -> u8 {
match presence {
Presence::Online => 3,
Presence::LikelyOnline => 2,
Presence::Unknown => 1,
Presence::Offline => 0,
}
}
impl From<u8> for Presence {
fn from(value: u8) -> Self {
match value {
3 => Self::Online,
2 => Self::LikelyOnline,
0 => Self::Offline,
_ => Self::Unknown,
}
}
}
#[derive(Debug, Default, Clone, Hash, Deserialize, Serialize)]
pub struct DeviceQuery {
pub name: Option<String>,
@@ -129,6 +240,16 @@ pub struct DhcpLeaseWithState {
pub nud_state: Option<NeighborState>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct LeaseQuery {
pub include_state: bool,
}
#[derive(Debug, Default, Clone, Serialize)]
pub struct DeviceInventory {
pub devices: Vec<Device>,
}
#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, Clone, Copy, Hash, PartialEq, Eq)]
pub struct WakeTarget {
@@ -190,6 +311,15 @@ pub enum QueryInput {
Name(String),
}
#[derive(Debug, Clone)]
pub enum Query {
Text(String),
Ip(IpAddr),
Mac(MacAddr),
Interface(String),
NeighborState(NeighborState),
}
#[derive(Debug, Display, thiserror::Error)]
pub enum NeighborParseError {
IpWhere,
+26
View File
@@ -102,4 +102,30 @@ pub mod mac {
.map_err(serde::de::Error::custom)
}
}
// Vec<DisplayFromStr> ?
pub mod vec_mac {
use macaddr::MacAddr;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub fn serialize<S>(macs: &[MacAddr], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let strings: Vec<String> = macs.iter().map(ToString::to_string).collect();
strings.serialize(serializer)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<MacAddr>, D::Error>
where
D: Deserializer<'de>,
{
let strings = Vec::<String>::deserialize(deserializer)?;
strings
.into_iter()
.map(|s| s.parse())
.collect::<Result<Vec<_>, _>>()
.map_err(serde::de::Error::custom)
}
}
}