1pub mod auth_bridge;
2pub mod authelia;
3pub mod backup;
4pub mod caddy;
5pub mod capability;
6pub mod config;
7pub mod configure;
8pub mod data;
9pub mod error;
10pub mod exposure;
11pub mod generate;
12pub mod manifest;
13pub mod metadata;
14pub mod paths;
15pub mod plan;
16pub mod registry;
17pub mod system;
18pub mod upgrade;
19pub mod well_known;
20
21use std::collections::BTreeMap;
22use std::path::{Path, PathBuf};
23
24use config::ConfigPaths;
25use config::schema::InstalledService;
26use error::{Error, Result};
27
28pub use capability::{
29 Capability, any_installed_provider, find_installed_provider, installed_provides,
30 service_provides,
31};
32pub use configure::{
33 ConfigureChange, ConfigureResult, ExposureChange, Overrides as ConfigureOverrides,
34 configure_service,
35};
36pub use exposure::{
37 Exposure, check_auth_exposure_compat, is_caddy_local_url, is_public_url, is_tailscale_url,
38};
39pub use generate::GeneratedFile;
40pub use manifest::{ManifestEntry, manifest_path};
41pub use metadata::{Metadata, load_metadata};
42pub use paths::{
43 CONFIG_DIR_ENV, DATA_DIR_ENV, DEFAULT_REGISTRY_URL, REGISTRY_DEFAULT, REGISTRY_DIR_ENV,
44 metadata_path, quadlet_dir, service_data_root, service_home, systemd_user_dir,
45};
46pub use plan::{AddResult, RemoveResult, ResetResult, Step, TailscalePort, TrackedEnv, Warning};
47pub use upgrade::{
48 BackupSnapshot, DEFAULT_BACKUP_KEEP, DiffEntry, DiffKind, DiffResult, EnvAddition,
49 RevertResult, UpgradeResult, diff_service, list_backups, prune_backups, revert_service,
50 upgrade_service,
51};
52pub use well_known::WellKnownService;
53
54pub(crate) use paths::home_dir;
55pub(crate) use well_known::caddy_https_port;
56
57pub async fn resolve_registry_dir(service_ref: ®istry::resolve::ServiceRef) -> Result<PathBuf> {
59 let paths = ConfigPaths::resolve()?;
60 paths.ensure_cache_dir()?;
61 let config = config::load_or_default(&paths.config_file)?;
62 registry::resolve::resolve_registry_dir(service_ref, &config, &paths.cache_dir).await
63}
64
65pub fn service_ref_from_installed(installed: &InstalledService) -> registry::resolve::ServiceRef {
67 if installed.repo.is_empty() || installed.repo == REGISTRY_DEFAULT {
68 registry::resolve::ServiceRef::Default(installed.name.clone())
69 } else {
70 registry::resolve::ServiceRef::Custom {
71 registry: installed.repo.clone(),
72 service: installed.name.clone(),
73 }
74 }
75}
76
77fn retroactive_network_joins(
86 new_service: &str,
87 quadlet_path: &std::path::Path,
88 _repo_dir: Option<&std::path::Path>,
89) -> Vec<Step> {
90 let mut steps = Vec::new();
91 let new_cap = if service_provides(new_service, Capability::ReverseProxy) {
96 Capability::ReverseProxy
97 } else if service_provides(new_service, Capability::SmtpRelay) {
98 Capability::SmtpRelay
99 } else {
100 return steps;
101 };
102
103 let installed = list_installed().unwrap_or_default();
104 for svc in &installed {
105 if !svc.provides.is_empty() {
108 continue;
109 }
110 let (network_name, should_join) = match new_cap {
111 Capability::ReverseProxy => {
112 let wants_proxy = matches!(
116 svc.exposure,
117 Exposure::Internal { .. } | Exposure::Public { .. }
118 );
119 (new_service.to_string(), wants_proxy)
120 }
121 Capability::SmtpRelay => {
122 (
125 new_service.to_string(),
126 service_uses_smtp_relay(&svc.name, new_service),
127 )
128 }
129 Capability::OidcProvider | Capability::ForwardAuthProvider => {
133 continue;
134 }
135 };
136 if !should_join {
137 continue;
138 }
139 let installed_names_owned: Vec<String> = installed.iter().map(|s| s.name.clone()).collect();
146 let all_service_names: Vec<&str> =
147 installed_names_owned.iter().map(|s| s.as_str()).collect();
148 let marker = format!("Network={network_name}.network");
149 let mut units_to_restart: Vec<String> = Vec::new();
150 let Ok(entries) = std::fs::read_dir(quadlet_path) else {
151 continue;
152 };
153 for entry in entries.flatten() {
154 let path = entry.path();
155 let name = match path.file_name().and_then(|n| n.to_str()) {
156 Some(n) if n.ends_with(".container") => n.to_string(),
157 _ => continue,
158 };
159 if !quadlet_belongs_to(&name, &svc.name, &all_service_names) {
160 continue;
161 }
162 let content = match std::fs::read_to_string(&path) {
163 Ok(c) => c,
164 Err(_) => continue,
165 };
166 if content.contains(&marker) {
167 continue;
168 }
169 let real_path = match std::fs::canonicalize(&path) {
173 Ok(p) => p,
174 Err(_) => continue,
175 };
176 let updated =
177 generate::bundle::inject_networks(&content, std::slice::from_ref(&network_name));
178 steps.push(Step::WriteFile(GeneratedFile {
179 path: real_path,
180 content: updated,
181 }));
182 let unit = name.trim_end_matches(".container").to_string();
185 units_to_restart.push(unit);
186 }
187 if !units_to_restart.is_empty() {
188 steps.push(Step::DaemonReload);
189 for unit in units_to_restart {
190 steps.push(Step::RestartService { unit });
191 }
192 }
193 }
194 steps
195}
196
197fn service_uses_smtp_relay(service_name: &str, relay_host: &str) -> bool {
203 let env_path = match service_home(service_name) {
204 Ok(h) => h.join(".env"),
205 Err(_) => return false,
206 };
207 let content = match std::fs::read_to_string(&env_path) {
208 Ok(c) => c,
209 Err(_) => return false,
210 };
211 let with_port = format!("{relay_host}:");
212 content.lines().any(|line| {
213 let Some((_, value)) = line.split_once('=') else {
214 return false;
215 };
216 let v = value.trim();
217 v == relay_host || v.starts_with(&with_port)
218 })
219}
220
221#[allow(clippy::too_many_arguments)]
233fn resolve_extra_networks(
234 service_name: &str,
235 enable_auth: bool,
236 authelia_installed: bool,
237 caddy_installed: bool,
238 inbucket_installed: bool,
239 has_url: bool,
240 has_smtp: bool,
241) -> Vec<String> {
242 let mut networks = Vec::new();
243 if enable_auth && authelia_installed && !WellKnownService::Authelia.matches(service_name) {
244 networks.push(WellKnownService::Authelia.to_string());
245 }
246 let joins_inbucket =
249 has_smtp && inbucket_installed && !WellKnownService::Inbucket.matches(service_name);
250 if joins_inbucket {
251 networks.push(WellKnownService::Inbucket.to_string());
252 }
253 let joins_caddy = (has_url || enable_auth || WellKnownService::Inbucket.matches(service_name))
254 && caddy_installed
255 && !WellKnownService::Caddy.matches(service_name);
256 if joins_caddy && !networks.contains(&WellKnownService::Caddy.to_string()) {
257 networks.push(WellKnownService::Caddy.to_string());
258 }
259 networks
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub enum PlanMode {
270 Add,
274 Upgrade,
278}
279
280#[derive(Debug, Clone, PartialEq, Eq)]
289pub enum AuthChoice {
290 None,
292 Native(registry::service_def::AuthKind),
295}
296
297impl AuthChoice {
298 pub fn enabled(&self) -> bool {
301 !matches!(self, AuthChoice::None)
302 }
303
304 pub fn native_kind(&self) -> Option<®istry::service_def::AuthKind> {
307 match self {
308 AuthChoice::Native(kind) => Some(kind),
309 AuthChoice::None => None,
310 }
311 }
312}
313
314pub struct AddServiceParams<'a> {
319 pub service_name: &'a str,
320 pub exposure: &'a Exposure,
321 pub auth: AuthChoice,
322 pub enable_smtp: bool,
323 pub enable_backup: bool,
324 pub env_overrides: &'a BTreeMap<String, String>,
325 pub enabled_groups: &'a std::collections::BTreeSet<String>,
326 pub registry_name: &'a str,
327 pub repo_dir: &'a Path,
328 pub pre_built_ctx: Option<BTreeMap<String, String>>,
332 pub port_in_use: &'a dyn Fn(u16) -> bool,
333 pub acme_mode: Option<&'a caddy::AcmeMode>,
334 pub mode: PlanMode,
335 pub port_overrides: &'a BTreeMap<String, u16>,
341}
342
343pub fn add_service(params: AddServiceParams<'_>) -> Result<AddResult> {
345 let AddServiceParams {
346 service_name,
347 exposure,
348 auth,
349 enable_smtp,
350 enable_backup,
351 env_overrides,
352 enabled_groups,
353 registry_name,
354 repo_dir,
355 pre_built_ctx,
356 port_in_use,
357 acme_mode,
358 mode,
359 port_overrides,
360 } = params;
361 let auth_kind: Option<®istry::service_def::AuthKind> = auth.native_kind();
366 let enable_auth: bool = auth.enabled();
367 let url: Option<&str> = exposure.url();
368 let paths = ConfigPaths::resolve()?;
369 let config = config::load_or_default(&paths.config_file)?;
370
371 if mode == PlanMode::Add {
377 if is_service_installed(service_name) {
378 return Err(Error::ServiceAlreadyInstalled(service_name.to_string()));
379 }
380
381 if data::enumerate_service(service_name)?.is_some() {
389 return Err(Error::ServiceIncomplete(service_name.to_string()));
390 }
391 }
392
393 let reg_service = registry::find_service(repo_dir, service_name)?;
394
395 if let Some(msg) = reg_service.def.check_architecture() {
397 return Err(Error::UnsupportedArchitecture(msg));
398 }
399
400 let missing_requires: Vec<&str> = reg_service
402 .def
403 .requires
404 .iter()
405 .filter(|r| !is_service_installed(&r.service))
406 .map(|r| r.service.as_str())
407 .collect();
408 if !missing_requires.is_empty() {
409 return Err(Error::MissingRequiredServices {
410 service: service_name.to_string(),
411 missing: missing_requires.iter().map(|s| s.to_string()).collect(),
412 });
413 }
414
415 if auth_kind.is_some() && config.auth.is_none() {
417 return Err(Error::AuthNotConfigured);
418 }
419
420 if enable_auth
424 && reg_service.def.integrations.auth.is_empty()
425 && !capability::def_provides(®_service.def, Capability::OidcProvider)
426 {
427 return Err(Error::NoOidcSupport(service_name.to_string()));
428 }
429
430 if enable_backup && !reg_service.def.integrations.backup {
434 return Err(Error::BackupNotSupported(service_name.to_string()));
435 }
436
437 for g in enabled_groups {
441 if !reg_service.def.env_groups.iter().any(|eg| &eg.name == g) {
442 let known: Vec<String> = reg_service
443 .def
444 .env_groups
445 .iter()
446 .map(|eg| eg.name.clone())
447 .collect();
448 let hint = if known.is_empty() {
449 " (service defines no env_groups)".to_string()
450 } else {
451 format!(" (known: {})", known.join(", "))
452 };
453 return Err(Error::UnknownEnvGroup {
454 service: service_name.to_string(),
455 group: g.clone(),
456 hint,
457 });
458 }
459 }
460
461 let mut port_warnings: Vec<Warning> = Vec::new();
467 let mut claimed: std::collections::HashSet<u16> = reg_service
468 .def
469 .ports
470 .iter()
471 .filter_map(|p| p.host_port)
472 .collect();
473 let mut resolved_ports: Vec<(String, u16)> = Vec::with_capacity(reg_service.def.ports.len());
474 for p in ®_service.def.ports {
475 let host = if let Some(pinned) = port_overrides.get(&p.name) {
476 *pinned
481 } else if let Some(hp) = p.host_port {
482 hp
483 } else {
484 let privileged = p.container_port < 1024;
485 let claimed_in_service = claimed.contains(&p.container_port);
486 let in_use = port_in_use(p.container_port);
487 if privileged || claimed_in_service || in_use {
488 let allocated = system::port::allocate_port_excluding(&claimed, port_in_use)?;
489 let reason = if privileged {
490 "port is privileged (requires root)".to_string()
491 } else if claimed_in_service {
492 format!(
493 "port {} is already claimed by another port in this service",
494 p.container_port
495 )
496 } else {
497 format!("port {} is already in use", p.container_port)
498 };
499 port_warnings.push(Warning::PortReassigned {
500 service_name: service_name.to_string(),
501 port_name: p.name.clone(),
502 original_port: p.container_port,
503 assigned_port: allocated,
504 reason,
505 });
506 allocated
507 } else {
508 p.container_port
509 }
510 };
511 claimed.insert(host);
512 resolved_ports.push((p.name.clone(), host));
513 }
514
515 if WellKnownService::Caddy.matches(service_name)
522 && system::sysctl::rootless_can_bind_low_ports()
523 {
524 for (name, port) in resolved_ports.iter_mut() {
525 match name.as_str() {
526 "http" if *port == 8080 => *port = 80,
527 "https" if *port == 8443 => *port = 443,
528 _ => {}
529 }
530 }
531 }
532
533 let host_port = resolved_ports
536 .iter()
537 .find(|(name, _)| name.eq_ignore_ascii_case("http"))
538 .or_else(|| resolved_ports.first())
539 .map(|(_, p)| *p);
540
541 for (_, port) in &resolved_ports {
545 if port_in_use(*port) {
546 return Err(Error::PortConflict { port: *port });
547 }
548 }
549
550 let home_dir = service_home(service_name)?;
551 let quadlet_path = quadlet_dir()?;
552
553 let installed_now = list_installed().unwrap_or_default();
557 let authelia_installed =
558 find_installed_provider(&installed_now, Capability::OidcProvider).is_some();
559 let caddy_installed =
560 find_installed_provider(&installed_now, Capability::ReverseProxy).is_some();
561 let inbucket_installed =
562 find_installed_provider(&installed_now, Capability::SmtpRelay).is_some();
563
564 let auth_bridge = auth_bridge::build(&auth_bridge::AuthBridgeParams {
568 service_name,
569 service_provides: ®_service.def.capabilities.provides,
570 enable_auth,
571 config: &config,
572 installed: &installed_now,
573 service_data: &home_dir,
574 })?;
575
576 let (extra_volumes, extra_env, extra_exec_start_pre, auth_bridge_steps) = match auth_bridge {
577 Some(b) => (b.volumes, b.env, b.exec_start_pre, b.steps),
578 None => (Vec::new(), BTreeMap::new(), Vec::new(), Vec::new()),
579 };
580
581 let has_smtp = enable_smtp
582 && reg_service.def.integrations.smtp
583 && !reg_service.def.mappings.smtp.is_empty()
584 && config.smtp.is_some();
585 let extra_networks = resolve_extra_networks(
586 service_name,
587 enable_auth,
588 authelia_installed,
589 caddy_installed,
590 inbucket_installed,
591 url.is_some(),
592 has_smtp,
593 );
594
595 let output = generate::generate_env(generate::GenerateEnvParams {
596 config: &config,
597 service_def: ®_service.def,
598 auth_kind,
599 host_port,
600 resolved_ports: &resolved_ports,
601 env_overrides,
602 exposure,
603 extra_env,
604 pre_built_ctx,
605 enable_smtp: has_smtp,
606 enabled_groups,
607 })?;
608
609 let podman_args: Vec<String> = Vec::new();
610
611 let port_names: Vec<String> = resolved_ports.iter().map(|(n, _)| n.clone()).collect();
613
614 let install_metadata = Metadata {
627 registry: registry_name.to_string(),
628 url: url.map(str::to_string),
629 auth: auth_kind.cloned(),
630 provides: reg_service.def.capabilities.provides.clone(),
631 backup_enabled: enable_backup,
632 smtp_enabled: enable_smtp,
633 enabled_groups: enabled_groups.iter().cloned().collect(),
634 runtime: reg_service.def.service.runtime.clone(),
635 };
636
637 if reg_service.def.service.runtime == registry::service_def::Runtime::Native {
642 let tracked_envs = collect_static_envs(®_service.def, &output.ctx, enabled_groups)?;
643 let allocated_ports = resolved_ports.clone();
644 let generated_secrets = collect_generated_secrets(®_service.def, env_overrides);
645 return build_native_add(NativeAddParams {
646 service_name,
647 reg_service: ®_service,
648 home_dir: &home_dir,
649 output,
650 install_metadata: &install_metadata,
651 registry_name,
652 url,
653 tracked_envs,
654 allocated_ports,
655 generated_secrets,
656 });
657 }
658
659 let bundle =
661 generate::bundle::process_quadlet_bundle(&generate::bundle::ProcessBundleParams {
662 service_dir: ®_service.service_dir,
663 service_name,
664 extra_networks: &extra_networks,
665 extra_volumes: &extra_volumes,
666 podman_args: &podman_args,
667 extra_exec_start_pre: &extra_exec_start_pre,
668 port_names: &port_names,
669 })?;
670
671 let mut warnings = Vec::new();
673
674 if let Some(ref reqs) = reg_service.def.requirements
675 && let Some(total) = system::memory::total_ram_mb()
676 {
677 if total < reqs.ram.min {
678 warnings.push(Warning::RamBelowMinimum {
679 service_name: service_name.to_string(),
680 min_mb: reqs.ram.min,
681 available_mb: total,
682 });
683 } else if let Some(rec) = reqs.ram.recommended
684 && total < rec
685 {
686 warnings.push(Warning::RamBelowRecommended {
687 service_name: service_name.to_string(),
688 recommended_mb: rec,
689 available_mb: total,
690 });
691 }
692 }
693 warnings.extend(port_warnings);
694
695 let mut steps = Vec::new();
697
698 steps.push(Step::CreateDir(home_dir.clone()));
700
701 let env_content = output.env_file.content.clone();
703
704 for image in &bundle.images {
706 steps.push(Step::PullImage {
707 image: image.clone(),
708 });
709 }
710
711 for file in bundle.quadlet_files {
715 let link = file
716 .path
717 .file_name()
718 .map(|n| quadlet_path.join(n))
719 .ok_or_else(|| {
720 Error::Bundle(format!("invalid quadlet path: {}", file.path.display()))
721 })?;
722 let target = file.path.clone();
723 steps.push(Step::WriteFile(file));
724 steps.push(Step::Symlink { link, target });
725 }
726
727 let metadata_content = toml::to_string_pretty(&install_metadata)?;
734 steps.push(Step::WriteFile(GeneratedFile {
735 path: metadata_path(service_name)?,
736 content: metadata_content,
737 }));
738
739 if mode == PlanMode::Add && exposure.is_tailscale() {
747 let svc_name = exposure.tailscale_svc_name().ok_or_else(|| {
757 Error::InvalidServiceRef(format!(
758 "tailscale exposure for '{service_name}' has a malformed URL — \
759 expected `https://<service>-<host>.<tailnet>.ts.net/`"
760 ))
761 })?;
762 let ts_ports = plan::tailscale_ports(®_service.def.ports, &resolved_ports, host_port);
766 if !ts_ports.is_empty() {
767 steps.push(Step::TailscaleSetup);
768 steps.push(Step::TailscaleEnable {
769 svc_name,
770 ports: ts_ports,
771 });
772 }
773 }
774
775 for file in bundle.config_files {
777 steps.push(Step::WriteFile(file));
778 }
779
780 for (src, dst) in bundle.files {
784 steps.push(Step::CopyFile { src, dst });
785 }
786
787 steps.push(Step::WriteFile(output.env_file));
789
790 for dir in &bundle.bind_mount_dirs {
792 steps.push(Step::CreateDir(dir.clone()));
793 }
794
795 steps.extend(auth_bridge_steps);
799
800 if mode == PlanMode::Add
809 && let (
810 Some(registry::service_def::AuthKind::Oidc),
811 Some(config::schema::AuthCredentials::Authelia { .. }),
812 ) = (auth_kind, config.auth.as_ref())
813 {
814 steps.extend(authelia::register_oidc_client(
815 service_name,
816 ®_service.def,
817 url,
818 &output.ctx,
819 &config,
820 &quadlet_path,
821 )?);
822 }
823
824 if let Some(url) = url
830 && !WellKnownService::Caddy.matches(service_name)
831 && !exposure.is_tailscale()
832 {
833 if caddy_installed {
834 let parsed = url::Url::parse(url)
835 .map_err(|e| Error::Template(format!("invalid service URL '{url}': {e}")))?;
836 let domain = parsed.host_str().ok_or_else(|| {
837 Error::Template(format!(
838 "service URL '{url}' has no host — Caddy needs a hostname to route to"
839 ))
840 })?;
841 let container_port = reg_service
842 .def
843 .ports
844 .first()
845 .map(|p| p.container_port)
846 .unwrap_or(80);
847 let primary_quadlet = reg_service
848 .service_dir
849 .join("quadlets")
850 .join(format!("{service_name}.container"));
851 let target_host = caddy::primary_container_name(&primary_quadlet, service_name);
852 let block = caddy::render_site_block(&caddy::CaddySiteParams {
853 service_name: service_name.to_string(),
854 target_host,
855 domain: domain.to_string(),
856 container_port,
857 https_port: caddy_https_port(&config),
858 });
859 let caddyfile_path = caddy::caddyfile_path()?;
860 let existing =
861 std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
862 path: caddyfile_path.clone(),
863 source,
864 })?;
865 let updated = caddy::add_route(&existing, service_name, &block);
866 steps.push(Step::WriteFile(GeneratedFile {
867 path: caddyfile_path,
868 content: updated,
869 }));
870 steps.push(Step::ReloadCaddy);
871 } else if let Some(primary) = host_port {
872 warnings.push(Warning::UrlWithoutReverseProxy {
877 service_name: service_name.to_string(),
878 url: url.to_string(),
879 host_port: primary,
880 });
881 }
882 }
883
884 if mode == PlanMode::Add {
894 steps.extend(retroactive_network_joins(
895 service_name,
896 &quadlet_path,
897 Some(repo_dir),
898 ));
899 }
900
901 if WellKnownService::Caddy.matches(service_name) {
908 let snippet_path = caddy::tls_snippet_path()?;
909 if !snippet_path.exists() {
910 let mode = acme_mode.cloned().unwrap_or(caddy::AcmeMode::Internal);
911 steps.push(Step::WriteFile(GeneratedFile {
912 path: snippet_path,
913 content: mode.snippet(),
914 }));
915 }
916 }
917
918 let manifest_path_for_svc = manifest::manifest_path(service_name)?;
927 let env_filename = std::ffi::OsStr::new(".env");
928 let mut manifest_entries: Vec<manifest::ManifestEntry> = Vec::new();
929 for step in &steps {
930 if let Step::WriteFile(file) = step {
931 if file.path == manifest_path_for_svc {
932 continue;
933 }
934 if file.path.file_name() == Some(env_filename) {
935 continue;
936 }
937 manifest_entries.push(manifest::ManifestEntry {
938 path: file.path.clone(),
939 sha256: manifest::hash_bytes(file.content.as_bytes()),
940 });
941 }
942 }
943 let tracked_envs = collect_static_envs(®_service.def, &output.ctx, enabled_groups)?;
951 let manifest_envs: Vec<manifest::EnvEntry> = tracked_envs
952 .iter()
953 .map(|t| manifest::EnvEntry {
954 key: t.key.clone(),
955 value: t.value.clone(),
956 })
957 .collect();
958 steps.push(Step::WriteFile(GeneratedFile {
959 path: manifest_path_for_svc,
960 content: manifest::format(&manifest_entries, &manifest_envs),
961 }));
962
963 steps.push(Step::DaemonReload);
965 steps.push(Step::StartService {
967 unit: service_name.to_string(),
968 });
969
970 let allocated_ports: Vec<(String, u16)> = resolved_ports.clone();
972
973 let mut generated_secrets: Vec<String> = reg_service
975 .def
976 .env
977 .iter()
978 .filter(|e| !env_overrides.contains_key(&e.name))
979 .flat_map(|e| generate::extract_secret_refs(&e.value))
980 .collect();
981 generated_secrets.sort();
983 generated_secrets.dedup();
984
985 Ok(AddResult {
986 steps,
987 warnings,
988 repo_url: registry_name.to_string(),
989 allocated_ports,
990 generated_secrets,
991 env_content,
992 url: url.map(|u| u.to_string()),
993 tracked_envs,
994 })
995}
996
997fn collect_generated_secrets(
1000 def: ®istry::service_def::ServiceDef,
1001 env_overrides: &BTreeMap<String, String>,
1002) -> Vec<String> {
1003 let mut out: Vec<String> = def
1004 .env
1005 .iter()
1006 .filter(|e| !env_overrides.contains_key(&e.name))
1007 .flat_map(|e| generate::extract_secret_refs(&e.value))
1008 .collect();
1009 out.sort();
1010 out.dedup();
1011 out
1012}
1013
1014struct NativeAddParams<'a> {
1016 service_name: &'a str,
1017 reg_service: &'a registry::RegistryService,
1018 home_dir: &'a Path,
1019 output: generate::EnvOutput,
1020 install_metadata: &'a Metadata,
1021 registry_name: &'a str,
1022 url: Option<&'a str>,
1023 tracked_envs: Vec<TrackedEnv>,
1024 allocated_ports: Vec<(String, u16)>,
1025 generated_secrets: Vec<String>,
1026}
1027
1028fn build_native_add(p: NativeAddParams<'_>) -> Result<AddResult> {
1034 let NativeAddParams {
1035 service_name,
1036 reg_service,
1037 home_dir,
1038 output,
1039 install_metadata,
1040 registry_name,
1041 url,
1042 tracked_envs,
1043 allocated_ports,
1044 generated_secrets,
1045 } = p;
1046
1047 let run = reg_service.def.service.run.as_ref().ok_or_else(|| {
1048 Error::Bundle(format!(
1049 "native service '{service_name}' is missing its `run` command"
1050 ))
1051 })?;
1052 let build = reg_service.def.service.build.as_ref();
1053
1054 let env_content = output.env_file.content.clone();
1055 let source_dir = reg_service.service_dir.clone();
1056 let mut steps = Vec::new();
1057
1058 steps.push(Step::CreateDir(home_dir.to_path_buf()));
1063 steps.push(Step::CreateDir(home_dir.join("data")));
1064
1065 if let Some(command) = build {
1067 steps.push(Step::Build {
1068 dir: source_dir.clone(),
1069 command: command.clone(),
1070 });
1071 }
1072
1073 steps.push(Step::WriteFile(GeneratedFile {
1075 path: metadata_path(service_name)?,
1076 content: toml::to_string_pretty(install_metadata)?,
1077 }));
1078 steps.push(Step::WriteFile(output.env_file));
1079
1080 let unit_name = format!("{service_name}.service");
1083 let unit_path = home_dir.join(&unit_name);
1084 steps.push(Step::WriteFile(GeneratedFile {
1085 path: unit_path.clone(),
1086 content: native_unit(
1087 home_dir,
1088 &source_dir,
1089 run,
1090 ®_service.def.service.description,
1091 ),
1092 }));
1093 steps.push(Step::Symlink {
1094 link: systemd_user_dir()?.join(&unit_name),
1095 target: unit_path,
1096 });
1097
1098 steps.push(Step::DaemonReload);
1099 steps.push(Step::StartService {
1100 unit: service_name.to_string(),
1101 });
1102
1103 Ok(AddResult {
1104 steps,
1105 warnings: Vec::new(),
1106 repo_url: registry_name.to_string(),
1107 allocated_ports,
1108 generated_secrets,
1109 env_content,
1110 url: url.map(|u| u.to_string()),
1111 tracked_envs,
1112 })
1113}
1114
1115fn native_unit(home_dir: &Path, source_dir: &Path, run: &str, description: &str) -> String {
1120 let home = home_dir.display();
1121 let source = source_dir.display();
1122 format!(
1131 "[Unit]\n\
1132 Description={description}\n\
1133 After=network.target\n\
1134 \n\
1135 [Service]\n\
1136 Type=simple\n\
1137 WorkingDirectory={source}\n\
1138 EnvironmentFile={home}/.env\n\
1139 Environment=SERVICE_HOME={home}\n\
1140 Environment=PATH=%h/.local/bin:%h/.cargo/bin:%h/.bun/bin:%h/.deno/bin:%h/go/bin:/usr/local/bin:/usr/bin:/bin\n\
1141 ExecStart=/bin/sh -c 'exec {run}'\n\
1142 Restart=always\n\
1143 RestartSec=5\n\
1144 \n\
1145 [Install]\n\
1146 WantedBy=default.target\n",
1147 )
1148}
1149
1150pub fn quadlet_belongs_to(filename: &str, service_name: &str, all_service_names: &[&str]) -> bool {
1159 if !filename.starts_with(service_name) {
1160 return false;
1161 }
1162 let rest = &filename[service_name.len()..];
1163 if rest.starts_with('.') {
1164 return true;
1165 }
1166 if !rest.starts_with('-') {
1167 return false;
1168 }
1169 !all_service_names.iter().any(|&other| {
1173 other.len() > service_name.len()
1174 && other.starts_with(service_name)
1175 && filename.starts_with(other)
1176 && filename[other.len()..].starts_with(['.', '-'])
1177 })
1178}
1179
1180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1182pub enum RemoveMode {
1183 Preserve,
1188 Purge,
1190}
1191
1192pub fn remove_service(service_name: &str, mode: RemoveMode) -> Result<RemoveResult> {
1194 let installed_owned = build_installed_from_metadata(service_name)
1197 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
1198 let installed = &installed_owned;
1199
1200 if let Ok(Some(meta)) = metadata::load_metadata(service_name)
1205 && meta.runtime == registry::service_def::Runtime::Native
1206 {
1207 let url = installed.exposure.url().map(|s| s.to_string());
1208 return remove_native_service(service_name, mode, url);
1209 }
1210
1211 let quadlet_path = quadlet_dir()?;
1214 let mut steps = Vec::new();
1215 let mut volume_names = Vec::new();
1216 let mut networks: Vec<String> = Vec::new();
1217 let mut has_named_volumes = false;
1218 let name_pool = scan_managed_services().unwrap_or_default();
1222 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
1223
1224 if let Some(svc_name) = installed.exposure.tailscale_svc_name() {
1236 steps.push(Step::TailscaleDisable { svc_name });
1237 }
1238
1239 if quadlet_path.is_dir()
1240 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
1241 {
1242 for entry in entries.flatten() {
1243 let file_name = entry.file_name();
1244 let name = file_name.to_string_lossy();
1245 if !quadlet_belongs_to(&name, service_name, &all_names) {
1248 continue;
1249 }
1250 if name.ends_with(".container") {
1252 let unit = name.trim_end_matches(".container").to_string();
1253 steps.push(Step::StopService { unit });
1254 }
1255 if name.ends_with(".network") {
1256 let net = name.trim_end_matches(".network").to_string();
1259 steps.push(Step::StopService {
1260 unit: format!("{net}-network"),
1261 });
1262 networks.push(net);
1263 }
1264 if name.ends_with(".volume") {
1265 has_named_volumes = true;
1266 if matches!(mode, RemoveMode::Purge) {
1267 let vol = name.trim_end_matches(".volume").to_string();
1268 volume_names.push(format!("systemd-{vol}"));
1270 }
1271 }
1272 steps.push(Step::RemoveFile(entry.path()));
1273 }
1274 }
1275
1276 let had_caddy_route = matches!(
1283 installed.exposure,
1284 Exposure::Internal { .. } | Exposure::Public { .. }
1285 );
1286 if !WellKnownService::Caddy.matches(service_name) && had_caddy_route {
1287 let caddyfile_path = caddy::caddyfile_path()?;
1288 if caddyfile_path.exists() {
1289 let existing =
1290 std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
1291 path: caddyfile_path.clone(),
1292 source,
1293 })?;
1294 let updated = caddy::remove_route(&existing, service_name);
1295 if updated != existing {
1296 steps.push(Step::WriteFile(GeneratedFile {
1297 path: caddyfile_path,
1298 content: updated.clone(),
1299 }));
1300 if !updated.trim().is_empty() {
1303 steps.push(Step::ReloadCaddy);
1304 }
1305 }
1306 }
1307 }
1308
1309 if !WellKnownService::Authelia.matches(service_name)
1310 && matches!(
1311 installed.auth_kind,
1312 Some(registry::service_def::AuthKind::Oidc)
1313 )
1314 {
1315 steps.extend(authelia::unregister_oidc_client(service_name)?);
1316 }
1317
1318 steps.push(Step::DaemonReload);
1320
1321 for net in networks {
1329 steps.push(Step::RemoveNetwork { name: net });
1330 }
1331
1332 match mode {
1333 RemoveMode::Purge => {
1334 for vol_name in volume_names {
1336 steps.push(Step::RemoveVolume { name: vol_name });
1337 }
1338 steps.push(Step::RemoveDir(service_home(service_name)?));
1340 }
1341 RemoveMode::Preserve => {
1342 let home = service_home(service_name)?;
1346 let (data, ephemeral) = crate::data::classify::classify_home_dir(&home)?;
1347 for path in ephemeral {
1348 match std::fs::metadata(&path) {
1349 Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(path)),
1350 Ok(_) => steps.push(Step::RemoveFile(path)),
1351 Err(_) => steps.push(Step::RemoveFile(path)),
1355 }
1356 }
1357 if data.is_empty() && !has_named_volumes && home.exists() {
1365 steps.push(Step::RemoveDir(home));
1366 }
1367 }
1368 }
1369
1370 let url = installed.exposure.url().map(|s| s.to_string());
1371
1372 Ok(RemoveResult {
1373 steps,
1374 service_name: service_name.to_string(),
1375 url,
1376 })
1377}
1378
1379fn remove_native_service(
1384 service_name: &str,
1385 mode: RemoveMode,
1386 url: Option<String>,
1387) -> Result<RemoveResult> {
1388 let home = service_home(service_name)?;
1389 let unit_name = format!("{service_name}.service");
1390 let mut steps = vec![
1391 Step::StopService {
1392 unit: service_name.to_string(),
1393 },
1394 Step::RemoveFile(systemd_user_dir()?.join(&unit_name)),
1395 Step::DaemonReload,
1396 ];
1397
1398 match mode {
1399 RemoveMode::Purge => steps.push(Step::RemoveDir(home)),
1400 RemoveMode::Preserve => {
1401 for child in ["bin", ".env", unit_name.as_str()] {
1404 let p = home.join(child);
1405 match std::fs::metadata(&p) {
1406 Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(p)),
1407 _ => steps.push(Step::RemoveFile(p)),
1408 }
1409 }
1410 }
1411 }
1412
1413 Ok(RemoveResult {
1414 steps,
1415 service_name: service_name.to_string(),
1416 url,
1417 })
1418}
1419
1420#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1422pub enum Lifecycle {
1423 Start,
1424 Stop,
1425}
1426
1427pub fn lifecycle_steps(service_name: &str, action: Lifecycle) -> Result<Vec<Step>> {
1436 build_installed_from_metadata(service_name)
1438 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
1439
1440 if matches!(
1443 metadata::load_metadata(service_name),
1444 Ok(Some(m)) if m.runtime == registry::service_def::Runtime::Native
1445 ) {
1446 let unit = service_name.to_string();
1447 return Ok(vec![match action {
1448 Lifecycle::Start => Step::StartService { unit },
1449 Lifecycle::Stop => Step::StopService { unit },
1450 }]);
1451 }
1452
1453 let mut units = service_container_units(service_name)?;
1454 match action {
1455 Lifecycle::Stop => units.sort_by_key(|u| u != service_name),
1457 Lifecycle::Start => units.sort_by_key(|u| u == service_name),
1459 }
1460
1461 Ok(units
1462 .into_iter()
1463 .map(|unit| match action {
1464 Lifecycle::Start => Step::StartService { unit },
1465 Lifecycle::Stop => Step::StopService { unit },
1466 })
1467 .collect())
1468}
1469
1470fn service_container_units(service_name: &str) -> Result<Vec<String>> {
1474 let quadlet_path = quadlet_dir()?;
1475 let name_pool = scan_managed_services().unwrap_or_default();
1476 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
1477
1478 let mut units = Vec::new();
1479 if quadlet_path.is_dir()
1480 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
1481 {
1482 for entry in entries.flatten() {
1483 let file_name = entry.file_name();
1484 let name = file_name.to_string_lossy();
1485 if !quadlet_belongs_to(&name, service_name, &all_names) {
1486 continue;
1487 }
1488 if name.ends_with(".container") {
1489 units.push(name.trim_end_matches(".container").to_string());
1490 }
1491 }
1492 }
1493 Ok(units)
1494}
1495
1496pub struct RecordPendingParams<'a> {
1498 pub service_name: &'a str,
1499 pub auth_kind: Option<registry::service_def::AuthKind>,
1500 pub registry_name: &'a str,
1501 pub allocated_ports: &'a [(String, u16)],
1502 pub repo_dir: &'a Path,
1503 pub exposure: &'a Exposure,
1510}
1511
1512pub fn record_pending(params: RecordPendingParams<'_>) -> Result<()> {
1519 let paths = ConfigPaths::resolve()?;
1520 paths.ensure_dirs()?;
1521 let mut config = config::load_or_default(&paths.config_file)?;
1522
1523 if WellKnownService::Authelia.matches(params.service_name) {
1528 config.auth = Some(authelia::auth_config(
1529 params.allocated_ports,
1530 params.exposure.url(),
1531 )?);
1532 config::save_config(&paths.config_file, &config)?;
1533 }
1534
1535 Ok(())
1536}
1537
1538pub fn finalize_remove(service_name: &str) -> Result<()> {
1545 let paths = ConfigPaths::resolve()?;
1546 let mut config = config::load_or_default(&paths.config_file)?;
1547
1548 if WellKnownService::Authelia.matches(service_name)
1549 && let Some(auth) = &config.auth
1550 && auth.provider_name() == "authelia"
1551 {
1552 config.auth = None;
1553 config::save_config(&paths.config_file, &config)?;
1554 }
1555
1556 Ok(())
1557}
1558
1559const SENSITIVE_TEMPLATE_REFS: &[&str] = &[
1578 "{{secret.",
1579 "{{auth.client_id",
1580 "{{auth.client_secret",
1581 "{{smtp.username",
1582 "{{smtp.password",
1583];
1584
1585fn is_static_template(value: &str) -> bool {
1586 !SENSITIVE_TEMPLATE_REFS.iter().any(|s| value.contains(s))
1587}
1588
1589fn collect_static_envs(
1605 service_def: ®istry::service_def::ServiceDef,
1606 ctx: &BTreeMap<String, String>,
1607 enabled_groups: &std::collections::BTreeSet<String>,
1608) -> Result<Vec<plan::TrackedEnv>> {
1609 use registry::service_def::EnvKind;
1610 let mut out: Vec<plan::TrackedEnv> = Vec::new();
1611 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1612 let push = |name: &str,
1613 value_template: &str,
1614 kind: EnvKind,
1615 prompt: Option<String>,
1616 out: &mut Vec<plan::TrackedEnv>,
1617 seen: &mut std::collections::HashSet<String>|
1618 -> Result<()> {
1619 if !is_static_template(value_template) {
1620 return Ok(());
1621 }
1622 if !seen.insert(name.to_string()) {
1623 return Ok(());
1624 }
1625 let value = generate::template::render(value_template, ctx)?;
1626 out.push(plan::TrackedEnv {
1627 key: name.to_string(),
1628 value,
1629 kind,
1630 prompt,
1631 });
1632 Ok(())
1633 };
1634 for env in &service_def.env {
1635 push(
1636 &env.name,
1637 &env.value,
1638 env.kind.clone(),
1639 env.prompt.clone(),
1640 &mut out,
1641 &mut seen,
1642 )?;
1643 }
1644 for group in &service_def.env_groups {
1645 if !enabled_groups.contains(&group.name) {
1646 continue;
1647 }
1648 for env in &group.env {
1649 push(
1650 &env.name,
1651 &env.value,
1652 env.kind.clone(),
1653 env.prompt.clone(),
1654 &mut out,
1655 &mut seen,
1656 )?;
1657 }
1658 }
1659 if service_def.integrations.smtp && ctx.contains_key("smtp.host") {
1665 for (env_name, value_template) in &service_def.mappings.smtp {
1666 push(
1667 env_name,
1668 value_template,
1669 EnvKind::Default,
1670 None,
1671 &mut out,
1672 &mut seen,
1673 )?;
1674 }
1675 }
1676 if ctx.contains_key("auth.client_id") {
1677 for (env_name, value_template) in &service_def.mappings.auth {
1678 push(
1679 env_name,
1680 value_template,
1681 EnvKind::Default,
1682 None,
1683 &mut out,
1684 &mut seen,
1685 )?;
1686 }
1687 }
1688 Ok(out)
1689}
1690
1691pub fn orphan_purge_steps(svc: &data::ServiceData) -> Vec<Step> {
1692 let mut steps = Vec::new();
1693
1694 let mut had_quadlet = false;
1700 let mut networks: Vec<String> = Vec::new();
1701 if let Ok(qdir) = quadlet_dir()
1702 && qdir.is_dir()
1703 && let Ok(entries) = std::fs::read_dir(&qdir)
1704 {
1705 let name_pool = scan_managed_services().unwrap_or_default();
1706 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
1707 for entry in entries.flatten() {
1708 let file_name = entry.file_name();
1709 let name = file_name.to_string_lossy();
1710 if !quadlet_belongs_to(&name, &svc.service, &all_names) {
1711 continue;
1712 }
1713 if name.ends_with(".container") {
1717 let unit = name.trim_end_matches(".container").to_string();
1718 steps.push(Step::StopService { unit });
1719 } else if name.ends_with(".network") {
1720 let net = name.trim_end_matches(".network").to_string();
1721 steps.push(Step::StopService {
1722 unit: format!("{net}-network"),
1723 });
1724 networks.push(net);
1725 } else if name.ends_with(".volume") {
1726 let unit = format!("{}-volume", name.trim_end_matches(".volume"));
1727 steps.push(Step::StopService { unit });
1728 }
1729 steps.push(Step::RemoveFile(entry.path()));
1730 had_quadlet = true;
1731 }
1732 }
1733 if had_quadlet {
1734 steps.push(Step::DaemonReload);
1735 }
1736 for net in networks {
1738 steps.push(Step::RemoveNetwork { name: net });
1739 }
1740
1741 for path in &svc.data_paths {
1742 if path.is_dir() {
1743 steps.push(Step::RemoveDir(path.clone()));
1744 } else {
1745 steps.push(Step::RemoveFile(path.clone()));
1746 }
1747 }
1748 if svc.home_dir.exists() {
1749 steps.push(Step::RemoveDir(svc.home_dir.clone()));
1750 }
1751 for v in &svc.volumes {
1752 steps.push(Step::RemoveVolume {
1753 name: v.name.clone(),
1754 });
1755 }
1756 steps
1757}
1758
1759pub fn reset() -> Result<ResetResult> {
1761 let mut steps = Vec::new();
1762
1763 let managed_names = scan_managed_services().unwrap_or_default();
1768
1769 for svc in list_installed().unwrap_or_default() {
1776 if let Some(svc_name) = svc.exposure.tailscale_svc_name() {
1777 steps.push(Step::TailscaleDisable { svc_name });
1778 }
1779 }
1780
1781 let quadlet_path = quadlet_dir()?;
1783 let all_names: Vec<&str> = managed_names.iter().map(|s| s.as_str()).collect();
1784 let mut networks: Vec<String> = Vec::new();
1785 if quadlet_path.is_dir()
1786 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
1787 {
1788 for entry in entries.flatten() {
1789 let file_name = entry.file_name();
1790 let name = file_name.to_string_lossy();
1791 let is_ryra_file = managed_names
1795 .iter()
1796 .any(|svc| quadlet_belongs_to(&name, svc, &all_names));
1797 if !is_ryra_file {
1798 continue;
1799 }
1800 if name.ends_with(".container") {
1801 let unit = name.trim_end_matches(".container").to_string();
1802 steps.push(Step::StopService { unit });
1803 }
1804 if name.ends_with(".network") {
1805 let net = name.trim_end_matches(".network").to_string();
1806 steps.push(Step::StopService {
1807 unit: format!("{net}-network"),
1808 });
1809 networks.push(net);
1810 }
1811 if name.ends_with(".volume") {
1812 let vol = name.trim_end_matches(".volume").to_string();
1813 steps.push(Step::StopService {
1820 unit: format!("{vol}-volume"),
1821 });
1822 }
1823 steps.push(Step::RemoveFile(entry.path()));
1824 }
1825 }
1826
1827 let user_unit_dir = systemd_user_dir()?;
1833 if let Ok(root) = service_data_root()
1834 && let Ok(entries) = std::fs::read_dir(&root)
1835 {
1836 for entry in entries.flatten() {
1837 let Some(name) = entry.file_name().to_str().map(str::to_string) else {
1838 continue;
1839 };
1840 if matches!(
1841 metadata::load_metadata(&name),
1842 Ok(Some(m)) if m.runtime == registry::service_def::Runtime::Native
1843 ) {
1844 steps.push(Step::StopService { unit: name.clone() });
1845 steps.push(Step::RemoveFile(
1846 user_unit_dir.join(format!("{name}.service")),
1847 ));
1848 }
1849 }
1850 }
1851
1852 steps.push(Step::DaemonReload);
1854
1855 for net in networks {
1858 steps.push(Step::RemoveNetwork { name: net });
1859 }
1860
1861 let mut seen_volumes = std::collections::BTreeSet::new();
1867 for svc in data::enumerate_all().unwrap_or_default() {
1868 for vol in svc.volumes {
1869 if seen_volumes.insert(vol.name.clone()) {
1870 steps.push(Step::RemoveVolume { name: vol.name });
1871 }
1872 }
1873 }
1874
1875 let data_root = service_data_root()?;
1881 if data_root.exists() {
1882 steps.push(Step::RemoveDir(data_root));
1883 }
1884
1885 Ok(ResetResult { steps })
1886}
1887
1888pub fn finalize_reset() -> Result<()> {
1890 let paths = ConfigPaths::resolve()?;
1891 if paths.config_dir.exists() {
1892 std::fs::remove_dir_all(&paths.config_dir).map_err(|source| Error::FileWrite {
1893 path: paths.config_dir,
1894 source,
1895 })?;
1896 }
1897 Ok(())
1898}
1899
1900pub fn status() -> config::status::RyraStatus {
1906 let paths = match ConfigPaths::resolve() {
1907 Ok(p) => p,
1908 Err(_) => return config::status::RyraStatus::NotInitialized,
1909 };
1910
1911 let has_quadlets = scan_managed_services()
1912 .map(|n| !n.is_empty())
1913 .unwrap_or(false);
1914
1915 let config = match config::load_config(&paths.config_file) {
1916 Ok(c) => c,
1917 Err(Error::ConfigNotFound(_)) if has_quadlets => config::schema::Config::default(),
1918 Err(Error::ConfigNotFound(_)) => return config::status::RyraStatus::NotInitialized,
1919 Err(e) => return config::status::RyraStatus::Error(e.to_string()),
1920 };
1921
1922 config::status::RyraStatus::Initialized(config::status::StatusInfo::from_config(
1923 paths.config_file,
1924 &config,
1925 ))
1926}
1927
1928pub fn is_service_installed(name: &str) -> bool {
1936 let Ok(Some(meta)) = metadata::load_metadata(name) else {
1940 return false;
1941 };
1942 match meta.runtime {
1943 registry::service_def::Runtime::Native => systemd_user_dir()
1944 .map(|d| d.join(format!("{name}.service")).exists())
1945 .unwrap_or(false),
1946 registry::service_def::Runtime::Podman => scan_managed_services()
1947 .map(|names| names.iter().any(|n| n == name))
1948 .unwrap_or(false),
1949 }
1950}
1951
1952pub fn scan_managed_services() -> Result<Vec<String>> {
1965 let dir = match quadlet_dir() {
1966 Ok(d) => d,
1967 Err(_) => return Ok(Vec::new()),
1968 };
1969 let entries = match std::fs::read_dir(&dir) {
1970 Ok(e) => e,
1971 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1972 Err(source) => return Err(Error::FileRead { path: dir, source }),
1973 };
1974 let mut names: Vec<String> = Vec::new();
1975 for entry in entries.flatten() {
1976 let path = entry.path();
1977 if path.extension().and_then(|e| e.to_str()) != Some("container") {
1978 continue;
1979 }
1980 let Ok(content) = std::fs::read_to_string(&path) else {
1981 continue;
1982 };
1983 for line in content.lines().take(16) {
1984 if let Some(rest) = line.trim().strip_prefix("# Service-Source: registry/")
1985 && !rest.is_empty()
1986 && !names.iter().any(|n| n == rest)
1987 {
1988 names.push(rest.to_string());
1989 break;
1990 }
1991 }
1992 }
1993 names.sort();
1994 Ok(names)
1995}
1996
1997fn build_installed_from_metadata(service_name: &str) -> Option<InstalledService> {
2003 let meta = load_metadata(service_name).ok().flatten()?;
2004
2005 let exposure = match meta.url.as_deref() {
2007 None => Exposure::Loopback,
2008 Some(u) => Exposure::from_url(u),
2009 };
2010
2011 let auth_kind = meta.auth.clone();
2012
2013 let ports = service_home(service_name)
2019 .ok()
2020 .and_then(|home| std::fs::read_to_string(home.join(".env")).ok())
2021 .map(|env| {
2022 env.lines()
2023 .filter_map(|l| {
2024 let l = l.trim();
2025 if l.is_empty() || l.starts_with('#') {
2026 return None;
2027 }
2028 let (key, val) = l.split_once('=')?;
2029 let name = key.strip_prefix("SERVICE_PORT_")?.to_lowercase();
2030 let port = val
2031 .trim_matches(|c: char| c == '"' || c == '\'')
2032 .parse::<u16>()
2033 .ok()?;
2034 Some((name, port))
2035 })
2036 .collect::<std::collections::BTreeMap<String, u16>>()
2037 })
2038 .unwrap_or_default();
2039
2040 Some(InstalledService {
2041 name: service_name.to_string(),
2042 version: "0.1.0".to_string(),
2043 repo: meta.registry,
2044 ports,
2045 auth_kind,
2046 exposure,
2047 provides: meta.provides,
2048 installed: true,
2049 })
2050}
2051
2052pub fn list_installed() -> Result<Vec<InstalledService>> {
2059 let mut names: std::collections::BTreeSet<String> = scan_managed_services()
2060 .unwrap_or_default()
2061 .into_iter()
2062 .collect();
2063 if let Ok(root) = service_data_root()
2067 && let Ok(entries) = std::fs::read_dir(&root)
2068 {
2069 for entry in entries.flatten() {
2070 if let Some(name) = entry.file_name().to_str()
2071 && !names.contains(name)
2072 && is_service_installed(name)
2073 {
2074 names.insert(name.to_string());
2075 }
2076 }
2077 }
2078 let out: Vec<InstalledService> = names
2079 .iter()
2080 .filter_map(|n| build_installed_from_metadata(n))
2081 .collect();
2082 Ok(out)
2083}
2084
2085pub fn search_services(repo_dir: &Path, query: Option<&str>) -> Result<Vec<SearchResult>> {
2087 let available = registry::list_available(repo_dir)?;
2088
2089 let results = available
2090 .into_iter()
2091 .filter(|reg_svc| match query {
2092 None => true,
2093 Some(q) => {
2094 let q = q.to_lowercase();
2095 reg_svc.def.service.name.to_lowercase().contains(&q)
2096 || reg_svc.def.service.description.to_lowercase().contains(&q)
2097 }
2098 })
2099 .map(|reg_svc| {
2100 let name = ®_svc.def.service.name;
2101 let installed = is_service_installed(name);
2102 let mut supports = Vec::new();
2103 for kind in ®_svc.def.integrations.auth {
2104 supports.push(kind.to_string());
2105 }
2106 if reg_svc.def.integrations.smtp {
2107 supports.push("smtp".to_string());
2108 }
2109 SearchResult {
2110 name: name.clone(),
2111 description: reg_svc.def.service.description,
2112 installed,
2113 supports,
2114 }
2115 })
2116 .collect();
2117
2118 Ok(results)
2119}
2120
2121pub struct SearchResult {
2122 pub name: String,
2123 pub description: String,
2124 pub installed: bool,
2125 pub supports: Vec<String>,
2127}
2128
2129pub async fn service_tests(service_name: &str) -> Result<ServiceTestInfo> {
2131 let installed = build_installed_from_metadata(service_name)
2132 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
2133
2134 let service_ref = service_ref_from_installed(&installed);
2135 let repo_dir = resolve_registry_dir(&service_ref).await?;
2136
2137 let test_toml_path = repo_dir.join(service_name).join("test.toml");
2138 let env_file = service_home(service_name)?.join(".env");
2139
2140 if !test_toml_path.exists() {
2141 return Ok(ServiceTestInfo {
2142 service_name: service_name.to_string(),
2143 registry_name: service_ref.registry_name().to_string(),
2144 tests: vec![],
2145 env_file,
2146 });
2147 }
2148
2149 let content = std::fs::read_to_string(&test_toml_path).map_err(|source| Error::FileRead {
2150 path: test_toml_path.clone(),
2151 source,
2152 })?;
2153
2154 #[derive(serde::Deserialize)]
2155 struct TestFile {
2156 #[serde(default)]
2157 tests: Vec<registry::test_def::TestDef>,
2158 }
2159
2160 let parsed: TestFile = toml::from_str(&content).map_err(|source| Error::TomlParse {
2161 path: test_toml_path,
2162 source,
2163 })?;
2164
2165 Ok(ServiceTestInfo {
2166 service_name: service_name.to_string(),
2167 registry_name: service_ref.registry_name().to_string(),
2168 tests: parsed.tests,
2169 env_file,
2170 })
2171}
2172
2173pub struct ServiceTestInfo {
2174 pub service_name: String,
2175 pub registry_name: String,
2176 pub tests: Vec<registry::test_def::TestDef>,
2177 pub env_file: PathBuf,
2178}
2179
2180pub fn service_info(repo_dir: &Path, service_name: &str) -> Result<ServiceDetail> {
2182 let reg_service = registry::find_service(repo_dir, service_name)?;
2183 let def = ®_service.def;
2184
2185 Ok(ServiceDetail {
2186 name: def.service.name.clone(),
2187 description: def.service.description.clone(),
2188 url: def.service.url.clone(),
2189 ports: def
2190 .ports
2191 .iter()
2192 .map(|p| (p.container_port, p.protocol.clone(), p.name.clone()))
2193 .collect(),
2194 env_vars: def
2195 .env
2196 .iter()
2197 .map(|e| (e.name.clone(), e.prompt.clone()))
2198 .collect(),
2199 })
2200}
2201
2202pub struct ServiceDetail {
2203 pub name: String,
2204 pub description: String,
2205 pub url: Option<String>,
2206 pub ports: Vec<(u16, registry::service_def::PortProtocol, String)>,
2207 pub env_vars: Vec<(String, Option<String>)>,
2208}
2209
2210#[cfg(test)]
2211mod tests {
2212 use super::*;
2213
2214 #[test]
2215 fn static_template_filter_excludes_secrets_and_credentials() {
2216 assert!(is_static_template("3306"));
2218 assert!(is_static_template("mariadb"));
2219 assert!(is_static_template("{{service.port}}"));
2221 assert!(is_static_template("{{service.url}}"));
2222 assert!(is_static_template("{{auth.url}}"));
2223 assert!(is_static_template("{{auth.issuer}}"));
2224 assert!(is_static_template("{{auth.provider}}"));
2225 assert!(is_static_template("{{auth.internal_url}}"));
2226 assert!(is_static_template("{{smtp.host}}"));
2227 assert!(is_static_template("{{smtp.port}}"));
2228 assert!(is_static_template("{{smtp.from}}"));
2229 assert!(is_static_template("{{service.url}}/oauth/callback"));
2231
2232 assert!(!is_static_template("{{secret.admin_password}}"));
2234 assert!(!is_static_template("{{secret.jwt_key}}"));
2235 assert!(!is_static_template("{{auth.client_id}}"));
2237 assert!(!is_static_template("{{auth.client_secret}}"));
2238 assert!(!is_static_template("{{smtp.username}}"));
2240 assert!(!is_static_template("{{smtp.password}}"));
2241 assert!(!is_static_template(
2243 "redis://:{{secret.redis_pw}}@host:6379"
2244 ));
2245 }
2246
2247 #[test]
2248 fn tailscale_url_matches() {
2249 assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net"));
2250 assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net:10001/"));
2251 assert!(is_tailscale_url("https://foo.example-net.ts.net"));
2252 assert!(is_tailscale_url("http://HOST.COBBLER-TUNA.TS.NET"));
2253 }
2254
2255 #[test]
2256 fn tailscale_url_rejects() {
2257 assert!(!is_tailscale_url("https://nextcloud.internal:8443"));
2258 assert!(!is_tailscale_url("https://example.com"));
2259 assert!(!is_tailscale_url("http://127.0.0.1:10001"));
2260 assert!(!is_tailscale_url("https://ts.net"));
2262 assert!(!is_tailscale_url("https://evil-ts.net.example.com"));
2263 assert!(!is_tailscale_url("not a url"));
2264 }
2265
2266 #[test]
2267 fn public_url_accepts_public_domains() {
2268 assert!(is_public_url("https://seafile.ryra.no"));
2269 assert!(is_public_url("https://example.com"));
2270 assert!(is_public_url("https://docs.ryra.no:8443"));
2271 }
2272
2273 #[test]
2274 fn public_url_rejects_lan_and_tailnet() {
2275 assert!(!is_public_url("https://nextcloud.internal:8443"));
2276 assert!(!is_public_url("https://service.localhost"));
2277 assert!(!is_public_url("https://something.local"));
2278 assert!(!is_public_url("https://localhost:8080"));
2279 assert!(!is_public_url("https://debian.cobbler-tuna.ts.net"));
2280 assert!(!is_public_url("http://127.0.0.1:10001"));
2281 assert!(!is_public_url("http://192.168.1.10"));
2282 assert!(!is_public_url("http://[::1]"));
2283 assert!(!is_public_url("not a url"));
2284 }
2285
2286 #[test]
2291 fn networks_empty_when_no_auth() {
2292 let nets = resolve_extra_networks("whoami", false, false, false, false, false, false);
2293 assert!(nets.is_empty());
2294 }
2295
2296 #[test]
2297 fn networks_empty_when_auth_but_no_authelia() {
2298 let nets = resolve_extra_networks("forgejo", true, false, false, false, false, false);
2299 assert!(nets.is_empty());
2300 }
2301
2302 #[test]
2303 fn networks_authelia_when_auth_enabled() {
2304 let nets = resolve_extra_networks("forgejo", true, true, false, false, false, false);
2305 assert_eq!(nets, vec!["authelia"]);
2306 }
2307
2308 #[test]
2309 fn networks_auth_with_caddy_includes_both() {
2310 let nets = resolve_extra_networks("forgejo", true, true, true, false, false, false);
2311 assert!(nets.contains(&"authelia".to_string()));
2312 assert!(nets.contains(&"caddy".to_string()));
2313 }
2314
2315 #[test]
2316 fn networks_authelia_excluded_for_authelia_itself() {
2317 let nets = resolve_extra_networks("authelia", true, true, false, false, false, false);
2318 assert!(nets.is_empty());
2319 }
2320
2321 #[test]
2322 fn networks_smtp_joins_inbucket_without_caddy() {
2323 let nets = resolve_extra_networks("forgejo", false, false, false, true, false, true);
2325 assert_eq!(nets, vec!["inbucket"]);
2326 }
2327
2328 #[test]
2329 fn networks_smtp_skips_inbucket_when_it_is_self() {
2330 let nets = resolve_extra_networks("inbucket", false, false, false, true, false, true);
2331 assert!(!nets.contains(&"inbucket".to_string()));
2332 }
2333
2334 #[test]
2335 fn networks_smtp_skips_inbucket_when_not_installed() {
2336 let nets = resolve_extra_networks("forgejo", false, false, false, false, false, true);
2337 assert!(!nets.contains(&"inbucket".to_string()));
2338 }
2339
2340 #[test]
2341 fn quadlet_belongs_to_exact_match() {
2342 let all = &["foo", "foo-bar"];
2343 assert!(quadlet_belongs_to("foo.container", "foo", all));
2344 assert!(quadlet_belongs_to("foo.network", "foo", all));
2345 }
2346
2347 #[test]
2348 fn quadlet_belongs_to_sidecar() {
2349 let all = &["foo"];
2351 assert!(quadlet_belongs_to("foo-db.volume", "foo", all));
2352 }
2353
2354 #[test]
2355 fn quadlet_belongs_to_rejects_prefix_collision() {
2356 let all = &["foo", "foo-bar"];
2357 assert!(!quadlet_belongs_to("foo-bar.container", "foo", all));
2358 assert!(!quadlet_belongs_to("foo-bar-db.volume", "foo", all));
2359 }
2360
2361 #[test]
2362 fn quadlet_belongs_to_hyphenated_service() {
2363 let all = &["foo", "foo-bar"];
2364 assert!(quadlet_belongs_to("foo-bar.container", "foo-bar", all));
2365 assert!(quadlet_belongs_to("foo-bar-db.volume", "foo-bar", all));
2366 assert!(!quadlet_belongs_to("foo.container", "foo-bar", all));
2367 }
2368}