use std::net::IpAddr;
use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
use crate::org::OrgId;
pub const DEFAULT_ROOT: &str = "/var/lib/isb/dns";
pub const DNSMASQ_GROUP: &str = "incus";
pub fn root() -> PathBuf {
std::env::var_os("ISB_DNS_DIR")
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(DEFAULT_ROOT))
}
pub fn org_dir(org: &OrgId) -> PathBuf {
root().join(org.as_str())
}
pub fn raw_dnsmasq(dir: &Path) -> String {
format!("hostsdir={}", dir.display())
}
pub fn prepare_org(org: &OrgId) -> Result<Option<PathBuf>> {
let dir = org_dir(org);
let root = root();
if !root.is_dir() || rustix::fs::access(&root, rustix::fs::Access::WRITE_OK).is_err() {
return Ok(None);
}
use std::os::unix::fs::{DirBuilderExt, MetadataExt};
let old = rustix::process::umask(rustix::fs::Mode::from_raw_mode(0o022));
let made = std::fs::DirBuilder::new()
.mode(dir_mode(&root))
.create(&dir);
rustix::process::umask(old);
match made {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(Error::Invalid(format!("create {}: {e}", dir.display()))),
}
let (rm, dm) = (std::fs::metadata(&root)?, std::fs::metadata(&dir)?);
if rm.mode() & 0o2000 != 0 && (dm.gid() != rm.gid() || dm.mode() & 0o2050 != 0o2050) {
return Err(Error::Invalid(format!(
"{} is not readable by dnsmasq (want group {DNSMASQ_GROUP}, setgid, g+rx); delete it and run this again",
dir.display()
)));
}
Ok(Some(dir))
}
pub fn remove_org(org: &OrgId) {
let _ = std::fs::remove_dir_all(org_dir(org));
}
fn dir_mode(root: &Path) -> u32 {
use std::os::unix::fs::PermissionsExt;
let m = std::fs::metadata(root)
.map(|m| m.permissions().mode())
.unwrap_or(0o2750);
0o2750 | (m & 0o005)
}
fn file_mode(dir: &Path) -> u32 {
use std::os::unix::fs::PermissionsExt;
let m = std::fs::metadata(dir)
.map(|m| m.permissions().mode())
.unwrap_or(0o750);
0o640 | (m & 0o004)
}
fn set_mode(p: &Path, mode: u32) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode))
.map_err(|e| Error::Invalid(format!("chmod {}: {e}", p.display())))
}
fn label(service: &str) -> String {
crate::compose::sanitize_name(service)
}
pub fn file_name(stack: &str, service: &str) -> String {
format!("{stack}.{}", label(service))
}
pub fn fqdn(org: &OrgId, stack: &str, service: &str) -> String {
format!("{}.{stack}.{org}.isb", label(service))
}
pub fn render(org: &OrgId, stack: &str, service: &str, ips: &[IpAddr]) -> String {
let mut ips = ips.to_vec();
ips.sort();
ips.dedup();
let full = fqdn(org, stack, service);
let short = format!("{}.{stack}", label(service));
let mut out = String::new();
for ip in ips {
out.push_str(&format!("{ip} {full} {short}\n"));
}
out
}
pub fn publish(dir: &Path, org: &OrgId, stack: &str, service: &str, ips: &[IpAddr]) -> Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let name = file_name(stack, service);
let path = dir.join(&name);
if ips.is_empty() {
return match std::fs::remove_file(&path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
Err(Error::Invalid(format!("remove {}: {e}", path.display())))
}
_ => Ok(()),
};
}
let tmp = dir.join(format!(".{name}.tmp"));
let step = |e: std::io::Error| Error::Invalid(format!("write {}: {e}", tmp.display()));
let mode = file_mode(dir);
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(mode)
.open(&tmp)
.map_err(step)?;
f.write_all(render(org, stack, service, ips).as_bytes())
.map_err(step)?;
drop(f);
set_mode(&tmp, mode)?;
std::fs::rename(&tmp, &path)
.map_err(|e| Error::Invalid(format!("rename to {}: {e}", path.display())))
}
pub fn prune(dir: &Path, keep: &[(String, String)]) {
let keep: std::collections::BTreeSet<String> =
keep.iter().map(|(s, v)| file_name(s, v)).collect();
let Ok(rd) = std::fs::read_dir(dir) else {
return;
};
for e in rd.flatten() {
let n = e.file_name().to_string_lossy().into_owned();
if !keep.contains(&n) {
let _ = std::fs::remove_file(e.path());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
#[test]
fn renders_round_robin_records() {
let o = OrgId::new("acme").unwrap();
let t = render(
&o,
"shop",
"web",
&[ip("10.64.3.18"), ip("10.64.3.17"), ip("10.64.3.18")],
);
assert_eq!(
t,
"10.64.3.17 web.shop.acme.isb web.shop\n10.64.3.18 web.shop.acme.isb web.shop\n"
);
assert_eq!(render(&o, "shop", "web", &[]), "");
assert_eq!(fqdn(&o, "shop", "my_db"), "my-db.shop.acme.isb");
assert_eq!(file_name("shop", "my_db"), "shop.my-db");
}
#[test]
fn publish_renames_and_removes() {
let d = tempfile::tempdir().unwrap();
let o = OrgId::new("acme").unwrap();
publish(d.path(), &o, "shop", "web", &[ip("10.0.0.2")]).unwrap();
let p = d.path().join("shop.web");
assert_eq!(
std::fs::read_to_string(&p).unwrap(),
"10.0.0.2 web.shop.acme.isb web.shop\n"
);
use std::os::unix::fs::PermissionsExt;
let m = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777;
assert_eq!(m & 0o640, 0o640, "{m:o}");
assert_eq!(std::fs::read_dir(d.path()).unwrap().count(), 1);
publish(d.path(), &o, "shop", "web", &[]).unwrap();
assert!(!p.exists());
publish(d.path(), &o, "shop", "web", &[]).unwrap();
publish(d.path(), &o, "shop", "web", &[ip("10.0.0.2")]).unwrap();
publish(d.path(), &o, "old", "db", &[ip("10.0.0.3")]).unwrap();
prune(d.path(), &[("shop".into(), "web".into())]);
assert!(p.exists());
assert!(!d.path().join("old.db").exists());
}
}