Skip to main content

isb_core/ingress/
extra.rs

1//! Routes that belong to no stack: a workspace's published ports
2//! (docs/concepts/workspaces.md#ports). They go through everything a
3//! stack's domains do (the org's allowlist, first-claim-wins, the org's
4//! provider: Caddy's public listeners or its Cloudflare Tunnel), with one
5//! upstream, the workspace's address, instead of a service's replicas.
6//!
7//! Their owner is the pseudo-stack `workspace:<name>` and the service
8//! `port-<port>`: no stack can have that name, so a port's claim never
9//! mixes with an app's.
10
11use super::*;
12use crate::spec::DomainSpec;
13
14/// A route to one instance outside any stack.
15#[derive(Debug, Clone, PartialEq)]
16pub struct ExtraRoute {
17    pub route: Route,
18    /// The instance's address; `None` while it has none (stopped).
19    pub upstream: Option<IpAddr>,
20}
21
22/// The owner a workspace's ports are filed under.
23pub fn workspace_stack(workspace: &str) -> String {
24    format!("workspace:{workspace}")
25}
26
27/// The service a port is filed under.
28pub fn port_service(port: u16) -> String {
29    format!("port-{port}")
30}
31
32/// The route for workspace `ws`'s `port` at `host`: a hostname, or `auto`
33/// for a generated `<port>-<ws>-<org>.<a-b-c-d>.sslip.io` (which needs the
34/// server's public address).
35pub 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    /// Replace the extra routes: one caller (the daemon's workspaces) owns
93    /// them all. Caddy is reloaded only when they changed.
94    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    /// Check a new extra route before it is kept: the org's allowlist and
105    /// provider, and no other claim on its name.
106    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    /// An extra route's state, as `stack_status` reports a domain's.
144    pub fn extra_status(&self, r: &Route) -> Vec<DomainStatus> {
145        Observer::domains(self, &r.qualified_stack(), &r.service)
146    }
147
148    /// The extra routes [`Manager::apply_once`] serves: into `routes`, with
149    /// their upstream in `rotation`, or into `refused` with the reason.
150    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}