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));
}
pub const STALE_ORG_DIR: std::time::Duration = std::time::Duration::from_secs(600);
pub fn prune_orgs(orgs: &[OrgId], older_than: std::time::Duration) -> Vec<OrgId> {
prune_orgs_in(&root(), orgs, older_than)
}
fn prune_orgs_in(root: &Path, orgs: &[OrgId], older_than: std::time::Duration) -> Vec<OrgId> {
let Ok(rd) = std::fs::read_dir(root) else {
return Vec::new();
};
let mut gone = Vec::new();
for e in rd.flatten() {
let Ok(org) = OrgId::new(e.file_name().to_string_lossy().to_string()) else {
continue;
};
let Ok(md) = e.metadata() else { continue };
let old = md
.modified()
.ok()
.and_then(|m| m.elapsed().ok())
.is_some_and(|age| age >= older_than);
if md.is_dir() && old && !orgs.contains(&org) && std::fs::remove_dir_all(e.path()).is_ok() {
gone.push(org);
}
}
gone.sort();
gone
}
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],
extra: Option<&str>,
) -> String {
let mut ips = ips.to_vec();
ips.sort();
ips.dedup();
let mut names = format!("{} {}.{stack}", fqdn(org, stack, service), label(service));
if let Some(x) = extra {
names.push_str(&format!(
" {} {}.{x}",
fqdn(org, x, service),
label(service)
));
}
let mut out = String::new();
for ip in ips {
out.push_str(&format!("{ip} {names}\n"));
}
out
}
pub fn publish(
dir: &Path,
org: &OrgId,
stack: &str,
service: &str,
ips: &[IpAddr],
extra: Option<&str>,
) -> 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, extra).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")],
None,
);
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", &[], None), "");
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")], None).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", &[], None).unwrap();
assert!(!p.exists());
publish(d.path(), &o, "shop", "web", &[], None).unwrap();
publish(d.path(), &o, "shop", "web", &[ip("10.0.0.2")], None).unwrap();
publish(d.path(), &o, "old", "db", &[ip("10.0.0.3")], None).unwrap();
prune(d.path(), &[("shop".into(), "web".into())]);
assert!(p.exists());
assert!(!d.path().join("old.db").exists());
}
#[test]
fn an_environment_adds_names_and_leaves_the_rest_as_it_was() {
let o = OrgId::new("fiftytwolabs").unwrap();
let ips = [ip("10.64.3.18"), ip("10.64.3.17")];
assert_eq!(
render(&o, "chat-production", "chat-postgres", &ips, None),
concat!(
"10.64.3.17 chat-postgres.chat-production.fiftytwolabs.isb chat-postgres.chat-production\n",
"10.64.3.18 chat-postgres.chat-production.fiftytwolabs.isb chat-postgres.chat-production\n",
)
);
assert_eq!(
render(&o, "wiki", "redis", &ips[..1], None),
"10.64.3.18 redis.wiki.fiftytwolabs.isb redis.wiki\n"
);
assert_eq!(
render(&o, "wiki", "my_redis", &ips[..1], Some("wiki-production")),
"10.64.3.18 my-redis.wiki.fiftytwolabs.isb my-redis.wiki my-redis.wiki-production.fiftytwolabs.isb my-redis.wiki-production\n"
);
let d = tempfile::tempdir().unwrap();
publish(d.path(), &o, "wiki", "redis", &ips, Some("wiki-production")).unwrap();
let t = std::fs::read_to_string(d.path().join("wiki.redis")).unwrap();
assert!(t.ends_with("redis.wiki-production\n"), "{t}");
}
#[test]
fn hosts_directories_of_gone_orgs_are_pruned_once_stale() {
let root = tempfile::tempdir().unwrap();
for d in ["acme", "gone", "fresh"] {
std::fs::create_dir(root.path().join(d)).unwrap();
std::fs::write(root.path().join(d).join("web.shop"), "10.0.0.1 web\n").unwrap();
}
std::fs::write(root.path().join("README"), "x").unwrap();
let acme = [OrgId::new("acme").unwrap()];
let hour = std::time::Duration::from_secs(3600);
assert!(prune_orgs_in(root.path(), &acme, hour).is_empty());
let gone = prune_orgs_in(root.path(), &acme, std::time::Duration::ZERO);
let names: Vec<&str> = gone.iter().map(OrgId::as_str).collect();
assert_eq!(names, ["fresh", "gone"]);
assert!(root.path().join("acme/web.shop").is_file());
assert!(root.path().join("README").is_file());
assert!(!root.path().join("gone").exists());
}
}