1use 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
21const SYSTEM_CA_PATHS: &[&str] = &[
23 "/etc/ssl/certs/ca-certificates.crt",
24 "/etc/pki/tls/certs/ca-bundle.crt",
25];
26
27pub struct AuthBridge {
30 pub volumes: Vec<String>,
32 pub env: BTreeMap<String, String>,
34 pub exec_start_pre: Vec<String>,
36 pub steps: Vec<Step>,
38}
39
40pub struct AuthBridgeParams<'a> {
42 pub service_name: &'a str,
43 pub service_provides: &'a [Capability],
47 pub enable_auth: bool,
48 pub config: &'a Config,
49 pub installed: &'a [crate::config::schema::InstalledService],
53 pub service_data: &'a Path,
55}
56
57pub fn build(params: &AuthBridgeParams<'_>) -> Result<Option<AuthBridge>> {
70 if !params.enable_auth {
71 return Ok(None);
72 }
73 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}