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>> {
69 if !params.enable_auth {
70 return Ok(None);
71 }
72 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 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 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 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 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 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 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 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 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 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 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 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 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 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}