Skip to main content

isb_core/ingress/
domain.rs

1//! A service's `domains:` entries: validation, generated hostnames, org
2//! allowlists, and who gets a hostname when two stacks ask for it.
3
4use 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
13/// The `host` that asks for a generated name.
14pub const AUTO: &str = "auto";
15
16/// One validated `domains:` entry of one service.
17#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
18pub struct Route {
19    pub org: OrgId,
20    /// The stack's own name (not qualified).
21    pub stack: String,
22    pub service: String,
23    /// Lowercase; `auto` already resolved.
24    pub host: String,
25    /// `/` or a prefix without a trailing slash.
26    pub path: String,
27    /// The replicas' port; `None` for a redirect.
28    pub port: Option<u16>,
29    pub https: bool,
30    pub redirect: Option<String>,
31    pub strip_prefix: bool,
32    /// Made from another entry's `www_redirect`.
33    pub generated: bool,
34    /// A generated (`host: auto`) name: outside any allowlist.
35    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    /// Who the route belongs to: `(org, stack, service)`.
48    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    /// The URL it is reached at, given the public ports.
57    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
72/// Check one service's entries without anything host-specific: hostname
73/// syntax, paths, ports, redirects, duplicates.
74pub 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
106/// A hostname a domain may name: DNS labels, at least two of them, the
107/// first optionally `*`. No IP addresses, `localhost` or isb's own `.isb`.
108pub 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
145/// `None` and `/` are `/`; otherwise an absolute prefix, trailing slash
146/// dropped, without query, fragment, wildcards or whitespace.
147pub 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
180/// Whether a redirect target keeps the request's path: it has none of its own.
181pub 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
192/// The generated name for `host: auto`:
193/// `<service>-<stack>-<org>.<a-b-c-d>.sslip.io`, which sslip.io resolves to
194/// `a.b.c.d`. A first label over 63 characters is shortened with a hash.
195pub 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
223/// Expand one service's entries into routes: `auto` resolved (needs
224/// `public_ip`), `www_redirect` adding its redirect route.
225pub 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
280/// Whether two host patterns can name the same host. A wildcard covers
281/// exactly one label, as in Caddy and TLS.
282pub 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
298/// Is `host` within an org's allowlist? Each entry is a domain suffix:
299/// `example.com` allows it and every name under it; `*.example.com` also
300/// allows wildcard hosts under `example.com`. An empty list allows any
301/// concrete name and no wildcard. Generated (`auto`) names are always
302/// allowed, and are not checked here.
303pub 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/// A claim on a hostname and path, kept across daemon restarts so the first
319/// claimant keeps it.
320#[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    /// Unix seconds the claim was first granted.
328    pub since: u64,
329}
330
331/// A route refused because another one holds its name.
332#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
333pub struct Conflict {
334    pub route: Route,
335    /// Shown to the route's own org: never names another org.
336    pub reason: String,
337}
338
339/// The outcome of [`resolve`].
340#[derive(Debug, Clone, Default)]
341pub struct Resolution {
342    pub accepted: Vec<Route>,
343    pub conflicts: Vec<Conflict>,
344    /// The claims to keep: one per accepted route.
345    pub claims: Vec<Claim>,
346}
347
348/// Decide which routes are served. First claim wins: routes already holding
349/// a claim go first (oldest first), then new ones in name order, so the
350/// outcome never depends on the order stacks were loaded in.
351///
352/// - Across orgs a hostname belongs to one org: a route whose host overlaps
353///   (equal, or covered by a wildcard) one another org already holds is
354///   refused, whatever the paths.
355/// - Within an org, one `(host, path)` goes to one service.
356pub 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        // Unknown keys are refused, as everywhere in the spec.
489        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        // Deterministic.
546        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        // b claimed first (persisted): it keeps the name although a sorts first.
632        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        // No history: name order decides, and the claim is stamped now.
653        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        // Same input order-independent.
657        let res2 = resolve(&[ra.clone(), rb.clone()], &[], 200);
658        assert_eq!(res2.accepted, res.accepted);
659
660        // A wildcard of another org blocks names under it.
661        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        // The holder leaves: the next claimant gets the name.
668        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}