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}