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::{Exposure, is_public_url, is_tailscale_url};
37pub use generate::GeneratedFile;
38pub use manifest::{ManifestEntry, manifest_path};
39pub use metadata::{Metadata, load_metadata};
40pub use paths::{
41 CONFIG_DIR_ENV, DATA_DIR_ENV, DEFAULT_REGISTRY_URL, REGISTRY_DEFAULT, REGISTRY_DIR_ENV,
42 metadata_path, quadlet_dir, service_data_root, service_home, systemd_user_dir,
43};
44pub use plan::{AddResult, RemoveResult, ResetResult, Step, TailscalePort, TrackedEnv, Warning};
45pub use upgrade::{
46 BackupSnapshot, DEFAULT_BACKUP_KEEP, DiffEntry, DiffKind, DiffResult, EnvAddition,
47 RevertResult, UpgradeResult, diff_service, list_backups, prune_backups, revert_service,
48 upgrade_service,
49};
50pub use well_known::WellKnownService;
51
52pub(crate) use paths::home_dir;
53pub(crate) use well_known::caddy_https_port;
54
55pub async fn resolve_registry_dir(service_ref: ®istry::resolve::ServiceRef) -> Result<PathBuf> {
57 let paths = ConfigPaths::resolve()?;
58 paths.ensure_cache_dir()?;
59 let config = config::load_or_default(&paths.config_file)?;
60 registry::resolve::resolve_registry_dir(service_ref, &config, &paths.cache_dir).await
61}
62
63pub fn service_ref_from_installed(installed: &InstalledService) -> registry::resolve::ServiceRef {
65 if installed.repo.is_empty() || installed.repo == REGISTRY_DEFAULT {
66 registry::resolve::ServiceRef::Default(installed.name.clone())
67 } else {
68 registry::resolve::ServiceRef::Custom {
69 registry: installed.repo.clone(),
70 service: installed.name.clone(),
71 }
72 }
73}
74
75fn retroactive_network_joins(
84 new_service: &str,
85 quadlet_path: &std::path::Path,
86 _repo_dir: Option<&std::path::Path>,
87) -> Vec<Step> {
88 let mut steps = Vec::new();
89 let new_cap = if service_provides(new_service, Capability::ReverseProxy) {
94 Capability::ReverseProxy
95 } else if service_provides(new_service, Capability::SmtpRelay) {
96 Capability::SmtpRelay
97 } else {
98 return steps;
99 };
100
101 let installed = list_installed().unwrap_or_default();
102 for svc in &installed {
103 if !svc.provides.is_empty() {
106 continue;
107 }
108 let (network_name, should_join) = match new_cap {
109 Capability::ReverseProxy => {
110 let wants_proxy = matches!(
114 svc.exposure,
115 Exposure::Internal { .. } | Exposure::Public { .. }
116 );
117 (new_service.to_string(), wants_proxy)
118 }
119 Capability::SmtpRelay => {
120 (
123 new_service.to_string(),
124 service_uses_smtp_relay(&svc.name, new_service),
125 )
126 }
127 Capability::OidcProvider | Capability::ForwardAuthProvider => {
131 continue;
132 }
133 };
134 if !should_join {
135 continue;
136 }
137 let installed_names_owned: Vec<String> = installed.iter().map(|s| s.name.clone()).collect();
144 let all_service_names: Vec<&str> =
145 installed_names_owned.iter().map(|s| s.as_str()).collect();
146 let marker = format!("Network={network_name}.network");
147 let mut units_to_restart: Vec<String> = Vec::new();
148 let Ok(entries) = std::fs::read_dir(quadlet_path) else {
149 continue;
150 };
151 for entry in entries.flatten() {
152 let path = entry.path();
153 let name = match path.file_name().and_then(|n| n.to_str()) {
154 Some(n) if n.ends_with(".container") => n.to_string(),
155 _ => continue,
156 };
157 if !quadlet_belongs_to(&name, &svc.name, &all_service_names) {
158 continue;
159 }
160 let content = match std::fs::read_to_string(&path) {
161 Ok(c) => c,
162 Err(_) => continue,
163 };
164 if content.contains(&marker) {
165 continue;
166 }
167 let updated =
168 generate::bundle::inject_networks(&content, std::slice::from_ref(&network_name));
169 steps.push(Step::WriteFile(GeneratedFile {
170 path,
171 content: updated,
172 }));
173 let unit = name.trim_end_matches(".container").to_string();
176 units_to_restart.push(unit);
177 }
178 if !units_to_restart.is_empty() {
179 steps.push(Step::DaemonReload);
180 for unit in units_to_restart {
181 steps.push(Step::RestartService { unit });
182 }
183 }
184 }
185 steps
186}
187
188fn service_uses_smtp_relay(service_name: &str, relay_host: &str) -> bool {
194 let env_path = match service_home(service_name) {
195 Ok(h) => h.join(".env"),
196 Err(_) => return false,
197 };
198 let content = match std::fs::read_to_string(&env_path) {
199 Ok(c) => c,
200 Err(_) => return false,
201 };
202 let with_port = format!("{relay_host}:");
203 content.lines().any(|line| {
204 let Some((_, value)) = line.split_once('=') else {
205 return false;
206 };
207 let v = value.trim();
208 v == relay_host || v.starts_with(&with_port)
209 })
210}
211
212#[allow(clippy::too_many_arguments)]
224fn resolve_extra_networks(
225 service_name: &str,
226 enable_auth: bool,
227 authelia_installed: bool,
228 caddy_installed: bool,
229 inbucket_installed: bool,
230 has_url: bool,
231 has_smtp: bool,
232) -> Vec<String> {
233 let mut networks = Vec::new();
234 if enable_auth && authelia_installed && !WellKnownService::Authelia.matches(service_name) {
235 networks.push(WellKnownService::Authelia.to_string());
236 }
237 let joins_inbucket =
240 has_smtp && inbucket_installed && !WellKnownService::Inbucket.matches(service_name);
241 if joins_inbucket {
242 networks.push(WellKnownService::Inbucket.to_string());
243 }
244 let joins_caddy = (has_url || enable_auth || WellKnownService::Inbucket.matches(service_name))
245 && caddy_installed
246 && !WellKnownService::Caddy.matches(service_name);
247 if joins_caddy && !networks.contains(&WellKnownService::Caddy.to_string()) {
248 networks.push(WellKnownService::Caddy.to_string());
249 }
250 networks
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260pub enum PlanMode {
261 Add,
265 Upgrade,
269}
270
271#[allow(clippy::too_many_arguments)]
277pub fn add_service(
278 service_name: &str,
279 exposure: &Exposure,
280 auth_kind: Option<registry::service_def::AuthKind>,
281 enable_auth: bool,
282 enable_smtp: bool,
283 enable_backup: bool,
284 env_overrides: &BTreeMap<String, String>,
285 enabled_groups: &std::collections::BTreeSet<String>,
286 registry_name: &str,
287 repo_dir: &Path,
288 pre_built_ctx: Option<BTreeMap<String, String>>,
289 port_in_use: &dyn Fn(u16) -> bool,
290 acme_mode: Option<&caddy::AcmeMode>,
291 mode: PlanMode,
292 port_overrides: &BTreeMap<String, u16>,
298) -> Result<AddResult> {
299 let url: Option<&str> = exposure.url();
306 let tailscale_enabled: bool = exposure.is_tailscale();
307 let paths = ConfigPaths::resolve()?;
308 let config = config::load_or_default(&paths.config_file)?;
309
310 if mode == PlanMode::Add {
316 if is_service_installed(service_name) {
317 return Err(Error::ServiceAlreadyInstalled(service_name.to_string()));
318 }
319
320 if data::enumerate_service(service_name)?.is_some() {
328 return Err(Error::ServiceIncomplete(service_name.to_string()));
329 }
330 }
331
332 let reg_service = registry::find_service(repo_dir, service_name)?;
333
334 if let Some(msg) = reg_service.def.check_architecture() {
336 return Err(Error::UnsupportedArchitecture(msg));
337 }
338
339 let missing_requires: Vec<&str> = reg_service
341 .def
342 .requires
343 .iter()
344 .filter(|r| !is_service_installed(&r.service))
345 .map(|r| r.service.as_str())
346 .collect();
347 if !missing_requires.is_empty() {
348 return Err(Error::MissingRequiredServices {
349 service: service_name.to_string(),
350 missing: missing_requires.iter().map(|s| s.to_string()).collect(),
351 });
352 }
353
354 if auth_kind.is_some() && config.auth.is_none() {
356 return Err(Error::AuthNotConfigured);
357 }
358
359 if enable_auth
363 && reg_service.def.integrations.auth.is_empty()
364 && !capability::def_provides(®_service.def, Capability::OidcProvider)
365 {
366 return Err(Error::NoOidcSupport(service_name.to_string()));
367 }
368
369 if enable_backup && !reg_service.def.integrations.backup {
373 return Err(Error::BackupNotSupported(service_name.to_string()));
374 }
375
376 for g in enabled_groups {
380 if !reg_service.def.env_groups.iter().any(|eg| &eg.name == g) {
381 let known: Vec<String> = reg_service
382 .def
383 .env_groups
384 .iter()
385 .map(|eg| eg.name.clone())
386 .collect();
387 let hint = if known.is_empty() {
388 " (service defines no env_groups)".to_string()
389 } else {
390 format!(" (known: {})", known.join(", "))
391 };
392 return Err(Error::UnknownEnvGroup {
393 service: service_name.to_string(),
394 group: g.clone(),
395 hint,
396 });
397 }
398 }
399
400 let mut port_warnings: Vec<Warning> = Vec::new();
406 let mut claimed: std::collections::HashSet<u16> = reg_service
407 .def
408 .ports
409 .iter()
410 .filter_map(|p| p.host_port)
411 .collect();
412 let mut resolved_ports: Vec<(String, u16)> = Vec::with_capacity(reg_service.def.ports.len());
413 for p in ®_service.def.ports {
414 let host = if let Some(pinned) = port_overrides.get(&p.name) {
415 *pinned
420 } else if let Some(hp) = p.host_port {
421 hp
422 } else {
423 let privileged = p.container_port < 1024;
424 let claimed_in_service = claimed.contains(&p.container_port);
425 let in_use = port_in_use(p.container_port);
426 if privileged || claimed_in_service || in_use {
427 let allocated = system::port::allocate_port_excluding(&claimed, port_in_use)?;
428 let reason = if privileged {
429 "port is privileged (requires root)".to_string()
430 } else if claimed_in_service {
431 format!(
432 "port {} is already claimed by another port in this service",
433 p.container_port
434 )
435 } else {
436 format!("port {} is already in use", p.container_port)
437 };
438 port_warnings.push(Warning::PortReassigned {
439 service_name: service_name.to_string(),
440 port_name: p.name.clone(),
441 original_port: p.container_port,
442 assigned_port: allocated,
443 reason,
444 });
445 allocated
446 } else {
447 p.container_port
448 }
449 };
450 claimed.insert(host);
451 resolved_ports.push((p.name.clone(), host));
452 }
453
454 if WellKnownService::Caddy.matches(service_name)
461 && system::sysctl::rootless_can_bind_low_ports()
462 {
463 for (name, port) in resolved_ports.iter_mut() {
464 match name.as_str() {
465 "http" if *port == 8080 => *port = 80,
466 "https" if *port == 8443 => *port = 443,
467 _ => {}
468 }
469 }
470 }
471
472 let host_port = resolved_ports
475 .iter()
476 .find(|(name, _)| name.eq_ignore_ascii_case("http"))
477 .or_else(|| resolved_ports.first())
478 .map(|(_, p)| *p);
479
480 for (_, port) in &resolved_ports {
484 if port_in_use(*port) {
485 return Err(Error::PortConflict { port: *port });
486 }
487 }
488
489 let home_dir = service_home(service_name)?;
490 let quadlet_path = quadlet_dir()?;
491
492 let installed_now = list_installed().unwrap_or_default();
496 let authelia_installed =
497 find_installed_provider(&installed_now, Capability::OidcProvider).is_some();
498 let caddy_installed =
499 find_installed_provider(&installed_now, Capability::ReverseProxy).is_some();
500 let inbucket_installed =
501 find_installed_provider(&installed_now, Capability::SmtpRelay).is_some();
502
503 let auth_bridge = auth_bridge::build(&auth_bridge::AuthBridgeParams {
507 service_name,
508 service_provides: ®_service.def.capabilities.provides,
509 enable_auth,
510 config: &config,
511 installed: &installed_now,
512 service_data: &home_dir,
513 })?;
514
515 let (extra_volumes, extra_env, extra_exec_start_pre, auth_bridge_steps) = match auth_bridge {
516 Some(b) => (b.volumes, b.env, b.exec_start_pre, b.steps),
517 None => (Vec::new(), BTreeMap::new(), Vec::new(), Vec::new()),
518 };
519
520 let has_smtp = enable_smtp
521 && reg_service.def.integrations.smtp
522 && !reg_service.def.mappings.smtp.is_empty()
523 && config.smtp.is_some();
524 let extra_networks = resolve_extra_networks(
525 service_name,
526 enable_auth,
527 authelia_installed,
528 caddy_installed,
529 inbucket_installed,
530 url.is_some(),
531 has_smtp,
532 );
533
534 let output = generate::generate_env(generate::GenerateEnvParams {
535 config: &config,
536 service_def: ®_service.def,
537 auth_kind: auth_kind.as_ref(),
538 host_port,
539 resolved_ports: &resolved_ports,
540 env_overrides,
541 url,
542 extra_env,
543 pre_built_ctx,
544 enable_smtp: has_smtp,
545 enabled_groups,
546 })?;
547
548 let podman_args: Vec<String> = Vec::new();
549
550 let port_vars: Vec<(String, String)> = resolved_ports
553 .iter()
554 .map(|(name, port)| {
555 (
556 format!("SERVICE_PORT_{}", name.to_uppercase()),
557 port.to_string(),
558 )
559 })
560 .collect();
561
562 let install_metadata = Metadata {
575 registry: registry_name.to_string(),
576 url: url.map(str::to_string),
577 auth: auth_kind.clone(),
578 provides: reg_service.def.capabilities.provides.clone(),
579 backup_enabled: enable_backup,
580 smtp_enabled: enable_smtp,
581 enabled_groups: enabled_groups.iter().cloned().collect(),
582 runtime: reg_service.def.service.runtime.clone(),
583 };
584
585 if reg_service.def.service.runtime == registry::service_def::Runtime::Native {
590 let tracked_envs = collect_static_envs(®_service.def, &output.ctx, enabled_groups)?;
591 let allocated_ports = resolved_ports.clone();
592 let generated_secrets = collect_generated_secrets(®_service.def, env_overrides);
593 return build_native_add(NativeAddParams {
594 service_name,
595 reg_service: ®_service,
596 home_dir: &home_dir,
597 output,
598 install_metadata: &install_metadata,
599 registry_name,
600 url,
601 tracked_envs,
602 allocated_ports,
603 generated_secrets,
604 });
605 }
606
607 let bundle =
609 generate::bundle::process_quadlet_bundle(&generate::bundle::ProcessBundleParams {
610 service_dir: ®_service.service_dir,
611 service_name,
612 extra_networks: &extra_networks,
613 extra_volumes: &extra_volumes,
614 podman_args: &podman_args,
615 extra_exec_start_pre: &extra_exec_start_pre,
616 port_vars: &port_vars,
617 })?;
618
619 let mut warnings = Vec::new();
621
622 if let Some(ref reqs) = reg_service.def.requirements
623 && let Some(total) = system::memory::total_ram_mb()
624 {
625 if total < reqs.ram.min {
626 warnings.push(Warning::RamBelowMinimum {
627 service_name: service_name.to_string(),
628 min_mb: reqs.ram.min,
629 available_mb: total,
630 });
631 } else if let Some(rec) = reqs.ram.recommended
632 && total < rec
633 {
634 warnings.push(Warning::RamBelowRecommended {
635 service_name: service_name.to_string(),
636 recommended_mb: rec,
637 available_mb: total,
638 });
639 }
640 }
641 warnings.extend(port_warnings);
642
643 let mut steps = Vec::new();
645
646 steps.push(Step::CreateDir(home_dir.clone()));
648
649 let env_content = output.env_file.content.clone();
651
652 for image in &bundle.images {
654 steps.push(Step::PullImage {
655 image: image.clone(),
656 });
657 }
658
659 for file in bundle.quadlet_files {
663 let link = file
664 .path
665 .file_name()
666 .map(|n| quadlet_path.join(n))
667 .ok_or_else(|| {
668 Error::Bundle(format!("invalid quadlet path: {}", file.path.display()))
669 })?;
670 let target = file.path.clone();
671 steps.push(Step::WriteFile(file));
672 steps.push(Step::Symlink { link, target });
673 }
674
675 let metadata_content = toml::to_string_pretty(&install_metadata)?;
682 steps.push(Step::WriteFile(GeneratedFile {
683 path: metadata_path(service_name)?,
684 content: metadata_content,
685 }));
686
687 if mode == PlanMode::Add && tailscale_enabled {
695 let svc_name = exposure.tailscale_svc_name().ok_or_else(|| {
704 Error::InvalidServiceRef(format!(
705 "tailscale exposure for '{service_name}' has a malformed URL — \
706 expected `https://<service>-<host>.<tailnet>.ts.net/`"
707 ))
708 })?;
709 let ts_ports = plan::tailscale_ports(®_service.def.ports, &resolved_ports, host_port);
713 if !ts_ports.is_empty() {
714 steps.push(Step::TailscaleSetup);
715 steps.push(Step::TailscaleEnable {
716 svc_name,
717 ports: ts_ports,
718 });
719 }
720 }
721
722 for file in bundle.config_files {
724 steps.push(Step::WriteFile(file));
725 }
726
727 for (src, dst) in bundle.files {
731 steps.push(Step::CopyFile { src, dst });
732 }
733
734 steps.push(Step::WriteFile(output.env_file));
736
737 for dir in &bundle.bind_mount_dirs {
739 steps.push(Step::CreateDir(dir.clone()));
740 }
741
742 steps.extend(auth_bridge_steps);
746
747 if mode == PlanMode::Add
756 && let (
757 Some(registry::service_def::AuthKind::Oidc),
758 Some(config::schema::AuthCredentials::Authelia { .. }),
759 ) = (auth_kind.as_ref(), config.auth.as_ref())
760 {
761 steps.extend(authelia::register_oidc_client(
762 service_name,
763 ®_service.def,
764 url,
765 &output.ctx,
766 &config,
767 &quadlet_path,
768 )?);
769 }
770
771 if let Some(url) = url
777 && !WellKnownService::Caddy.matches(service_name)
778 && !exposure.is_tailscale()
779 {
780 if caddy_installed {
781 let parsed = url::Url::parse(url)
782 .map_err(|e| Error::Template(format!("invalid service URL '{url}': {e}")))?;
783 let domain = parsed.host_str().ok_or_else(|| {
784 Error::Template(format!(
785 "service URL '{url}' has no host — Caddy needs a hostname to route to"
786 ))
787 })?;
788 let container_port = reg_service
789 .def
790 .ports
791 .first()
792 .map(|p| p.container_port)
793 .unwrap_or(80);
794 let primary_quadlet = reg_service
795 .service_dir
796 .join("quadlets")
797 .join(format!("{service_name}.container"));
798 let target_host = caddy::primary_container_name(&primary_quadlet, service_name);
799 let block = caddy::render_site_block(&caddy::CaddySiteParams {
800 service_name: service_name.to_string(),
801 target_host,
802 domain: domain.to_string(),
803 container_port,
804 https_port: caddy_https_port(&config),
805 });
806 let caddyfile_path = caddy::caddyfile_path()?;
807 let existing =
808 std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
809 path: caddyfile_path.clone(),
810 source,
811 })?;
812 let updated = caddy::add_route(&existing, service_name, &block);
813 steps.push(Step::WriteFile(GeneratedFile {
814 path: caddyfile_path,
815 content: updated,
816 }));
817 steps.push(Step::ReloadCaddy);
818 } else if let Some(primary) = host_port {
819 warnings.push(Warning::UrlWithoutReverseProxy {
824 service_name: service_name.to_string(),
825 url: url.to_string(),
826 host_port: primary,
827 });
828 }
829 }
830
831 if mode == PlanMode::Add {
841 steps.extend(retroactive_network_joins(
842 service_name,
843 &quadlet_path,
844 Some(repo_dir),
845 ));
846 }
847
848 if WellKnownService::Caddy.matches(service_name) {
855 let snippet_path = caddy::tls_snippet_path()?;
856 if !snippet_path.exists() {
857 let mode = acme_mode.cloned().unwrap_or(caddy::AcmeMode::Internal);
858 steps.push(Step::WriteFile(GeneratedFile {
859 path: snippet_path,
860 content: mode.snippet(),
861 }));
862 }
863 }
864
865 let manifest_path_for_svc = manifest::manifest_path(service_name)?;
874 let env_filename = std::ffi::OsStr::new(".env");
875 let mut manifest_entries: Vec<manifest::ManifestEntry> = Vec::new();
876 for step in &steps {
877 if let Step::WriteFile(file) = step {
878 if file.path == manifest_path_for_svc {
879 continue;
880 }
881 if file.path.file_name() == Some(env_filename) {
882 continue;
883 }
884 manifest_entries.push(manifest::ManifestEntry {
885 path: file.path.clone(),
886 sha256: manifest::hash_bytes(file.content.as_bytes()),
887 });
888 }
889 }
890 let tracked_envs = collect_static_envs(®_service.def, &output.ctx, enabled_groups)?;
898 let manifest_envs: Vec<manifest::EnvEntry> = tracked_envs
899 .iter()
900 .map(|t| manifest::EnvEntry {
901 key: t.key.clone(),
902 value: t.value.clone(),
903 })
904 .collect();
905 steps.push(Step::WriteFile(GeneratedFile {
906 path: manifest_path_for_svc,
907 content: manifest::format(&manifest_entries, &manifest_envs),
908 }));
909
910 steps.push(Step::DaemonReload);
912 steps.push(Step::StartService {
914 unit: service_name.to_string(),
915 });
916
917 let allocated_ports: Vec<(String, u16)> = resolved_ports.clone();
919
920 let mut generated_secrets: Vec<String> = reg_service
922 .def
923 .env
924 .iter()
925 .filter(|e| !env_overrides.contains_key(&e.name))
926 .flat_map(|e| generate::extract_secret_refs(&e.value))
927 .collect();
928 generated_secrets.sort();
930 generated_secrets.dedup();
931
932 Ok(AddResult {
933 steps,
934 warnings,
935 repo_url: registry_name.to_string(),
936 allocated_ports,
937 generated_secrets,
938 env_content,
939 url: url.map(|u| u.to_string()),
940 tracked_envs,
941 })
942}
943
944fn collect_generated_secrets(
947 def: ®istry::service_def::ServiceDef,
948 env_overrides: &BTreeMap<String, String>,
949) -> Vec<String> {
950 let mut out: Vec<String> = def
951 .env
952 .iter()
953 .filter(|e| !env_overrides.contains_key(&e.name))
954 .flat_map(|e| generate::extract_secret_refs(&e.value))
955 .collect();
956 out.sort();
957 out.dedup();
958 out
959}
960
961struct NativeAddParams<'a> {
963 service_name: &'a str,
964 reg_service: &'a registry::RegistryService,
965 home_dir: &'a Path,
966 output: generate::EnvOutput,
967 install_metadata: &'a Metadata,
968 registry_name: &'a str,
969 url: Option<&'a str>,
970 tracked_envs: Vec<TrackedEnv>,
971 allocated_ports: Vec<(String, u16)>,
972 generated_secrets: Vec<String>,
973}
974
975fn build_native_add(p: NativeAddParams<'_>) -> Result<AddResult> {
981 let NativeAddParams {
982 service_name,
983 reg_service,
984 home_dir,
985 output,
986 install_metadata,
987 registry_name,
988 url,
989 tracked_envs,
990 allocated_ports,
991 generated_secrets,
992 } = p;
993
994 let build = reg_service.def.build.as_ref().ok_or_else(|| {
995 Error::Bundle(format!(
996 "native service '{service_name}' is missing its [build] section"
997 ))
998 })?;
999
1000 let bin_name = Path::new(&build.bin)
1002 .file_name()
1003 .map(|n| n.to_string_lossy().into_owned())
1004 .ok_or_else(|| Error::Bundle(format!("invalid [build].bin: {}", build.bin)))?;
1005 let bin_dst = home_dir.join("bin").join(&bin_name);
1006
1007 let env_content = output.env_file.content.clone();
1008 let mut steps = Vec::new();
1009
1010 steps.push(Step::CreateDir(home_dir.to_path_buf()));
1012 steps.push(Step::CreateDir(home_dir.join("data")));
1013 steps.push(Step::CreateDir(home_dir.join("bin")));
1014
1015 if let Some(command) = &build.command {
1017 steps.push(Step::Build {
1018 dir: reg_service.service_dir.clone(),
1019 command: command.clone(),
1020 });
1021 }
1022 steps.push(Step::CopyFile {
1023 src: reg_service.service_dir.join(&build.bin),
1024 dst: bin_dst.clone(),
1025 });
1026
1027 steps.push(Step::WriteFile(GeneratedFile {
1029 path: metadata_path(service_name)?,
1030 content: toml::to_string_pretty(install_metadata)?,
1031 }));
1032 steps.push(Step::WriteFile(output.env_file));
1033
1034 let unit_name = format!("{service_name}.service");
1037 let unit_path = home_dir.join(&unit_name);
1038 steps.push(Step::WriteFile(GeneratedFile {
1039 path: unit_path.clone(),
1040 content: native_unit(home_dir, &bin_dst, ®_service.def.service.description),
1041 }));
1042 steps.push(Step::Symlink {
1043 link: systemd_user_dir()?.join(&unit_name),
1044 target: unit_path,
1045 });
1046
1047 steps.push(Step::DaemonReload);
1048 steps.push(Step::StartService {
1049 unit: service_name.to_string(),
1050 });
1051
1052 Ok(AddResult {
1053 steps,
1054 warnings: Vec::new(),
1055 repo_url: registry_name.to_string(),
1056 allocated_ports,
1057 generated_secrets,
1058 env_content,
1059 url: url.map(|u| u.to_string()),
1060 tracked_envs,
1061 })
1062}
1063
1064fn native_unit(home_dir: &Path, bin: &Path, description: &str) -> String {
1069 let home = home_dir.display();
1070 format!(
1071 "[Unit]\n\
1072 Description={description}\n\
1073 After=network.target\n\
1074 \n\
1075 [Service]\n\
1076 Type=simple\n\
1077 WorkingDirectory={home}\n\
1078 EnvironmentFile={home}/.env\n\
1079 Environment=SERVICE_HOME={home}\n\
1080 ExecStart={bin}\n\
1081 Restart=always\n\
1082 RestartSec=5\n\
1083 \n\
1084 [Install]\n\
1085 WantedBy=default.target\n",
1086 bin = bin.display(),
1087 )
1088}
1089
1090pub fn quadlet_belongs_to(filename: &str, service_name: &str, all_service_names: &[&str]) -> bool {
1099 if !filename.starts_with(service_name) {
1100 return false;
1101 }
1102 let rest = &filename[service_name.len()..];
1103 if rest.starts_with('.') {
1104 return true;
1105 }
1106 if !rest.starts_with('-') {
1107 return false;
1108 }
1109 !all_service_names.iter().any(|&other| {
1113 other.len() > service_name.len()
1114 && other.starts_with(service_name)
1115 && filename.starts_with(other)
1116 && filename[other.len()..].starts_with(['.', '-'])
1117 })
1118}
1119
1120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1122pub enum RemoveMode {
1123 Preserve,
1128 Purge,
1130}
1131
1132pub fn remove_service(service_name: &str, mode: RemoveMode) -> Result<RemoveResult> {
1134 let installed_owned = build_installed_from_metadata(service_name)
1137 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
1138 let installed = &installed_owned;
1139
1140 if let Ok(Some(meta)) = metadata::load_metadata(service_name)
1145 && meta.runtime == registry::service_def::Runtime::Native
1146 {
1147 let url = installed.exposure.url().map(|s| s.to_string());
1148 return remove_native_service(service_name, mode, url);
1149 }
1150
1151 let quadlet_path = quadlet_dir()?;
1154 let mut steps = Vec::new();
1155 let mut volume_names = Vec::new();
1156 let mut networks: Vec<String> = Vec::new();
1157 let mut has_named_volumes = false;
1158 let name_pool = scan_managed_services().unwrap_or_default();
1162 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
1163
1164 if let Some(svc_name) = installed.exposure.tailscale_svc_name() {
1176 steps.push(Step::TailscaleDisable { svc_name });
1177 }
1178
1179 if quadlet_path.is_dir()
1180 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
1181 {
1182 for entry in entries.flatten() {
1183 let file_name = entry.file_name();
1184 let name = file_name.to_string_lossy();
1185 if !quadlet_belongs_to(&name, service_name, &all_names) {
1188 continue;
1189 }
1190 if name.ends_with(".container") {
1192 let unit = name.trim_end_matches(".container").to_string();
1193 steps.push(Step::StopService { unit });
1194 }
1195 if name.ends_with(".network") {
1196 let net = name.trim_end_matches(".network").to_string();
1199 steps.push(Step::StopService {
1200 unit: format!("{net}-network"),
1201 });
1202 networks.push(net);
1203 }
1204 if name.ends_with(".volume") {
1205 has_named_volumes = true;
1206 if matches!(mode, RemoveMode::Purge) {
1207 let vol = name.trim_end_matches(".volume").to_string();
1208 volume_names.push(format!("systemd-{vol}"));
1210 }
1211 }
1212 steps.push(Step::RemoveFile(entry.path()));
1213 }
1214 }
1215
1216 let had_caddy_route = matches!(
1223 installed.exposure,
1224 Exposure::Internal { .. } | Exposure::Public { .. }
1225 );
1226 if !WellKnownService::Caddy.matches(service_name) && had_caddy_route {
1227 let caddyfile_path = caddy::caddyfile_path()?;
1228 if caddyfile_path.exists() {
1229 let existing =
1230 std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
1231 path: caddyfile_path.clone(),
1232 source,
1233 })?;
1234 let updated = caddy::remove_route(&existing, service_name);
1235 if updated != existing {
1236 steps.push(Step::WriteFile(GeneratedFile {
1237 path: caddyfile_path,
1238 content: updated.clone(),
1239 }));
1240 if !updated.trim().is_empty() {
1243 steps.push(Step::ReloadCaddy);
1244 }
1245 }
1246 }
1247 }
1248
1249 if !WellKnownService::Authelia.matches(service_name)
1250 && matches!(
1251 installed.auth_kind,
1252 Some(registry::service_def::AuthKind::Oidc)
1253 )
1254 {
1255 steps.extend(authelia::unregister_oidc_client(service_name)?);
1256 }
1257
1258 steps.push(Step::DaemonReload);
1260
1261 for net in networks {
1269 steps.push(Step::RemoveNetwork { name: net });
1270 }
1271
1272 match mode {
1273 RemoveMode::Purge => {
1274 for vol_name in volume_names {
1276 steps.push(Step::RemoveVolume { name: vol_name });
1277 }
1278 steps.push(Step::RemoveDir(service_home(service_name)?));
1280 }
1281 RemoveMode::Preserve => {
1282 let home = service_home(service_name)?;
1286 let (data, ephemeral) = crate::data::classify::classify_home_dir(&home)?;
1287 for path in ephemeral {
1288 match std::fs::metadata(&path) {
1289 Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(path)),
1290 Ok(_) => steps.push(Step::RemoveFile(path)),
1291 Err(_) => steps.push(Step::RemoveFile(path)),
1295 }
1296 }
1297 if data.is_empty() && !has_named_volumes && home.exists() {
1305 steps.push(Step::RemoveDir(home));
1306 }
1307 }
1308 }
1309
1310 let url = installed.exposure.url().map(|s| s.to_string());
1311
1312 Ok(RemoveResult {
1313 steps,
1314 service_name: service_name.to_string(),
1315 url,
1316 })
1317}
1318
1319fn remove_native_service(
1324 service_name: &str,
1325 mode: RemoveMode,
1326 url: Option<String>,
1327) -> Result<RemoveResult> {
1328 let home = service_home(service_name)?;
1329 let unit_name = format!("{service_name}.service");
1330 let mut steps = vec![
1331 Step::StopService {
1332 unit: service_name.to_string(),
1333 },
1334 Step::RemoveFile(systemd_user_dir()?.join(&unit_name)),
1335 Step::DaemonReload,
1336 ];
1337
1338 match mode {
1339 RemoveMode::Purge => steps.push(Step::RemoveDir(home)),
1340 RemoveMode::Preserve => {
1341 for child in ["bin", ".env", unit_name.as_str()] {
1344 let p = home.join(child);
1345 match std::fs::metadata(&p) {
1346 Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(p)),
1347 _ => steps.push(Step::RemoveFile(p)),
1348 }
1349 }
1350 }
1351 }
1352
1353 Ok(RemoveResult {
1354 steps,
1355 service_name: service_name.to_string(),
1356 url,
1357 })
1358}
1359
1360#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1362pub enum Lifecycle {
1363 Start,
1364 Stop,
1365}
1366
1367pub fn lifecycle_steps(service_name: &str, action: Lifecycle) -> Result<Vec<Step>> {
1376 build_installed_from_metadata(service_name)
1378 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
1379
1380 if matches!(
1383 metadata::load_metadata(service_name),
1384 Ok(Some(m)) if m.runtime == registry::service_def::Runtime::Native
1385 ) {
1386 let unit = service_name.to_string();
1387 return Ok(vec![match action {
1388 Lifecycle::Start => Step::StartService { unit },
1389 Lifecycle::Stop => Step::StopService { unit },
1390 }]);
1391 }
1392
1393 let mut units = service_container_units(service_name)?;
1394 match action {
1395 Lifecycle::Stop => units.sort_by_key(|u| u != service_name),
1397 Lifecycle::Start => units.sort_by_key(|u| u == service_name),
1399 }
1400
1401 Ok(units
1402 .into_iter()
1403 .map(|unit| match action {
1404 Lifecycle::Start => Step::StartService { unit },
1405 Lifecycle::Stop => Step::StopService { unit },
1406 })
1407 .collect())
1408}
1409
1410fn service_container_units(service_name: &str) -> Result<Vec<String>> {
1414 let quadlet_path = quadlet_dir()?;
1415 let name_pool = scan_managed_services().unwrap_or_default();
1416 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
1417
1418 let mut units = Vec::new();
1419 if quadlet_path.is_dir()
1420 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
1421 {
1422 for entry in entries.flatten() {
1423 let file_name = entry.file_name();
1424 let name = file_name.to_string_lossy();
1425 if !quadlet_belongs_to(&name, service_name, &all_names) {
1426 continue;
1427 }
1428 if name.ends_with(".container") {
1429 units.push(name.trim_end_matches(".container").to_string());
1430 }
1431 }
1432 }
1433 Ok(units)
1434}
1435
1436pub struct RecordPendingParams<'a> {
1438 pub service_name: &'a str,
1439 pub auth_kind: Option<registry::service_def::AuthKind>,
1440 pub registry_name: &'a str,
1441 pub allocated_ports: &'a [(String, u16)],
1442 pub repo_dir: &'a Path,
1443 pub exposure: &'a Exposure,
1450}
1451
1452pub fn record_pending(params: RecordPendingParams<'_>) -> Result<()> {
1459 let paths = ConfigPaths::resolve()?;
1460 paths.ensure_dirs()?;
1461 let mut config = config::load_or_default(&paths.config_file)?;
1462
1463 if WellKnownService::Authelia.matches(params.service_name) {
1468 config.auth = Some(authelia::auth_config(
1469 params.allocated_ports,
1470 params.exposure.url(),
1471 )?);
1472 config::save_config(&paths.config_file, &config)?;
1473 }
1474
1475 Ok(())
1476}
1477
1478pub fn finalize_remove(service_name: &str) -> Result<()> {
1485 let paths = ConfigPaths::resolve()?;
1486 let mut config = config::load_or_default(&paths.config_file)?;
1487
1488 if WellKnownService::Authelia.matches(service_name)
1489 && let Some(auth) = &config.auth
1490 && auth.provider_name() == "authelia"
1491 {
1492 config.auth = None;
1493 config::save_config(&paths.config_file, &config)?;
1494 }
1495
1496 Ok(())
1497}
1498
1499const SENSITIVE_TEMPLATE_REFS: &[&str] = &[
1518 "{{secret.",
1519 "{{auth.client_id",
1520 "{{auth.client_secret",
1521 "{{smtp.username",
1522 "{{smtp.password",
1523];
1524
1525fn is_static_template(value: &str) -> bool {
1526 !SENSITIVE_TEMPLATE_REFS.iter().any(|s| value.contains(s))
1527}
1528
1529fn collect_static_envs(
1545 service_def: ®istry::service_def::ServiceDef,
1546 ctx: &BTreeMap<String, String>,
1547 enabled_groups: &std::collections::BTreeSet<String>,
1548) -> Result<Vec<plan::TrackedEnv>> {
1549 use registry::service_def::EnvKind;
1550 let mut out: Vec<plan::TrackedEnv> = Vec::new();
1551 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1552 let push = |name: &str,
1553 value_template: &str,
1554 kind: EnvKind,
1555 prompt: Option<String>,
1556 out: &mut Vec<plan::TrackedEnv>,
1557 seen: &mut std::collections::HashSet<String>|
1558 -> Result<()> {
1559 if !is_static_template(value_template) {
1560 return Ok(());
1561 }
1562 if !seen.insert(name.to_string()) {
1563 return Ok(());
1564 }
1565 let value = generate::template::render(value_template, ctx)?;
1566 out.push(plan::TrackedEnv {
1567 key: name.to_string(),
1568 value,
1569 kind,
1570 prompt,
1571 });
1572 Ok(())
1573 };
1574 for env in &service_def.env {
1575 push(
1576 &env.name,
1577 &env.value,
1578 env.kind.clone(),
1579 env.prompt.clone(),
1580 &mut out,
1581 &mut seen,
1582 )?;
1583 }
1584 for group in &service_def.env_groups {
1585 if !enabled_groups.contains(&group.name) {
1586 continue;
1587 }
1588 for env in &group.env {
1589 push(
1590 &env.name,
1591 &env.value,
1592 env.kind.clone(),
1593 env.prompt.clone(),
1594 &mut out,
1595 &mut seen,
1596 )?;
1597 }
1598 }
1599 if service_def.integrations.smtp && ctx.contains_key("smtp.host") {
1605 for (env_name, value_template) in &service_def.mappings.smtp {
1606 push(
1607 env_name,
1608 value_template,
1609 EnvKind::Default,
1610 None,
1611 &mut out,
1612 &mut seen,
1613 )?;
1614 }
1615 }
1616 if ctx.contains_key("auth.client_id") {
1617 for (env_name, value_template) in &service_def.mappings.auth {
1618 push(
1619 env_name,
1620 value_template,
1621 EnvKind::Default,
1622 None,
1623 &mut out,
1624 &mut seen,
1625 )?;
1626 }
1627 }
1628 Ok(out)
1629}
1630
1631pub fn orphan_purge_steps(svc: &data::ServiceData) -> Vec<Step> {
1632 let mut steps = Vec::new();
1633
1634 let mut had_quadlet = false;
1640 let mut networks: Vec<String> = Vec::new();
1641 if let Ok(qdir) = quadlet_dir()
1642 && qdir.is_dir()
1643 && let Ok(entries) = std::fs::read_dir(&qdir)
1644 {
1645 let name_pool = scan_managed_services().unwrap_or_default();
1646 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
1647 for entry in entries.flatten() {
1648 let file_name = entry.file_name();
1649 let name = file_name.to_string_lossy();
1650 if !quadlet_belongs_to(&name, &svc.service, &all_names) {
1651 continue;
1652 }
1653 if name.ends_with(".container") {
1657 let unit = name.trim_end_matches(".container").to_string();
1658 steps.push(Step::StopService { unit });
1659 } else if name.ends_with(".network") {
1660 let net = name.trim_end_matches(".network").to_string();
1661 steps.push(Step::StopService {
1662 unit: format!("{net}-network"),
1663 });
1664 networks.push(net);
1665 } else if name.ends_with(".volume") {
1666 let unit = format!("{}-volume", name.trim_end_matches(".volume"));
1667 steps.push(Step::StopService { unit });
1668 }
1669 steps.push(Step::RemoveFile(entry.path()));
1670 had_quadlet = true;
1671 }
1672 }
1673 if had_quadlet {
1674 steps.push(Step::DaemonReload);
1675 }
1676 for net in networks {
1678 steps.push(Step::RemoveNetwork { name: net });
1679 }
1680
1681 for path in &svc.data_paths {
1682 if path.is_dir() {
1683 steps.push(Step::RemoveDir(path.clone()));
1684 } else {
1685 steps.push(Step::RemoveFile(path.clone()));
1686 }
1687 }
1688 if svc.home_dir.exists() {
1689 steps.push(Step::RemoveDir(svc.home_dir.clone()));
1690 }
1691 for v in &svc.volumes {
1692 steps.push(Step::RemoveVolume {
1693 name: v.name.clone(),
1694 });
1695 }
1696 steps
1697}
1698
1699pub fn reset() -> Result<ResetResult> {
1701 let mut steps = Vec::new();
1702
1703 let managed_names = scan_managed_services().unwrap_or_default();
1708
1709 for svc in list_installed().unwrap_or_default() {
1716 if let Some(svc_name) = svc.exposure.tailscale_svc_name() {
1717 steps.push(Step::TailscaleDisable { svc_name });
1718 }
1719 }
1720
1721 let quadlet_path = quadlet_dir()?;
1723 let all_names: Vec<&str> = managed_names.iter().map(|s| s.as_str()).collect();
1724 let mut networks: Vec<String> = Vec::new();
1725 if quadlet_path.is_dir()
1726 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
1727 {
1728 for entry in entries.flatten() {
1729 let file_name = entry.file_name();
1730 let name = file_name.to_string_lossy();
1731 let is_ryra_file = managed_names
1735 .iter()
1736 .any(|svc| quadlet_belongs_to(&name, svc, &all_names));
1737 if !is_ryra_file {
1738 continue;
1739 }
1740 if name.ends_with(".container") {
1741 let unit = name.trim_end_matches(".container").to_string();
1742 steps.push(Step::StopService { unit });
1743 }
1744 if name.ends_with(".network") {
1745 let net = name.trim_end_matches(".network").to_string();
1746 steps.push(Step::StopService {
1747 unit: format!("{net}-network"),
1748 });
1749 networks.push(net);
1750 }
1751 if name.ends_with(".volume") {
1752 let vol = name.trim_end_matches(".volume").to_string();
1753 steps.push(Step::StopService {
1760 unit: format!("{vol}-volume"),
1761 });
1762 }
1763 steps.push(Step::RemoveFile(entry.path()));
1764 }
1765 }
1766
1767 let user_unit_dir = systemd_user_dir()?;
1773 if let Ok(root) = service_data_root()
1774 && let Ok(entries) = std::fs::read_dir(&root)
1775 {
1776 for entry in entries.flatten() {
1777 let Some(name) = entry.file_name().to_str().map(str::to_string) else {
1778 continue;
1779 };
1780 if matches!(
1781 metadata::load_metadata(&name),
1782 Ok(Some(m)) if m.runtime == registry::service_def::Runtime::Native
1783 ) {
1784 steps.push(Step::StopService { unit: name.clone() });
1785 steps.push(Step::RemoveFile(
1786 user_unit_dir.join(format!("{name}.service")),
1787 ));
1788 }
1789 }
1790 }
1791
1792 steps.push(Step::DaemonReload);
1794
1795 for net in networks {
1798 steps.push(Step::RemoveNetwork { name: net });
1799 }
1800
1801 let mut seen_volumes = std::collections::BTreeSet::new();
1807 for svc in data::enumerate_all().unwrap_or_default() {
1808 for vol in svc.volumes {
1809 if seen_volumes.insert(vol.name.clone()) {
1810 steps.push(Step::RemoveVolume { name: vol.name });
1811 }
1812 }
1813 }
1814
1815 let data_root = service_data_root()?;
1821 if data_root.exists() {
1822 steps.push(Step::RemoveDir(data_root));
1823 }
1824
1825 Ok(ResetResult { steps })
1826}
1827
1828pub fn finalize_reset() -> Result<()> {
1830 let paths = ConfigPaths::resolve()?;
1831 if paths.config_dir.exists() {
1832 std::fs::remove_dir_all(&paths.config_dir).map_err(|source| Error::FileWrite {
1833 path: paths.config_dir,
1834 source,
1835 })?;
1836 }
1837 Ok(())
1838}
1839
1840pub fn status() -> config::status::RyraStatus {
1846 let paths = match ConfigPaths::resolve() {
1847 Ok(p) => p,
1848 Err(_) => return config::status::RyraStatus::NotInitialized,
1849 };
1850
1851 let has_quadlets = scan_managed_services()
1852 .map(|n| !n.is_empty())
1853 .unwrap_or(false);
1854
1855 let config = match config::load_config(&paths.config_file) {
1856 Ok(c) => c,
1857 Err(Error::ConfigNotFound(_)) if has_quadlets => config::schema::Config::default(),
1858 Err(Error::ConfigNotFound(_)) => return config::status::RyraStatus::NotInitialized,
1859 Err(e) => return config::status::RyraStatus::Error(e.to_string()),
1860 };
1861
1862 config::status::RyraStatus::Initialized(config::status::StatusInfo::from_config(
1863 paths.config_file,
1864 &config,
1865 ))
1866}
1867
1868pub fn is_service_installed(name: &str) -> bool {
1876 let Ok(Some(meta)) = metadata::load_metadata(name) else {
1880 return false;
1881 };
1882 match meta.runtime {
1883 registry::service_def::Runtime::Native => systemd_user_dir()
1884 .map(|d| d.join(format!("{name}.service")).exists())
1885 .unwrap_or(false),
1886 registry::service_def::Runtime::Podman => scan_managed_services()
1887 .map(|names| names.iter().any(|n| n == name))
1888 .unwrap_or(false),
1889 }
1890}
1891
1892pub fn scan_managed_services() -> Result<Vec<String>> {
1905 let dir = match quadlet_dir() {
1906 Ok(d) => d,
1907 Err(_) => return Ok(Vec::new()),
1908 };
1909 let entries = match std::fs::read_dir(&dir) {
1910 Ok(e) => e,
1911 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1912 Err(source) => return Err(Error::FileRead { path: dir, source }),
1913 };
1914 let mut names: Vec<String> = Vec::new();
1915 for entry in entries.flatten() {
1916 let path = entry.path();
1917 if path.extension().and_then(|e| e.to_str()) != Some("container") {
1918 continue;
1919 }
1920 let Ok(content) = std::fs::read_to_string(&path) else {
1921 continue;
1922 };
1923 for line in content.lines().take(16) {
1924 if let Some(rest) = line.trim().strip_prefix("# Service-Source: registry/")
1925 && !rest.is_empty()
1926 && !names.iter().any(|n| n == rest)
1927 {
1928 names.push(rest.to_string());
1929 break;
1930 }
1931 }
1932 }
1933 names.sort();
1934 Ok(names)
1935}
1936
1937fn build_installed_from_metadata(service_name: &str) -> Option<InstalledService> {
1943 let meta = load_metadata(service_name).ok().flatten()?;
1944
1945 let exposure = match meta.url.as_deref() {
1947 None => Exposure::Loopback,
1948 Some(u) => Exposure::from_url(u),
1949 };
1950
1951 let auth_kind = meta.auth.clone();
1952
1953 let ports = service_home(service_name)
1959 .ok()
1960 .and_then(|home| std::fs::read_to_string(home.join(".env")).ok())
1961 .map(|env| {
1962 env.lines()
1963 .filter_map(|l| {
1964 let l = l.trim();
1965 if l.is_empty() || l.starts_with('#') {
1966 return None;
1967 }
1968 let (key, val) = l.split_once('=')?;
1969 let name = key.strip_prefix("SERVICE_PORT_")?.to_lowercase();
1970 let port = val
1971 .trim_matches(|c: char| c == '"' || c == '\'')
1972 .parse::<u16>()
1973 .ok()?;
1974 Some((name, port))
1975 })
1976 .collect::<std::collections::BTreeMap<String, u16>>()
1977 })
1978 .unwrap_or_default();
1979
1980 Some(InstalledService {
1981 name: service_name.to_string(),
1982 version: "0.1.0".to_string(),
1983 repo: meta.registry,
1984 ports,
1985 auth_kind,
1986 exposure,
1987 provides: meta.provides,
1988 installed: true,
1989 })
1990}
1991
1992pub fn list_installed() -> Result<Vec<InstalledService>> {
1999 let mut names: std::collections::BTreeSet<String> = scan_managed_services()
2000 .unwrap_or_default()
2001 .into_iter()
2002 .collect();
2003 if let Ok(root) = service_data_root()
2007 && let Ok(entries) = std::fs::read_dir(&root)
2008 {
2009 for entry in entries.flatten() {
2010 if let Some(name) = entry.file_name().to_str()
2011 && !names.contains(name)
2012 && is_service_installed(name)
2013 {
2014 names.insert(name.to_string());
2015 }
2016 }
2017 }
2018 let out: Vec<InstalledService> = names
2019 .iter()
2020 .filter_map(|n| build_installed_from_metadata(n))
2021 .collect();
2022 Ok(out)
2023}
2024
2025pub fn search_services(repo_dir: &Path, query: Option<&str>) -> Result<Vec<SearchResult>> {
2027 let available = registry::list_available(repo_dir)?;
2028
2029 let results = available
2030 .into_iter()
2031 .filter(|reg_svc| match query {
2032 None => true,
2033 Some(q) => {
2034 let q = q.to_lowercase();
2035 reg_svc.def.service.name.to_lowercase().contains(&q)
2036 || reg_svc.def.service.description.to_lowercase().contains(&q)
2037 }
2038 })
2039 .map(|reg_svc| {
2040 let name = ®_svc.def.service.name;
2041 let installed = is_service_installed(name);
2042 let mut supports = Vec::new();
2043 for kind in ®_svc.def.integrations.auth {
2044 supports.push(kind.to_string());
2045 }
2046 if reg_svc.def.integrations.smtp {
2047 supports.push("smtp".to_string());
2048 }
2049 SearchResult {
2050 name: name.clone(),
2051 description: reg_svc.def.service.description,
2052 installed,
2053 supports,
2054 }
2055 })
2056 .collect();
2057
2058 Ok(results)
2059}
2060
2061pub struct SearchResult {
2062 pub name: String,
2063 pub description: String,
2064 pub installed: bool,
2065 pub supports: Vec<String>,
2067}
2068
2069pub async fn service_tests(service_name: &str) -> Result<ServiceTestInfo> {
2071 let installed = build_installed_from_metadata(service_name)
2072 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
2073
2074 let service_ref = service_ref_from_installed(&installed);
2075 let repo_dir = resolve_registry_dir(&service_ref).await?;
2076
2077 let test_toml_path = repo_dir.join(service_name).join("test.toml");
2078 let env_file = service_home(service_name)?.join(".env");
2079
2080 if !test_toml_path.exists() {
2081 return Ok(ServiceTestInfo {
2082 service_name: service_name.to_string(),
2083 registry_name: service_ref.registry_name().to_string(),
2084 tests: vec![],
2085 env_file,
2086 });
2087 }
2088
2089 let content = std::fs::read_to_string(&test_toml_path).map_err(|source| Error::FileRead {
2090 path: test_toml_path.clone(),
2091 source,
2092 })?;
2093
2094 #[derive(serde::Deserialize)]
2095 struct TestFile {
2096 #[serde(default)]
2097 tests: Vec<registry::test_def::TestDef>,
2098 }
2099
2100 let parsed: TestFile = toml::from_str(&content).map_err(|source| Error::TomlParse {
2101 path: test_toml_path,
2102 source,
2103 })?;
2104
2105 Ok(ServiceTestInfo {
2106 service_name: service_name.to_string(),
2107 registry_name: service_ref.registry_name().to_string(),
2108 tests: parsed.tests,
2109 env_file,
2110 })
2111}
2112
2113pub struct ServiceTestInfo {
2114 pub service_name: String,
2115 pub registry_name: String,
2116 pub tests: Vec<registry::test_def::TestDef>,
2117 pub env_file: PathBuf,
2118}
2119
2120pub fn service_info(repo_dir: &Path, service_name: &str) -> Result<ServiceDetail> {
2122 let reg_service = registry::find_service(repo_dir, service_name)?;
2123 let def = ®_service.def;
2124
2125 Ok(ServiceDetail {
2126 name: def.service.name.clone(),
2127 description: def.service.description.clone(),
2128 url: def.service.url.clone(),
2129 ports: def
2130 .ports
2131 .iter()
2132 .map(|p| (p.container_port, p.protocol.clone(), p.name.clone()))
2133 .collect(),
2134 env_vars: def
2135 .env
2136 .iter()
2137 .map(|e| (e.name.clone(), e.prompt.clone()))
2138 .collect(),
2139 })
2140}
2141
2142pub struct ServiceDetail {
2143 pub name: String,
2144 pub description: String,
2145 pub url: Option<String>,
2146 pub ports: Vec<(u16, registry::service_def::PortProtocol, String)>,
2147 pub env_vars: Vec<(String, Option<String>)>,
2148}
2149
2150#[cfg(test)]
2151mod tests {
2152 use super::*;
2153
2154 #[test]
2155 fn static_template_filter_excludes_secrets_and_credentials() {
2156 assert!(is_static_template("3306"));
2158 assert!(is_static_template("mariadb"));
2159 assert!(is_static_template("{{service.port}}"));
2161 assert!(is_static_template("{{service.url}}"));
2162 assert!(is_static_template("{{auth.url}}"));
2163 assert!(is_static_template("{{auth.issuer}}"));
2164 assert!(is_static_template("{{auth.provider}}"));
2165 assert!(is_static_template("{{auth.internal_url}}"));
2166 assert!(is_static_template("{{smtp.host}}"));
2167 assert!(is_static_template("{{smtp.port}}"));
2168 assert!(is_static_template("{{smtp.from}}"));
2169 assert!(is_static_template("{{service.url}}/oauth/callback"));
2171
2172 assert!(!is_static_template("{{secret.admin_password}}"));
2174 assert!(!is_static_template("{{secret.jwt_key}}"));
2175 assert!(!is_static_template("{{auth.client_id}}"));
2177 assert!(!is_static_template("{{auth.client_secret}}"));
2178 assert!(!is_static_template("{{smtp.username}}"));
2180 assert!(!is_static_template("{{smtp.password}}"));
2181 assert!(!is_static_template(
2183 "redis://:{{secret.redis_pw}}@host:6379"
2184 ));
2185 }
2186
2187 #[test]
2188 fn tailscale_url_matches() {
2189 assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net"));
2190 assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net:10001/"));
2191 assert!(is_tailscale_url("https://foo.example-net.ts.net"));
2192 assert!(is_tailscale_url("http://HOST.COBBLER-TUNA.TS.NET"));
2193 }
2194
2195 #[test]
2196 fn tailscale_url_rejects() {
2197 assert!(!is_tailscale_url("https://nextcloud.internal:8443"));
2198 assert!(!is_tailscale_url("https://example.com"));
2199 assert!(!is_tailscale_url("http://127.0.0.1:10001"));
2200 assert!(!is_tailscale_url("https://ts.net"));
2202 assert!(!is_tailscale_url("https://evil-ts.net.example.com"));
2203 assert!(!is_tailscale_url("not a url"));
2204 }
2205
2206 #[test]
2207 fn public_url_accepts_public_domains() {
2208 assert!(is_public_url("https://seafile.ryra.no"));
2209 assert!(is_public_url("https://example.com"));
2210 assert!(is_public_url("https://docs.ryra.no:8443"));
2211 }
2212
2213 #[test]
2214 fn public_url_rejects_lan_and_tailnet() {
2215 assert!(!is_public_url("https://nextcloud.internal:8443"));
2216 assert!(!is_public_url("https://service.localhost"));
2217 assert!(!is_public_url("https://something.local"));
2218 assert!(!is_public_url("https://localhost:8080"));
2219 assert!(!is_public_url("https://debian.cobbler-tuna.ts.net"));
2220 assert!(!is_public_url("http://127.0.0.1:10001"));
2221 assert!(!is_public_url("http://192.168.1.10"));
2222 assert!(!is_public_url("http://[::1]"));
2223 assert!(!is_public_url("not a url"));
2224 }
2225
2226 #[test]
2231 fn networks_empty_when_no_auth() {
2232 let nets = resolve_extra_networks("whoami", false, false, false, false, false, false);
2233 assert!(nets.is_empty());
2234 }
2235
2236 #[test]
2237 fn networks_empty_when_auth_but_no_authelia() {
2238 let nets = resolve_extra_networks("forgejo", true, false, false, false, false, false);
2239 assert!(nets.is_empty());
2240 }
2241
2242 #[test]
2243 fn networks_authelia_when_auth_enabled() {
2244 let nets = resolve_extra_networks("forgejo", true, true, false, false, false, false);
2245 assert_eq!(nets, vec!["authelia"]);
2246 }
2247
2248 #[test]
2249 fn networks_auth_with_caddy_includes_both() {
2250 let nets = resolve_extra_networks("forgejo", true, true, true, false, false, false);
2251 assert!(nets.contains(&"authelia".to_string()));
2252 assert!(nets.contains(&"caddy".to_string()));
2253 }
2254
2255 #[test]
2256 fn networks_authelia_excluded_for_authelia_itself() {
2257 let nets = resolve_extra_networks("authelia", true, true, false, false, false, false);
2258 assert!(nets.is_empty());
2259 }
2260
2261 #[test]
2262 fn networks_smtp_joins_inbucket_without_caddy() {
2263 let nets = resolve_extra_networks("forgejo", false, false, false, true, false, true);
2265 assert_eq!(nets, vec!["inbucket"]);
2266 }
2267
2268 #[test]
2269 fn networks_smtp_skips_inbucket_when_it_is_self() {
2270 let nets = resolve_extra_networks("inbucket", false, false, false, true, false, true);
2271 assert!(!nets.contains(&"inbucket".to_string()));
2272 }
2273
2274 #[test]
2275 fn networks_smtp_skips_inbucket_when_not_installed() {
2276 let nets = resolve_extra_networks("forgejo", false, false, false, false, false, true);
2277 assert!(!nets.contains(&"inbucket".to_string()));
2278 }
2279
2280 #[test]
2281 fn quadlet_belongs_to_exact_match() {
2282 let all = &["foo", "foo-bar"];
2283 assert!(quadlet_belongs_to("foo.container", "foo", all));
2284 assert!(quadlet_belongs_to("foo.network", "foo", all));
2285 }
2286
2287 #[test]
2288 fn quadlet_belongs_to_sidecar() {
2289 let all = &["foo"];
2291 assert!(quadlet_belongs_to("foo-db.volume", "foo", all));
2292 }
2293
2294 #[test]
2295 fn quadlet_belongs_to_rejects_prefix_collision() {
2296 let all = &["foo", "foo-bar"];
2297 assert!(!quadlet_belongs_to("foo-bar.container", "foo", all));
2298 assert!(!quadlet_belongs_to("foo-bar-db.volume", "foo", all));
2299 }
2300
2301 #[test]
2302 fn quadlet_belongs_to_hyphenated_service() {
2303 let all = &["foo", "foo-bar"];
2304 assert!(quadlet_belongs_to("foo-bar.container", "foo-bar", all));
2305 assert!(quadlet_belongs_to("foo-bar-db.volume", "foo-bar", all));
2306 assert!(!quadlet_belongs_to("foo.container", "foo-bar", all));
2307 }
2308}