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 configure`; 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 configure` 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 /// Optional path prefix inside the bucket. Lets one bucket
89 /// host multiple ryra installs (one per host or per user) by
90 /// scoping each to a sub-prefix.
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 prefix: Option<String>,
93 },
94 /// A local filesystem path. Primarily a testing affordance — point
95 /// at a tempdir and round-trip backup/restore without spinning up
96 /// MinIO. Production users should prefer the S3 variant pointed at
97 /// off-machine storage; a "local" backup gives no protection from
98 /// disk failure.
99 Local { path: std::path::PathBuf },
100}
101
102impl BackupBackend {
103 /// The `--repo` argument passed to the restic binary. restic uses
104 /// a single colon-prefixed string to identify the backend ("s3:",
105 /// "rest:", a raw path for local). This builder centralises the
106 /// formatting so callers never hand-construct it.
107 pub fn restic_repo(&self) -> String {
108 match self {
109 BackupBackend::S3 {
110 endpoint,
111 bucket,
112 prefix,
113 ..
114 } => {
115 let stripped = endpoint
116 .trim_end_matches('/')
117 .trim_start_matches("http://")
118 .trim_start_matches("https://");
119 // Keep the scheme: restic distinguishes
120 // s3:http://… (plain HTTP) from s3:https://….
121 let scheme = if endpoint.starts_with("http://") {
122 "http://"
123 } else {
124 "https://"
125 };
126 let base = format!("s3:{scheme}{stripped}/{bucket}");
127 match prefix.as_deref().map(|p| p.trim_matches('/')) {
128 Some(p) if !p.is_empty() => format!("{base}/{p}"),
129 _ => base,
130 }
131 }
132 BackupBackend::Local { path } => path.display().to_string(),
133 }
134 }
135
136 /// Environment variables restic needs to authenticate to this
137 /// backend. Returned as a vec of `(key, value)` pairs so the
138 /// caller can decide whether to set them on a `Command` or via
139 /// `std::env::set_var` (the former is preferred — keeps the
140 /// process env clean and per-invocation).
141 pub fn env(&self) -> Vec<(&'static str, String)> {
142 match self {
143 BackupBackend::S3 {
144 access_key_id,
145 secret_access_key,
146 ..
147 } => vec![
148 ("AWS_ACCESS_KEY_ID", access_key_id.clone()),
149 ("AWS_SECRET_ACCESS_KEY", secret_access_key.clone()),
150 ],
151 BackupBackend::Local { .. } => vec![],
152 }
153 }
154}
155
156#[derive(Debug, Clone, Default, Serialize, Deserialize)]
157pub struct HostConfig {
158 #[serde(default)]
159 pub domain: Option<String>,
160}
161
162// --- SMTP ---
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct SmtpCredentials {
166 pub host: String,
167 pub port: u16,
168 pub username: String,
169 pub password: String,
170 pub from: String,
171 #[serde(default)]
172 pub security: SmtpSecurity,
173}
174
175/// Inbucket's internal SMTP container port. Services on the shared podman
176/// network reach inbucket by container name, so this (not the host-side
177/// `PublishPort=`) is what goes into `config.smtp`.
178pub const INBUCKET_SMTP_PORT: u16 = 2500;
179
180impl SmtpCredentials {
181 /// SMTP settings for a ryra-managed inbucket install: target the
182 /// container by name on the shared podman network, no auth, no TLS.
183 /// (The host port isn't reachable from `--no-hosts` containers.)
184 pub fn inbucket() -> Self {
185 Self {
186 host: "inbucket".to_string(),
187 port: INBUCKET_SMTP_PORT,
188 username: String::new(),
189 password: String::new(),
190 from: "noreply@example.com".to_string(),
191 security: SmtpSecurity::Off,
192 }
193 }
194}
195
196/// SMTP transport security mode.
197#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
198#[serde(rename_all = "snake_case")]
199pub enum SmtpSecurity {
200 #[default]
201 Starttls,
202 ForceTls,
203 Off,
204}
205
206impl SmtpSecurity {
207 pub fn as_str(&self) -> &'static str {
208 match self {
209 SmtpSecurity::Starttls => "starttls",
210 SmtpSecurity::ForceTls => "force_tls",
211 SmtpSecurity::Off => "off",
212 }
213 }
214}
215
216// --- Auth ---
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
219#[serde(tag = "provider", rename_all = "lowercase")]
220pub enum AuthCredentials {
221 /// Managed Authelia instance installed via ryra.
222 Authelia { url: String, port: u16 },
223 /// External OIDC provider managed by the user.
224 External { url: String },
225}
226
227impl AuthCredentials {
228 pub fn url(&self) -> &str {
229 match self {
230 AuthCredentials::Authelia { url, .. } => url,
231 AuthCredentials::External { url } => url,
232 }
233 }
234
235 pub fn provider_name(&self) -> &str {
236 match self {
237 AuthCredentials::Authelia { .. } => "authelia",
238 AuthCredentials::External { .. } => "external",
239 }
240 }
241
242 pub fn port(&self) -> Option<u16> {
243 match self {
244 AuthCredentials::Authelia { port, .. } => Some(*port),
245 AuthCredentials::External { .. } => None,
246 }
247 }
248}
249
250// --- Caddy local domain ---
251
252/// Hardcoded Caddy domain. Caddy in ryra exists for local HTTPS during
253/// development and OIDC testing — services are reachable at
254/// `<service>.internal:<caddy_https_port>` from the host. There's no
255/// global "TLS provider" config; the URL on each `InstalledService`
256/// is the source of truth for how that service is reached, and ryra
257/// inspects URL hostnames (`*.internal` → Caddy local) when behavior
258/// has to dispatch on it (auth bridge, /etc/hosts writes).
259pub const CADDY_LOCAL_DOMAIN: &str = "internal";
260
261// --- Tailscale ---
262
263/// Tag ryra applies to the host advertising services. Required by
264/// Tailscale Services (service hosts must be tagged), declared in the
265/// tailnet ACL by `ensure_setup`. Single per-tailnet tag — every ryra
266/// host shares it.
267pub const HOST_TAG: &str = "tag:ryra-host";
268
269/// Tag ryra applies to defined services. Used by autoApprovers in the
270/// ACL so every ryra-defined service auto-approves its host without
271/// manual admin clicks.
272pub const SERVICE_TAG: &str = "tag:ryra-service";
273
274/// Admin API token + cached tailnet metadata for Tailscale Services.
275/// Stored in preferences.toml under `[tailscale]` so the user pastes the
276/// admin token once and every subsequent `--tailscale` install reuses
277/// it for service definition + ACL setup. Same file mode (0600) as
278/// SMTP/auth credentials.
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct TailscaleConfig {
281 /// Admin API token (`tskey-api-…`). Used to manage Tailscale
282 /// Services: define services, update ACL with auto-approval, tag
283 /// the host. Stored locally because every `--tailscale` install
284 /// (and every `--tailscale` removal) calls the API.
285 pub admin_api_key: String,
286 /// Cached tailnet suffix (e.g. `cobbler-tuna.ts.net`). Resolved
287 /// lazily from `tailscale status --json` and remembered so we don't
288 /// re-shell out on every install.
289 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub tailnet: Option<String>,
291}
292
293// --- Registry entry ---
294
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct RegistryEntry {
297 pub name: String,
298 pub url: String,
299}
300
301// --- Installed service record ---
302
303/// In-memory view of a single installed service. Reconstructed by
304/// `ryra_core::list_installed()` from the quadlet directory's
305/// `# Service-*` headers + the per-service `.env` file. No longer
306/// persisted to `preferences.toml` — the on-disk artifacts are the
307/// source of truth.
308#[derive(Debug, Clone)]
309pub struct InstalledService {
310 pub name: String,
311 pub version: String,
312 pub repo: String,
313 /// All allocated host ports by name (e.g., "http" → 8080, "tcp" → 5432).
314 pub ports: BTreeMap<String, u16>,
315 /// The auth kind the user chose when installing this service, if any.
316 pub auth_kind: Option<AuthKind>,
317 /// How this service is reachable.
318 pub exposure: crate::Exposure,
319 /// Capabilities this service provides — the persisted snapshot of
320 /// `service.toml`'s `[capabilities] provides` taken at install time.
321 /// Empty for services whose service.toml didn't declare any (i.e.
322 /// most application services, all of which are pure consumers).
323 pub provides: Vec<Capability>,
324 /// Whether the service was fully installed. Always `true` when
325 /// reconstructed from the quadlet scan (a marker'd `.container`
326 /// only exists for completed installs).
327 pub installed: bool,
328}
329
330impl Config {
331 /// Validate structural invariants after deserialization.
332 pub fn validate(&self) -> Result<(), String> {
333 // Future invariants land here. Per-service uniqueness is no
334 // longer a Config concern: the source of truth for installed
335 // services is the quadlet directory, where each service has a
336 // single `.container` by definition.
337 let _ = self;
338 Ok(())
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 #[test]
347 fn tailscale_config_round_trip() {
348 let cfg = Config {
349 tailscale: Some(TailscaleConfig {
350 admin_api_key: "tskey-api-XXXX".into(),
351 tailnet: Some("cobbler-tuna.ts.net".into()),
352 }),
353 ..Config::default()
354 };
355 let serialized = toml::to_string(&cfg).unwrap();
356 assert!(serialized.contains("[tailscale]"));
357 assert!(serialized.contains("admin_api_key = \"tskey-api-XXXX\""));
358 assert!(serialized.contains("tailnet = \"cobbler-tuna.ts.net\""));
359 let parsed: Config = toml::from_str(&serialized).unwrap();
360 let ts = parsed.tailscale.expect("[tailscale] should round-trip");
361 assert_eq!(ts.admin_api_key, "tskey-api-XXXX");
362 assert_eq!(ts.tailnet.as_deref(), Some("cobbler-tuna.ts.net"));
363 }
364
365 #[test]
366 fn tailscale_config_tailnet_optional() {
367 // Cached tailnet should be skipped on serialize when None — the
368 // first install resolves it lazily and writes it back; serialize
369 // shouldn't emit `tailnet = ""` for fresh configs.
370 let cfg = Config {
371 tailscale: Some(TailscaleConfig {
372 admin_api_key: "tskey-api-YYY".into(),
373 tailnet: None,
374 }),
375 ..Config::default()
376 };
377 let s = toml::to_string(&cfg).unwrap();
378 assert!(!s.contains("tailnet"));
379 }
380
381 #[test]
382 fn backup_s3_repo_string_is_restic_compatible() {
383 let backend = BackupBackend::S3 {
384 endpoint: "http://127.0.0.1:9000".into(),
385 bucket: "ryra-backups".into(),
386 access_key_id: "minio".into(),
387 secret_access_key: "minio123".into(),
388 prefix: None,
389 };
390 assert_eq!(
391 backend.restic_repo(),
392 "s3:http://127.0.0.1:9000/ryra-backups"
393 );
394 }
395
396 #[test]
397 fn backup_s3_repo_with_prefix() {
398 let backend = BackupBackend::S3 {
399 endpoint: "https://s3.eu-west-1.amazonaws.com".into(),
400 bucket: "shared-bucket".into(),
401 access_key_id: "k".into(),
402 secret_access_key: "s".into(),
403 prefix: Some("hosts/laptop".into()),
404 };
405 assert_eq!(
406 backend.restic_repo(),
407 "s3:https://s3.eu-west-1.amazonaws.com/shared-bucket/hosts/laptop"
408 );
409 }
410
411 #[test]
412 fn backup_s3_trims_trailing_endpoint_slashes() {
413 // Sloppy user input shouldn't double-slash the resulting URL —
414 // restic accepts both but the canonical form is cleaner.
415 let backend = BackupBackend::S3 {
416 endpoint: "http://127.0.0.1:9000/".into(),
417 bucket: "b".into(),
418 access_key_id: "k".into(),
419 secret_access_key: "s".into(),
420 prefix: None,
421 };
422 assert_eq!(backend.restic_repo(), "s3:http://127.0.0.1:9000/b");
423 }
424
425 #[test]
426 fn backup_local_repo_is_path_string() {
427 let backend = BackupBackend::Local {
428 path: "/tmp/ryra-test-repo".into(),
429 };
430 assert_eq!(backend.restic_repo(), "/tmp/ryra-test-repo");
431 }
432
433 #[test]
434 fn backup_s3_env_carries_aws_credentials() {
435 let backend = BackupBackend::S3 {
436 endpoint: "http://127.0.0.1:9000".into(),
437 bucket: "b".into(),
438 access_key_id: "the_id".into(),
439 secret_access_key: "the_secret".into(),
440 prefix: None,
441 };
442 let env: std::collections::HashMap<_, _> = backend.env().into_iter().collect();
443 assert_eq!(env.get("AWS_ACCESS_KEY_ID"), Some(&"the_id".to_string()));
444 assert_eq!(
445 env.get("AWS_SECRET_ACCESS_KEY"),
446 Some(&"the_secret".to_string())
447 );
448 }
449
450 #[test]
451 fn backup_local_env_is_empty() {
452 let backend = BackupBackend::Local {
453 path: "/tmp/x".into(),
454 };
455 assert!(backend.env().is_empty());
456 }
457
458 #[test]
459 fn backup_settings_round_trip() {
460 let cfg = Config {
461 backup: Some(BackupSettings {
462 password: "the-key".into(),
463 backend: BackupBackend::S3 {
464 endpoint: "http://127.0.0.1:9000".into(),
465 bucket: "ryra".into(),
466 access_key_id: "minio".into(),
467 secret_access_key: "minio123".into(),
468 prefix: None,
469 },
470 }),
471 ..Config::default()
472 };
473 let text = toml::to_string(&cfg).unwrap();
474 assert!(text.contains("[backup]"), "expected [backup] table: {text}");
475 assert!(text.contains("password = \"the-key\""), "{text}");
476 assert!(text.contains("kind = \"s3\""), "{text}");
477 let parsed: Config = toml::from_str(&text).unwrap();
478 let b = parsed.backup.expect("backup round-trips");
479 assert_eq!(b.password, "the-key");
480 match b.backend {
481 BackupBackend::S3 { bucket, .. } => assert_eq!(bucket, "ryra"),
482 other => panic!("unexpected backend: {other:?}"),
483 }
484 }
485
486 #[test]
487 fn backup_settings_counted_in_has_secrets() {
488 // Triggers the "first time secrets are saved" warning the same
489 // way SMTP / Tailscale do.
490 let cfg = Config {
491 backup: Some(BackupSettings {
492 password: "x".into(),
493 backend: BackupBackend::Local {
494 path: "/tmp/r".into(),
495 },
496 }),
497 ..Config::default()
498 };
499 assert!(cfg.has_secrets());
500 }
501}