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
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
122const 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
132pub const INGRESS_CADDY: &str = "caddy";
135pub const INGRESS_CLOUDFLARE_TUNNEL: &str = "cloudflare-tunnel";
136
137pub 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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
155pub struct OrgOptions {
156 pub cpus: Option<u32>,
158 pub memory: Option<String>,
160 pub disk: Option<String>,
162 pub instances: Option<u32>,
163 #[serde(default, skip_serializing_if = "Vec::is_empty")]
165 pub lift: Vec<Limit>,
166 pub default_cpus: Option<u32>,
168 pub default_memory: Option<String>,
169 pub bind_roots: Vec<PathBuf>,
171 pub egress: Option<Vec<Egress>>,
174 pub domains: Option<Vec<String>>,
177 pub ingress: Option<String>,
179 pub cloudflare_account: Option<String>,
181 pub cloudflare_zone: Option<String>,
182 pub udp: Option<Vec<std::net::SocketAddr>>,
185}
186
187#[derive(Debug, Clone, Serialize)]
189pub struct OrgInfo {
190 pub name: OrgId,
191 pub project: String,
192 pub network: Option<String>,
194 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 pub default_cpus: Option<String>,
202 pub default_memory: Option<String>,
203 pub default_disk: Option<String>,
205 pub allocation: BTreeMap<String, Budget>,
208 pub bind_roots: Vec<String>,
209 pub egress: Vec<String>,
211 pub domains: Vec<String>,
213 pub ingress: String,
215 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 pub dns_dir: Option<String>,
225 pub instances: usize,
227 pub allow_nesting: bool,
229}
230
231pub 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
246const 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
271fn 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
277fn 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 }
290
291fn 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
315#[serde(try_from = "String", into = "String")]
316pub struct Egress {
317 net: (u32, u32),
318 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 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
411fn 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
430fn 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
459pub 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
474fn egress_rules(own: Option<(u32, u32)>, egress: &[Egress]) -> Result<Vec<Value>> {
480 check_egress(egress)?;
481 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 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
540pub fn client(base: &Client, org: &OrgId) -> Client {
542 base.clone().project(org.incus_project())
543}
544
545fn 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
567fn 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
657pub 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
672pub 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
687pub 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
702pub 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 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 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 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 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 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 assert!(!covered(deny, "10.160.44.9"));
937 assert!(!covered(deny, "10.20.200.1"));
938 assert!(!covered(deny, "100.79.171.47"));
939 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 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 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 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 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}