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

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::{
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::<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)
}
}