1use std::path::{Path, PathBuf};
2
3use crate::config::schema::Config;
4use crate::error::{Error, Result};
5use crate::generate::GeneratedFile;
6use crate::generate::bundle::inject_networks;
7use crate::{Step, WellKnownService};
8
9pub fn caddyfile_path() -> Result<PathBuf> {
14 Ok(crate::service_home(WellKnownService::Caddy.as_str())?
15 .join("config")
16 .join("Caddyfile"))
17}
18
19pub fn tls_snippet_path() -> Result<PathBuf> {
27 Ok(crate::service_home(WellKnownService::Caddy.as_str())?
28 .join("config")
29 .join("tls.caddy"))
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum AcmeMode {
39 Internal,
42 Anonymous,
45 WithEmail(String),
47}
48
49impl AcmeMode {
50 pub fn snippet(&self) -> String {
52 match self {
53 AcmeMode::Internal => "(services_tls) {\n\ttls internal\n}\n".to_string(),
54 AcmeMode::Anonymous => "(services_tls) {\n}\n".to_string(),
57 AcmeMode::WithEmail(email) => format!("(services_tls) {{\n\ttls {email}\n}}\n"),
58 }
59 }
60
61 pub fn detect_from_snippet(contents: &str) -> Option<Self> {
68 let open = contents.find('{')?;
70 let close = contents.rfind('}')?;
71 if close <= open {
72 return None;
73 }
74 let body = contents[open + 1..close].trim();
75 if body.is_empty() {
76 return Some(AcmeMode::Anonymous);
77 }
78 if body.lines().count() != 1 {
82 return None;
83 }
84 let line = body.trim();
85 if line == "tls internal" {
86 return Some(AcmeMode::Internal);
87 }
88 if let Some(rest) = line.strip_prefix("tls ") {
89 let arg = rest.trim();
90 if arg.contains('@') && !arg.contains(' ') {
93 return Some(AcmeMode::WithEmail(arg.to_string()));
94 }
95 }
96 None
97 }
98}
99
100pub fn ensure_auth_provider_routed(
110 config: &Config,
111 auth_service: WellKnownService,
112 auth_domain: &str,
113 auth_container_port: u16,
114 quadlet_dir: &Path,
115) -> Result<Vec<Step>> {
116 if !crate::is_service_installed("caddy") {
117 return Ok(Vec::new());
118 }
119
120 let mut steps = Vec::new();
121 let mut need_caddy_restart = false;
122
123 let caddy_quadlet_link = quadlet_dir.join("caddy.container");
133 let content =
134 std::fs::read_to_string(&caddy_quadlet_link).map_err(|source| Error::FileRead {
135 path: caddy_quadlet_link.clone(),
136 source,
137 })?;
138 let caddy_quadlet_target =
139 std::fs::canonicalize(&caddy_quadlet_link).map_err(|source| Error::FileRead {
140 path: caddy_quadlet_link.clone(),
141 source,
142 })?;
143 let network_spec = format!("{auth_service}:alias={auth_domain}");
144 if !content.contains(&format!("alias={auth_domain}")) {
145 let updated = inject_networks(&content, &[network_spec]);
146 steps.push(Step::WriteFile(GeneratedFile {
147 path: caddy_quadlet_target,
148 content: updated,
149 }));
150 need_caddy_restart = true;
151 }
152
153 let caddyfile_path = caddyfile_path()?;
154 let caddyfile = std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
155 path: caddyfile_path.clone(),
156 source,
157 })?;
158 if !caddyfile.contains(&format!("# Service-Source: registry/{auth_service}")) {
159 let target_host = primary_container_name(
164 &caddy_quadlet_link.with_file_name(format!("{auth_service}.container")),
165 auth_service.as_str(),
166 );
167 let block = render_site_block(&CaddySiteParams {
168 service_name: auth_service.to_string(),
169 target_host,
170 domain: auth_domain.to_string(),
171 container_port: auth_container_port,
172 https_port: crate::caddy_https_port(config),
173 });
174 let updated = add_route(&caddyfile, auth_service.as_str(), &block);
175 steps.push(Step::WriteFile(GeneratedFile {
176 path: caddyfile_path,
177 content: updated,
178 }));
179 need_caddy_restart = true;
180 }
181
182 if need_caddy_restart {
183 steps.push(Step::DaemonReload);
184 steps.push(Step::RestartService {
185 unit: "caddy".to_string(),
186 });
187 let ca_path = crate::service_home("caddy")?
192 .parent()
193 .map(|p| p.join("caddy-root-ca.crt"))
194 .unwrap_or_default();
195 steps.push(Step::WaitForFile {
196 path: ca_path,
197 timeout_secs: 15,
198 });
199 }
200
201 Ok(steps)
202}
203
204pub struct CaddySiteParams {
206 pub service_name: String,
209 pub domain: String,
210 pub target_host: String,
216 pub container_port: u16,
218 pub https_port: u16,
220}
221
222pub fn render_site_block(params: &CaddySiteParams) -> String {
233 let mut block = format!("# Service-Source: registry/{}\n", params.service_name);
234 block.push_str(&format!("{}:{} {{\n", params.domain, params.https_port));
235 if params.domain.ends_with(".internal") {
236 block.push_str(" tls internal\n");
237 } else {
238 block.push_str(" import services_tls\n");
239 }
240 block.push_str(&format!(
242 " reverse_proxy {}:{}\n",
243 params.target_host, params.container_port
244 ));
245 block.push_str("}\n");
246 block
247}
248
249pub fn primary_container_name(quadlet_path: &std::path::Path, fallback: &str) -> String {
254 let Ok(content) = std::fs::read_to_string(quadlet_path) else {
255 return fallback.to_string();
256 };
257 for line in content.lines() {
258 if let Some(rest) = line.trim().strip_prefix("ContainerName=") {
259 let name = rest.trim();
260 if !name.is_empty() {
261 return name.to_string();
262 }
263 }
264 }
265 fallback.to_string()
266}
267
268pub fn add_route(caddyfile: &str, service_name: &str, block: &str) -> String {
273 let cleaned = remove_route(caddyfile, service_name);
274 let mut result = cleaned.trim_end().to_string();
275 if !result.is_empty() {
276 result.push_str("\n\n");
277 }
278 result.push_str(block);
279 result.push('\n');
280 result
281}
282
283pub fn remove_route(caddyfile: &str, service_name: &str) -> String {
289 let marker = format!("# Service-Source: registry/{service_name}");
290 let lines: Vec<&str> = caddyfile.lines().collect();
291 let mut result = Vec::new();
292 let mut i = 0;
293
294 while i < lines.len() {
295 if lines[i].trim() == marker {
296 i += 1;
301 let mut depth: i32 = 0;
302 let mut entered_block = false;
303 while i < lines.len() {
304 let trimmed = lines[i].trim();
305 if trimmed.ends_with('{') {
306 depth += 1;
307 entered_block = true;
308 }
309 if trimmed.starts_with('}') {
310 depth -= 1;
311 }
312 i += 1;
313 if entered_block && depth <= 0 {
314 break;
315 }
316 }
317 while i < lines.len() && lines[i].trim().is_empty() {
319 i += 1;
320 }
321 } else {
322 result.push(lines[i]);
323 i += 1;
324 }
325 }
326
327 while result.last().map(|l| l.trim().is_empty()).unwrap_or(false) {
329 result.pop();
330 }
331
332 let mut out = result.join("\n");
333 if !out.is_empty() {
334 out.push('\n');
335 }
336 out
337}
338
339pub fn parse_domains(caddyfile: &str) -> Vec<(String, String)> {
343 let mut domains = Vec::new();
344 let mut current_service: Option<String> = None;
345
346 for line in caddyfile.lines() {
347 let trimmed = line.trim();
348 if let Some(svc) = trimmed.strip_prefix("# Service-Source: registry/") {
349 current_service = Some(svc.to_string());
350 } else if let Some(ref svc) = current_service {
351 if let Some(domain) = trimmed.strip_suffix('{') {
353 let domain = domain.trim();
354 if !domain.is_empty() {
355 domains.push((svc.clone(), domain.to_string()));
356 }
357 current_service = None;
358 }
359 }
360 }
361
362 domains
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368
369 #[test]
370 fn render_basic_block() {
371 let params = CaddySiteParams {
372 service_name: "whoami".to_string(),
373 target_host: "whoami".to_string(),
374 domain: "whoami.example.com".to_string(),
375 container_port: 8080,
376 https_port: 8443,
377 };
378 let block = render_site_block(¶ms);
379 assert!(block.starts_with("# Service-Source: registry/whoami\n"));
380 assert!(block.contains("whoami.example.com:8443 {"));
381 assert!(block.contains(" import services_tls\n"));
382 assert!(!block.contains("tls internal"));
383 assert!(block.contains(" reverse_proxy whoami:8080"));
384 assert!(block.ends_with("}\n"));
385 }
386
387 #[test]
388 fn render_block_with_distinct_target_host() {
389 let params = CaddySiteParams {
395 service_name: "immich".to_string(),
396 target_host: "immich-server".to_string(),
397 domain: "immich.internal".to_string(),
398 container_port: 2283,
399 https_port: 8443,
400 };
401 let block = render_site_block(¶ms);
402 assert!(block.contains("# Service-Source: registry/immich\n"));
403 assert!(block.contains(" reverse_proxy immich-server:2283"));
404 assert!(!block.contains("reverse_proxy immich:"));
405 }
406
407 #[test]
408 fn primary_container_name_reads_directive()
409 -> std::result::Result<(), Box<dyn std::error::Error>> {
410 let dir = tempfile::tempdir()?;
411 let path = dir.path().join("immich.container");
412 std::fs::write(
413 &path,
414 "[Container]\nImage=docker.io/immich/server\nContainerName=immich-server\nNetwork=immich.network\n",
415 )?;
416 assert_eq!(primary_container_name(&path, "immich"), "immich-server");
417 Ok(())
418 }
419
420 #[test]
421 fn primary_container_name_falls_back_when_directive_absent()
422 -> std::result::Result<(), Box<dyn std::error::Error>> {
423 let dir = tempfile::tempdir()?;
424 let path = dir.path().join("whoami.container");
425 std::fs::write(
426 &path,
427 "[Container]\nImage=docker.io/whoami\nNetwork=whoami.network\n",
428 )?;
429 assert_eq!(primary_container_name(&path, "whoami"), "whoami");
430 Ok(())
431 }
432
433 #[test]
434 fn primary_container_name_falls_back_when_file_missing() {
435 let missing = std::path::Path::new("/nonexistent/missing.container");
436 assert_eq!(primary_container_name(missing, "fallback"), "fallback");
437 }
438
439 #[test]
440 fn render_internal_domain_keeps_tls_internal() {
441 let params = CaddySiteParams {
444 service_name: "authelia".to_string(),
445 target_host: "authelia".to_string(),
446 domain: "auth.internal".to_string(),
447 container_port: 9091,
448 https_port: 8443,
449 };
450 let block = render_site_block(¶ms);
451 assert!(block.contains(" tls internal\n"));
452 assert!(!block.contains("import services_tls"));
453 }
454
455 #[test]
456 fn acme_mode_internal_snippet() {
457 let s = AcmeMode::Internal.snippet();
458 assert!(s.starts_with("(services_tls) {"));
459 assert!(s.contains("tls internal"));
460 }
461
462 #[test]
463 fn acme_mode_with_email_snippet() {
464 let s = AcmeMode::WithEmail("admin@example.com".to_string()).snippet();
465 assert!(s.starts_with("(services_tls) {"));
466 assert!(s.contains("tls admin@example.com"));
467 assert!(!s.contains("tls internal"));
468 }
469
470 #[test]
471 fn acme_mode_detect_round_trips() {
472 for mode in [
473 AcmeMode::Internal,
474 AcmeMode::Anonymous,
475 AcmeMode::WithEmail("admin@example.com".into()),
476 ] {
477 let snippet = mode.snippet();
478 let detected = AcmeMode::detect_from_snippet(&snippet);
479 assert_eq!(detected, Some(mode));
480 }
481 }
482
483 #[test]
484 fn acme_mode_detect_user_customized_returns_none() {
485 let cf = "(services_tls) {\n\ttls {\n\t\tdns cloudflare {env.CF_API_TOKEN}\n\t}\n}\n";
489 assert_eq!(AcmeMode::detect_from_snippet(cf), None);
490
491 let byo = "(services_tls) {\n\ttls /etc/ssl/cert.pem /etc/ssl/key.pem\n}\n";
492 assert_eq!(AcmeMode::detect_from_snippet(byo), None);
493
494 let extra = "(services_tls) {\n\ttls internal\n\theader X-Foo bar\n}\n";
495 assert_eq!(AcmeMode::detect_from_snippet(extra), None);
496 }
497
498 #[test]
499 fn acme_mode_anonymous_snippet_omits_tls_directive() {
500 let s = AcmeMode::Anonymous.snippet();
501 assert!(s.starts_with("(services_tls) {"));
502 assert!(!s.contains("\ttls "));
506 assert!(!s.contains("tls internal"));
507 assert!(!s.contains("tls @"));
508 }
509
510 #[test]
511 fn render_block_custom_https_port() {
512 let params = CaddySiteParams {
513 service_name: "app".to_string(),
514 target_host: "app".to_string(),
515 domain: "app.example.com".to_string(),
516 container_port: 3000,
517 https_port: 9443,
518 };
519 let block = render_site_block(¶ms);
520 assert!(block.contains("app.example.com:9443 {"));
521 }
522
523 #[test]
524 fn add_route_to_empty() {
525 let block = "# Service-Source: registry/whoami\nwhoami.example.com {\n reverse_proxy host.containers.internal:8080\n}\n";
526 let result = add_route("", "whoami", block);
527 assert_eq!(result, format!("{block}\n"));
528 }
529
530 #[test]
531 fn add_route_appends() {
532 let existing = "# Service-Source: registry/foo\nfoo.example.com {\n reverse_proxy host.containers.internal:3000\n}\n";
533 let block = "# Service-Source: registry/bar\nbar.example.com {\n reverse_proxy host.containers.internal:4000\n}\n";
534 let result = add_route(existing, "bar", block);
535 assert!(result.contains("# Service-Source: registry/foo"));
536 assert!(result.contains("# Service-Source: registry/bar"));
537 }
538
539 #[test]
540 fn add_route_replaces_existing() {
541 let existing = "# Service-Source: registry/whoami\nwhoami.example.com {\n reverse_proxy host.containers.internal:8080\n}\n";
542 let new_block = "# Service-Source: registry/whoami\nwhoami.example.com {\n reverse_proxy host.containers.internal:9090\n}\n";
543 let result = add_route(existing, "whoami", new_block);
544 assert!(!result.contains("8080"));
545 assert!(result.contains("9090"));
546 }
547
548 #[test]
549 fn remove_route_single() {
550 let caddyfile = "# Service-Source: registry/whoami\nwhoami.example.com {\n reverse_proxy host.containers.internal:8080\n}\n";
551 let result = remove_route(caddyfile, "whoami");
552 assert_eq!(result, "");
553 }
554
555 #[test]
556 fn remove_route_preserves_others() {
557 let caddyfile = concat!(
558 "# Service-Source: registry/foo\nfoo.example.com {\n reverse_proxy host.containers.internal:3000\n}\n\n",
559 "# Service-Source: registry/bar\nbar.example.com {\n reverse_proxy host.containers.internal:4000\n}\n",
560 );
561 let result = remove_route(caddyfile, "foo");
562 assert!(!result.contains("foo"));
563 assert!(result.contains("# Service-Source: registry/bar"));
564 assert!(result.contains("reverse_proxy host.containers.internal:4000"));
565 }
566
567 #[test]
568 fn remove_route_preserves_user_blocks() {
569 let caddyfile = concat!(
570 "mysite.example.com {\n root * /var/www\n file_server\n}\n\n",
571 "# Service-Source: registry/whoami\nwhoami.example.com {\n reverse_proxy host.containers.internal:8080\n}\n",
572 );
573 let result = remove_route(caddyfile, "whoami");
574 assert!(result.contains("mysite.example.com"));
575 assert!(result.contains("file_server"));
576 assert!(!result.contains("ryra:whoami"));
577 }
578
579 #[test]
580 fn remove_route_with_nested_braces() {
581 let caddyfile = concat!(
582 "# Service-Source: registry/myapp\n",
583 "myapp.example.com {\n",
584 " forward_auth host.containers.internal:9091 {\n",
585 " uri /api/authz/forward-auth\n",
586 " }\n",
587 " reverse_proxy host.containers.internal:3000\n",
588 "}\n",
589 );
590 let result = remove_route(caddyfile, "myapp");
591 assert_eq!(result, "");
592 }
593
594 #[test]
595 fn parse_domains_basic() {
596 let caddyfile = concat!(
597 "# Service-Source: registry/whoami\nwhoami.example.com {\n reverse_proxy host.containers.internal:8080\n}\n\n",
598 "# Service-Source: registry/myapp\nmyapp.example.com {\n reverse_proxy host.containers.internal:3000\n}\n",
599 );
600 let domains = parse_domains(caddyfile);
601 assert_eq!(domains.len(), 2);
602 assert_eq!(
603 domains[0],
604 ("whoami".to_string(), "whoami.example.com".to_string())
605 );
606 assert_eq!(
607 domains[1],
608 ("myapp".to_string(), "myapp.example.com".to_string())
609 );
610 }
611
612 #[test]
613 fn parse_domains_ignores_user_blocks() {
614 let caddyfile = concat!(
615 "mysite.example.com {\n file_server\n}\n\n",
616 "# Service-Source: registry/whoami\nwhoami.example.com {\n reverse_proxy host.containers.internal:8080\n}\n",
617 );
618 let domains = parse_domains(caddyfile);
619 assert_eq!(domains.len(), 1);
620 assert_eq!(domains[0].0, "whoami");
621 }
622
623 #[test]
624 fn caddyfile_path_is_under_service_home() {
625 let path = caddyfile_path().expect("HOME should be set in test environment");
626 assert!(
627 path.ends_with("services/caddy/config/Caddyfile"),
628 "unexpected caddyfile path: {path:?}"
629 );
630 }
631}