use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::Step;
use crate::capability::{Capability, find_installed_provider};
use crate::config::schema::Config;
use crate::error::{Error, Result};
use crate::generate::GeneratedFile;
const SYSTEM_CA_PATHS: &[&str] = &[
"/etc/ssl/certs/ca-certificates.crt",
"/etc/pki/tls/certs/ca-bundle.crt",
];
pub struct AuthBridge {
pub volumes: Vec<String>,
pub env: BTreeMap<String, String>,
pub exec_start_pre: Vec<String>,
pub steps: Vec<Step>,
}
pub struct AuthBridgeParams<'a> {
pub service_name: &'a str,
pub service_provides: &'a [Capability],
pub enable_auth: bool,
pub config: &'a Config,
pub installed: &'a [crate::config::schema::InstalledService],
pub service_data: &'a Path,
}
pub fn build(params: &AuthBridgeParams<'_>) -> Result<Option<AuthBridge>> {
if !params.enable_auth {
return Ok(None);
}
if params.service_provides.contains(&Capability::OidcProvider)
|| params.service_provides.contains(&Capability::ReverseProxy)
{
return Ok(None);
}
let Some(authelia) = find_installed_provider(params.installed, Capability::OidcProvider) else {
return Ok(None);
};
let has_auth_host = authelia
.exposure
.url()
.and_then(|u| url::Url::parse(u).ok())
.and_then(|u| u.host_str().map(str::to_string))
.is_some();
if !has_auth_host {
return Ok(None);
}
if find_installed_provider(params.installed, Capability::ReverseProxy).is_none() {
return Ok(None);
}
let ryra_dir: PathBuf = params
.service_data
.parent()
.ok_or_else(|| Error::Bundle("service data dir has no parent directory".into()))?
.to_path_buf();
let merged_bundle = params.service_data.join("ca-bundle.crt");
let refresh_ca_script = params.service_data.join("refresh-ca-bundle.sh");
let auth_host_script = params.service_data.join("resolve-auth-host.sh");
let auth_hosts = params.service_data.join("auth-hosts.txt");
let mut volumes = Vec::new();
let mut env = BTreeMap::new();
let mut exec_start_pre = Vec::new();
let mut steps = Vec::new();
let ca_cert_host = ryra_dir.join("caddy-root-ca.crt");
let mut bundle = String::new();
for sys_path in SYSTEM_CA_PATHS {
if let Ok(content) = std::fs::read_to_string(sys_path) {
bundle = content;
break;
}
}
if let Ok(caddy_ca) = std::fs::read_to_string(&ca_cert_host) {
bundle.push_str("\n# services-caddy-ca\n");
bundle.push_str(&caddy_ca);
}
steps.push(Step::WriteFile(GeneratedFile {
path: merged_bundle.clone(),
content: bundle,
}));
volumes.push(format!(
"{}:/etc/ssl/certs/ca-certificates.crt:ro,z",
merged_bundle.display()
));
for var in ["REQUESTS_CA_BUNDLE", "SSL_CERT_FILE", "NODE_EXTRA_CA_CERTS"] {
env.insert(var.into(), "/etc/ssl/certs/ca-certificates.crt".into());
}
let refresh_script = render_refresh_ca_script(&ryra_dir, params.service_data);
steps.push(Step::WriteFile(GeneratedFile {
path: refresh_ca_script.clone(),
content: refresh_script,
}));
exec_start_pre.push(format!("-/bin/bash {}", refresh_ca_script.display()));
if let Some(auth_url) = authelia.exposure.url()
&& let Ok(parsed) = url::Url::parse(auth_url)
&& let Some(host) = parsed.host_str()
{
let resolve_script = render_resolve_auth_host_script(params.service_data, host);
steps.push(Step::WriteFile(GeneratedFile {
path: auth_host_script.clone(),
content: resolve_script,
}));
steps.push(Step::WriteFile(GeneratedFile {
path: auth_hosts.clone(),
content: format!("127.0.0.1 {host}\n"),
}));
exec_start_pre.push(format!("-/bin/bash {}", auth_host_script.display()));
volumes.push(format!("{}:/etc/hosts:z", auth_hosts.display()));
}
Ok(Some(AuthBridge {
volumes,
env,
exec_start_pre,
steps,
}))
}
fn render_refresh_ca_script(ryra_dir: &Path, service_data: &Path) -> String {
format!(
"#!/bin/bash\n\
CADDY_CA=\"{ryra_dir}/caddy-root-ca.crt\"\n\
MERGED=\"{service_data}/ca-bundle.crt\"\n\
[ -f \"$CADDY_CA\" ] || exit 0\n\
for f in /etc/ssl/certs/ca-certificates.crt /etc/pki/tls/certs/ca-bundle.crt; do\n\
if [ -f \"$f\" ]; then cp \"$f\" \"$MERGED\"; break; fi\n\
done\n\
cat \"$CADDY_CA\" >> \"$MERGED\" 2>/dev/null || true\n\
exit 0\n",
ryra_dir = ryra_dir.display(),
service_data = service_data.display(),
)
}
fn render_resolve_auth_host_script(service_data: &Path, host: &str) -> String {
format!(
"#!/bin/bash\n\
# Resolve caddy's current IP for the auth domain\n\
HOSTS=\"{service_data}/auth-hosts.txt\"\n\
CADDY_IP=$(timeout 5 podman inspect caddy --format '{{{{range .NetworkSettings.Networks}}}}{{{{.IPAddress}}}} {{{{end}}}}' 2>/dev/null | awk '{{print $1}}')\n\
if [ -n \"$CADDY_IP\" ]; then\n\
echo \"$CADDY_IP {host}\" > \"$HOSTS\"\n\
else\n\
echo \"127.0.0.1 {host}\" > \"$HOSTS\"\n\
fi\n\
exit 0\n",
service_data = service_data.display(),
host = host,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::schema::{AuthCredentials, InstalledService};
use std::collections::BTreeMap;
type TestResult = std::result::Result<(), Box<dyn std::error::Error>>;
fn provides_for(name: &str) -> &'static [Capability] {
match name {
"authelia" => &[Capability::OidcProvider, Capability::ForwardAuthProvider],
"caddy" => &[Capability::ReverseProxy],
_ => &[],
}
}
fn installed(name: &str, url: Option<&str>) -> InstalledService {
let exposure = match url {
Some(u) => crate::Exposure::from_url(u),
None => crate::Exposure::Loopback,
};
InstalledService {
name: name.into(),
version: "0.1.0".into(),
repo: "default".into(),
ports: BTreeMap::new(),
auth_kind: None,
exposure,
provides: provides_for(name).to_vec(),
installed: true,
}
}
fn fixture(
services: Vec<InstalledService>,
auth: Option<AuthCredentials>,
) -> (Config, Vec<InstalledService>) {
let cfg = Config {
auth,
..Config::default()
};
(cfg, services)
}
fn write_paths(bridge: &AuthBridge) -> Vec<&Path> {
bridge
.steps
.iter()
.filter_map(|s| match s {
Step::WriteFile(f) => Some(f.path.as_path()),
_ => None,
})
.collect()
}
#[test]
fn returns_none_when_auth_disabled() -> TestResult {
let tmp = tempfile::tempdir()?;
let (cfg, installed) = fixture(
vec![installed("authelia", Some("https://auth.internal"))],
None,
);
let out = build(&AuthBridgeParams {
service_name: "forgejo",
service_provides: provides_for("forgejo"),
enable_auth: false,
config: &cfg,
installed: &installed,
service_data: tmp.path(),
})?;
assert!(out.is_none());
Ok(())
}
#[test]
fn returns_none_when_authelia_not_installed() -> TestResult {
let tmp = tempfile::tempdir()?;
let (cfg, installed) = fixture(vec![installed("caddy", None)], None);
let out = build(&AuthBridgeParams {
service_name: "forgejo",
service_provides: provides_for("forgejo"),
enable_auth: true,
config: &cfg,
installed: &installed,
service_data: tmp.path(),
})?;
assert!(out.is_none());
Ok(())
}
#[test]
fn returns_none_when_caddy_not_installed() -> TestResult {
let tmp = tempfile::tempdir()?;
let (cfg, installed) = fixture(
vec![installed("authelia", Some("https://auth.internal"))],
None,
);
let out = build(&AuthBridgeParams {
service_name: "forgejo",
service_provides: provides_for("forgejo"),
enable_auth: true,
config: &cfg,
installed: &installed,
service_data: tmp.path(),
})?;
assert!(out.is_none());
Ok(())
}
#[test]
fn returns_none_for_authelia_itself() -> TestResult {
let tmp = tempfile::tempdir()?;
let (cfg, installed) = fixture(
vec![
installed("authelia", Some("https://auth.internal")),
installed("caddy", None),
],
None,
);
let out = build(&AuthBridgeParams {
service_name: "authelia",
service_provides: provides_for("authelia"),
enable_auth: true,
config: &cfg,
installed: &installed,
service_data: tmp.path(),
})?;
assert!(out.is_none());
Ok(())
}
#[test]
fn builds_for_tailscale_authelia_url() -> TestResult {
let tmp = tempfile::tempdir()?;
let service_data = tmp.path().join("forgejo");
let bridge = build_forgejo_bridge(&service_data, Some("https://auth.example.ts.net"))?;
let hosts_step = bridge
.steps
.iter()
.find_map(|s| match s {
Step::WriteFile(f) if f.path == service_data.join("auth-hosts.txt") => Some(f),
_ => None,
})
.ok_or("auth-hosts.txt step missing")?;
assert_eq!(hosts_step.content, "127.0.0.1 auth.example.ts.net\n");
Ok(())
}
#[test]
fn returns_none_for_authelia_url_without_host() -> TestResult {
let tmp = tempfile::tempdir()?;
let (cfg, installed) = fixture(
vec![
installed("authelia", Some("not-a-url")),
installed("caddy", None),
],
None,
);
let out = build(&AuthBridgeParams {
service_name: "forgejo",
service_provides: provides_for("forgejo"),
enable_auth: true,
config: &cfg,
installed: &installed,
service_data: tmp.path(),
})?;
assert!(out.is_none());
Ok(())
}
#[test]
fn returns_none_for_caddy_itself() -> TestResult {
let tmp = tempfile::tempdir()?;
let (cfg, installed) = fixture(
vec![
installed("authelia", Some("https://auth.internal")),
installed("caddy", None),
],
None,
);
let out = build(&AuthBridgeParams {
service_name: "caddy",
service_provides: provides_for("caddy"),
enable_auth: true,
config: &cfg,
installed: &installed,
service_data: tmp.path(),
})?;
assert!(out.is_none());
Ok(())
}
#[test]
fn build_does_not_write_to_service_data() -> TestResult {
let tmp = tempfile::tempdir()?;
let service_data = tmp.path().join("forgejo");
std::fs::create_dir_all(&service_data)?;
let (cfg, installed) = fixture(
vec![
installed("authelia", Some("https://auth.internal")),
installed("caddy", None),
],
None,
);
let out = build(&AuthBridgeParams {
service_name: "forgejo",
service_provides: provides_for("forgejo"),
enable_auth: true,
config: &cfg,
installed: &installed,
service_data: &service_data,
})?;
assert!(out.is_some());
let entries: Vec<_> = std::fs::read_dir(&service_data)?.collect();
assert!(
entries.is_empty(),
"build() must not write to service_data, found: {entries:?}"
);
Ok(())
}
fn build_forgejo_bridge(service_data: &Path, authelia_url: Option<&str>) -> Result<AuthBridge> {
let (cfg, installed) = fixture(
vec![
installed("authelia", authelia_url),
installed("caddy", None),
],
None,
);
build(&AuthBridgeParams {
service_name: "forgejo",
service_provides: provides_for("forgejo"),
enable_auth: true,
config: &cfg,
installed: &installed,
service_data,
})?
.ok_or_else(|| {
Error::Bundle(
"auth bridge unexpectedly returned None for forgejo + authelia + caddy".into(),
)
})
}
#[test]
fn emits_expected_write_file_steps() -> TestResult {
let tmp = tempfile::tempdir()?;
let service_data = tmp.path().join("forgejo");
let bridge = build_forgejo_bridge(&service_data, Some("https://auth.internal"))?;
let paths = write_paths(&bridge);
assert!(paths.contains(&service_data.join("ca-bundle.crt").as_path()));
assert!(paths.contains(&service_data.join("refresh-ca-bundle.sh").as_path()));
assert!(paths.contains(&service_data.join("resolve-auth-host.sh").as_path()));
assert!(paths.contains(&service_data.join("auth-hosts.txt").as_path()));
Ok(())
}
#[test]
fn returns_none_when_authelia_has_no_url() -> TestResult {
let tmp = tempfile::tempdir()?;
let (cfg, installed) = fixture(
vec![installed("authelia", None), installed("caddy", None)],
None,
);
let out = build(&AuthBridgeParams {
service_name: "forgejo",
service_provides: provides_for("forgejo"),
enable_auth: true,
config: &cfg,
installed: &installed,
service_data: tmp.path(),
})?;
assert!(out.is_none());
Ok(())
}
#[test]
fn emits_ca_trust_volume_and_env() -> TestResult {
let tmp = tempfile::tempdir()?;
let service_data = tmp.path().join("forgejo");
let bridge = build_forgejo_bridge(&service_data, Some("https://auth.internal"))?;
let bundle_mount = format!(
"{}:/etc/ssl/certs/ca-certificates.crt:ro,z",
service_data.join("ca-bundle.crt").display()
);
assert!(bridge.volumes.contains(&bundle_mount));
assert_eq!(
bridge.env.get("REQUESTS_CA_BUNDLE").map(String::as_str),
Some("/etc/ssl/certs/ca-certificates.crt")
);
assert_eq!(
bridge.env.get("SSL_CERT_FILE").map(String::as_str),
Some("/etc/ssl/certs/ca-certificates.crt")
);
assert_eq!(
bridge.env.get("NODE_EXTRA_CA_CERTS").map(String::as_str),
Some("/etc/ssl/certs/ca-certificates.crt")
);
Ok(())
}
#[test]
fn auth_hosts_contains_authelia_hostname() -> TestResult {
let tmp = tempfile::tempdir()?;
let service_data = tmp.path().join("forgejo");
let bridge = build_forgejo_bridge(&service_data, Some("https://auth.internal"))?;
let hosts_step = bridge
.steps
.iter()
.find_map(|s| match s {
Step::WriteFile(f) if f.path == service_data.join("auth-hosts.txt") => Some(f),
_ => None,
})
.ok_or("auth-hosts.txt step missing")?;
assert_eq!(hosts_step.content, "127.0.0.1 auth.internal\n");
Ok(())
}
#[test]
fn exec_start_pre_references_emitted_scripts() -> TestResult {
let tmp = tempfile::tempdir()?;
let service_data = tmp.path().join("forgejo");
let bridge = build_forgejo_bridge(&service_data, Some("https://auth.internal"))?;
let refresh = format!(
"-/bin/bash {}",
service_data.join("refresh-ca-bundle.sh").display()
);
let resolve = format!(
"-/bin/bash {}",
service_data.join("resolve-auth-host.sh").display()
);
assert!(bridge.exec_start_pre.contains(&refresh));
assert!(bridge.exec_start_pre.contains(&resolve));
Ok(())
}
}