6 Commits
45 changed files with 1640 additions and 841 deletions

No files matched your search

Generated
+1 -1
View File
@@ -772,7 +772,7 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "lda-ipjs"
version = "0.0.2"
version = "0.0.3"
dependencies = [
"anyhow",
"futures",
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "lda-ipjs"
description = "ip -j show schemas"
version = "0.0.2"
version = "0.0.3"
edition = "2024"
publish = ["gitea"]
+135 -6
View File
@@ -11,7 +11,10 @@ pub mod nl;
pub use crate::subcommands::Backend;
use crate::utils::serialize::mac::option_mac;
use macaddr::MacAddr;
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::net::{IpAddr, Ipv4Addr};
use crate::subcommands::link::OperState;
/// i dont include what i dont know about (almost all ts)
#[derive(Serialize, Debug, Deserialize)]
@@ -19,7 +22,7 @@ pub struct AddrOutput {
pub ifindex: u32,
pub ifname: String,
/// i imagine UP or DOWN, unknown
pub operstate: String,
pub operstate: OperState,
// 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>,
@@ -31,18 +34,102 @@ pub struct AddrOutput {
// Raw JSON shape from ip -j -4 address show
#[derive(Debug, Deserialize, Serialize)]
pub struct AddrInfo {
pub family: Option<String>,
pub family: Option<AddressFamily>,
pub local: Option<String>,
pub prefixlen: Option<u8>,
#[serde(flatten, default)]
pub cidr: InterfaceCidr,
pub broadcast: Option<String>,
pub broadcast: Option<Ipv4Addr>,
pub scope: Option<String>,
pub label: Option<String>,
// many more exist; we only take what we need
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddressFamily {
Inet,
Inet6,
Other,
}
impl AddressFamily {
pub fn parse_lossy(value: &str) -> Self {
match value.to_ascii_lowercase().as_str() {
"inet" => Self::Inet,
"inet6" => Self::Inet6,
_ => Self::Other,
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Inet => "inet",
Self::Inet6 => "inet6",
Self::Other => "other",
}
}
}
impl Serialize for AddressFamily {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for AddressFamily {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Ok(Self::parse_lossy(&value))
}
}
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
pub struct InterfaceCidr {
pub local: Option<IpAddr>,
pub prefixlen: Option<u8>,
}
impl InterfaceCidr {
pub fn is_complete(&self) -> bool {
self.local.is_some() && self.prefixlen.is_some()
}
}
impl AddrInfo {
pub fn local_addr(&self) -> Option<IpAddr> {
self.cidr.local
}
pub fn prefixlen(&self) -> Option<u8> {
self.cidr.prefixlen
}
pub fn is_ipv4(&self) -> bool {
matches!(self.family, Some(AddressFamily::Inet))
}
pub fn is_ipv6(&self) -> bool {
matches!(self.family, Some(AddressFamily::Inet6))
}
}
impl AddrOutput {
pub fn ipv4_addrs(&self) -> impl Iterator<Item = &AddrInfo> {
self.addr_info.iter().filter(|info| info.is_ipv4())
}
pub fn ipv6_addrs(&self) -> impl Iterator<Item = &AddrInfo> {
self.addr_info.iter().filter(|info| info.is_ipv6())
}
}
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<AddrOutput>> {
get_with_backend(Backend::Json, dev).await
}
@@ -57,3 +144,45 @@ pub async fn get_with_backend(
Backend::Netlink => nl::get(dev).await,
}
}
#[cfg(test)]
mod tests {
use std::net::{IpAddr, Ipv4Addr};
use super::{AddrInfo, AddrOutput, AddressFamily};
use crate::subcommands::link::OperState;
#[test]
fn address_family_parses_known_values() {
assert_eq!(AddressFamily::parse_lossy("inet"), AddressFamily::Inet);
assert_eq!(AddressFamily::parse_lossy("INET6"), AddressFamily::Inet6);
assert_eq!(AddressFamily::parse_lossy("weird"), AddressFamily::Other);
}
#[test]
fn addr_info_deserializes_typed_ip_fields() {
let info: AddrInfo = serde_json::from_str(
r#"{"family":"inet","local":"192.168.1.1","prefixlen":24,"broadcast":"192.168.1.255","scope":"global"}"#,
)
.expect("addr_info json should deserialize");
assert_eq!(info.family, Some(AddressFamily::Inet));
assert_eq!(
info.local_addr(),
Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)))
);
assert_eq!(info.prefixlen(), Some(24));
assert_eq!(info.broadcast, Some(Ipv4Addr::new(192, 168, 1, 255)));
assert!(info.cidr.is_complete());
}
#[test]
fn addr_output_deserializes_typed_operstate() {
let output: AddrOutput = serde_json::from_str(
r#"{"ifindex":2,"ifname":"br-lan","operstate":"UP","address":"aa:bb:cc:dd:ee:ff","addr_info":[]}"#,
)
.expect("addr_output json should deserialize");
assert_eq!(output.operstate, OperState::Up);
}
}
+101 -14
View File
@@ -1,22 +1,109 @@
//! i said i aint doing ts no more why am i still here
#![cfg(unix)]
use std::collections::BTreeMap;
use futures::TryStreamExt;
use rtnetlink::Handle;
use rtnetlink::packet_route::{AddressFamily, address::AddressAttribute};
use crate::subcommands::address::AddrOutput;
use crate::subcommands::{
address::{AddrInfo, AddrOutput, AddressFamily as IpAddressFamily, InterfaceCidr},
link,
};
// shit this one is even worse you needa collect info from two places
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<AddrOutput>> {
let (conn, handle, _) = rtnetlink::new_connection()?;
tokio::spawn(conn); // every time?
let mut address = handle.address().get();
let mut link = handle.link().get();
if let Some(dev) = dev {
link = link.match_name(dev.to_owned());
if let Some(ind) = link.execute().try_next().await?.map(|a| a.header.index) {
address = address.set_link_index_filter(ind);
};
address.execute().try_next().await?;
} else {
};
todo!();
tokio::spawn(conn);
get_with_handle(&handle, dev).await
}
pub async fn get_with_handle(
handle: &Handle,
dev: Option<&str>,
) -> anyhow::Result<Vec<AddrOutput>> {
let links = link::nl::get_with_handle(handle, dev).await?;
let link_by_index: BTreeMap<u32, crate::subcommands::link::LinkOutput> =
links.into_iter().map(|link| (link.ifindex, link)).collect();
let mut address = handle.address().get();
if let Some(dev) = dev {
if let Some(index) = link_by_index
.values()
.find(|link| link.ifname == dev)
.map(|link| link.ifindex)
{
address = address.set_link_index_filter(index);
} else {
return Ok(Vec::new());
}
}
let mut stream = address.execute();
let mut addr_info_by_index: BTreeMap<u32, Vec<AddrInfo>> = BTreeMap::new();
while let Some(msg) = stream.try_next().await? {
if !matches!(
msg.header.family,
AddressFamily::Inet | AddressFamily::Inet6
) {
continue;
}
let family = match msg.header.family {
AddressFamily::Inet => Some(IpAddressFamily::Inet),
AddressFamily::Inet6 => Some(IpAddressFamily::Inet6),
_ => None,
};
let mut local = None;
let mut prefixlen = Some(msg.header.prefix_len);
let mut broadcast = None;
let mut scope = Some(format!("{:?}", msg.header.scope).to_lowercase());
let mut label = None;
for attr in msg.attributes {
match attr {
AddressAttribute::Address(addr) | AddressAttribute::Local(addr) => {
if local.is_none() {
local = addr.to_string().parse().ok();
}
}
AddressAttribute::Broadcast(addr) => {
broadcast = addr.to_string().parse().ok();
}
AddressAttribute::Label(name) => {
label = Some(name);
}
_ => {}
}
}
addr_info_by_index
.entry(msg.header.index)
.or_default()
.push(AddrInfo {
family,
cidr: InterfaceCidr {
local,
prefixlen: prefixlen.take(),
},
broadcast,
scope: scope.take(),
label,
});
}
let out = link_by_index
.into_values()
.map(|link| AddrOutput {
ifindex: link.ifindex,
ifname: link.ifname,
operstate: link.operstate.unwrap_or(link::OperState::Unknown),
address: link.address,
addr_info: addr_info_by_index.remove(&link.ifindex).unwrap_or_default(),
})
.collect();
Ok(out)
}
+116 -2
View File
@@ -7,18 +7,99 @@ pub mod nl;
pub use crate::subcommands::Backend;
use crate::utils::serialize::mac::option_mac;
use macaddr::MacAddr;
use serde::{Deserialize, Serialize};
#[cfg(all(unix, feature = "experimental-nl"))]
use rtnetlink::packet_route::link::State as NetlinkOperState;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinkOutput {
pub ifindex: u32,
pub ifname: String,
#[serde(default)]
pub operstate: Option<String>,
pub operstate: Option<OperState>,
#[serde(default, with = "option_mac")]
pub address: Option<MacAddr>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OperState {
Up,
Down,
Unknown,
Dormant,
LowerLayerDown,
NotPresent,
Testing,
Other,
}
impl OperState {
pub fn parse_lossy(value: &str) -> Self {
match value.to_ascii_uppercase().as_str() {
"UP" => Self::Up,
"DOWN" => Self::Down,
"UNKNOWN" => Self::Unknown,
"DORMANT" => Self::Dormant,
"LOWERLAYERDOWN" | "LOWER_LAYER_DOWN" | "LOWERLAYER_DOWN" => Self::LowerLayerDown,
"NOTPRESENT" | "NOT_PRESENT" => Self::NotPresent,
"TESTING" => Self::Testing,
_ => Self::Other,
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Up => "UP",
Self::Down => "DOWN",
Self::Unknown => "UNKNOWN",
Self::Dormant => "DORMANT",
Self::LowerLayerDown => "LOWERLAYERDOWN",
Self::NotPresent => "NOTPRESENT",
Self::Testing => "TESTING",
Self::Other => "OTHER",
}
}
pub fn is_up(self) -> bool {
matches!(self, Self::Up)
}
}
impl Serialize for OperState {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for OperState {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Ok(Self::parse_lossy(&value))
}
}
#[cfg(all(unix, feature = "experimental-nl"))]
impl From<NetlinkOperState> for OperState {
fn from(value: NetlinkOperState) -> Self {
match value {
NetlinkOperState::Up => Self::Up,
NetlinkOperState::Down => Self::Down,
NetlinkOperState::Unknown => Self::Unknown,
NetlinkOperState::Dormant => Self::Dormant,
NetlinkOperState::LowerLayerDown => Self::LowerLayerDown,
NetlinkOperState::NotPresent => Self::NotPresent,
NetlinkOperState::Testing => Self::Testing,
_ => Self::Other,
}
}
}
pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<LinkOutput>> {
get_with_backend(Backend::Json, dev).await
}
@@ -33,3 +114,36 @@ pub async fn get_with_backend(
Backend::Netlink => nl::get(dev).await,
}
}
#[cfg(test)]
mod tests {
use super::{LinkOutput, OperState};
#[test]
fn operstate_parses_json_and_netlink_spellings() {
assert_eq!(OperState::parse_lossy("UP"), OperState::Up);
assert_eq!(OperState::parse_lossy("Up"), OperState::Up);
assert_eq!(
OperState::parse_lossy("LOWERLAYERDOWN"),
OperState::LowerLayerDown
);
assert_eq!(
OperState::parse_lossy("LowerLayerDown"),
OperState::LowerLayerDown
);
}
#[test]
fn link_output_deserializes_typed_operstate() {
let link: LinkOutput = serde_json::from_str(
r#"{"ifindex":1,"ifname":"eth0","operstate":"UP","address":"aa:bb:cc:dd:ee:ff"}"#,
)
.expect("link json should deserialize");
assert_eq!(link.operstate, Some(OperState::Up));
assert_eq!(
link.address.map(|mac| mac.to_string()).as_deref(),
Some("aa:bb:cc:dd:ee:ff")
);
}
}
+9 -1
View File
@@ -1,6 +1,7 @@
#![cfg(unix)]
use futures::TryStreamExt;
use rtnetlink::Handle;
use rtnetlink::packet_route::link::LinkAttribute;
use super::LinkOutput;
@@ -9,6 +10,13 @@ pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<LinkOutput>> {
let (conn, handle, _) = rtnetlink::new_connection()?;
tokio::spawn(conn);
get_with_handle(&handle, dev).await
}
pub async fn get_with_handle(
handle: &Handle,
dev: Option<&str>,
) -> anyhow::Result<Vec<LinkOutput>> {
let mut req = handle.link().get();
if let Some(dev) = dev {
req = req.match_name(dev.to_owned());
@@ -32,7 +40,7 @@ pub async fn get(dev: Option<&str>) -> anyhow::Result<Vec<LinkOutput>> {
_ => None,
}
}
LinkAttribute::OperState(state) => operstate = Some(format!("{state:?}")),
LinkAttribute::OperState(state) => operstate = Some(state.into()),
_ => {}
}
}
+12 -1
View File
@@ -7,6 +7,7 @@ use std::{
use futures::TryStreamExt;
use macaddr::MacAddr;
use rtnetlink::Handle;
use rtnetlink::packet_route::{
AddressFamily,
neighbour::{NeighbourAddress, NeighbourAttribute, NeighbourState},
@@ -27,6 +28,16 @@ pub async fn get(
let (conn, handle, _) = rtnetlink::new_connection()?;
tokio::spawn(conn);
get_with_handle(&handle, ips, devs, nuds, macs).await
}
pub async fn get_with_handle(
handle: &Handle,
ips: &[IpAddr],
devs: &[impl AsRef<str>],
nuds: &[NUDState],
macs: &[MacAddr],
) -> anyhow::Result<Vec<NeighborItem>> {
let mut neighbor_data = handle.neighbours().get().execute();
// Build filter sets (empty = match all)
@@ -36,7 +47,7 @@ pub async fn get(
let mac_set: HashSet<MacAddr> = macs.iter().copied().collect();
// Prefetch all links once; link lookups were the ugliest and most expensive part.
let ifname_cache: HashMap<u32, String> = link::nl::get(None)
let ifname_cache: HashMap<u32, String> = link::nl::get_with_handle(handle, None)
.await?
.into_iter()
.map(|link| (link.ifindex, link.ifname))
+19 -3
View File
@@ -15,6 +15,22 @@ function Get-DefaultPassword {
return (Get-Content -Raw $pwPath).Trim()
}
function Normalize-LineEndings {
param([string]$Text)
if ($null -eq $Text) {
return $null
}
return ($Text -replace "`r`n", "`n" -replace "`r", "")
}
function Quote-ShArg {
param([AllowNull()][string]$Value)
if ($null -eq $Value) {
return "''"
}
return "'" + ($Value -replace "'", ("'" + '"' + "'" + '"' + "'")) + "'"
}
function Invoke-Ext {
param($Exe, $Arguments, $Label)
$displayArgs = $Arguments.Clone()
@@ -27,8 +43,8 @@ function Invoke-Ext {
$out = & $Exe @Arguments 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Error ("{0} failed ({1}):`n{2}" -f $Label, $LASTEXITCODE, ($out -join "`n"))
throw ("{0} failed ({1})" -f $Label, $LASTEXITCODE)
Write-Error ("{0} exited with code {1}:`n{2}" -f $Label, $LASTEXITCODE, ($out -join "`n"))
throw ("{0} exited with code {1}" -f $Label, $LASTEXITCODE)
}
return $out
}
@@ -55,7 +71,7 @@ function Invoke-Scp {
function Invoke-Ssh {
param($Cmd, $User, $Remote, $Pass, [switch]$Quiet)
$Cmd = $Cmd -replace "`r`n", "`n" -replace "`r", ""
$Cmd = Normalize-LineEndings $Cmd
if ($plink = Get-Command plink.exe -ErrorAction SilentlyContinue) {
$arguments = @('-batch', '-ssh')
if ($Pass) { $arguments += @('-pw', $Pass) }
+100 -34
View File
@@ -3,14 +3,21 @@
param(
[string]$Package = "lda-ipjs",
[string]$TestName = "",
[switch]$AllPackages,
[string]$Filter = "",
[switch]$Exact,
[switch]$List,
[ValidateSet("debug", "release")]
[string]$BuildProfile = "debug",
[string]$password,
[switch]$Verbose,
[string]$RemoteTestPath = "/root/.bin/test",
[string]$RemoteHost = "[email protected]",
[switch]$Ignored
[switch]$Ignored,
[switch]$IncludeIgnored,
[switch]$NoCapture,
[switch]$ShowOutput,
[int]$Threads = 0
)
$ErrorActionPreference = "Stop"
@@ -19,49 +26,108 @@ $ErrorActionPreference = "Stop"
$password = Get-DefaultPassword $password
# Build tests and capture output
Write-Host "Building tests for $Package..." -ForegroundColor Cyan
function Get-WorkspacePackages {
$metadata = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json
$workspaceMembers = [System.Collections.Generic.HashSet[string]]::new()
foreach ($member in $metadata.workspace_members) {
[void]$workspaceMembers.Add($member)
}
# 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 |
@(
$metadata.packages |
Where-Object { $workspaceMembers.Contains($_.id) } |
Select-Object -ExpandProperty name
)
}
function Get-TestBinaryPaths {
param([string[]]$CargoOutput)
@(
$CargoOutput |
Select-String -Pattern "Executable.*\((.+)\)" |
ForEach-Object { $_.Matches.Groups[1].Value }
)
}
function Build-RemoteExecCommand {
param(
[string]$RemotePath,
[string[]]$Arguments
)
$quotedPath = Quote-ShArg $RemotePath
$quotedArgs = @($Arguments | ForEach-Object { Quote-ShArg "$_" })
$exec = (@($quotedPath) + $quotedArgs) -join " "
"chmod +x $quotedPath && $exec"
}
$packages = if ($AllPackages) { Get-WorkspacePackages } else { @($Package) }
$failures = New-Object System.Collections.Generic.List[string]
foreach ($packageName in $packages) {
Write-Host "Building tests for $packageName..." -ForegroundColor Cyan
# Stream cargo output and convert to text
$cargoOutput = cargo test --no-run -p $packageName --target armv7-unknown-linux-musleabihf $(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
$line
}
# Parse test binary paths from cargo output
$testBinaries = $cargoOutput |
Select-String -Pattern "Executable.*\((.+)\)" |
ForEach-Object { $_.Matches.Groups[1].Value } |
Get-Item
if ($LASTEXITCODE -ne 0) {
$failures.Add("$packageName (build failed)")
Write-Warning "Build failed for $packageName"
continue
}
$testBinaryPaths = Get-TestBinaryPaths $cargoOutput
$testBinaries = @($testBinaryPaths | Get-Item)
if ($testBinaries.Count -eq 0) {
Write-Error "No test binaries found! Exiting..."
# Write-Host "Cargo output:" -ForegroundColor Yellow
# $cargoOutput | ForEach-Object { Write-Host $_ }
Write-Host "Skipping ${packageName}: no test executables" -ForegroundColor DarkYellow
continue
}
Write-Host "Found $($testBinaries.Count) test $($testBinaries.Count -eq 1 ? "binary" : "binaries") for $packageName" -ForegroundColor Green
foreach ($testBinary in $testBinaries) {
Write-Host "`nTesting: $packageName / $($testBinary.Name)" -ForegroundColor Cyan
try {
# Copy and make executable
Invoke-Scp -Local $testBinary.FullName -Dest "${RemoteHost}:$RemoteTestPath" -Pass $password -Quiet
# Build test args
$parts = @()
if ($Filter) { $parts += $Filter }
if ($Exact) { $parts += "--exact" }
if ($List) { $parts += "--list" }
if ($Ignored) { $parts += "--ignored" }
if ($IncludeIgnored) { $parts += "--include-ignored" }
if ($ShowOutput -or $Verbose) { $parts += "--show-output" }
if ($NoCapture -or $Verbose) { $parts += "--nocapture" }
if ($Threads -gt 0) { $parts += "--test-threads"; $parts += $Threads }
$remoteCmd = Build-RemoteExecCommand -RemotePath $RemoteTestPath -Arguments $parts
# Run test binary (with chmod to ensure executable)
Invoke-Ssh -Cmd $remoteCmd -Remote $RemoteHost -Pass $password
}
catch {
$failures.Add("$packageName / $($testBinary.Name)")
Write-Warning "Test binary failed: $packageName / $($testBinary.Name)"
Write-Warning $_.Exception.Message
continue
}
}
}
if ($failures.Count -gt 0) {
Write-Error ("One or more test binaries failed: {0}" -f ($failures -join ", "))
exit 1
}
Write-Host "Found $($testBinaries.Count) test $($testBinaries.Count -eq 1 ? "binary" : "binaries")" -ForegroundColor Green
# Run each test binary
foreach ($testBinary in $testBinaries) {
Write-Host "`nTesting: $($testBinary.Name)" -ForegroundColor Cyan
# Copy to target
Invoke-Scp -Local $testBinary.FullName -Dest "${RemoteHost}:$RemoteTestPath" -Pass $password -Quiet
# Run on target
$parts = @()
if ($TestName) { $parts += $TestName }
if ($Ignored) { $parts += "--ignored" }
if ($Verbose) { $parts += "--nocapture"; $parts += "--show-output" }
$testArgs = $parts -join " "
Invoke-Ssh -Cmd "chmod +x $RemoteTestPath && $RemoteTestPath $testArgs" -Remote $RemoteHost -Pass $password
}
Write-Host "`nDone!" -ForegroundColor Green
File renamed without changes.
+34
View File
@@ -0,0 +1,34 @@
pub mod compat;
pub mod route;
use std::{io, net::SocketAddr};
use axum::Router;
use tokio::net::TcpListener;
use tower_http::services::ServeDir;
pub fn http_app(static_root: std::path::PathBuf) -> Router {
Router::new()
.nest("/api", route::api_router())
.fallback_service(axum::routing::get_service(
ServeDir::new(static_root)
.append_index_html_on_directories(true)
.precompressed_br()
.precompressed_deflate()
.precompressed_gzip()
.precompressed_zstd(),
))
}
pub async fn serve_http(addr: SocketAddr, static_root: std::path::PathBuf) -> io::Result<()> {
let listener = TcpListener::bind(addr).await?;
axum::serve(listener, http_app(static_root).into_make_service()).await
}
pub async fn serve_http_from_current_exe(addr: SocketAddr) -> io::Result<()> {
let exe = std::env::current_exe()?;
let root = exe
.parent()
.ok_or_else(|| io::Error::other("no parent dir"))?;
serve_http(addr, root.join("static")).await
}
+4 -4
View File
@@ -1,17 +1,17 @@
use crate::route::error::ApiError;
use crate::http::route::error::ApiError;
use axum::Json;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::{extract::Path, response::Redirect};
use wakey_core::DeviceQuery;
use crate::route::status::NamePath;
use crate::http::route::status::NamePath;
// Smart redirect: accept IP, MAC, dev, or NUD state and redirect to /api/status accordingly
pub async fn status_smart_redirect(
Path(q): Path<String>,
) -> axum::response::Result<Redirect, impl IntoResponse> {
let query: DeviceQuery = match crate::resolve_query(q).await {
let query: DeviceQuery = match crate::service::resolve_query(q).await {
Ok(query) => query,
Err(e) => {
return Err(ApiError {
@@ -37,7 +37,7 @@ pub async fn status_redirect(Path(NamePath { name }): Path<NamePath>) -> Redirec
}
pub async fn ip(Path(name): Path<String>) -> impl IntoResponse {
crate::get_ips(&name).await.map_or_else(
crate::service::get_ips(&name).await.map_or_else(
|e| ApiError::ise(e.to_string()).into_response(),
|ips| Json(ips).into_response(),
)
+2 -2
View File
@@ -1,8 +1,8 @@
use crate::route::error::ApiError;
use crate::http::route::error::ApiError;
use axum::{Json, response::IntoResponse};
pub async fn devs_router() -> impl IntoResponse {
match crate::list_interfaces().await {
match crate::service::list_interfaces().await {
Ok(devs) => Json(devs).into_response(),
Err(e) => ApiError::ise(e.to_string()).into_response(),
}
+3 -3
View File
@@ -1,4 +1,4 @@
use crate::{route::error::ApiError, utils::parse::boolish_str};
use crate::{http::route::error::ApiError, utils::parse::boolish_str};
use axum::{Json, extract::Query, http::StatusCode, response::IntoResponse};
// DHCP lease endpoints
@@ -12,10 +12,10 @@ 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(wakey_core::LeaseQuery { include_state }).await {
match crate::service::get_leases(wakey_core::LeaseQuery { include_state }).await {
Ok(leases) => (
StatusCode::OK,
Json(crate::compat::legacy_leases_from_domain(leases)),
Json(crate::http::compat::legacy_leases_from_domain(leases)),
)
.into_response(),
Err(e) => ApiError {
File renamed without changes.
+9 -10
View File
@@ -5,16 +5,15 @@ 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::error::ApiError;
use crate::route::status::get_status_json;
use crate::route::wake::wake_multi;
use crate::http::route::api::ip;
use crate::http::route::api::status_redirect;
use crate::http::route::api::status_smart_redirect;
use crate::http::route::devs::devs_router;
use crate::http::route::dhcp::get_dhcp_leases;
use crate::http::route::error::ApiError;
use crate::http::route::status::get_status_json;
use crate::http::route::wake::wake_multi;
use crate::legacy::dhcpparse::load_mac_name_cache;
use axum::Json;
use axum::Router;
@@ -1,13 +1,13 @@
use crate::route::error::ApiError;
use crate::http::route::error::ApiError;
use axum::{Json, http::StatusCode, response::IntoResponse};
use axum_extra::extract::Query;
pub use wakey_core::{DeviceQuery, NamePath};
pub async fn get_status_json(Query(query): Query<DeviceQuery>) -> impl IntoResponse {
match crate::inventory(query.clone()).await {
match crate::service::inventory(query.clone()).await {
Ok(inventory) => (
StatusCode::OK,
Json(crate::compat::legacy_status_from_inventory(
Json(crate::http::compat::legacy_status_from_inventory(
inventory,
query.name,
query.filter,
+3 -3
View File
@@ -1,12 +1,12 @@
use crate::route::error::ApiError;
use crate::http::route::error::ApiError;
use axum::{extract::Json, http::StatusCode, response::IntoResponse};
pub use wakey_core::WakeTarget;
pub async fn wake_multi(Json(req): Json<Vec<WakeTarget>>) -> impl IntoResponse {
match crate::wake_targets(req).await {
match crate::service::wake_targets(req).await {
Ok(result) => (
StatusCode::OK,
Json(crate::compat::legacy_wake_from_domain(result)),
Json(crate::http::compat::legacy_wake_from_domain(result)),
)
.into_response(),
Err(error) => {
File renamed without changes.
File renamed without changes.
+2
View File
@@ -0,0 +1,2 @@
pub mod arpparse;
pub mod dhcpparse;
+15 -290
View File
@@ -1,299 +1,24 @@
pub mod arpparse;
pub mod compat;
pub mod dhcpparse;
pub mod route;
pub mod http;
pub mod legacy;
pub mod service;
pub mod utils;
use std::{io, net::SocketAddr};
use anyhow::{Context, Result};
use axum::Router;
use tokio::net::TcpListener;
use tower_http::services::ServeDir;
use wakey_core::{
Device, DeviceFilters, DeviceInventory, DeviceQuery, DhcpLease, DhcpLeaseWithState, LeaseQuery,
NeighborEntry, Presence, Query, QueryInput, Status, WakeResult, WakeTarget,
pub use http::{http_app, serve_http, serve_http_from_current_exe};
pub use service::{
StatusResponse, broadcast_wake_targets, device_to_status_rows, get_interface_summaries,
get_interface_summary, get_ips, get_leases, get_status, get_status_for_input, inventory,
leases_without_state, list_interfaces, merge_devices, query_to_device_query, resolve_devices,
resolve_query, resolve_selector, resolve_wake_targets, wake_explicit, wake_from_query,
wake_targets,
};
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) => 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()
},
Query::Mac(mac_addr) => DeviceQuery {
filter: DeviceFilters {
macs: vec![mac_addr],
..Default::default()
},
..Default::default()
},
Query::Interface(dev) => DeviceQuery {
filter: DeviceFilters {
devs: vec![dev],
..Default::default()
},
..Default::default()
},
Query::NeighborState(state) => DeviceQuery {
filter: DeviceFilters {
nuds: vec![state],
..Default::default()
},
..Default::default()
},
Query::Text(name) => DeviceQuery {
name: Some(name),
..Default::default()
},
})
}
pub async fn get_status(query: DeviceQuery) -> Result<StatusResponse> {
let inventory = inventory(query.clone()).await?;
let table = inventory
.devices
.iter()
.flat_map(device_to_status_rows)
.collect();
Ok(Status {
name: query.name,
table,
filters: query.filter,
})
}
pub async fn get_status_for_input(input: impl Into<String>) -> Result<StatusResponse> {
let query = resolve_query(input).await?;
get_status(query).await
}
pub async fn get_leases(query: LeaseQuery) -> Result<Vec<DhcpLeaseWithState>> {
let leases = wakey_linux::dhcp::read_dhcp_leases_with_names()
.await
.context("failed to read DHCP leases")?;
if query.include_state {
Ok(wakey_linux::dhcp::enrich_leases_with_nud_state(leases).await)
} else {
Ok(leases_without_state(leases))
}
}
pub async fn wake_targets(targets: Vec<WakeTarget>) -> Result<WakeResult> {
let result = wakey_linux::wake::wake_many(targets)
.await
.context("failed to send wake packets")?;
Ok(WakeResult { result })
}
pub async fn wake_from_query(input: impl Into<String>) -> Result<WakeResult> {
let targets = resolve_wake_targets(input).await?;
wake_targets(targets).await
}
pub async fn list_interfaces() -> Result<Vec<String>> {
Ok(wakey_linux::devices::devs_sorted().await)
}
pub async fn get_ips(name: impl AsRef<str>) -> Result<Vec<std::net::IpAddr>> {
Ok(wakey_linux::devices::get_ips(name.as_ref())
.await?
.collect())
}
pub 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 neighbors = wakey_linux::devices::query_status(&query).await?;
let leases = get_leases(LeaseQuery {
include_state: false,
})
.await?;
Ok(DeviceInventory {
devices: merge_devices(neighbors, 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,
}
}
fn device_to_status_rows(device: &Device) -> Vec<NeighborEntry> {
if !device.neighbors.is_empty() {
return device.neighbors.clone();
}
let fallback_mac = device.macs.first().copied();
let fallback_dev = device.interfaces.first().cloned();
let fallback_state = match device.presence {
Presence::Online => wakey_core::NeighborState::Reachable,
Presence::LikelyOnline => wakey_core::NeighborState::Stale,
Presence::Offline => wakey_core::NeighborState::Failed,
Presence::Unknown => wakey_core::NeighborState::None,
};
device
.ips
.iter()
.copied()
.map(|ip| NeighborEntry {
ip,
dev: fallback_dev.clone(),
mac: fallback_mac,
state: fallback_state,
})
.collect()
}
pub fn http_app(static_root: std::path::PathBuf) -> Router {
Router::new()
.nest("/api", route::api_router())
.fallback_service(axum::routing::get_service(
ServeDir::new(static_root)
.append_index_html_on_directories(true)
.precompressed_br()
.precompressed_deflate()
.precompressed_gzip()
.precompressed_zstd(),
))
}
pub async fn serve_http(addr: SocketAddr, static_root: std::path::PathBuf) -> io::Result<()> {
let listener = TcpListener::bind(addr).await?;
axum::serve(listener, http_app(static_root).into_make_service()).await
}
pub async fn serve_http_from_current_exe(addr: SocketAddr) -> io::Result<()> {
let exe = std::env::current_exe()?;
let root = exe
.parent()
.ok_or_else(|| io::Error::other("no parent dir"))?;
serve_http(addr, root.join("static")).await
}
pub fn leases_without_state(leases: Vec<DhcpLease>) -> Vec<DhcpLeaseWithState> {
leases
.into_iter()
.map(|lease_line| DhcpLeaseWithState {
lease_line,
nud_state: None,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr};
use wakey_core::{NeighborState, WakeStatus};
use wakey_core::{
DhcpLease, NeighborEntry, NeighborState, Presence, Query, WakeStatus, WakeTarget,
};
#[tokio::test]
async fn resolve_query_parses_ip() {
@@ -350,7 +75,7 @@ mod tests {
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
state: NeighborState::Reachable,
}];
let leases = vec![DhcpLeaseWithState {
let leases = vec![wakey_core::DhcpLeaseWithState {
lease_line: DhcpLease {
expires_epoch: 1,
ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)),
@@ -359,7 +84,7 @@ mod tests {
},
nud_state: None,
}];
let devices = merge_devices(neighbors, leases, &DeviceQuery::default());
let devices = merge_devices(neighbors, leases, &wakey_core::DeviceQuery::default());
assert_eq!(devices.len(), 1);
assert_eq!(devices[0].presence, Presence::Online);
assert_eq!(devices[0].names, vec!["pc".to_string()]);
+174 -12
View File
@@ -2,8 +2,8 @@ use std::net::{IpAddr, SocketAddr};
use chrono::{DateTime, Local, Utc};
use clap::{Args, Parser, Subcommand};
use comfy_table::{Cell, ContentArrangement, Table, presets::UTF8_FULL};
use wakey_core::{DeviceFilters, DeviceQuery, DhcpLeaseWithState, WakeResult};
use comfy_table::{Cell, ContentArrangement, Table, presets};
use wakey_core::{DeviceFilters, DeviceQuery, DhcpLeaseWithState, InterfaceSummary, WakeResult};
#[derive(Parser)]
#[command(name = "wakey")]
@@ -19,7 +19,7 @@ enum Command {
Status(StatusArgs),
Leases(LeasesArgs),
Wake(WakeArgs),
Devs,
Devs(DevsArgs),
}
#[derive(Args)]
@@ -40,7 +40,11 @@ struct LeasesArgs {
#[derive(Args)]
struct WakeArgs {
query: String,
query: Option<String>,
#[arg(long)]
mac: Option<macaddr::MacAddr>,
#[arg(long)]
ip: Option<IpAddr>,
#[arg(long)]
json: bool,
}
@@ -62,6 +66,15 @@ struct StatusArgs {
json: bool,
}
#[derive(Args)]
struct DevsArgs {
dev: Option<String>,
#[arg(long)]
up: bool,
#[arg(long)]
json: bool,
}
fn status_args_to_query(args: StatusArgs) -> wakey_core::DeviceQuery {
if let Some(query) = args.query.as_ref()
&& args.name.is_none()
@@ -90,7 +103,7 @@ fn status_args_to_query(args: StatusArgs) -> wakey_core::DeviceQuery {
fn base_table() -> Table {
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.load_preset(presets::UTF8_FULL_CONDENSED)
.set_content_arrangement(ContentArrangement::Dynamic);
table
}
@@ -143,15 +156,98 @@ fn render_wake_table(result: &WakeResult) -> Table {
table
}
fn render_devs_table(devs: &[String]) -> Table {
fn validate_wake_args(args: &WakeArgs) -> anyhow::Result<()> {
let has_query = args.query.is_some();
let has_mac = args.mac.is_some();
let has_ip = args.ip.is_some();
if has_ip && !has_mac {
anyhow::bail!("`wakey wake --ip` needs `--mac`");
}
if has_query && (has_mac || has_ip) {
anyhow::bail!("query mode and explicit `--mac/--ip` mode are mutually exclusive");
}
if !has_query && !has_mac {
anyhow::bail!("provide either a query or `--mac`");
}
Ok(())
}
async fn run_wake(args: WakeArgs) -> anyhow::Result<WakeResult> {
validate_wake_args(&args)?;
match (args.query, args.mac, args.ip) {
(Some(query), None, None) => wakey::wake_from_query(query).await,
(None, Some(mac), ip) => wakey::wake_explicit(mac, ip).await,
_ => unreachable!("wake args validated before dispatch"),
}
}
fn render_devs_table(devs: &[InterfaceSummary]) -> Table {
let mut table = base_table();
table.set_header(["Interface"]);
table.set_header([
"Interface",
"State",
"MAC",
"Addresses",
"Broadcasts",
"Scope/Label",
]);
for dev in devs {
table.add_row([Cell::new(dev)]);
let addresses = dev
.addrs
.iter()
.filter_map(|addr| addr.cidr.as_deref())
.collect::<Vec<_>>()
.join("\n");
let broadcasts = dev
.addrs
.iter()
.filter_map(|addr| addr.broadcast)
.map(|addr| addr.to_string())
.collect::<Vec<_>>()
.join("\n");
let scope_label = dev
.addrs
.iter()
.map(|addr| match (&addr.scope, &addr.label) {
(Some(scope), Some(label)) => format!("{scope} ({label})"),
(Some(scope), None) => scope.clone(),
(None, Some(label)) => label.clone(),
(None, None) => String::new(),
})
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("\n");
table.add_row([
Cell::new(&dev.ifname),
Cell::new(&dev.operstate),
Cell::new(dev.mac.map(|m| m.to_string()).unwrap_or_default()),
Cell::new(addresses),
Cell::new(broadcasts),
Cell::new(scope_label),
]);
}
table
}
fn filter_interface_summaries(
mut devs: Vec<InterfaceSummary>,
args: &DevsArgs,
) -> Vec<InterfaceSummary> {
if args.up {
devs.retain(|dev| dev.operstate == "up");
}
if let Some(name) = &args.dev {
devs.retain(|dev| &dev.ifname == name);
}
devs
}
fn format_epoch_local(epoch: u64) -> String {
match DateTime::<Utc>::from_timestamp(epoch as i64, 0) {
Some(dt) => dt
@@ -212,17 +308,83 @@ async fn main() -> anyhow::Result<()> {
}
}
Command::Wake(args) => {
let result = wakey::wake_from_query(args.query).await?;
if args.json {
let as_json = args.json;
let result = run_wake(args).await?;
if as_json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
println!("{}", render_wake_table(&result));
}
}
Command::Devs => {
let devs = wakey::list_interfaces().await?;
Command::Devs(args) => {
let devs = if let Some(name) = &args.dev {
wakey::get_interface_summary(name)
.await?
.into_iter()
.collect()
} else {
wakey::get_interface_summaries().await?
};
let devs = filter_interface_summaries(devs, &args);
if args.json {
println!("{}", serde_json::to_string_pretty(&devs)?);
} else {
println!("{}", render_devs_table(&devs));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::WakeArgs;
#[test]
fn wake_rejects_ip_without_mac() {
let err = super::validate_wake_args(&WakeArgs {
query: None,
mac: None,
ip: Some("192.168.1.10".parse().expect("ip")),
json: false,
})
.expect_err("ip-only wake should be rejected");
assert!(err.to_string().contains("--ip"));
}
#[test]
fn wake_rejects_mixed_query_and_explicit_mode() {
let err = super::validate_wake_args(&WakeArgs {
query: Some("pc".into()),
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
ip: None,
json: false,
})
.expect_err("mixed wake mode should be rejected");
assert!(err.to_string().contains("mutually exclusive"));
}
#[test]
fn wake_accepts_query_mode() {
super::validate_wake_args(&WakeArgs {
query: Some("pc".into()),
mac: None,
ip: None,
json: false,
})
.expect("query mode should be accepted");
}
#[test]
fn wake_accepts_manual_mac_mode() {
super::validate_wake_args(&WakeArgs {
query: None,
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
ip: None,
json: false,
})
.expect("manual mac mode should be accepted");
}
}
+23
View File
@@ -0,0 +1,23 @@
use anyhow::Result;
use wakey_core::InterfaceSummary;
pub async fn list_interfaces() -> Result<Vec<String>> {
Ok(wakey_linux::devices::devs_sorted().await)
}
pub async fn get_interface_summaries() -> Result<Vec<InterfaceSummary>> {
wakey_linux::devices::list_interface_summaries().await
}
pub async fn get_interface_summary(name: &str) -> Result<Option<InterfaceSummary>> {
Ok(get_interface_summaries()
.await?
.into_iter()
.find(|iface| iface.ifname == name))
}
pub async fn get_ips(name: impl AsRef<str>) -> Result<Vec<std::net::IpAddr>> {
Ok(wakey_linux::devices::get_ips(name.as_ref())
.await?
.collect())
}
+97
View File
@@ -0,0 +1,97 @@
use anyhow::Result;
use wakey_core::{
Device, DeviceInventory, DeviceQuery, DhcpLease, DhcpLeaseWithState, NeighborEntry, Presence,
};
use crate::service::leases::get_leases;
use crate::service::query::resolve_query;
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 neighbors = wakey_linux::devices::query_status(&query).await?;
let leases = get_leases(wakey_core::LeaseQuery {
include_state: false,
})
.await?;
Ok(DeviceInventory {
devices: merge_devices(neighbors, leases, &query),
})
}
pub fn merge_devices(
neighbors: Vec<NeighborEntry>,
leases: Vec<DhcpLeaseWithState>,
query: &DeviceQuery,
) -> Vec<Device> {
use std::collections::BTreeMap;
let mut by_mac: BTreeMap<String, (Vec<NeighborEntry>, Vec<DhcpLease>)> = BTreeMap::new();
for row in neighbors {
let key = row
.mac
.map(|m| m.to_string())
.unwrap_or_else(|| format!("ip:{}", row.ip));
by_mac.entry(key).or_default().0.push(row);
}
for lease in leases {
let key = lease.lease_line.mac.to_string();
by_mac.entry(key).or_default().1.push(lease.lease_line);
}
let mut devices: Vec<Device> = by_mac
.into_values()
.map(|(neighbors, leases)| Device::from_parts(neighbors, leases))
.collect();
if let Some(name) = &query.name {
devices.retain(|device| device.names.iter().any(|n| n == name));
}
if !query.filter.devs.is_empty() {
devices.retain(|device| {
device
.interfaces
.iter()
.any(|iface| query.filter.devs.contains(iface))
});
}
if !query.filter.ips.is_empty() {
devices.retain(|device| device.ips.iter().any(|ip| query.filter.ips.contains(ip)));
}
if !query.filter.macs.is_empty() {
devices.retain(|device| {
device
.macs
.iter()
.any(|mac| query.filter.macs.contains(mac))
});
}
if !query.filter.nuds.is_empty() {
devices.retain(|device| {
device
.neighbors
.iter()
.any(|neighbor| query.filter.nuds.contains(&neighbor.state))
});
}
devices.sort_by(|a, b| {
presence_rank(b.presence)
.cmp(&presence_rank(a.presence))
.then_with(|| a.names.first().cmp(&b.names.first()))
});
devices
}
const fn presence_rank(presence: Presence) -> u8 {
match presence {
Presence::Online => 3,
Presence::LikelyOnline => 2,
Presence::Unknown => 1,
Presence::Offline => 0,
}
}
+23
View File
@@ -0,0 +1,23 @@
use anyhow::{Context, Result};
use wakey_core::{DhcpLease, DhcpLeaseWithState, LeaseQuery};
pub async fn get_leases(query: LeaseQuery) -> Result<Vec<DhcpLeaseWithState>> {
let leases = wakey_linux::dhcp::read_dhcp_leases_with_names()
.await
.context("failed to read DHCP leases")?;
if query.include_state {
Ok(wakey_linux::dhcp::enrich_leases_with_nud_state(leases).await)
} else {
Ok(leases_without_state(leases))
}
}
pub fn leases_without_state(leases: Vec<DhcpLease>) -> Vec<DhcpLeaseWithState> {
leases
.into_iter()
.map(|lease_line| DhcpLeaseWithState {
lease_line,
nud_state: None,
})
.collect()
}
+15
View File
@@ -0,0 +1,15 @@
pub mod interfaces;
pub mod inventory;
pub mod leases;
pub mod query;
pub mod status;
pub mod wake;
pub use interfaces::{get_interface_summaries, get_interface_summary, get_ips, list_interfaces};
pub use inventory::{inventory, merge_devices, resolve_devices};
pub use leases::{get_leases, leases_without_state};
pub use query::{query_to_device_query, resolve_query, resolve_selector};
pub use status::{StatusResponse, device_to_status_rows, get_status, get_status_for_input};
pub use wake::{
broadcast_wake_targets, resolve_wake_targets, wake_explicit, wake_from_query, wake_targets,
};
+55
View File
@@ -0,0 +1,55 @@
use anyhow::Result;
use wakey_core::{DeviceFilters, DeviceQuery, Query, QueryInput};
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) => 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()
},
Query::Mac(mac_addr) => DeviceQuery {
filter: DeviceFilters {
macs: vec![mac_addr],
..Default::default()
},
..Default::default()
},
Query::Interface(dev) => DeviceQuery {
filter: DeviceFilters {
devs: vec![dev],
..Default::default()
},
..Default::default()
},
Query::NeighborState(state) => DeviceQuery {
filter: DeviceFilters {
nuds: vec![state],
..Default::default()
},
..Default::default()
},
Query::Text(name) => DeviceQuery {
name: Some(name),
..Default::default()
},
})
}
+53
View File
@@ -0,0 +1,53 @@
use anyhow::Result;
use wakey_core::{Device, DeviceQuery, NeighborEntry, Presence, Status};
use crate::service::inventory::inventory;
use crate::service::query::resolve_query;
pub type StatusResponse = Status<NeighborEntry>;
pub async fn get_status(query: DeviceQuery) -> Result<StatusResponse> {
let inventory = inventory(query.clone()).await?;
let table = inventory
.devices
.iter()
.flat_map(device_to_status_rows)
.collect();
Ok(Status {
name: query.name,
table,
filters: query.filter,
})
}
pub async fn get_status_for_input(input: impl Into<String>) -> Result<StatusResponse> {
let query = resolve_query(input).await?;
get_status(query).await
}
pub fn device_to_status_rows(device: &Device) -> Vec<NeighborEntry> {
if !device.neighbors.is_empty() {
return device.neighbors.clone();
}
let fallback_mac = device.macs.first().copied();
let fallback_dev = device.interfaces.first().cloned();
let fallback_state = match device.presence {
Presence::Online => wakey_core::NeighborState::Reachable,
Presence::LikelyOnline => wakey_core::NeighborState::Stale,
Presence::Offline => wakey_core::NeighborState::Failed,
Presence::Unknown => wakey_core::NeighborState::None,
};
device
.ips
.iter()
.copied()
.map(|ip| NeighborEntry {
ip,
dev: fallback_dev.clone(),
mac: fallback_mac,
state: fallback_state,
})
.collect()
}
+57
View File
@@ -0,0 +1,57 @@
use anyhow::{Context, Result};
use macaddr::MacAddr;
use std::net::IpAddr;
use wakey_core::{WakeResult, WakeTarget};
use crate::service::interfaces::get_interface_summaries;
use crate::service::inventory::resolve_devices;
pub async fn wake_targets(targets: Vec<WakeTarget>) -> Result<WakeResult> {
let result = wakey_linux::wake::wake_many(targets)
.await
.context("failed to send wake packets")?;
Ok(WakeResult { result })
}
pub async fn wake_from_query(input: impl Into<String>) -> Result<WakeResult> {
let targets = resolve_wake_targets(input).await?;
wake_targets(targets).await
}
pub async fn broadcast_wake_targets(mac: MacAddr) -> Result<Vec<WakeTarget>> {
Ok(get_interface_summaries()
.await?
.into_iter()
.flat_map(|iface| iface.addrs.into_iter())
.filter_map(|addr| addr.broadcast)
.map(|ip| WakeTarget {
ip: Some(IpAddr::V4(ip)),
mac: Some(mac),
})
.collect())
}
pub async fn wake_explicit(mac: MacAddr, ip: Option<IpAddr>) -> Result<WakeResult> {
let targets = match ip {
Some(ip) => vec![WakeTarget {
ip: Some(ip),
mac: Some(mac),
}],
None => broadcast_wake_targets(mac).await?,
};
wake_targets(targets).await
}
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())
}
+1 -1
View File
@@ -7,7 +7,7 @@ use tokio::{
time::timeout,
};
use crate::{arpparse::NUDState, utils::query::get_mac};
use crate::{legacy::arpparse::NUDState, utils::query::get_mac};
pub async fn _ping_ip<T: ToSocketAddrs>(addr: T) -> bool {
timeout(Duration::from_secs(1), TcpStream::connect(addr))
+2 -5
View File
@@ -7,10 +7,7 @@ pub mod leases {
pub mod parser {
pub use wakey_core::QueryInput as QueryType;
pub async fn parse_query(q: String) -> QueryType {
wakey_linux::devices::classify_query(q).await
}
pub use wakey_linux::devices::classify_query as parse_query;
}
pub use leases::*;
@@ -20,7 +17,7 @@ pub mod macs {
use anyhow::Result;
use std::net::IpAddr;
use crate::arpparse::{IpNeighLine, NUDState};
use crate::legacy::arpparse::{IpNeighLine, NUDState};
pub async fn get_ips(machine_name: &str) -> Result<impl Iterator<Item = IpAddr>> {
wakey_linux::devices::get_ips(machine_name).await
-374
View File
@@ -1,374 +0,0 @@
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, 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>,
#[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>,
}
#[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 {
#[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, Clone)]
pub enum Query {
Text(String),
Ip(IpAddr),
Mac(MacAddr),
Interface(String),
NeighborState(NeighborState),
}
#[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)
}
}
+123
View File
@@ -0,0 +1,123 @@
use macaddr::MacAddr;
use serde::Serialize;
use serde_with::skip_serializing_none;
use std::net::IpAddr;
use crate::model::{DhcpLease, NeighborEntry, NeighborState};
use crate::parse::mac;
#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash, Serialize, serde::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, Serialize)]
pub struct DeviceInventory {
pub devices: Vec<Device>,
}
+29
View File
@@ -0,0 +1,29 @@
use macaddr::MacAddr;
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
use std::net::IpAddr;
use crate::model::NeighborState;
use crate::parse::mac;
#[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>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct LeaseQuery {
pub include_state: bool,
}
+26
View File
@@ -0,0 +1,26 @@
use macaddr::MacAddr;
use serde::Serialize;
use serde_with::skip_serializing_none;
use crate::parse::mac;
#[skip_serializing_none]
#[derive(Debug, Clone, Serialize)]
pub struct InterfaceSummary {
pub ifindex: u32,
pub ifname: String,
pub operstate: String,
#[serde(with = "mac::option_mac")]
pub mac: Option<MacAddr>,
pub addrs: Vec<InterfaceAddr>,
}
#[skip_serializing_none]
#[derive(Debug, Clone, Serialize)]
pub struct InterfaceAddr {
pub family: Option<String>,
pub cidr: Option<String>,
pub broadcast: Option<std::net::Ipv4Addr>,
pub scope: Option<String>,
pub label: Option<String>,
}
+15
View File
@@ -0,0 +1,15 @@
mod device;
mod dhcp;
mod interface;
mod neighbor;
mod query;
mod status;
mod wake;
pub use device::{Device, DeviceId, DeviceInventory, Presence};
pub use dhcp::{DhcpLease, DhcpLeaseWithState, LeaseQuery};
pub use interface::{InterfaceAddr, InterfaceSummary};
pub use neighbor::{NeighborEntry, NeighborParseError, NeighborState, parse_neighbor_line};
pub use query::{DeviceFilters, DeviceQuery, NamePath, Query, QueryInput};
pub use status::Status;
pub use wake::{WakeResult, WakeStatus, WakeTarget, WakeTargetResult};
+128
View File
@@ -0,0 +1,128 @@
use macaddr::MacAddr;
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
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, 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)
}
}
+53
View File
@@ -0,0 +1,53 @@
use macaddr::MacAddr;
use serde::{Deserialize, Serialize};
use serde_with::{DisplayFromStr, OneOrMany, serde_as};
use std::net::IpAddr;
use crate::model::NeighborState;
#[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>,
}
#[derive(Debug, Default, Clone, Hash, Deserialize)]
pub struct NamePath {
pub name: String,
}
#[derive(Debug)]
pub enum QueryInput {
Ip(IpAddr),
Mac(MacAddr),
Dev(String),
Nud(NeighborState),
Name(String),
}
#[derive(Debug, Clone)]
pub enum Query {
Text(String),
Ip(IpAddr),
Mac(MacAddr),
Interface(String),
NeighborState(NeighborState),
}
+12
View File
@@ -0,0 +1,12 @@
use serde::Serialize;
use serde_with::skip_serializing_none;
use crate::model::DeviceFilters;
#[skip_serializing_none]
#[derive(Debug, Default, Serialize)]
pub struct Status<T> {
pub name: Option<String>,
pub table: Vec<T>,
pub filters: DeviceFilters,
}
+58
View File
@@ -0,0 +1,58 @@
use macaddr::MacAddr;
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
use std::net::IpAddr;
use crate::parse::mac;
#[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,
}
}
}
+96
View File
@@ -0,0 +1,96 @@
use anyhow::{Context, Result};
use lda_ipjs::subcommands::address;
use std::collections::HashSet;
use wakey_core::{InterfaceAddr, InterfaceSummary};
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 list_interface_summaries() -> Result<Vec<InterfaceSummary>> {
#[cfg(unix)]
let rows = address::nl::get(None)
.await
.context("rtnetlink address query failed")?;
#[cfg(not(unix))]
let rows = address::get_with_backend(address::Backend::Json, None)
.await
.context("ip -j address show failed")?;
let mut out: Vec<InterfaceSummary> = rows
.into_iter()
.filter(|row| row.ifname != "lo")
.map(|row| InterfaceSummary {
ifindex: row.ifindex,
ifname: row.ifname,
operstate: row.operstate.as_str().to_ascii_lowercase(),
mac: row.address,
addrs: row
.addr_info
.into_iter()
.map(|info| InterfaceAddr {
family: info.family.map(|family| family.as_str().to_string()),
cidr: info
.cidr
.local
.zip(info.cidr.prefixlen)
.map(|(addr, prefixlen)| format!("{addr}/{prefixlen}")),
broadcast: info.broadcast,
scope: info.scope,
label: info.label,
})
.collect(),
})
.collect();
out.sort_by(|a, b| a.ifname.cmp(&b.ifname));
Ok(out)
}
pub async fn has_dev(name: &str) -> bool {
list_devs().await.contains(name)
}
+7
View File
@@ -0,0 +1,7 @@
mod interfaces;
mod neighbors;
mod query;
pub use interfaces::{devs_sorted, has_dev, list_devs, list_interface_summaries};
pub use neighbors::{get_ips, get_neighbors, query_status};
pub use query::classify_query;
@@ -1,10 +1,9 @@
use anyhow::{Context, Result};
use futures::future::try_join_all;
use lda_ipjs::subcommands::neighbor;
use std::collections::HashSet;
use std::net::IpAddr;
use wakey_core::{DeviceQuery, NeighborEntry, NeighborState, QueryInput, parse};
use lda_ipjs::subcommands::neighbor;
use wakey_core::{DeviceQuery, NeighborEntry, NeighborState};
pub async fn get_ips(machine_name: &str) -> Result<impl Iterator<Item = IpAddr>> {
Ok(tokio::net::lookup_host((machine_name, 0))
@@ -112,74 +111,6 @@ pub async fn query_status(query: &DeviceQuery) -> Result<Vec<NeighborEntry>> {
.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,
+22
View File
@@ -0,0 +1,22 @@
use std::net::IpAddr;
use wakey_core::{NeighborState, QueryInput, parse};
use crate::devices::interfaces::has_dev;
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())
}