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