1use super::*;
6
7struct Kept {
9 egress: Vec<Egress>,
10 domains: String,
11 ingress: String,
12 cf_account: String,
13 cf_zone: String,
14 udp: String,
15}
16
17fn kept(opts: &OrgOptions, existing: Option<&Value>) -> Result<Kept> {
19 let keep = |key: &str| -> String {
20 existing
21 .and_then(|p| p["config"][key].as_str())
22 .unwrap_or_default()
23 .to_string()
24 };
25 let egress: Vec<Egress> = match &opts.egress {
26 Some(e) => e.clone(),
27 None => existing
28 .and_then(|p| p["config"][KEY_EGRESS].as_str())
29 .map(parse_egress_list)
30 .unwrap_or_default(),
31 };
32 check_egress(&egress)?;
33 let domains = match &opts.domains {
34 Some(d) => d
35 .iter()
36 .map(|s| check_domain_suffix(s))
37 .collect::<Result<Vec<_>>>()?
38 .join(" "),
39 None => keep(KEY_DOMAINS),
40 };
41 let ingress = match &opts.ingress {
42 Some(i) if i == INGRESS_CADDY || i == INGRESS_CLOUDFLARE_TUNNEL => i.clone(),
43 Some(i) => {
44 return Err(Error::invalid(format!(
45 "--ingress {i:?}: {INGRESS_CADDY} or {INGRESS_CLOUDFLARE_TUNNEL}"
46 )));
47 }
48 None => keep(KEY_INGRESS),
49 };
50 let cf_account = opts
51 .cloudflare_account
52 .clone()
53 .unwrap_or_else(|| keep(KEY_CF_ACCOUNT));
54 let cf_zone = opts
55 .cloudflare_zone
56 .clone()
57 .unwrap_or_else(|| keep(KEY_CF_ZONE));
58 for v in [&cf_account, &cf_zone] {
59 if !v.chars().all(|c| c.is_ascii_alphanumeric()) {
60 return Err(Error::invalid(format!(
61 "Cloudflare id {v:?}: letters and digits only"
62 )));
63 }
64 }
65 let udp = match &opts.udp {
66 Some(u) => {
67 for a in u {
68 check_udp_port(&a.to_string())?;
69 }
70 udp::render(u)
71 }
72 None => keep(KEY_UDP),
73 };
74 Ok(Kept {
75 egress,
76 domains,
77 ingress,
78 cf_account,
79 cf_zone,
80 udp,
81 })
82}
83
84fn no_directory(org: &OrgId) -> String {
86 format!(
87 "{org}: no writable {}: service names are off (run `sudo isb host setup`; a running `isb serve` then turns them on, or run this again)",
88 crate::discovery::root().display()
89 )
90}
91
92fn raw_dnsmasq(org: &OrgId, report: &mut dyn FnMut(&str)) -> Result<String> {
96 let dns_dir = crate::discovery::prepare_org(org)?;
97 if dns_dir.is_none() {
98 report(&no_directory(org));
99 }
100 Ok(dns_dir
101 .as_deref()
102 .map(crate::discovery::raw_dnsmasq)
103 .unwrap_or_default())
104}
105
106fn ensure_network(
108 h: &Client,
109 org: &OrgId,
110 raw_dnsmasq: &str,
111 report: &mut dyn FnMut(&str),
112) -> Result<Value> {
113 let bridge = bridge_name(org);
114 let net_path = format!("/1.0/networks/{}", encode_segment(&bridge));
115 if h.get_opt(&net_path)?.is_none() {
116 report(&format!("{org}: creating network {bridge}"));
117 let mut config = json!({
118 "ipv4.address": "auto",
119 "ipv4.nat": "true",
120 "ipv6.address": "none",
121 "dns.domain": format!("{org}.isb"),
122 });
123 if !raw_dnsmasq.is_empty() {
124 config["raw.dnsmasq"] = json!(raw_dnsmasq);
125 }
126 h.mutate(
127 "POST",
128 "/1.0/networks",
129 Some(&json!({
130 "name": bridge,
131 "type": "bridge",
132 "description": format!("isb org {org}"),
133 "config": config,
134 })),
135 &format!("create network {bridge}"),
136 h.get_timeouts().other,
137 )?;
138 }
139 h.get(&net_path)
140}
141
142fn ensure_acl(
145 h: &Client,
146 org: &OrgId,
147 net: &Value,
148 egress: &[Egress],
149 report: &mut dyn FnMut(&str),
150) -> Result<()> {
151 let subnet = net["config"]["ipv4.address"].as_str().unwrap_or_default();
152 let own = subnet_of(subnet).and_then(|s| parse_cidr(&s));
153 let acl = acl_name(org);
154 let acl_body = json!({
155 "description": format!("isb org {org}: allow within the org, deny other private networks"),
156 "egress": egress_rules(own, egress)?,
157 "ingress": [],
158 "config": {},
159 });
160 let acl_path = format!("/1.0/network-acls/{}", encode_segment(&acl));
161 if h.get_opt(&acl_path)?.is_none() {
162 report(&format!("{org}: creating ACL {acl}"));
163 let mut body = acl_body.clone();
164 body["name"] = json!(acl);
165 h.mutate(
166 "POST",
167 "/1.0/network-acls",
168 Some(&body),
169 &format!("create ACL {acl}"),
170 h.get_timeouts().other,
171 )?;
172 } else {
173 h.mutate(
174 "PUT",
175 &acl_path,
176 Some(&acl_body),
177 &format!("update ACL {acl}"),
178 h.get_timeouts().other,
179 )?;
180 }
181 Ok(())
182}
183
184fn attach(
186 h: &Client,
187 org: &OrgId,
188 net: &Value,
189 raw_dnsmasq: &str,
190 report: &mut dyn FnMut(&str),
191) -> Result<()> {
192 let bridge = bridge_name(org);
193 let acl = acl_name(org);
194 let mut cfg = net["config"].clone();
195 let mut changed = Vec::new();
196 if net["config"]["security.acls"].as_str() != Some(acl.as_str()) {
197 cfg["security.acls"] = json!(acl);
198 cfg["security.acls.default.egress.action"] = json!("allow");
201 cfg["security.acls.default.ingress.action"] = json!("allow");
202 changed.push("attach ACL");
203 }
204 if !raw_dnsmasq.is_empty() && net["config"]["raw.dnsmasq"].as_str() != Some(raw_dnsmasq) {
207 report(&format!(
208 "{org}: turning on service names (restarts {bridge}'s DNS)"
209 ));
210 cfg["raw.dnsmasq"] = json!(raw_dnsmasq);
211 changed.push("set raw.dnsmasq");
212 }
213 if !changed.is_empty() {
214 h.mutate(
215 "PATCH",
216 &format!("/1.0/networks/{}", encode_segment(&bridge)),
217 Some(&json!({"config": cfg})),
218 &format!("{} on {bridge}", changed.join(", ")),
219 h.get_timeouts().other,
220 )?;
221 }
222 Ok(())
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227pub enum Names {
228 Present,
231 TurnedOn,
233 Unavailable,
235}
236
237fn with_hostsdir(current: &str, line: &str) -> Option<String> {
240 if current.lines().any(|l| l.trim().starts_with("hostsdir=")) {
241 return None;
242 }
243 let keep = current.trim_end();
244 Some(if keep.is_empty() {
245 line.to_string()
246 } else {
247 format!("{keep}\n{line}")
248 })
249}
250
251fn converge_names(
256 h: &Client,
257 org: &OrgId,
258 dns_dir: Option<&Path>,
259 report: &mut dyn FnMut(&str),
260) -> Result<Names> {
261 let bridge = bridge_name(org);
262 let net_path = format!("/1.0/networks/{}", encode_segment(&bridge));
263 let Some(net) = h.get_opt(&net_path)? else {
264 return Ok(Names::Present);
265 };
266 let current = net["config"]["raw.dnsmasq"].as_str().unwrap_or_default();
267 if current.lines().any(|l| l.trim().starts_with("hostsdir=")) {
268 return Ok(Names::Present);
269 }
270 let Some(dir) = dns_dir else {
271 return Ok(Names::Unavailable);
272 };
273 let Some(raw) = with_hostsdir(current, &crate::discovery::raw_dnsmasq(dir)) else {
274 return Ok(Names::Present);
275 };
276 report(&format!(
277 "{org}: turning on service names (restarts {bridge}'s DNS)"
278 ));
279 let mut cfg = net["config"].clone();
280 cfg["raw.dnsmasq"] = json!(raw);
281 h.mutate(
282 "PATCH",
283 &net_path,
284 Some(&json!({"config": cfg})),
285 &format!("set raw.dnsmasq on {bridge}"),
286 h.get_timeouts().other,
287 )?;
288 Ok(Names::TurnedOn)
289}
290
291pub fn ensure_service_names(
295 base: &Client,
296 org: &OrgId,
297 report: &mut dyn FnMut(&str),
298) -> Result<Names> {
299 ensure_service_names_in(base, org, &crate::discovery::prepare_org, report)
300}
301
302pub(super) type PrepareDir<'a> = &'a dyn Fn(&OrgId) -> Result<Option<PathBuf>>;
304
305pub(super) fn ensure_service_names_in(
307 base: &Client,
308 org: &OrgId,
309 prepare: PrepareDir,
310 report: &mut dyn FnMut(&str),
311) -> Result<Names> {
312 let h = host(base);
313 let dir = prepare(org)?;
314 let names = converge_names(&h, org, dir.as_deref(), report)?;
315 if names == Names::Unavailable {
316 report(&no_directory(org));
317 }
318 Ok(names)
319}
320
321fn network_access(bridge: &str, existing: Option<&Value>) -> String {
324 let mut names = vec![bridge.to_string()];
325 let old = existing
326 .and_then(|p| p["config"]["restricted.networks.access"].as_str())
327 .unwrap_or_default();
328 names.extend(
329 old.split(',')
330 .map(str::trim)
331 .filter(|n| n.starts_with(crate::egress::plumb::NET_PREFIX))
332 .map(String::from),
333 );
334 names.join(",")
335}
336
337fn project_config(org: &OrgId, k: &Kept, opts: &OrgOptions, existing: Option<&Value>) -> Value {
341 let bridge = bridge_name(org);
342 let uid = rustix::process::getuid().as_raw();
343 let gid = rustix::process::getgid().as_raw();
344 let mut config = json!({
345 "features.images": "false",
346 "features.profiles": "true",
347 "features.storage.volumes": "true",
348 "features.storage.buckets": "true",
349 "features.networks": "false",
350 "restricted": "true",
351 "restricted.containers.privilege": "unprivileged",
352 "restricted.snapshots": "allow",
354 "restricted.backups": "allow",
355 "restricted.networks.access": network_access(&bridge, existing),
356 "restricted.idmap.uid": uid.to_string(),
359 "restricted.idmap.gid": gid.to_string(),
360 KEY_ORG: org.as_str(),
361 KEY_NETWORK: bridge,
362 KEY_EGRESS: k.egress.iter().map(Egress::render).collect::<Vec<_>>().join(" "),
363 KEY_DOMAINS: k.domains,
364 KEY_INGRESS: k.ingress,
365 KEY_CF_ACCOUNT: k.cf_account,
366 KEY_CF_ZONE: k.cf_zone,
367 "restricted.devices.proxy": if k.udp.is_empty() { "block" } else { "allow" },
370 KEY_UDP: k.udp,
371 });
372 let roots: Vec<String> = opts
373 .bind_roots
374 .iter()
375 .map(|p| p.display().to_string())
376 .collect();
377 let homes = existing
378 .map(|p| homes::recorded(&p["config"]))
379 .unwrap_or_default();
380 let paths = homes::disk_paths(&roots, &homes);
381 if paths.is_empty() {
382 config["restricted.devices.disk"] = json!("managed");
383 } else {
384 config["restricted.devices.disk"] = json!("allow");
385 config["restricted.devices.disk.paths"] = json!(paths.join(","));
386 }
387 for (key, v) in [
388 ("limits.cpu", opts.cpus.map(|c| c.to_string())),
389 ("limits.memory", opts.memory.clone()),
390 ("limits.disk", opts.disk.clone()),
391 ("limits.instances", opts.instances.map(|c| c.to_string())),
392 ] {
393 if let Some(v) = v {
394 config[key] = json!(v);
395 }
396 }
397 for l in &opts.lift {
399 config[l.key()] = Value::Null;
400 }
401 config
402}
403
404fn disk_limited(config: &Value, existing: Option<&Value>) -> bool {
407 match config.get("limits.disk") {
408 Some(v) => v.is_string(),
409 None => existing.is_some_and(|p| p["config"]["limits.disk"].is_string()),
410 }
411}
412
413fn merged_config(current: &Value, config: &Value) -> Value {
416 let mut merged = current.clone();
417 if let (Some(m), Some(c)) = (merged.as_object_mut(), config.as_object()) {
418 for (k, v) in c {
419 if v.is_null() {
420 m.remove(k);
421 } else {
422 m.insert(k.clone(), v.clone());
423 }
424 }
425 if !c.contains_key("restricted.devices.disk.paths") {
426 m.remove("restricted.devices.disk.paths");
427 }
428 }
429 merged
430}
431
432fn put_project(
434 h: &Client,
435 org: &OrgId,
436 existing: Option<&Value>,
437 config: &Value,
438 report: &mut dyn FnMut(&str),
439) -> Result<()> {
440 let project = org.incus_project();
441 let Some(p) = existing else {
442 report(&format!("{org}: creating project {project}"));
443 let config = merged_config(&json!({}), config);
444 h.mutate(
445 "POST",
446 "/1.0/projects",
447 Some(&json!({"name": project, "description": format!("isb org {org}"), "config": config})),
448 &format!("create project {project}"),
449 h.get_timeouts().other,
450 )?;
451 return Ok(());
452 };
453 let merged = merged_config(&p["config"], config);
454 h.mutate(
455 "PUT",
456 &format!("/1.0/projects/{}", encode_segment(&project)),
457 Some(&json!({"description": p["description"], "config": merged})),
458 &format!("update project {project}"),
459 h.get_timeouts().other,
460 )?;
461 Ok(())
462}
463
464fn default_profile(org: &OrgId, pool: &str, opts: &OrgOptions, root_size: Option<&str>) -> Value {
468 let mut root = json!({"type": "disk", "path": "/", "pool": pool});
469 if let Some(size) = root_size {
470 root["size"] = json!(size);
471 }
472 json!({
473 "description": format!("isb org {org}"),
474 "config": {
475 "limits.cpu": opts.default_cpus.unwrap_or(1).to_string(),
476 "limits.memory": opts.default_memory.clone().unwrap_or_else(|| "512MiB".into()),
477 "security.idmap.isolated": "true",
478 },
479 "devices": {
480 "root": root,
481 "eth0": {"type": "nic", "name": "eth0", "network": bridge_name(org)},
482 },
483 })
484}
485
486fn set_default_profile(
493 base: &Client,
494 h: &Client,
495 org: &OrgId,
496 opts: &OrgOptions,
497 disk_limited: bool,
498) -> Result<()> {
499 let oc = client(base, org);
500 let pool = crate::sandbox::host_facts(h)?.pick_pool(None)?;
501 let root_size = if disk_limited {
502 let current = oc.get_opt("/1.0/profiles/default")?.unwrap_or_default();
503 Some(
504 current["devices"]["root"]["size"]
505 .as_str()
506 .unwrap_or(limits::DEFAULT_ROOT_SIZE)
507 .to_string(),
508 )
509 } else {
510 None
511 };
512 let profile = default_profile(org, &pool, opts, root_size.as_deref());
513 oc.mutate(
514 "PUT",
515 "/1.0/profiles/default",
516 Some(&profile),
517 &format!("set {org}'s default profile"),
518 oc.get_timeouts().other,
519 )?;
520 Ok(())
521}
522
523pub fn ensure(
528 base: &Client,
529 org: &OrgId,
530 opts: &OrgOptions,
531 report: &mut dyn FnMut(&str),
532) -> Result<OrgInfo> {
533 let h = host(base);
534 let project = org.incus_project();
535 let existing = h.get_opt(&format!("/1.0/projects/{}", encode_segment(&project)))?;
536 if let Some(p) = &existing {
537 if p["config"][KEY_ORG].as_str() != Some(org.as_str()) {
538 return Err(Error::AlreadyExists(format!(
539 "incus project {project} exists but is not isb org {org}"
540 )));
541 }
542 }
543 for l in &opts.lift {
544 let given = match l {
545 Limit::Cpus => opts.cpus.is_some(),
546 Limit::Memory => opts.memory.is_some(),
547 Limit::Disk => opts.disk.is_some(),
548 Limit::Instances => opts.instances.is_some(),
549 };
550 if given {
551 return Err(Error::invalid(format!(
552 "{}: set and lifted at once; give a value or none",
553 l.key()
554 )));
555 }
556 }
557 let k = kept(opts, existing.as_ref())?;
558 let raw_dnsmasq = raw_dnsmasq(org, report)?;
559 let net = ensure_network(&h, org, &raw_dnsmasq, report)?;
560 ensure_acl(&h, org, &net, &k.egress, report)?;
561 attach(&h, org, &net, &raw_dnsmasq, report)?;
562 let config = project_config(org, &k, opts, existing.as_ref());
563 let disk = disk_limited(&config, existing.as_ref());
564 if existing.is_some() && disk {
565 set_default_profile(base, &h, org, opts, disk)?;
568 put_project(&h, org, existing.as_ref(), &config, report)?;
569 } else {
570 put_project(&h, org, existing.as_ref(), &config, report)?;
573 set_default_profile(base, &h, org, opts, disk)?;
574 }
575 get(base, org)
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581 use crate::client::fake::{Route, serve};
582
583 fn bridge_route(prefix: &'static str, config: Value) -> Route {
584 Route {
585 prefix,
586 status: 200,
587 body: json!({"config": config}),
588 }
589 }
590
591 #[test]
592 fn hostsdir_is_added_beside_the_operators_own_lines() {
593 let line = "hostsdir=/var/lib/isb/dns/default";
594 assert_eq!(with_hostsdir("", line).as_deref(), Some(line));
595 assert_eq!(
596 with_hostsdir("log-queries\n", line).as_deref(),
597 Some("log-queries\nhostsdir=/var/lib/isb/dns/default")
598 );
599 assert_eq!(with_hostsdir("hostsdir=/elsewhere", line), None);
600 }
601
602 #[test]
603 fn an_org_made_before_host_setup_gets_service_names_afterwards() {
604 let org = OrgId::default_org();
605 let net = "GET /1.0/networks/";
606 let dir = std::path::Path::new("/var/lib/isb/dns/default");
607 let mut lines = Vec::new();
608
609 let (_d, c) = serve(vec![bridge_route(net, json!({"ipv4.address": "auto"}))]);
611 let n = converge_names(&c, &org, None, &mut |l| lines.push(l.to_string())).unwrap();
612 assert_eq!(n, Names::Unavailable);
613
614 let (_d, c) = serve(vec![
616 bridge_route(net, json!({"ipv4.address": "auto"})),
617 Route {
618 prefix: "PATCH /1.0/networks/",
619 status: 200,
620 body: json!({}),
621 },
622 ]);
623 let n = converge_names(&c, &org, Some(dir), &mut |l| lines.push(l.to_string())).unwrap();
624 assert_eq!(n, Names::TurnedOn);
625 assert!(lines.iter().any(|l| l.contains("turning on service names")));
626
627 let (_d, c) = serve(vec![bridge_route(net, json!({}))]);
629 assert!(converge_names(&c, &org, Some(dir), &mut |_| {}).is_err());
630
631 let (_d, c) = serve(vec![bridge_route(
633 net,
634 json!({"raw.dnsmasq": "hostsdir=/srv/dns"}),
635 )]);
636 let n = converge_names(&c, &org, Some(dir), &mut |_| {}).unwrap();
637 assert_eq!(n, Names::Present);
638 let (_d, c) = serve(vec![]);
639 let n = converge_names(&c, &org, Some(dir), &mut |_| {}).unwrap();
640 assert_eq!(n, Names::Present);
641 }
642
643 fn kept_default() -> Kept {
644 Kept {
645 egress: Vec::new(),
646 domains: String::new(),
647 ingress: INGRESS_CADDY.into(),
648 cf_account: String::new(),
649 cf_zone: String::new(),
650 udp: String::new(),
651 }
652 }
653
654 #[test]
655 fn rewriting_an_org_keeps_its_workspace_homes_bindable() {
656 let org = OrgId::new("lab").unwrap();
657 let existing = json!({"config": {
658 "restricted.devices.disk": "allow",
659 "restricted.devices.disk.paths": "/srv/ws/lab",
660 homes::KEY_WORKSPACE_HOMES: "/srv/ws/lab",
661 }});
662 let c = project_config(
664 &org,
665 &kept_default(),
666 &OrgOptions::default(),
667 Some(&existing),
668 );
669 assert_eq!(c["restricted.devices.disk"], "allow");
670 assert_eq!(c["restricted.devices.disk.paths"], "/srv/ws/lab");
671 let opts = OrgOptions {
673 bind_roots: vec!["/data/lab".into()],
674 cpus: Some(2),
675 ..Default::default()
676 };
677 let c = project_config(&org, &kept_default(), &opts, Some(&existing));
678 assert_eq!(c["restricted.devices.disk.paths"], "/data/lab,/srv/ws/lab");
679 assert_eq!(c["limits.cpu"], "2");
680 let c = project_config(&org, &kept_default(), &OrgOptions::default(), None);
682 assert_eq!(c["restricted.devices.disk"], "managed");
683 assert!(c.get("restricted.devices.disk.paths").is_none());
684 }
685
686 #[test]
687 fn a_lifted_limit_is_removed_and_others_are_kept() {
688 let org = OrgId::new("lab").unwrap();
689 let existing = json!({"config": {
690 "limits.cpu": "4", "limits.memory": "8GiB", "limits.disk": "50GiB",
691 }});
692 let opts = OrgOptions {
693 lift: vec![Limit::Disk, Limit::Cpus],
694 instances: Some(5),
695 ..Default::default()
696 };
697 let c = project_config(&org, &kept_default(), &opts, Some(&existing));
698 assert!(!disk_limited(&c, Some(&existing)));
699 let m = merged_config(&existing["config"], &c);
700 assert!(m.get("limits.disk").is_none(), "{m}");
701 assert!(m.get("limits.cpu").is_none(), "{m}");
702 assert_eq!(m["limits.memory"], "8GiB");
703 assert_eq!(m["limits.instances"], "5");
704 let m = merged_config(&json!({}), &c);
706 assert!(m.as_object().unwrap().values().all(|v| !v.is_null()));
707
708 let none = project_config(&org, &kept_default(), &OrgOptions::default(), None);
710 assert!(disk_limited(&none, Some(&existing)));
711 assert!(!disk_limited(&none, None));
712 let set = OrgOptions {
713 disk: Some("10GiB".into()),
714 ..Default::default()
715 };
716 let c = project_config(&org, &kept_default(), &set, None);
717 assert!(disk_limited(&c, None));
718 }
719
720 #[test]
721 fn the_default_profile_sizes_the_root_disk_only_under_a_disk_limit() {
722 let org = OrgId::new("lab").unwrap();
723 let opts = OrgOptions::default();
724 let p = default_profile(&org, "default", &opts, None);
725 assert!(p["devices"]["root"].get("size").is_none(), "{p}");
726 assert_eq!(p["config"]["limits.cpu"], "1");
727 let p = default_profile(&org, "default", &opts, Some(limits::DEFAULT_ROOT_SIZE));
728 assert_eq!(p["devices"]["root"]["size"], "10GiB");
729 assert_eq!(p["devices"]["root"]["pool"], "default");
730 }
731}