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