1use super::*;
12use crate::spec::DomainSpec;
13
14#[derive(Debug, Clone, PartialEq)]
16pub struct ExtraRoute {
17 pub route: Route,
18 pub upstream: Option<IpAddr>,
20}
21
22pub fn workspace_stack(workspace: &str) -> String {
24 format!("workspace:{workspace}")
25}
26
27pub fn port_service(port: u16) -> String {
29 format!("port-{port}")
30}
31
32pub fn workspace_route(
36 org: &OrgId,
37 ws: &str,
38 port: u16,
39 host: &str,
40 public_ip: Option<IpAddr>,
41) -> Result<Route> {
42 if port == 0 {
43 return Err(Error::invalid("port must be 1-65535"));
44 }
45 let d = DomainSpec {
46 host: host.to_string(),
47 port: Some(port),
48 ..Default::default()
49 };
50 let what = format!("port {port}");
51 domain::validate(&what, std::slice::from_ref(&d))?;
52 let mut host = host.trim().to_ascii_lowercase();
53 let auto = host == domain::AUTO;
54 if auto {
55 let ip = public_ip.ok_or_else(|| {
56 Error::invalid(
57 "host: auto needs the server's public address (isb serve --ingress-public-ip)",
58 )
59 })?;
60 let IpAddr::V4(v4) = ip else {
61 return Err(Error::invalid("host: auto needs an IPv4 public address"));
62 };
63 let label = format!("{port}-{ws}-{org}");
64 if label.len() > 63 {
65 return Err(Error::invalid(format!(
66 "port {port}: {label} is too long for a generated name; give a host"
67 )));
68 }
69 let o = v4.octets();
70 host = format!("{label}.{}-{}-{}-{}.sslip.io", o[0], o[1], o[2], o[3]);
71 } else if host.starts_with("*.") {
72 return Err(Error::invalid(format!(
73 "port {port}: a workspace port is one hostname, not a wildcard"
74 )));
75 }
76 Ok(Route {
77 org: org.clone(),
78 stack: workspace_stack(ws),
79 service: port_service(port),
80 host,
81 path: "/".into(),
82 port: Some(port),
83 https: true,
84 redirect: None,
85 strip_prefix: false,
86 generated: false,
87 auto,
88 })
89}
90
91impl Manager {
92 pub fn set_extras(&self, extras: Vec<ExtraRoute>) {
95 let mut st = self.state.lock().unwrap();
96 if st.extras == extras {
97 return;
98 }
99 st.extras = extras;
100 st.generation += 1;
101 self.wake.notify_all();
102 }
103
104 pub fn check_extra(&self, r: &Route) -> Result<()> {
107 let oi = self.org_settings(&r.org, true)?;
108 if !r.auto && !domain::allowed(&r.host, &oi.domains) {
109 return Err(Error::invalid(not_allowed(&r.host, &r.org, &oi.domains)));
110 }
111 let (defs, claims, extras) = {
112 let st = self.state.lock().unwrap();
113 (st.defs.clone(), st.claims.clone(), st.extras.clone())
114 };
115 let mut all: Vec<Route> = vec![r.clone()];
116 for d in &defs {
117 for (svc, spec) in &d.file.services {
118 if let Ok(rs) =
119 domain::routes_for(&d.org, &d.name, svc, &spec.domains, self.cfg.public_ip)
120 {
121 all.extend(rs);
122 }
123 }
124 }
125 all.extend(
126 extras
127 .into_iter()
128 .map(|e| e.route)
129 .filter(|x| x.owner() != r.owner()),
130 );
131 let res = domain::resolve(&all, &claims, crate::stack::now_secs());
132 match res.conflicts.iter().find(|c| c.route.owner() == r.owner()) {
133 Some(c) => Err(Error::invalid(format!("domain conflict: {}", c.reason))),
134 None => Ok(()),
135 }
136 }
137
138 pub fn extra_status(&self, r: &Route) -> Vec<DomainStatus> {
140 Observer::domains(self, &r.qualified_stack(), &r.service)
141 }
142
143 pub(super) fn merge_extras(
146 extras: &[ExtraRoute],
147 orgs: &BTreeMap<OrgId, OrgIngress>,
148 org_errors: &BTreeMap<OrgId, String>,
149 out: (&mut Vec<Route>, &mut Rotation, &mut Refused),
150 ) {
151 let (routes, rotation, refused) = out;
152 for e in extras {
153 let r = &e.route;
154 let oi = orgs.get(&r.org).cloned().unwrap_or_default();
155 let why = if let Some(err) = org_errors.get(&r.org) {
156 Some(format!("org settings: {err}"))
157 } else if !r.auto && !domain::allowed(&r.host, &oi.domains) {
158 Some(not_allowed(&r.host, &r.org, &oi.domains))
159 } else {
160 None
161 };
162 let key = (r.qualified_stack(), r.service.clone());
163 match why {
164 Some(w) => {
165 refused
166 .entry(key)
167 .or_default()
168 .push((r.host.clone(), r.path.clone(), w))
169 }
170 None => {
171 routes.push(r.clone());
172 rotation.insert(key, e.upstream.into_iter().collect());
173 }
174 }
175 }
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn a_port_is_one_hostname_under_its_workspace() {
185 let acme = OrgId::new("acme").unwrap();
186 let r = workspace_route(&acme, "workspace", 3000, "3000-workspace.acme.dev", None).unwrap();
187 assert_eq!(r.stack, "workspace:workspace");
188 assert_eq!(r.service, "port-3000");
189 assert_eq!((r.port, r.https, r.path.as_str()), (Some(3000), true, "/"));
190 assert!(!r.auto);
191 assert_eq!(r.qualified_stack(), "acme/workspace:workspace");
192 let ip: IpAddr = "203.0.113.7".parse().unwrap();
193 let a = workspace_route(&acme, "workspace", 5173, "auto", Some(ip)).unwrap();
194 assert_eq!(a.host, "5173-workspace-acme.203-0-113-7.sslip.io");
195 assert!(a.auto);
196 for (port, host) in [
197 (0, "a.acme.dev"),
198 (80, "*.acme.dev"),
199 (80, "localhost"),
200 (80, "10.0.0.1"),
201 (80, "x.acme.isb"),
202 (80, "auto"),
203 ] {
204 assert!(
205 workspace_route(&acme, "workspace", port, host, None).is_err(),
206 "{port} {host}"
207 );
208 }
209 }
210
211 #[test]
212 fn extras_are_refused_outside_the_allowlist_and_served_inside_it() {
213 let acme = OrgId::new("acme").unwrap();
214 let ip: IpAddr = "10.1.2.3".parse().unwrap();
215 let mk = |host: &str| ExtraRoute {
216 route: workspace_route(&acme, "workspace", 3000, host, None).unwrap(),
217 upstream: Some(ip),
218 };
219 let orgs = BTreeMap::from([(
220 acme.clone(),
221 OrgIngress {
222 domains: vec!["acme.dev".into()],
223 ..Default::default()
224 },
225 )]);
226 let (mut routes, mut rotation, mut refused) = (Vec::new(), Rotation::new(), Refused::new());
227 Manager::merge_extras(
228 &[mk("3000-workspace.acme.dev"), mk("evil.example.com")],
229 &orgs,
230 &BTreeMap::new(),
231 (&mut routes, &mut rotation, &mut refused),
232 );
233 assert_eq!(routes.len(), 1);
234 assert_eq!(routes[0].host, "3000-workspace.acme.dev");
235 let key = (
236 "acme/workspace:workspace".to_string(),
237 "port-3000".to_string(),
238 );
239 assert_eq!(rotation[&key], vec![ip]);
240 assert!(refused[&key][0].2.contains("outside org acme's domains"));
241 }
242
243 #[test]
244 fn the_default_org_serves_extras_through_its_tunnel() {
245 let org = OrgId::default_org();
246 let ip: IpAddr = "10.1.2.3".parse().unwrap();
247 let e = ExtraRoute {
248 route: workspace_route(&org, "workspace", 3000, "3000-workspace.acme.dev", None)
249 .unwrap(),
250 upstream: Some(ip),
251 };
252 let orgs = BTreeMap::from([(
253 org,
254 OrgIngress {
255 domains: vec!["acme.dev".into()],
256 tunnel: true,
257 ..Default::default()
258 },
259 )]);
260 let (mut routes, mut rotation, mut refused) = (Vec::new(), Rotation::new(), Refused::new());
261 Manager::merge_extras(
262 &[e],
263 &orgs,
264 &BTreeMap::new(),
265 (&mut routes, &mut rotation, &mut refused),
266 );
267 assert_eq!(routes.len(), 1, "{refused:?}");
268 assert!(refused.is_empty());
269 }
270}