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