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