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 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
120const 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
130pub const INGRESS_CADDY: &str = "caddy";
133pub const INGRESS_CLOUDFLARE_TUNNEL: &str = "cloudflare-tunnel";
134
135pub 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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
153pub struct OrgOptions {
154 pub cpus: Option<u32>,
156 pub memory: Option<String>,
158 pub disk: Option<String>,
160 pub instances: Option<u32>,
161 pub default_cpus: Option<u32>,
163 pub default_memory: Option<String>,
164 pub bind_roots: Vec<PathBuf>,
166 pub egress: Option<Vec<Egress>>,
169 pub domains: Option<Vec<String>>,
172 pub ingress: Option<String>,
174 pub cloudflare_account: Option<String>,
176 pub cloudflare_zone: Option<String>,
177 pub udp: Option<Vec<std::net::SocketAddr>>,
180}
181
182#[derive(Debug, Clone, Serialize)]
184pub struct OrgInfo {
185 pub name: OrgId,
186 pub project: String,
187 pub network: Option<String>,
189 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 pub default_cpus: Option<String>,
197 pub default_memory: Option<String>,
198 pub bind_roots: Vec<String>,
199 pub egress: Vec<String>,
201 pub domains: Vec<String>,
203 pub ingress: String,
205 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 pub dns_dir: Option<String>,
215 pub instances: usize,
217 pub allow_nesting: bool,
219}
220
221pub 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
236const 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
261fn 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
267fn 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 }
280
281fn 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
305#[serde(try_from = "String", into = "String")]
306pub struct Egress {
307 net: (u32, u32),
308 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 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
401fn 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
420fn 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
449pub 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
464fn egress_rules(own: Option<(u32, u32)>, egress: &[Egress]) -> Result<Vec<Value>> {
470 check_egress(egress)?;
471 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 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
530pub fn client(base: &Client, org: &OrgId) -> Client {
532 base.clone().project(org.incus_project())
533}
534
535fn 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
557fn 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
640pub 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
655pub 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
670pub 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
689pub 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 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 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 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 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 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 assert!(!covered(deny, "10.160.44.9"));
924 assert!(!covered(deny, "10.20.200.1"));
925 assert!(!covered(deny, "100.79.171.47"));
926 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 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 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 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 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}