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