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