use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct OrgId(String);
pub const DEFAULT_ORG: &str = "default";
pub const DEFAULT_ORG_PROJECT: &str = "isb-default";
const RESERVED_SYSTEM: &str = "system";
impl OrgId {
pub fn new(s: impl Into<String>) -> Result<OrgId> {
let s = s.into();
let ok = !s.is_empty()
&& s.len() <= 31
&& s.starts_with(|c: char| c.is_ascii_lowercase())
&& !s.ends_with('-')
&& s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
if s == RESERVED_SYSTEM {
Err(Error::invalid(
"org name \"system\" is reserved: incus project isb-system holds isb's own services",
))
} else if ok {
Ok(OrgId(s))
} else {
Err(Error::invalid(format!(
"org name {s:?}: up to 31 characters of [a-z0-9-], starting with a letter"
)))
}
}
pub fn default_org() -> OrgId {
OrgId(DEFAULT_ORG.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn is_default(&self) -> bool {
self.0 == DEFAULT_ORG
}
pub fn incus_project(&self) -> String {
format!("isb-{}", self.0)
}
pub fn from_incus_project(project: &str) -> Option<OrgId> {
project
.strip_prefix("isb-")
.and_then(|o| OrgId::new(o).ok())
}
pub fn dir(&self, state: &Path) -> PathBuf {
state.join("orgs").join(&self.0)
}
}
impl std::fmt::Display for OrgId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl TryFrom<String> for OrgId {
type Error = Error;
fn try_from(s: String) -> Result<OrgId> {
OrgId::new(s)
}
}
impl From<OrgId> for String {
fn from(o: OrgId) -> String {
o.0
}
}
use crate::client::{Client, encode_segment};
use serde_json::{Value, json};
use std::collections::BTreeMap;
pub mod disk;
mod ensure;
mod homes;
mod names;
pub use ensure::{Names, ensure_service_names};
pub use names::{ensure_all_service_names, ensure_default, names, of_project};
pub(crate) mod limits;
pub use limits::{Budget, DEFAULT_ROOT_SIZE, Limit, bytes as format_bytes};
pub mod nesting;
mod udp;
pub use udp::{allowed_udp, check_proxies, check_udp_port};
pub use ensure::ensure;
pub use homes::allow_home;
const KEY_ORG: &str = "user.isb.org";
const KEY_NETWORK: &str = "user.isb.network";
const KEY_EGRESS: &str = "user.isb.egress";
const KEY_DOMAINS: &str = "user.isb.domains";
const KEY_INGRESS: &str = "user.isb.ingress";
const KEY_CF_ACCOUNT: &str = "user.isb.ingress.cloudflare.account";
const KEY_CF_ZONE: &str = "user.isb.ingress.cloudflare.zone";
const KEY_UDP: &str = "user.isb.udp";
pub const INGRESS_CADDY: &str = "caddy";
pub const INGRESS_CLOUDFLARE_TUNNEL: &str = "cloudflare-tunnel";
pub fn check_domain_suffix(s: &str) -> Result<String> {
let s = s.trim().to_ascii_lowercase();
let base = s.strip_prefix("*.").unwrap_or(&s);
if base.starts_with("*.") {
return Err(Error::invalid(format!(
"--allow-domain {s:?}: one * at most"
)));
}
crate::ingress::domain::check_host(base)
.map_err(|e| Error::invalid(format!("--allow-domain {s:?}: {e}")))?;
Ok(s)
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct OrgOptions {
pub cpus: Option<u32>,
pub memory: Option<String>,
pub disk: Option<String>,
pub instances: Option<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lift: Vec<Limit>,
pub default_cpus: Option<u32>,
pub default_memory: Option<String>,
pub bind_roots: Vec<PathBuf>,
pub egress: Option<Vec<Egress>>,
pub domains: Option<Vec<String>>,
pub ingress: Option<String>,
pub cloudflare_account: Option<String>,
pub cloudflare_zone: Option<String>,
pub udp: Option<Vec<std::net::SocketAddr>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct OrgInfo {
pub name: OrgId,
pub project: String,
pub network: Option<String>,
pub subnet: Option<String>,
pub cpus: Option<String>,
pub memory: Option<String>,
pub disk: Option<String>,
pub instances_limit: Option<String>,
pub default_cpus: Option<String>,
pub default_memory: Option<String>,
pub default_disk: Option<String>,
pub allocation: BTreeMap<String, Budget>,
pub bind_roots: Vec<String>,
pub egress: Vec<String>,
pub domains: Vec<String>,
pub ingress: String,
pub udp: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cloudflare_account: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cloudflare_zone: Option<String>,
pub dns_dir: Option<String>,
pub instances: usize,
pub allow_nesting: bool,
}
pub fn bridge_name(org: &OrgId) -> String {
let mut h: u32 = 0x811c9dc5;
for b in org.as_str().bytes() {
h ^= b as u32;
h = h.wrapping_mul(0x01000193);
}
format!("isbbr{h:08x}")
}
fn acl_name(org: &OrgId) -> String {
format!("isb-{org}")
}
const PRIVATE: [&str; 5] = [
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"100.64.0.0/10",
"169.254.0.0/16",
];
fn parse_cidr(s: &str) -> Option<(u32, u32)> {
let (ip, len) = s.split_once('/')?;
let ip: std::net::Ipv4Addr = ip.parse().ok()?;
let len: u32 = len.parse().ok().filter(|l| *l <= 32)?;
let mask = if len == 0 { 0 } else { u32::MAX << (32 - len) };
Some((u32::from(ip) & mask, len))
}
fn mask(len: u32) -> u32 {
if len == 0 { 0 } else { u32::MAX << (32 - len) }
}
fn fmt_cidr(c: (u32, u32)) -> String {
format!("{}/{}", std::net::Ipv4Addr::from(c.0), c.1)
}
fn overlaps(a: (u32, u32), b: (u32, u32)) -> bool {
let l = a.1.min(b.1);
a.0 & mask(l) == b.0 & mask(l)
}
fn subtract(range: (u32, u32), hole: (u32, u32), out: &mut Vec<(u32, u32)>) {
let (net, len) = range;
if !overlaps(range, hole) {
out.push(range);
} else if hole.1 > len {
let half = 1u32 << (31 - len);
subtract((net, len + 1), hole, out);
subtract((net | half, len + 1), hole, out);
}
}
fn denied_ranges(holes: &[(u32, u32)]) -> Vec<String> {
let mut ranges: Vec<(u32, u32)> = PRIVATE
.iter()
.map(|r| parse_cidr(r).expect("constant"))
.collect();
for h in holes {
let mut next = Vec::new();
for r in ranges {
subtract(r, *h, &mut next);
}
ranges = next;
}
ranges.into_iter().map(fmt_cidr).collect()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct Egress {
net: (u32, u32),
ports: Option<(Proto, Vec<(u16, u16)>)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Proto {
Tcp,
Udp,
}
impl Proto {
fn as_str(self) -> &'static str {
match self {
Proto::Tcp => "tcp",
Proto::Udp => "udp",
}
}
}
impl Egress {
pub fn parse(s: &str) -> Result<Egress> {
let bad = |why: &str| {
Error::invalid(format!(
"egress exception {s:?}: {why} (want CIDR[:PORTS[/tcp|udp]], e.g. 100.79.171.47/32:1080/tcp)"
))
};
let (addr, rest) = match s.split_once(':') {
Some((a, r)) => (a, Some(r)),
None => (s, None),
};
let addr = if addr.contains('/') {
addr.to_string()
} else {
format!("{addr}/32")
};
let net = parse_cidr(&addr).ok_or_else(|| bad("not an IPv4 address or CIDR"))?;
let ports = match rest {
None => None,
Some(r) => {
let (list, proto) = match r.split_once('/') {
Some((l, "tcp")) => (l, Proto::Tcp),
Some((l, "udp")) => (l, Proto::Udp),
Some(_) => return Err(bad("the protocol must be tcp or udp")),
None => (r, Proto::Tcp),
};
let mut ranges = Vec::new();
for p in list.split(',') {
let (a, b) = p.split_once('-').unwrap_or((p, p));
let a: u16 = a.parse().map_err(|_| bad("bad port"))?;
let b: u16 = b.parse().map_err(|_| bad("bad port"))?;
if a == 0 || b < a {
return Err(bad("bad port range"));
}
ranges.push((a, b));
}
Some((proto, merge_ports(ranges)))
}
};
Ok(Egress { net, ports })
}
pub fn render(&self) -> String {
let mut s = fmt_cidr(self.net);
if let Some((proto, ranges)) = &self.ports {
s.push(':');
s.push_str(&fmt_ports(ranges));
s.push('/');
s.push_str(proto.as_str());
}
s
}
}
impl std::fmt::Display for Egress {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.render())
}
}
impl TryFrom<String> for Egress {
type Error = Error;
fn try_from(s: String) -> Result<Egress> {
Egress::parse(&s)
}
}
impl From<Egress> for String {
fn from(e: Egress) -> String {
e.render()
}
}
fn parse_egress_list(s: &str) -> Vec<Egress> {
s.split_whitespace()
.filter_map(|e| Egress::parse(e).ok())
.collect()
}
fn merge_ports(mut r: Vec<(u16, u16)>) -> Vec<(u16, u16)> {
r.sort();
let mut out: Vec<(u16, u16)> = Vec::new();
for (a, b) in r {
match out.last_mut() {
Some(l) if a as u32 <= l.1 as u32 + 1 => l.1 = l.1.max(b),
_ => out.push((a, b)),
}
}
out
}
fn complement_ports(r: &[(u16, u16)]) -> Vec<(u16, u16)> {
let mut out = Vec::new();
let mut next: u32 = 1;
for &(a, b) in r {
if (a as u32) > next {
out.push((next as u16, a - 1));
}
next = b as u32 + 1;
}
if next <= 65535 {
out.push((next as u16, 65535));
}
out
}
fn fmt_ports(r: &[(u16, u16)]) -> String {
r.iter()
.map(|&(a, b)| {
if a == b {
a.to_string()
} else {
format!("{a}-{b}")
}
})
.collect::<Vec<_>>()
.join(",")
}
pub fn check_egress(rules: &[Egress]) -> Result<()> {
for (i, a) in rules.iter().enumerate() {
for b in &rules[i + 1..] {
if a.net != b.net && overlaps(a.net, b.net) {
return Err(Error::invalid(format!(
"egress exceptions {a} and {b} overlap; use the same network for both"
)));
}
}
}
Ok(())
}
fn egress_rules(own: Option<(u32, u32)>, egress: &[Egress]) -> Result<Vec<Value>> {
check_egress(egress)?;
let private: Vec<(u32, u32)> = PRIVATE
.iter()
.map(|r| parse_cidr(r).expect("constant"))
.collect();
let egress: Vec<&Egress> = egress
.iter()
.filter(|e| private.iter().any(|p| overlaps(*p, e.net)))
.collect();
let mut holes: Vec<(u32, u32)> = own.into_iter().collect();
holes.extend(egress.iter().map(|e| e.net));
let mut out = vec![json!({
"action": "reject",
"destination": denied_ranges(&holes).join(","),
"state": "enabled",
"description": "other orgs and private networks",
})];
type Allowed = Option<BTreeMap<Proto, Vec<(u16, u16)>>>;
let mut nets: BTreeMap<(u32, u32), Allowed> = BTreeMap::new();
for e in egress {
let slot = nets.entry(e.net).or_insert_with(|| Some(BTreeMap::new()));
match (&e.ports, slot.as_mut()) {
(None, _) => *slot = None,
(Some((p, r)), Some(m)) => m.entry(*p).or_default().extend(r.iter().copied()),
(Some(_), None) => {}
}
}
for (net, allowed) in nets {
let Some(allowed) = allowed else { continue };
let dest = fmt_cidr(net);
for proto in [Proto::Tcp, Proto::Udp] {
let mut rule = json!({
"action": "reject",
"destination": dest,
"protocol": proto.as_str(),
"state": "enabled",
"description": format!("egress exception {dest}: other {} ports", proto.as_str()),
});
if let Some(r) = allowed.get(&proto) {
let rest = complement_ports(&merge_ports(r.clone()));
if rest.is_empty() {
continue;
}
rule["destination_port"] = json!(fmt_ports(&rest));
}
out.push(rule);
}
out.push(json!({
"action": "reject",
"destination": dest,
"protocol": "icmp4",
"state": "enabled",
"description": format!("egress exception {dest}: ICMP"),
}));
}
Ok(out)
}
pub fn client(base: &Client, org: &OrgId) -> Client {
base.clone().project(org.incus_project())
}
fn host(base: &Client) -> Client {
base.clone().project("default")
}
fn strmap(v: &Value) -> std::collections::BTreeMap<String, String> {
v.as_object()
.map(|m| {
m.iter()
.map(|(k, v)| {
(
k.clone(),
v.as_str()
.map(String::from)
.unwrap_or_else(|| v.to_string()),
)
})
.collect()
})
.unwrap_or_default()
}
fn subnet_of(cidr: &str) -> Option<String> {
let (ip, len) = cidr.split_once('/')?;
let ip: std::net::Ipv4Addr = ip.parse().ok()?;
let len: u32 = len.parse().ok()?;
if len > 32 {
return None;
}
let mask = if len == 0 { 0 } else { u32::MAX << (32 - len) };
Some(format!(
"{}/{len}",
std::net::Ipv4Addr::from(u32::from(ip) & mask)
))
}
fn info(base: &Client, org: OrgId, p: &Value) -> Result<OrgInfo> {
let cfg = strmap(&p["config"]);
let network = cfg.get(KEY_NETWORK).cloned();
let net = match &network {
Some(n) => host(base).get_opt(&format!("/1.0/networks/{}", encode_segment(n)))?,
None => None,
};
let subnet = net
.as_ref()
.and_then(|v| v["config"]["ipv4.address"].as_str().map(String::from));
let dns_dir = net.as_ref().and_then(|v| {
v["config"]["raw.dnsmasq"]
.as_str()?
.lines()
.find_map(|l| l.trim().strip_prefix("hostsdir=").map(String::from))
});
let oc = client(base, &org);
let instances = oc
.get("/1.0/instances")?
.as_array()
.map(|a| a.len())
.unwrap_or(0);
let profile = oc.get_opt("/1.0/profiles/default")?.unwrap_or_default();
let defaults = strmap(&profile["config"]);
let allocation = limits::read_budgets(base, &org.incus_project());
Ok(OrgInfo {
default_disk: profile["devices"]["root"]["size"]
.as_str()
.map(String::from)
.or_else(|| cfg.get("limits.disk").map(|_| DEFAULT_ROOT_SIZE.into())),
allocation,
project: org.incus_project(),
name: org,
network,
subnet,
cpus: cfg.get("limits.cpu").cloned(),
memory: cfg.get("limits.memory").cloned(),
disk: cfg.get("limits.disk").cloned(),
instances_limit: cfg.get("limits.instances").cloned(),
default_cpus: defaults.get("limits.cpu").cloned(),
default_memory: defaults.get("limits.memory").cloned(),
bind_roots: cfg
.get("restricted.devices.disk.paths")
.map(|s| {
s.split(',')
.filter(|x| !x.is_empty())
.map(String::from)
.collect()
})
.unwrap_or_default(),
egress: cfg
.get(KEY_EGRESS)
.map(|s| s.split_whitespace().map(String::from).collect())
.unwrap_or_default(),
domains: cfg
.get(KEY_DOMAINS)
.map(|s| s.split_whitespace().map(String::from).collect())
.unwrap_or_default(),
ingress: cfg
.get(KEY_INGRESS)
.filter(|s| !s.is_empty())
.cloned()
.unwrap_or_else(|| INGRESS_CADDY.to_string()),
udp: udp::parse_list(cfg.get(KEY_UDP).map(String::as_str).unwrap_or_default())
.iter()
.map(ToString::to_string)
.collect(),
cloudflare_account: cfg.get(KEY_CF_ACCOUNT).filter(|s| !s.is_empty()).cloned(),
cloudflare_zone: cfg.get(KEY_CF_ZONE).filter(|s| !s.is_empty()).cloned(),
dns_dir,
instances,
allow_nesting: nesting::allowed(&p["config"]),
})
}
pub fn get(base: &Client, org: &OrgId) -> Result<OrgInfo> {
let h = host(base);
let p = h
.get_opt(&format!(
"/1.0/projects/{}",
encode_segment(&org.incus_project())
))?
.ok_or_else(|| Error::NotFound(format!("org {org}")))?;
if p["config"][KEY_ORG].as_str() != Some(org.as_str()) {
return Err(Error::NotFound(format!("org {org}")));
}
info(base, org.clone(), &p)
}
pub fn check_exists(base: &Client, org: &OrgId) -> Result<()> {
let p = host(base).get_opt(&format!(
"/1.0/projects/{}",
encode_segment(&org.incus_project())
))?;
match p {
Some(p) if p["config"][KEY_ORG].as_str() == Some(org.as_str()) => Ok(()),
_ => Err(Error::NotFound(format!("org {org}"))),
}
}
pub fn list(base: &Client) -> Result<Vec<OrgInfo>> {
let h = host(base);
let v = h.get("/1.0/projects?recursion=1")?;
let mut out = Vec::new();
for p in v.as_array().into_iter().flatten() {
let Some(org) = of_project(p) else {
continue;
};
out.push(info(base, org, p)?);
}
out.sort_by(|a, b| (!a.name.is_default(), &a.name).cmp(&(!b.name.is_default(), &b.name)));
Ok(out)
}
pub fn remove(base: &Client, org: &OrgId, force: bool, report: &mut dyn FnMut(&str)) -> Result<()> {
if org.is_default() {
return Err(Error::invalid("the default org cannot be removed"));
}
let o = get(base, org)?;
if o.instances > 0 && !force {
return Err(Error::invalid(format!(
"org {org} has {} instance(s); remove them, or pass force",
o.instances
)));
}
let h = host(base);
let oc = client(base, org);
for name in oc
.get("/1.0/instances")?
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
{
let n = name.rsplit('/').next().unwrap_or(name);
let n = n.split('?').next().unwrap_or(n);
report(&format!("{org}: deleting {n}"));
crate::sandbox::Sandbox::remove(&oc, n, true)?;
}
report(&format!("{org}: deleting project {}", o.project));
h.mutate(
"DELETE",
&format!("/1.0/projects/{}?force=true", encode_segment(&o.project)),
None,
&format!("delete project {}", o.project),
h.get_timeouts().other,
)?;
if let Some(n) = &o.network {
report(&format!("{org}: deleting network {n}"));
match h.mutate(
"DELETE",
&format!("/1.0/networks/{}", encode_segment(n)),
None,
&format!("delete network {n}"),
h.get_timeouts().other,
) {
Err(e) if !e.is_not_found() => return Err(e),
_ => {}
}
}
crate::discovery::remove_org(org);
let acl = acl_name(org);
match h.mutate(
"DELETE",
&format!("/1.0/network-acls/{}", encode_segment(&acl)),
None,
&format!("delete ACL {acl}"),
h.get_timeouts().other,
) {
Err(e) if !e.is_not_found() => Err(e),
_ => Ok(()),
}
}
#[cfg(test)]
mod tests {
#[test]
fn an_unknown_org_is_not_found_up_front() {
use crate::client::fake::{Route, serve};
let (_d, c) = serve(vec![
Route {
prefix: "GET /1.0/projects/isb-lab",
status: 200,
body: json!({"config": {KEY_ORG: "lab"}}),
},
Route {
prefix: "GET /1.0/projects/isb-other",
status: 200,
body: json!({"config": {}}),
},
]);
assert!(check_exists(&c, &OrgId::new("lab").unwrap()).is_ok());
for o in ["demo", "other"] {
let e = check_exists(&c, &OrgId::new(o).unwrap()).unwrap_err();
assert!(e.is_not_found(), "{e}");
assert_eq!(e.to_string(), format!("org {o} not found"));
}
}
#[test]
fn the_default_org_is_isb_default_and_incus_default_is_no_org() {
let d = OrgId::default_org();
assert_eq!(d.incus_project(), DEFAULT_ORG_PROJECT);
assert_eq!(OrgId::from_incus_project("isb-default"), Some(d));
assert_eq!(OrgId::from_incus_project("default"), None);
}
use super::*;
#[test]
fn names_and_projects() {
assert!(OrgId::new("ocai").is_ok());
assert!(OrgId::new("Ocai").is_err());
assert!(OrgId::new("a-").is_err());
assert!(OrgId::new("x".repeat(32)).is_err());
assert!(OrgId::new("system").is_err());
assert_eq!(OrgId::from_incus_project(crate::registry::PROJECT), None);
let o = OrgId::new("ocai").unwrap();
assert_eq!(o.incus_project(), "isb-ocai");
assert_eq!(OrgId::default_org().incus_project(), "isb-default");
assert_eq!(OrgId::from_incus_project("isb-ocai"), Some(o));
assert_eq!(OrgId::from_incus_project("titan-ocai-ct"), None);
let j: OrgId = serde_json::from_str("\"norm\"").unwrap();
assert_eq!(j.as_str(), "norm");
assert!(serde_json::from_str::<OrgId>("\"Bad Name\"").is_err());
}
#[test]
fn bridges_and_subnets() {
let b = bridge_name(&OrgId::new("a-very-long-org-name-indeed").unwrap());
assert!(b.len() <= 15 && b.starts_with("isbbr"), "{b}");
assert_ne!(b, bridge_name(&OrgId::new("other").unwrap()));
assert_eq!(subnet_of("10.64.3.1/24").as_deref(), Some("10.64.3.0/24"));
assert_eq!(subnet_of("10.180.0.1/16").as_deref(), Some("10.180.0.0/16"));
assert_eq!(subnet_of("nope"), None);
}
#[test]
fn denied_ranges_carve_out_the_org() {
let d = denied_ranges(&[parse_cidr("10.160.44.0/24").unwrap()]);
assert!(!d.iter().any(|r| r == "10.0.0.0/8"));
assert!(d.contains(&"172.16.0.0/12".to_string()));
assert_eq!(d.len(), 16 + 4);
let covers = |r: &str, ip: u32| {
let (n, l) = parse_cidr(r).unwrap();
let m = if l == 0 { 0 } else { u32::MAX << (32 - l) };
ip & m == n
};
let ip = |s: &str| u32::from(s.parse::<std::net::Ipv4Addr>().unwrap());
assert!(!d.iter().any(|r| covers(r, ip("10.160.44.7"))));
for other in [
"10.160.45.1",
"10.0.0.1",
"10.255.255.254",
"10.238.212.250",
] {
assert!(d.iter().any(|r| covers(r, ip(other))), "{other}");
}
assert_eq!(denied_ranges(&[]).len(), 5);
assert_eq!(denied_ranges(&[parse_cidr("10.0.0.0/7").unwrap()]).len(), 4);
}
fn covered(ranges: &str, ip: &str) -> bool {
let ip = u32::from(ip.parse::<std::net::Ipv4Addr>().unwrap());
ranges.split(',').any(|r| {
let (n, l) = parse_cidr(r).unwrap();
ip & mask(l) == n
})
}
#[test]
fn egress_parses_and_renders() {
let e = Egress::parse("100.79.171.47/32:1080/tcp").unwrap();
assert_eq!(e.render(), "100.79.171.47/32:1080/tcp");
assert_eq!(
Egress::parse("100.79.171.47:1080").unwrap(),
e,
"a bare address is a /32 and tcp is the default"
);
assert_eq!(
Egress::parse("10.1.2.9/24").unwrap().render(),
"10.1.2.0/24"
);
assert_eq!(
Egress::parse("10.1.2.3:9000,8000-8100,8050/udp")
.unwrap()
.render(),
"10.1.2.3/32:8000-8100,9000/udp"
);
for bad in [
"db.example.com:5432",
"10.1.2.3:0",
"10.1.2.3:90-80",
"10.1.2.3:80/sctp",
"10.1.2.3/33",
"10.1.2.3:http",
] {
assert!(Egress::parse(bad).is_err(), "{bad}");
}
let j: Vec<Egress> = serde_json::from_str("[\"10.0.0.1:22\"]").unwrap();
assert_eq!(
serde_json::to_string(&j).unwrap(),
"[\"10.0.0.1/32:22/tcp\"]"
);
assert_eq!(
parse_egress_list("10.0.0.1/32:22/tcp 10.2.0.0/16"),
vec![
Egress::parse("10.0.0.1:22").unwrap(),
Egress::parse("10.2.0.0/16").unwrap()
]
);
}
#[test]
fn ports_complement() {
assert_eq!(
complement_ports(&[(1080, 1080)]),
vec![(1, 1079), (1081, 65535)]
);
assert_eq!(
complement_ports(&[(1, 10), (65535, 65535)]),
vec![(11, 65534)]
);
assert_eq!(complement_ports(&[(1, 65535)]), vec![]);
assert_eq!(
merge_ports(vec![(5, 9), (1, 4), (20, 30), (25, 40)]),
vec![(1, 9), (20, 40)]
);
}
#[test]
fn egress_exceptions_in_the_acl() {
let own = parse_cidr("10.160.44.0/24");
let whole = Egress::parse("10.20.0.0/16").unwrap();
let port = Egress::parse("100.79.171.47:1080").unwrap();
let udp = Egress::parse("100.79.171.47:53/udp").unwrap();
let public = Egress::parse("8.8.8.8:53/udp").unwrap();
let rules = egress_rules(own, &[whole, port, udp, public]).unwrap();
let deny = rules[0]["destination"].as_str().unwrap();
assert!(!covered(deny, "10.160.44.9"));
assert!(!covered(deny, "10.20.200.1"));
assert!(!covered(deny, "100.79.171.47"));
for ip in ["10.21.0.1", "100.79.171.46", "100.79.171.48", "192.168.1.1"] {
assert!(covered(deny, ip), "{ip}");
}
let rest: Vec<(String, String, String)> = rules[1..]
.iter()
.map(|r| {
(
r["destination"].as_str().unwrap().to_string(),
r["protocol"].as_str().unwrap().to_string(),
r["destination_port"].as_str().unwrap_or("").to_string(),
)
})
.collect();
let h = "100.79.171.47/32".to_string();
assert_eq!(
rest,
vec![
(h.clone(), "tcp".into(), "1-1079,1081-65535".into()),
(h.clone(), "udp".into(), "1-52,54-65535".into()),
(h, "icmp4".into(), String::new()),
]
);
assert!(rules.iter().all(|r| r["action"] == "reject"));
let rules = egress_rules(own, &[Egress::parse("10.9.9.9:5432").unwrap()]).unwrap();
assert_eq!(rules[2]["protocol"], "udp");
assert!(rules[2].get("destination_port").is_none());
let rules = egress_rules(
own,
&[
Egress::parse("10.9.9.9:5432").unwrap(),
Egress::parse("10.9.9.9").unwrap(),
],
)
.unwrap();
assert_eq!(rules.len(), 1);
assert!(
egress_rules(
own,
&[
Egress::parse("10.9.9.0/24:80").unwrap(),
Egress::parse("10.9.9.9:443").unwrap()
]
)
.is_err()
);
}
}