1use super::*;
6
7struct Kept {
9 egress: Vec<Egress>,
10 domains: String,
11 ingress: String,
12 cf_account: String,
13 cf_zone: String,
14}
15
16fn kept(opts: &OrgOptions, existing: Option<&Value>) -> Result<Kept> {
18 let keep = |key: &str| -> String {
19 existing
20 .and_then(|p| p["config"][key].as_str())
21 .unwrap_or_default()
22 .to_string()
23 };
24 let egress: Vec<Egress> = match &opts.egress {
25 Some(e) => e.clone(),
26 None => existing
27 .and_then(|p| p["config"][KEY_EGRESS].as_str())
28 .map(parse_egress_list)
29 .unwrap_or_default(),
30 };
31 check_egress(&egress)?;
32 let domains = match &opts.domains {
33 Some(d) => d
34 .iter()
35 .map(|s| check_domain_suffix(s))
36 .collect::<Result<Vec<_>>>()?
37 .join(" "),
38 None => keep(KEY_DOMAINS),
39 };
40 let ingress = match &opts.ingress {
41 Some(i) if i == INGRESS_CADDY || i == INGRESS_CLOUDFLARE_TUNNEL => i.clone(),
42 Some(i) => {
43 return Err(Error::invalid(format!(
44 "--ingress {i:?}: {INGRESS_CADDY} or {INGRESS_CLOUDFLARE_TUNNEL}"
45 )));
46 }
47 None => keep(KEY_INGRESS),
48 };
49 let cf_account = opts
50 .cloudflare_account
51 .clone()
52 .unwrap_or_else(|| keep(KEY_CF_ACCOUNT));
53 let cf_zone = opts
54 .cloudflare_zone
55 .clone()
56 .unwrap_or_else(|| keep(KEY_CF_ZONE));
57 for v in [&cf_account, &cf_zone] {
58 if !v.chars().all(|c| c.is_ascii_alphanumeric()) {
59 return Err(Error::invalid(format!(
60 "Cloudflare id {v:?}: letters and digits only"
61 )));
62 }
63 }
64 Ok(Kept {
65 egress,
66 domains,
67 ingress,
68 cf_account,
69 cf_zone,
70 })
71}
72
73fn no_directory(org: &OrgId) -> String {
75 format!(
76 "{org}: no writable {}: service names are off (run `sudo isb host setup`; a running `isb serve` then turns them on, or run this again)",
77 crate::discovery::root().display()
78 )
79}
80
81fn raw_dnsmasq(org: &OrgId, report: &mut dyn FnMut(&str)) -> Result<String> {
85 let dns_dir = crate::discovery::prepare_org(org)?;
86 if dns_dir.is_none() {
87 report(&no_directory(org));
88 }
89 Ok(dns_dir
90 .as_deref()
91 .map(crate::discovery::raw_dnsmasq)
92 .unwrap_or_default())
93}
94
95fn ensure_network(
97 h: &Client,
98 org: &OrgId,
99 raw_dnsmasq: &str,
100 report: &mut dyn FnMut(&str),
101) -> Result<Value> {
102 let bridge = bridge_name(org);
103 let net_path = format!("/1.0/networks/{}", encode_segment(&bridge));
104 if h.get_opt(&net_path)?.is_none() {
105 report(&format!("{org}: creating network {bridge}"));
106 let mut config = json!({
107 "ipv4.address": "auto",
108 "ipv4.nat": "true",
109 "ipv6.address": "none",
110 "dns.domain": format!("{org}.isb"),
111 });
112 if !raw_dnsmasq.is_empty() {
113 config["raw.dnsmasq"] = json!(raw_dnsmasq);
114 }
115 h.mutate(
116 "POST",
117 "/1.0/networks",
118 Some(&json!({
119 "name": bridge,
120 "type": "bridge",
121 "description": format!("isb org {org}"),
122 "config": config,
123 })),
124 &format!("create network {bridge}"),
125 h.get_timeouts().other,
126 )?;
127 }
128 h.get(&net_path)
129}
130
131fn ensure_acl(
134 h: &Client,
135 org: &OrgId,
136 net: &Value,
137 egress: &[Egress],
138 report: &mut dyn FnMut(&str),
139) -> Result<()> {
140 let subnet = net["config"]["ipv4.address"].as_str().unwrap_or_default();
141 let own = subnet_of(subnet).and_then(|s| parse_cidr(&s));
142 let acl = acl_name(org);
143 let acl_body = json!({
144 "description": format!("isb org {org}: allow within the org, deny other private networks"),
145 "egress": egress_rules(own, egress)?,
146 "ingress": [],
147 "config": {},
148 });
149 let acl_path = format!("/1.0/network-acls/{}", encode_segment(&acl));
150 if h.get_opt(&acl_path)?.is_none() {
151 report(&format!("{org}: creating ACL {acl}"));
152 let mut body = acl_body.clone();
153 body["name"] = json!(acl);
154 h.mutate(
155 "POST",
156 "/1.0/network-acls",
157 Some(&body),
158 &format!("create ACL {acl}"),
159 h.get_timeouts().other,
160 )?;
161 } else {
162 h.mutate(
163 "PUT",
164 &acl_path,
165 Some(&acl_body),
166 &format!("update ACL {acl}"),
167 h.get_timeouts().other,
168 )?;
169 }
170 Ok(())
171}
172
173fn attach(
175 h: &Client,
176 org: &OrgId,
177 net: &Value,
178 raw_dnsmasq: &str,
179 report: &mut dyn FnMut(&str),
180) -> Result<()> {
181 let bridge = bridge_name(org);
182 let acl = acl_name(org);
183 let mut cfg = net["config"].clone();
184 let mut changed = Vec::new();
185 if net["config"]["security.acls"].as_str() != Some(acl.as_str()) {
186 cfg["security.acls"] = json!(acl);
187 cfg["security.acls.default.egress.action"] = json!("allow");
190 cfg["security.acls.default.ingress.action"] = json!("allow");
191 changed.push("attach ACL");
192 }
193 if !raw_dnsmasq.is_empty() && net["config"]["raw.dnsmasq"].as_str() != Some(raw_dnsmasq) {
196 report(&format!(
197 "{org}: turning on service names (restarts {bridge}'s DNS)"
198 ));
199 cfg["raw.dnsmasq"] = json!(raw_dnsmasq);
200 changed.push("set raw.dnsmasq");
201 }
202 if !changed.is_empty() {
203 h.mutate(
204 "PATCH",
205 &format!("/1.0/networks/{}", encode_segment(&bridge)),
206 Some(&json!({"config": cfg})),
207 &format!("{} on {bridge}", changed.join(", ")),
208 h.get_timeouts().other,
209 )?;
210 }
211 Ok(())
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum Names {
217 Present,
220 TurnedOn,
222 Unavailable,
224}
225
226fn with_hostsdir(current: &str, line: &str) -> Option<String> {
229 if current.lines().any(|l| l.trim().starts_with("hostsdir=")) {
230 return None;
231 }
232 let keep = current.trim_end();
233 Some(if keep.is_empty() {
234 line.to_string()
235 } else {
236 format!("{keep}\n{line}")
237 })
238}
239
240fn converge_names(
245 h: &Client,
246 org: &OrgId,
247 dns_dir: Option<&Path>,
248 report: &mut dyn FnMut(&str),
249) -> Result<Names> {
250 let bridge = bridge_name(org);
251 let net_path = format!("/1.0/networks/{}", encode_segment(&bridge));
252 let Some(net) = h.get_opt(&net_path)? else {
253 return Ok(Names::Present);
254 };
255 let current = net["config"]["raw.dnsmasq"].as_str().unwrap_or_default();
256 if current.lines().any(|l| l.trim().starts_with("hostsdir=")) {
257 return Ok(Names::Present);
258 }
259 let Some(dir) = dns_dir else {
260 return Ok(Names::Unavailable);
261 };
262 let Some(raw) = with_hostsdir(current, &crate::discovery::raw_dnsmasq(dir)) else {
263 return Ok(Names::Present);
264 };
265 report(&format!(
266 "{org}: turning on service names (restarts {bridge}'s DNS)"
267 ));
268 let mut cfg = net["config"].clone();
269 cfg["raw.dnsmasq"] = json!(raw);
270 h.mutate(
271 "PATCH",
272 &net_path,
273 Some(&json!({"config": cfg})),
274 &format!("set raw.dnsmasq on {bridge}"),
275 h.get_timeouts().other,
276 )?;
277 Ok(Names::TurnedOn)
278}
279
280pub fn ensure_service_names(
284 base: &Client,
285 org: &OrgId,
286 report: &mut dyn FnMut(&str),
287) -> Result<Names> {
288 ensure_service_names_in(base, org, &crate::discovery::prepare_org, report)
289}
290
291pub(super) type PrepareDir<'a> = &'a dyn Fn(&OrgId) -> Result<Option<PathBuf>>;
293
294pub(super) fn ensure_service_names_in(
296 base: &Client,
297 org: &OrgId,
298 prepare: PrepareDir,
299 report: &mut dyn FnMut(&str),
300) -> Result<Names> {
301 let h = host(base);
302 let dir = prepare(org)?;
303 let names = converge_names(&h, org, dir.as_deref(), report)?;
304 if names == Names::Unavailable {
305 report(&no_directory(org));
306 }
307 Ok(names)
308}
309
310fn network_access(bridge: &str, existing: Option<&Value>) -> String {
313 let mut names = vec![bridge.to_string()];
314 let old = existing
315 .and_then(|p| p["config"]["restricted.networks.access"].as_str())
316 .unwrap_or_default();
317 names.extend(
318 old.split(',')
319 .map(str::trim)
320 .filter(|n| n.starts_with(crate::egress::plumb::NET_PREFIX))
321 .map(String::from),
322 );
323 names.join(",")
324}
325
326fn project_config(org: &OrgId, k: &Kept, opts: &OrgOptions, existing: Option<&Value>) -> Value {
330 let bridge = bridge_name(org);
331 let uid = rustix::process::getuid().as_raw();
332 let gid = rustix::process::getgid().as_raw();
333 let mut config = json!({
334 "features.images": "false",
335 "features.profiles": "true",
336 "features.storage.volumes": "true",
337 "features.storage.buckets": "true",
338 "features.networks": "false",
339 "restricted": "true",
340 "restricted.containers.privilege": "unprivileged",
341 "restricted.snapshots": "allow",
343 "restricted.backups": "allow",
344 "restricted.networks.access": network_access(&bridge, existing),
345 "restricted.idmap.uid": uid.to_string(),
348 "restricted.idmap.gid": gid.to_string(),
349 KEY_ORG: org.as_str(),
350 KEY_NETWORK: bridge,
351 KEY_EGRESS: k.egress.iter().map(Egress::render).collect::<Vec<_>>().join(" "),
352 KEY_DOMAINS: k.domains,
353 KEY_INGRESS: k.ingress,
354 KEY_CF_ACCOUNT: k.cf_account,
355 KEY_CF_ZONE: k.cf_zone,
356 });
357 let roots: Vec<String> = opts
358 .bind_roots
359 .iter()
360 .map(|p| p.display().to_string())
361 .collect();
362 let homes = existing
363 .map(|p| homes::recorded(&p["config"]))
364 .unwrap_or_default();
365 let paths = homes::disk_paths(&roots, &homes);
366 if paths.is_empty() {
367 config["restricted.devices.disk"] = json!("managed");
368 } else {
369 config["restricted.devices.disk"] = json!("allow");
370 config["restricted.devices.disk.paths"] = json!(paths.join(","));
371 }
372 for (key, v) in [
373 ("limits.cpu", opts.cpus.map(|c| c.to_string())),
374 ("limits.memory", opts.memory.clone()),
375 ("limits.disk", opts.disk.clone()),
376 ("limits.instances", opts.instances.map(|c| c.to_string())),
377 ] {
378 if let Some(v) = v {
379 config[key] = json!(v);
380 }
381 }
382 config
383}
384
385fn put_project(
387 h: &Client,
388 org: &OrgId,
389 existing: Option<&Value>,
390 config: &Value,
391 report: &mut dyn FnMut(&str),
392) -> Result<()> {
393 let project = org.incus_project();
394 let Some(p) = existing else {
395 report(&format!("{org}: creating project {project}"));
396 h.mutate(
397 "POST",
398 "/1.0/projects",
399 Some(&json!({"name": project, "description": format!("isb org {org}"), "config": config})),
400 &format!("create project {project}"),
401 h.get_timeouts().other,
402 )?;
403 return Ok(());
404 };
405 let mut merged = p["config"].clone();
406 if let (Some(m), Some(c)) = (merged.as_object_mut(), config.as_object()) {
407 for (k, v) in c {
408 m.insert(k.clone(), v.clone());
409 }
410 if !c.contains_key("restricted.devices.disk.paths") {
411 m.remove("restricted.devices.disk.paths");
412 }
413 }
414 h.mutate(
415 "PUT",
416 &format!("/1.0/projects/{}", encode_segment(&project)),
417 Some(&json!({"description": p["description"], "config": merged})),
418 &format!("update project {project}"),
419 h.get_timeouts().other,
420 )?;
421 Ok(())
422}
423
424fn set_default_profile(base: &Client, h: &Client, org: &OrgId, opts: &OrgOptions) -> Result<()> {
427 let oc = client(base, org);
428 let pool = crate::sandbox::host_facts(h)?.pick_pool(None)?;
429 let profile = json!({
430 "description": format!("isb org {org}"),
431 "config": {
432 "limits.cpu": opts.default_cpus.unwrap_or(1).to_string(),
433 "limits.memory": opts.default_memory.clone().unwrap_or_else(|| "512MiB".into()),
434 "security.idmap.isolated": "true",
435 },
436 "devices": {
437 "root": {"type": "disk", "path": "/", "pool": pool},
438 "eth0": {"type": "nic", "name": "eth0", "network": bridge_name(org)},
439 },
440 });
441 oc.mutate(
442 "PUT",
443 "/1.0/profiles/default",
444 Some(&profile),
445 &format!("set {org}'s default profile"),
446 oc.get_timeouts().other,
447 )?;
448 Ok(())
449}
450
451pub fn ensure(
456 base: &Client,
457 org: &OrgId,
458 opts: &OrgOptions,
459 report: &mut dyn FnMut(&str),
460) -> Result<OrgInfo> {
461 let h = host(base);
462 let project = org.incus_project();
463 let existing = h.get_opt(&format!("/1.0/projects/{}", encode_segment(&project)))?;
464 if let Some(p) = &existing {
465 if p["config"][KEY_ORG].as_str() != Some(org.as_str()) {
466 return Err(Error::AlreadyExists(format!(
467 "incus project {project} exists but is not isb org {org}"
468 )));
469 }
470 }
471 let k = kept(opts, existing.as_ref())?;
472 let raw_dnsmasq = raw_dnsmasq(org, report)?;
473 let net = ensure_network(&h, org, &raw_dnsmasq, report)?;
474 ensure_acl(&h, org, &net, &k.egress, report)?;
475 attach(&h, org, &net, &raw_dnsmasq, report)?;
476 let config = project_config(org, &k, opts, existing.as_ref());
477 put_project(&h, org, existing.as_ref(), &config, report)?;
478 set_default_profile(base, &h, org, opts)?;
479 get(base, org)
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485 use crate::client::fake::{Route, serve};
486
487 fn bridge_route(prefix: &'static str, config: Value) -> Route {
488 Route {
489 prefix,
490 status: 200,
491 body: json!({"config": config}),
492 }
493 }
494
495 #[test]
496 fn hostsdir_is_added_beside_the_operators_own_lines() {
497 let line = "hostsdir=/var/lib/isb/dns/default";
498 assert_eq!(with_hostsdir("", line).as_deref(), Some(line));
499 assert_eq!(
500 with_hostsdir("log-queries\n", line).as_deref(),
501 Some("log-queries\nhostsdir=/var/lib/isb/dns/default")
502 );
503 assert_eq!(with_hostsdir("hostsdir=/elsewhere", line), None);
504 }
505
506 #[test]
507 fn an_org_made_before_host_setup_gets_service_names_afterwards() {
508 let org = OrgId::default_org();
509 let net = "GET /1.0/networks/";
510 let dir = std::path::Path::new("/var/lib/isb/dns/default");
511 let mut lines = Vec::new();
512
513 let (_d, c) = serve(vec![bridge_route(net, json!({"ipv4.address": "auto"}))]);
515 let n = converge_names(&c, &org, None, &mut |l| lines.push(l.to_string())).unwrap();
516 assert_eq!(n, Names::Unavailable);
517
518 let (_d, c) = serve(vec![
520 bridge_route(net, json!({"ipv4.address": "auto"})),
521 Route {
522 prefix: "PATCH /1.0/networks/",
523 status: 200,
524 body: json!({}),
525 },
526 ]);
527 let n = converge_names(&c, &org, Some(dir), &mut |l| lines.push(l.to_string())).unwrap();
528 assert_eq!(n, Names::TurnedOn);
529 assert!(lines.iter().any(|l| l.contains("turning on service names")));
530
531 let (_d, c) = serve(vec![bridge_route(net, json!({}))]);
533 assert!(converge_names(&c, &org, Some(dir), &mut |_| {}).is_err());
534
535 let (_d, c) = serve(vec![bridge_route(
537 net,
538 json!({"raw.dnsmasq": "hostsdir=/srv/dns"}),
539 )]);
540 let n = converge_names(&c, &org, Some(dir), &mut |_| {}).unwrap();
541 assert_eq!(n, Names::Present);
542 let (_d, c) = serve(vec![]);
543 let n = converge_names(&c, &org, Some(dir), &mut |_| {}).unwrap();
544 assert_eq!(n, Names::Present);
545 }
546
547 fn kept_default() -> Kept {
548 Kept {
549 egress: Vec::new(),
550 domains: String::new(),
551 ingress: INGRESS_CADDY.into(),
552 cf_account: String::new(),
553 cf_zone: String::new(),
554 }
555 }
556
557 #[test]
558 fn rewriting_an_org_keeps_its_workspace_homes_bindable() {
559 let org = OrgId::new("lab").unwrap();
560 let existing = json!({"config": {
561 "restricted.devices.disk": "allow",
562 "restricted.devices.disk.paths": "/srv/ws/lab",
563 homes::KEY_WORKSPACE_HOMES: "/srv/ws/lab",
564 }});
565 let c = project_config(
567 &org,
568 &kept_default(),
569 &OrgOptions::default(),
570 Some(&existing),
571 );
572 assert_eq!(c["restricted.devices.disk"], "allow");
573 assert_eq!(c["restricted.devices.disk.paths"], "/srv/ws/lab");
574 let opts = OrgOptions {
576 bind_roots: vec!["/data/lab".into()],
577 cpus: Some(2),
578 ..Default::default()
579 };
580 let c = project_config(&org, &kept_default(), &opts, Some(&existing));
581 assert_eq!(c["restricted.devices.disk.paths"], "/data/lab,/srv/ws/lab");
582 assert_eq!(c["limits.cpu"], "2");
583 let c = project_config(&org, &kept_default(), &OrgOptions::default(), None);
585 assert_eq!(c["restricted.devices.disk"], "managed");
586 assert!(c.get("restricted.devices.disk.paths").is_none());
587 }
588}