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 config
398}
399
400fn put_project(
402 h: &Client,
403 org: &OrgId,
404 existing: Option<&Value>,
405 config: &Value,
406 report: &mut dyn FnMut(&str),
407) -> Result<()> {
408 let project = org.incus_project();
409 let Some(p) = existing else {
410 report(&format!("{org}: creating project {project}"));
411 h.mutate(
412 "POST",
413 "/1.0/projects",
414 Some(&json!({"name": project, "description": format!("isb org {org}"), "config": config})),
415 &format!("create project {project}"),
416 h.get_timeouts().other,
417 )?;
418 return Ok(());
419 };
420 let mut merged = p["config"].clone();
421 if let (Some(m), Some(c)) = (merged.as_object_mut(), config.as_object()) {
422 for (k, v) in c {
423 m.insert(k.clone(), v.clone());
424 }
425 if !c.contains_key("restricted.devices.disk.paths") {
426 m.remove("restricted.devices.disk.paths");
427 }
428 }
429 h.mutate(
430 "PUT",
431 &format!("/1.0/projects/{}", encode_segment(&project)),
432 Some(&json!({"description": p["description"], "config": merged})),
433 &format!("update project {project}"),
434 h.get_timeouts().other,
435 )?;
436 Ok(())
437}
438
439fn set_default_profile(base: &Client, h: &Client, org: &OrgId, opts: &OrgOptions) -> Result<()> {
442 let oc = client(base, org);
443 let pool = crate::sandbox::host_facts(h)?.pick_pool(None)?;
444 let profile = json!({
445 "description": format!("isb org {org}"),
446 "config": {
447 "limits.cpu": opts.default_cpus.unwrap_or(1).to_string(),
448 "limits.memory": opts.default_memory.clone().unwrap_or_else(|| "512MiB".into()),
449 "security.idmap.isolated": "true",
450 },
451 "devices": {
452 "root": {"type": "disk", "path": "/", "pool": pool},
453 "eth0": {"type": "nic", "name": "eth0", "network": bridge_name(org)},
454 },
455 });
456 oc.mutate(
457 "PUT",
458 "/1.0/profiles/default",
459 Some(&profile),
460 &format!("set {org}'s default profile"),
461 oc.get_timeouts().other,
462 )?;
463 Ok(())
464}
465
466pub fn ensure(
471 base: &Client,
472 org: &OrgId,
473 opts: &OrgOptions,
474 report: &mut dyn FnMut(&str),
475) -> Result<OrgInfo> {
476 let h = host(base);
477 let project = org.incus_project();
478 let existing = h.get_opt(&format!("/1.0/projects/{}", encode_segment(&project)))?;
479 if let Some(p) = &existing {
480 if p["config"][KEY_ORG].as_str() != Some(org.as_str()) {
481 return Err(Error::AlreadyExists(format!(
482 "incus project {project} exists but is not isb org {org}"
483 )));
484 }
485 }
486 let k = kept(opts, existing.as_ref())?;
487 let raw_dnsmasq = raw_dnsmasq(org, report)?;
488 let net = ensure_network(&h, org, &raw_dnsmasq, report)?;
489 ensure_acl(&h, org, &net, &k.egress, report)?;
490 attach(&h, org, &net, &raw_dnsmasq, report)?;
491 let config = project_config(org, &k, opts, existing.as_ref());
492 put_project(&h, org, existing.as_ref(), &config, report)?;
493 set_default_profile(base, &h, org, opts)?;
494 get(base, org)
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500 use crate::client::fake::{Route, serve};
501
502 fn bridge_route(prefix: &'static str, config: Value) -> Route {
503 Route {
504 prefix,
505 status: 200,
506 body: json!({"config": config}),
507 }
508 }
509
510 #[test]
511 fn hostsdir_is_added_beside_the_operators_own_lines() {
512 let line = "hostsdir=/var/lib/isb/dns/default";
513 assert_eq!(with_hostsdir("", line).as_deref(), Some(line));
514 assert_eq!(
515 with_hostsdir("log-queries\n", line).as_deref(),
516 Some("log-queries\nhostsdir=/var/lib/isb/dns/default")
517 );
518 assert_eq!(with_hostsdir("hostsdir=/elsewhere", line), None);
519 }
520
521 #[test]
522 fn an_org_made_before_host_setup_gets_service_names_afterwards() {
523 let org = OrgId::default_org();
524 let net = "GET /1.0/networks/";
525 let dir = std::path::Path::new("/var/lib/isb/dns/default");
526 let mut lines = Vec::new();
527
528 let (_d, c) = serve(vec![bridge_route(net, json!({"ipv4.address": "auto"}))]);
530 let n = converge_names(&c, &org, None, &mut |l| lines.push(l.to_string())).unwrap();
531 assert_eq!(n, Names::Unavailable);
532
533 let (_d, c) = serve(vec![
535 bridge_route(net, json!({"ipv4.address": "auto"})),
536 Route {
537 prefix: "PATCH /1.0/networks/",
538 status: 200,
539 body: json!({}),
540 },
541 ]);
542 let n = converge_names(&c, &org, Some(dir), &mut |l| lines.push(l.to_string())).unwrap();
543 assert_eq!(n, Names::TurnedOn);
544 assert!(lines.iter().any(|l| l.contains("turning on service names")));
545
546 let (_d, c) = serve(vec![bridge_route(net, json!({}))]);
548 assert!(converge_names(&c, &org, Some(dir), &mut |_| {}).is_err());
549
550 let (_d, c) = serve(vec![bridge_route(
552 net,
553 json!({"raw.dnsmasq": "hostsdir=/srv/dns"}),
554 )]);
555 let n = converge_names(&c, &org, Some(dir), &mut |_| {}).unwrap();
556 assert_eq!(n, Names::Present);
557 let (_d, c) = serve(vec![]);
558 let n = converge_names(&c, &org, Some(dir), &mut |_| {}).unwrap();
559 assert_eq!(n, Names::Present);
560 }
561
562 fn kept_default() -> Kept {
563 Kept {
564 egress: Vec::new(),
565 domains: String::new(),
566 ingress: INGRESS_CADDY.into(),
567 cf_account: String::new(),
568 cf_zone: String::new(),
569 udp: String::new(),
570 }
571 }
572
573 #[test]
574 fn rewriting_an_org_keeps_its_workspace_homes_bindable() {
575 let org = OrgId::new("lab").unwrap();
576 let existing = json!({"config": {
577 "restricted.devices.disk": "allow",
578 "restricted.devices.disk.paths": "/srv/ws/lab",
579 homes::KEY_WORKSPACE_HOMES: "/srv/ws/lab",
580 }});
581 let c = project_config(
583 &org,
584 &kept_default(),
585 &OrgOptions::default(),
586 Some(&existing),
587 );
588 assert_eq!(c["restricted.devices.disk"], "allow");
589 assert_eq!(c["restricted.devices.disk.paths"], "/srv/ws/lab");
590 let opts = OrgOptions {
592 bind_roots: vec!["/data/lab".into()],
593 cpus: Some(2),
594 ..Default::default()
595 };
596 let c = project_config(&org, &kept_default(), &opts, Some(&existing));
597 assert_eq!(c["restricted.devices.disk.paths"], "/data/lab,/srv/ws/lab");
598 assert_eq!(c["limits.cpu"], "2");
599 let c = project_config(&org, &kept_default(), &OrgOptions::default(), None);
601 assert_eq!(c["restricted.devices.disk"], "managed");
602 assert!(c.get("restricted.devices.disk.paths").is_none());
603 }
604}