cli refactor and more tracing
This commit is contained in:
+365
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
+3
-372
@@ -1,198 +1,6 @@
|
||||
mod cli_table;
|
||||
mod cli;
|
||||
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
use clap::{ArgAction, Args, Parser, Subcommand};
|
||||
use tracing::{debug, info};
|
||||
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 {
|
||||
/// Increase log verbosity. Use `-v` for debug and `-vv` for trace.
|
||||
#[arg(short = 'v', long = "verbose", action = ArgAction::Count, global = true)]
|
||||
verbose: u8,
|
||||
|
||||
#[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
|
||||
}
|
||||
use clap::Parser;
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn main() -> anyhow::Result<()> {
|
||||
@@ -204,182 +12,5 @@ fn main() -> anyhow::Result<()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
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!("{}", cli_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!("{}", cli_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!("{}", cli_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!("{}", cli_table::render_devs_table(&devs));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn init_tracing(verbose: u8) {
|
||||
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
cli::run(cli::Cli::parse()).await
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use tracing::{debug, instrument};
|
||||
use wakey_core::InterfaceSummary;
|
||||
|
||||
/// 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.
|
||||
#[instrument(skip_all)]
|
||||
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.
|
||||
#[instrument(skip_all, fields(ifname = name))]
|
||||
pub async fn get_interface_summary(name: &str) -> Result<Option<InterfaceSummary>> {
|
||||
Ok(get_interface_summaries()
|
||||
let summary = get_interface_summaries()
|
||||
.await?
|
||||
.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.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use tracing::{debug, instrument};
|
||||
use wakey_core::{Device, DeviceQuery, NeighborEntry, Presence, Status};
|
||||
|
||||
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
|
||||
/// increasingly device-centered.
|
||||
#[instrument(skip_all, fields(name = ?query.name))]
|
||||
pub async fn get_status(query: DeviceQuery) -> Result<StatusResponse> {
|
||||
let inventory = inventory(query.clone()).await?;
|
||||
let table = inventory
|
||||
let table: Vec<NeighborEntry> = inventory
|
||||
.devices
|
||||
.iter()
|
||||
.flat_map(device_to_status_rows)
|
||||
.collect();
|
||||
debug!(rows = table.len(), devices = inventory.devices.len(), "built status response");
|
||||
Ok(Status {
|
||||
name: query.name,
|
||||
table,
|
||||
@@ -26,6 +29,7 @@ pub async fn get_status(query: DeviceQuery) -> Result<StatusResponse> {
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let query = resolve_query(input).await?;
|
||||
get_status(query).await
|
||||
|
||||
+12
-2
@@ -1,20 +1,24 @@
|
||||
use anyhow::{Context, Result};
|
||||
use macaddr::MacAddr;
|
||||
use std::net::IpAddr;
|
||||
use tracing::{debug, instrument};
|
||||
use wakey_core::{InterfaceSummary, WakeResult, WakeTarget};
|
||||
|
||||
use crate::service::interfaces::get_interface_summaries;
|
||||
use crate::service::inventory::resolve_devices;
|
||||
|
||||
/// 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> {
|
||||
let result = wakey_linux::wake::wake_many(targets)
|
||||
.await
|
||||
.context("failed to send wake packets")?;
|
||||
debug!(results = result.len(), "wake packets sent");
|
||||
Ok(WakeResult { result })
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let targets = resolve_wake_targets(input).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.
|
||||
///
|
||||
/// 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>> {
|
||||
let interfaces = get_interface_summaries().await?;
|
||||
broadcast_wake_targets_from_interfaces(&interfaces, mac)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let targets = match 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,
|
||||
/// 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>> {
|
||||
let devices = resolve_devices(input).await?;
|
||||
Ok(devices
|
||||
let targets: Vec<WakeTarget> = devices
|
||||
.into_iter()
|
||||
.flat_map(|device| {
|
||||
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()
|
||||
.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> {
|
||||
@@ -80,6 +89,7 @@ fn broadcast_wake_targets_from_interfaces(
|
||||
anyhow::bail!("no broadcast-capable interfaces found");
|
||||
}
|
||||
|
||||
debug!(targets = targets.len(), interfaces = interfaces.len(), "built broadcast wake targets");
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user