feat(homelab)!: setup pihole entry generation, add treeminer to minecraft-main
This commit is contained in:
@@ -10,6 +10,10 @@ pub enum Error {
|
||||
IO(#[from] std::io::Error),
|
||||
#[error("command return non 0 exit code: {0}")]
|
||||
ExitCode(i32),
|
||||
#[error("HTTP error: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
#[error("Pi-hole API error: {0}")]
|
||||
PiHole(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
@@ -2,8 +2,10 @@ mod commands;
|
||||
mod dns;
|
||||
mod error;
|
||||
mod lease_parser;
|
||||
mod pihole;
|
||||
mod transport;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Context;
|
||||
@@ -18,6 +20,7 @@ use crate::{
|
||||
},
|
||||
dns::Router,
|
||||
lease_parser::{BindingState, Lease},
|
||||
pihole::PiHoleClient,
|
||||
transport::SSHTransport,
|
||||
};
|
||||
|
||||
@@ -41,6 +44,21 @@ pub enum HelperError {
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
routes: Vec<Route>,
|
||||
pihole: Option<PiHoleConfig>,
|
||||
router: Option<RouterConfig>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct PiHoleConfig {
|
||||
url: String,
|
||||
password_file: String,
|
||||
extra_hosts: Option<HashSet<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct RouterConfig {
|
||||
host: String,
|
||||
lease_file: String,
|
||||
}
|
||||
|
||||
pub fn parse_config<T: AsRef<Path>>(path: T) -> anyhow::Result<Config> {
|
||||
@@ -51,7 +69,8 @@ pub fn parse_config<T: AsRef<Path>>(path: T) -> anyhow::Result<Config> {
|
||||
Ok(toml::from_slice::<Config>(&bytes)?)
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match &cli.command {
|
||||
@@ -60,18 +79,58 @@ fn main() -> anyhow::Result<()> {
|
||||
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")?
|
||||
let config = parse_config("./config.toml")?;
|
||||
let pihole_config = config
|
||||
.pihole
|
||||
.context("pihole configuration is necessary for syncing dns")?;
|
||||
let router_config = config
|
||||
.router
|
||||
.context("router configuration is necessary for syncing dns")?;
|
||||
|
||||
let password = std::fs::read_to_string(&pihole_config.password_file)
|
||||
.context(format!(
|
||||
"failed to read pihole password from {}",
|
||||
pihole_config.password_file
|
||||
))?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let leases = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<Lease>> {
|
||||
let r = Router::new(SSHTransport::new(&router_config.host)?);
|
||||
let leases = r
|
||||
.dhcp_leases(&router_config.lease_file)?
|
||||
.into_iter()
|
||||
.filter(|l| {
|
||||
l.binding_state == BindingState::Active && l.client_hostname.is_some()
|
||||
})
|
||||
.collect();
|
||||
Ok(leases)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let mut desired = leases
|
||||
.into_iter()
|
||||
.filter(|l| {
|
||||
if !(l.binding_state == BindingState::Active && l.client_hostname.is_some()) {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
.map(|l| {
|
||||
format!(
|
||||
"{} {}",
|
||||
l.ip,
|
||||
l.client_hostname.expect("filtered for Some above")
|
||||
)
|
||||
})
|
||||
.collect::<Vec<Lease>>();
|
||||
println!("{:#?}", leases);
|
||||
.collect::<HashSet<String>>();
|
||||
if let Some(extra_hosts) = pihole_config.extra_hosts {
|
||||
desired.extend(extra_hosts);
|
||||
}
|
||||
|
||||
println!("Found {} active leases with hostnames", desired.len());
|
||||
|
||||
let client = PiHoleClient::new(&pihole_config.url, &password).await?;
|
||||
|
||||
let stats = client.sync_hosts(desired).await?;
|
||||
println!(
|
||||
"Sync complete: added {}, removed {}",
|
||||
stats.added, stats.removed
|
||||
);
|
||||
}
|
||||
None => Cli::command().print_long_help()?,
|
||||
}
|
||||
|
||||
171
nix/homelab/src/pihole.rs
Normal file
171
nix/homelab/src/pihole.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
pub struct PiHoleClient {
|
||||
client: Client,
|
||||
base_url: String,
|
||||
sid: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SyncStats {
|
||||
pub added: usize,
|
||||
pub removed: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthResponse {
|
||||
session: Session,
|
||||
error: Option<AuthError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Session {
|
||||
valid: bool,
|
||||
sid: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthError {
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AuthRequest {
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ConfigResponse {
|
||||
config: DnsConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DnsConfig {
|
||||
dns: DnsHosts,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DnsHosts {
|
||||
hosts: Vec<String>,
|
||||
}
|
||||
|
||||
impl PiHoleClient {
|
||||
pub async fn new(base_url: &str, password: &str) -> Result<Self> {
|
||||
let client = Client::new();
|
||||
let url = format!("{}/api/auth", base_url.trim_end_matches('/'));
|
||||
|
||||
let response = client
|
||||
.post(&url)
|
||||
.json(&AuthRequest {
|
||||
password: password.to_string(),
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let auth: AuthResponse = response.json().await?;
|
||||
|
||||
if !auth.session.valid {
|
||||
return Err(Error::PiHole(format!(
|
||||
"authentication failed: {}",
|
||||
auth.error.unwrap().message
|
||||
)));
|
||||
}
|
||||
|
||||
let sid = auth.session.sid.ok_or_else(|| {
|
||||
Error::PiHole("authentication succeeded but no session ID returned".to_string())
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
sid,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_hosts(&self) -> Result<HashSet<String>> {
|
||||
let url = format!("{}/api/config/dns/hosts", self.base_url);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.header("X-FTL-SID", &self.sid)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if let Err(e) = response.error_for_status_ref() {
|
||||
return Err(Error::PiHole(format!("failed to get hosts: {}", e)));
|
||||
}
|
||||
|
||||
let config: ConfigResponse = response.json().await?;
|
||||
Ok(config.config.dns.hosts.into_iter().collect())
|
||||
}
|
||||
|
||||
pub async fn add_host(&self, entry: &str) -> Result<()> {
|
||||
let encoded = urlencoding::encode(entry);
|
||||
let url = format!("{}/api/config/dns/hosts/{}", self.base_url, encoded);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.put(&url)
|
||||
.header("X-FTL-SID", &self.sid)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if let Err(e) = response.error_for_status_ref() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(Error::PiHole(format!(
|
||||
"failed to add host '{}': {} - {}",
|
||||
entry, e, body
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_host(&self, entry: &str) -> Result<()> {
|
||||
let encoded = urlencoding::encode(entry);
|
||||
let url = format!("{}/api/config/dns/hosts/{}", self.base_url, encoded);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.delete(&url)
|
||||
.header("X-FTL-SID", &self.sid)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if let Err(e) = response.error_for_status_ref() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(Error::PiHole(format!(
|
||||
"failed to delete host '{}': {} - {}",
|
||||
entry, e, body
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn sync_hosts(&self, desired: HashSet<String>) -> Result<SyncStats> {
|
||||
let current = self.get_hosts().await?;
|
||||
|
||||
let to_delete = current.difference(&desired).cloned().collect::<Vec<_>>();
|
||||
let to_add = desired.difference(¤t).cloned().collect::<Vec<_>>();
|
||||
|
||||
for entry in &to_delete {
|
||||
self.delete_host(entry).await?;
|
||||
}
|
||||
|
||||
for entry in &to_add {
|
||||
self.add_host(entry).await?;
|
||||
}
|
||||
|
||||
Ok(SyncStats {
|
||||
added: to_add.len(),
|
||||
removed: to_delete.len(),
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user