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 run = reg_service.def.service.run.as_ref().ok_or_else(|| {
995 Error::Bundle(format!(
996 "native service '{service_name}' is missing its `run` command"
997 ))
998 })?;
999 let build = reg_service.def.service.build.as_ref();
1000
1001 let env_content = output.env_file.content.clone();
1002 let source_dir = reg_service.service_dir.clone();
1003 let mut steps = Vec::new();
1004
1005 steps.push(Step::CreateDir(home_dir.to_path_buf()));
1010 steps.push(Step::CreateDir(home_dir.join("data")));
1011
1012 if let Some(command) = build {
1014 steps.push(Step::Build {
1015 dir: source_dir.clone(),
1016 command: command.clone(),
1017 });
1018 }
1019
1020 steps.push(Step::WriteFile(GeneratedFile {
1022 path: metadata_path(service_name)?,
1023 content: toml::to_string_pretty(install_metadata)?,
1024 }));
1025 steps.push(Step::WriteFile(output.env_file));
1026
1027 let unit_name = format!("{service_name}.service");
1030 let unit_path = home_dir.join(&unit_name);
1031 steps.push(Step::WriteFile(GeneratedFile {
1032 path: unit_path.clone(),
1033 content: native_unit(
1034 home_dir,
1035 &source_dir,
1036 run,
1037 ®_service.def.service.description,
1038 ),
1039 }));
1040 steps.push(Step::Symlink {
1041 link: systemd_user_dir()?.join(&unit_name),
1042 target: unit_path,
1043 });
1044
1045 steps.push(Step::DaemonReload);
1046 steps.push(Step::StartService {
1047 unit: service_name.to_string(),
1048 });
1049
1050 Ok(AddResult {
1051 steps,
1052 warnings: Vec::new(),
1053 repo_url: registry_name.to_string(),
1054 allocated_ports,
1055 generated_secrets,
1056 env_content,
1057 url: url.map(|u| u.to_string()),
1058 tracked_envs,
1059 })
1060}
1061
1062fn native_unit(home_dir: &Path, source_dir: &Path, run: &str, description: &str) -> String {
1067 let home = home_dir.display();
1068 let source = source_dir.display();
1069 format!(
1078 "[Unit]\n\
1079 Description={description}\n\
1080 After=network.target\n\
1081 \n\
1082 [Service]\n\
1083 Type=simple\n\
1084 WorkingDirectory={source}\n\
1085 EnvironmentFile={home}/.env\n\
1086 Environment=SERVICE_HOME={home}\n\
1087 Environment=PATH=%h/.local/bin:%h/.cargo/bin:%h/.bun/bin:%h/.deno/bin:%h/go/bin:/usr/local/bin:/usr/bin:/bin\n\
1088 ExecStart=/bin/sh -c 'exec {run}'\n\
1089 Restart=always\n\
1090 RestartSec=5\n\
1091 \n\
1092 [Install]\n\
1093 WantedBy=default.target\n",
1094 )
1095}
1096
1097pub fn quadlet_belongs_to(filename: &str, service_name: &str, all_service_names: &[&str]) -> bool {
1106 if !filename.starts_with(service_name) {
1107 return false;
1108 }
1109 let rest = &filename[service_name.len()..];
1110 if rest.starts_with('.') {
1111 return true;
1112 }
1113 if !rest.starts_with('-') {
1114 return false;
1115 }
1116 !all_service_names.iter().any(|&other| {
1120 other.len() > service_name.len()
1121 && other.starts_with(service_name)
1122 && filename.starts_with(other)
1123 && filename[other.len()..].starts_with(['.', '-'])
1124 })
1125}
1126
1127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1129pub enum RemoveMode {
1130 Preserve,
1135 Purge,
1137}
1138
1139pub fn remove_service(service_name: &str, mode: RemoveMode) -> Result<RemoveResult> {
1141 let installed_owned = build_installed_from_metadata(service_name)
1144 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
1145 let installed = &installed_owned;
1146
1147 if let Ok(Some(meta)) = metadata::load_metadata(service_name)
1152 && meta.runtime == registry::service_def::Runtime::Native
1153 {
1154 let url = installed.exposure.url().map(|s| s.to_string());
1155 return remove_native_service(service_name, mode, url);
1156 }
1157
1158 let quadlet_path = quadlet_dir()?;
1161 let mut steps = Vec::new();
1162 let mut volume_names = Vec::new();
1163 let mut networks: Vec<String> = Vec::new();
1164 let mut has_named_volumes = false;
1165 let name_pool = scan_managed_services().unwrap_or_default();
1169 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
1170
1171 if let Some(svc_name) = installed.exposure.tailscale_svc_name() {
1183 steps.push(Step::TailscaleDisable { svc_name });
1184 }
1185
1186 if quadlet_path.is_dir()
1187 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
1188 {
1189 for entry in entries.flatten() {
1190 let file_name = entry.file_name();
1191 let name = file_name.to_string_lossy();
1192 if !quadlet_belongs_to(&name, service_name, &all_names) {
1195 continue;
1196 }
1197 if name.ends_with(".container") {
1199 let unit = name.trim_end_matches(".container").to_string();
1200 steps.push(Step::StopService { unit });
1201 }
1202 if name.ends_with(".network") {
1203 let net = name.trim_end_matches(".network").to_string();
1206 steps.push(Step::StopService {
1207 unit: format!("{net}-network"),
1208 });
1209 networks.push(net);
1210 }
1211 if name.ends_with(".volume") {
1212 has_named_volumes = true;
1213 if matches!(mode, RemoveMode::Purge) {
1214 let vol = name.trim_end_matches(".volume").to_string();
1215 volume_names.push(format!("systemd-{vol}"));
1217 }
1218 }
1219 steps.push(Step::RemoveFile(entry.path()));
1220 }
1221 }
1222
1223 let had_caddy_route = matches!(
1230 installed.exposure,
1231 Exposure::Internal { .. } | Exposure::Public { .. }
1232 );
1233 if !WellKnownService::Caddy.matches(service_name) && had_caddy_route {
1234 let caddyfile_path = caddy::caddyfile_path()?;
1235 if caddyfile_path.exists() {
1236 let existing =
1237 std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
1238 path: caddyfile_path.clone(),
1239 source,
1240 })?;
1241 let updated = caddy::remove_route(&existing, service_name);
1242 if updated != existing {
1243 steps.push(Step::WriteFile(GeneratedFile {
1244 path: caddyfile_path,
1245 content: updated.clone(),
1246 }));
1247 if !updated.trim().is_empty() {
1250 steps.push(Step::ReloadCaddy);
1251 }
1252 }
1253 }
1254 }
1255
1256 if !WellKnownService::Authelia.matches(service_name)
1257 && matches!(
1258 installed.auth_kind,
1259 Some(registry::service_def::AuthKind::Oidc)
1260 )
1261 {
1262 steps.extend(authelia::unregister_oidc_client(service_name)?);
1263 }
1264
1265 steps.push(Step::DaemonReload);
1267
1268 for net in networks {
1276 steps.push(Step::RemoveNetwork { name: net });
1277 }
1278
1279 match mode {
1280 RemoveMode::Purge => {
1281 for vol_name in volume_names {
1283 steps.push(Step::RemoveVolume { name: vol_name });
1284 }
1285 steps.push(Step::RemoveDir(service_home(service_name)?));
1287 }
1288 RemoveMode::Preserve => {
1289 let home = service_home(service_name)?;
1293 let (data, ephemeral) = crate::data::classify::classify_home_dir(&home)?;
1294 for path in ephemeral {
1295 match std::fs::metadata(&path) {
1296 Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(path)),
1297 Ok(_) => steps.push(Step::RemoveFile(path)),
1298 Err(_) => steps.push(Step::RemoveFile(path)),
1302 }
1303 }
1304 if data.is_empty() && !has_named_volumes && home.exists() {
1312 steps.push(Step::RemoveDir(home));
1313 }
1314 }
1315 }
1316
1317 let url = installed.exposure.url().map(|s| s.to_string());
1318
1319 Ok(RemoveResult {
1320 steps,
1321 service_name: service_name.to_string(),
1322 url,
1323 })
1324}
1325
1326fn remove_native_service(
1331 service_name: &str,
1332 mode: RemoveMode,
1333 url: Option<String>,
1334) -> Result<RemoveResult> {
1335 let home = service_home(service_name)?;
1336 let unit_name = format!("{service_name}.service");
1337 let mut steps = vec![
1338 Step::StopService {
1339 unit: service_name.to_string(),
1340 },
1341 Step::RemoveFile(systemd_user_dir()?.join(&unit_name)),
1342 Step::DaemonReload,
1343 ];
1344
1345 match mode {
1346 RemoveMode::Purge => steps.push(Step::RemoveDir(home)),
1347 RemoveMode::Preserve => {
1348 for child in ["bin", ".env", unit_name.as_str()] {
1351 let p = home.join(child);
1352 match std::fs::metadata(&p) {
1353 Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(p)),
1354 _ => steps.push(Step::RemoveFile(p)),
1355 }
1356 }
1357 }
1358 }
1359
1360 Ok(RemoveResult {
1361 steps,
1362 service_name: service_name.to_string(),
1363 url,
1364 })
1365}
1366
1367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1369pub enum Lifecycle {
1370 Start,
1371 Stop,
1372}
1373
1374pub fn lifecycle_steps(service_name: &str, action: Lifecycle) -> Result<Vec<Step>> {
1383 build_installed_from_metadata(service_name)
1385 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
1386
1387 if matches!(
1390 metadata::load_metadata(service_name),
1391 Ok(Some(m)) if m.runtime == registry::service_def::Runtime::Native
1392 ) {
1393 let unit = service_name.to_string();
1394 return Ok(vec![match action {
1395 Lifecycle::Start => Step::StartService { unit },
1396 Lifecycle::Stop => Step::StopService { unit },
1397 }]);
1398 }
1399
1400 let mut units = service_container_units(service_name)?;
1401 match action {
1402 Lifecycle::Stop => units.sort_by_key(|u| u != service_name),
1404 Lifecycle::Start => units.sort_by_key(|u| u == service_name),
1406 }
1407
1408 Ok(units
1409 .into_iter()
1410 .map(|unit| match action {
1411 Lifecycle::Start => Step::StartService { unit },
1412 Lifecycle::Stop => Step::StopService { unit },
1413 })
1414 .collect())
1415}
1416
1417fn service_container_units(service_name: &str) -> Result<Vec<String>> {
1421 let quadlet_path = quadlet_dir()?;
1422 let name_pool = scan_managed_services().unwrap_or_default();
1423 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
1424
1425 let mut units = Vec::new();
1426 if quadlet_path.is_dir()
1427 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
1428 {
1429 for entry in entries.flatten() {
1430 let file_name = entry.file_name();
1431 let name = file_name.to_string_lossy();
1432 if !quadlet_belongs_to(&name, service_name, &all_names) {
1433 continue;
1434 }
1435 if name.ends_with(".container") {
1436 units.push(name.trim_end_matches(".container").to_string());
1437 }
1438 }
1439 }
1440 Ok(units)
1441}
1442
1443pub struct RecordPendingParams<'a> {
1445 pub service_name: &'a str,
1446 pub auth_kind: Option<registry::service_def::AuthKind>,
1447 pub registry_name: &'a str,
1448 pub allocated_ports: &'a [(String, u16)],
1449 pub repo_dir: &'a Path,
1450 pub exposure: &'a Exposure,
1457}
1458
1459pub fn record_pending(params: RecordPendingParams<'_>) -> Result<()> {
1466 let paths = ConfigPaths::resolve()?;
1467 paths.ensure_dirs()?;
1468 let mut config = config::load_or_default(&paths.config_file)?;
1469
1470 if WellKnownService::Authelia.matches(params.service_name) {
1475 config.auth = Some(authelia::auth_config(
1476 params.allocated_ports,
1477 params.exposure.url(),
1478 )?);
1479 config::save_config(&paths.config_file, &config)?;
1480 }
1481
1482 Ok(())
1483}
1484
1485pub fn finalize_remove(service_name: &str) -> Result<()> {
1492 let paths = ConfigPaths::resolve()?;
1493 let mut config = config::load_or_default(&paths.config_file)?;
1494
1495 if WellKnownService::Authelia.matches(service_name)
1496 && let Some(auth) = &config.auth
1497 && auth.provider_name() == "authelia"
1498 {
1499 config.auth = None;
1500 config::save_config(&paths.config_file, &config)?;
1501 }
1502
1503 Ok(())
1504}
1505
1506const SENSITIVE_TEMPLATE_REFS: &[&str] = &[
1525 "{{secret.",
1526 "{{auth.client_id",
1527 "{{auth.client_secret",
1528 "{{smtp.username",
1529 "{{smtp.password",
1530];
1531
1532fn is_static_template(value: &str) -> bool {
1533 !SENSITIVE_TEMPLATE_REFS.iter().any(|s| value.contains(s))
1534}
1535
1536fn collect_static_envs(
1552 service_def: ®istry::service_def::ServiceDef,
1553 ctx: &BTreeMap<String, String>,
1554 enabled_groups: &std::collections::BTreeSet<String>,
1555) -> Result<Vec<plan::TrackedEnv>> {
1556 use registry::service_def::EnvKind;
1557 let mut out: Vec<plan::TrackedEnv> = Vec::new();
1558 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1559 let push = |name: &str,
1560 value_template: &str,
1561 kind: EnvKind,
1562 prompt: Option<String>,
1563 out: &mut Vec<plan::TrackedEnv>,
1564 seen: &mut std::collections::HashSet<String>|
1565 -> Result<()> {
1566 if !is_static_template(value_template) {
1567 return Ok(());
1568 }
1569 if !seen.insert(name.to_string()) {
1570 return Ok(());
1571 }
1572 let value = generate::template::render(value_template, ctx)?;
1573 out.push(plan::TrackedEnv {
1574 key: name.to_string(),
1575 value,
1576 kind,
1577 prompt,
1578 });
1579 Ok(())
1580 };
1581 for env in &service_def.env {
1582 push(
1583 &env.name,
1584 &env.value,
1585 env.kind.clone(),
1586 env.prompt.clone(),
1587 &mut out,
1588 &mut seen,
1589 )?;
1590 }
1591 for group in &service_def.env_groups {
1592 if !enabled_groups.contains(&group.name) {
1593 continue;
1594 }
1595 for env in &group.env {
1596 push(
1597 &env.name,
1598 &env.value,
1599 env.kind.clone(),
1600 env.prompt.clone(),
1601 &mut out,
1602 &mut seen,
1603 )?;
1604 }
1605 }
1606 if service_def.integrations.smtp && ctx.contains_key("smtp.host") {
1612 for (env_name, value_template) in &service_def.mappings.smtp {
1613 push(
1614 env_name,
1615 value_template,
1616 EnvKind::Default,
1617 None,
1618 &mut out,
1619 &mut seen,
1620 )?;
1621 }
1622 }
1623 if ctx.contains_key("auth.client_id") {
1624 for (env_name, value_template) in &service_def.mappings.auth {
1625 push(
1626 env_name,
1627 value_template,
1628 EnvKind::Default,
1629 None,
1630 &mut out,
1631 &mut seen,
1632 )?;
1633 }
1634 }
1635 Ok(out)
1636}
1637
1638pub fn orphan_purge_steps(svc: &data::ServiceData) -> Vec<Step> {
1639 let mut steps = Vec::new();
1640
1641 let mut had_quadlet = false;
1647 let mut networks: Vec<String> = Vec::new();
1648 if let Ok(qdir) = quadlet_dir()
1649 && qdir.is_dir()
1650 && let Ok(entries) = std::fs::read_dir(&qdir)
1651 {
1652 let name_pool = scan_managed_services().unwrap_or_default();
1653 let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
1654 for entry in entries.flatten() {
1655 let file_name = entry.file_name();
1656 let name = file_name.to_string_lossy();
1657 if !quadlet_belongs_to(&name, &svc.service, &all_names) {
1658 continue;
1659 }
1660 if name.ends_with(".container") {
1664 let unit = name.trim_end_matches(".container").to_string();
1665 steps.push(Step::StopService { unit });
1666 } else if name.ends_with(".network") {
1667 let net = name.trim_end_matches(".network").to_string();
1668 steps.push(Step::StopService {
1669 unit: format!("{net}-network"),
1670 });
1671 networks.push(net);
1672 } else if name.ends_with(".volume") {
1673 let unit = format!("{}-volume", name.trim_end_matches(".volume"));
1674 steps.push(Step::StopService { unit });
1675 }
1676 steps.push(Step::RemoveFile(entry.path()));
1677 had_quadlet = true;
1678 }
1679 }
1680 if had_quadlet {
1681 steps.push(Step::DaemonReload);
1682 }
1683 for net in networks {
1685 steps.push(Step::RemoveNetwork { name: net });
1686 }
1687
1688 for path in &svc.data_paths {
1689 if path.is_dir() {
1690 steps.push(Step::RemoveDir(path.clone()));
1691 } else {
1692 steps.push(Step::RemoveFile(path.clone()));
1693 }
1694 }
1695 if svc.home_dir.exists() {
1696 steps.push(Step::RemoveDir(svc.home_dir.clone()));
1697 }
1698 for v in &svc.volumes {
1699 steps.push(Step::RemoveVolume {
1700 name: v.name.clone(),
1701 });
1702 }
1703 steps
1704}
1705
1706pub fn reset() -> Result<ResetResult> {
1708 let mut steps = Vec::new();
1709
1710 let managed_names = scan_managed_services().unwrap_or_default();
1715
1716 for svc in list_installed().unwrap_or_default() {
1723 if let Some(svc_name) = svc.exposure.tailscale_svc_name() {
1724 steps.push(Step::TailscaleDisable { svc_name });
1725 }
1726 }
1727
1728 let quadlet_path = quadlet_dir()?;
1730 let all_names: Vec<&str> = managed_names.iter().map(|s| s.as_str()).collect();
1731 let mut networks: Vec<String> = Vec::new();
1732 if quadlet_path.is_dir()
1733 && let Ok(entries) = std::fs::read_dir(&quadlet_path)
1734 {
1735 for entry in entries.flatten() {
1736 let file_name = entry.file_name();
1737 let name = file_name.to_string_lossy();
1738 let is_ryra_file = managed_names
1742 .iter()
1743 .any(|svc| quadlet_belongs_to(&name, svc, &all_names));
1744 if !is_ryra_file {
1745 continue;
1746 }
1747 if name.ends_with(".container") {
1748 let unit = name.trim_end_matches(".container").to_string();
1749 steps.push(Step::StopService { unit });
1750 }
1751 if name.ends_with(".network") {
1752 let net = name.trim_end_matches(".network").to_string();
1753 steps.push(Step::StopService {
1754 unit: format!("{net}-network"),
1755 });
1756 networks.push(net);
1757 }
1758 if name.ends_with(".volume") {
1759 let vol = name.trim_end_matches(".volume").to_string();
1760 steps.push(Step::StopService {
1767 unit: format!("{vol}-volume"),
1768 });
1769 }
1770 steps.push(Step::RemoveFile(entry.path()));
1771 }
1772 }
1773
1774 let user_unit_dir = systemd_user_dir()?;
1780 if let Ok(root) = service_data_root()
1781 && let Ok(entries) = std::fs::read_dir(&root)
1782 {
1783 for entry in entries.flatten() {
1784 let Some(name) = entry.file_name().to_str().map(str::to_string) else {
1785 continue;
1786 };
1787 if matches!(
1788 metadata::load_metadata(&name),
1789 Ok(Some(m)) if m.runtime == registry::service_def::Runtime::Native
1790 ) {
1791 steps.push(Step::StopService { unit: name.clone() });
1792 steps.push(Step::RemoveFile(
1793 user_unit_dir.join(format!("{name}.service")),
1794 ));
1795 }
1796 }
1797 }
1798
1799 steps.push(Step::DaemonReload);
1801
1802 for net in networks {
1805 steps.push(Step::RemoveNetwork { name: net });
1806 }
1807
1808 let mut seen_volumes = std::collections::BTreeSet::new();
1814 for svc in data::enumerate_all().unwrap_or_default() {
1815 for vol in svc.volumes {
1816 if seen_volumes.insert(vol.name.clone()) {
1817 steps.push(Step::RemoveVolume { name: vol.name });
1818 }
1819 }
1820 }
1821
1822 let data_root = service_data_root()?;
1828 if data_root.exists() {
1829 steps.push(Step::RemoveDir(data_root));
1830 }
1831
1832 Ok(ResetResult { steps })
1833}
1834
1835pub fn finalize_reset() -> Result<()> {
1837 let paths = ConfigPaths::resolve()?;
1838 if paths.config_dir.exists() {
1839 std::fs::remove_dir_all(&paths.config_dir).map_err(|source| Error::FileWrite {
1840 path: paths.config_dir,
1841 source,
1842 })?;
1843 }
1844 Ok(())
1845}
1846
1847pub fn status() -> config::status::RyraStatus {
1853 let paths = match ConfigPaths::resolve() {
1854 Ok(p) => p,
1855 Err(_) => return config::status::RyraStatus::NotInitialized,
1856 };
1857
1858 let has_quadlets = scan_managed_services()
1859 .map(|n| !n.is_empty())
1860 .unwrap_or(false);
1861
1862 let config = match config::load_config(&paths.config_file) {
1863 Ok(c) => c,
1864 Err(Error::ConfigNotFound(_)) if has_quadlets => config::schema::Config::default(),
1865 Err(Error::ConfigNotFound(_)) => return config::status::RyraStatus::NotInitialized,
1866 Err(e) => return config::status::RyraStatus::Error(e.to_string()),
1867 };
1868
1869 config::status::RyraStatus::Initialized(config::status::StatusInfo::from_config(
1870 paths.config_file,
1871 &config,
1872 ))
1873}
1874
1875pub fn is_service_installed(name: &str) -> bool {
1883 let Ok(Some(meta)) = metadata::load_metadata(name) else {
1887 return false;
1888 };
1889 match meta.runtime {
1890 registry::service_def::Runtime::Native => systemd_user_dir()
1891 .map(|d| d.join(format!("{name}.service")).exists())
1892 .unwrap_or(false),
1893 registry::service_def::Runtime::Podman => scan_managed_services()
1894 .map(|names| names.iter().any(|n| n == name))
1895 .unwrap_or(false),
1896 }
1897}
1898
1899pub fn scan_managed_services() -> Result<Vec<String>> {
1912 let dir = match quadlet_dir() {
1913 Ok(d) => d,
1914 Err(_) => return Ok(Vec::new()),
1915 };
1916 let entries = match std::fs::read_dir(&dir) {
1917 Ok(e) => e,
1918 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1919 Err(source) => return Err(Error::FileRead { path: dir, source }),
1920 };
1921 let mut names: Vec<String> = Vec::new();
1922 for entry in entries.flatten() {
1923 let path = entry.path();
1924 if path.extension().and_then(|e| e.to_str()) != Some("container") {
1925 continue;
1926 }
1927 let Ok(content) = std::fs::read_to_string(&path) else {
1928 continue;
1929 };
1930 for line in content.lines().take(16) {
1931 if let Some(rest) = line.trim().strip_prefix("# Service-Source: registry/")
1932 && !rest.is_empty()
1933 && !names.iter().any(|n| n == rest)
1934 {
1935 names.push(rest.to_string());
1936 break;
1937 }
1938 }
1939 }
1940 names.sort();
1941 Ok(names)
1942}
1943
1944fn build_installed_from_metadata(service_name: &str) -> Option<InstalledService> {
1950 let meta = load_metadata(service_name).ok().flatten()?;
1951
1952 let exposure = match meta.url.as_deref() {
1954 None => Exposure::Loopback,
1955 Some(u) => Exposure::from_url(u),
1956 };
1957
1958 let auth_kind = meta.auth.clone();
1959
1960 let ports = service_home(service_name)
1966 .ok()
1967 .and_then(|home| std::fs::read_to_string(home.join(".env")).ok())
1968 .map(|env| {
1969 env.lines()
1970 .filter_map(|l| {
1971 let l = l.trim();
1972 if l.is_empty() || l.starts_with('#') {
1973 return None;
1974 }
1975 let (key, val) = l.split_once('=')?;
1976 let name = key.strip_prefix("SERVICE_PORT_")?.to_lowercase();
1977 let port = val
1978 .trim_matches(|c: char| c == '"' || c == '\'')
1979 .parse::<u16>()
1980 .ok()?;
1981 Some((name, port))
1982 })
1983 .collect::<std::collections::BTreeMap<String, u16>>()
1984 })
1985 .unwrap_or_default();
1986
1987 Some(InstalledService {
1988 name: service_name.to_string(),
1989 version: "0.1.0".to_string(),
1990 repo: meta.registry,
1991 ports,
1992 auth_kind,
1993 exposure,
1994 provides: meta.provides,
1995 installed: true,
1996 })
1997}
1998
1999pub fn list_installed() -> Result<Vec<InstalledService>> {
2006 let mut names: std::collections::BTreeSet<String> = scan_managed_services()
2007 .unwrap_or_default()
2008 .into_iter()
2009 .collect();
2010 if let Ok(root) = service_data_root()
2014 && let Ok(entries) = std::fs::read_dir(&root)
2015 {
2016 for entry in entries.flatten() {
2017 if let Some(name) = entry.file_name().to_str()
2018 && !names.contains(name)
2019 && is_service_installed(name)
2020 {
2021 names.insert(name.to_string());
2022 }
2023 }
2024 }
2025 let out: Vec<InstalledService> = names
2026 .iter()
2027 .filter_map(|n| build_installed_from_metadata(n))
2028 .collect();
2029 Ok(out)
2030}
2031
2032pub fn search_services(repo_dir: &Path, query: Option<&str>) -> Result<Vec<SearchResult>> {
2034 let available = registry::list_available(repo_dir)?;
2035
2036 let results = available
2037 .into_iter()
2038 .filter(|reg_svc| match query {
2039 None => true,
2040 Some(q) => {
2041 let q = q.to_lowercase();
2042 reg_svc.def.service.name.to_lowercase().contains(&q)
2043 || reg_svc.def.service.description.to_lowercase().contains(&q)
2044 }
2045 })
2046 .map(|reg_svc| {
2047 let name = ®_svc.def.service.name;
2048 let installed = is_service_installed(name);
2049 let mut supports = Vec::new();
2050 for kind in ®_svc.def.integrations.auth {
2051 supports.push(kind.to_string());
2052 }
2053 if reg_svc.def.integrations.smtp {
2054 supports.push("smtp".to_string());
2055 }
2056 SearchResult {
2057 name: name.clone(),
2058 description: reg_svc.def.service.description,
2059 installed,
2060 supports,
2061 }
2062 })
2063 .collect();
2064
2065 Ok(results)
2066}
2067
2068pub struct SearchResult {
2069 pub name: String,
2070 pub description: String,
2071 pub installed: bool,
2072 pub supports: Vec<String>,
2074}
2075
2076pub async fn service_tests(service_name: &str) -> Result<ServiceTestInfo> {
2078 let installed = build_installed_from_metadata(service_name)
2079 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
2080
2081 let service_ref = service_ref_from_installed(&installed);
2082 let repo_dir = resolve_registry_dir(&service_ref).await?;
2083
2084 let test_toml_path = repo_dir.join(service_name).join("test.toml");
2085 let env_file = service_home(service_name)?.join(".env");
2086
2087 if !test_toml_path.exists() {
2088 return Ok(ServiceTestInfo {
2089 service_name: service_name.to_string(),
2090 registry_name: service_ref.registry_name().to_string(),
2091 tests: vec![],
2092 env_file,
2093 });
2094 }
2095
2096 let content = std::fs::read_to_string(&test_toml_path).map_err(|source| Error::FileRead {
2097 path: test_toml_path.clone(),
2098 source,
2099 })?;
2100
2101 #[derive(serde::Deserialize)]
2102 struct TestFile {
2103 #[serde(default)]
2104 tests: Vec<registry::test_def::TestDef>,
2105 }
2106
2107 let parsed: TestFile = toml::from_str(&content).map_err(|source| Error::TomlParse {
2108 path: test_toml_path,
2109 source,
2110 })?;
2111
2112 Ok(ServiceTestInfo {
2113 service_name: service_name.to_string(),
2114 registry_name: service_ref.registry_name().to_string(),
2115 tests: parsed.tests,
2116 env_file,
2117 })
2118}
2119
2120pub struct ServiceTestInfo {
2121 pub service_name: String,
2122 pub registry_name: String,
2123 pub tests: Vec<registry::test_def::TestDef>,
2124 pub env_file: PathBuf,
2125}
2126
2127pub fn service_info(repo_dir: &Path, service_name: &str) -> Result<ServiceDetail> {
2129 let reg_service = registry::find_service(repo_dir, service_name)?;
2130 let def = ®_service.def;
2131
2132 Ok(ServiceDetail {
2133 name: def.service.name.clone(),
2134 description: def.service.description.clone(),
2135 url: def.service.url.clone(),
2136 ports: def
2137 .ports
2138 .iter()
2139 .map(|p| (p.container_port, p.protocol.clone(), p.name.clone()))
2140 .collect(),
2141 env_vars: def
2142 .env
2143 .iter()
2144 .map(|e| (e.name.clone(), e.prompt.clone()))
2145 .collect(),
2146 })
2147}
2148
2149pub struct ServiceDetail {
2150 pub name: String,
2151 pub description: String,
2152 pub url: Option<String>,
2153 pub ports: Vec<(u16, registry::service_def::PortProtocol, String)>,
2154 pub env_vars: Vec<(String, Option<String>)>,
2155}
2156
2157#[cfg(test)]
2158mod tests {
2159 use super::*;
2160
2161 #[test]
2162 fn static_template_filter_excludes_secrets_and_credentials() {
2163 assert!(is_static_template("3306"));
2165 assert!(is_static_template("mariadb"));
2166 assert!(is_static_template("{{service.port}}"));
2168 assert!(is_static_template("{{service.url}}"));
2169 assert!(is_static_template("{{auth.url}}"));
2170 assert!(is_static_template("{{auth.issuer}}"));
2171 assert!(is_static_template("{{auth.provider}}"));
2172 assert!(is_static_template("{{auth.internal_url}}"));
2173 assert!(is_static_template("{{smtp.host}}"));
2174 assert!(is_static_template("{{smtp.port}}"));
2175 assert!(is_static_template("{{smtp.from}}"));
2176 assert!(is_static_template("{{service.url}}/oauth/callback"));
2178
2179 assert!(!is_static_template("{{secret.admin_password}}"));
2181 assert!(!is_static_template("{{secret.jwt_key}}"));
2182 assert!(!is_static_template("{{auth.client_id}}"));
2184 assert!(!is_static_template("{{auth.client_secret}}"));
2185 assert!(!is_static_template("{{smtp.username}}"));
2187 assert!(!is_static_template("{{smtp.password}}"));
2188 assert!(!is_static_template(
2190 "redis://:{{secret.redis_pw}}@host:6379"
2191 ));
2192 }
2193
2194 #[test]
2195 fn tailscale_url_matches() {
2196 assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net"));
2197 assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net:10001/"));
2198 assert!(is_tailscale_url("https://foo.example-net.ts.net"));
2199 assert!(is_tailscale_url("http://HOST.COBBLER-TUNA.TS.NET"));
2200 }
2201
2202 #[test]
2203 fn tailscale_url_rejects() {
2204 assert!(!is_tailscale_url("https://nextcloud.internal:8443"));
2205 assert!(!is_tailscale_url("https://example.com"));
2206 assert!(!is_tailscale_url("http://127.0.0.1:10001"));
2207 assert!(!is_tailscale_url("https://ts.net"));
2209 assert!(!is_tailscale_url("https://evil-ts.net.example.com"));
2210 assert!(!is_tailscale_url("not a url"));
2211 }
2212
2213 #[test]
2214 fn public_url_accepts_public_domains() {
2215 assert!(is_public_url("https://seafile.ryra.no"));
2216 assert!(is_public_url("https://example.com"));
2217 assert!(is_public_url("https://docs.ryra.no:8443"));
2218 }
2219
2220 #[test]
2221 fn public_url_rejects_lan_and_tailnet() {
2222 assert!(!is_public_url("https://nextcloud.internal:8443"));
2223 assert!(!is_public_url("https://service.localhost"));
2224 assert!(!is_public_url("https://something.local"));
2225 assert!(!is_public_url("https://localhost:8080"));
2226 assert!(!is_public_url("https://debian.cobbler-tuna.ts.net"));
2227 assert!(!is_public_url("http://127.0.0.1:10001"));
2228 assert!(!is_public_url("http://192.168.1.10"));
2229 assert!(!is_public_url("http://[::1]"));
2230 assert!(!is_public_url("not a url"));
2231 }
2232
2233 #[test]
2238 fn networks_empty_when_no_auth() {
2239 let nets = resolve_extra_networks("whoami", false, false, false, false, false, false);
2240 assert!(nets.is_empty());
2241 }
2242
2243 #[test]
2244 fn networks_empty_when_auth_but_no_authelia() {
2245 let nets = resolve_extra_networks("forgejo", true, false, false, false, false, false);
2246 assert!(nets.is_empty());
2247 }
2248
2249 #[test]
2250 fn networks_authelia_when_auth_enabled() {
2251 let nets = resolve_extra_networks("forgejo", true, true, false, false, false, false);
2252 assert_eq!(nets, vec!["authelia"]);
2253 }
2254
2255 #[test]
2256 fn networks_auth_with_caddy_includes_both() {
2257 let nets = resolve_extra_networks("forgejo", true, true, true, false, false, false);
2258 assert!(nets.contains(&"authelia".to_string()));
2259 assert!(nets.contains(&"caddy".to_string()));
2260 }
2261
2262 #[test]
2263 fn networks_authelia_excluded_for_authelia_itself() {
2264 let nets = resolve_extra_networks("authelia", true, true, false, false, false, false);
2265 assert!(nets.is_empty());
2266 }
2267
2268 #[test]
2269 fn networks_smtp_joins_inbucket_without_caddy() {
2270 let nets = resolve_extra_networks("forgejo", false, false, false, true, false, true);
2272 assert_eq!(nets, vec!["inbucket"]);
2273 }
2274
2275 #[test]
2276 fn networks_smtp_skips_inbucket_when_it_is_self() {
2277 let nets = resolve_extra_networks("inbucket", false, false, false, true, false, true);
2278 assert!(!nets.contains(&"inbucket".to_string()));
2279 }
2280
2281 #[test]
2282 fn networks_smtp_skips_inbucket_when_not_installed() {
2283 let nets = resolve_extra_networks("forgejo", false, false, false, false, false, true);
2284 assert!(!nets.contains(&"inbucket".to_string()));
2285 }
2286
2287 #[test]
2288 fn quadlet_belongs_to_exact_match() {
2289 let all = &["foo", "foo-bar"];
2290 assert!(quadlet_belongs_to("foo.container", "foo", all));
2291 assert!(quadlet_belongs_to("foo.network", "foo", all));
2292 }
2293
2294 #[test]
2295 fn quadlet_belongs_to_sidecar() {
2296 let all = &["foo"];
2298 assert!(quadlet_belongs_to("foo-db.volume", "foo", all));
2299 }
2300
2301 #[test]
2302 fn quadlet_belongs_to_rejects_prefix_collision() {
2303 let all = &["foo", "foo-bar"];
2304 assert!(!quadlet_belongs_to("foo-bar.container", "foo", all));
2305 assert!(!quadlet_belongs_to("foo-bar-db.volume", "foo", all));
2306 }
2307
2308 #[test]
2309 fn quadlet_belongs_to_hyphenated_service() {
2310 let all = &["foo", "foo-bar"];
2311 assert!(quadlet_belongs_to("foo-bar.container", "foo-bar", all));
2312 assert!(quadlet_belongs_to("foo-bar-db.volume", "foo-bar", all));
2313 assert!(!quadlet_belongs_to("foo.container", "foo-bar", all));
2314 }
2315}