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 oi.tunnel && r.org.is_default() {
109 return Err(Error::invalid(
110 "the default org cannot use a Cloudflare tunnel",
111 ));
112 }
113 if !r.auto && !domain::allowed(&r.host, &oi.domains) {
114 return Err(Error::invalid(not_allowed(&r.host, &r.org, &oi.domains)));
115 }
116 let (defs, claims, extras) = {
117 let st = self.state.lock().unwrap();
118 (st.defs.clone(), st.claims.clone(), st.extras.clone())
119 };
120 let mut all: Vec<Route> = vec![r.clone()];
121 for d in &defs {
122 for (svc, spec) in &d.file.services {
123 if let Ok(rs) =
124 domain::routes_for(&d.org, &d.name, svc, &spec.domains, self.cfg.public_ip)
125 {
126 all.extend(rs);
127 }
128 }
129 }
130 all.extend(
131 extras
132 .into_iter()
133 .map(|e| e.route)
134 .filter(|x| x.owner() != r.owner()),
135 );
136 let res = domain::resolve(&all, &claims, crate::stack::now_secs());
137 match res.conflicts.iter().find(|c| c.route.owner() == r.owner()) {
138 Some(c) => Err(Error::invalid(format!("domain conflict: {}", c.reason))),
139 None => Ok(()),
140 }
141 }
142
143 pub fn extra_status(&self, r: &Route) -> Vec<DomainStatus> {
145 Observer::domains(self, &r.qualified_stack(), &r.service)
146 }
147
148 pub(super) fn merge_extras(
151 extras: &[ExtraRoute],
152 orgs: &BTreeMap<OrgId, OrgIngress>,
153 org_errors: &BTreeMap<OrgId, String>,
154 out: (&mut Vec<Route>, &mut Rotation, &mut Refused),
155 ) {
156 let (routes, rotation, refused) = out;
157 for e in extras {
158 let r = &e.route;
159 let oi = orgs.get(&r.org).cloned().unwrap_or_default();
160 let why = if let Some(err) = org_errors.get(&r.org) {
161 Some(format!("org settings: {err}"))
162 } else if !r.auto && !domain::allowed(&r.host, &oi.domains) {
163 Some(not_allowed(&r.host, &r.org, &oi.domains))
164 } else if oi.tunnel && r.org.is_default() {
165 Some("the default org cannot use a Cloudflare tunnel".into())
166 } else {
167 None
168 };
169 let key = (r.qualified_stack(), r.service.clone());
170 match why {
171 Some(w) => {
172 refused
173 .entry(key)
174 .or_default()
175 .push((r.host.clone(), r.path.clone(), w))
176 }
177 None => {
178 routes.push(r.clone());
179 rotation.insert(key, e.upstream.into_iter().collect());
180 }
181 }
182 }
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 #[test]
191 fn a_port_is_one_hostname_under_its_workspace() {
192 let acme = OrgId::new("acme").unwrap();
193 let r = workspace_route(&acme, "workspace", 3000, "3000-workspace.acme.dev", None).unwrap();
194 assert_eq!(r.stack, "workspace:workspace");
195 assert_eq!(r.service, "port-3000");
196 assert_eq!((r.port, r.https, r.path.as_str()), (Some(3000), true, "/"));
197 assert!(!r.auto);
198 assert_eq!(r.qualified_stack(), "acme/workspace:workspace");
199 let ip: IpAddr = "203.0.113.7".parse().unwrap();
200 let a = workspace_route(&acme, "workspace", 5173, "auto", Some(ip)).unwrap();
201 assert_eq!(a.host, "5173-workspace-acme.203-0-113-7.sslip.io");
202 assert!(a.auto);
203 for (port, host) in [
204 (0, "a.acme.dev"),
205 (80, "*.acme.dev"),
206 (80, "localhost"),
207 (80, "10.0.0.1"),
208 (80, "x.acme.isb"),
209 (80, "auto"),
210 ] {
211 assert!(
212 workspace_route(&acme, "workspace", port, host, None).is_err(),
213 "{port} {host}"
214 );
215 }
216 }
217
218 #[test]
219 fn extras_are_refused_outside_the_allowlist_and_served_inside_it() {
220 let acme = OrgId::new("acme").unwrap();
221 let ip: IpAddr = "10.1.2.3".parse().unwrap();
222 let mk = |host: &str| ExtraRoute {
223 route: workspace_route(&acme, "workspace", 3000, host, None).unwrap(),
224 upstream: Some(ip),
225 };
226 let orgs = BTreeMap::from([(
227 acme.clone(),
228 OrgIngress {
229 domains: vec!["acme.dev".into()],
230 ..Default::default()
231 },
232 )]);
233 let (mut routes, mut rotation, mut refused) = (Vec::new(), Rotation::new(), Refused::new());
234 Manager::merge_extras(
235 &[mk("3000-workspace.acme.dev"), mk("evil.example.com")],
236 &orgs,
237 &BTreeMap::new(),
238 (&mut routes, &mut rotation, &mut refused),
239 );
240 assert_eq!(routes.len(), 1);
241 assert_eq!(routes[0].host, "3000-workspace.acme.dev");
242 let key = (
243 "acme/workspace:workspace".to_string(),
244 "port-3000".to_string(),
245 );
246 assert_eq!(rotation[&key], vec![ip]);
247 assert!(refused[&key][0].2.contains("outside org acme's domains"));
248 }
249}