1use std::path::{Path, PathBuf};
2
3use crate::error::{Error, Result};
4use crate::generate::GeneratedFile;
5use crate::generate::bundle::inject_networks;
6use crate::{Step, WellKnownService};
7
8pub fn caddyfile_path() -> Result<PathBuf> {
13 Ok(crate::service_home(WellKnownService::Caddy.as_str())?
14 .join("config")
15 .join("Caddyfile"))
16}
17
18pub fn tls_snippet_path() -> Result<PathBuf> {
26 Ok(crate::service_home(WellKnownService::Caddy.as_str())?
27 .join("config")
28 .join("tls.caddy"))
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum AcmeMode {
38 Internal,
41 Anonymous,
44 WithEmail(String),
46 Byo { cert: String, key: String },
51}
52
53impl AcmeMode {
54 pub fn from_email(email: &str) -> Self {
58 if email.is_empty() {
59 AcmeMode::Anonymous
60 } else {
61 AcmeMode::WithEmail(email.to_string())
62 }
63 }
64
65 pub fn snippet(&self) -> String {
67 match self {
68 AcmeMode::Internal => "(services_tls) {\n\ttls internal\n}\n".to_string(),
69 AcmeMode::Anonymous => "(services_tls) {\n}\n".to_string(),
72 AcmeMode::WithEmail(email) => format!("(services_tls) {{\n\ttls {email}\n}}\n"),
73 AcmeMode::Byo { cert, key } => {
74 format!("(services_tls) {{\n\ttls {cert} {key}\n}}\n")
75 }
76 }
77 }
78
79 pub fn detect_from_snippet(contents: &str) -> Option<Self> {
86 let open = contents.find('{')?;
88 let close = contents.rfind('}')?;
89 if close <= open {
90 return None;
91 }
92 let body = contents[open + 1..close].trim();
93 if body.is_empty() {
94 return Some(AcmeMode::Anonymous);
95 }
96 if body.lines().count() != 1 {
100 return None;
101 }
102 let line = body.trim();
103 if line == "tls internal" {
104 return Some(AcmeMode::Internal);
105 }
106 if let Some(rest) = line.strip_prefix("tls ") {
107 let arg = rest.trim();
108 if arg.contains('@') && !arg.contains(' ') {
110 return Some(AcmeMode::WithEmail(arg.to_string()));
111 }
112 let parts: Vec<&str> = arg.split_whitespace().collect();
114 if parts.len() == 2 && !arg.contains('@') {
115 return Some(AcmeMode::Byo {
116 cert: parts[0].to_string(),
117 key: parts[1].to_string(),
118 });
119 }
120 }
121 None
122 }
123}
124
125pub fn ensure_auth_provider_routed(
141 auth_service: WellKnownService,
142 auth_domain: &str,
143 auth_container_port: u16,
144 issuer_port: u16,
145 quadlet_dir: &Path,
146) -> Result<Vec<Step>> {
147 if !crate::is_service_installed("caddy") {
148 return Ok(Vec::new());
149 }
150
151 let mut steps = Vec::new();
152 let mut need_caddy_restart = false;
153
154 let caddy_quadlet_link = quadlet_dir.join("caddy.container");
164 let content =
165 std::fs::read_to_string(&caddy_quadlet_link).map_err(|source| Error::FileRead {
166 path: caddy_quadlet_link.clone(),
167 source,
168 })?;
169 let caddy_quadlet_target =
170 std::fs::canonicalize(&caddy_quadlet_link).map_err(|source| Error::FileRead {
171 path: caddy_quadlet_link.clone(),
172 source,
173 })?;
174 let network_spec = format!("{auth_service}:alias={auth_domain}");
175 if !content.contains(&format!("alias={auth_domain}")) {
176 let updated = inject_networks(&content, &[network_spec]);
177 steps.push(Step::WriteFile(GeneratedFile {
178 path: caddy_quadlet_target,
179 content: updated,
180 }));
181 need_caddy_restart = true;
182 }
183
184 let caddyfile_path = caddyfile_path()?;
185 let caddyfile = std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
186 path: caddyfile_path.clone(),
187 source,
188 })?;
189 if !caddyfile.contains(&format!("# Service-Source: registry/{auth_service}")) {
190 let target_host = primary_container_name(
195 &caddy_quadlet_link.with_file_name(format!("{auth_service}.container")),
196 auth_service.as_str(),
197 );
198 let block = render_site_block(&CaddySiteParams {
199 service_name: auth_service.to_string(),
200 target_host,
201 domain: auth_domain.to_string(),
202 container_port: auth_container_port,
203 https_port: issuer_port,
204 force_internal_tls: true,
208 });
209 let updated = add_route(&caddyfile, auth_service.as_str(), &block);
210 steps.push(Step::WriteFile(GeneratedFile {
211 path: caddyfile_path,
212 content: updated,
213 }));
214 need_caddy_restart = true;
215 }
216
217 if need_caddy_restart {
218 steps.push(Step::DaemonReload);
219 steps.push(Step::RestartService {
220 unit: "caddy".to_string(),
221 });
222 let ca_path = crate::service_home("caddy")?
227 .parent()
228 .map(|p| p.join("caddy-root-ca.crt"))
229 .unwrap_or_default();
230 steps.push(Step::WaitForFile {
231 path: ca_path,
232 timeout_secs: 15,
233 });
234 }
235
236 Ok(steps)
237}
238
239pub struct CaddySiteParams {
241 pub service_name: String,
244 pub domain: String,
245 pub target_host: String,
251 pub container_port: u16,
253 pub https_port: u16,
255 pub force_internal_tls: bool,
262}
263
264pub fn render_site_block(params: &CaddySiteParams) -> String {
275 let mut block = format!("# Service-Source: registry/{}\n", params.service_name);
276 block.push_str(&format!("{}:{} {{\n", params.domain, params.https_port));
277 if params.force_internal_tls || params.domain.ends_with(".internal") {
278 block.push_str(" tls internal\n");
279 } else {
280 block.push_str(" import services_tls\n");
281 }
282 block.push_str(&format!(
284 " reverse_proxy {}:{}\n",
285 params.target_host, params.container_port
286 ));
287 block.push_str("}\n");
288 block
289}
290
291pub fn primary_container_name(quadlet_path: &std::path::Path, fallback: &str) -> String {
296 let Ok(content) = std::fs::read_to_string(quadlet_path) else {
297 return fallback.to_string();
298 };
299 for line in content.lines() {
300 if let Some(rest) = line.trim().strip_prefix("ContainerName=") {
301 let name = rest.trim();
302 if !name.is_empty() {
303 return name.to_string();
304 }
305 }
306 }
307 fallback.to_string()
308}
309
310pub fn add_route(caddyfile: &str, service_name: &str, block: &str) -> String {
315 let cleaned = remove_route(caddyfile, service_name);
316 let mut result = cleaned.trim_end().to_string();
317 if !result.is_empty() {
318 result.push_str("\n\n");
319 }
320 result.push_str(block);
321 result.push('\n');
322 result
323}
324
325pub fn remove_route(caddyfile: &str, service_name: &str) -> String {
331 let marker = format!("# Service-Source: registry/{service_name}");
332 let lines: Vec<&str> = caddyfile.lines().collect();
333 let mut result = Vec::new();
334 let mut i = 0;
335
336 while i < lines.len() {
337 if lines[i].trim() == marker {
338 i += 1;
343 let mut depth: i32 = 0;
344 let mut entered_block = false;
345 while i < lines.len() {
346 let trimmed = lines[i].trim();
347 if trimmed.ends_with('{') {
348 depth += 1;
349 entered_block = true;
350 }
351 if trimmed.starts_with('}') {
352 depth -= 1;
353 }
354 i += 1;
355 if entered_block && depth <= 0 {
356 break;
357 }
358 }
359 while i < lines.len() && lines[i].trim().is_empty() {
361 i += 1;
362 }
363 } else {
364 result.push(lines[i]);
365 i += 1;
366 }
367 }
368
369 while result.last().map(|l| l.trim().is_empty()).unwrap_or(false) {
371 result.pop();
372 }
373
374 let mut out = result.join("\n");
375 if !out.is_empty() {
376 out.push('\n');
377 }
378 out
379}
380
381pub fn parse_domains(caddyfile: &str) -> Vec<(String, String)> {
385 let mut domains = Vec::new();
386 let mut current_service: Option<String> = None;
387
388 for line in caddyfile.lines() {
389 let trimmed = line.trim();
390 if let Some(svc) = trimmed.strip_prefix("# Service-Source: registry/") {
391 current_service = Some(svc.to_string());
392 } else if let Some(ref svc) = current_service {
393 if let Some(domain) = trimmed.strip_suffix('{') {
395 let domain = domain.trim();
396 if !domain.is_empty() {
397 domains.push((svc.clone(), domain.to_string()));
398 }
399 current_service = None;
400 }
401 }
402 }
403
404 domains
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 #[test]
412 fn byo_cert_snippet_round_trips() {
413 let mode = AcmeMode::Byo {
414 cert: "/etc/ryra/certs/origin.pem".into(),
415 key: "/etc/ryra/certs/origin.key".into(),
416 };
417 let snippet = mode.snippet();
418 assert!(
419 snippet.contains("tls /etc/ryra/certs/origin.pem /etc/ryra/certs/origin.key"),
420 "got: {snippet}"
421 );
422 assert_eq!(AcmeMode::detect_from_snippet(&snippet), Some(mode));
425 }
426
427 #[test]
428 fn detect_does_not_confuse_email_with_byo() {
429 assert_eq!(
431 AcmeMode::detect_from_snippet("(services_tls) {\n\ttls me@example.com\n}\n"),
432 Some(AcmeMode::WithEmail("me@example.com".into()))
433 );
434 }
435
436 #[test]
437 fn render_basic_block() {
438 let params = CaddySiteParams {
439 service_name: "whoami".to_string(),
440 target_host: "whoami".to_string(),
441 domain: "whoami.example.com".to_string(),
442 container_port: 8080,
443 https_port: 8443,
444 force_internal_tls: false,
445 };
446 let block = render_site_block(¶ms);
447 assert!(block.starts_with("# Service-Source: registry/whoami\n"));
448 assert!(block.contains("whoami.example.com:8443 {"));
449 assert!(block.contains(" import services_tls\n"));
450 assert!(!block.contains("tls internal"));
451 assert!(block.contains(" reverse_proxy whoami:8080"));
452 assert!(block.ends_with("}\n"));
453 }
454
455 #[test]
456 fn render_block_with_distinct_target_host() {
457 let params = CaddySiteParams {
463 service_name: "immich".to_string(),
464 target_host: "immich-server".to_string(),
465 domain: "immich.internal".to_string(),
466 container_port: 2283,
467 https_port: 8443,
468 force_internal_tls: false,
469 };
470 let block = render_site_block(¶ms);
471 assert!(block.contains("# Service-Source: registry/immich\n"));
472 assert!(block.contains(" reverse_proxy immich-server:2283"));
473 assert!(!block.contains("reverse_proxy immich:"));
474 }
475
476 #[test]
477 fn primary_container_name_reads_directive()
478 -> std::result::Result<(), Box<dyn std::error::Error>> {
479 let dir = tempfile::tempdir()?;
480 let path = dir.path().join("immich.container");
481 std::fs::write(
482 &path,
483 "[Container]\nImage=docker.io/immich/server\nContainerName=immich-server\nNetwork=immich.network\n",
484 )?;
485 assert_eq!(primary_container_name(&path, "immich"), "immich-server");
486 Ok(())
487 }
488
489 #[test]
490 fn primary_container_name_falls_back_when_directive_absent()
491 -> std::result::Result<(), Box<dyn std::error::Error>> {
492 let dir = tempfile::tempdir()?;
493 let path = dir.path().join("whoami.container");
494 std::fs::write(
495 &path,
496 "[Container]\nImage=docker.io/whoami\nNetwork=whoami.network\n",
497 )?;
498 assert_eq!(primary_container_name(&path, "whoami"), "whoami");
499 Ok(())
500 }
501
502 #[test]
503 fn primary_container_name_falls_back_when_file_missing() {
504 let missing = std::path::Path::new("/nonexistent/missing.container");
505 assert_eq!(primary_container_name(missing, "fallback"), "fallback");
506 }
507
508 #[test]
509 fn render_internal_domain_keeps_tls_internal() {
510 let params = CaddySiteParams {
513 service_name: "authelia".to_string(),
514 target_host: "authelia".to_string(),
515 domain: "auth.internal".to_string(),
516 container_port: 9091,
517 https_port: 8443,
518 force_internal_tls: false,
519 };
520 let block = render_site_block(¶ms);
521 assert!(block.contains(" tls internal\n"));
522 assert!(!block.contains("import services_tls"));
523 }
524
525 #[test]
526 fn acme_mode_internal_snippet() {
527 let s = AcmeMode::Internal.snippet();
528 assert!(s.starts_with("(services_tls) {"));
529 assert!(s.contains("tls internal"));
530 }
531
532 #[test]
533 fn acme_mode_with_email_snippet() {
534 let s = AcmeMode::WithEmail("admin@example.com".to_string()).snippet();
535 assert!(s.starts_with("(services_tls) {"));
536 assert!(s.contains("tls admin@example.com"));
537 assert!(!s.contains("tls internal"));
538 }
539
540 #[test]
541 fn acme_mode_detect_round_trips() {
542 for mode in [
543 AcmeMode::Internal,
544 AcmeMode::Anonymous,
545 AcmeMode::WithEmail("admin@example.com".into()),
546 ] {
547 let snippet = mode.snippet();
548 let detected = AcmeMode::detect_from_snippet(&snippet);
549 assert_eq!(detected, Some(mode));
550 }
551 }
552
553 #[test]
554 fn acme_mode_detect_user_customized_returns_none() {
555 let cf = "(services_tls) {\n\ttls {\n\t\tdns cloudflare {env.CF_API_TOKEN}\n\t}\n}\n";
560 assert_eq!(AcmeMode::detect_from_snippet(cf), None);
561
562 let extra = "(services_tls) {\n\ttls internal\n\theader X-Foo bar\n}\n";
563 assert_eq!(AcmeMode::detect_from_snippet(extra), None);
564 }
565
566 #[test]
567 fn acme_mode_anonymous_snippet_omits_tls_directive() {
568 let s = AcmeMode::Anonymous.snippet();
569 assert!(s.starts_with("(services_tls) {"));
570 assert!(!s.contains("\ttls "));
574 assert!(!s.contains("tls internal"));
575 assert!(!s.contains("tls @"));
576 }
577
578 #[test]
579 fn render_block_custom_https_port() {
580 let params = CaddySiteParams {
581 service_name: "app".to_string(),
582 target_host: "app".to_string(),
583 domain: "app.example.com".to_string(),
584 container_port: 3000,
585 https_port: 9443,
586 force_internal_tls: false,
587 };
588 let block = render_site_block(¶ms);
589 assert!(block.contains("app.example.com:9443 {"));
590 }
591
592 #[test]
593 fn render_force_internal_tls_on_public_domain() {
594 let params = CaddySiteParams {
600 service_name: "authelia".to_string(),
601 target_host: "authelia".to_string(),
602 domain: "auth.example.ts.net".to_string(),
603 container_port: 9091,
604 https_port: 443,
605 force_internal_tls: true,
606 };
607 let block = render_site_block(¶ms);
608 assert!(block.contains("auth.example.ts.net:443 {"));
609 assert!(block.contains(" tls internal\n"));
610 assert!(!block.contains("import services_tls"));
611 }
612
613 #[test]
614 fn add_route_to_empty() {
615 let block = "# Service-Source: registry/whoami\nwhoami.example.com {\n reverse_proxy host.containers.internal:8080\n}\n";
616 let result = add_route("", "whoami", block);
617 assert_eq!(result, format!("{block}\n"));
618 }
619
620 #[test]
621 fn add_route_appends() {
622 let existing = "# Service-Source: registry/foo\nfoo.example.com {\n reverse_proxy host.containers.internal:3000\n}\n";
623 let block = "# Service-Source: registry/bar\nbar.example.com {\n reverse_proxy host.containers.internal:4000\n}\n";
624 let result = add_route(existing, "bar", block);
625 assert!(result.contains("# Service-Source: registry/foo"));
626 assert!(result.contains("# Service-Source: registry/bar"));
627 }
628
629 #[test]
630 fn add_route_replaces_existing() {
631 let existing = "# Service-Source: registry/whoami\nwhoami.example.com {\n reverse_proxy host.containers.internal:8080\n}\n";
632 let new_block = "# Service-Source: registry/whoami\nwhoami.example.com {\n reverse_proxy host.containers.internal:9090\n}\n";
633 let result = add_route(existing, "whoami", new_block);
634 assert!(!result.contains("8080"));
635 assert!(result.contains("9090"));
636 }
637
638 #[test]
639 fn remove_route_single() {
640 let caddyfile = "# Service-Source: registry/whoami\nwhoami.example.com {\n reverse_proxy host.containers.internal:8080\n}\n";
641 let result = remove_route(caddyfile, "whoami");
642 assert_eq!(result, "");
643 }
644
645 #[test]
646 fn remove_route_preserves_others() {
647 let caddyfile = concat!(
648 "# Service-Source: registry/foo\nfoo.example.com {\n reverse_proxy host.containers.internal:3000\n}\n\n",
649 "# Service-Source: registry/bar\nbar.example.com {\n reverse_proxy host.containers.internal:4000\n}\n",
650 );
651 let result = remove_route(caddyfile, "foo");
652 assert!(!result.contains("foo"));
653 assert!(result.contains("# Service-Source: registry/bar"));
654 assert!(result.contains("reverse_proxy host.containers.internal:4000"));
655 }
656
657 #[test]
658 fn remove_route_preserves_user_blocks() {
659 let caddyfile = concat!(
660 "mysite.example.com {\n root * /var/www\n file_server\n}\n\n",
661 "# Service-Source: registry/whoami\nwhoami.example.com {\n reverse_proxy host.containers.internal:8080\n}\n",
662 );
663 let result = remove_route(caddyfile, "whoami");
664 assert!(result.contains("mysite.example.com"));
665 assert!(result.contains("file_server"));
666 assert!(!result.contains("ryra:whoami"));
667 }
668
669 #[test]
670 fn remove_route_with_nested_braces() {
671 let caddyfile = concat!(
672 "# Service-Source: registry/myapp\n",
673 "myapp.example.com {\n",
674 " forward_auth host.containers.internal:9091 {\n",
675 " uri /api/authz/forward-auth\n",
676 " }\n",
677 " reverse_proxy host.containers.internal:3000\n",
678 "}\n",
679 );
680 let result = remove_route(caddyfile, "myapp");
681 assert_eq!(result, "");
682 }
683
684 #[test]
685 fn parse_domains_basic() {
686 let caddyfile = concat!(
687 "# Service-Source: registry/whoami\nwhoami.example.com {\n reverse_proxy host.containers.internal:8080\n}\n\n",
688 "# Service-Source: registry/myapp\nmyapp.example.com {\n reverse_proxy host.containers.internal:3000\n}\n",
689 );
690 let domains = parse_domains(caddyfile);
691 assert_eq!(domains.len(), 2);
692 assert_eq!(
693 domains[0],
694 ("whoami".to_string(), "whoami.example.com".to_string())
695 );
696 assert_eq!(
697 domains[1],
698 ("myapp".to_string(), "myapp.example.com".to_string())
699 );
700 }
701
702 #[test]
703 fn parse_domains_ignores_user_blocks() {
704 let caddyfile = concat!(
705 "mysite.example.com {\n file_server\n}\n\n",
706 "# Service-Source: registry/whoami\nwhoami.example.com {\n reverse_proxy host.containers.internal:8080\n}\n",
707 );
708 let domains = parse_domains(caddyfile);
709 assert_eq!(domains.len(), 1);
710 assert_eq!(domains[0].0, "whoami");
711 }
712
713 #[test]
714 fn caddyfile_path_is_under_service_home() {
715 let path = caddyfile_path().expect("HOME should be set in test environment");
716 assert!(
717 path.ends_with("services/caddy/config/Caddyfile"),
718 "unexpected caddyfile path: {path:?}"
719 );
720 }
721}