Skip to main content

pbox_core/
config.rs

1use crate::VmidPattern;
2use reqwest::Url;
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4use std::collections::BTreeMap;
5use std::fmt;
6use std::fs::{self, OpenOptions};
7use std::io::Write;
8use std::path::{Path, PathBuf};
9use std::time::{Duration, SystemTime, UNIX_EPOCH};
10use thiserror::Error;
11
12#[derive(Clone, PartialEq, Eq)]
13pub struct Secret(String);
14
15impl Secret {
16    pub fn new(value: impl Into<String>) -> Self {
17        Self(value.into())
18    }
19
20    pub fn expose(&self) -> &str {
21        &self.0
22    }
23}
24
25impl fmt::Debug for Secret {
26    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
27        formatter.write_str("<redacted>")
28    }
29}
30
31impl Serialize for Secret {
32    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
33    where
34        S: Serializer,
35    {
36        serializer.serialize_str(&self.0)
37    }
38}
39
40impl<'de> Deserialize<'de> for Secret {
41    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
42    where
43        D: Deserializer<'de>,
44    {
45        Ok(Self(String::deserialize(deserializer)?))
46    }
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50#[serde(default)]
51pub struct PveDefaults {
52    pub cores: u64,
53    pub memory: u64,
54    pub swap: u64,
55    pub disk: String,
56    pub unprivileged: bool,
57    pub onboot: bool,
58}
59
60impl Default for PveDefaults {
61    fn default() -> Self {
62        Self {
63            cores: 2,
64            memory: 1024,
65            swap: 256,
66            disk: "8G".to_owned(),
67            unprivileged: true,
68            onboot: false,
69        }
70    }
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
74#[serde(default)]
75pub struct PveConfig {
76    pub url: Option<String>,
77    pub token_id: Option<String>,
78    pub token_secret: Option<Secret>,
79    pub tls_insecure: bool,
80    pub node: String,
81    pub storage: String,
82    pub template_storage: String,
83    pub bridge: String,
84    pub defaults: PveDefaults,
85}
86
87impl Default for PveConfig {
88    fn default() -> Self {
89        Self {
90            url: None,
91            token_id: None,
92            token_secret: None,
93            tls_insecure: false,
94            node: "auto".to_owned(),
95            storage: "auto".to_owned(),
96            template_storage: "local".to_owned(),
97            bridge: "vmbr0".to_owned(),
98            defaults: PveDefaults::default(),
99        }
100    }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
104#[serde(default)]
105pub struct AgentConfig {
106    pub binary: Option<PathBuf>,
107    pub port: u16,
108}
109
110impl Default for AgentConfig {
111    fn default() -> Self {
112        Self {
113            binary: None,
114            port: 7443,
115        }
116    }
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
120#[serde(default)]
121pub struct RecipeConfig {
122    pub repository: String,
123    #[serde(rename = "ref")]
124    pub reference: String,
125    pub auto_sync: bool,
126    pub sync_ttl: String,
127    pub snapshot_before_apply: String,
128    pub rollback_on_failure: bool,
129}
130
131impl Default for RecipeConfig {
132    fn default() -> Self {
133        Self {
134            repository: "https://github.com/kierandrewett/pbox-recipes.git".to_owned(),
135            reference: "main".to_owned(),
136            auto_sync: true,
137            sync_ttl: "15m".to_owned(),
138            snapshot_before_apply: "auto".to_owned(),
139            rollback_on_failure: false,
140        }
141    }
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
145#[serde(default)]
146pub struct ImageConfig {
147    pub default: String,
148}
149
150impl Default for ImageConfig {
151    fn default() -> Self {
152        Self {
153            default: "debian-13".to_owned(),
154        }
155    }
156}
157
158/// Relay settings are optional. Credentials are read from a separate owner-readable file.
159#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
160#[serde(default)]
161pub struct RelayConfig {
162    pub url: Option<String>,
163    pub key_file: Option<PathBuf>,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
167#[serde(default)]
168pub struct Config {
169    pub pve: PveConfig,
170    pub agent: AgentConfig,
171    pub relay: RelayConfig,
172    pub images: ImageConfig,
173    pub recipes: RecipeConfig,
174    pub vmid_pattern: VmidPattern,
175}
176
177impl Default for Config {
178    fn default() -> Self {
179        Self {
180            pve: PveConfig::default(),
181            agent: AgentConfig::default(),
182            relay: RelayConfig::default(),
183            images: ImageConfig::default(),
184            recipes: RecipeConfig::default(),
185            vmid_pattern: VmidPattern::parse("9xxx").expect("default VMID pattern is valid"),
186        }
187    }
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
191pub struct RedactedPveDefaults {
192    pub cores: u64,
193    pub memory: u64,
194    pub swap: u64,
195    pub disk: String,
196    pub unprivileged: bool,
197    pub onboot: bool,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
201pub struct RedactedPveConfig {
202    pub url: Option<String>,
203    pub token_id: Option<String>,
204    pub token_secret: Option<String>,
205    pub tls_insecure: bool,
206    pub node: String,
207    pub storage: String,
208    pub template_storage: String,
209    pub bridge: String,
210    pub defaults: RedactedPveDefaults,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
214pub struct RedactedAgentConfig {
215    pub binary: Option<PathBuf>,
216    pub port: u16,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
220pub struct RedactedImageConfig {
221    pub default: String,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
225pub struct RedactedRecipeConfig {
226    pub repository: String,
227    #[serde(rename = "ref")]
228    pub reference: String,
229    pub auto_sync: bool,
230    pub sync_ttl: String,
231    pub snapshot_before_apply: String,
232    pub rollback_on_failure: bool,
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
236pub struct RedactedConfig {
237    pub pve: RedactedPveConfig,
238    pub agent: RedactedAgentConfig,
239    pub relay: RelayConfig,
240    pub images: RedactedImageConfig,
241    pub recipes: RedactedRecipeConfig,
242    pub vmid_pattern: VmidPattern,
243}
244
245fn validate_relay_url(key: &str, value: &str) -> Result<(), ConfigError> {
246    let url = Url::parse(value).map_err(|_| ConfigError::InvalidValue {
247        key: key.to_owned(),
248        reason: "expected a relay HTTP(S) or WS(S) origin".to_owned(),
249    })?;
250    if !matches!(url.scheme(), "http" | "https" | "ws" | "wss")
251        || url.host_str().is_none()
252        || !url.username().is_empty()
253        || url.password().is_some()
254        || url.query().is_some()
255        || url.fragment().is_some()
256        || !matches!(url.path(), "" | "/")
257    {
258        return Err(ConfigError::InvalidValue {
259            key: key.to_owned(),
260            reason: "expected a relay origin without credentials, path, query, or fragment"
261                .to_owned(),
262        });
263    }
264    Ok(())
265}
266
267pub fn parse_duration(value: &str) -> Result<Duration, String> {
268    let trimmed = value.trim();
269    if trimmed.is_empty() {
270        return Err("duration cannot be empty".to_owned());
271    }
272    let (amount_text, multiplier) = match trimmed.chars().last() {
273        Some('s') => (&trimmed[..trimmed.len() - 1], 1_u64),
274        Some('m') => (&trimmed[..trimmed.len() - 1], 60_u64),
275        Some('h') => (&trimmed[..trimmed.len() - 1], 60_u64 * 60),
276        Some('d') => (&trimmed[..trimmed.len() - 1], 60_u64 * 60 * 24),
277        Some(character) if character.is_ascii_digit() => (trimmed, 1_u64),
278        _ => return Err("use seconds, minutes, hours, or days".to_owned()),
279    };
280    let amount = amount_text
281        .parse::<u64>()
282        .map_err(|_| "duration amount must be an integer".to_owned())?;
283    let seconds = amount
284        .checked_mul(multiplier)
285        .ok_or_else(|| "duration is too large".to_owned())?;
286    Ok(Duration::from_secs(seconds))
287}
288
289impl Config {
290    pub fn validate(&self) -> Result<(), ConfigError> {
291        if let Some(url) = &self.relay.url {
292            validate_relay_url("relay.url", url)?;
293        }
294        if let Some(path) = &self.relay.key_file {
295            validate_non_empty(
296                "relay.key-file",
297                &path.to_string_lossy(),
298                "relay key file path cannot be empty",
299            )?;
300        }
301        if let Some(url) = &self.pve.url {
302            validate_non_empty("pve.url", url, "URL cannot be empty")?;
303            validate_pve_url("pve.url", url)?;
304        }
305        if let Some(token_id) = &self.pve.token_id {
306            validate_non_empty("pve.token_id", token_id, "token id cannot be empty")?;
307        }
308        if let Some(token_secret) = &self.pve.token_secret
309            && token_secret.expose().is_empty()
310        {
311            return Err(ConfigError::InvalidValue {
312                key: "pve.token_secret".to_owned(),
313                reason: "token secret cannot be empty".to_owned(),
314            });
315        }
316        if let Some(binary) = &self.agent.binary
317            && binary.as_os_str().is_empty()
318        {
319            return Err(ConfigError::InvalidValue {
320                key: "agent.binary".to_owned(),
321                reason: "agent binary path cannot be empty".to_owned(),
322            });
323        }
324        if self.agent.port == 0 {
325            return Err(ConfigError::InvalidValue {
326                key: "agent.port".to_owned(),
327                reason: "expected a TCP port from 1 to 65535".to_owned(),
328            });
329        }
330        validate_non_empty("pve.node", &self.pve.node, "PVE node cannot be empty")?;
331        validate_non_empty(
332            "pve.storage",
333            &self.pve.storage,
334            "PVE storage cannot be empty",
335        )?;
336        validate_non_empty(
337            "pve.template-storage",
338            &self.pve.template_storage,
339            "PVE template storage cannot be empty",
340        )?;
341        validate_non_empty("pve.bridge", &self.pve.bridge, "PVE bridge cannot be empty")?;
342        validate_positive("pve.defaults.cores", self.pve.defaults.cores)?;
343        validate_positive("pve.defaults.memory", self.pve.defaults.memory)?;
344        validate_non_empty(
345            "pve.defaults.disk",
346            &self.pve.defaults.disk,
347            "default disk size cannot be empty",
348        )?;
349        validate_non_empty(
350            "images.default",
351            &self.images.default,
352            "default image cannot be empty",
353        )?;
354        validate_repository_reference("recipes.repository", &self.recipes.repository)?;
355        validate_non_empty(
356            "recipes.ref",
357            &self.recipes.reference,
358            "recipe reference cannot be empty",
359        )?;
360        parse_duration(&self.recipes.sync_ttl).map_err(|reason| ConfigError::InvalidValue {
361            key: "recipes.sync-ttl".to_owned(),
362            reason,
363        })?;
364        validate_snapshot_policy(
365            "recipes.snapshot-before-apply",
366            &self.recipes.snapshot_before_apply,
367        )?;
368        Ok(())
369    }
370
371    pub fn redacted(&self) -> RedactedConfig {
372        RedactedConfig {
373            pve: RedactedPveConfig {
374                url: redact_url(&self.pve.url),
375                token_id: self.pve.token_id.clone(),
376                token_secret: self
377                    .pve
378                    .token_secret
379                    .as_ref()
380                    .map(|_| "<redacted>".to_owned()),
381                tls_insecure: self.pve.tls_insecure,
382                node: self.pve.node.clone(),
383                storage: self.pve.storage.clone(),
384                template_storage: self.pve.template_storage.clone(),
385                bridge: self.pve.bridge.clone(),
386                defaults: RedactedPveDefaults {
387                    cores: self.pve.defaults.cores,
388                    memory: self.pve.defaults.memory,
389                    swap: self.pve.defaults.swap,
390                    disk: self.pve.defaults.disk.clone(),
391                    unprivileged: self.pve.defaults.unprivileged,
392                    onboot: self.pve.defaults.onboot,
393                },
394            },
395            relay: self.relay.clone(),
396            agent: RedactedAgentConfig {
397                binary: self.agent.binary.clone(),
398                port: self.agent.port,
399            },
400            images: RedactedImageConfig {
401                default: self.images.default.clone(),
402            },
403            recipes: RedactedRecipeConfig {
404                repository: self.recipes.repository.clone(),
405                reference: self.recipes.reference.clone(),
406                auto_sync: self.recipes.auto_sync,
407                sync_ttl: self.recipes.sync_ttl.clone(),
408                snapshot_before_apply: self.recipes.snapshot_before_apply.clone(),
409                rollback_on_failure: self.recipes.rollback_on_failure,
410            },
411            vmid_pattern: self.vmid_pattern.clone(),
412        }
413    }
414}
415
416impl Config {
417    pub fn redacted_pairs(&self) -> BTreeMap<String, String> {
418        let mut values = BTreeMap::new();
419        values.insert(
420            "pve.url".to_owned(),
421            optional_value(&redact_url(&self.pve.url)),
422        );
423        values.insert(
424            "pve.token_id".to_owned(),
425            optional_value(&self.pve.token_id),
426        );
427        values.insert(
428            "pve.token_secret".to_owned(),
429            if self.pve.token_secret.is_some() {
430                "<redacted>".to_owned()
431            } else {
432                "<unset>".to_owned()
433            },
434        );
435        values.insert(
436            "pve.tls_insecure".to_owned(),
437            self.pve.tls_insecure.to_string(),
438        );
439        values.insert("pve.node".to_owned(), self.pve.node.clone());
440        values.insert("pve.storage".to_owned(), self.pve.storage.clone());
441        values.insert(
442            "pve.template-storage".to_owned(),
443            self.pve.template_storage.clone(),
444        );
445        values.insert("pve.bridge".to_owned(), self.pve.bridge.clone());
446        values.insert(
447            "pve.defaults.cores".to_owned(),
448            self.pve.defaults.cores.to_string(),
449        );
450        values.insert(
451            "pve.defaults.memory".to_owned(),
452            self.pve.defaults.memory.to_string(),
453        );
454        values.insert(
455            "pve.defaults.swap".to_owned(),
456            self.pve.defaults.swap.to_string(),
457        );
458        values.insert(
459            "pve.defaults.disk".to_owned(),
460            self.pve.defaults.disk.clone(),
461        );
462        values.insert(
463            "pve.defaults.unprivileged".to_owned(),
464            self.pve.defaults.unprivileged.to_string(),
465        );
466        values.insert(
467            "pve.defaults.onboot".to_owned(),
468            self.pve.defaults.onboot.to_string(),
469        );
470        values.insert("images.default".to_owned(), self.images.default.clone());
471        values.insert(
472            "agent.binary".to_owned(),
473            self.agent
474                .binary
475                .as_ref()
476                .map(|path| path.display().to_string())
477                .unwrap_or_else(|| "<unset>".to_owned()),
478        );
479        values.insert("agent.port".to_owned(), self.agent.port.to_string());
480        values.insert(
481            "relay.url".to_owned(),
482            self.relay.url.clone().unwrap_or_default(),
483        );
484        values.insert(
485            "relay.key-file".to_owned(),
486            self.relay
487                .key_file
488                .as_ref()
489                .map(|p| p.display().to_string())
490                .unwrap_or_default(),
491        );
492        values.insert(
493            "recipes.repository".to_owned(),
494            self.recipes.repository.clone(),
495        );
496        values.insert("recipes.ref".to_owned(), self.recipes.reference.clone());
497        values.insert(
498            "recipes.auto-sync".to_owned(),
499            self.recipes.auto_sync.to_string(),
500        );
501        values.insert("recipes.sync-ttl".to_owned(), self.recipes.sync_ttl.clone());
502        values.insert(
503            "recipes.snapshot-before-apply".to_owned(),
504            self.recipes.snapshot_before_apply.clone(),
505        );
506        values.insert(
507            "recipes.rollback-on-failure".to_owned(),
508            self.recipes.rollback_on_failure.to_string(),
509        );
510        values.insert("pve.vmid-pattern".to_owned(), self.vmid_pattern.to_string());
511        values
512    }
513
514    pub fn get_redacted(&self, key: &str) -> Option<String> {
515        self.redacted_pairs().remove(key)
516    }
517
518    pub fn set_value(&mut self, key: &str, value: &str) -> Result<(), ConfigError> {
519        match key {
520            "pve.url" => {
521                validate_non_empty(key, value, "URL cannot be empty")?;
522                validate_pve_url(key, value)?;
523                self.pve.url = Some(value.trim().to_owned());
524            }
525            "pve.token_id" => {
526                validate_non_empty(key, value, "token id cannot be empty")?;
527                self.pve.token_id = Some(value.to_owned());
528            }
529            "pve.token_secret" => {
530                if value.is_empty() {
531                    return Err(ConfigError::InvalidValue {
532                        key: key.to_owned(),
533                        reason: "token secret cannot be empty".to_owned(),
534                    });
535                }
536                self.pve.token_secret = Some(Secret::new(value));
537            }
538            "pve.tls_insecure" => {
539                self.pve.tls_insecure = parse_bool(key, value)?;
540            }
541            "pve.node" => {
542                validate_non_empty(key, value, "PVE node cannot be empty")?;
543                self.pve.node = value.trim().to_owned();
544            }
545            "pve.storage" => {
546                validate_non_empty(key, value, "PVE storage cannot be empty")?;
547                self.pve.storage = value.trim().to_owned();
548            }
549            "pve.template-storage" => {
550                validate_non_empty(key, value, "PVE template storage cannot be empty")?;
551                self.pve.template_storage = value.trim().to_owned();
552            }
553            "pve.bridge" => {
554                validate_non_empty(key, value, "PVE bridge cannot be empty")?;
555                self.pve.bridge = value.trim().to_owned();
556            }
557            "pve.defaults.cores" => {
558                self.pve.defaults.cores = parse_positive(key, value)?;
559            }
560            "pve.defaults.memory" => {
561                self.pve.defaults.memory = parse_positive(key, value)?;
562            }
563            "pve.defaults.swap" => {
564                self.pve.defaults.swap = parse_unsigned(key, value)?;
565            }
566            "pve.defaults.disk" => {
567                validate_non_empty(key, value, "default disk size cannot be empty")?;
568                self.pve.defaults.disk = value.trim().to_owned();
569            }
570            "pve.defaults.unprivileged" => {
571                self.pve.defaults.unprivileged = parse_bool(key, value)?;
572            }
573            "pve.defaults.onboot" => {
574                self.pve.defaults.onboot = parse_bool(key, value)?;
575            }
576            "images.default" => {
577                validate_non_empty(key, value, "default image cannot be empty")?;
578                self.images.default = value.trim().to_owned();
579            }
580            "agent.binary" => {
581                if value.trim().is_empty() {
582                    return Err(ConfigError::InvalidValue {
583                        key: key.to_owned(),
584                        reason: "agent binary path cannot be empty".to_owned(),
585                    });
586                }
587                self.agent.binary = Some(PathBuf::from(value));
588            }
589            "relay.url" => {
590                validate_relay_url(key, value)?;
591                self.relay.url = Some(value.to_owned());
592            }
593            "relay.key-file" => self.relay.key_file = Some(PathBuf::from(value)),
594            "agent.port" => {
595                self.agent.port = parse_port(key, value)?;
596            }
597            "recipes.repository" => {
598                validate_repository_reference(key, value)?;
599                self.recipes.repository = value.to_owned();
600            }
601            "recipes.ref" => {
602                validate_non_empty(key, value, "recipe reference cannot be empty")?;
603                self.recipes.reference = value.to_owned();
604            }
605            "recipes.auto-sync" => {
606                self.recipes.auto_sync = parse_bool(key, value)?;
607            }
608            "recipes.sync-ttl" => {
609                parse_duration(value).map_err(|reason| ConfigError::InvalidValue {
610                    key: key.to_owned(),
611                    reason,
612                })?;
613                self.recipes.sync_ttl = value.to_owned();
614            }
615            "recipes.snapshot-before-apply" => {
616                validate_snapshot_policy(key, value)?;
617                self.recipes.snapshot_before_apply = value.to_owned();
618            }
619            "recipes.rollback-on-failure" => {
620                self.recipes.rollback_on_failure = parse_bool(key, value)?;
621            }
622            "pve.vmid-pattern" => {
623                self.vmid_pattern =
624                    value
625                        .parse()
626                        .map_err(|error: crate::VmidError| ConfigError::InvalidValue {
627                            key: key.to_owned(),
628                            reason: error.to_string(),
629                        })?;
630            }
631            _ => return Err(ConfigError::UnknownKey(key.to_owned())),
632        }
633        Ok(())
634    }
635
636    pub fn unset_value(&mut self, key: &str) -> Result<(), ConfigError> {
637        match key {
638            "pve.url" => self.pve.url = None,
639            "pve.token_id" => self.pve.token_id = None,
640            "pve.token_secret" => self.pve.token_secret = None,
641            "pve.tls_insecure" => self.pve.tls_insecure = false,
642            "pve.node" => self.pve.node = PveConfig::default().node,
643            "pve.storage" => self.pve.storage = PveConfig::default().storage,
644            "pve.template-storage" => {
645                self.pve.template_storage = PveConfig::default().template_storage
646            }
647            "pve.bridge" => self.pve.bridge = PveConfig::default().bridge,
648            "pve.defaults.cores" => self.pve.defaults.cores = PveDefaults::default().cores,
649            "pve.defaults.memory" => self.pve.defaults.memory = PveDefaults::default().memory,
650            "pve.defaults.swap" => self.pve.defaults.swap = PveDefaults::default().swap,
651            "pve.defaults.disk" => self.pve.defaults.disk = PveDefaults::default().disk,
652            "pve.defaults.unprivileged" => {
653                self.pve.defaults.unprivileged = PveDefaults::default().unprivileged
654            }
655            "pve.defaults.onboot" => self.pve.defaults.onboot = PveDefaults::default().onboot,
656            "images.default" => self.images.default = ImageConfig::default().default,
657            "agent.binary" => self.agent.binary = None,
658            "relay.url" => self.relay.url = None,
659            "relay.key-file" => self.relay.key_file = None,
660            "agent.port" => self.agent.port = AgentConfig::default().port,
661            "recipes.repository" => self.recipes.repository = RecipeConfig::default().repository,
662            "recipes.ref" => self.recipes.reference = RecipeConfig::default().reference,
663            "recipes.auto-sync" => self.recipes.auto_sync = RecipeConfig::default().auto_sync,
664            "recipes.sync-ttl" => self.recipes.sync_ttl = RecipeConfig::default().sync_ttl,
665            "recipes.snapshot-before-apply" => {
666                self.recipes.snapshot_before_apply = RecipeConfig::default().snapshot_before_apply
667            }
668            "recipes.rollback-on-failure" => {
669                self.recipes.rollback_on_failure = RecipeConfig::default().rollback_on_failure
670            }
671            "pve.vmid-pattern" => self.vmid_pattern = Config::default().vmid_pattern,
672            _ => return Err(ConfigError::UnknownKey(key.to_owned())),
673        }
674        Ok(())
675    }
676
677    pub fn apply_overrides(&mut self, overrides: &ConfigOverrides) -> Result<(), ConfigError> {
678        if let Some(value) = &overrides.pve_url {
679            self.set_value("pve.url", value)?;
680        }
681        if let Some(value) = &overrides.pve_token_id {
682            self.set_value("pve.token_id", value)?;
683        }
684        if let Some(value) = &overrides.pve_token_secret {
685            self.set_value("pve.token_secret", value)?;
686        }
687        if let Some(value) = &overrides.pve_tls_insecure {
688            self.pve.tls_insecure = *value;
689        }
690        if let Some(value) = &overrides.agent_binary {
691            self.set_value("agent.binary", value)?;
692        }
693        if let Some(value) = &overrides.agent_port {
694            self.set_value("agent.port", value)?;
695        }
696        if let Some(value) = &overrides.recipes_repository {
697            self.set_value("recipes.repository", value)?;
698        }
699        if let Some(value) = &overrides.recipes_reference {
700            self.set_value("recipes.ref", value)?;
701        }
702        if let Some(value) = &overrides.recipes_auto_sync {
703            self.recipes.auto_sync = *value;
704        }
705        if let Some(value) = &overrides.recipes_sync_ttl {
706            self.set_value("recipes.sync-ttl", value)?;
707        }
708        if let Some(value) = &overrides.recipes_snapshot_before_apply {
709            self.set_value("recipes.snapshot-before-apply", value)?;
710        }
711        if let Some(value) = &overrides.recipes_rollback_on_failure {
712            self.recipes.rollback_on_failure = *value;
713        }
714        if let Some(value) = &overrides.vmid_pattern {
715            self.set_value("pve.vmid-pattern", value)?;
716        }
717        Ok(())
718    }
719}
720
721fn optional_value(value: &Option<String>) -> String {
722    value.clone().unwrap_or_else(|| "<unset>".to_owned())
723}
724fn redact_url(value: &Option<String>) -> Option<String> {
725    let value = value.as_ref()?;
726    let Ok(mut parsed) = Url::parse(value) else {
727        return Some("<invalid>".to_owned());
728    };
729    if !parsed.username().is_empty() {
730        let _ = parsed.set_username("");
731    }
732    if parsed.password().is_some() {
733        let _ = parsed.set_password(None);
734    }
735    if parsed.query().is_some() {
736        parsed.set_query(None);
737    }
738    if parsed.fragment().is_some() {
739        parsed.set_fragment(None);
740    }
741    Some(parsed.to_string())
742}
743
744fn validate_non_empty(key: &str, value: &str, reason: &str) -> Result<(), ConfigError> {
745    if value.trim().is_empty() {
746        return Err(ConfigError::InvalidValue {
747            key: key.to_owned(),
748            reason: reason.to_owned(),
749        });
750    }
751    Ok(())
752}
753
754fn validate_pve_url(key: &str, value: &str) -> Result<(), ConfigError> {
755    if value != value.trim() {
756        return Err(ConfigError::InvalidValue {
757            key: key.to_owned(),
758            reason: "URL must not have surrounding whitespace".to_owned(),
759        });
760    }
761    let parsed = Url::parse(value.trim()).map_err(|_| ConfigError::InvalidValue {
762        key: key.to_owned(),
763        reason: "URL must be a valid HTTPS URL".to_owned(),
764    })?;
765    if parsed.scheme() != "https" || parsed.host_str().is_none() {
766        return Err(ConfigError::InvalidValue {
767            key: key.to_owned(),
768            reason: "URL must use HTTPS and include a host".to_owned(),
769        });
770    }
771    if !parsed.username().is_empty()
772        || parsed.password().is_some()
773        || parsed.query().is_some()
774        || parsed.fragment().is_some()
775    {
776        return Err(ConfigError::InvalidValue {
777            key: key.to_owned(),
778            reason: "URL must not contain credentials, query, or fragment data".to_owned(),
779        });
780    }
781    Ok(())
782}
783
784fn validate_snapshot_policy(key: &str, value: &str) -> Result<(), ConfigError> {
785    if matches!(value, "auto" | "always" | "never") {
786        return Ok(());
787    }
788    Err(ConfigError::InvalidValue {
789        key: key.to_owned(),
790        reason: "expected auto, always, or never".to_owned(),
791    })
792}
793
794fn parse_bool(key: &str, value: &str) -> Result<bool, ConfigError> {
795    value
796        .parse::<bool>()
797        .map_err(|_| ConfigError::InvalidValue {
798            key: key.to_owned(),
799            reason: "expected true or false".to_owned(),
800        })
801}
802
803fn parse_port(key: &str, value: &str) -> Result<u16, ConfigError> {
804    let port = value
805        .parse::<u16>()
806        .map_err(|_| ConfigError::InvalidValue {
807            key: key.to_owned(),
808            reason: "expected a TCP port from 1 to 65535".to_owned(),
809        })?;
810    if port == 0 {
811        return Err(ConfigError::InvalidValue {
812            key: key.to_owned(),
813            reason: "expected a TCP port from 1 to 65535".to_owned(),
814        });
815    }
816    Ok(port)
817}
818
819fn validate_positive(key: &str, value: u64) -> Result<(), ConfigError> {
820    if value == 0 {
821        return Err(ConfigError::InvalidValue {
822            key: key.to_owned(),
823            reason: "expected a value greater than zero".to_owned(),
824        });
825    }
826    Ok(())
827}
828
829fn parse_unsigned(key: &str, value: &str) -> Result<u64, ConfigError> {
830    value.parse::<u64>().map_err(|_| ConfigError::InvalidValue {
831        key: key.to_owned(),
832        reason: "expected a non-negative integer".to_owned(),
833    })
834}
835
836fn parse_positive(key: &str, value: &str) -> Result<u64, ConfigError> {
837    let parsed = parse_unsigned(key, value)?;
838    validate_positive(key, parsed)?;
839    Ok(parsed)
840}
841
842fn validate_repository_reference(key: &str, value: &str) -> Result<(), ConfigError> {
843    validate_non_empty(key, value, "recipe repository cannot be empty")?;
844    if value.contains('?') || value.contains('#') {
845        return Err(ConfigError::InvalidValue {
846            key: key.to_owned(),
847            reason: "recipe repository must not contain query or fragment data".to_owned(),
848        });
849    }
850    if value.contains("::") {
851        return Err(ConfigError::InvalidValue {
852            key: key.to_owned(),
853            reason: "recipe repository must not use Git external transport".to_owned(),
854        });
855    }
856    if let Some((scheme, authority_and_path)) = value.split_once("://") {
857        let scheme = scheme.to_ascii_lowercase();
858        if !matches!(scheme.as_str(), "https" | "ssh" | "file") {
859            return Err(ConfigError::InvalidValue {
860                key: key.to_owned(),
861                reason: "recipe repository URLs must use HTTPS, SSH, or file transport".to_owned(),
862            });
863        }
864        let authority = authority_and_path
865            .split(['/', '\\'])
866            .next()
867            .unwrap_or_default();
868        if authority.contains('@') {
869            return Err(ConfigError::InvalidValue {
870                key: key.to_owned(),
871                reason: "recipe repository URLs must not contain embedded credentials".to_owned(),
872            });
873        }
874        if scheme != "file" && authority.is_empty() {
875            return Err(ConfigError::InvalidValue {
876                key: key.to_owned(),
877                reason: "recipe repository URL must include a host".to_owned(),
878            });
879        }
880    }
881    Ok(())
882}
883
884#[derive(Debug, Clone, Default, PartialEq, Eq)]
885pub struct ConfigOverrides {
886    pub pve_url: Option<String>,
887    pub pve_token_id: Option<String>,
888    pub pve_token_secret: Option<String>,
889    pub pve_tls_insecure: Option<bool>,
890    pub agent_binary: Option<String>,
891    pub agent_port: Option<String>,
892    pub recipes_repository: Option<String>,
893    pub recipes_reference: Option<String>,
894    pub recipes_auto_sync: Option<bool>,
895    pub recipes_sync_ttl: Option<String>,
896    pub recipes_snapshot_before_apply: Option<String>,
897    pub recipes_rollback_on_failure: Option<bool>,
898    pub vmid_pattern: Option<String>,
899}
900
901#[derive(Debug, Error)]
902pub enum ConfigError {
903    #[error("could not read config file {path}: {source}")]
904    Read {
905        path: PathBuf,
906        source: std::io::Error,
907    },
908    #[error("could not write config file {path}: {source}")]
909    Write {
910        path: PathBuf,
911        source: std::io::Error,
912    },
913    #[error("invalid TOML in config file: {0}")]
914    Parse(#[from] toml::de::Error),
915    #[error("could not serialize config: {0}")]
916    Serialize(#[from] toml::ser::Error),
917    #[error("unknown config key: {0}")]
918    UnknownKey(String),
919    #[error("invalid value for {key}: {reason}")]
920    InvalidValue { key: String, reason: String },
921}
922
923pub fn default_config_path() -> PathBuf {
924    dirs::config_dir()
925        .unwrap_or_else(|| PathBuf::from("."))
926        .join("pbox")
927        .join("config.toml")
928}
929
930#[derive(Debug, Clone)]
931pub struct ConfigStore {
932    path: PathBuf,
933}
934
935impl ConfigStore {
936    pub fn new(path: impl Into<PathBuf>) -> Self {
937        Self { path: path.into() }
938    }
939
940    pub fn path(&self) -> &Path {
941        &self.path
942    }
943
944    pub fn load_file(&self) -> Result<Config, ConfigError> {
945        load_file(&self.path)
946    }
947
948    pub fn load(&self, overrides: &ConfigOverrides) -> Result<Config, ConfigError> {
949        let mut config = self.load_file()?;
950        apply_environment(&mut config)?;
951        config.apply_overrides(overrides)?;
952        config.validate()?;
953        Ok(config)
954    }
955
956    pub fn save(&self, config: &Config) -> Result<(), ConfigError> {
957        save_file(&self.path, config)
958    }
959}
960
961impl Default for ConfigStore {
962    fn default() -> Self {
963        Self::new(default_config_path())
964    }
965}
966
967pub fn load_file(path: &Path) -> Result<Config, ConfigError> {
968    match fs::read_to_string(path) {
969        Ok(contents) => {
970            let config: Config = toml::from_str(&contents)?;
971            config.validate()?;
972            Ok(config)
973        }
974        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()),
975        Err(source) => Err(ConfigError::Read {
976            path: path.to_owned(),
977            source,
978        }),
979    }
980}
981
982pub fn save_file(path: &Path, config: &Config) -> Result<(), ConfigError> {
983    config.validate()?;
984    if let Some(parent) = path.parent() {
985        fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
986            path: path.to_owned(),
987            source,
988        })?;
989    }
990    if let Ok(metadata) = fs::symlink_metadata(path)
991        && metadata.file_type().is_symlink()
992    {
993        return Err(ConfigError::Write {
994            path: path.to_owned(),
995            source: std::io::Error::new(
996                std::io::ErrorKind::InvalidInput,
997                "refusing to write through a symbolic link",
998            ),
999        });
1000    }
1001    let contents = toml::to_string_pretty(config)? + "\n";
1002    let file_name = path
1003        .file_name()
1004        .and_then(|name| name.to_str())
1005        .unwrap_or("config.toml");
1006    let stamp = SystemTime::now()
1007        .duration_since(UNIX_EPOCH)
1008        .unwrap_or_default()
1009        .as_nanos();
1010    let temporary = path.with_file_name(format!(".{file_name}.tmp-{}-{stamp}", std::process::id()));
1011    let result = (|| {
1012        let mut options = OpenOptions::new();
1013        options.write(true).create_new(true);
1014        #[cfg(unix)]
1015        {
1016            use std::os::unix::fs::OpenOptionsExt;
1017            options.mode(0o600);
1018        }
1019        let mut file = options
1020            .open(&temporary)
1021            .map_err(|source| ConfigError::Write {
1022                path: temporary.clone(),
1023                source,
1024            })?;
1025        file.write_all(contents.as_bytes())
1026            .and_then(|_| file.sync_all())
1027            .map_err(|source| ConfigError::Write {
1028                path: temporary.clone(),
1029                source,
1030            })?;
1031        fs::rename(&temporary, path).map_err(|source| ConfigError::Write {
1032            path: path.to_owned(),
1033            source,
1034        })?;
1035        Ok(())
1036    })();
1037    if result.is_err() {
1038        let _ = fs::remove_file(&temporary);
1039    }
1040    result
1041}
1042
1043fn apply_environment(config: &mut Config) -> Result<(), ConfigError> {
1044    let mut overrides = ConfigOverrides::default();
1045    for (key, value) in std::env::vars() {
1046        match key.as_str() {
1047            "PBOX_PVE_URL" => overrides.pve_url = Some(value),
1048            "PBOX_PVE_TOKEN_ID" => overrides.pve_token_id = Some(value),
1049            "PBOX_PVE_TOKEN_SECRET" => overrides.pve_token_secret = Some(value),
1050            "PBOX_PVE_TLS_INSECURE" => {
1051                overrides.pve_tls_insecure = Some(parse_bool("PBOX_PVE_TLS_INSECURE", &value)?)
1052            }
1053            "PBOX_AGENT_BINARY" => overrides.agent_binary = Some(value),
1054            "PBOX_AGENT_PORT" => overrides.agent_port = Some(value),
1055            "PBOX_RECIPES_REPOSITORY" => overrides.recipes_repository = Some(value),
1056            "PBOX_RECIPES_REF" => overrides.recipes_reference = Some(value),
1057            "PBOX_RECIPES_AUTO_SYNC" => {
1058                overrides.recipes_auto_sync = Some(parse_bool("PBOX_RECIPES_AUTO_SYNC", &value)?)
1059            }
1060            "PBOX_RECIPES_SYNC_TTL" => overrides.recipes_sync_ttl = Some(value),
1061            "PBOX_RECIPES_SNAPSHOT_BEFORE_APPLY" => {
1062                overrides.recipes_snapshot_before_apply = Some(value)
1063            }
1064            "PBOX_RECIPES_ROLLBACK_ON_FAILURE" => {
1065                overrides.recipes_rollback_on_failure =
1066                    Some(parse_bool("PBOX_RECIPES_ROLLBACK_ON_FAILURE", &value)?)
1067            }
1068            "PBOX_VMID_PATTERN" => overrides.vmid_pattern = Some(value),
1069            _ => {}
1070        }
1071    }
1072    config.apply_overrides(&overrides)
1073}
1074
1075#[cfg(test)]
1076mod tests {
1077    #[test]
1078    fn relay_config_rejects_url_credentials_and_round_trips_private_ip() {
1079        let mut config = super::Config::default();
1080        assert!(
1081            config
1082                .set_value("relay.url", "https://user:secret@example.com")
1083                .is_err()
1084        );
1085        config
1086            .set_value("relay.url", "http://100.64.1.2:8080")
1087            .unwrap();
1088        config
1089            .set_value("relay.key-file", "/tmp/relay.key")
1090            .unwrap();
1091        let encoded = toml::to_string(&config).unwrap();
1092        let decoded: super::Config = toml::from_str(&encoded).unwrap();
1093        decoded.validate().unwrap();
1094        assert_eq!(decoded, config);
1095        config.unset_value("relay.url").unwrap();
1096        assert!(config.relay.url.is_none());
1097    }
1098
1099    use super::*;
1100    use std::time::{SystemTime, UNIX_EPOCH};
1101
1102    fn temporary_path() -> PathBuf {
1103        let suffix = SystemTime::now()
1104            .duration_since(UNIX_EPOCH)
1105            .unwrap()
1106            .as_nanos();
1107        std::env::temp_dir().join(format!("pbox-config-test-{suffix}.toml"))
1108    }
1109
1110    #[test]
1111    fn defaults_are_safe_and_secrets_are_redacted() {
1112        let mut config = Config::default();
1113        config
1114            .set_value("pve.token_secret", "do-not-print")
1115            .unwrap();
1116        let json = serde_json::to_string(&config.redacted()).unwrap();
1117        assert!(!json.contains("do-not-print"));
1118        assert!(json.contains("<redacted>"));
1119    }
1120
1121    #[test]
1122    fn file_then_environment_then_cli_precedence_is_explicit() {
1123        let path = temporary_path();
1124        let mut file = Config::default();
1125        file.set_value("pve.url", "https://file.example").unwrap();
1126        save_file(&path, &file).unwrap();
1127        let mut resolved = load_file(&path).unwrap();
1128        resolved
1129            .apply_overrides(&ConfigOverrides {
1130                pve_url: Some("https://environment.example".to_owned()),
1131                ..ConfigOverrides::default()
1132            })
1133            .unwrap();
1134        resolved
1135            .apply_overrides(&ConfigOverrides {
1136                pve_url: Some("https://cli.example".to_owned()),
1137                ..ConfigOverrides::default()
1138            })
1139            .unwrap();
1140        assert_eq!(resolved.pve.url.as_deref(), Some("https://cli.example"));
1141        let _ = fs::remove_file(path);
1142    }
1143    #[test]
1144    fn url_and_repository_transports_reject_credential_leaks_and_plain_http() {
1145        let mut config = Config::default();
1146        assert!(
1147            config
1148                .set_value("pve.url", "https://user:secret@pve.example")
1149                .is_err()
1150        );
1151        assert!(
1152            config
1153                .set_value("recipes.repository", "http://example.test/recipes.git")
1154                .is_err()
1155        );
1156        assert!(
1157            config
1158                .set_value("recipes.repository", "ext::sh -c evil")
1159                .is_err()
1160        );
1161        config.pve.url = Some("https://user:secret@pve.example".to_owned());
1162        assert!(config.validate().is_err());
1163    }
1164
1165    #[cfg(unix)]
1166    #[test]
1167    fn save_rejects_symlinked_config_path() {
1168        use std::os::unix::fs::symlink;
1169
1170        let suffix = SystemTime::now()
1171            .duration_since(UNIX_EPOCH)
1172            .unwrap()
1173            .as_nanos();
1174        let target = std::env::temp_dir().join(format!("pbox-config-target-{suffix}.toml"));
1175        let link = std::env::temp_dir().join(format!("pbox-config-link-{suffix}.toml"));
1176        fs::write(&target, "original\n").unwrap();
1177        symlink(&target, &link).unwrap();
1178
1179        let result = save_file(&link, &Config::default());
1180
1181        assert!(result.is_err());
1182        assert_eq!(fs::read_to_string(&target).unwrap(), "original\n");
1183        fs::remove_file(&link).unwrap();
1184        fs::remove_file(&target).unwrap();
1185    }
1186
1187    #[test]
1188    fn duration_parser_accepts_units_and_rejects_invalid_values() {
1189        assert_eq!(parse_duration("15m").unwrap(), Duration::from_secs(900));
1190        assert_eq!(parse_duration("2h").unwrap(), Duration::from_secs(7_200));
1191        assert_eq!(parse_duration("3d").unwrap(), Duration::from_secs(259_200));
1192        assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
1193        assert!(parse_duration("").is_err());
1194        assert!(parse_duration("15x").is_err());
1195        assert!(parse_duration("xm").is_err());
1196    }
1197
1198    #[test]
1199    fn invalid_recipe_sync_ttl_is_rejected_when_setting_or_loading() {
1200        let mut config = Config::default();
1201        assert!(config.set_value("recipes.sync-ttl", "15x").is_err());
1202
1203        let path = temporary_path();
1204        fs::write(&path, "[recipes]\nsync_ttl = \"15x\"\n").unwrap();
1205        assert!(load_file(&path).is_err());
1206        fs::remove_file(path).unwrap();
1207    }
1208
1209    #[test]
1210    fn snapshot_policy_defaults_and_validation_are_explicit() {
1211        let mut config = Config::default();
1212        assert_eq!(config.recipes.snapshot_before_apply, "auto");
1213        assert!(!config.recipes.rollback_on_failure);
1214        config
1215            .set_value("recipes.snapshot-before-apply", "always")
1216            .unwrap();
1217        config
1218            .set_value("recipes.rollback-on-failure", "true")
1219            .unwrap();
1220        assert_eq!(config.recipes.snapshot_before_apply, "always");
1221        assert!(config.recipes.rollback_on_failure);
1222        assert!(
1223            config
1224                .set_value("recipes.snapshot-before-apply", "sometimes")
1225                .is_err()
1226        );
1227        config.unset_value("recipes.snapshot-before-apply").unwrap();
1228        config.unset_value("recipes.rollback-on-failure").unwrap();
1229        assert_eq!(config.recipes.snapshot_before_apply, "auto");
1230        assert!(!config.recipes.rollback_on_failure);
1231    }
1232    #[test]
1233    fn provisioning_defaults_are_configurable_and_listed() {
1234        let mut config = Config::default();
1235        assert_eq!(config.pve.node, "auto");
1236        assert_eq!(config.pve.storage, "auto");
1237        assert_eq!(config.pve.template_storage, "local");
1238        assert_eq!(config.pve.bridge, "vmbr0");
1239        assert_eq!(config.images.default, "debian-13");
1240        assert_eq!(config.pve.defaults.disk, "8G");
1241
1242        config.set_value("pve.node", "node-a").unwrap();
1243        config.set_value("pve.defaults.memory", "2048").unwrap();
1244        config
1245            .set_value("pve.defaults.unprivileged", "false")
1246            .unwrap();
1247        config.set_value("images.default", "ubuntu-24.04").unwrap();
1248
1249        assert_eq!(config.get_redacted("pve.node").as_deref(), Some("node-a"));
1250        assert_eq!(
1251            config.get_redacted("pve.defaults.memory").as_deref(),
1252            Some("2048")
1253        );
1254        assert_eq!(
1255            config.get_redacted("pve.defaults.unprivileged").as_deref(),
1256            Some("false")
1257        );
1258        assert_eq!(
1259            config.get_redacted("images.default").as_deref(),
1260            Some("ubuntu-24.04")
1261        );
1262
1263        config.unset_value("pve.node").unwrap();
1264        config.unset_value("images.default").unwrap();
1265        assert_eq!(config.pve.node, "auto");
1266        assert_eq!(config.images.default, "debian-13");
1267        assert!(config.set_value("pve.defaults.cores", "0").is_err());
1268    }
1269
1270    #[test]
1271    fn vmid_pattern_uses_the_public_pve_key() {
1272        let mut config = Config::default();
1273        config.set_value("pve.vmid-pattern", "95xx").unwrap();
1274        assert_eq!(config.vmid_pattern.to_string(), "95xx");
1275        assert_eq!(
1276            config.get_redacted("pve.vmid-pattern").as_deref(),
1277            Some("95xx"),
1278        );
1279        config.unset_value("pve.vmid-pattern").unwrap();
1280        assert_eq!(config.vmid_pattern.to_string(), "9xxx");
1281        assert!(config.set_value("vmid_pattern", "95xx").is_err());
1282    }
1283}