From 3005a07e28b0e7ba882e98aeb9417226d1508983 Mon Sep 17 00:00:00 2001 From: lucalise Date: Fri, 2 Jan 2026 18:15:03 -0800 Subject: [PATCH] feat(homelab): add dhcp lease parsing --- nix/homelab/Cargo.lock | 172 +++++++++++++++++++++++++++++++ nix/homelab/Cargo.toml | 2 + nix/homelab/src/commands.rs | 2 + nix/homelab/src/dns.rs | 20 ++++ nix/homelab/src/error.rs | 15 +++ nix/homelab/src/lease_parser.rs | 146 ++++++++++++++++++++++++++ nix/homelab/src/main.rs | 29 +++++- nix/homelab/src/transport.rs | 9 ++ nix/homelab/src/transport/ssh.rs | 46 +++++++++ 9 files changed, 438 insertions(+), 3 deletions(-) create mode 100644 nix/homelab/src/dns.rs create mode 100644 nix/homelab/src/error.rs create mode 100644 nix/homelab/src/lease_parser.rs create mode 100644 nix/homelab/src/transport.rs create mode 100644 nix/homelab/src/transport/ssh.rs diff --git a/nix/homelab/Cargo.lock b/nix/homelab/Cargo.lock index d0d81df..0fbada3 100644 --- a/nix/homelab/Cargo.lock +++ b/nix/homelab/Cargo.lock @@ -58,6 +58,28 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "cc" +version = "1.2.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "clap" version = "4.5.53" @@ -110,6 +132,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "find-msvc-tools" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" + [[package]] name = "hashbrown" version = "0.16.1" @@ -128,7 +156,9 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "nom", "serde", + "ssh2", "thiserror", "toml", ] @@ -149,12 +179,109 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "libssh2-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "proc-macro2" version = "1.0.103" @@ -173,6 +300,21 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.228" @@ -212,6 +354,30 @@ dependencies = [ "serde_core", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "ssh2" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f84d13b3b8a0d4e91a2629911e951db1bb8671512f5c09d7d4ba34500ba68c8" +dependencies = [ + "bitflags", + "libc", + "libssh2-sys", + "parking_lot", +] + [[package]] name = "strsim" version = "0.11.1" @@ -300,6 +466,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/nix/homelab/Cargo.toml b/nix/homelab/Cargo.toml index 610fb64..2482187 100644 --- a/nix/homelab/Cargo.toml +++ b/nix/homelab/Cargo.toml @@ -6,6 +6,8 @@ edition = "2024" [dependencies] anyhow = "1.0.100" clap = { version = "4.5.53", features = ["derive"] } +nom = "8.0.0" serde = { version = "1.0.228", features = ["serde_derive"] } +ssh2 = "0.9.5" thiserror = "2.0.17" toml = "0.9.10" diff --git a/nix/homelab/src/commands.rs b/nix/homelab/src/commands.rs index 01e8d2d..a6bf764 100644 --- a/nix/homelab/src/commands.rs +++ b/nix/homelab/src/commands.rs @@ -6,4 +6,6 @@ use clap::Subcommand; pub enum Commands { /// generate gateway api routes GenerateRoutes, + /// sync dns records with pi-hole + SyncDNS, } diff --git a/nix/homelab/src/dns.rs b/nix/homelab/src/dns.rs new file mode 100644 index 0000000..f3c9884 --- /dev/null +++ b/nix/homelab/src/dns.rs @@ -0,0 +1,20 @@ +use crate::{ + error::Result, + lease_parser::{Lease, parse_leases}, + transport::Transport, +}; + +pub struct Router { + transport: T, +} + +impl Router { + pub fn new(transport: T) -> Self { + Self { transport } + } + + pub fn dhcp_leases(&self, resource: &str) -> Result> { + let raw = self.transport.fetch(resource)?; + parse_leases(&raw).map_err(|e| crate::error::Error::Parse(e.to_string())) + } +} diff --git a/nix/homelab/src/error.rs b/nix/homelab/src/error.rs new file mode 100644 index 0000000..7b1050b --- /dev/null +++ b/nix/homelab/src/error.rs @@ -0,0 +1,15 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("SSH error: {0}")] + SSH(#[from] ssh2::Error), + #[error("parse error: {0}")] + Parse(String), + #[error("IO error: {0}")] + IO(#[from] std::io::Error), + #[error("command return non 0 exit code: {0}")] + ExitCode(i32), +} + +pub type Result = std::result::Result; diff --git a/nix/homelab/src/lease_parser.rs b/nix/homelab/src/lease_parser.rs new file mode 100644 index 0000000..a9c60ad --- /dev/null +++ b/nix/homelab/src/lease_parser.rs @@ -0,0 +1,146 @@ +use std::net::IpAddr; + +use nom::{ + IResult, Parser, + branch::alt, + bytes::complete::{tag, take_till, take_until, take_while1}, + character::complete::{char, digit1, multispace1, space0, space1}, + combinator::{map, map_res, value}, + multi::many0, + sequence::{delimited, preceded, terminated}, +}; + +#[derive(Debug, Clone, PartialEq)] +pub struct Lease { + pub ip: IpAddr, + pub binding_state: BindingState, + pub client_hostname: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq)] +pub enum BindingState { + Active, + Free, + Abandoned, + Backup, +} + +#[allow(dead_code)] +#[derive(Debug, Clone)] +enum LeaseField { + BindingState(BindingState), + ClientHostname(String), + Other, +} + +fn ws(input: &str) -> IResult<&str, ()> { + value( + (), + many0(alt(( + value((), multispace1), + value((), (tag("#"), take_until("\n"), tag("\n"))), + ))), + ) + .parse(input) +} + +fn parse_ip(input: &str) -> IResult<&str, IpAddr> { + map_res( + take_while1(|c: char| c.is_ascii_digit() || c == '.'), + str::parse, + ) + .parse(input) +} + +fn parse_field(input: &str) -> IResult<&str, LeaseField> { + alt(( + map( + preceded( + (tag("client-hostname"), space1), + terminated( + delimited(char('"'), take_until("\""), char('"')), + (space0, char(';')), + ), + ), + |h: &str| LeaseField::ClientHostname(h.to_string()), + ), + map( + preceded( + (tag("binding"), space1, tag("state"), space1), + terminated( + alt(( + value(BindingState::Active, tag("active")), + value(BindingState::Free, tag("free")), + value(BindingState::Abandoned, tag("abandoned")), + value(BindingState::Backup, tag("backup")), + )), + (space0, char(';')), + ), + ), + LeaseField::BindingState, + ), + value( + LeaseField::Other, + preceded( + (tag("uid"), space1), + terminated( + delimited(char('"'), take_until("\""), char('"')), + (space0, char(';')), + ), + ), + ), + value( + LeaseField::Other, + (take_till(|c| c == ';' || c == '}'), char(';')), + ), + )) + .parse(input) +} + +pub fn parse_lease(input: &str) -> IResult<&str, Lease> { + let (input, _) = ws(input)?; + let (input, _) = tag("lease").parse(input)?; + let (input, _) = space1(input)?; + let (input, ip) = parse_ip(input)?; + let (input, _) = space0(input)?; + let (input, _) = char('{').parse(input)?; + let (input, _) = ws(input)?; + + let (input, fields) = many0(terminated(parse_field, ws)).parse(input)?; + + let (input, _) = char('}').parse(input)?; + let (input, _) = ws(input)?; + + let mut lease = Lease { + ip, + binding_state: BindingState::Free, + client_hostname: None, + }; + + for field in fields { + match field { + LeaseField::BindingState(s) => lease.binding_state = s, + LeaseField::ClientHostname(h) => lease.client_hostname = Some(h), + LeaseField::Other => {} + } + } + + Ok((input, lease)) +} + +pub fn parse_leases(input: &str) -> Result, nom::Err>> { + let mut leases = Vec::new(); + let Some(start) = input.find("\nlease ") else { + return Ok(leases); + }; + + let mut remaining = &input[start..]; + + while !remaining.is_empty() { + let (rest, lease) = parse_lease(remaining)?; + leases.push(lease); + remaining = rest; + } + Ok(leases) +} diff --git a/nix/homelab/src/main.rs b/nix/homelab/src/main.rs index 20d29c1..354bf1d 100644 --- a/nix/homelab/src/main.rs +++ b/nix/homelab/src/main.rs @@ -1,4 +1,8 @@ mod commands; +mod dns; +mod error; +mod lease_parser; +mod transport; use std::path::Path; @@ -7,9 +11,14 @@ use clap::{CommandFactory, Parser}; use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::commands::{ - Commands, - generate_routes::{Route, generate_routes}, +use crate::{ + commands::{ + Commands, + generate_routes::{Route, generate_routes}, + }, + dns::Router, + lease_parser::{BindingState, Lease}, + transport::SSHTransport, }; #[derive(Parser, Debug)] @@ -50,6 +59,20 @@ fn main() -> anyhow::Result<()> { let config = parse_config("./config.toml")?; generate_routes(&config)?; } + Some(Commands::SyncDNS {}) => { + let r = Router::new(SSHTransport::new("192.168.15.1:22")?); + let leases = r + .dhcp_leases("/var/dhcpd/var/db/dhcpd.leases")? + .into_iter() + .filter(|l| { + if !(l.binding_state == BindingState::Active && l.client_hostname.is_some()) { + return false; + } + true + }) + .collect::>(); + println!("{:#?}", leases); + } None => Cli::command().print_long_help()?, } diff --git a/nix/homelab/src/transport.rs b/nix/homelab/src/transport.rs new file mode 100644 index 0000000..4570c51 --- /dev/null +++ b/nix/homelab/src/transport.rs @@ -0,0 +1,9 @@ +mod ssh; + +pub use ssh::SSHTransport; + +use crate::error::Result; + +pub trait Transport { + fn fetch(&self, resource: &str) -> Result; +} diff --git a/nix/homelab/src/transport/ssh.rs b/nix/homelab/src/transport/ssh.rs new file mode 100644 index 0000000..5a6f9e4 --- /dev/null +++ b/nix/homelab/src/transport/ssh.rs @@ -0,0 +1,46 @@ +use std::{io::Read, net::TcpStream, path::Path}; + +use ssh2::Session; + +use crate::{error::Result, transport::Transport}; + +pub struct SSHTransport { + session: Session, +} + +impl SSHTransport { + pub fn new(host: &str) -> Result { + let stream = TcpStream::connect(host)?; + + let mut s = Self { + session: Session::new()?, + }; + s.session.set_tcp_stream(stream); + s.session.handshake()?; + s.session.userauth_pubkey_file( + "luca", + None, + Path::new("/home/luca/.ssh/id_ed25519"), + None, + )?; + Ok(s) + } +} + +impl Transport for SSHTransport { + fn fetch(&self, resource: &str) -> Result { + let mut channel = self.session.channel_session()?; + channel.exec(&format!("cat {}", resource))?; + + let mut output = String::new(); + channel.read_to_string(&mut output)?; + channel.wait_close()?; + + let c = channel.exit_status()?; + if c != 0 { + return Err(crate::error::Error::ExitCode(c)); + } + + Ok(output) + } +}