feat(homelab): add dhcp lease parsing

This commit is contained in:
2026-01-02 18:15:03 -08:00
parent 8fa31858b4
commit 3005a07e28
9 changed files with 438 additions and 3 deletions

172
nix/homelab/Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -6,4 +6,6 @@ use clap::Subcommand;
pub enum Commands {
/// generate gateway api routes
GenerateRoutes,
/// sync dns records with pi-hole
SyncDNS,
}

20
nix/homelab/src/dns.rs Normal file
View File

@@ -0,0 +1,20 @@
use crate::{
error::Result,
lease_parser::{Lease, parse_leases},
transport::Transport,
};
pub struct Router<T: Transport> {
transport: T,
}
impl<T: Transport> Router<T> {
pub fn new(transport: T) -> Self {
Self { transport }
}
pub fn dhcp_leases(&self, resource: &str) -> Result<Vec<Lease>> {
let raw = self.transport.fetch(resource)?;
parse_leases(&raw).map_err(|e| crate::error::Error::Parse(e.to_string()))
}
}

15
nix/homelab/src/error.rs Normal file
View File

@@ -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<T> = std::result::Result<T, Error>;

View File

@@ -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<String>,
}
#[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<Vec<Lease>, nom::Err<nom::error::Error<&str>>> {
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)
}

View File

@@ -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::{
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::<Vec<Lease>>();
println!("{:#?}", leases);
}
None => Cli::command().print_long_help()?,
}

View File

@@ -0,0 +1,9 @@
mod ssh;
pub use ssh::SSHTransport;
use crate::error::Result;
pub trait Transport {
fn fetch(&self, resource: &str) -> Result<String>;
}

View File

@@ -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<Self> {
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<String> {
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)
}
}