use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::capability::Capability;
use crate::registry::service_def::AuthKind;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing)]
pub host: HostConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub admin_email: Option<String>,
pub smtp: Option<SmtpCredentials>,
pub auth: Option<AuthCredentials>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tailscale: Option<TailscaleConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub registries: Vec<RegistryEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backup: Option<BackupSettings>,
}
impl Config {
pub fn has_secrets(&self) -> bool {
self.smtp.is_some() || self.tailscale.is_some() || self.backup.is_some()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupSettings {
pub password: String,
pub backend: BackupBackend,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum BackupBackend {
S3 {
endpoint: String,
bucket: String,
access_key_id: String,
secret_access_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
prefix: Option<String>,
},
Local { path: std::path::PathBuf },
}
impl BackupBackend {
pub fn restic_repo(&self) -> String {
match self {
BackupBackend::S3 {
endpoint,
bucket,
prefix,
..
} => {
let stripped = endpoint
.trim_end_matches('/')
.trim_start_matches("http://")
.trim_start_matches("https://");
let scheme = if endpoint.starts_with("http://") {
"http://"
} else {
"https://"
};
let base = format!("s3:{scheme}{stripped}/{bucket}");
match prefix.as_deref().map(|p| p.trim_matches('/')) {
Some(p) if !p.is_empty() => format!("{base}/{p}"),
_ => base,
}
}
BackupBackend::Local { path } => path.display().to_string(),
}
}
pub fn env(&self) -> Vec<(&'static str, String)> {
match self {
BackupBackend::S3 {
access_key_id,
secret_access_key,
..
} => vec![
("AWS_ACCESS_KEY_ID", access_key_id.clone()),
("AWS_SECRET_ACCESS_KEY", secret_access_key.clone()),
],
BackupBackend::Local { .. } => vec![],
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HostConfig {
#[serde(default)]
pub domain: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SmtpCredentials {
pub host: String,
pub port: u16,
pub username: String,
pub password: String,
pub from: String,
#[serde(default)]
pub security: SmtpSecurity,
}
pub const INBUCKET_SMTP_PORT: u16 = 2500;
impl SmtpCredentials {
pub fn inbucket() -> Self {
Self {
host: "inbucket".to_string(),
port: INBUCKET_SMTP_PORT,
username: String::new(),
password: String::new(),
from: "noreply@example.com".to_string(),
security: SmtpSecurity::Off,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SmtpSecurity {
#[default]
Starttls,
ForceTls,
Off,
}
impl SmtpSecurity {
pub fn as_str(&self) -> &'static str {
match self {
SmtpSecurity::Starttls => "starttls",
SmtpSecurity::ForceTls => "force_tls",
SmtpSecurity::Off => "off",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "provider", rename_all = "lowercase")]
pub enum AuthCredentials {
Authelia { url: String, port: u16 },
External { url: String },
}
impl AuthCredentials {
pub fn url(&self) -> &str {
match self {
AuthCredentials::Authelia { url, .. } => url,
AuthCredentials::External { url } => url,
}
}
pub fn provider_name(&self) -> &str {
match self {
AuthCredentials::Authelia { .. } => "authelia",
AuthCredentials::External { .. } => "external",
}
}
pub fn port(&self) -> Option<u16> {
match self {
AuthCredentials::Authelia { port, .. } => Some(*port),
AuthCredentials::External { .. } => None,
}
}
}
pub const CADDY_LOCAL_DOMAIN: &str = "internal";
pub const HOST_TAG: &str = "tag:ryra-host";
pub const SERVICE_TAG: &str = "tag:ryra-service";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TailscaleConfig {
pub admin_api_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tailnet: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistryEntry {
pub name: String,
pub url: String,
}
#[derive(Debug, Clone)]
pub struct InstalledService {
pub name: String,
pub version: String,
pub repo: String,
pub ports: BTreeMap<String, u16>,
pub auth_kind: Option<AuthKind>,
pub exposure: crate::Exposure,
pub provides: Vec<Capability>,
pub installed: bool,
}
impl Config {
pub fn validate(&self) -> Result<(), String> {
let _ = self;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tailscale_config_round_trip() {
let cfg = Config {
tailscale: Some(TailscaleConfig {
admin_api_key: "tskey-api-XXXX".into(),
tailnet: Some("cobbler-tuna.ts.net".into()),
}),
..Config::default()
};
let serialized = toml::to_string(&cfg).unwrap();
assert!(serialized.contains("[tailscale]"));
assert!(serialized.contains("admin_api_key = \"tskey-api-XXXX\""));
assert!(serialized.contains("tailnet = \"cobbler-tuna.ts.net\""));
let parsed: Config = toml::from_str(&serialized).unwrap();
let ts = parsed.tailscale.expect("[tailscale] should round-trip");
assert_eq!(ts.admin_api_key, "tskey-api-XXXX");
assert_eq!(ts.tailnet.as_deref(), Some("cobbler-tuna.ts.net"));
}
#[test]
fn tailscale_config_tailnet_optional() {
let cfg = Config {
tailscale: Some(TailscaleConfig {
admin_api_key: "tskey-api-YYY".into(),
tailnet: None,
}),
..Config::default()
};
let s = toml::to_string(&cfg).unwrap();
assert!(!s.contains("tailnet"));
}
#[test]
fn backup_s3_repo_string_is_restic_compatible() {
let backend = BackupBackend::S3 {
endpoint: "http://127.0.0.1:9000".into(),
bucket: "ryra-backups".into(),
access_key_id: "minio".into(),
secret_access_key: "minio123".into(),
prefix: None,
};
assert_eq!(
backend.restic_repo(),
"s3:http://127.0.0.1:9000/ryra-backups"
);
}
#[test]
fn backup_s3_repo_with_prefix() {
let backend = BackupBackend::S3 {
endpoint: "https://s3.eu-west-1.amazonaws.com".into(),
bucket: "shared-bucket".into(),
access_key_id: "k".into(),
secret_access_key: "s".into(),
prefix: Some("hosts/laptop".into()),
};
assert_eq!(
backend.restic_repo(),
"s3:https://s3.eu-west-1.amazonaws.com/shared-bucket/hosts/laptop"
);
}
#[test]
fn backup_s3_trims_trailing_endpoint_slashes() {
let backend = BackupBackend::S3 {
endpoint: "http://127.0.0.1:9000/".into(),
bucket: "b".into(),
access_key_id: "k".into(),
secret_access_key: "s".into(),
prefix: None,
};
assert_eq!(backend.restic_repo(), "s3:http://127.0.0.1:9000/b");
}
#[test]
fn backup_local_repo_is_path_string() {
let backend = BackupBackend::Local {
path: "/tmp/ryra-test-repo".into(),
};
assert_eq!(backend.restic_repo(), "/tmp/ryra-test-repo");
}
#[test]
fn backup_s3_env_carries_aws_credentials() {
let backend = BackupBackend::S3 {
endpoint: "http://127.0.0.1:9000".into(),
bucket: "b".into(),
access_key_id: "the_id".into(),
secret_access_key: "the_secret".into(),
prefix: None,
};
let env: std::collections::HashMap<_, _> = backend.env().into_iter().collect();
assert_eq!(env.get("AWS_ACCESS_KEY_ID"), Some(&"the_id".to_string()));
assert_eq!(
env.get("AWS_SECRET_ACCESS_KEY"),
Some(&"the_secret".to_string())
);
}
#[test]
fn backup_local_env_is_empty() {
let backend = BackupBackend::Local {
path: "/tmp/x".into(),
};
assert!(backend.env().is_empty());
}
#[test]
fn backup_settings_round_trip() {
let cfg = Config {
backup: Some(BackupSettings {
password: "the-key".into(),
backend: BackupBackend::S3 {
endpoint: "http://127.0.0.1:9000".into(),
bucket: "ryra".into(),
access_key_id: "minio".into(),
secret_access_key: "minio123".into(),
prefix: None,
},
}),
..Config::default()
};
let text = toml::to_string(&cfg).unwrap();
assert!(text.contains("[backup]"), "expected [backup] table: {text}");
assert!(text.contains("password = \"the-key\""), "{text}");
assert!(text.contains("kind = \"s3\""), "{text}");
let parsed: Config = toml::from_str(&text).unwrap();
let b = parsed.backup.expect("backup round-trips");
assert_eq!(b.password, "the-key");
match b.backend {
BackupBackend::S3 { bucket, .. } => assert_eq!(bucket, "ryra"),
other => panic!("unexpected backend: {other:?}"),
}
}
#[test]
fn backup_settings_counted_in_has_secrets() {
let cfg = Config {
backup: Some(BackupSettings {
password: "x".into(),
backend: BackupBackend::Local {
path: "/tmp/r".into(),
},
}),
..Config::default()
};
assert!(cfg.has_secrets());
}
}