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 !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    /// An extra route's state, as `stack_status` reports a domain's.
139    pub fn extra_status(&self, r: &Route) -> Vec<DomainStatus> {
140        Observer::domains(self, &r.qualified_stack(), &r.service)
141    }
142
143    /// The extra routes [`Manager::apply_once`] serves: into `routes`, with
144    /// their upstream in `rotation`, or into `refused` with the reason.
145    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}