Skip to main content

ryra_core/config/
schema.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::capability::Capability;
6use crate::registry::service_def::AuthKind;
7
8/// Top-level preferences.toml configuration.
9#[derive(Debug, Clone, Default, Serialize, Deserialize)]
10pub struct Config {
11    /// Ryra version that last wrote this config. Written on every save,
12    /// checked on load to reject configs from newer versions.
13    #[serde(default, skip_serializing_if = "Option::is_none")]
14    pub version: Option<String>,
15    /// Legacy — reads old configs with [host], never written back.
16    #[serde(default, skip_serializing)]
17    pub host: HostConfig,
18    /// Admin email used as the default for services that need an admin account.
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub admin_email: Option<String>,
21    pub smtp: Option<SmtpCredentials>,
22    pub auth: Option<AuthCredentials>,
23    /// Tailscale auth credential + cached tailnet metadata. Set on first
24    /// `--tailscale` install; reused for every subsequent service so the
25    /// user only ever pastes their key once.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub tailscale: Option<TailscaleConfig>,
28    #[serde(default, skip_serializing_if = "Vec::is_empty")]
29    pub registries: Vec<RegistryEntry>,
30    /// Backup repository + encryption password. Set by
31    /// `ryra backup config`; consumed by every `ryra backup run`,
32    /// `ryra backup restore`, and `ryra backup list` invocation.
33    /// `None` means the user hasn't configured backups yet — every
34    /// backup command refuses with [`Error::BackupRepoNotConfigured`].
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub backup: Option<BackupSettings>,
37}
38
39impl Config {
40    /// True iff this config carries credentials/tokens that must be
41    /// protected from casual disclosure: SMTP user/password, Tailscale
42    /// admin API token, and anything similar added in the future.
43    /// Callers use this to fire a one-time warning the first time
44    /// preferences.toml acquires sensitive content.
45    pub fn has_secrets(&self) -> bool {
46        self.smtp.is_some() || self.tailscale.is_some() || self.backup.is_some()
47    }
48}
49
50// --- Backup ---
51
52/// Top-level backup repository configuration. Persisted in
53/// preferences.toml under `[backup]`. Storing the password here (vs.
54/// requiring it on every invocation) is the only ergonomic way to run
55/// `ryra backup run` from a systemd timer — but the file is already
56/// 0600 and contains comparably-sensitive SMTP and Tailscale tokens,
57/// so the threat model doesn't change.
58///
59/// Losing this password = losing access to every snapshot. Surfaced
60/// once by `ryra backup config` with a print-and-confirm step.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct BackupSettings {
63    /// The restic encryption password. Forms the only key that can
64    /// decrypt the repo's content.
65    pub password: String,
66    /// Storage backend the snapshots are pushed to. Typed enum
67    /// (instead of a raw restic URL string + opaque env map) so
68    /// invalid combinations of credentials are unrepresentable and
69    /// the CLI can prompt for the right fields per backend.
70    pub backend: BackupBackend,
71}
72
73/// Storage backend for the backup repository. The variants map to
74/// restic's supported backends; each carries exactly the fields restic
75/// needs to authenticate, no more.
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
77#[serde(tag = "kind", rename_all = "lowercase")]
78pub enum BackupBackend {
79    /// Any S3-compatible object store: MinIO, AWS S3, Backblaze B2 via
80    /// S3 API, Cloudflare R2, Wasabi. The `endpoint` is the full URL
81    /// to the API (e.g. `http://127.0.0.1:9000` for a local MinIO,
82    /// `https://s3.us-east-1.amazonaws.com` for AWS).
83    S3 {
84        endpoint: String,
85        bucket: String,
86        access_key_id: String,
87        secret_access_key: String,
88        /// Short-lived STS-style session token, set only for vended (managed)
89        /// credentials. `None` for static S3 keys.
90        #[serde(default, skip_serializing_if = "Option::is_none")]
91        session_token: Option<String>,
92        /// Optional path prefix inside the bucket. Lets one bucket
93        /// host multiple ryra installs (one per host or per user) by
94        /// scoping each to a sub-prefix.
95        #[serde(default, skip_serializing_if = "Option::is_none")]
96        prefix: Option<String>,
97    },
98    /// A local filesystem path. Primarily a testing affordance — point
99    /// at a tempdir and round-trip backup/restore without spinning up
100    /// MinIO. Production users should prefer the S3 variant pointed at
101    /// off-machine storage; a "local" backup gives no protection from
102    /// disk failure.
103    Local { path: std::path::PathBuf },
104    /// Ryra-managed backups. Carries no credentials: the box authenticates with
105    /// its account key and vends short-lived, prefix-scoped storage credentials
106    /// at backup time, resolving this to `S3` before restic runs. The restic
107    /// password stays client-side (zero-knowledge).
108    Managed,
109}
110
111impl BackupBackend {
112    /// The `--repo` argument passed to the restic binary. restic uses
113    /// a single colon-prefixed string to identify the backend ("s3:",
114    /// "rest:", a raw path for local). This builder centralises the
115    /// formatting so callers never hand-construct it.
116    pub fn restic_repo(&self) -> String {
117        match self {
118            BackupBackend::S3 {
119                endpoint,
120                bucket,
121                prefix,
122                ..
123            } => {
124                let stripped = endpoint
125                    .trim_end_matches('/')
126                    .trim_start_matches("http://")
127                    .trim_start_matches("https://");
128                // Keep the scheme: restic distinguishes
129                // s3:http://… (plain HTTP) from s3:https://….
130                let scheme = if endpoint.starts_with("http://") {
131                    "http://"
132                } else {
133                    "https://"
134                };
135                let base = format!("s3:{scheme}{stripped}/{bucket}");
136                match prefix.as_deref().map(|p| p.trim_matches('/')) {
137                    Some(p) if !p.is_empty() => format!("{base}/{p}"),
138                    _ => base,
139                }
140            }
141            BackupBackend::Local { path } => path.display().to_string(),
142            BackupBackend::Managed => {
143                unreachable!("managed backend must be resolved to S3 before restic runs")
144            }
145        }
146    }
147
148    /// Environment variables restic needs to authenticate to this
149    /// backend. Returned as a vec of `(key, value)` pairs so the
150    /// caller can decide whether to set them on a `Command` or via
151    /// `std::env::set_var` (the former is preferred — keeps the
152    /// process env clean and per-invocation).
153    pub fn env(&self) -> Vec<(&'static str, String)> {
154        match self {
155            BackupBackend::S3 {
156                access_key_id,
157                secret_access_key,
158                session_token,
159                ..
160            } => {
161                let mut env = vec![
162                    ("AWS_ACCESS_KEY_ID", access_key_id.clone()),
163                    ("AWS_SECRET_ACCESS_KEY", secret_access_key.clone()),
164                ];
165                if let Some(token) = session_token {
166                    env.push(("AWS_SESSION_TOKEN", token.clone()));
167                }
168                env
169            }
170            BackupBackend::Local { .. } => vec![],
171            BackupBackend::Managed => {
172                unreachable!("managed backend must be resolved to S3 before restic runs")
173            }
174        }
175    }
176}
177
178#[derive(Debug, Clone, Default, Serialize, Deserialize)]
179pub struct HostConfig {
180    #[serde(default)]
181    pub domain: Option<String>,
182}
183
184// --- SMTP ---
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct SmtpCredentials {
188    pub host: String,
189    pub port: u16,
190    pub username: String,
191    pub password: String,
192    pub from: String,
193    #[serde(default)]
194    pub security: SmtpSecurity,
195}
196
197/// Inbucket's internal SMTP container port. Services on the shared podman
198/// network reach inbucket by container name, so this (not the host-side
199/// `PublishPort=`) is what goes into `config.smtp`.
200pub const INBUCKET_SMTP_PORT: u16 = 2500;
201
202impl SmtpCredentials {
203    /// SMTP settings for a ryra-managed inbucket install: target the
204    /// container by name on the shared podman network, no auth, no TLS.
205    /// (The host port isn't reachable from `--no-hosts` containers.)
206    pub fn inbucket() -> Self {
207        Self {
208            host: "inbucket".to_string(),
209            port: INBUCKET_SMTP_PORT,
210            username: String::new(),
211            password: String::new(),
212            from: "noreply@example.com".to_string(),
213            security: SmtpSecurity::Off,
214        }
215    }
216}
217
218/// SMTP transport security mode.
219#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
220#[serde(rename_all = "snake_case")]
221pub enum SmtpSecurity {
222    #[default]
223    Starttls,
224    ForceTls,
225    Off,
226}
227
228impl SmtpSecurity {
229    pub fn as_str(&self) -> &'static str {
230        match self {
231            SmtpSecurity::Starttls => "starttls",
232            SmtpSecurity::ForceTls => "force_tls",
233            SmtpSecurity::Off => "off",
234        }
235    }
236}
237
238// --- Auth ---
239
240#[derive(Debug, Clone, Serialize, Deserialize)]
241#[serde(tag = "provider", rename_all = "lowercase")]
242pub enum AuthCredentials {
243    /// Managed Authelia instance installed via ryra.
244    Authelia { url: String, port: u16 },
245    /// External OIDC provider managed by the user.
246    External { url: String },
247}
248
249impl AuthCredentials {
250    pub fn url(&self) -> &str {
251        match self {
252            AuthCredentials::Authelia { url, .. } => url,
253            AuthCredentials::External { url } => url,
254        }
255    }
256
257    pub fn provider_name(&self) -> &str {
258        match self {
259            AuthCredentials::Authelia { .. } => "authelia",
260            AuthCredentials::External { .. } => "external",
261        }
262    }
263
264    pub fn port(&self) -> Option<u16> {
265        match self {
266            AuthCredentials::Authelia { port, .. } => Some(*port),
267            AuthCredentials::External { .. } => None,
268        }
269    }
270}
271
272// --- Caddy local domain ---
273
274/// Hardcoded Caddy domain. Caddy in ryra exists for local HTTPS during
275/// development and OIDC testing — services are reachable at
276/// `<service>.internal:<caddy_https_port>` from the host. There's no
277/// global "TLS provider" config; the URL on each `InstalledService`
278/// is the source of truth for how that service is reached, and ryra
279/// inspects URL hostnames (`*.internal` → Caddy local) when behavior
280/// has to dispatch on it (auth bridge, /etc/hosts writes).
281pub const CADDY_LOCAL_DOMAIN: &str = "internal";
282
283// --- Tailscale ---
284
285/// Tag ryra applies to the host advertising services. Required by
286/// Tailscale Services (service hosts must be tagged), declared in the
287/// tailnet ACL by `ensure_setup`. Single per-tailnet tag — every ryra
288/// host shares it.
289pub const HOST_TAG: &str = "tag:ryra-host";
290
291/// Tag ryra applies to defined services. Used by autoApprovers in the
292/// ACL so every ryra-defined service auto-approves its host without
293/// manual admin clicks.
294pub const SERVICE_TAG: &str = "tag:ryra-service";
295
296/// Admin API token + cached tailnet metadata for Tailscale Services.
297/// Stored in preferences.toml under `[tailscale]` so the user pastes the
298/// admin token once and every subsequent `--tailscale` install reuses
299/// it for service definition + ACL setup. Same file mode (0600) as
300/// SMTP/auth credentials.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct TailscaleConfig {
303    /// Admin API token (`tskey-api-…`). Used to manage Tailscale
304    /// Services: define services, update ACL with auto-approval, tag
305    /// the host. Stored locally because every `--tailscale` install
306    /// (and every `--tailscale` removal) calls the API.
307    pub admin_api_key: String,
308    /// Cached tailnet suffix (e.g. `cobbler-tuna.ts.net`). Resolved
309    /// lazily from `tailscale status --json` and remembered so we don't
310    /// re-shell out on every install.
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub tailnet: Option<String>,
313}
314
315// --- Registry entry ---
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct RegistryEntry {
319    pub name: String,
320    pub url: String,
321}
322
323// --- Installed service record ---
324
325/// In-memory view of a single installed service. Reconstructed by
326/// `ryra_core::list_installed()` from the quadlet directory's
327/// `# Service-*` headers + the per-service `.env` file. No longer
328/// persisted to `preferences.toml` — the on-disk artifacts are the
329/// source of truth.
330#[derive(Debug, Clone)]
331pub struct InstalledService {
332    pub name: String,
333    pub version: String,
334    pub repo: String,
335    /// All allocated host ports by name (e.g., "http" → 8080, "tcp" → 5432).
336    pub ports: BTreeMap<String, u16>,
337    /// The auth kind the user chose when installing this service, if any.
338    pub auth_kind: Option<AuthKind>,
339    /// How this service is reachable.
340    pub exposure: crate::Exposure,
341    /// Capabilities this service provides — the persisted snapshot of
342    /// `service.toml`'s `[capabilities] provides` taken at install time.
343    /// Empty for services whose service.toml didn't declare any (i.e.
344    /// most application services, all of which are pure consumers).
345    pub provides: Vec<Capability>,
346    /// Whether the service was fully installed. Always `true` when
347    /// reconstructed from the quadlet scan (a marker'd `.container`
348    /// only exists for completed installs).
349    pub installed: bool,
350}
351
352impl Config {
353    /// Validate structural invariants after deserialization.
354    pub fn validate(&self) -> Result<(), String> {
355        // Future invariants land here. Per-service uniqueness is no
356        // longer a Config concern: the source of truth for installed
357        // services is the quadlet directory, where each service has a
358        // single `.container` by definition.
359        let _ = self;
360        Ok(())
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn tailscale_config_round_trip() {
370        let cfg = Config {
371            tailscale: Some(TailscaleConfig {
372                admin_api_key: "tskey-api-XXXX".into(),
373                tailnet: Some("cobbler-tuna.ts.net".into()),
374            }),
375            ..Config::default()
376        };
377        let serialized = toml::to_string(&cfg).unwrap();
378        assert!(serialized.contains("[tailscale]"));
379        assert!(serialized.contains("admin_api_key = \"tskey-api-XXXX\""));
380        assert!(serialized.contains("tailnet = \"cobbler-tuna.ts.net\""));
381        let parsed: Config = toml::from_str(&serialized).unwrap();
382        let ts = parsed.tailscale.expect("[tailscale] should round-trip");
383        assert_eq!(ts.admin_api_key, "tskey-api-XXXX");
384        assert_eq!(ts.tailnet.as_deref(), Some("cobbler-tuna.ts.net"));
385    }
386
387    #[test]
388    fn tailscale_config_tailnet_optional() {
389        // Cached tailnet should be skipped on serialize when None — the
390        // first install resolves it lazily and writes it back; serialize
391        // shouldn't emit `tailnet = ""` for fresh configs.
392        let cfg = Config {
393            tailscale: Some(TailscaleConfig {
394                admin_api_key: "tskey-api-YYY".into(),
395                tailnet: None,
396            }),
397            ..Config::default()
398        };
399        let s = toml::to_string(&cfg).unwrap();
400        assert!(!s.contains("tailnet"));
401    }
402
403    #[test]
404    fn backup_s3_repo_string_is_restic_compatible() {
405        let backend = BackupBackend::S3 {
406            endpoint: "http://127.0.0.1:9000".into(),
407            bucket: "ryra-backups".into(),
408            access_key_id: "minio".into(),
409            secret_access_key: "minio123".into(),
410            session_token: None,
411            prefix: None,
412        };
413        assert_eq!(
414            backend.restic_repo(),
415            "s3:http://127.0.0.1:9000/ryra-backups"
416        );
417    }
418
419    #[test]
420    fn backup_s3_repo_with_prefix() {
421        let backend = BackupBackend::S3 {
422            endpoint: "https://s3.eu-west-1.amazonaws.com".into(),
423            bucket: "shared-bucket".into(),
424            access_key_id: "k".into(),
425            secret_access_key: "s".into(),
426            session_token: None,
427            prefix: Some("hosts/laptop".into()),
428        };
429        assert_eq!(
430            backend.restic_repo(),
431            "s3:https://s3.eu-west-1.amazonaws.com/shared-bucket/hosts/laptop"
432        );
433    }
434
435    #[test]
436    fn backup_s3_trims_trailing_endpoint_slashes() {
437        // Sloppy user input shouldn't double-slash the resulting URL —
438        // restic accepts both but the canonical form is cleaner.
439        let backend = BackupBackend::S3 {
440            endpoint: "http://127.0.0.1:9000/".into(),
441            bucket: "b".into(),
442            access_key_id: "k".into(),
443            secret_access_key: "s".into(),
444            session_token: None,
445            prefix: None,
446        };
447        assert_eq!(backend.restic_repo(), "s3:http://127.0.0.1:9000/b");
448    }
449
450    #[test]
451    fn backup_local_repo_is_path_string() {
452        let backend = BackupBackend::Local {
453            path: "/tmp/ryra-test-repo".into(),
454        };
455        assert_eq!(backend.restic_repo(), "/tmp/ryra-test-repo");
456    }
457
458    #[test]
459    fn backup_s3_env_carries_aws_credentials() {
460        let backend = BackupBackend::S3 {
461            endpoint: "http://127.0.0.1:9000".into(),
462            bucket: "b".into(),
463            access_key_id: "the_id".into(),
464            secret_access_key: "the_secret".into(),
465            session_token: None,
466            prefix: None,
467        };
468        let env: std::collections::HashMap<_, _> = backend.env().into_iter().collect();
469        assert_eq!(env.get("AWS_ACCESS_KEY_ID"), Some(&"the_id".to_string()));
470        assert_eq!(
471            env.get("AWS_SECRET_ACCESS_KEY"),
472            Some(&"the_secret".to_string())
473        );
474    }
475
476    #[test]
477    fn backup_local_env_is_empty() {
478        let backend = BackupBackend::Local {
479            path: "/tmp/x".into(),
480        };
481        assert!(backend.env().is_empty());
482    }
483
484    #[test]
485    fn backup_settings_round_trip() {
486        let cfg = Config {
487            backup: Some(BackupSettings {
488                password: "the-key".into(),
489                backend: BackupBackend::S3 {
490                    endpoint: "http://127.0.0.1:9000".into(),
491                    bucket: "ryra".into(),
492                    access_key_id: "minio".into(),
493                    secret_access_key: "minio123".into(),
494                    session_token: None,
495                    prefix: None,
496                },
497            }),
498            ..Config::default()
499        };
500        let text = toml::to_string(&cfg).unwrap();
501        assert!(text.contains("[backup]"), "expected [backup] table: {text}");
502        assert!(text.contains("password = \"the-key\""), "{text}");
503        assert!(text.contains("kind = \"s3\""), "{text}");
504        let parsed: Config = toml::from_str(&text).unwrap();
505        let b = parsed.backup.expect("backup round-trips");
506        assert_eq!(b.password, "the-key");
507        match b.backend {
508            BackupBackend::S3 { bucket, .. } => assert_eq!(bucket, "ryra"),
509            other => panic!("unexpected backend: {other:?}"),
510        }
511    }
512
513    #[test]
514    fn backup_settings_counted_in_has_secrets() {
515        // Triggers the "first time secrets are saved" warning the same
516        // way SMTP / Tailscale do.
517        let cfg = Config {
518            backup: Some(BackupSettings {
519                password: "x".into(),
520                backend: BackupBackend::Local {
521                    path: "/tmp/r".into(),
522                },
523            }),
524            ..Config::default()
525        };
526        assert!(cfg.has_secrets());
527    }
528}