Skip to main content

ryra_core/
auth_bridge.rs

1//! Auth bridge: artifacts that let an auth-enabled service talk to Caddy+Authelia.
2//!
3//! Services with `--auth` need to reach Authelia for OIDC. When Caddy is
4//! handling HTTPS with a self-signed cert, the service container must trust
5//! that cert and be able to resolve the auth provider's hostname to Caddy's
6//! (dynamic) container IP.
7//!
8//! This module is a pure builder: it reads probe files under `/etc/ssl/certs`
9//! and constructs [`Step::WriteFile`] entries for the caller to execute. It
10//! performs no writes itself, so planning remains side-effect-free and
11//! respects `--dry-run`.
12use std::collections::BTreeMap;
13use std::path::{Path, PathBuf};
14
15use crate::Step;
16use crate::capability::{Capability, find_installed_provider};
17use crate::config::schema::Config;
18use crate::error::{Error, Result};
19use crate::generate::GeneratedFile;
20
21/// System CA bundle locations probed in order. First hit wins.
22const SYSTEM_CA_PATHS: &[&str] = &[
23    "/etc/ssl/certs/ca-certificates.crt",
24    "/etc/pki/tls/certs/ca-bundle.crt",
25];
26
27/// Quadlet + filesystem artifacts a caller should merge into the service's
28/// generated unit and execute as part of the install plan.
29pub struct AuthBridge {
30    /// Extra `Volume=` entries for the service's `.container` unit.
31    pub volumes: Vec<String>,
32    /// Extra env vars for the service's `.env` file (CA trust for Python/Node).
33    pub env: BTreeMap<String, String>,
34    /// Extra `ExecStartPre=` entries for the service's `.container` unit.
35    pub exec_start_pre: Vec<String>,
36    /// Files to write before the service starts (CA bundle + helper scripts).
37    pub steps: Vec<Step>,
38}
39
40/// Inputs to [`build`].
41pub struct AuthBridgeParams<'a> {
42    pub service_name: &'a str,
43    /// Capabilities declared by the currently-installing service. The
44    /// bridge skips itself when the caller is the OIDC provider or the
45    /// reverse proxy — those services don't consume themselves.
46    pub service_provides: &'a [Capability],
47    pub enable_auth: bool,
48    pub config: &'a Config,
49    /// Snapshot of installed services. Production callers pass
50    /// [`crate::list_installed`]; tests construct synthetic data so
51    /// they can drive the function without writing real quadlet files.
52    pub installed: &'a [crate::config::schema::InstalledService],
53    /// Absolute path to the service's data dir (`~/.local/share/services/<name>`).
54    pub service_data: &'a Path,
55}
56
57/// Build auth-bridge artifacts for a service. Returns `Ok(None)` when the
58/// bridge does not apply — the caller isn't using auth, the service is
59/// authelia/caddy itself, authelia/Caddy aren't yet installed, or authelia
60/// has no URL (a Loopback exposure can't host OIDC).
61///
62/// The bridge routes the OIDC back-channel through Caddy as the internal TLS
63/// terminator regardless of how the *browser* reaches authelia: Caddy on a
64/// `*.internal` host, `tailscale serve` on a `*.ts.net` host, or an external
65/// proxy on a public domain. In every case the service container reaches
66/// authelia at its issuer host via Caddy (network alias + dynamic
67/// `/etc/hosts`) and trusts Caddy's self-signed CA through the mounted
68/// bundle. Only a Loopback authelia is excluded — it has no issuer URL.
69pub fn build(params: &AuthBridgeParams<'_>) -> Result<Option<AuthBridge>> {
70    if !params.enable_auth {
71        return Ok(None);
72    }
73    // The bridge wires consumers to providers — providers themselves
74    // (the OIDC IdP, the reverse proxy) don't need it.
75    if params.service_provides.contains(&Capability::OidcProvider)
76        || params.service_provides.contains(&Capability::ReverseProxy)
77    {
78        return Ok(None);
79    }
80    let Some(authelia) = find_installed_provider(params.installed, Capability::OidcProvider) else {
81        return Ok(None);
82    };
83    // The bridge keys the back-channel routing on authelia's issuer host
84    // (network alias + dynamic `/etc/hosts`). Without a URL that parses to a
85    // host there's nothing to route, so bail: a Loopback authelia has no URL,
86    // and a malformed one can't be resolved. Internal / Tailscale / Public
87    // all carry a host and route the same way.
88    let has_auth_host = authelia
89        .exposure
90        .url()
91        .and_then(|u| url::Url::parse(u).ok())
92        .and_then(|u| u.host_str().map(str::to_string))
93        .is_some();
94    if !has_auth_host {
95        return Ok(None);
96    }
97    // Caddy is the internal TLS terminator for the back-channel, so it must
98    // be installed. `add_service` turns a missing reverse proxy into a loud
99    // error (Error::AuthRequiresReverseProxy) before reaching this point —
100    // returning None here is the belt-and-suspenders path for callers that
101    // build the bridge without that pre-check.
102    if find_installed_provider(params.installed, Capability::ReverseProxy).is_none() {
103        return Ok(None);
104    }
105
106    let ryra_dir: PathBuf = params
107        .service_data
108        .parent()
109        .ok_or_else(|| Error::Bundle("service data dir has no parent directory".into()))?
110        .to_path_buf();
111
112    let merged_bundle = params.service_data.join("ca-bundle.crt");
113    let refresh_ca_script = params.service_data.join("refresh-ca-bundle.sh");
114    let auth_host_script = params.service_data.join("resolve-auth-host.sh");
115    let auth_hosts = params.service_data.join("auth-hosts.txt");
116
117    let mut volumes = Vec::new();
118    let mut env = BTreeMap::new();
119    let mut exec_start_pre = Vec::new();
120    let mut steps = Vec::new();
121
122    // --- CA bundle: system CAs + caddy's self-signed CA (if already present) ---
123    //
124    // Reading from /etc/ssl/certs is a read-only probe. If no system bundle is
125    // found, we start with an empty string — the refresh-ca-bundle.sh hook
126    // rebuilds it each start. If caddy's CA isn't on disk yet (caddy is being
127    // installed alongside), refresh-ca-bundle.sh will pick it up at first
128    // start. Either way, the placeholder is safe.
129    let ca_cert_host = ryra_dir.join("caddy-root-ca.crt");
130    let mut bundle = String::new();
131    for sys_path in SYSTEM_CA_PATHS {
132        if let Ok(content) = std::fs::read_to_string(sys_path) {
133            bundle = content;
134            break;
135        }
136    }
137    if let Ok(caddy_ca) = std::fs::read_to_string(&ca_cert_host) {
138        bundle.push_str("\n# services-caddy-ca\n");
139        bundle.push_str(&caddy_ca);
140    }
141    steps.push(Step::WriteFile(GeneratedFile {
142        path: merged_bundle.clone(),
143        content: bundle,
144    }));
145    volumes.push(format!(
146        "{}:/etc/ssl/certs/ca-certificates.crt:ro,z",
147        merged_bundle.display()
148    ));
149    // Python (requests/certifi) and Node don't honour the system CA store —
150    // they need explicit env vars.
151    for var in ["REQUESTS_CA_BUNDLE", "SSL_CERT_FILE", "NODE_EXTRA_CA_CERTS"] {
152        env.insert(var.into(), "/etc/ssl/certs/ca-certificates.crt".into());
153    }
154
155    // --- refresh-ca-bundle.sh: rebuild bundle at each service start ---
156    let refresh_script = render_refresh_ca_script(&ryra_dir, params.service_data);
157    steps.push(Step::WriteFile(GeneratedFile {
158        path: refresh_ca_script.clone(),
159        content: refresh_script,
160    }));
161    exec_start_pre.push(format!("-/bin/bash {}", refresh_ca_script.display()));
162
163    // --- resolve-auth-host.sh: dynamic /etc/hosts for auth domain ---
164    //
165    // The auth provider's hostname (typically an ICANN `.internal` address)
166    // isn't resolvable via normal DNS, so we write a small hosts file at
167    // service-start time mapping it to caddy's current container IP and
168    // bind-mount that over /etc/hosts.
169    if let Some(auth_url) = authelia.exposure.url()
170        && let Ok(parsed) = url::Url::parse(auth_url)
171        && let Some(host) = parsed.host_str()
172    {
173        let resolve_script = render_resolve_auth_host_script(params.service_data, host);
174        steps.push(Step::WriteFile(GeneratedFile {
175            path: auth_host_script.clone(),
176            content: resolve_script,
177        }));
178        steps.push(Step::WriteFile(GeneratedFile {
179            path: auth_hosts.clone(),
180            content: format!("127.0.0.1 {host}\n"),
181        }));
182        exec_start_pre.push(format!("-/bin/bash {}", auth_host_script.display()));
183        volumes.push(format!("{}:/etc/hosts:z", auth_hosts.display()));
184    }
185
186    Ok(Some(AuthBridge {
187        volumes,
188        env,
189        exec_start_pre,
190        steps,
191    }))
192}
193
194fn render_refresh_ca_script(ryra_dir: &Path, service_data: &Path) -> String {
195    format!(
196        "#!/bin/bash\n\
197         CADDY_CA=\"{ryra_dir}/caddy-root-ca.crt\"\n\
198         MERGED=\"{service_data}/ca-bundle.crt\"\n\
199         [ -f \"$CADDY_CA\" ] || exit 0\n\
200         for f in /etc/ssl/certs/ca-certificates.crt /etc/pki/tls/certs/ca-bundle.crt; do\n\
201           if [ -f \"$f\" ]; then cp \"$f\" \"$MERGED\"; break; fi\n\
202         done\n\
203         cat \"$CADDY_CA\" >> \"$MERGED\" 2>/dev/null || true\n\
204         exit 0\n",
205        ryra_dir = ryra_dir.display(),
206        service_data = service_data.display(),
207    )
208}
209
210fn render_resolve_auth_host_script(service_data: &Path, host: &str) -> String {
211    // `timeout 5` guards against a wedged podman socket: this runs in
212    // ExecStartPre, and without the guard a hung podman blocks service
213    // startup indefinitely. On timeout we fall through to 127.0.0.1 —
214    // OIDC won't work, but the service comes up instead of hanging.
215    format!(
216        "#!/bin/bash\n\
217         # Resolve caddy's current IP for the auth domain\n\
218         HOSTS=\"{service_data}/auth-hosts.txt\"\n\
219         CADDY_IP=$(timeout 5 podman inspect caddy --format '{{{{range .NetworkSettings.Networks}}}}{{{{.IPAddress}}}} {{{{end}}}}' 2>/dev/null | awk '{{print $1}}')\n\
220         if [ -n \"$CADDY_IP\" ]; then\n\
221           echo \"$CADDY_IP {host}\" > \"$HOSTS\"\n\
222         else\n\
223           echo \"127.0.0.1 {host}\" > \"$HOSTS\"\n\
224         fi\n\
225         exit 0\n",
226        service_data = service_data.display(),
227        host = host,
228    )
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::config::schema::{AuthCredentials, InstalledService};
235    use std::collections::BTreeMap;
236
237    type TestResult = std::result::Result<(), Box<dyn std::error::Error>>;
238
239    /// Capability list a known-by-name service in these tests provides.
240    /// Used to populate both `InstalledService::provides` and
241    /// `AuthBridgeParams::service_provides` without depending on the
242    /// default registry being cloned in tempdirs.
243    fn provides_for(name: &str) -> &'static [Capability] {
244        match name {
245            "authelia" => &[Capability::OidcProvider, Capability::ForwardAuthProvider],
246            "caddy" => &[Capability::ReverseProxy],
247            _ => &[],
248        }
249    }
250
251    fn installed(name: &str, url: Option<&str>) -> InstalledService {
252        let exposure = match url {
253            Some(u) => crate::Exposure::from_url(u),
254            None => crate::Exposure::Loopback,
255        };
256        InstalledService {
257            name: name.into(),
258            version: "0.1.0".into(),
259            repo: "default".into(),
260            ports: BTreeMap::new(),
261            auth_kind: None,
262            exposure,
263            provides: provides_for(name).to_vec(),
264            installed: true,
265        }
266    }
267
268    /// Build a (Config, installed-list) pair so tests can drive `build()`
269    /// without writing real quadlet files. The `services` list is now
270    /// passed alongside `config`, not embedded in it.
271    fn fixture(
272        services: Vec<InstalledService>,
273        auth: Option<AuthCredentials>,
274    ) -> (Config, Vec<InstalledService>) {
275        let cfg = Config {
276            auth,
277            ..Config::default()
278        };
279        (cfg, services)
280    }
281
282    fn write_paths(bridge: &AuthBridge) -> Vec<&Path> {
283        bridge
284            .steps
285            .iter()
286            .filter_map(|s| match s {
287                Step::WriteFile(f) => Some(f.path.as_path()),
288                _ => None,
289            })
290            .collect()
291    }
292
293    #[test]
294    fn returns_none_when_auth_disabled() -> TestResult {
295        let tmp = tempfile::tempdir()?;
296        let (cfg, installed) = fixture(
297            vec![installed("authelia", Some("https://auth.internal"))],
298            None,
299        );
300        let out = build(&AuthBridgeParams {
301            service_name: "forgejo",
302            service_provides: provides_for("forgejo"),
303            enable_auth: false,
304            config: &cfg,
305            installed: &installed,
306            service_data: tmp.path(),
307        })?;
308        assert!(out.is_none());
309        Ok(())
310    }
311
312    #[test]
313    fn returns_none_when_authelia_not_installed() -> TestResult {
314        let tmp = tempfile::tempdir()?;
315        let (cfg, installed) = fixture(vec![installed("caddy", None)], None);
316        let out = build(&AuthBridgeParams {
317            service_name: "forgejo",
318            service_provides: provides_for("forgejo"),
319            enable_auth: true,
320            config: &cfg,
321            installed: &installed,
322            service_data: tmp.path(),
323        })?;
324        assert!(out.is_none());
325        Ok(())
326    }
327
328    #[test]
329    fn returns_none_when_caddy_not_installed() -> TestResult {
330        let tmp = tempfile::tempdir()?;
331        let (cfg, installed) = fixture(
332            vec![installed("authelia", Some("https://auth.internal"))],
333            None,
334        );
335        let out = build(&AuthBridgeParams {
336            service_name: "forgejo",
337            service_provides: provides_for("forgejo"),
338            enable_auth: true,
339            config: &cfg,
340            installed: &installed,
341            service_data: tmp.path(),
342        })?;
343        assert!(out.is_none());
344        Ok(())
345    }
346
347    #[test]
348    fn returns_none_for_authelia_itself() -> TestResult {
349        let tmp = tempfile::tempdir()?;
350        let (cfg, installed) = fixture(
351            vec![
352                installed("authelia", Some("https://auth.internal")),
353                installed("caddy", None),
354            ],
355            None,
356        );
357        let out = build(&AuthBridgeParams {
358            service_name: "authelia",
359            service_provides: provides_for("authelia"),
360            enable_auth: true,
361            config: &cfg,
362            installed: &installed,
363            service_data: tmp.path(),
364        })?;
365        assert!(out.is_none());
366        Ok(())
367    }
368
369    #[test]
370    fn builds_for_tailscale_authelia_url() -> TestResult {
371        // Authelia exposed via `tailscale serve` (`*.ts.net`) still routes
372        // the OIDC back-channel through Caddy as the internal TLS
373        // terminator, so the bridge applies and writes the same CA-trust +
374        // host-resolve artifacts, keyed on the ts.net hostname.
375        let tmp = tempfile::tempdir()?;
376        let service_data = tmp.path().join("forgejo");
377        let bridge = build_forgejo_bridge(&service_data, Some("https://auth.example.ts.net"))?;
378
379        let hosts_step = bridge
380            .steps
381            .iter()
382            .find_map(|s| match s {
383                Step::WriteFile(f) if f.path == service_data.join("auth-hosts.txt") => Some(f),
384                _ => None,
385            })
386            .ok_or("auth-hosts.txt step missing")?;
387        assert_eq!(hosts_step.content, "127.0.0.1 auth.example.ts.net\n");
388        Ok(())
389    }
390
391    #[test]
392    fn returns_none_for_authelia_url_without_host() -> TestResult {
393        // Defensive: a URL that fails to parse or lacks a host shouldn't
394        // crash the builder — it should bail cleanly.
395        let tmp = tempfile::tempdir()?;
396        let (cfg, installed) = fixture(
397            vec![
398                installed("authelia", Some("not-a-url")),
399                installed("caddy", None),
400            ],
401            None,
402        );
403        let out = build(&AuthBridgeParams {
404            service_name: "forgejo",
405            service_provides: provides_for("forgejo"),
406            enable_auth: true,
407            config: &cfg,
408            installed: &installed,
409            service_data: tmp.path(),
410        })?;
411        assert!(out.is_none());
412        Ok(())
413    }
414
415    #[test]
416    fn returns_none_for_caddy_itself() -> TestResult {
417        let tmp = tempfile::tempdir()?;
418        let (cfg, installed) = fixture(
419            vec![
420                installed("authelia", Some("https://auth.internal")),
421                installed("caddy", None),
422            ],
423            None,
424        );
425        let out = build(&AuthBridgeParams {
426            service_name: "caddy",
427            service_provides: provides_for("caddy"),
428            enable_auth: true,
429            config: &cfg,
430            installed: &installed,
431            service_data: tmp.path(),
432        })?;
433        assert!(out.is_none());
434        Ok(())
435    }
436
437    #[test]
438    fn build_does_not_write_to_service_data() -> TestResult {
439        // The whole point of this refactor: a pure planning call must not
440        // touch the service's data dir.
441        let tmp = tempfile::tempdir()?;
442        let service_data = tmp.path().join("forgejo");
443        std::fs::create_dir_all(&service_data)?;
444
445        let (cfg, installed) = fixture(
446            vec![
447                installed("authelia", Some("https://auth.internal")),
448                installed("caddy", None),
449            ],
450            None,
451        );
452        let out = build(&AuthBridgeParams {
453            service_name: "forgejo",
454            service_provides: provides_for("forgejo"),
455            enable_auth: true,
456            config: &cfg,
457            installed: &installed,
458            service_data: &service_data,
459        })?;
460        assert!(out.is_some());
461
462        let entries: Vec<_> = std::fs::read_dir(&service_data)?.collect();
463        assert!(
464            entries.is_empty(),
465            "build() must not write to service_data, found: {entries:?}"
466        );
467        Ok(())
468    }
469
470    fn build_forgejo_bridge(service_data: &Path, authelia_url: Option<&str>) -> Result<AuthBridge> {
471        let (cfg, installed) = fixture(
472            vec![
473                installed("authelia", authelia_url),
474                installed("caddy", None),
475            ],
476            None,
477        );
478        build(&AuthBridgeParams {
479            service_name: "forgejo",
480            service_provides: provides_for("forgejo"),
481            enable_auth: true,
482            config: &cfg,
483            installed: &installed,
484            service_data,
485        })?
486        .ok_or_else(|| {
487            Error::Bundle(
488                "auth bridge unexpectedly returned None for forgejo + authelia + caddy".into(),
489            )
490        })
491    }
492
493    #[test]
494    fn emits_expected_write_file_steps() -> TestResult {
495        let tmp = tempfile::tempdir()?;
496        let service_data = tmp.path().join("forgejo");
497        let bridge = build_forgejo_bridge(&service_data, Some("https://auth.internal"))?;
498
499        let paths = write_paths(&bridge);
500        assert!(paths.contains(&service_data.join("ca-bundle.crt").as_path()));
501        assert!(paths.contains(&service_data.join("refresh-ca-bundle.sh").as_path()));
502        assert!(paths.contains(&service_data.join("resolve-auth-host.sh").as_path()));
503        assert!(paths.contains(&service_data.join("auth-hosts.txt").as_path()));
504        Ok(())
505    }
506
507    #[test]
508    fn returns_none_when_authelia_has_no_url() -> TestResult {
509        // Bridge dispatch reads authelia's URL hostname; without one, ryra
510        // can't tell if the deployment is Caddy-local or something else, so
511        // it bails rather than guessing.
512        let tmp = tempfile::tempdir()?;
513        let (cfg, installed) = fixture(
514            vec![installed("authelia", None), installed("caddy", None)],
515            None,
516        );
517        let out = build(&AuthBridgeParams {
518            service_name: "forgejo",
519            service_provides: provides_for("forgejo"),
520            enable_auth: true,
521            config: &cfg,
522            installed: &installed,
523            service_data: tmp.path(),
524        })?;
525        assert!(out.is_none());
526        Ok(())
527    }
528
529    #[test]
530    fn emits_ca_trust_volume_and_env() -> TestResult {
531        let tmp = tempfile::tempdir()?;
532        let service_data = tmp.path().join("forgejo");
533        let bridge = build_forgejo_bridge(&service_data, Some("https://auth.internal"))?;
534
535        let bundle_mount = format!(
536            "{}:/etc/ssl/certs/ca-certificates.crt:ro,z",
537            service_data.join("ca-bundle.crt").display()
538        );
539        assert!(bridge.volumes.contains(&bundle_mount));
540        assert_eq!(
541            bridge.env.get("REQUESTS_CA_BUNDLE").map(String::as_str),
542            Some("/etc/ssl/certs/ca-certificates.crt")
543        );
544        assert_eq!(
545            bridge.env.get("SSL_CERT_FILE").map(String::as_str),
546            Some("/etc/ssl/certs/ca-certificates.crt")
547        );
548        assert_eq!(
549            bridge.env.get("NODE_EXTRA_CA_CERTS").map(String::as_str),
550            Some("/etc/ssl/certs/ca-certificates.crt")
551        );
552        Ok(())
553    }
554
555    #[test]
556    fn auth_hosts_contains_authelia_hostname() -> TestResult {
557        let tmp = tempfile::tempdir()?;
558        let service_data = tmp.path().join("forgejo");
559        // *.internal is the bridge's domain — non-internal URLs fall
560        // outside Caddy-local dispatch and don't get a bridge.
561        let bridge = build_forgejo_bridge(&service_data, Some("https://auth.internal"))?;
562
563        let hosts_step = bridge
564            .steps
565            .iter()
566            .find_map(|s| match s {
567                Step::WriteFile(f) if f.path == service_data.join("auth-hosts.txt") => Some(f),
568                _ => None,
569            })
570            .ok_or("auth-hosts.txt step missing")?;
571        assert_eq!(hosts_step.content, "127.0.0.1 auth.internal\n");
572        Ok(())
573    }
574
575    #[test]
576    fn exec_start_pre_references_emitted_scripts() -> TestResult {
577        let tmp = tempfile::tempdir()?;
578        let service_data = tmp.path().join("forgejo");
579        let bridge = build_forgejo_bridge(&service_data, Some("https://auth.internal"))?;
580
581        let refresh = format!(
582            "-/bin/bash {}",
583            service_data.join("refresh-ca-bundle.sh").display()
584        );
585        let resolve = format!(
586            "-/bin/bash {}",
587            service_data.join("resolve-auth-host.sh").display()
588        );
589        assert!(bridge.exec_start_pre.contains(&refresh));
590        assert!(bridge.exec_start_pre.contains(&resolve));
591        Ok(())
592    }
593}