Skip to main content

ryra_core/
exposure.rs

1//! How a service is exposed to clients. Decided once at install time
2//! (`--url`, `--tailscale`, the interactive prompt, or auto-derived) and
3//! threaded through the planner so every code path that needs to know
4//! "where does this service live?" pattern-matches a single typed value
5//! instead of juggling a parallel `(Option<url>, bool)` pair where some
6//! combinations were silently invalid (e.g. a `*.ts.net` URL with
7//! `tailscale_enabled = false`).
8
9/// True if the URL's host is a Tailscale MagicDNS name (`*.ts.net`). When
10/// this matches, ryra skips the dances it does for `.internal` (Caddy route,
11/// `/etc/hosts` entry, local CA trust) — Tailscale's tunnel already provides
12/// routing, DNS, and encryption. Templates still populate normally so
13/// service-specific config (trusted_domains, OIDC callbacks) picks up the
14/// Tailscale hostname.
15pub fn is_tailscale_url(url: &str) -> bool {
16    url::Url::parse(url)
17        .ok()
18        .and_then(|u| u.host_str().map(|h| h.to_ascii_lowercase()))
19        .is_some_and(|h| h.ends_with(".ts.net"))
20}
21
22/// Serialized form on disk uses an internal `kind` tag so the variant
23/// is explicit in the TOML — no guessing whether `url = "foo.ts.net"`
24/// implies Tailscale-mode or not.
25#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
26#[serde(tag = "kind", rename_all = "snake_case")]
27pub enum Exposure {
28    /// No public-facing URL. Service runs on `http://127.0.0.1:<port>`
29    /// only — reachable from the host but nothing routes external
30    /// traffic to it. Used for services that don't need a domain
31    /// (e.g. inbucket when the user only hits it via localhost).
32    Loopback,
33    /// LAN-only via Caddy at a `*.internal` hostname. Self-signed
34    /// certs from Caddy's internal CA — useful on a single machine
35    /// where the user has imported ryra's CA into their browser.
36    Internal { url: String },
37    /// Exposed on the user's tailnet at `<service>.<tailnet>.ts.net`
38    /// via `tailscale serve` on the host. Real cert from the
39    /// Tailscale-managed CA, no Caddyfile entry needed.
40    Tailscale { url: String },
41    /// A public hostname. Caddy is the reverse proxy when installed
42    /// (LE or self-signed depending on `tls.caddy`); without Caddy,
43    /// the user is fronting with their own proxy (Cloudflare Tunnel,
44    /// nginx, etc.) and ryra leaves routing alone.
45    Public { url: String },
46}
47
48impl Exposure {
49    /// Browser-visible URL, if any. Convenient when something
50    /// downstream is OK with `Option<&str>` (template context, OIDC
51    /// redirects) and doesn't care about the routing variant.
52    pub fn url(&self) -> Option<&str> {
53        match self {
54            Exposure::Loopback => None,
55            Exposure::Internal { url } | Exposure::Tailscale { url } | Exposure::Public { url } => {
56                Some(url)
57            }
58        }
59    }
60
61    /// True when this exposure is reached via `tailscale serve` instead
62    /// of Caddy. Used to skip Caddyfile routes for `*.ts.net` URLs.
63    pub fn is_tailscale(&self) -> bool {
64        matches!(self, Exposure::Tailscale { .. })
65    }
66
67    /// For Tailscale exposures, the Tailscale Service name (the part
68    /// after `svc:` — i.e. the first DNS label of the URL host). With
69    /// per-host scoping this is `<service>-<host>` (e.g.
70    /// `vikunja-debian`). Used by remove/reset paths to address the
71    /// admin-API service definition without re-deriving the host from
72    /// `tailscale status` (the URL was captured at install time, so
73    /// renaming the host post-install doesn't break teardown).
74    pub fn tailscale_svc_name(&self) -> Option<String> {
75        let url = match self {
76            Exposure::Tailscale { url } => url,
77            _ => return None,
78        };
79        url::Url::parse(url)
80            .ok()
81            .and_then(|u| u.host_str().map(|h| h.to_ascii_lowercase()))
82            .and_then(|h| h.split_once('.').map(|(label, _)| label.to_string()))
83    }
84
85    /// Stable string form of the variant, for the `exposure` field in
86    /// `metadata.toml` and reading it back. Mirrors the snake_case names
87    /// used by serde's `tag = "kind"` representation so the two stay in
88    /// lockstep.
89    pub fn kind_str(&self) -> &'static str {
90        match self {
91            Exposure::Loopback => "loopback",
92            Exposure::Internal { .. } => "internal",
93            Exposure::Tailscale { .. } => "tailscale",
94            Exposure::Public { .. } => "public",
95        }
96    }
97
98    /// Classify a user-supplied URL string into the corresponding
99    /// Exposure variant. `*.internal` → `Internal`, `*.ts.net` →
100    /// `Tailscale`, anything else → `Public`. Used by the CLI when a
101    /// raw `--url <X>` flag is passed.
102    pub fn from_url(url: &str) -> Self {
103        let host = url::Url::parse(url)
104            .ok()
105            .and_then(|u| u.host_str().map(|h| h.to_ascii_lowercase()));
106        match host.as_deref() {
107            Some(h) if h.ends_with(".internal") => Exposure::Internal {
108                url: url.to_string(),
109            },
110            Some(h) if h.ends_with(".ts.net") => Exposure::Tailscale {
111                url: url.to_string(),
112            },
113            _ => Exposure::Public {
114                url: url.to_string(),
115            },
116        }
117    }
118}
119
120/// True when a service URL targets Caddy's local-CA `*.internal` domain.
121/// Used to gate `/etc/hosts` writes and CA trust setup: Tailscale and
122/// External URLs handle DNS / trust through other paths.
123///
124/// Defined as "what [`Exposure::from_url`] classifies as `Internal`" so
125/// there is exactly one URL-classification rule.
126pub fn is_caddy_local_url(url: &str) -> bool {
127    matches!(Exposure::from_url(url), Exposure::Internal { .. })
128}
129
130/// Reject the combination where authelia is exposed locally (`*.internal`)
131/// but the service being added will be reachable somewhere broader
132/// (tailnet, custom URL). In that combination, off-host clients (a phone
133/// on the tailnet, a public browser) hit the service fine but can't follow
134/// the OIDC redirect to `authelia.internal`, because that hostname only
135/// resolves on the ryra host.
136///
137/// The reverse, authelia broader than the service, is fine: the local
138/// browser reaches both, and `*.ts.net` resolves on the host via MagicDNS.
139pub fn check_auth_exposure_compat(
140    config: &crate::config::schema::Config,
141    service: &str,
142    service_url: Option<&str>,
143) -> crate::error::Result<()> {
144    let Some(auth) = &config.auth else {
145        return Ok(());
146    };
147    let auth_url = auth.url();
148    if !is_caddy_local_url(auth_url) {
149        return Ok(());
150    }
151    let Some(svc_url) = service_url else {
152        return Ok(());
153    };
154    if is_caddy_local_url(svc_url) {
155        return Ok(());
156    }
157    Err(crate::error::Error::AuthExposureMismatch {
158        auth_url: auth_url.to_string(),
159        service: service.to_string(),
160        service_url: svc_url.to_string(),
161    })
162}
163
164/// True when the URL's host is publicly resolvable — i.e. something a
165/// browser on the open internet would expect to reach. Used by the CLI
166/// to decide whether to surface the Let's Encrypt prompt.
167///
168/// False for hosts that are LAN/loopback/tailnet by construction:
169/// `*.internal`, `*.localhost`, `*.local`, the bare `localhost`,
170/// `*.ts.net`, and any literal IP address.
171pub fn is_public_url(url: &str) -> bool {
172    let Some(host) = url::Url::parse(url)
173        .ok()
174        .and_then(|u| u.host_str().map(|h| h.to_ascii_lowercase()))
175    else {
176        return false;
177    };
178    // url::Url wraps IPv6 hosts in `[ ]`; strip them before the IpAddr parse.
179    let bare = host
180        .strip_prefix('[')
181        .and_then(|s| s.strip_suffix(']'))
182        .unwrap_or(&host);
183    if bare.parse::<std::net::IpAddr>().is_ok() {
184        return false;
185    }
186    if host == "localhost" {
187        return false;
188    }
189    !(host.ends_with(".internal")
190        || host.ends_with(".localhost")
191        || host.ends_with(".local")
192        || host.ends_with(".ts.net"))
193}