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