Skip to main content

ryra_core/
caddy.rs

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
8/// Path to the ryra-managed Caddyfile.
9///
10/// Lives inside the caddy service's data dir so the existing volume mount
11/// (`%h/config` -> `/etc/caddy/`) picks it up automatically.
12pub fn caddyfile_path() -> Result<PathBuf> {
13    Ok(crate::service_home(WellKnownService::Caddy.as_str())?
14        .join("config")
15        .join("Caddyfile"))
16}
17
18/// Path to the user-owned TLS snippet.
19///
20/// Site blocks emit `import services_tls`; the actual TLS strategy lives here.
21/// Default is `tls internal` (LAN mode); `ryra add caddy --acme <email>`
22/// seeds it with `tls <email>` (Let's Encrypt). After first write, ryra
23/// never touches this file — users edit it for Cloudflare DNS-01,
24/// wildcards, BYO certs, or anything else Caddy supports.
25pub 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/// What ryra writes into `tls.caddy` on first install of caddy.
32///
33/// Modeled as an exhaustive enum so callers can't smuggle a third
34/// state via empty-string sentinels — `match` on this and the planner,
35/// install message, and snippet renderer stay in agreement.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum AcmeMode {
38    /// Self-signed via Caddy's internal CA (LAN-friendly default).
39    /// Browsers warn unless ryra's CA is trusted.
40    Internal,
41    /// Let's Encrypt with no registration email — real certs but no
42    /// renewal-notice email tied to an account.
43    Anonymous,
44    /// Let's Encrypt with a registration email for renewal notices.
45    WithEmail(String),
46    /// Bring-your-own certificate: Caddy serves the given cert + key files
47    /// directly, issuing nothing. The right mode behind an upstream proxy that
48    /// terminates the public TLS (Cloudflare in Full-Strict with an Origin CA
49    /// cert), or anywhere the operator manages certs out of band.
50    Byo { cert: String, key: String },
51}
52
53impl AcmeMode {
54    /// Convert a user-supplied Let's Encrypt email into the matching LE
55    /// mode: empty (user hit Enter at the prompt) means anonymous LE,
56    /// anything else means LE with that email for renewal notices.
57    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    /// Renders the `(services_tls) { … }` snippet body for this mode.
66    pub fn snippet(&self) -> String {
67        match self {
68            AcmeMode::Internal => "(services_tls) {\n\ttls internal\n}\n".to_string(),
69            // Empty body — sites that import this get no `tls` directive,
70            // which makes Caddy auto-issue LE certs for any public hostname.
71            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    /// Best-effort reverse of [`Self::snippet`]: looks at the contents
80    /// of `tls.caddy` and recognizes the three ryra-written shapes —
81    /// `tls internal`, `tls <email>`, or an empty body. Returns `None`
82    /// for user-customized snippets (Cloudflare DNS-01, BYO cert paths,
83    /// arbitrary directives) so the install message can fall back to
84    /// "user-managed" instead of misclassifying.
85    pub fn detect_from_snippet(contents: &str) -> Option<Self> {
86        // Strip the `(services_tls) { … }` wrapper and look at the body.
87        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        // Single-line body. Anything more complicated (multiple
97        // directives, sub-blocks like `tls { dns cloudflare ... }`) is
98        // user territory.
99        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            // `tls <email>` — single token with an @.
109            if arg.contains('@') && !arg.contains(' ') {
110                return Some(AcmeMode::WithEmail(arg.to_string()));
111            }
112            // `tls <cert> <key>` — two tokens, both file paths (no @).
113            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
125/// Ensure Caddy is set up to route requests for the auth provider.
126///
127/// Returns steps to (a) add Caddy to the auth provider's podman network with
128/// a domain alias so service containers resolving the auth FQDN reach Caddy,
129/// and (b) install a site block in the Caddyfile that terminates TLS and
130/// reverse-proxies to the auth provider (which requires proper
131/// X-Forwarded-Proto/Host headers for OIDC).
132///
133/// `issuer_port` is the port the internal vhost listens on — it must equal
134/// the port in authelia's issuer URL so the back-channel reaches Caddy at the
135/// exact host:port authelia's discovery response advertises. `*.internal`
136/// carries Caddy's HTTPS port (e.g. `:8443`); `tailscale serve` / public
137/// exposures are port-less (`:443`).
138///
139/// A no-op when Caddy isn't installed — returns an empty Vec.
140pub 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    // Caddy is installed, so caddy.container must exist — a missing or
155    // unreadable file here means something has gone wrong that we should
156    // surface, not swallow (OIDC discovery silently breaks otherwise).
157    //
158    // The path under quadlet_dir is a symlink ryra installs pointing at
159    // the real file in service_home. Read through the symlink for content,
160    // but write to the resolved target — Step::WriteFile refuses to
161    // clobber symlinks, and we don't want to convert this one to a
162    // regular file (the symlink is what systemd-quadlet looks up).
163    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        // The auth provider's primary container DNS name. For Authelia
191        // today this matches the service name, but go through the same
192        // resolver so a future provider with a non-default ContainerName
193        // (or a multi-container layout) just works.
194        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            // The auth provider's internal back-channel vhost always
205            // terminates with Caddy's self-signed CA, even when the browser
206            // reaches authelia at a real cert via tailscale/external proxy.
207            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        // Wait for caddy's ExecStartPost to export the CA cert. The cert is
223        // needed by add_service to create the merged CA bundle for OIDC
224        // services. `systemctl restart` returns after ExecStart but before
225        // ExecStartPost completes.
226        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
239/// Parameters for generating a Caddy site block.
240pub struct CaddySiteParams {
241    /// Registry service name. Used as the `# Service-Source: registry/<name>`
242    /// marker so [`add_route`] / [`remove_route`] can locate the block.
243    pub service_name: String,
244    pub domain: String,
245    /// Container DNS name caddy reverse-proxies to. Often equals
246    /// `service_name`, but multi-container services declare a different
247    /// `ContainerName=` for their primary container (e.g. immich's main
248    /// container is `immich-server`, not `immich`). See
249    /// [`primary_container_name`] for the resolution helper.
250    pub target_host: String,
251    /// Container port the service listens on (used with container DNS name).
252    pub container_port: u16,
253    /// Caddy's HTTPS listen port (from the installed caddy service's port map).
254    pub https_port: u16,
255    /// Force `tls internal` (Caddy's self-signed CA) regardless of the
256    /// domain suffix. Set for the auth provider's internal OIDC-terminator
257    /// vhost: even when authelia is browser-reachable at a `*.ts.net` or
258    /// public host (real cert via `tailscale serve` / external proxy), the
259    /// *internal* back-channel hop terminates at Caddy with a self-signed
260    /// cert that the service trusts via the mounted CA bundle.
261    pub force_internal_tls: bool,
262}
263
264/// Generate a Caddy site block for a service.
265///
266/// The block always starts with a `# Service-Source: registry/<service_name>` marker comment
267/// so that [`add_route`] and [`remove_route`] can locate it.
268///
269/// TLS strategy is delegated to the user-owned `(services_tls)` snippet
270/// imported at the top of the Caddyfile — see [`tls_snippet_path`].
271/// Exception: `*.internal` hosts always use `tls internal` directly,
272/// because Let's Encrypt can't issue certs for the reserved `.internal`
273/// TLD even when the user has flipped the snippet to ACME mode.
274pub 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    // Use the primary container's DNS name on caddy's shared network.
283    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
291/// Read the `ContainerName=` directive from a quadlet file. Returns
292/// `fallback` if the file can't be read or the directive is absent —
293/// quadlets without `ContainerName=` are named after the unit by default
294/// and that default matches the service name in ryra's convention.
295pub 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
310/// Add or replace a service's block in the Caddyfile content.
311///
312/// If a block with `# Service-Source: registry/<service_name>` already exists, it is replaced.
313/// Otherwise the new block is appended.
314pub 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
325/// Remove a service's block from the Caddyfile content.
326///
327/// Finds the `# Service-Source: registry/<service_name>` marker and removes everything from
328/// that line through the closing `}` at brace depth 0, using brace-depth
329/// tracking to correctly handle nested blocks.
330pub 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            // Skip the marker line and the entire site block that follows.
339            // Caddyfile blocks open with `domain {` and close with `}` alone
340            // on a line. Track depth by looking at line-ending `{` and
341            // line-starting `}` (the only valid positions in Caddyfile syntax).
342            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            // Skip trailing empty lines after the removed block
360            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    // Clean up trailing blank lines
370    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
381/// Parse ryra-managed domains from a Caddyfile.
382///
383/// Returns `(service_name, domain)` pairs extracted from `# Service-Source: registry/` markers.
384pub 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            // Next non-comment line after marker should be "domain.com {"
394            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        // Reading it back recovers the same mode (so `ryra add` can report
423        // "your own certificate" instead of "user-customized").
424        assert_eq!(AcmeMode::detect_from_snippet(&snippet), Some(mode));
425    }
426
427    #[test]
428    fn detect_does_not_confuse_email_with_byo() {
429        // A single token with @ is still LE-with-email, not a cert pair.
430        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(&params);
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        // Multi-container services declare a primary ContainerName=
458        // different from the service name (e.g. immich's main container
459        // is `immich-server`). The Service-Source marker must stay
460        // service-named so add_route/remove_route locate the block, but
461        // the reverse_proxy target must use the actual container name.
462        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(&params);
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        // *.internal hosts can't get LE certs, so they must bypass the
511        // user-owned snippet (which may have been flipped to ACME mode).
512        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(&params);
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        // Shapes ryra doesn't model (DNS-01 sub-blocks, multi-directive bodies)
556        // must fall back to "user-managed" instead of being mis-classified.
557        // Note: a plain `tls <cert> <key>` pair IS modeled now (AcmeMode::Byo),
558        // covered by `byo_cert_snippet_round_trips`.
559        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        // No `tls` *directive* inside the body — Caddy auto-issues
571        // anonymously when the snippet is empty. The "tls" inside
572        // `services_tls` is the snippet name and must remain.
573        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(&params);
589        assert!(block.contains("app.example.com:9443 {"));
590    }
591
592    #[test]
593    fn render_force_internal_tls_on_public_domain() {
594        // The auth provider's internal back-channel vhost terminates with
595        // Caddy's self-signed CA even on a non-`.internal` host (e.g. a
596        // `*.ts.net` issuer fronted by `tailscale serve`). The browser
597        // never hits this block; only service containers do, trusting
598        // Caddy's CA via the mounted bundle.
599        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(&params);
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}