2 Commits
14 changed files with 815 additions and 751 deletions

No files matched your search

Generated
+243 -309
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -24,7 +24,9 @@ tokio = { version = "1", features = [
"io-util", "io-util",
"macros", "macros",
] } ] }
tower-http = { version = "0", features = ["fs"] } tower-http = { version = "0", features = ["fs", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
urlencoding = "2" urlencoding = "2"
wakey-core = { path = "wakey-core" } wakey-core = { path = "wakey-core" }
wakey-linux = { path = "wakey-linux" } wakey-linux = { path = "wakey-linux" }
+23
View File
@@ -64,6 +64,29 @@ The long-term direction is:
- keep HTTP as an adapter, not the architecture - keep HTTP as an adapter, not the architecture
- eventually move toward an agent + control-plane model - eventually move toward an agent + control-plane model
## Future direction
The likely next large step is splitting the current temporary HTTP/web hosting
role away from the main `wakey` binary.
The intended shape is roughly:
- `wakey`
- stable service layer and operator CLI
- `wakey-agent`
- router-side daemon exposing a network API over the service layer
- control-center app
- remote UI or control plane that talks to one or more agents
That future agent layer will likely need:
- explicit registration/authentication
- a stable remote API
- a small deployment/bootstrap story on the router
The current CLI and compatibility HTTP adapter are being kept small on purpose
so that split can happen later without moving the real product logic again.
## CLI ## CLI
`wakey` is usable as a local/operator CLI. `wakey` is usable as a local/operator CLI.
+365
View File
@@ -0,0 +1,365 @@
//! CLI argument parsing, dispatch, rendering, and tracing defaults.
pub mod table;
use std::net::{IpAddr, SocketAddr};
use anyhow::Result;
use clap::{ArgAction, Args, Parser, Subcommand};
use tracing::{debug, info};
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
use wakey_core::{DeviceFilters, DeviceQuery, InterfaceSummary, WakeResult};
#[derive(Parser)]
#[command(name = "wakey")]
#[command(version, about = "CLI and temporary HTTP adapter for Wakey")]
#[command(
long_about = "Wakey can run as a local/operator CLI or serve the legacy HTTP/static interface during the migration to a service-first architecture."
)]
pub struct Cli {
/// Increase log verbosity. Use `-v` for debug and `-vv` for trace.
#[arg(short = 'v', long = "verbose", action = ArgAction::Count, global = true)]
pub verbose: u8,
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand)]
pub enum Command {
/// Serve the temporary legacy HTTP/static app.
Http(HttpArgs),
/// Show device status rows from neighbor/device data.
Status(StatusArgs),
/// Show DHCP leases, optionally enriched with current neighbor state.
Leases(LeasesArgs),
/// Send Wake-on-LAN packets from a query or explicit MAC/IP pair.
Wake(WakeArgs),
/// Show condensed network interface summaries.
Devs(DevsArgs),
}
#[derive(Args)]
pub struct HttpArgs {
/// Host address to bind the HTTP server to.
#[arg(long, default_value = "::")]
pub host: IpAddr,
/// TCP port to bind the HTTP server to.
#[arg(long, default_value_t = 12012)]
pub port: u16,
}
#[derive(Args)]
pub struct LeasesArgs {
/// Include best-known current neighbor state for each lease IP.
#[arg(long)]
pub include_state: bool,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
pub json: bool,
}
#[derive(Args)]
#[command(after_long_help = "Examples:
wakey wake bedroom-pc
wakey wake --mac aa:bb:cc:dd:ee:ff
wakey wake --mac aa:bb:cc:dd:ee:ff --ip 192.168.1.255
Rules:
- query mode and explicit --mac/--ip mode are mutually exclusive
- --ip requires --mac
- --mac without --ip fans out to interface broadcast targets")]
pub struct WakeArgs {
/// Free-form device query, for example a hostname, IP, MAC, interface, or NUD state.
pub query: Option<String>,
/// Explicit MAC address for manual wake mode.
#[arg(long)]
pub mac: Option<macaddr::MacAddr>,
/// Explicit destination IP or broadcast address for manual wake mode.
#[arg(long)]
pub ip: Option<IpAddr>,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
pub json: bool,
}
#[derive(Args)]
#[command(after_long_help = "Examples:
wakey status bedroom-pc
wakey status --mac aa:bb:cc:dd:ee:ff
wakey status --dev br-lan --nud reachable
If only the positional query is provided, it is treated as free-form input and resolved through the smart selector path.")]
pub struct StatusArgs {
/// Free-form device query.
pub query: Option<String>,
/// Explicit name/text filter.
#[arg(long)]
pub name: Option<String>,
/// Explicit IP filters.
#[arg(long = "ip")]
pub ips: Vec<std::net::IpAddr>,
/// Explicit interface-name filters.
#[arg(long = "dev")]
pub devs: Vec<String>,
/// Explicit neighbor-state filters.
#[arg(long = "nud")]
pub nuds: Vec<wakey_core::NeighborState>,
/// Explicit MAC-address filters.
#[arg(long = "mac")]
pub macs: Vec<macaddr::MacAddr>,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
pub json: bool,
}
#[derive(Args)]
#[command(after_long_help = "Examples:
wakey devs
wakey devs br-lan
wakey devs --up
wakey devs --json")]
pub struct DevsArgs {
/// Optional interface name to show.
pub dev: Option<String>,
/// Show only interfaces whose operstate is `up`.
#[arg(long)]
pub up: bool,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
pub json: bool,
}
pub fn init_tracing(verbose: u8) {
let filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(default_filter_for_verbosity(verbose)))
.expect("static tracing filter should parse");
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer())
.init();
}
pub fn default_filter_for_verbosity(verbose: u8) -> &'static str {
match verbose {
0 => "wakey=info,tower_http=info",
1 => "wakey=debug,tower_http=debug",
_ => "wakey=trace,tower_http=trace",
}
}
pub async fn run(cli: Cli) -> Result<()> {
init_tracing(cli.verbose);
match cli.command {
Command::Http(args) => {
let addr = SocketAddr::new(args.host, args.port);
info!(%addr, "dispatching http command");
wakey::serve_http_from_current_exe(addr).await?;
}
Command::Status(args) => {
let as_json = args.json;
let query = status_args_to_query(args);
debug!(?query, json = as_json, "dispatching status command");
let status = if query.name.is_some()
&& query.filter.ips.is_empty()
&& query.filter.devs.is_empty()
&& query.filter.nuds.is_empty()
&& query.filter.macs.is_empty()
{
wakey::get_status_for_input(query.name.clone().unwrap_or_default()).await?
} else {
wakey::get_status(query).await?
};
if as_json {
println!("{}", serde_json::to_string_pretty(&status)?);
} else {
if let Some(name) = &status.name {
println!("name: {name}");
}
println!("{}", table::render_status_table(&status));
}
}
Command::Leases(args) => {
debug!(
include_state = args.include_state,
json = args.json,
"dispatching leases command"
);
let leases = wakey::get_leases(wakey_core::LeaseQuery {
include_state: args.include_state,
})
.await?;
if args.json {
println!("{}", serde_json::to_string_pretty(&leases)?);
} else {
println!("{}", table::render_leases_table(&leases));
}
}
Command::Wake(args) => {
let as_json = args.json;
debug!(
has_query = args.query.is_some(),
has_mac = args.mac.is_some(),
has_ip = args.ip.is_some(),
json = as_json,
"dispatching wake command"
);
let result = run_wake(args).await?;
if as_json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
println!("{}", table::render_wake_table(&result));
}
}
Command::Devs(args) => {
debug!(dev = ?args.dev, up = args.up, json = args.json, "dispatching devs command");
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!("{}", table::render_devs_table(&devs));
}
}
}
Ok(())
}
fn status_args_to_query(args: StatusArgs) -> DeviceQuery {
if let Some(query) = args.query.as_ref()
&& args.name.is_none()
&& args.ips.is_empty()
&& args.devs.is_empty()
&& args.nuds.is_empty()
&& args.macs.is_empty()
{
return DeviceQuery {
name: Some(query.clone()),
..Default::default()
};
}
DeviceQuery {
name: args.name.or(args.query),
filter: DeviceFilters {
ips: args.ips,
devs: args.devs,
nuds: args.nuds,
macs: args.macs,
},
}
}
fn validate_wake_args(args: &WakeArgs) -> 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) -> 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 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
}
#[cfg(test)]
mod tests {
use super::{WakeArgs, default_filter_for_verbosity};
#[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");
}
#[test]
fn verbosity_maps_to_expected_default_filters() {
assert_eq!(default_filter_for_verbosity(0), "wakey=info,tower_http=info");
assert_eq!(default_filter_for_verbosity(1), "wakey=debug,tower_http=debug");
assert_eq!(default_filter_for_verbosity(2), "wakey=trace,tower_http=trace");
assert_eq!(default_filter_for_verbosity(9), "wakey=trace,tower_http=trace");
}
}
+112
View File
@@ -0,0 +1,112 @@
use chrono::{DateTime, Local};
use comfy_table::{Cell, ContentArrangement, Table, presets::UTF8_FULL};
use wakey_core::{DhcpLeaseWithState, InterfaceSummary, Status, WakeResult};
pub fn render_status_table(status: &Status<wakey_core::NeighborEntry>) -> Table {
let mut table = base_table();
table.set_header(vec!["IP", "MAC", "Dev", "State"]);
for row in &status.table {
table.add_row(vec![
Cell::new(row.ip.to_string()),
Cell::new(row.mac.map(|v| v.to_string()).unwrap_or_default()),
Cell::new(row.dev.clone().unwrap_or_default()),
Cell::new(row.state.to_string()),
]);
}
table
}
pub fn render_leases_table(leases: &[DhcpLeaseWithState]) -> Table {
let mut table = base_table();
table.set_header(vec!["Expires", "IP", "MAC", "Name", "State"]);
for lease in leases {
table.add_row(vec![
Cell::new(format_epoch(lease.lease_line.expires_epoch)),
Cell::new(lease.lease_line.ip.to_string()),
Cell::new(lease.lease_line.mac.to_string()),
Cell::new(lease.lease_line.name.clone().unwrap_or_default()),
Cell::new(
lease
.nud_state
.map(|v| v.to_string())
.unwrap_or_default(),
),
]);
}
table
}
pub fn render_wake_table(result: &WakeResult) -> Table {
let mut table = base_table();
table.set_header(vec!["IP", "MAC", "Status"]);
for row in &result.result {
table.add_row(vec![
Cell::new(row.target.ip.map(|v| v.to_string()).unwrap_or_default()),
Cell::new(row.target.mac.map(|v| v.to_string()).unwrap_or_default()),
Cell::new(format!("{:?}", row.status)),
]);
}
table
}
pub fn render_devs_table(devs: &[InterfaceSummary]) -> Table {
let mut table = base_table();
table.set_header(vec!["Ifname", "State", "MAC", "Family", "CIDR", "Broadcast", "Scope/Label"]);
for dev in devs {
if dev.addrs.is_empty() {
table.add_row(vec![
Cell::new(&dev.ifname),
Cell::new(&dev.operstate),
Cell::new(dev.mac.map(|v| v.to_string()).unwrap_or_default()),
Cell::new(""),
Cell::new(""),
Cell::new(""),
Cell::new(""),
]);
continue;
}
for (idx, addr) in dev.addrs.iter().enumerate() {
let lead = idx == 0;
table.add_row(vec![
Cell::new(if lead { dev.ifname.as_str() } else { "" }),
Cell::new(if lead { dev.operstate.as_str() } else { "" }),
Cell::new(if lead {
dev.mac.map(|v| v.to_string()).unwrap_or_default()
} else {
String::new()
}),
Cell::new(addr.family.clone().unwrap_or_default()),
Cell::new(addr.cidr.clone().unwrap_or_default()),
Cell::new(addr.broadcast.map(|v| v.to_string()).unwrap_or_default()),
Cell::new(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(),
}),
]);
}
}
table
}
fn base_table() -> Table {
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic);
table
}
fn format_epoch(epoch: u64) -> String {
DateTime::from_timestamp(epoch as i64, 0)
.map(|dt| {
dt.with_timezone(&Local)
.format("%Y-%m-%d %H:%M:%S")
.to_string()
})
.unwrap_or_else(|| epoch.to_string())
}
-123
View File
@@ -1,123 +0,0 @@
use chrono::{DateTime, Local, Utc};
use comfy_table::{Cell, ContentArrangement, Table, presets};
use wakey_core::{DhcpLeaseWithState, InterfaceSummary, WakeResult};
fn base_table() -> Table {
let mut table = Table::new();
table
.load_preset(presets::UTF8_FULL_CONDENSED)
.set_content_arrangement(ContentArrangement::Dynamic);
table
}
pub fn render_status_table(status: &wakey::StatusResponse) -> Table {
let mut table = base_table();
table.set_header(["IP", "MAC", "State", "IF"]);
for row in &status.table {
table.add_row([
Cell::new(row.ip),
Cell::new(row.mac.map(|m| m.to_string()).unwrap_or_default()),
Cell::new(format!("{:?}", row.state).to_lowercase()),
Cell::new(row.dev.clone().unwrap_or_default()),
]);
}
table
}
pub fn render_leases_table(leases: &[DhcpLeaseWithState]) -> Table {
let mut table = base_table();
table.set_header(["IP", "MAC", "Name", "Expires", "NUD"]);
for lease in leases {
let expires = format_epoch_local(lease.lease_line.expires_epoch);
table.add_row([
Cell::new(lease.lease_line.ip),
Cell::new(lease.lease_line.mac),
Cell::new(lease.lease_line.name.clone().unwrap_or_default()),
Cell::new(expires),
Cell::new(
lease
.nud_state
.map(|s| format!("{:?}", s).to_lowercase())
.unwrap_or_default(),
),
]);
}
table
}
pub fn render_wake_table(result: &WakeResult) -> Table {
let mut table = base_table();
table.set_header(["IP", "MAC", "Status"]);
for row in &result.result {
table.add_row([
Cell::new(row.target.ip.map(|ip| ip.to_string()).unwrap_or_default()),
Cell::new(row.target.mac.map(|m| m.to_string()).unwrap_or_default()),
Cell::new(format!("{:?}", row.status).to_lowercase()),
]);
}
table
}
pub fn render_devs_table(devs: &[InterfaceSummary]) -> Table {
let mut table = base_table();
table.set_header([
"Interface",
"State",
"MAC",
"Family",
"Address",
"Broadcast",
"Scope/Label",
]);
for dev in devs {
if dev.addrs.is_empty() {
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(""),
Cell::new(""),
Cell::new(""),
Cell::new(""),
]);
continue;
}
for (idx, addr) in dev.addrs.iter().enumerate() {
let scope_label = 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(),
};
let lead = idx == 0;
table.add_row([
Cell::new(if lead { dev.ifname.as_str() } else { "" }),
Cell::new(if lead { dev.operstate.as_str() } else { "" }),
Cell::new(if lead {
dev.mac.map(|m| m.to_string()).unwrap_or_default()
} else {
String::new()
}),
Cell::new(addr.family.clone().unwrap_or_default()),
Cell::new(addr.cidr.clone().unwrap_or_default()),
Cell::new(addr.broadcast.map(|ip| ip.to_string()).unwrap_or_default()),
Cell::new(scope_label),
]);
}
}
table
}
fn format_epoch_local(epoch: u64) -> String {
match DateTime::<Utc>::from_timestamp(epoch as i64, 0) {
Some(dt) => dt
.with_timezone(&Local)
.format("%Y-%m-%d %H:%M:%S")
.to_string(),
None => epoch.to_string(),
}
}
+8
View File
@@ -1,3 +1,9 @@
//! Compatibility types and mappers for the legacy HTTP/static client.
//!
//! These types intentionally preserve old JSON shapes expected by `/static`
//! while the core and service layers evolve underneath them. They do not define
//! the long-term domain model of the project.
use serde::Serialize; use serde::Serialize;
use wakey_core::parse::mac; use wakey_core::parse::mac;
use wakey_core::{ use wakey_core::{
@@ -49,6 +55,8 @@ pub struct LegacyWakeResultRow {
} }
/// Map legacy-style status rows into the old response shape. /// Map legacy-style status rows into the old response shape.
///
/// This helper exists for compatibility with the original frontend contract.
pub fn legacy_status_from_domain(status: Status<NeighborEntry>) -> LegacyStatusResponse { pub fn legacy_status_from_domain(status: Status<NeighborEntry>) -> LegacyStatusResponse {
LegacyStatusResponse { LegacyStatusResponse {
name: status.name, name: status.name,
+18 -2
View File
@@ -1,3 +1,9 @@
//! Temporary HTTP adapter for the legacy web/static surface.
//!
//! This module exists to keep the old `/api` routes and `/static` frontend
//! working while the project is migrated toward a service-first architecture.
//! New product logic should live in [`crate::service`], not here.
pub mod compat; pub mod compat;
pub mod route; pub mod route;
@@ -5,12 +11,17 @@ use std::{io, net::SocketAddr};
use axum::Router; use axum::Router;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tower_http::services::ServeDir; use tower_http::{services::ServeDir, trace::TraceLayer};
use tracing::info;
/// Build the temporary HTTP app that serves the legacy API and static frontend. /// Build the temporary HTTP app that serves the legacy API and static frontend.
///
/// This is a compatibility surface. It should stay thin and delegate actual
/// product behavior to the service layer.
pub fn http_app(static_root: std::path::PathBuf) -> Router { pub fn http_app(static_root: std::path::PathBuf) -> Router {
Router::new() Router::new()
.nest("/api", route::api_router()) .nest("/api", route::api_router())
.layer(TraceLayer::new_for_http())
.fallback_service(axum::routing::get_service( .fallback_service(axum::routing::get_service(
ServeDir::new(static_root) ServeDir::new(static_root)
.append_index_html_on_directories(true) .append_index_html_on_directories(true)
@@ -22,12 +33,17 @@ pub fn http_app(static_root: std::path::PathBuf) -> Router {
} }
/// Serve the temporary HTTP app on the provided socket address. /// Serve the temporary HTTP app on the provided socket address.
///
/// This is intended for transition and compatibility, not as the long-term
/// architecture boundary of the project.
pub async fn serve_http(addr: SocketAddr, static_root: std::path::PathBuf) -> io::Result<()> { pub async fn serve_http(addr: SocketAddr, static_root: std::path::PathBuf) -> io::Result<()> {
info!(%addr, static_root = %static_root.display(), "starting legacy http adapter");
let listener = TcpListener::bind(addr).await?; let listener = TcpListener::bind(addr).await?;
axum::serve(listener, http_app(static_root).into_make_service()).await axum::serve(listener, http_app(static_root).into_make_service()).await
} }
/// Serve the HTTP app using the `static/` directory next to the current executable. /// Serve the HTTP app using the `static/` directory next to the current
/// executable.
pub async fn serve_http_from_current_exe(addr: SocketAddr) -> io::Result<()> { pub async fn serve_http_from_current_exe(addr: SocketAddr) -> io::Result<()> {
let exe = std::env::current_exe()?; let exe = std::env::current_exe()?;
let root = exe let root = exe
+6
View File
@@ -1,2 +1,8 @@
//! Transitional compatibility wrappers preserved during the migration.
//!
//! Items in this module exist so the codebase can keep working while older
//! parsing paths and adapter surfaces are being retired or replaced. New logic
//! should prefer the service layer and the dedicated crate boundaries instead.
pub mod arpparse; pub mod arpparse;
pub mod dhcpparse; pub mod dhcpparse;
+3 -308
View File
@@ -1,193 +1,6 @@
mod cli_table; mod cli;
use std::net::{IpAddr, SocketAddr}; use clap::Parser;
use clap::{Args, Parser, Subcommand};
use wakey_core::{DeviceFilters, DeviceQuery, InterfaceSummary, WakeResult};
#[derive(Parser)]
#[command(name = "wakey")]
#[command(version, about = "CLI and temporary HTTP adapter for Wakey")]
#[command(
long_about = "Wakey can run as a local/operator CLI or serve the legacy HTTP/static interface during the migration to a service-first architecture."
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Serve the temporary legacy HTTP/static app.
Http(HttpArgs),
/// Show device status rows from neighbor/device data.
Status(StatusArgs),
/// Show DHCP leases, optionally enriched with current neighbor state.
Leases(LeasesArgs),
/// Send Wake-on-LAN packets from a query or explicit MAC/IP pair.
Wake(WakeArgs),
/// Show condensed network interface summaries.
Devs(DevsArgs),
}
#[derive(Args)]
struct HttpArgs {
/// Host address to bind the HTTP server to.
#[arg(long, default_value = "::")]
host: IpAddr,
/// TCP port to bind the HTTP server to.
#[arg(long, default_value_t = 12012)]
port: u16,
}
#[derive(Args)]
struct LeasesArgs {
/// Include best-known current neighbor state for each lease IP.
#[arg(long)]
include_state: bool,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
json: bool,
}
#[derive(Args)]
#[command(after_long_help = "Examples:
wakey wake bedroom-pc
wakey wake --mac aa:bb:cc:dd:ee:ff
wakey wake --mac aa:bb:cc:dd:ee:ff --ip 192.168.1.255
Rules:
- query mode and explicit --mac/--ip mode are mutually exclusive
- --ip requires --mac
- --mac without --ip fans out to interface broadcast targets")]
struct WakeArgs {
/// Free-form device query, for example a hostname, IP, MAC, interface, or NUD state.
query: Option<String>,
/// Explicit MAC address for manual wake mode.
#[arg(long)]
mac: Option<macaddr::MacAddr>,
/// Explicit destination IP or broadcast address for manual wake mode.
#[arg(long)]
ip: Option<IpAddr>,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
json: bool,
}
#[derive(Args)]
#[command(after_long_help = "Examples:
wakey status bedroom-pc
wakey status --mac aa:bb:cc:dd:ee:ff
wakey status --dev br-lan --nud reachable
If only the positional query is provided, it is treated as free-form input and resolved through the smart selector path.")]
struct StatusArgs {
/// Free-form device query.
query: Option<String>,
/// Explicit name/text filter.
#[arg(long)]
name: Option<String>,
/// Explicit IP filters.
#[arg(long = "ip")]
ips: Vec<std::net::IpAddr>,
/// Explicit interface-name filters.
#[arg(long = "dev")]
devs: Vec<String>,
/// Explicit neighbor-state filters.
#[arg(long = "nud")]
nuds: Vec<wakey_core::NeighborState>,
/// Explicit MAC-address filters.
#[arg(long = "mac")]
macs: Vec<macaddr::MacAddr>,
/// Print machine-readable JSON instead of a table.
#[arg(long)]
json: bool,
}
#[derive(Args)]
#[command(after_long_help = "Examples:
wakey devs
wakey devs br-lan
wakey devs --up
wakey devs --json")]
struct DevsArgs {
/// Optional interface name to show.
dev: Option<String>,
/// Show only interfaces whose operstate is `up`.
#[arg(long)]
up: bool,
/// Print machine-readable JSON instead of a table.
#[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()
&& args.ips.is_empty()
&& args.devs.is_empty()
&& args.nuds.is_empty()
&& args.macs.is_empty()
{
return DeviceQuery {
name: Some(query.clone()),
..Default::default()
};
}
DeviceQuery {
name: args.name.or(args.query),
filter: DeviceFilters {
ips: args.ips,
devs: args.devs,
nuds: args.nuds,
macs: args.macs,
},
}
}
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 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
}
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
@@ -199,123 +12,5 @@ fn main() -> anyhow::Result<()> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let cli = Cli::parse(); cli::run(cli::Cli::parse()).await
match cli.command {
Command::Http(args) => {
let addr = SocketAddr::new(args.host, args.port);
wakey::serve_http_from_current_exe(addr).await?;
}
Command::Status(args) => {
let as_json = args.json;
let query = status_args_to_query(args);
let status = if query.name.is_some()
&& query.filter.ips.is_empty()
&& query.filter.devs.is_empty()
&& query.filter.nuds.is_empty()
&& query.filter.macs.is_empty()
{
wakey::get_status_for_input(query.name.clone().unwrap_or_default()).await?
} else {
wakey::get_status(query).await?
};
if as_json {
println!("{}", serde_json::to_string_pretty(&status)?);
} else {
if let Some(name) = &status.name {
println!("name: {name}");
}
println!("{}", cli_table::render_status_table(&status));
}
}
Command::Leases(args) => {
let leases = wakey::get_leases(wakey_core::LeaseQuery {
include_state: args.include_state,
})
.await?;
if args.json {
println!("{}", serde_json::to_string_pretty(&leases)?);
} else {
println!("{}", cli_table::render_leases_table(&leases));
}
}
Command::Wake(args) => {
let as_json = args.json;
let result = run_wake(args).await?;
if as_json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
println!("{}", cli_table::render_wake_table(&result));
}
}
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!("{}", cli_table::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");
}
} }
+10 -3
View File
@@ -1,4 +1,5 @@
use anyhow::Result; use anyhow::Result;
use tracing::{debug, instrument};
use wakey_core::InterfaceSummary; use wakey_core::InterfaceSummary;
/// Return interface names only. /// Return interface names only.
@@ -9,16 +10,22 @@ pub async fn list_interfaces() -> Result<Vec<String>> {
} }
/// Return condensed interface summaries useful for CLI and wake routing. /// Return condensed interface summaries useful for CLI and wake routing.
#[instrument(skip_all)]
pub async fn get_interface_summaries() -> Result<Vec<InterfaceSummary>> { pub async fn get_interface_summaries() -> Result<Vec<InterfaceSummary>> {
wakey_linux::devices::list_interface_summaries().await let summaries = wakey_linux::devices::list_interface_summaries().await?;
debug!(count = summaries.len(), "loaded interface summaries");
Ok(summaries)
} }
/// Return one named interface summary when present. /// Return one named interface summary when present.
#[instrument(skip_all, fields(ifname = name))]
pub async fn get_interface_summary(name: &str) -> Result<Option<InterfaceSummary>> { pub async fn get_interface_summary(name: &str) -> Result<Option<InterfaceSummary>> {
Ok(get_interface_summaries() let summary = get_interface_summaries()
.await? .await?
.into_iter() .into_iter()
.find(|iface| iface.ifname == name)) .find(|iface| iface.ifname == name);
debug!(found = summary.is_some(), "resolved interface summary");
Ok(summary)
} }
/// Resolve a hostname through the local resolver and collect all returned IPs. /// Resolve a hostname through the local resolver and collect all returned IPs.
+5 -1
View File
@@ -1,4 +1,5 @@
use anyhow::Result; use anyhow::Result;
use tracing::{debug, instrument};
use wakey_core::{Device, DeviceQuery, NeighborEntry, Presence, Status}; use wakey_core::{Device, DeviceQuery, NeighborEntry, Presence, Status};
use crate::service::inventory::inventory; use crate::service::inventory::inventory;
@@ -11,13 +12,15 @@ pub type StatusResponse = Status<NeighborEntry>;
/// ///
/// This keeps the old status response shape alive while the underlying model is /// This keeps the old status response shape alive while the underlying model is
/// increasingly device-centered. /// increasingly device-centered.
#[instrument(skip_all, fields(name = ?query.name))]
pub async fn get_status(query: DeviceQuery) -> Result<StatusResponse> { pub async fn get_status(query: DeviceQuery) -> Result<StatusResponse> {
let inventory = inventory(query.clone()).await?; let inventory = inventory(query.clone()).await?;
let table = inventory let table: Vec<NeighborEntry> = inventory
.devices .devices
.iter() .iter()
.flat_map(device_to_status_rows) .flat_map(device_to_status_rows)
.collect(); .collect();
debug!(rows = table.len(), devices = inventory.devices.len(), "built status response");
Ok(Status { Ok(Status {
name: query.name, name: query.name,
table, table,
@@ -26,6 +29,7 @@ pub async fn get_status(query: DeviceQuery) -> Result<StatusResponse> {
} }
/// Convenience wrapper around [`get_status`] for free-form user input. /// Convenience wrapper around [`get_status`] for free-form user input.
#[instrument(skip_all)]
pub async fn get_status_for_input(input: impl Into<String>) -> Result<StatusResponse> { pub async fn get_status_for_input(input: impl Into<String>) -> Result<StatusResponse> {
let query = resolve_query(input).await?; let query = resolve_query(input).await?;
get_status(query).await get_status(query).await
+16 -3
View File
@@ -1,20 +1,24 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use macaddr::MacAddr; use macaddr::MacAddr;
use std::net::IpAddr; use std::net::IpAddr;
use tracing::{debug, instrument};
use wakey_core::{InterfaceSummary, WakeResult, WakeTarget}; use wakey_core::{InterfaceSummary, WakeResult, WakeTarget};
use crate::service::interfaces::get_interface_summaries; use crate::service::interfaces::get_interface_summaries;
use crate::service::inventory::resolve_devices; use crate::service::inventory::resolve_devices;
/// Send Wake-on-LAN packets for already-concrete wake targets. /// Send Wake-on-LAN packets for already-concrete wake targets.
#[instrument(skip_all, fields(targets = targets.len()))]
pub async fn wake_targets(targets: Vec<WakeTarget>) -> Result<WakeResult> { pub async fn wake_targets(targets: Vec<WakeTarget>) -> Result<WakeResult> {
let result = wakey_linux::wake::wake_many(targets) let result = wakey_linux::wake::wake_many(targets)
.await .await
.context("failed to send wake packets")?; .context("failed to send wake packets")?;
debug!(results = result.len(), "wake packets sent");
Ok(WakeResult { result }) Ok(WakeResult { result })
} }
/// Resolve free-form input into wake targets and send the packets. /// Resolve free-form input into wake targets and send the packets.
#[instrument(skip_all)]
pub async fn wake_from_query(input: impl Into<String>) -> Result<WakeResult> { pub async fn wake_from_query(input: impl Into<String>) -> Result<WakeResult> {
let targets = resolve_wake_targets(input).await?; let targets = resolve_wake_targets(input).await?;
wake_targets(targets).await wake_targets(targets).await
@@ -23,12 +27,14 @@ pub async fn wake_from_query(input: impl Into<String>) -> Result<WakeResult> {
/// Build broadcast wake targets for every broadcast-capable interface. /// Build broadcast wake targets for every broadcast-capable interface.
/// ///
/// This is used by explicit manual wake mode when only a MAC address is supplied. /// This is used by explicit manual wake mode when only a MAC address is supplied.
#[instrument(skip_all)]
pub async fn broadcast_wake_targets(mac: MacAddr) -> Result<Vec<WakeTarget>> { pub async fn broadcast_wake_targets(mac: MacAddr) -> Result<Vec<WakeTarget>> {
let interfaces = get_interface_summaries().await?; let interfaces = get_interface_summaries().await?;
broadcast_wake_targets_from_interfaces(&interfaces, mac) broadcast_wake_targets_from_interfaces(&interfaces, mac)
} }
/// Wake a device explicitly by MAC, optionally targeting a specific IP/broadcast. /// Wake a device explicitly by MAC, optionally targeting a specific IP/broadcast.
#[instrument(skip_all, fields(has_ip = ip.is_some()))]
pub async fn wake_explicit(mac: MacAddr, ip: Option<IpAddr>) -> Result<WakeResult> { pub async fn wake_explicit(mac: MacAddr, ip: Option<IpAddr>) -> Result<WakeResult> {
let targets = match ip { let targets = match ip {
Some(ip) => explicit_wake_targets_for_ip(mac, ip), Some(ip) => explicit_wake_targets_for_ip(mac, ip),
@@ -41,9 +47,10 @@ pub async fn wake_explicit(mac: MacAddr, ip: Option<IpAddr>) -> Result<WakeResul
/// ///
/// The current resolution strategy fans out one wake target per resolved device IP, /// The current resolution strategy fans out one wake target per resolved device IP,
/// using the first known MAC address for that device. /// using the first known MAC address for that device.
#[instrument(skip_all)]
pub async fn resolve_wake_targets(input: impl Into<String>) -> Result<Vec<WakeTarget>> { pub async fn resolve_wake_targets(input: impl Into<String>) -> Result<Vec<WakeTarget>> {
let devices = resolve_devices(input).await?; let devices = resolve_devices(input).await?;
Ok(devices let targets: Vec<WakeTarget> = devices
.into_iter() .into_iter()
.flat_map(|device| { .flat_map(|device| {
let mac = device.macs.first().copied(); let mac = device.macs.first().copied();
@@ -52,7 +59,9 @@ pub async fn resolve_wake_targets(input: impl Into<String>) -> Result<Vec<WakeTa
.into_iter() .into_iter()
.map(move |ip| WakeTarget { ip: Some(ip), mac }) .map(move |ip| WakeTarget { ip: Some(ip), mac })
}) })
.collect()) .collect();
debug!(targets = targets.len(), "resolved wake targets");
Ok(targets)
} }
fn explicit_wake_targets_for_ip(mac: MacAddr, ip: IpAddr) -> Vec<WakeTarget> { fn explicit_wake_targets_for_ip(mac: MacAddr, ip: IpAddr) -> Vec<WakeTarget> {
@@ -80,6 +89,7 @@ fn broadcast_wake_targets_from_interfaces(
anyhow::bail!("no broadcast-capable interfaces found"); anyhow::bail!("no broadcast-capable interfaces found");
} }
debug!(targets = targets.len(), interfaces = interfaces.len(), "built broadcast wake targets");
Ok(targets) Ok(targets)
} }
@@ -122,7 +132,10 @@ mod tests {
let err = broadcast_wake_targets_from_interfaces(&interfaces, mac) let err = broadcast_wake_targets_from_interfaces(&interfaces, mac)
.expect_err("should error without broadcast-capable interfaces"); .expect_err("should error without broadcast-capable interfaces");
assert!(err.to_string().contains("no broadcast-capable interfaces found")); assert!(
err.to_string()
.contains("no broadcast-capable interfaces found")
);
} }
#[test] #[test]
+3 -1
View File
@@ -72,7 +72,9 @@ async fn broadcast_wake_targets_real_router_resolve_from_interfaces() -> anyhow:
"all broadcast targets should preserve the requested MAC" "all broadcast targets should preserve the requested MAC"
); );
assert!( assert!(
targets.iter().all(|target| matches!(target.ip, Some(IpAddr::V4(_)))), targets
.iter()
.all(|target| matches!(target.ip, Some(IpAddr::V4(_)))),
"broadcast targets should be IPv4 broadcast destinations" "broadcast targets should be IPv4 broadcast destinations"
); );