1use std::collections::{BTreeMap, BTreeSet};
5use std::net::IpAddr;
6
7use serde::{Deserialize, Serialize};
8
9use crate::error::{Error, Result};
10use crate::org::OrgId;
11use crate::spec::DomainSpec;
12
13pub const AUTO: &str = "auto";
15
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
18pub struct Route {
19 pub org: OrgId,
20 pub stack: String,
22 pub service: String,
23 pub host: String,
25 pub path: String,
27 pub port: Option<u16>,
29 pub https: bool,
30 pub redirect: Option<String>,
31 pub strip_prefix: bool,
32 pub generated: bool,
34 pub auto: bool,
36}
37
38impl Route {
39 pub fn qualified_stack(&self) -> String {
40 crate::stack::qualified(&self.org, &self.stack)
41 }
42
43 pub fn is_wildcard(&self) -> bool {
44 self.host.starts_with("*.")
45 }
46
47 pub fn owner(&self) -> (String, String, String) {
49 (
50 self.org.to_string(),
51 self.stack.clone(),
52 self.service.clone(),
53 )
54 }
55
56 pub fn url(&self, https_port: Option<u16>, http_port: Option<u16>) -> String {
58 let (scheme, port, default) = if self.https {
59 ("https", https_port, 443)
60 } else {
61 ("http", http_port, 80)
62 };
63 let port = match port {
64 Some(p) if p != default => format!(":{p}"),
65 _ => String::new(),
66 };
67 let path = if self.path == "/" { "/" } else { &self.path };
68 format!("{scheme}://{}{port}{path}", self.host)
69 }
70}
71
72pub fn validate(service: &str, domains: &[DomainSpec]) -> Result<()> {
75 let mut seen = BTreeSet::new();
76 for d in domains {
77 let what = format!("service {service}: domain {:?}", d.host);
78 let bad = |why: String| Error::invalid(format!("{what}: {why}"));
79 let host = d.host.trim().to_ascii_lowercase();
80 if host != AUTO {
81 check_host(&host).map_err(bad)?;
82 }
83 let path = normalize_path(d.path.as_deref()).map_err(bad)?;
84 match (&d.redirect, d.port) {
85 (Some(r), _) => check_redirect(r).map_err(bad)?,
86 (None, None) => return Err(bad("needs port (or redirect)".into())),
87 (None, Some(0)) => return Err(bad("port must be 1-65535".into())),
88 (None, Some(_)) => {}
89 }
90 if d.strip_prefix && path == "/" {
91 return Err(bad("strip_prefix needs a path".into()));
92 }
93 if d.www_redirect && (host == AUTO || host.starts_with("*.") || host.starts_with("www.")) {
94 return Err(bad(
95 "www_redirect goes on the bare name (example.com), not a www, wildcard or auto host"
96 .into(),
97 ));
98 }
99 if !seen.insert((host.clone(), path.clone())) {
100 return Err(bad(format!("{host}{path} is listed twice")));
101 }
102 }
103 Ok(())
104}
105
106pub fn check_host(host: &str) -> std::result::Result<(), String> {
109 if host.is_empty() || host.len() > 253 {
110 return Err("a hostname is 1-253 characters".into());
111 }
112 if host.parse::<IpAddr>().is_ok() {
113 return Err("an IP address is not a hostname; use host: auto for a generated name".into());
114 }
115 let labels: Vec<&str> = host.split('.').collect();
116 if labels.len() < 2 {
117 return Err("a hostname needs a domain (app.example.com)".into());
118 }
119 for (i, l) in labels.iter().enumerate() {
120 if i == 0 && *l == "*" {
121 if labels.len() < 3 {
122 return Err("a wildcard needs a domain under it (*.example.com)".into());
123 }
124 continue;
125 }
126 let ok = !l.is_empty()
127 && l.len() <= 63
128 && !l.starts_with('-')
129 && !l.ends_with('-')
130 && l.chars()
131 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
132 if !ok {
133 return Err(format!(
134 "label {l:?} is not a DNS label ([a-z0-9-], at most 63, no leading or trailing -)"
135 ));
136 }
137 }
138 let tld = labels[labels.len() - 1];
139 if tld == "isb" || tld == "localhost" || tld == "incus" {
140 return Err(format!(".{tld} names are internal"));
141 }
142 Ok(())
143}
144
145pub fn normalize_path(p: Option<&str>) -> std::result::Result<String, String> {
148 let p = p.unwrap_or("/").trim();
149 if p.is_empty() || p == "/" {
150 return Ok("/".into());
151 }
152 if !p.starts_with('/') {
153 return Err(format!("path {p:?} must start with /"));
154 }
155 if p.contains(|c: char| c.is_whitespace() || matches!(c, '?' | '#' | '*' | '%' | '{' | '}')) {
156 return Err(format!(
157 "path {p:?}: a plain prefix, without ? # * % or braces"
158 ));
159 }
160 if p.contains("//") || p.split('/').any(|s| s == ".." || s == ".") {
161 return Err(format!("path {p:?} is not normalized"));
162 }
163 Ok(p.trim_end_matches('/').to_string())
164}
165
166fn check_redirect(r: &str) -> std::result::Result<(), String> {
167 let rest = r
168 .strip_prefix("https://")
169 .or_else(|| r.strip_prefix("http://"))
170 .ok_or_else(|| format!("redirect {r:?} must be an http(s):// URL"))?;
171 if rest.is_empty()
172 || rest.starts_with('/')
173 || r.contains(|c: char| c.is_whitespace() || c.is_control() || matches!(c, '{' | '}' | '"'))
174 {
175 return Err(format!("redirect {r:?} is not a URL"));
176 }
177 Ok(())
178}
179
180pub fn redirect_keeps_path(target: &str) -> bool {
182 let rest = target
183 .strip_prefix("https://")
184 .or_else(|| target.strip_prefix("http://"))
185 .unwrap_or(target);
186 match rest.find('/') {
187 None => true,
188 Some(i) => &rest[i..] == "/",
189 }
190}
191
192pub fn auto_host(org: &OrgId, stack: &str, service: &str, ip: IpAddr) -> Result<String> {
196 let IpAddr::V4(v4) = ip else {
197 return Err(Error::invalid(format!(
198 "host: auto needs an IPv4 public address, not {ip}"
199 )));
200 };
201 let mut label = format!(
202 "{}-{stack}-{}",
203 crate::compose::sanitize_name(service),
204 org.as_str()
205 );
206 if label.len() > 63 {
207 let mut h: u32 = 0x811c9dc5;
208 for b in label.bytes() {
209 h ^= b as u32;
210 h = h.wrapping_mul(0x01000193);
211 }
212 label.truncate(54);
213 let label2 = label.trim_end_matches('-').to_string();
214 label = format!("{label2}-{h:08x}");
215 }
216 let o = v4.octets();
217 Ok(format!(
218 "{label}.{}-{}-{}-{}.sslip.io",
219 o[0], o[1], o[2], o[3]
220 ))
221}
222
223pub fn routes_for(
226 org: &OrgId,
227 stack: &str,
228 service: &str,
229 domains: &[DomainSpec],
230 public_ip: Option<IpAddr>,
231) -> Result<Vec<Route>> {
232 validate(service, domains)?;
233 let mut out = Vec::new();
234 for d in domains {
235 let mut host = d.host.trim().to_ascii_lowercase();
236 let auto = host == AUTO;
237 if auto {
238 let ip = public_ip.ok_or_else(|| {
239 Error::invalid(format!(
240 "service {service}: host: auto needs the server's public address (isb serve --ingress-public-ip)"
241 ))
242 })?;
243 host = auto_host(org, stack, service, ip)?;
244 }
245 let path = normalize_path(d.path.as_deref()).map_err(Error::invalid)?;
246 let https = d.https.unwrap_or(true);
247 if d.www_redirect {
248 let scheme = if https { "https" } else { "http" };
249 out.push(Route {
250 org: org.clone(),
251 stack: stack.into(),
252 service: service.into(),
253 host: format!("www.{host}"),
254 path: path.clone(),
255 port: None,
256 https,
257 redirect: Some(format!("{scheme}://{host}")),
258 strip_prefix: false,
259 generated: true,
260 auto,
261 });
262 }
263 out.push(Route {
264 org: org.clone(),
265 stack: stack.into(),
266 service: service.into(),
267 host,
268 path,
269 port: if d.redirect.is_some() { None } else { d.port },
270 https,
271 redirect: d.redirect.clone(),
272 strip_prefix: d.strip_prefix,
273 generated: false,
274 auto,
275 });
276 }
277 Ok(out)
278}
279
280pub fn overlaps(a: &str, b: &str) -> bool {
283 if a == b {
284 return true;
285 }
286 let covers = |w: &str, h: &str| -> bool {
287 let Some(base) = w.strip_prefix("*.") else {
288 return false;
289 };
290 match h.split_once('.') {
291 Some((first, rest)) => rest == base && first != "*",
292 None => false,
293 }
294 };
295 covers(a, b) || covers(b, a)
296}
297
298pub fn allowed(host: &str, allowlist: &[String]) -> bool {
304 let under = |h: &str, suffix: &str| h == suffix || h.ends_with(&format!(".{suffix}"));
305 match host.strip_prefix("*.") {
306 Some(base) => allowlist
307 .iter()
308 .filter_map(|e| e.strip_prefix("*."))
309 .any(|s| under(base, s)),
310 None if allowlist.is_empty() => true,
311 None => allowlist
312 .iter()
313 .map(|e| e.strip_prefix("*.").unwrap_or(e))
314 .any(|s| under(host, s)),
315 }
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
321pub struct Claim {
322 pub host: String,
323 pub path: String,
324 pub org: String,
325 pub stack: String,
326 pub service: String,
327 pub since: u64,
329}
330
331#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
333pub struct Conflict {
334 pub route: Route,
335 pub reason: String,
337}
338
339#[derive(Debug, Clone, Default)]
341pub struct Resolution {
342 pub accepted: Vec<Route>,
343 pub conflicts: Vec<Conflict>,
344 pub claims: Vec<Claim>,
346}
347
348pub fn resolve(routes: &[Route], previous: &[Claim], now: u64) -> Resolution {
357 let prev: BTreeMap<(String, String, String, String, String), u64> = previous
358 .iter()
359 .map(|c| {
360 (
361 (
362 c.host.clone(),
363 c.path.clone(),
364 c.org.clone(),
365 c.stack.clone(),
366 c.service.clone(),
367 ),
368 c.since,
369 )
370 })
371 .collect();
372 let key = |r: &Route| {
373 (
374 r.host.clone(),
375 r.path.clone(),
376 r.org.to_string(),
377 r.stack.clone(),
378 r.service.clone(),
379 )
380 };
381 let mut order: Vec<(u64, &Route)> = routes
382 .iter()
383 .map(|r| (prev.get(&key(r)).copied().unwrap_or(u64::MAX), r))
384 .collect();
385 order.sort_by(|a, b| {
386 (
387 a.0,
388 a.1.org.as_str(),
389 &a.1.stack,
390 &a.1.service,
391 &a.1.host,
392 &a.1.path,
393 )
394 .cmp(&(
395 b.0,
396 b.1.org.as_str(),
397 &b.1.stack,
398 &b.1.service,
399 &b.1.host,
400 &b.1.path,
401 ))
402 });
403 let mut out = Resolution::default();
404 for (since, r) in order {
405 let cross = out
406 .accepted
407 .iter()
408 .any(|a| a.org != r.org && overlaps(&a.host, &r.host));
409 if cross {
410 out.conflicts.push(Conflict {
411 route: r.clone(),
412 reason: format!("{} is already served by another org", r.host),
413 });
414 continue;
415 }
416 if let Some(a) = out
417 .accepted
418 .iter()
419 .find(|a| a.org == r.org && a.host == r.host && a.path == r.path)
420 {
421 out.conflicts.push(Conflict {
422 route: r.clone(),
423 reason: format!(
424 "{}{} is already served by {}/{}",
425 r.host,
426 if r.path == "/" { "" } else { &r.path },
427 a.stack,
428 a.service
429 ),
430 });
431 continue;
432 }
433 out.claims.push(Claim {
434 host: r.host.clone(),
435 path: r.path.clone(),
436 org: r.org.to_string(),
437 stack: r.stack.clone(),
438 service: r.service.clone(),
439 since: if since == u64::MAX { now } else { since },
440 });
441 out.accepted.push(r.clone());
442 }
443 out
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449
450 fn d(y: &str) -> Vec<DomainSpec> {
451 serde_yaml_ng::from_str(y).unwrap()
452 }
453
454 fn org(s: &str) -> OrgId {
455 OrgId::new(s).unwrap()
456 }
457
458 fn route(o: &str, stack: &str, svc: &str, host: &str, path: &str) -> Route {
459 Route {
460 org: org(o),
461 stack: stack.into(),
462 service: svc.into(),
463 host: host.into(),
464 path: path.into(),
465 port: Some(80),
466 https: true,
467 redirect: None,
468 strip_prefix: false,
469 generated: false,
470 auto: false,
471 }
472 }
473
474 #[test]
475 fn parses_the_contract_shape() {
476 let v = d(
477 "- {host: app.example.com, port: 8080}\n- {host: Example.com, path: /api/, port: 3000, https: false, strip_prefix: true}\n- {host: www.example.com, redirect: 'https://example.com'}\n",
478 );
479 validate("web", &v).unwrap();
480 assert_eq!(v[1].https, Some(false));
481 let r = routes_for(&org("acme"), "shop", "web", &v, None).unwrap();
482 assert_eq!(r[1].host, "example.com");
483 assert_eq!(r[1].path, "/api");
484 assert!(!r[1].https);
485 assert!(r[0].https, "https defaults to true");
486 assert_eq!(r[0].path, "/", "path defaults to /");
487 assert_eq!(r[2].port, None);
488 assert!(
490 serde_yaml_ng::from_str::<Vec<DomainSpec>>("- {host: a.b, port: 1, tls: x}").is_err()
491 );
492 }
493
494 #[test]
495 fn refuses_bad_entries() {
496 for (y, why) in [
497 ("- {host: example.com}", "needs port"),
498 ("- {host: localhost, port: 1}", "needs a domain"),
499 ("- {host: a.isb, port: 1}", "internal"),
500 ("- {host: 10.0.0.1, port: 1}", "IP address"),
501 ("- {host: a_b.com, port: 1}", "DNS label"),
502 ("- {host: -a.com, port: 1}", "DNS label"),
503 ("- {host: '*.com', port: 1}", "wildcard needs"),
504 ("- {host: a.*.com, port: 1}", "DNS label"),
505 ("- {host: a.com, port: 0}", "1-65535"),
506 ("- {host: a.com, path: api, port: 1}", "start with /"),
507 ("- {host: a.com, path: '/a?b', port: 1}", "plain prefix"),
508 ("- {host: a.com, path: '/a/../b', port: 1}", "normalized"),
509 (
510 "- {host: a.com, port: 1, strip_prefix: true}",
511 "needs a path",
512 ),
513 ("- {host: a.com, redirect: example.com}", "http(s)://"),
514 (
515 "- {host: www.a.com, port: 1, www_redirect: true}",
516 "bare name",
517 ),
518 (
519 "- {host: a.com, port: 1}\n- {host: A.com, path: /, port: 2}",
520 "twice",
521 ),
522 ] {
523 let e = validate("web", &d(y)).unwrap_err().to_string();
524 assert!(e.contains(why), "{y}: {e}");
525 }
526 validate("web", &d("- {host: '*.apps.example.com', port: 1}")).unwrap();
527 validate("web", &d("- {host: auto, port: 1}")).unwrap();
528 }
529
530 #[test]
531 fn generates_sslip_names() {
532 let ip: IpAddr = "203.0.113.7".parse().unwrap();
533 assert_eq!(
534 auto_host(&org("acme"), "shop", "web", ip).unwrap(),
535 "web-shop-acme.203-0-113-7.sslip.io"
536 );
537 assert_eq!(
538 auto_host(&OrgId::default_org(), "shop", "my_api", ip).unwrap(),
539 "my-api-shop-default.203-0-113-7.sslip.io"
540 );
541 let long = auto_host(&org("acme"), &"s".repeat(30), &"v".repeat(40), ip).unwrap();
542 let first = long.split('.').next().unwrap();
543 assert!(first.len() <= 63, "{first}");
544 check_host(&long).unwrap();
545 assert_eq!(
547 long,
548 auto_host(&org("acme"), &"s".repeat(30), &"v".repeat(40), ip).unwrap()
549 );
550 assert!(auto_host(&org("acme"), "s", "w", "::1".parse().unwrap()).is_err());
551 let r = routes_for(
552 &org("acme"),
553 "shop",
554 "web",
555 &d("- {host: auto, port: 80}"),
556 Some(ip),
557 )
558 .unwrap();
559 assert_eq!(r[0].host, "web-shop-acme.203-0-113-7.sslip.io");
560 let e = routes_for(
561 &org("acme"),
562 "shop",
563 "web",
564 &d("- {host: auto, port: 80}"),
565 None,
566 )
567 .unwrap_err();
568 assert!(e.to_string().contains("--ingress-public-ip"), "{e}");
569 }
570
571 #[test]
572 fn www_redirect_adds_a_route() {
573 let r = routes_for(
574 &org("acme"),
575 "shop",
576 "web",
577 &d("- {host: example.com, port: 80, www_redirect: true}"),
578 None,
579 )
580 .unwrap();
581 assert_eq!(r.len(), 2);
582 assert_eq!(r[0].host, "www.example.com");
583 assert_eq!(r[0].redirect.as_deref(), Some("https://example.com"));
584 assert!(r[0].generated);
585 assert!(redirect_keeps_path("https://example.com"));
586 assert!(redirect_keeps_path("https://example.com/"));
587 assert!(!redirect_keeps_path("https://example.com/landing"));
588 }
589
590 #[test]
591 fn urls() {
592 let mut r = route("acme", "s", "w", "a.example.com", "/");
593 assert_eq!(r.url(Some(443), Some(80)), "https://a.example.com/");
594 assert_eq!(r.url(Some(8443), Some(80)), "https://a.example.com:8443/");
595 r.https = false;
596 r.path = "/api".into();
597 assert_eq!(
598 r.url(Some(443), Some(18080)),
599 "http://a.example.com:18080/api"
600 );
601 }
602
603 #[test]
604 fn wildcards_and_allowlists() {
605 assert!(overlaps("*.example.com", "a.example.com"));
606 assert!(overlaps("a.example.com", "*.example.com"));
607 assert!(!overlaps("*.example.com", "a.b.example.com"));
608 assert!(!overlaps("*.example.com", "example.com"));
609 assert!(!overlaps("a.example.com", "b.example.com"));
610
611 let none: Vec<String> = vec![];
612 assert!(allowed("anything.example.org", &none));
613 assert!(
614 !allowed("*.example.org", &none),
615 "wildcards need the allowlist"
616 );
617 let list = vec!["example.com".to_string(), "*.apps.example.net".to_string()];
618 assert!(allowed("example.com", &list));
619 assert!(allowed("a.b.example.com", &list));
620 assert!(!allowed("badexample.com", &list));
621 assert!(!allowed("example.org", &list));
622 assert!(!allowed("*.example.com", &list), "no wildcard entry for it");
623 assert!(allowed("x.apps.example.net", &list));
624 assert!(allowed("*.apps.example.net", &list));
625 assert!(allowed("*.team.apps.example.net", &list));
626 assert!(!allowed("*.example.net", &list));
627 }
628
629 #[test]
630 fn first_claim_wins_across_orgs() {
631 let ra = route("a", "s", "w", "app.example.com", "/");
633 let rb = route("b", "s", "w", "app.example.com", "/api");
634 let prev = vec![Claim {
635 host: "app.example.com".into(),
636 path: "/api".into(),
637 org: "b".into(),
638 stack: "s".into(),
639 service: "w".into(),
640 since: 100,
641 }];
642 let res = resolve(&[ra.clone(), rb.clone()], &prev, 200);
643 assert_eq!(res.accepted, vec![rb.clone()]);
644 assert_eq!(res.conflicts.len(), 1);
645 assert_eq!(res.conflicts[0].route, ra);
646 assert_eq!(
647 res.conflicts[0].reason,
648 "app.example.com is already served by another org"
649 );
650 assert_eq!(res.claims[0].since, 100);
651
652 let res = resolve(&[rb.clone(), ra.clone()], &[], 200);
654 assert_eq!(res.accepted, vec![ra.clone()]);
655 assert_eq!(res.claims[0].since, 200);
656 let res2 = resolve(&[ra.clone(), rb.clone()], &[], 200);
658 assert_eq!(res2.accepted, res.accepted);
659
660 let w = route("a", "s", "w", "*.example.com", "/");
662 let c = route("b", "t", "x", "shop.example.com", "/");
663 let res = resolve(&[c.clone(), w.clone()], &[], 1);
664 assert_eq!(res.accepted, vec![w]);
665 assert_eq!(res.conflicts[0].route, c);
666
667 let res = resolve(std::slice::from_ref(&ra), &prev, 300);
669 assert_eq!(res.accepted, vec![ra]);
670 }
671
672 #[test]
673 fn within_an_org_paths_split_a_host() {
674 let a = route("a", "s", "web", "app.example.com", "/");
675 let b = route("a", "s", "api", "app.example.com", "/api");
676 let c = route("a", "t", "api2", "app.example.com", "/api");
677 let res = resolve(&[a.clone(), b.clone(), c.clone()], &[], 1);
678 assert_eq!(res.accepted.len(), 2);
679 assert_eq!(res.conflicts.len(), 1);
680 assert_eq!(res.conflicts[0].route, c);
681 assert!(
682 res.conflicts[0].reason.contains("s/api"),
683 "{}",
684 res.conflicts[0].reason
685 );
686 }
687}