1use std::net::IpAddr;
29use std::path::{Path, PathBuf};
30
31use crate::error::{Error, Result};
32use crate::org::OrgId;
33
34pub const DEFAULT_ROOT: &str = "/var/lib/isb/dns";
36
37pub const DNSMASQ_GROUP: &str = "incus";
39
40pub fn root() -> PathBuf {
41 std::env::var_os("ISB_DNS_DIR")
42 .filter(|s| !s.is_empty())
43 .map(PathBuf::from)
44 .unwrap_or_else(|| PathBuf::from(DEFAULT_ROOT))
45}
46
47pub fn org_dir(org: &OrgId) -> PathBuf {
49 root().join(org.as_str())
50}
51
52pub fn raw_dnsmasq(dir: &Path) -> String {
54 format!("hostsdir={}", dir.display())
55}
56
57pub fn prepare_org(org: &OrgId) -> Result<Option<PathBuf>> {
61 let dir = org_dir(org);
62 let root = root();
63 if !root.is_dir() || rustix::fs::access(&root, rustix::fs::Access::WRITE_OK).is_err() {
64 return Ok(None);
65 }
66 use std::os::unix::fs::{DirBuilderExt, MetadataExt};
70 let old = rustix::process::umask(rustix::fs::Mode::from_raw_mode(0o022));
71 let made = std::fs::DirBuilder::new()
72 .mode(dir_mode(&root))
73 .create(&dir);
74 rustix::process::umask(old);
75 match made {
76 Ok(()) => {}
77 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
78 Err(e) => return Err(Error::Invalid(format!("create {}: {e}", dir.display()))),
79 }
80 let (rm, dm) = (std::fs::metadata(&root)?, std::fs::metadata(&dir)?);
81 if rm.mode() & 0o2000 != 0 && (dm.gid() != rm.gid() || dm.mode() & 0o2050 != 0o2050) {
82 return Err(Error::Invalid(format!(
83 "{} is not readable by dnsmasq (want group {DNSMASQ_GROUP}, setgid, g+rx); delete it and run this again",
84 dir.display()
85 )));
86 }
87 Ok(Some(dir))
88}
89
90pub fn remove_org(org: &OrgId) {
92 let _ = std::fs::remove_dir_all(org_dir(org));
93}
94
95pub const STALE_ORG_DIR: std::time::Duration = std::time::Duration::from_secs(600);
99
100pub fn prune_orgs(orgs: &[OrgId], older_than: std::time::Duration) -> Vec<OrgId> {
106 prune_orgs_in(&root(), orgs, older_than)
107}
108
109fn prune_orgs_in(root: &Path, orgs: &[OrgId], older_than: std::time::Duration) -> Vec<OrgId> {
110 let Ok(rd) = std::fs::read_dir(root) else {
111 return Vec::new();
112 };
113 let mut gone = Vec::new();
114 for e in rd.flatten() {
115 let Ok(org) = OrgId::new(e.file_name().to_string_lossy().to_string()) else {
116 continue;
117 };
118 let Ok(md) = e.metadata() else { continue };
119 let old = md
120 .modified()
121 .ok()
122 .and_then(|m| m.elapsed().ok())
123 .is_some_and(|age| age >= older_than);
124 if md.is_dir() && old && !orgs.contains(&org) && std::fs::remove_dir_all(e.path()).is_ok() {
125 gone.push(org);
126 }
127 }
128 gone.sort();
129 gone
130}
131
132fn dir_mode(root: &Path) -> u32 {
135 use std::os::unix::fs::PermissionsExt;
136 let m = std::fs::metadata(root)
137 .map(|m| m.permissions().mode())
138 .unwrap_or(0o2750);
139 0o2750 | (m & 0o005)
140}
141
142fn file_mode(dir: &Path) -> u32 {
143 use std::os::unix::fs::PermissionsExt;
144 let m = std::fs::metadata(dir)
145 .map(|m| m.permissions().mode())
146 .unwrap_or(0o750);
147 0o640 | (m & 0o004)
148}
149
150fn set_mode(p: &Path, mode: u32) -> Result<()> {
151 use std::os::unix::fs::PermissionsExt;
152 std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode))
153 .map_err(|e| Error::Invalid(format!("chmod {}: {e}", p.display())))
154}
155
156fn label(service: &str) -> String {
158 crate::compose::sanitize_name(service)
159}
160
161pub fn file_name(stack: &str, service: &str) -> String {
164 format!("{stack}.{}", label(service))
165}
166
167pub fn fqdn(org: &OrgId, stack: &str, service: &str) -> String {
169 format!("{}.{stack}.{org}.isb", label(service))
170}
171
172pub fn render(
176 org: &OrgId,
177 stack: &str,
178 service: &str,
179 ips: &[IpAddr],
180 extra: Option<&str>,
181) -> String {
182 let mut ips = ips.to_vec();
183 ips.sort();
184 ips.dedup();
185 let mut names = format!("{} {}.{stack}", fqdn(org, stack, service), label(service));
186 if let Some(x) = extra {
187 names.push_str(&format!(
188 " {} {}.{x}",
189 fqdn(org, x, service),
190 label(service)
191 ));
192 }
193 let mut out = String::new();
194 for ip in ips {
195 out.push_str(&format!("{ip} {names}\n"));
196 }
197 out
198}
199
200pub fn publish(
204 dir: &Path,
205 org: &OrgId,
206 stack: &str,
207 service: &str,
208 ips: &[IpAddr],
209 extra: Option<&str>,
210) -> Result<()> {
211 use std::io::Write;
212 use std::os::unix::fs::OpenOptionsExt;
213 let name = file_name(stack, service);
214 let path = dir.join(&name);
215 if ips.is_empty() {
216 return match std::fs::remove_file(&path) {
217 Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
218 Err(Error::Invalid(format!("remove {}: {e}", path.display())))
219 }
220 _ => Ok(()),
221 };
222 }
223 let tmp = dir.join(format!(".{name}.tmp"));
224 let step = |e: std::io::Error| Error::Invalid(format!("write {}: {e}", tmp.display()));
225 let mode = file_mode(dir);
226 let mut f = std::fs::OpenOptions::new()
227 .write(true)
228 .create(true)
229 .truncate(true)
230 .mode(mode)
231 .open(&tmp)
232 .map_err(step)?;
233 f.write_all(render(org, stack, service, ips, extra).as_bytes())
234 .map_err(step)?;
235 drop(f);
236 set_mode(&tmp, mode)?;
238 std::fs::rename(&tmp, &path)
239 .map_err(|e| Error::Invalid(format!("rename to {}: {e}", path.display())))
240}
241
242pub fn prune(dir: &Path, keep: &[(String, String)]) {
245 let keep: std::collections::BTreeSet<String> =
246 keep.iter().map(|(s, v)| file_name(s, v)).collect();
247 let Ok(rd) = std::fs::read_dir(dir) else {
248 return;
249 };
250 for e in rd.flatten() {
251 let n = e.file_name().to_string_lossy().into_owned();
252 if !keep.contains(&n) {
253 let _ = std::fs::remove_file(e.path());
254 }
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 fn ip(s: &str) -> IpAddr {
263 s.parse().unwrap()
264 }
265
266 #[test]
267 fn renders_round_robin_records() {
268 let o = OrgId::new("acme").unwrap();
269 let t = render(
270 &o,
271 "shop",
272 "web",
273 &[ip("10.64.3.18"), ip("10.64.3.17"), ip("10.64.3.18")],
274 None,
275 );
276 assert_eq!(
277 t,
278 "10.64.3.17 web.shop.acme.isb web.shop\n10.64.3.18 web.shop.acme.isb web.shop\n"
279 );
280 assert_eq!(render(&o, "shop", "web", &[], None), "");
281 assert_eq!(fqdn(&o, "shop", "my_db"), "my-db.shop.acme.isb");
282 assert_eq!(file_name("shop", "my_db"), "shop.my-db");
283 }
284
285 #[test]
286 fn publish_renames_and_removes() {
287 let d = tempfile::tempdir().unwrap();
288 let o = OrgId::new("acme").unwrap();
289 publish(d.path(), &o, "shop", "web", &[ip("10.0.0.2")], None).unwrap();
290 let p = d.path().join("shop.web");
291 assert_eq!(
292 std::fs::read_to_string(&p).unwrap(),
293 "10.0.0.2 web.shop.acme.isb web.shop\n"
294 );
295 use std::os::unix::fs::PermissionsExt;
296 let m = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777;
297 assert_eq!(m & 0o640, 0o640, "{m:o}");
298 assert_eq!(std::fs::read_dir(d.path()).unwrap().count(), 1);
300 publish(d.path(), &o, "shop", "web", &[], None).unwrap();
301 assert!(!p.exists());
302 publish(d.path(), &o, "shop", "web", &[], None).unwrap();
303
304 publish(d.path(), &o, "shop", "web", &[ip("10.0.0.2")], None).unwrap();
305 publish(d.path(), &o, "old", "db", &[ip("10.0.0.3")], None).unwrap();
306 prune(d.path(), &[("shop".into(), "web".into())]);
307 assert!(p.exists());
308 assert!(!d.path().join("old.db").exists());
309 }
310
311 #[test]
312 fn an_environment_adds_names_and_leaves_the_rest_as_it_was() {
313 let o = OrgId::new("fiftytwolabs").unwrap();
314 let ips = [ip("10.64.3.18"), ip("10.64.3.17")];
315 assert_eq!(
317 render(&o, "chat-production", "chat-postgres", &ips, None),
318 concat!(
319 "10.64.3.17 chat-postgres.chat-production.fiftytwolabs.isb chat-postgres.chat-production\n",
320 "10.64.3.18 chat-postgres.chat-production.fiftytwolabs.isb chat-postgres.chat-production\n",
321 )
322 );
323 assert_eq!(
324 render(&o, "wiki", "redis", &ips[..1], None),
325 "10.64.3.18 redis.wiki.fiftytwolabs.isb redis.wiki\n"
326 );
327 assert_eq!(
329 render(&o, "wiki", "my_redis", &ips[..1], Some("wiki-production")),
330 "10.64.3.18 my-redis.wiki.fiftytwolabs.isb my-redis.wiki my-redis.wiki-production.fiftytwolabs.isb my-redis.wiki-production\n"
331 );
332 let d = tempfile::tempdir().unwrap();
334 publish(d.path(), &o, "wiki", "redis", &ips, Some("wiki-production")).unwrap();
335 let t = std::fs::read_to_string(d.path().join("wiki.redis")).unwrap();
336 assert!(t.ends_with("redis.wiki-production\n"), "{t}");
337 }
338
339 #[test]
340 fn hosts_directories_of_gone_orgs_are_pruned_once_stale() {
341 let root = tempfile::tempdir().unwrap();
342 for d in ["acme", "gone", "fresh"] {
343 std::fs::create_dir(root.path().join(d)).unwrap();
344 std::fs::write(root.path().join(d).join("web.shop"), "10.0.0.1 web\n").unwrap();
345 }
346 std::fs::write(root.path().join("README"), "x").unwrap();
348 let acme = [OrgId::new("acme").unwrap()];
349 let hour = std::time::Duration::from_secs(3600);
351 assert!(prune_orgs_in(root.path(), &acme, hour).is_empty());
352 let gone = prune_orgs_in(root.path(), &acme, std::time::Duration::ZERO);
353 let names: Vec<&str> = gone.iter().map(OrgId::as_str).collect();
354 assert_eq!(names, ["fresh", "gone"]);
355 assert!(root.path().join("acme/web.shop").is_file());
356 assert!(root.path().join("README").is_file());
357 assert!(!root.path().join("gone").exists());
358 }
359}