Skip to main content

isb_core/
org.rs

1//! Orgs: the trust boundary. An org is an incus project; its people and
2//! agents fully administer what is in it, and nothing crosses orgs.
3//!
4//! Any org `x` is the incus project `isb-x`, the `default` org included
5//! (`isb-default`, which [`ensure_default`] creates when the daemon
6//! starts). incus' own `default` project is never an org: it holds the
7//! plain sandboxes `isb create` and `isb up` make without `--org`.
8
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{Error, Result};
14
15/// A validated org name: `[a-z][a-z0-9-]{0,30}`, not ending in `-`.
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
17#[serde(try_from = "String", into = "String")]
18pub struct OrgId(String);
19
20pub const DEFAULT_ORG: &str = "default";
21/// The incus project of the default org.
22pub const DEFAULT_ORG_PROJECT: &str = "isb-default";
23
24/// Not an org: `isb-system` is [`crate::registry::PROJECT`].
25const RESERVED_SYSTEM: &str = "system";
26
27impl OrgId {
28    pub fn new(s: impl Into<String>) -> Result<OrgId> {
29        let s = s.into();
30        let ok = !s.is_empty()
31            && s.len() <= 31
32            && s.starts_with(|c: char| c.is_ascii_lowercase())
33            && !s.ends_with('-')
34            && s.chars()
35                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
36        if s == RESERVED_SYSTEM {
37            Err(Error::invalid(
38                "org name \"system\" is reserved: incus project isb-system holds isb's own services",
39            ))
40        } else if ok {
41            Ok(OrgId(s))
42        } else {
43            Err(Error::invalid(format!(
44                "org name {s:?}: up to 31 characters of [a-z0-9-], starting with a letter"
45            )))
46        }
47    }
48
49    pub fn default_org() -> OrgId {
50        OrgId(DEFAULT_ORG.into())
51    }
52
53    pub fn as_str(&self) -> &str {
54        &self.0
55    }
56
57    pub fn is_default(&self) -> bool {
58        self.0 == DEFAULT_ORG
59    }
60
61    /// The incus project holding this org: `isb-<org>`.
62    pub fn incus_project(&self) -> String {
63        format!("isb-{}", self.0)
64    }
65
66    /// The org a project belongs to, if it is one of isb's. incus' own
67    /// `default` project is none.
68    pub fn from_incus_project(project: &str) -> Option<OrgId> {
69        project
70            .strip_prefix("isb-")
71            .and_then(|o| OrgId::new(o).ok())
72    }
73
74    /// This org's directory under a daemon state directory.
75    pub fn dir(&self, state: &Path) -> PathBuf {
76        state.join("orgs").join(&self.0)
77    }
78}
79
80impl std::fmt::Display for OrgId {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        f.write_str(&self.0)
83    }
84}
85
86impl TryFrom<String> for OrgId {
87    type Error = Error;
88    fn try_from(s: String) -> Result<OrgId> {
89        OrgId::new(s)
90    }
91}
92
93impl From<OrgId> for String {
94    fn from(o: OrgId) -> String {
95        o.0
96    }
97}
98
99// ----------------------------------------------------------------------------
100// The org runtime: an incus project, a bridge, an ACL and a default profile.
101// ----------------------------------------------------------------------------
102
103use crate::client::{Client, encode_segment};
104use serde_json::{Value, json};
105use std::collections::BTreeMap;
106
107mod ensure;
108mod homes;
109mod names;
110pub use ensure::{Names, ensure_service_names};
111pub use names::{ensure_all_service_names, ensure_default};
112pub(crate) mod limits;
113pub use limits::{Budget, DEFAULT_ROOT_SIZE, Limit, bytes as format_bytes};
114pub mod nesting;
115mod udp;
116pub use udp::{allowed_udp, check_proxies, check_udp_port};
117
118pub use ensure::ensure;
119pub use homes::allow_home;
120
121/// Config keys isb keeps on the org's project.
122const KEY_ORG: &str = "user.isb.org";
123const KEY_NETWORK: &str = "user.isb.network";
124const KEY_EGRESS: &str = "user.isb.egress";
125const KEY_DOMAINS: &str = "user.isb.domains";
126const KEY_INGRESS: &str = "user.isb.ingress";
127const KEY_CF_ACCOUNT: &str = "user.isb.ingress.cloudflare.account";
128const KEY_CF_ZONE: &str = "user.isb.ingress.cloudflare.zone";
129const KEY_UDP: &str = "user.isb.udp";
130
131/// How an org's domains reach it: Caddy's public listeners (default) or the
132/// org's own Cloudflare Tunnel.
133pub const INGRESS_CADDY: &str = "caddy";
134pub const INGRESS_CLOUDFLARE_TUNNEL: &str = "cloudflare-tunnel";
135
136/// Check an allowlist entry: a domain suffix (`example.com`), or
137/// `*.example.com` to allow wildcard hosts under it too.
138pub fn check_domain_suffix(s: &str) -> Result<String> {
139    let s = s.trim().to_ascii_lowercase();
140    let base = s.strip_prefix("*.").unwrap_or(&s);
141    if base.starts_with("*.") {
142        return Err(Error::invalid(format!(
143            "--allow-domain {s:?}: one * at most"
144        )));
145    }
146    crate::ingress::domain::check_host(base)
147        .map_err(|e| Error::invalid(format!("--allow-domain {s:?}: {e}")))?;
148    Ok(s)
149}
150
151/// Limits for an org as a whole (the incus project's limits) and the
152/// defaults each instance gets when its spec sets none.
153#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
154pub struct OrgOptions {
155    /// Total CPUs across the org's instances.
156    pub cpus: Option<u32>,
157    /// Total memory, e.g. `16GiB`.
158    pub memory: Option<String>,
159    /// Total disk, e.g. `100GiB`.
160    pub disk: Option<String>,
161    pub instances: Option<u32>,
162    /// Limits to remove, so the org is no longer limited by them.
163    #[serde(default, skip_serializing_if = "Vec::is_empty")]
164    pub lift: Vec<Limit>,
165    /// Per-instance defaults (incus requires one once the project is limited).
166    pub default_cpus: Option<u32>,
167    pub default_memory: Option<String>,
168    /// Host directories the org's instances may bind-mount from.
169    pub bind_roots: Vec<PathBuf>,
170    /// Private destinations the org may reach despite the default deny.
171    /// `None` keeps what the org has; `Some` replaces it.
172    pub egress: Option<Vec<Egress>>,
173    /// Domain suffixes the org's services may serve; `Some(empty)` clears,
174    /// `None` keeps.
175    pub domains: Option<Vec<String>>,
176    /// `caddy` or `cloudflare-tunnel`; `None` keeps.
177    pub ingress: Option<String>,
178    /// Cloudflare account and zone ids for the tunnel provider's API calls (`Some("")` clears).
179    pub cloudflare_account: Option<String>,
180    pub cloudflare_zone: Option<String>,
181    /// UDP ports (`IP:PORT`) the org's stacks may publish ([`allowed_udp`]);
182    /// `Some(empty)` clears, `None` keeps.
183    pub udp: Option<Vec<std::net::SocketAddr>>,
184}
185
186/// An org as it exists in incus.
187#[derive(Debug, Clone, Serialize)]
188pub struct OrgInfo {
189    pub name: OrgId,
190    pub project: String,
191    /// The org's bridge, `None` for the default org.
192    pub network: Option<String>,
193    /// The bridge's IPv4 address, e.g. `10.64.3.1/24`.
194    pub subnet: Option<String>,
195    pub cpus: Option<String>,
196    pub memory: Option<String>,
197    pub disk: Option<String>,
198    pub instances_limit: Option<String>,
199    /// What an instance gets when its spec sets no limits (the org's default profile).
200    pub default_cpus: Option<String>,
201    pub default_memory: Option<String>,
202    /// The root disk size it gets: set while the org has a disk limit.
203    pub default_disk: Option<String>,
204    /// Each limit's budget (`cpu`, `memory`, `disk`, `instances`): what
205    /// every instance's limit adds up to, stopped ones included.
206    pub allocation: BTreeMap<String, Budget>,
207    pub bind_roots: Vec<String>,
208    /// Egress exceptions, as `isb org create --allow-egress` takes them.
209    pub egress: Vec<String>,
210    /// Domain suffixes its services may serve (empty: any concrete name).
211    pub domains: Vec<String>,
212    /// `caddy` or `cloudflare-tunnel`.
213    pub ingress: String,
214    /// UDP ports (`IP:PORT`) its stacks may publish, as a platform admin
215    /// allowed them.
216    pub udp: Vec<String>,
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub cloudflare_account: Option<String>,
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub cloudflare_zone: Option<String>,
221    /// The hosts directory the org's dnsmasq reads service names from, when
222    /// service discovery is on.
223    pub dns_dir: Option<String>,
224    /// Instances in the org right now.
225    pub instances: usize,
226    /// Its workspace may run Docker (`security.nesting`): [`nesting`].
227    pub allow_nesting: bool,
228}
229
230/// The bridge for an org: `isbbr` + 8 hex digits of the name's hash, inside
231/// the kernel's 15-character limit on interface names.
232pub fn bridge_name(org: &OrgId) -> String {
233    let mut h: u32 = 0x811c9dc5;
234    for b in org.as_str().bytes() {
235        h ^= b as u32;
236        h = h.wrapping_mul(0x01000193);
237    }
238    format!("isbbr{h:08x}")
239}
240
241fn acl_name(org: &OrgId) -> String {
242    format!("isb-{org}")
243}
244
245/// Private ranges an org may not reach, apart from its own subnet.
246const PRIVATE: [&str; 5] = [
247    "10.0.0.0/8",
248    "172.16.0.0/12",
249    "192.168.0.0/16",
250    "100.64.0.0/10",
251    "169.254.0.0/16",
252];
253
254fn parse_cidr(s: &str) -> Option<(u32, u32)> {
255    let (ip, len) = s.split_once('/')?;
256    let ip: std::net::Ipv4Addr = ip.parse().ok()?;
257    let len: u32 = len.parse().ok().filter(|l| *l <= 32)?;
258    let mask = if len == 0 { 0 } else { u32::MAX << (32 - len) };
259    Some((u32::from(ip) & mask, len))
260}
261
262fn mask(len: u32) -> u32 {
263    if len == 0 { 0 } else { u32::MAX << (32 - len) }
264}
265
266fn fmt_cidr(c: (u32, u32)) -> String {
267    format!("{}/{}", std::net::Ipv4Addr::from(c.0), c.1)
268}
269
270/// Whether two CIDRs share an address (then one contains the other).
271fn overlaps(a: (u32, u32), b: (u32, u32)) -> bool {
272    let l = a.1.min(b.1);
273    a.0 & mask(l) == b.0 & mask(l)
274}
275
276/// `range` minus `hole`, as CIDRs: halve the range until the hole is
277/// carved out exactly.
278fn subtract(range: (u32, u32), hole: (u32, u32), out: &mut Vec<(u32, u32)>) {
279    let (net, len) = range;
280    if !overlaps(range, hole) {
281        out.push(range);
282    } else if hole.1 > len {
283        let half = 1u32 << (31 - len);
284        subtract((net, len + 1), hole, out);
285        subtract((net | half, len + 1), hole, out);
286    }
287    // Otherwise the hole covers the whole range: nothing is left of it.
288}
289
290/// What an org's ACL rejects: every private range minus the holes (its own
291/// subnet and its egress exceptions). incus applies reject rules before
292/// allow rules, so an exception has to be carved out of the ranges rather
293/// than allowed on top of them.
294fn denied_ranges(holes: &[(u32, u32)]) -> Vec<String> {
295    let mut ranges: Vec<(u32, u32)> = PRIVATE
296        .iter()
297        .map(|r| parse_cidr(r).expect("constant"))
298        .collect();
299    for h in holes {
300        let mut next = Vec::new();
301        for r in ranges {
302            subtract(r, *h, &mut next);
303        }
304        ranges = next;
305    }
306    ranges.into_iter().map(fmt_cidr).collect()
307}
308
309/// An egress exception: a private destination an org may reach despite the
310/// default deny. Written `CIDR[:PORTS[/PROTO]]`: `10.1.2.0/24` (everything
311/// there), `100.79.171.47:1080` (one TCP port), `10.1.2.3:53/udp`,
312/// `10.1.2.3:8000-8100,9000/tcp`. A bare address is a /32.
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314#[serde(try_from = "String", into = "String")]
315pub struct Egress {
316    net: (u32, u32),
317    /// `None`: every port and protocol.
318    ports: Option<(Proto, Vec<(u16, u16)>)>,
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
322enum Proto {
323    Tcp,
324    Udp,
325}
326
327impl Proto {
328    fn as_str(self) -> &'static str {
329        match self {
330            Proto::Tcp => "tcp",
331            Proto::Udp => "udp",
332        }
333    }
334}
335
336impl Egress {
337    pub fn parse(s: &str) -> Result<Egress> {
338        let bad = |why: &str| {
339            Error::invalid(format!(
340                "egress exception {s:?}: {why} (want CIDR[:PORTS[/tcp|udp]], e.g. 100.79.171.47/32:1080/tcp)"
341            ))
342        };
343        let (addr, rest) = match s.split_once(':') {
344            Some((a, r)) => (a, Some(r)),
345            None => (s, None),
346        };
347        let addr = if addr.contains('/') {
348            addr.to_string()
349        } else {
350            format!("{addr}/32")
351        };
352        let net = parse_cidr(&addr).ok_or_else(|| bad("not an IPv4 address or CIDR"))?;
353        let ports = match rest {
354            None => None,
355            Some(r) => {
356                let (list, proto) = match r.split_once('/') {
357                    Some((l, "tcp")) => (l, Proto::Tcp),
358                    Some((l, "udp")) => (l, Proto::Udp),
359                    Some(_) => return Err(bad("the protocol must be tcp or udp")),
360                    None => (r, Proto::Tcp),
361                };
362                let mut ranges = Vec::new();
363                for p in list.split(',') {
364                    let (a, b) = p.split_once('-').unwrap_or((p, p));
365                    let a: u16 = a.parse().map_err(|_| bad("bad port"))?;
366                    let b: u16 = b.parse().map_err(|_| bad("bad port"))?;
367                    if a == 0 || b < a {
368                        return Err(bad("bad port range"));
369                    }
370                    ranges.push((a, b));
371                }
372                Some((proto, merge_ports(ranges)))
373            }
374        };
375        Ok(Egress { net, ports })
376    }
377
378    /// The canonical spelling, as stored on the org.
379    pub fn render(&self) -> String {
380        let mut s = fmt_cidr(self.net);
381        if let Some((proto, ranges)) = &self.ports {
382            s.push(':');
383            s.push_str(&fmt_ports(ranges));
384            s.push('/');
385            s.push_str(proto.as_str());
386        }
387        s
388    }
389}
390
391impl std::fmt::Display for Egress {
392    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
393        f.write_str(&self.render())
394    }
395}
396
397impl TryFrom<String> for Egress {
398    type Error = Error;
399    fn try_from(s: String) -> Result<Egress> {
400        Egress::parse(&s)
401    }
402}
403
404impl From<Egress> for String {
405    fn from(e: Egress) -> String {
406        e.render()
407    }
408}
409
410/// Exceptions as stored in `user.isb.egress`: space-separated.
411fn parse_egress_list(s: &str) -> Vec<Egress> {
412    s.split_whitespace()
413        .filter_map(|e| Egress::parse(e).ok())
414        .collect()
415}
416
417fn merge_ports(mut r: Vec<(u16, u16)>) -> Vec<(u16, u16)> {
418    r.sort();
419    let mut out: Vec<(u16, u16)> = Vec::new();
420    for (a, b) in r {
421        match out.last_mut() {
422            Some(l) if a as u32 <= l.1 as u32 + 1 => l.1 = l.1.max(b),
423            _ => out.push((a, b)),
424        }
425    }
426    out
427}
428
429/// Ports 1-65535 not in `r` (merged and sorted).
430fn complement_ports(r: &[(u16, u16)]) -> Vec<(u16, u16)> {
431    let mut out = Vec::new();
432    let mut next: u32 = 1;
433    for &(a, b) in r {
434        if (a as u32) > next {
435            out.push((next as u16, a - 1));
436        }
437        next = b as u32 + 1;
438    }
439    if next <= 65535 {
440        out.push((next as u16, 65535));
441    }
442    out
443}
444
445fn fmt_ports(r: &[(u16, u16)]) -> String {
446    r.iter()
447        .map(|&(a, b)| {
448            if a == b {
449                a.to_string()
450            } else {
451                format!("{a}-{b}")
452            }
453        })
454        .collect::<Vec<_>>()
455        .join(",")
456}
457
458/// Exceptions for different networks must not overlap: a port-limited one
459/// would otherwise cut into the other. The same network may repeat (its ports add up).
460pub fn check_egress(rules: &[Egress]) -> Result<()> {
461    for (i, a) in rules.iter().enumerate() {
462        for b in &rules[i + 1..] {
463            if a.net != b.net && overlaps(a.net, b.net) {
464                return Err(Error::invalid(format!(
465                    "egress exceptions {a} and {b} overlap; use the same network for both"
466                )));
467            }
468        }
469    }
470    Ok(())
471}
472
473/// The org ACL's egress rules. Reject the private ranges minus the org's
474/// subnet and every exception's network; then, since incus orders rejects
475/// before allows whatever the rules say, limit a port-specific exception by
476/// rejecting the rest of its network's TCP and UDP ports, and ICMP. Other IP
477/// protocols to such a network are not filtered.
478fn egress_rules(own: Option<(u32, u32)>, egress: &[Egress]) -> Result<Vec<Value>> {
479    check_egress(egress)?;
480    // An exception outside the private ranges is allowed anyway.
481    let private: Vec<(u32, u32)> = PRIVATE
482        .iter()
483        .map(|r| parse_cidr(r).expect("constant"))
484        .collect();
485    let egress: Vec<&Egress> = egress
486        .iter()
487        .filter(|e| private.iter().any(|p| overlaps(*p, e.net)))
488        .collect();
489    let mut holes: Vec<(u32, u32)> = own.into_iter().collect();
490    holes.extend(egress.iter().map(|e| e.net));
491    let mut out = vec![json!({
492        "action": "reject",
493        "destination": denied_ranges(&holes).join(","),
494        "state": "enabled",
495        "description": "other orgs and private networks",
496    })];
497    // Per network: `None` = everything allowed, else the allowed ports.
498    type Allowed = Option<BTreeMap<Proto, Vec<(u16, u16)>>>;
499    let mut nets: BTreeMap<(u32, u32), Allowed> = BTreeMap::new();
500    for e in egress {
501        let slot = nets.entry(e.net).or_insert_with(|| Some(BTreeMap::new()));
502        match (&e.ports, slot.as_mut()) {
503            (None, _) => *slot = None,
504            (Some((p, r)), Some(m)) => m.entry(*p).or_default().extend(r.iter().copied()),
505            (Some(_), None) => {}
506        }
507    }
508    for (net, allowed) in nets {
509        let Some(allowed) = allowed else { continue };
510        let dest = fmt_cidr(net);
511        for proto in [Proto::Tcp, Proto::Udp] {
512            let mut rule = json!({
513                "action": "reject",
514                "destination": dest,
515                "protocol": proto.as_str(),
516                "state": "enabled",
517                "description": format!("egress exception {dest}: other {} ports", proto.as_str()),
518            });
519            if let Some(r) = allowed.get(&proto) {
520                let rest = complement_ports(&merge_ports(r.clone()));
521                if rest.is_empty() {
522                    continue;
523                }
524                rule["destination_port"] = json!(fmt_ports(&rest));
525            }
526            out.push(rule);
527        }
528        out.push(json!({
529            "action": "reject",
530            "destination": dest,
531            "protocol": "icmp4",
532            "state": "enabled",
533            "description": format!("egress exception {dest}: ICMP"),
534        }));
535    }
536    Ok(out)
537}
538
539/// The client to use for an org: its project.
540pub fn client(base: &Client, org: &OrgId) -> Client {
541    base.clone().project(org.incus_project())
542}
543
544/// A client on the default project, for host-wide objects (networks, ACLs, projects).
545fn host(base: &Client) -> Client {
546    base.clone().project("default")
547}
548
549fn strmap(v: &Value) -> std::collections::BTreeMap<String, String> {
550    v.as_object()
551        .map(|m| {
552            m.iter()
553                .map(|(k, v)| {
554                    (
555                        k.clone(),
556                        v.as_str()
557                            .map(String::from)
558                            .unwrap_or_else(|| v.to_string()),
559                    )
560                })
561                .collect()
562        })
563        .unwrap_or_default()
564}
565
566/// The network part of a CIDR address: `10.64.3.1/24` -> `10.64.3.0/24`.
567fn subnet_of(cidr: &str) -> Option<String> {
568    let (ip, len) = cidr.split_once('/')?;
569    let ip: std::net::Ipv4Addr = ip.parse().ok()?;
570    let len: u32 = len.parse().ok()?;
571    if len > 32 {
572        return None;
573    }
574    let mask = if len == 0 { 0 } else { u32::MAX << (32 - len) };
575    Some(format!(
576        "{}/{len}",
577        std::net::Ipv4Addr::from(u32::from(ip) & mask)
578    ))
579}
580
581fn info(base: &Client, org: OrgId, p: &Value) -> Result<OrgInfo> {
582    let cfg = strmap(&p["config"]);
583    let network = cfg.get(KEY_NETWORK).cloned();
584    let net = match &network {
585        Some(n) => host(base).get_opt(&format!("/1.0/networks/{}", encode_segment(n)))?,
586        None => None,
587    };
588    let subnet = net
589        .as_ref()
590        .and_then(|v| v["config"]["ipv4.address"].as_str().map(String::from));
591    let dns_dir = net.as_ref().and_then(|v| {
592        v["config"]["raw.dnsmasq"]
593            .as_str()?
594            .lines()
595            .find_map(|l| l.trim().strip_prefix("hostsdir=").map(String::from))
596    });
597    let oc = client(base, &org);
598    let instances = oc
599        .get("/1.0/instances")?
600        .as_array()
601        .map(|a| a.len())
602        .unwrap_or(0);
603    let profile = oc.get_opt("/1.0/profiles/default")?.unwrap_or_default();
604    let defaults = strmap(&profile["config"]);
605    let allocation = limits::read_budgets(base, &org.incus_project());
606    Ok(OrgInfo {
607        default_disk: profile["devices"]["root"]["size"]
608            .as_str()
609            .map(String::from),
610        allocation,
611        project: org.incus_project(),
612        name: org,
613        network,
614        subnet,
615        cpus: cfg.get("limits.cpu").cloned(),
616        memory: cfg.get("limits.memory").cloned(),
617        disk: cfg.get("limits.disk").cloned(),
618        instances_limit: cfg.get("limits.instances").cloned(),
619        default_cpus: defaults.get("limits.cpu").cloned(),
620        default_memory: defaults.get("limits.memory").cloned(),
621        bind_roots: cfg
622            .get("restricted.devices.disk.paths")
623            .map(|s| {
624                s.split(',')
625                    .filter(|x| !x.is_empty())
626                    .map(String::from)
627                    .collect()
628            })
629            .unwrap_or_default(),
630        egress: cfg
631            .get(KEY_EGRESS)
632            .map(|s| s.split_whitespace().map(String::from).collect())
633            .unwrap_or_default(),
634        domains: cfg
635            .get(KEY_DOMAINS)
636            .map(|s| s.split_whitespace().map(String::from).collect())
637            .unwrap_or_default(),
638        ingress: cfg
639            .get(KEY_INGRESS)
640            .filter(|s| !s.is_empty())
641            .cloned()
642            .unwrap_or_else(|| INGRESS_CADDY.to_string()),
643        udp: udp::parse_list(cfg.get(KEY_UDP).map(String::as_str).unwrap_or_default())
644            .iter()
645            .map(ToString::to_string)
646            .collect(),
647        cloudflare_account: cfg.get(KEY_CF_ACCOUNT).filter(|s| !s.is_empty()).cloned(),
648        cloudflare_zone: cfg.get(KEY_CF_ZONE).filter(|s| !s.is_empty()).cloned(),
649        dns_dir,
650        instances,
651        allow_nesting: nesting::allowed(&p["config"]),
652    })
653}
654
655/// One org.
656pub fn get(base: &Client, org: &OrgId) -> Result<OrgInfo> {
657    let h = host(base);
658    let p = h
659        .get_opt(&format!(
660            "/1.0/projects/{}",
661            encode_segment(&org.incus_project())
662        ))?
663        .ok_or_else(|| Error::NotFound(format!("org {org}")))?;
664    if p["config"][KEY_ORG].as_str() != Some(org.as_str()) {
665        return Err(Error::NotFound(format!("org {org}")));
666    }
667    info(base, org.clone(), &p)
668}
669
670/// `Ok` when `org` exists here, else the same "org X not found" as
671/// [`get`], without reading the rest of it. For a tool to check before it
672/// acts, so an unknown org is refused up front instead of failing halfway
673/// on an incus error about a missing project.
674pub fn check_exists(base: &Client, org: &OrgId) -> Result<()> {
675    let p = host(base).get_opt(&format!(
676        "/1.0/projects/{}",
677        encode_segment(&org.incus_project())
678    ))?;
679    match p {
680        Some(p) if p["config"][KEY_ORG].as_str() == Some(org.as_str()) => Ok(()),
681        _ => Err(Error::NotFound(format!("org {org}"))),
682    }
683}
684
685/// Every org: the default one first, then isb's projects by name.
686pub fn list(base: &Client) -> Result<Vec<OrgInfo>> {
687    let h = host(base);
688    let v = h.get("/1.0/projects?recursion=1")?;
689    let mut out = Vec::new();
690    for p in v.as_array().into_iter().flatten() {
691        let name = p["name"].as_str().unwrap_or_default();
692        let Some(org) = OrgId::from_incus_project(name) else {
693            continue;
694        };
695        if p["config"][KEY_ORG].as_str() != Some(org.as_str()) {
696            continue;
697        }
698        out.push(info(base, org, p)?);
699    }
700    out.sort_by(|a, b| (!a.name.is_default(), &a.name).cmp(&(!b.name.is_default(), &b.name)));
701    Ok(out)
702}
703
704/// Delete an org: its project (with `force`, everything in it), its network
705/// and its ACL. Refuses a non-empty org without `force`.
706pub fn remove(base: &Client, org: &OrgId, force: bool, report: &mut dyn FnMut(&str)) -> Result<()> {
707    if org.is_default() {
708        return Err(Error::invalid("the default org cannot be removed"));
709    }
710    let o = get(base, org)?;
711    if o.instances > 0 && !force {
712        return Err(Error::invalid(format!(
713            "org {org} has {} instance(s); remove them, or pass force",
714            o.instances
715        )));
716    }
717    let h = host(base);
718    let oc = client(base, org);
719    for name in oc
720        .get("/1.0/instances")?
721        .as_array()
722        .into_iter()
723        .flatten()
724        .filter_map(Value::as_str)
725    {
726        // `/1.0/instances/<name>?project=<project>`
727        let n = name.rsplit('/').next().unwrap_or(name);
728        let n = n.split('?').next().unwrap_or(n);
729        report(&format!("{org}: deleting {n}"));
730        crate::sandbox::Sandbox::remove(&oc, n, true)?;
731    }
732    report(&format!("{org}: deleting project {}", o.project));
733    // force also takes the org's volumes, profiles and buckets with it.
734    h.mutate(
735        "DELETE",
736        &format!("/1.0/projects/{}?force=true", encode_segment(&o.project)),
737        None,
738        &format!("delete project {}", o.project),
739        h.get_timeouts().other,
740    )?;
741    if let Some(n) = &o.network {
742        report(&format!("{org}: deleting network {n}"));
743        match h.mutate(
744            "DELETE",
745            &format!("/1.0/networks/{}", encode_segment(n)),
746            None,
747            &format!("delete network {n}"),
748            h.get_timeouts().other,
749        ) {
750            Err(e) if !e.is_not_found() => return Err(e),
751            _ => {}
752        }
753    }
754    crate::discovery::remove_org(org);
755    let acl = acl_name(org);
756    match h.mutate(
757        "DELETE",
758        &format!("/1.0/network-acls/{}", encode_segment(&acl)),
759        None,
760        &format!("delete ACL {acl}"),
761        h.get_timeouts().other,
762    ) {
763        Err(e) if !e.is_not_found() => Err(e),
764        _ => Ok(()),
765    }
766}
767
768#[cfg(test)]
769mod tests {
770
771    #[test]
772    fn an_unknown_org_is_not_found_up_front() {
773        use crate::client::fake::{Route, serve};
774        let (_d, c) = serve(vec![
775            Route {
776                prefix: "GET /1.0/projects/isb-lab",
777                status: 200,
778                body: json!({"config": {KEY_ORG: "lab"}}),
779            },
780            // Another tool's project that happens to look like one.
781            Route {
782                prefix: "GET /1.0/projects/isb-other",
783                status: 200,
784                body: json!({"config": {}}),
785            },
786        ]);
787        assert!(check_exists(&c, &OrgId::new("lab").unwrap()).is_ok());
788        for o in ["demo", "other"] {
789            let e = check_exists(&c, &OrgId::new(o).unwrap()).unwrap_err();
790            assert!(e.is_not_found(), "{e}");
791            assert_eq!(e.to_string(), format!("org {o} not found"));
792        }
793    }
794
795    #[test]
796    fn the_default_org_is_isb_default_and_incus_default_is_no_org() {
797        let d = OrgId::default_org();
798        assert_eq!(d.incus_project(), DEFAULT_ORG_PROJECT);
799        assert_eq!(OrgId::from_incus_project("isb-default"), Some(d));
800        assert_eq!(OrgId::from_incus_project("default"), None);
801    }
802
803    use super::*;
804
805    #[test]
806    fn names_and_projects() {
807        assert!(OrgId::new("ocai").is_ok());
808        assert!(OrgId::new("Ocai").is_err());
809        assert!(OrgId::new("a-").is_err());
810        assert!(OrgId::new("x".repeat(32)).is_err());
811        assert!(OrgId::new("system").is_err());
812        assert_eq!(OrgId::from_incus_project(crate::registry::PROJECT), None);
813        let o = OrgId::new("ocai").unwrap();
814        assert_eq!(o.incus_project(), "isb-ocai");
815        assert_eq!(OrgId::default_org().incus_project(), "isb-default");
816        assert_eq!(OrgId::from_incus_project("isb-ocai"), Some(o));
817        assert_eq!(OrgId::from_incus_project("titan-ocai-ct"), None);
818        let j: OrgId = serde_json::from_str("\"norm\"").unwrap();
819        assert_eq!(j.as_str(), "norm");
820        assert!(serde_json::from_str::<OrgId>("\"Bad Name\"").is_err());
821    }
822
823    #[test]
824    fn bridges_and_subnets() {
825        let b = bridge_name(&OrgId::new("a-very-long-org-name-indeed").unwrap());
826        assert!(b.len() <= 15 && b.starts_with("isbbr"), "{b}");
827        assert_ne!(b, bridge_name(&OrgId::new("other").unwrap()));
828        assert_eq!(subnet_of("10.64.3.1/24").as_deref(), Some("10.64.3.0/24"));
829        assert_eq!(subnet_of("10.180.0.1/16").as_deref(), Some("10.180.0.0/16"));
830        assert_eq!(subnet_of("nope"), None);
831    }
832
833    #[test]
834    fn denied_ranges_carve_out_the_org() {
835        let d = denied_ranges(&[parse_cidr("10.160.44.0/24").unwrap()]);
836        assert!(!d.iter().any(|r| r == "10.0.0.0/8"));
837        assert!(d.contains(&"172.16.0.0/12".to_string()));
838        // 16 halvings from /8 to /24: 16 pieces plus the other 4 ranges.
839        assert_eq!(d.len(), 16 + 4);
840        let covers = |r: &str, ip: u32| {
841            let (n, l) = parse_cidr(r).unwrap();
842            let m = if l == 0 { 0 } else { u32::MAX << (32 - l) };
843            ip & m == n
844        };
845        let ip = |s: &str| u32::from(s.parse::<std::net::Ipv4Addr>().unwrap());
846        assert!(!d.iter().any(|r| covers(r, ip("10.160.44.7"))));
847        for other in [
848            "10.160.45.1",
849            "10.0.0.1",
850            "10.255.255.254",
851            "10.238.212.250",
852        ] {
853            assert!(d.iter().any(|r| covers(r, ip(other))), "{other}");
854        }
855        assert_eq!(denied_ranges(&[]).len(), 5);
856        // A hole that covers a whole range removes it.
857        assert_eq!(denied_ranges(&[parse_cidr("10.0.0.0/7").unwrap()]).len(), 4);
858    }
859
860    fn covered(ranges: &str, ip: &str) -> bool {
861        let ip = u32::from(ip.parse::<std::net::Ipv4Addr>().unwrap());
862        ranges.split(',').any(|r| {
863            let (n, l) = parse_cidr(r).unwrap();
864            ip & mask(l) == n
865        })
866    }
867
868    #[test]
869    fn egress_parses_and_renders() {
870        let e = Egress::parse("100.79.171.47/32:1080/tcp").unwrap();
871        assert_eq!(e.render(), "100.79.171.47/32:1080/tcp");
872        assert_eq!(
873            Egress::parse("100.79.171.47:1080").unwrap(),
874            e,
875            "a bare address is a /32 and tcp is the default"
876        );
877        assert_eq!(
878            Egress::parse("10.1.2.9/24").unwrap().render(),
879            "10.1.2.0/24"
880        );
881        assert_eq!(
882            Egress::parse("10.1.2.3:9000,8000-8100,8050/udp")
883                .unwrap()
884                .render(),
885            "10.1.2.3/32:8000-8100,9000/udp"
886        );
887        for bad in [
888            "db.example.com:5432",
889            "10.1.2.3:0",
890            "10.1.2.3:90-80",
891            "10.1.2.3:80/sctp",
892            "10.1.2.3/33",
893            "10.1.2.3:http",
894        ] {
895            assert!(Egress::parse(bad).is_err(), "{bad}");
896        }
897        let j: Vec<Egress> = serde_json::from_str("[\"10.0.0.1:22\"]").unwrap();
898        assert_eq!(
899            serde_json::to_string(&j).unwrap(),
900            "[\"10.0.0.1/32:22/tcp\"]"
901        );
902        assert_eq!(
903            parse_egress_list("10.0.0.1/32:22/tcp  10.2.0.0/16"),
904            vec![
905                Egress::parse("10.0.0.1:22").unwrap(),
906                Egress::parse("10.2.0.0/16").unwrap()
907            ]
908        );
909    }
910
911    #[test]
912    fn ports_complement() {
913        assert_eq!(
914            complement_ports(&[(1080, 1080)]),
915            vec![(1, 1079), (1081, 65535)]
916        );
917        assert_eq!(
918            complement_ports(&[(1, 10), (65535, 65535)]),
919            vec![(11, 65534)]
920        );
921        assert_eq!(complement_ports(&[(1, 65535)]), vec![]);
922        assert_eq!(
923            merge_ports(vec![(5, 9), (1, 4), (20, 30), (25, 40)]),
924            vec![(1, 9), (20, 40)]
925        );
926    }
927
928    #[test]
929    fn egress_exceptions_in_the_acl() {
930        let own = parse_cidr("10.160.44.0/24");
931        let whole = Egress::parse("10.20.0.0/16").unwrap();
932        let port = Egress::parse("100.79.171.47:1080").unwrap();
933        let udp = Egress::parse("100.79.171.47:53/udp").unwrap();
934        let public = Egress::parse("8.8.8.8:53/udp").unwrap();
935        let rules = egress_rules(own, &[whole, port, udp, public]).unwrap();
936        let deny = rules[0]["destination"].as_str().unwrap();
937        // Carved out: the org, the whole exception, the port-limited host.
938        assert!(!covered(deny, "10.160.44.9"));
939        assert!(!covered(deny, "10.20.200.1"));
940        assert!(!covered(deny, "100.79.171.47"));
941        // Still denied around them.
942        for ip in ["10.21.0.1", "100.79.171.46", "100.79.171.48", "192.168.1.1"] {
943            assert!(covered(deny, ip), "{ip}");
944        }
945        // The port-limited host: every other TCP and UDP port, and ICMP. The
946        // public exception and the whole network add nothing.
947        let rest: Vec<(String, String, String)> = rules[1..]
948            .iter()
949            .map(|r| {
950                (
951                    r["destination"].as_str().unwrap().to_string(),
952                    r["protocol"].as_str().unwrap().to_string(),
953                    r["destination_port"].as_str().unwrap_or("").to_string(),
954                )
955            })
956            .collect();
957        let h = "100.79.171.47/32".to_string();
958        assert_eq!(
959            rest,
960            vec![
961                (h.clone(), "tcp".into(), "1-1079,1081-65535".into()),
962                (h.clone(), "udp".into(), "1-52,54-65535".into()),
963                (h, "icmp4".into(), String::new()),
964            ]
965        );
966        assert!(rules.iter().all(|r| r["action"] == "reject"));
967
968        // A port-limited exception with no UDP rejects all of UDP.
969        let rules = egress_rules(own, &[Egress::parse("10.9.9.9:5432").unwrap()]).unwrap();
970        assert_eq!(rules[2]["protocol"], "udp");
971        assert!(rules[2].get("destination_port").is_none());
972        // The same network once whole and once by port is whole.
973        let rules = egress_rules(
974            own,
975            &[
976                Egress::parse("10.9.9.9:5432").unwrap(),
977                Egress::parse("10.9.9.9").unwrap(),
978            ],
979        )
980        .unwrap();
981        assert_eq!(rules.len(), 1);
982        // Different, overlapping networks are refused.
983        assert!(
984            egress_rules(
985                own,
986                &[
987                    Egress::parse("10.9.9.0/24:80").unwrap(),
988                    Egress::parse("10.9.9.9:443").unwrap()
989                ]
990            )
991            .is_err()
992        );
993    }
994}