use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
use crate::generate::GeneratedFile;
use crate::generate::bundle::inject_networks;
use crate::{Step, WellKnownService};
pub fn caddyfile_path() -> Result<PathBuf> {
Ok(crate::service_home(WellKnownService::Caddy.as_str())?
.join("config")
.join("Caddyfile"))
}
pub fn tls_snippet_path() -> Result<PathBuf> {
Ok(crate::service_home(WellKnownService::Caddy.as_str())?
.join("config")
.join("tls.caddy"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AcmeMode {
Internal,
Anonymous,
WithEmail(String),
Byo { cert: String, key: String },
}
impl AcmeMode {
pub fn from_email(email: &str) -> Self {
if email.is_empty() {
AcmeMode::Anonymous
} else {
AcmeMode::WithEmail(email.to_string())
}
}
pub fn snippet(&self) -> String {
match self {
AcmeMode::Internal => "(services_tls) {\n\ttls internal\n}\n".to_string(),
AcmeMode::Anonymous => "(services_tls) {\n}\n".to_string(),
AcmeMode::WithEmail(email) => format!("(services_tls) {{\n\ttls {email}\n}}\n"),
AcmeMode::Byo { cert, key } => {
format!("(services_tls) {{\n\ttls {cert} {key}\n}}\n")
}
}
}
pub fn detect_from_snippet(contents: &str) -> Option<Self> {
let open = contents.find('{')?;
let close = contents.rfind('}')?;
if close <= open {
return None;
}
let body = contents[open + 1..close].trim();
if body.is_empty() {
return Some(AcmeMode::Anonymous);
}
if body.lines().count() != 1 {
return None;
}
let line = body.trim();
if line == "tls internal" {
return Some(AcmeMode::Internal);
}
if let Some(rest) = line.strip_prefix("tls ") {
let arg = rest.trim();
if arg.contains('@') && !arg.contains(' ') {
return Some(AcmeMode::WithEmail(arg.to_string()));
}
let parts: Vec<&str> = arg.split_whitespace().collect();
if parts.len() == 2 && !arg.contains('@') {
return Some(AcmeMode::Byo {
cert: parts[0].to_string(),
key: parts[1].to_string(),
});
}
}
None
}
}
pub fn ensure_auth_provider_routed(
auth_service: WellKnownService,
auth_domain: &str,
auth_container_port: u16,
issuer_port: u16,
quadlet_dir: &Path,
) -> Result<Vec<Step>> {
if !crate::is_service_installed("caddy") {
return Ok(Vec::new());
}
let mut steps = Vec::new();
let mut need_caddy_restart = false;
let caddy_quadlet_link = quadlet_dir.join("caddy.container");
let content =
std::fs::read_to_string(&caddy_quadlet_link).map_err(|source| Error::FileRead {
path: caddy_quadlet_link.clone(),
source,
})?;
let caddy_quadlet_target =
std::fs::canonicalize(&caddy_quadlet_link).map_err(|source| Error::FileRead {
path: caddy_quadlet_link.clone(),
source,
})?;
let network_spec = format!("{auth_service}:alias={auth_domain}");
if !content.contains(&format!("alias={auth_domain}")) {
let updated = inject_networks(&content, &[network_spec]);
steps.push(Step::WriteFile(GeneratedFile {
path: caddy_quadlet_target,
content: updated,
}));
need_caddy_restart = true;
}
let caddyfile_path = caddyfile_path()?;
let caddyfile = std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
path: caddyfile_path.clone(),
source,
})?;
if !caddyfile.contains(&format!("# Service-Source: registry/{auth_service}")) {
let target_host = primary_container_name(
&caddy_quadlet_link.with_file_name(format!("{auth_service}.container")),
auth_service.as_str(),
);
let block = render_site_block(&CaddySiteParams {
service_name: auth_service.to_string(),
target_host,
domain: auth_domain.to_string(),
container_port: auth_container_port,
https_port: issuer_port,
force_internal_tls: true,
});
let updated = add_route(&caddyfile, auth_service.as_str(), &block);
steps.push(Step::WriteFile(GeneratedFile {
path: caddyfile_path,
content: updated,
}));
need_caddy_restart = true;
}
if need_caddy_restart {
steps.push(Step::DaemonReload);
steps.push(Step::RestartService {
unit: "caddy".to_string(),
});
let ca_path = crate::service_home("caddy")?
.parent()
.map(|p| p.join("caddy-root-ca.crt"))
.unwrap_or_default();
steps.push(Step::WaitForFile {
path: ca_path,
timeout_secs: 15,
});
}
Ok(steps)
}
pub struct CaddySiteParams {
pub service_name: String,
pub domain: String,
pub target_host: String,
pub container_port: u16,
pub https_port: u16,
pub force_internal_tls: bool,
}
pub fn render_site_block(params: &CaddySiteParams) -> String {
let mut block = format!("# Service-Source: registry/{}\n", params.service_name);
block.push_str(&format!("{}:{} {{\n", params.domain, params.https_port));
if params.force_internal_tls || params.domain.ends_with(".internal") {
block.push_str(" tls internal\n");
} else {
block.push_str(" import services_tls\n");
}
block.push_str(&format!(
" reverse_proxy {}:{}\n",
params.target_host, params.container_port
));
block.push_str("}\n");
block
}
pub fn primary_container_name(quadlet_path: &std::path::Path, fallback: &str) -> String {
let Ok(content) = std::fs::read_to_string(quadlet_path) else {
return fallback.to_string();
};
for line in content.lines() {
if let Some(rest) = line.trim().strip_prefix("ContainerName=") {
let name = rest.trim();
if !name.is_empty() {
return name.to_string();
}
}
}
fallback.to_string()
}
pub fn add_route(caddyfile: &str, service_name: &str, block: &str) -> String {
let cleaned = remove_route(caddyfile, service_name);
let mut result = cleaned.trim_end().to_string();
if !result.is_empty() {
result.push_str("\n\n");
}
result.push_str(block);
result.push('\n');
result
}
pub fn remove_route(caddyfile: &str, service_name: &str) -> String {
let marker = format!("# Service-Source: registry/{service_name}");
let lines: Vec<&str> = caddyfile.lines().collect();
let mut result = Vec::new();
let mut i = 0;
while i < lines.len() {
if lines[i].trim() == marker {
i += 1;
let mut depth: i32 = 0;
let mut entered_block = false;
while i < lines.len() {
let trimmed = lines[i].trim();
if trimmed.ends_with('{') {
depth += 1;
entered_block = true;
}
if trimmed.starts_with('}') {
depth -= 1;
}
i += 1;
if entered_block && depth <= 0 {
break;
}
}
while i < lines.len() && lines[i].trim().is_empty() {
i += 1;
}
} else {
result.push(lines[i]);
i += 1;
}
}
while result.last().map(|l| l.trim().is_empty()).unwrap_or(false) {
result.pop();
}
let mut out = result.join("\n");
if !out.is_empty() {
out.push('\n');
}
out
}
pub fn parse_domains(caddyfile: &str) -> Vec<(String, String)> {
let mut domains = Vec::new();
let mut current_service: Option<String> = None;
for line in caddyfile.lines() {
let trimmed = line.trim();
if let Some(svc) = trimmed.strip_prefix("# Service-Source: registry/") {
current_service = Some(svc.to_string());
} else if let Some(ref svc) = current_service {
if let Some(domain) = trimmed.strip_suffix('{') {
let domain = domain.trim();
if !domain.is_empty() {
domains.push((svc.clone(), domain.to_string()));
}
current_service = None;
}
}
}
domains
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn byo_cert_snippet_round_trips() {
let mode = AcmeMode::Byo {
cert: "/etc/ryra/certs/origin.pem".into(),
key: "/etc/ryra/certs/origin.key".into(),
};
let snippet = mode.snippet();
assert!(
snippet.contains("tls /etc/ryra/certs/origin.pem /etc/ryra/certs/origin.key"),
"got: {snippet}"
);
assert_eq!(AcmeMode::detect_from_snippet(&snippet), Some(mode));
}
#[test]
fn detect_does_not_confuse_email_with_byo() {
assert_eq!(
AcmeMode::detect_from_snippet("(services_tls) {\n\ttls me@example.com\n}\n"),
Some(AcmeMode::WithEmail("me@example.com".into()))
);
}
#[test]
fn render_basic_block() {
let params = CaddySiteParams {
service_name: "whoami".to_string(),
target_host: "whoami".to_string(),
domain: "whoami.example.com".to_string(),
container_port: 8080,
https_port: 8443,
force_internal_tls: false,
};
let block = render_site_block(¶ms);
assert!(block.starts_with("# Service-Source: registry/whoami\n"));
assert!(block.contains("whoami.example.com:8443 {"));
assert!(block.contains(" import services_tls\n"));
assert!(!block.contains("tls internal"));
assert!(block.contains(" reverse_proxy whoami:8080"));
assert!(block.ends_with("}\n"));
}
#[test]
fn render_block_with_distinct_target_host() {
let params = CaddySiteParams {
service_name: "immich".to_string(),
target_host: "immich-server".to_string(),
domain: "immich.internal".to_string(),
container_port: 2283,
https_port: 8443,
force_internal_tls: false,
};
let block = render_site_block(¶ms);
assert!(block.contains("# Service-Source: registry/immich\n"));
assert!(block.contains(" reverse_proxy immich-server:2283"));
assert!(!block.contains("reverse_proxy immich:"));
}
#[test]
fn primary_container_name_reads_directive()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("immich.container");
std::fs::write(
&path,
"[Container]\nImage=docker.io/immich/server\nContainerName=immich-server\nNetwork=immich.network\n",
)?;
assert_eq!(primary_container_name(&path, "immich"), "immich-server");
Ok(())
}
#[test]
fn primary_container_name_falls_back_when_directive_absent()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("whoami.container");
std::fs::write(
&path,
"[Container]\nImage=docker.io/whoami\nNetwork=whoami.network\n",
)?;
assert_eq!(primary_container_name(&path, "whoami"), "whoami");
Ok(())
}
#[test]
fn primary_container_name_falls_back_when_file_missing() {
let missing = std::path::Path::new("/nonexistent/missing.container");
assert_eq!(primary_container_name(missing, "fallback"), "fallback");
}
#[test]
fn render_internal_domain_keeps_tls_internal() {
let params = CaddySiteParams {
service_name: "authelia".to_string(),
target_host: "authelia".to_string(),
domain: "auth.internal".to_string(),
container_port: 9091,
https_port: 8443,
force_internal_tls: false,
};
let block = render_site_block(¶ms);
assert!(block.contains(" tls internal\n"));
assert!(!block.contains("import services_tls"));
}
#[test]
fn acme_mode_internal_snippet() {
let s = AcmeMode::Internal.snippet();
assert!(s.starts_with("(services_tls) {"));
assert!(s.contains("tls internal"));
}
#[test]
fn acme_mode_with_email_snippet() {
let s = AcmeMode::WithEmail("admin@example.com".to_string()).snippet();
assert!(s.starts_with("(services_tls) {"));
assert!(s.contains("tls admin@example.com"));
assert!(!s.contains("tls internal"));
}
#[test]
fn acme_mode_detect_round_trips() {
for mode in [
AcmeMode::Internal,
AcmeMode::Anonymous,
AcmeMode::WithEmail("admin@example.com".into()),
] {
let snippet = mode.snippet();
let detected = AcmeMode::detect_from_snippet(&snippet);
assert_eq!(detected, Some(mode));
}
}
#[test]
fn acme_mode_detect_user_customized_returns_none() {
let cf = "(services_tls) {\n\ttls {\n\t\tdns cloudflare {env.CF_API_TOKEN}\n\t}\n}\n";
assert_eq!(AcmeMode::detect_from_snippet(cf), None);
let extra = "(services_tls) {\n\ttls internal\n\theader X-Foo bar\n}\n";
assert_eq!(AcmeMode::detect_from_snippet(extra), None);
}
#[test]
fn acme_mode_anonymous_snippet_omits_tls_directive() {
let s = AcmeMode::Anonymous.snippet();
assert!(s.starts_with("(services_tls) {"));
assert!(!s.contains("\ttls "));
assert!(!s.contains("tls internal"));
assert!(!s.contains("tls @"));
}
#[test]
fn render_block_custom_https_port() {
let params = CaddySiteParams {
service_name: "app".to_string(),
target_host: "app".to_string(),
domain: "app.example.com".to_string(),
container_port: 3000,
https_port: 9443,
force_internal_tls: false,
};
let block = render_site_block(¶ms);
assert!(block.contains("app.example.com:9443 {"));
}
#[test]
fn render_force_internal_tls_on_public_domain() {
let params = CaddySiteParams {
service_name: "authelia".to_string(),
target_host: "authelia".to_string(),
domain: "auth.example.ts.net".to_string(),
container_port: 9091,
https_port: 443,
force_internal_tls: true,
};
let block = render_site_block(¶ms);
assert!(block.contains("auth.example.ts.net:443 {"));
assert!(block.contains(" tls internal\n"));
assert!(!block.contains("import services_tls"));
}
#[test]
fn add_route_to_empty() {
let block = "# Service-Source: registry/whoami\nwhoami.example.com {\n reverse_proxy host.containers.internal:8080\n}\n";
let result = add_route("", "whoami", block);
assert_eq!(result, format!("{block}\n"));
}
#[test]
fn add_route_appends() {
let existing = "# Service-Source: registry/foo\nfoo.example.com {\n reverse_proxy host.containers.internal:3000\n}\n";
let block = "# Service-Source: registry/bar\nbar.example.com {\n reverse_proxy host.containers.internal:4000\n}\n";
let result = add_route(existing, "bar", block);
assert!(result.contains("# Service-Source: registry/foo"));
assert!(result.contains("# Service-Source: registry/bar"));
}
#[test]
fn add_route_replaces_existing() {
let existing = "# Service-Source: registry/whoami\nwhoami.example.com {\n reverse_proxy host.containers.internal:8080\n}\n";
let new_block = "# Service-Source: registry/whoami\nwhoami.example.com {\n reverse_proxy host.containers.internal:9090\n}\n";
let result = add_route(existing, "whoami", new_block);
assert!(!result.contains("8080"));
assert!(result.contains("9090"));
}
#[test]
fn remove_route_single() {
let caddyfile = "# Service-Source: registry/whoami\nwhoami.example.com {\n reverse_proxy host.containers.internal:8080\n}\n";
let result = remove_route(caddyfile, "whoami");
assert_eq!(result, "");
}
#[test]
fn remove_route_preserves_others() {
let caddyfile = concat!(
"# Service-Source: registry/foo\nfoo.example.com {\n reverse_proxy host.containers.internal:3000\n}\n\n",
"# Service-Source: registry/bar\nbar.example.com {\n reverse_proxy host.containers.internal:4000\n}\n",
);
let result = remove_route(caddyfile, "foo");
assert!(!result.contains("foo"));
assert!(result.contains("# Service-Source: registry/bar"));
assert!(result.contains("reverse_proxy host.containers.internal:4000"));
}
#[test]
fn remove_route_preserves_user_blocks() {
let caddyfile = concat!(
"mysite.example.com {\n root * /var/www\n file_server\n}\n\n",
"# Service-Source: registry/whoami\nwhoami.example.com {\n reverse_proxy host.containers.internal:8080\n}\n",
);
let result = remove_route(caddyfile, "whoami");
assert!(result.contains("mysite.example.com"));
assert!(result.contains("file_server"));
assert!(!result.contains("ryra:whoami"));
}
#[test]
fn remove_route_with_nested_braces() {
let caddyfile = concat!(
"# Service-Source: registry/myapp\n",
"myapp.example.com {\n",
" forward_auth host.containers.internal:9091 {\n",
" uri /api/authz/forward-auth\n",
" }\n",
" reverse_proxy host.containers.internal:3000\n",
"}\n",
);
let result = remove_route(caddyfile, "myapp");
assert_eq!(result, "");
}
#[test]
fn parse_domains_basic() {
let caddyfile = concat!(
"# Service-Source: registry/whoami\nwhoami.example.com {\n reverse_proxy host.containers.internal:8080\n}\n\n",
"# Service-Source: registry/myapp\nmyapp.example.com {\n reverse_proxy host.containers.internal:3000\n}\n",
);
let domains = parse_domains(caddyfile);
assert_eq!(domains.len(), 2);
assert_eq!(
domains[0],
("whoami".to_string(), "whoami.example.com".to_string())
);
assert_eq!(
domains[1],
("myapp".to_string(), "myapp.example.com".to_string())
);
}
#[test]
fn parse_domains_ignores_user_blocks() {
let caddyfile = concat!(
"mysite.example.com {\n file_server\n}\n\n",
"# Service-Source: registry/whoami\nwhoami.example.com {\n reverse_proxy host.containers.internal:8080\n}\n",
);
let domains = parse_domains(caddyfile);
assert_eq!(domains.len(), 1);
assert_eq!(domains[0].0, "whoami");
}
#[test]
fn caddyfile_path_is_under_service_home() {
let path = caddyfile_path().expect("HOME should be set in test environment");
assert!(
path.ends_with("services/caddy/config/Caddyfile"),
"unexpected caddyfile path: {path:?}"
);
}
}