use super::*;
use crate::spec::DomainSpec;
#[derive(Debug, Clone, PartialEq)]
pub struct ExtraRoute {
pub route: Route,
pub upstream: Option<IpAddr>,
}
pub fn workspace_stack(workspace: &str) -> String {
format!("workspace:{workspace}")
}
pub fn port_service(port: u16) -> String {
format!("port-{port}")
}
pub fn workspace_route(
org: &OrgId,
ws: &str,
port: u16,
host: &str,
public_ip: Option<IpAddr>,
) -> Result<Route> {
if port == 0 {
return Err(Error::invalid("port must be 1-65535"));
}
let d = DomainSpec {
host: host.to_string(),
port: Some(port),
..Default::default()
};
let what = format!("port {port}");
domain::validate(&what, std::slice::from_ref(&d))?;
let mut host = host.trim().to_ascii_lowercase();
let auto = host == domain::AUTO;
if auto {
let ip = public_ip.ok_or_else(|| {
Error::invalid(
"host: auto needs the server's public address (isb serve --ingress-public-ip)",
)
})?;
let IpAddr::V4(v4) = ip else {
return Err(Error::invalid("host: auto needs an IPv4 public address"));
};
let label = format!("{port}-{ws}-{org}");
if label.len() > 63 {
return Err(Error::invalid(format!(
"port {port}: {label} is too long for a generated name; give a host"
)));
}
let o = v4.octets();
host = format!("{label}.{}-{}-{}-{}.sslip.io", o[0], o[1], o[2], o[3]);
} else if host.starts_with("*.") {
return Err(Error::invalid(format!(
"port {port}: a workspace port is one hostname, not a wildcard"
)));
}
Ok(Route {
org: org.clone(),
stack: workspace_stack(ws),
service: port_service(port),
host,
path: "/".into(),
port: Some(port),
https: true,
redirect: None,
strip_prefix: false,
generated: false,
auto,
})
}
impl Manager {
pub fn set_extras(&self, extras: Vec<ExtraRoute>) {
let mut st = self.state.lock().unwrap();
if st.extras == extras {
return;
}
st.extras = extras;
st.generation += 1;
self.wake.notify_all();
}
pub fn check_extra(&self, r: &Route) -> Result<()> {
let oi = self.org_settings(&r.org, true)?;
if !r.auto && !domain::allowed(&r.host, &oi.domains) {
return Err(Error::invalid(not_allowed(&r.host, &r.org, &oi.domains)));
}
let (defs, claims, extras) = {
let st = self.state.lock().unwrap();
(st.defs.clone(), st.claims.clone(), st.extras.clone())
};
let mut all: Vec<Route> = vec![r.clone()];
for d in &defs {
for (svc, spec) in &d.file.services {
if let Ok(rs) =
domain::routes_for(&d.org, &d.name, svc, &spec.domains, self.cfg.public_ip)
{
all.extend(rs);
}
}
}
all.extend(
extras
.into_iter()
.map(|e| e.route)
.filter(|x| x.owner() != r.owner()),
);
let res = domain::resolve(&all, &claims, crate::stack::now_secs());
match res.conflicts.iter().find(|c| c.route.owner() == r.owner()) {
Some(c) => Err(Error::invalid(format!("domain conflict: {}", c.reason))),
None => Ok(()),
}
}
pub fn extra_status(&self, r: &Route) -> Vec<DomainStatus> {
Observer::domains(self, &r.qualified_stack(), &r.service)
}
pub(super) fn merge_extras(
extras: &[ExtraRoute],
orgs: &BTreeMap<OrgId, OrgIngress>,
org_errors: &BTreeMap<OrgId, String>,
out: (&mut Vec<Route>, &mut Rotation, &mut Refused),
) {
let (routes, rotation, refused) = out;
for e in extras {
let r = &e.route;
let oi = orgs.get(&r.org).cloned().unwrap_or_default();
let why = if let Some(err) = org_errors.get(&r.org) {
Some(format!("org settings: {err}"))
} else if !r.auto && !domain::allowed(&r.host, &oi.domains) {
Some(not_allowed(&r.host, &r.org, &oi.domains))
} else {
None
};
let key = (r.qualified_stack(), r.service.clone());
match why {
Some(w) => {
refused
.entry(key)
.or_default()
.push((r.host.clone(), r.path.clone(), w))
}
None => {
routes.push(r.clone());
rotation.insert(key, e.upstream.into_iter().collect());
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_port_is_one_hostname_under_its_workspace() {
let acme = OrgId::new("acme").unwrap();
let r = workspace_route(&acme, "workspace", 3000, "3000-workspace.acme.dev", None).unwrap();
assert_eq!(r.stack, "workspace:workspace");
assert_eq!(r.service, "port-3000");
assert_eq!((r.port, r.https, r.path.as_str()), (Some(3000), true, "/"));
assert!(!r.auto);
assert_eq!(r.qualified_stack(), "acme/workspace:workspace");
let ip: IpAddr = "203.0.113.7".parse().unwrap();
let a = workspace_route(&acme, "workspace", 5173, "auto", Some(ip)).unwrap();
assert_eq!(a.host, "5173-workspace-acme.203-0-113-7.sslip.io");
assert!(a.auto);
for (port, host) in [
(0, "a.acme.dev"),
(80, "*.acme.dev"),
(80, "localhost"),
(80, "10.0.0.1"),
(80, "x.acme.isb"),
(80, "auto"),
] {
assert!(
workspace_route(&acme, "workspace", port, host, None).is_err(),
"{port} {host}"
);
}
}
#[test]
fn extras_are_refused_outside_the_allowlist_and_served_inside_it() {
let acme = OrgId::new("acme").unwrap();
let ip: IpAddr = "10.1.2.3".parse().unwrap();
let mk = |host: &str| ExtraRoute {
route: workspace_route(&acme, "workspace", 3000, host, None).unwrap(),
upstream: Some(ip),
};
let orgs = BTreeMap::from([(
acme.clone(),
OrgIngress {
domains: vec!["acme.dev".into()],
..Default::default()
},
)]);
let (mut routes, mut rotation, mut refused) = (Vec::new(), Rotation::new(), Refused::new());
Manager::merge_extras(
&[mk("3000-workspace.acme.dev"), mk("evil.example.com")],
&orgs,
&BTreeMap::new(),
(&mut routes, &mut rotation, &mut refused),
);
assert_eq!(routes.len(), 1);
assert_eq!(routes[0].host, "3000-workspace.acme.dev");
let key = (
"acme/workspace:workspace".to_string(),
"port-3000".to_string(),
);
assert_eq!(rotation[&key], vec![ip]);
assert!(refused[&key][0].2.contains("outside org acme's domains"));
}
#[test]
fn the_default_org_serves_extras_through_its_tunnel() {
let org = OrgId::default_org();
let ip: IpAddr = "10.1.2.3".parse().unwrap();
let e = ExtraRoute {
route: workspace_route(&org, "workspace", 3000, "3000-workspace.acme.dev", None)
.unwrap(),
upstream: Some(ip),
};
let orgs = BTreeMap::from([(
org,
OrgIngress {
domains: vec!["acme.dev".into()],
tunnel: true,
..Default::default()
},
)]);
let (mut routes, mut rotation, mut refused) = (Vec::new(), Rotation::new(), Refused::new());
Manager::merge_extras(
&[e],
&orgs,
&BTreeMap::new(),
(&mut routes, &mut rotation, &mut refused),
);
assert_eq!(routes.len(), 1, "{refused:?}");
assert!(refused.is_empty());
}
}