Skip to main content

ryra_core/
caddy.rs

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
9/// Path to the ryra-managed Caddyfile.
10///
11/// Lives inside the caddy service's data dir so the existing volume mount
12/// (`%h/config` -> `/etc/caddy/`) picks it up automatically.
13pub fn caddyfile_path() -> Result<PathBuf> {
14    Ok(crate::service_home(WellKnownService::Caddy.as_str())?
15        .join("config")
16        .join("Caddyfile"))
17}
18
19/// Path to the user-owned TLS snippet.
20///
21/// Site blocks emit `import services_tls`; the actual TLS strategy lives here.
22/// Default is `tls internal` (LAN mode); `ryra add caddy --acme <email>`
23/// seeds it with `tls <email>` (Let's Encrypt). After first write, ryra
24/// never touches this file — users edit it for Cloudflare DNS-01,
25/// wildcards, BYO certs, or anything else Caddy supports.
26pub 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/// What ryra writes into `tls.caddy` on first install of caddy.
33///
34/// Modeled as an exhaustive enum so callers can't smuggle a third
35/// state via empty-string sentinels — `match` on this and the planner,
36/// install message, and snippet renderer stay in agreement.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum AcmeMode {
39    /// Self-signed via Caddy's internal CA (LAN-friendly default).
40    /// Browsers warn unless ryra's CA is trusted.
41    Internal,
42    /// Let's Encrypt with no registration email — real certs but no
43    /// renewal-notice email tied to an account.
44    Anonymous,
45    /// Let's Encrypt with a registration email for renewal notices.
46    WithEmail(String),
47}
48
49impl AcmeMode {
50    /// Renders the `(services_tls) { … }` snippet body for this mode.
51    pub fn snippet(&self) -> String {
52        match self {
53            AcmeMode::Internal => "(services_tls) {\n\ttls internal\n}\n".to_string(),
54            // Empty body — sites that import this get no `tls` directive,
55            // which makes Caddy auto-issue LE certs for any public hostname.
56            AcmeMode::Anonymous => "(services_tls) {\n}\n".to_string(),
57            AcmeMode::WithEmail(email) => format!("(services_tls) {{\n\ttls {email}\n}}\n"),
58        }
59    }
60
61    /// Best-effort reverse of [`Self::snippet`]: looks at the contents
62    /// of `tls.caddy` and recognizes the three ryra-written shapes —
63    /// `tls internal`, `tls <email>`, or an empty body. Returns `None`
64    /// for user-customized snippets (Cloudflare DNS-01, BYO cert paths,
65    /// arbitrary directives) so the install message can fall back to
66    /// "user-managed" instead of misclassifying.
67    pub fn detect_from_snippet(contents: &str) -> Option<Self> {
68        // Strip the `(services_tls) { … }` wrapper and look at the body.
69        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        // Single-line body. Anything more complicated (multiple
79        // directives, sub-blocks like `tls { dns cloudflare ... }`) is
80        // user territory.
81        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            // Reject obvious non-email shapes (file paths, directive
91            // blocks) — those are user-managed.
92            if arg.contains('@') && !arg.contains(' ') {
93                return Some(AcmeMode::WithEmail(arg.to_string()));
94            }
95        }
96        None
97    }
98}
99
100/// Ensure Caddy is set up to route requests for the auth provider.
101///
102/// Returns steps to (a) add Caddy to the auth provider's podman network with
103/// a domain alias so service containers resolving the auth FQDN reach Caddy,
104/// and (b) install a site block in the Caddyfile that terminates TLS and
105/// reverse-proxies to the auth provider (which requires proper
106/// X-Forwarded-Proto/Host headers for OIDC).
107///
108/// A no-op when Caddy isn't installed — returns an empty Vec.
109pub 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    // Caddy is installed, so caddy.container must exist — a missing or
124    // unreadable file here means something has gone wrong that we should
125    // surface, not swallow (OIDC discovery silently breaks otherwise).
126    //
127    // The path under quadlet_dir is a symlink ryra installs pointing at
128    // the real file in service_home. Read through the symlink for content,
129    // but write to the resolved target — Step::WriteFile refuses to
130    // clobber symlinks, and we don't want to convert this one to a
131    // regular file (the symlink is what systemd-quadlet looks up).
132    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        // The auth provider's primary container DNS name. For Authelia
160        // today this matches the service name, but go through the same
161        // resolver so a future provider with a non-default ContainerName
162        // (or a multi-container layout) just works.
163        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        // Wait for caddy's ExecStartPost to export the CA cert. The cert is
188        // needed by add_service to create the merged CA bundle for OIDC
189        // services. `systemctl restart` returns after ExecStart but before
190        // ExecStartPost completes.
191        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
204/// Parameters for generating a Caddy site block.
205pub struct CaddySiteParams {
206    /// Registry service name. Used as the `# Service-Source: registry/<name>`
207    /// marker so [`add_route`] / [`remove_route`] can locate the block.
208    pub service_name: String,
209    pub domain: String,
210    /// Container DNS name caddy reverse-proxies to. Often equals
211    /// `service_name`, but multi-container services declare a different
212    /// `ContainerName=` for their primary container (e.g. immich's main
213    /// container is `immich-server`, not `immich`). See
214    /// [`primary_container_name`] for the resolution helper.
215    pub target_host: String,
216    /// Container port the service listens on (used with container DNS name).
217    pub container_port: u16,
218    /// Caddy's HTTPS listen port (from the installed caddy service's port map).
219    pub https_port: u16,
220}
221
222/// Generate a Caddy site block for a service.
223///
224/// The block always starts with a `# Service-Source: registry/<service_name>` marker comment
225/// so that [`add_route`] and [`remove_route`] can locate it.
226///
227/// TLS strategy is delegated to the user-owned `(services_tls)` snippet
228/// imported at the top of the Caddyfile — see [`tls_snippet_path`].
229/// Exception: `*.internal` hosts always use `tls internal` directly,
230/// because Let's Encrypt can't issue certs for the reserved `.internal`
231/// TLD even when the user has flipped the snippet to ACME mode.
232pub 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    // Use the primary container's DNS name on caddy's shared network.
241    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
249/// Read the `ContainerName=` directive from a quadlet file. Returns
250/// `fallback` if the file can't be read or the directive is absent —
251/// quadlets without `ContainerName=` are named after the unit by default
252/// and that default matches the service name in ryra's convention.
253pub 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
268/// Add or replace a service's block in the Caddyfile content.
269///
270/// If a block with `# Service-Source: registry/<service_name>` already exists, it is replaced.
271/// Otherwise the new block is appended.
272pub 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
283/// Remove a service's block from the Caddyfile content.
284///
285/// Finds the `# Service-Source: registry/<service_name>` marker and removes everything from
286/// that line through the closing `}` at brace depth 0, using brace-depth
287/// tracking to correctly handle nested blocks.
288pub 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            // Skip the marker line and the entire site block that follows.
297            // Caddyfile blocks open with `domain {` and close with `}` alone
298            // on a line. Track depth by looking at line-ending `{` and
299            // line-starting `}` (the only valid positions in Caddyfile syntax).
300            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            // Skip trailing empty lines after the removed block
318            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    // Clean up trailing blank lines
328    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
339/// Parse ryra-managed domains from a Caddyfile.
340///
341/// Returns `(service_name, domain)` pairs extracted from `# Service-Source: registry/` markers.
342pub 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            // Next non-comment line after marker should be "domain.com {"
352            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(&params);
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        // Multi-container services declare a primary ContainerName=
390        // different from the service name (e.g. immich's main container
391        // is `immich-server`). The Service-Source marker must stay
392        // service-named so add_route/remove_route locate the block, but
393        // the reverse_proxy target must use the actual container name.
394        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(&params);
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        // *.internal hosts can't get LE certs, so they must bypass the
442        // user-owned snippet (which may have been flipped to ACME mode).
443        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(&params);
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        // Cloudflare DNS-01 and BYO-cert shapes shouldn't be mis-classified
486        // as one of the ryra-written modes — the install message has to
487        // fall back to "user-managed" instead of lying.
488        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        // No `tls` *directive* inside the body — Caddy auto-issues
503        // anonymously when the snippet is empty. The "tls" inside
504        // `services_tls` is the snippet name and must remain.
505        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(&params);
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}