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    udp: String,
15}
16
17/// `opts` over the existing project's settings, checked.
18fn 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
84/// What an org's service names are waiting for.
85fn 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
92/// Service discovery: the org's dnsmasq reads its hosts directory. Set at
93/// creation, since changing raw.dnsmasq later restarts dnsmasq. Empty when
94/// the host has no directory for it.
95fn 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
106/// The org's bridge, made if missing; its current state.
107fn 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
142/// Deny private ranges, except the org's own subnet (which holds its DNS)
143/// and its exceptions: the ACL made or rewritten.
144fn 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
184/// The ACL attached to the bridge, and service names turned on.
185fn 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        // Traffic no rule matches passes: ingress from the host and the
199        // balancer, egress to the internet.
200        cfg["security.acls.default.egress.action"] = json!("allow");
201        cfg["security.acls.default.ingress.action"] = json!("allow");
202        changed.push("attach ACL");
203    }
204    // Only ever added: a host without the directory leaves an org's
205    // existing setting alone.
206    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/// Where an org stands on service names.
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227pub enum Names {
228    /// The org's bridge already reads its hosts directory (or the org has
229    /// no bridge to change).
230    Present,
231    /// Just turned on: the bridge's `raw.dnsmasq` now names the directory.
232    TurnedOn,
233    /// Off, because this host has no writable directory for it yet.
234    Unavailable,
235}
236
237/// `raw.dnsmasq` with the `hostsdir=` line for `line` in it, or `None` when
238/// it already names a hosts directory. Other lines the operator set stay.
239fn 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
251/// Turn service names on for an existing org whose bridge does not read a
252/// hosts directory yet, as `isb org create` does: the bridge's
253/// `raw.dnsmasq` gets `hostsdir=<dir>`. `dns_dir` is the org's hosts
254/// directory, or `None` when the host has none.
255fn 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
291/// Bring one existing org's service names in line: when the host has the
292/// hosts directory now (`isb host setup` ran after the org was made), make
293/// the org's directory and point its bridge at it.
294pub 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
302/// The directory of an org's hosts files, made if the host allows it.
303pub(super) type PrepareDir<'a> = &'a dyn Fn(&OrgId) -> Result<Option<PathBuf>>;
304
305/// [`ensure_service_names`] with the directory step given.
306pub(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
321/// The networks the project's instances may use: the org's bridge, and the
322/// egress bridges of its sandboxes (`isbbrx...`), which isb adds one by one.
323fn 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
337/// The project's config: restricted to the org's bridge and uid, its
338/// limits, isb's own keys, and the disk paths it may bind (the bind roots
339/// and its workspaces' host-folder homes).
340fn 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        // Volume snapshots and exports (crate::volume_backup).
353        "restricted.snapshots": "allow",
354        "restricted.backups": "allow",
355        "restricted.networks.access": network_access(&bridge, existing),
356        // The daemon's own uid may be mapped 1:1, so `idmap: auto` keeps
357        // bind-mounted files writable; root never.
358        "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        // A stack's UDP ports are NAT proxy devices: allowed in the project
368        // once the org has any, and kept to them by `check_proxies`.
369        "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
400/// Create the project, or write `config` over what it has.
401fn 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
439/// The default profile: root disk, the org NIC, per-instance defaults and
440/// an isolated uid range per instance.
441fn 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
466/// Create an org, or bring an existing one in line with `opts`. The project's
467/// disk paths are `opts.bind_roots` plus the host-folder workspace homes
468/// recorded on it ([`allow_home`]), so rewriting the bind roots never
469/// drops a home.
470pub 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        // No directory on this host yet: off, and nothing changed.
529        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        // The directory is there now: the bridge is patched.
534        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        // Without the PATCH route the same call fails: it did try to patch.
547        let (_d, c) = serve(vec![bridge_route(net, json!({}))]);
548        assert!(converge_names(&c, &org, Some(dir), &mut |_| {}).is_err());
549
550        // Already on, or no bridge at all: left alone (no PATCH route to answer).
551        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        // `isb org create lab` again, without bind roots.
582        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        // With a bind root of its own.
591        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        // An org without homes or roots binds managed volumes only.
600        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}