Skip to main content

isb_core/org/
ensure.rs

1//! [`ensure`]: create an org, or bring an existing one in line with its
2//! options: its bridge, its network ACL, its restricted project and its
3//! default profile.
4
5use super::*;
6
7/// The org's settings that `opts` may leave to what the org has now.
8struct Kept {
9    egress: Vec<Egress>,
10    domains: String,
11    ingress: String,
12    cf_account: String,
13    cf_zone: String,
14}
15
16/// `opts` over the existing project's settings, checked.
17fn 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
73/// What an org's service names are waiting for.
74fn 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
81/// Service discovery: the org's dnsmasq reads its hosts directory. Set at
82/// creation, since changing raw.dnsmasq later restarts dnsmasq. Empty when
83/// the host has no directory for it.
84fn 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
95/// The org's bridge, made if missing; its current state.
96fn 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
131/// Deny private ranges, except the org's own subnet (which holds its DNS)
132/// and its exceptions: the ACL made or rewritten.
133fn 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
173/// The ACL attached to the bridge, and service names turned on.
174fn 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        // Traffic no rule matches passes: ingress from the host and the
188        // balancer, egress to the internet.
189        cfg["security.acls.default.egress.action"] = json!("allow");
190        cfg["security.acls.default.ingress.action"] = json!("allow");
191        changed.push("attach ACL");
192    }
193    // Only ever added: a host without the directory leaves an org's
194    // existing setting alone.
195    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/// Where an org stands on service names.
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum Names {
217    /// The org's bridge already reads its hosts directory (or the org has
218    /// no bridge to change).
219    Present,
220    /// Just turned on: the bridge's `raw.dnsmasq` now names the directory.
221    TurnedOn,
222    /// Off, because this host has no writable directory for it yet.
223    Unavailable,
224}
225
226/// `raw.dnsmasq` with the `hostsdir=` line for `line` in it, or `None` when
227/// it already names a hosts directory. Other lines the operator set stay.
228fn 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
240/// Turn service names on for an existing org whose bridge does not read a
241/// hosts directory yet, as `isb org create` does: the bridge's
242/// `raw.dnsmasq` gets `hostsdir=<dir>`. `dns_dir` is the org's hosts
243/// directory, or `None` when the host has none.
244fn 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
280/// Bring one existing org's service names in line: when the host has the
281/// hosts directory now (`isb host setup` ran after the org was made), make
282/// the org's directory and point its bridge at it.
283pub 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
291/// The directory of an org's hosts files, made if the host allows it.
292pub(super) type PrepareDir<'a> = &'a dyn Fn(&OrgId) -> Result<Option<PathBuf>>;
293
294/// [`ensure_service_names`] with the directory step given.
295pub(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
310/// The networks the project's instances may use: the org's bridge, and the
311/// egress bridges of its sandboxes (`isbbrx...`), which isb adds one by one.
312fn 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
326/// The project's config: restricted to the org's bridge and uid, its
327/// limits, isb's own keys, and the disk paths it may bind (the bind roots
328/// and its workspaces' host-folder homes).
329fn 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        // Volume snapshots and exports (crate::volume_backup).
342        "restricted.snapshots": "allow",
343        "restricted.backups": "allow",
344        "restricted.networks.access": network_access(&bridge, existing),
345        // The daemon's own uid may be mapped 1:1, so `idmap: auto` keeps
346        // bind-mounted files writable; root never.
347        "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
385/// Create the project, or write `config` over what it has.
386fn 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
424/// The default profile: root disk, the org NIC, per-instance defaults and
425/// an isolated uid range per instance.
426fn 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
451/// Create an org, or bring an existing one in line with `opts`. The project's
452/// disk paths are `opts.bind_roots` plus the host-folder workspace homes
453/// recorded on it ([`allow_home`]), so rewriting the bind roots never
454/// drops a home.
455pub 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        // No directory on this host yet: off, and nothing changed.
514        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        // The directory is there now: the bridge is patched.
519        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        // Without the PATCH route the same call fails: it did try to patch.
532        let (_d, c) = serve(vec![bridge_route(net, json!({}))]);
533        assert!(converge_names(&c, &org, Some(dir), &mut |_| {}).is_err());
534
535        // Already on, or no bridge at all: left alone (no PATCH route to answer).
536        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        // `isb org create lab` again, without bind roots.
566        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        // With a bind root of its own.
575        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        // An org without homes or roots binds managed volumes only.
584        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}