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#[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}