diff --git a/src/config.rs b/src/config.rs index 83ea0461..94b7fb6c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -20,6 +20,7 @@ pub mod acme; pub mod cached_user_info; pub mod datastore; pub mod network; +pub mod node; pub mod remote; pub mod sync; pub mod tfa; diff --git a/src/config/node.rs b/src/config/node.rs new file mode 100644 index 00000000..7ea85e2d --- /dev/null +++ b/src/config/node.rs @@ -0,0 +1,202 @@ +use std::collections::HashSet; +use std::fs::File; +use std::time::Duration; + +use anyhow::{bail, format_err, Error}; +use nix::sys::stat::Mode; +use serde::{Deserialize, Serialize}; + +use proxmox::api::api; +use proxmox::api::schema::{self, Updater}; +use proxmox::tools::fs::{replace_file, CreateOptions}; + +use crate::acme::AcmeClient; +use crate::config::acme::{AccountName, AcmeDomain}; + +const CONF_FILE: &str = configdir!("/node.cfg"); +const LOCK_FILE: &str = configdir!("/.node.cfg.lck"); +const LOCK_TIMEOUT: Duration = Duration::from_secs(10); + +pub fn lock() -> Result { + proxmox::tools::fs::open_file_locked(LOCK_FILE, LOCK_TIMEOUT, true) +} + +/// Read the Node Config. +pub fn config() -> Result<(NodeConfig, [u8; 32]), Error> { + let content = + proxmox::tools::fs::file_read_optional_string(CONF_FILE)?.unwrap_or_else(|| "".to_string()); + + let digest = openssl::sha::sha256(content.as_bytes()); + let data: NodeConfig = crate::tools::config::from_str(&content, &NodeConfig::API_SCHEMA)?; + + Ok((data, digest)) +} + +/// Write the Node Config, requires the write lock to be held. +pub fn save_config(config: &NodeConfig) -> Result<(), Error> { + config.validate()?; + + let raw = crate::tools::config::to_bytes(config, &NodeConfig::API_SCHEMA)?; + + let backup_user = crate::backup::backup_user()?; + let options = CreateOptions::new() + .perm(Mode::from_bits_truncate(0o0640)) + .owner(nix::unistd::ROOT) + .group(backup_user.gid); + + replace_file(CONF_FILE, &raw, options) +} + +#[api( + properties: { + account: { type: AccountName }, + } +)] +#[derive(Deserialize, Serialize)] +/// The ACME configuration. +/// +/// Currently only contains the name of the account use. +pub struct AcmeConfig { + /// Account to use to acquire ACME certificates. + account: AccountName, +} + +#[api( + properties: { + acme: { + optional: true, + type: String, + format: &schema::ApiStringFormat::PropertyString(&AcmeConfig::API_SCHEMA), + }, + acmedomain0: { + type: String, + optional: true, + format: &schema::ApiStringFormat::PropertyString(&AcmeDomain::API_SCHEMA), + }, + acmedomain1: { + type: String, + optional: true, + format: &schema::ApiStringFormat::PropertyString(&AcmeDomain::API_SCHEMA), + }, + acmedomain2: { + type: String, + optional: true, + format: &schema::ApiStringFormat::PropertyString(&AcmeDomain::API_SCHEMA), + }, + acmedomain3: { + type: String, + optional: true, + format: &schema::ApiStringFormat::PropertyString(&AcmeDomain::API_SCHEMA), + }, + acmedomain4: { + type: String, + optional: true, + format: &schema::ApiStringFormat::PropertyString(&AcmeDomain::API_SCHEMA), + }, + }, +)] +#[derive(Deserialize, Serialize, Updater)] +/// Node specific configuration. +pub struct NodeConfig { + /// The acme account to use on this node. + #[serde(skip_serializing_if = "Updater::is_empty")] + acme: Option, + + /// ACME domain to get a certificate for for this node. + #[serde(skip_serializing_if = "Updater::is_empty")] + acmedomain0: Option, + + /// ACME domain to get a certificate for for this node. + #[serde(skip_serializing_if = "Updater::is_empty")] + acmedomain1: Option, + + /// ACME domain to get a certificate for for this node. + #[serde(skip_serializing_if = "Updater::is_empty")] + acmedomain2: Option, + + /// ACME domain to get a certificate for for this node. + #[serde(skip_serializing_if = "Updater::is_empty")] + acmedomain3: Option, + + /// ACME domain to get a certificate for for this node. + #[serde(skip_serializing_if = "Updater::is_empty")] + acmedomain4: Option, +} + +impl NodeConfig { + pub fn acme_config(&self) -> Option> { + self.acme.as_deref().map(|config| -> Result<_, Error> { + Ok(crate::tools::config::from_property_string( + config, + &AcmeConfig::API_SCHEMA, + )?) + }) + } + + pub async fn acme_client(&self) -> Result { + AcmeClient::load( + &self + .acme_config() + .ok_or_else(|| format_err!("no acme client configured"))?? + .account, + ) + .await + } + + pub fn acme_domains(&self) -> AcmeDomainIter { + AcmeDomainIter::new(self) + } + + /// Validate the configuration. + pub fn validate(&self) -> Result<(), Error> { + let mut domains = HashSet::new(); + for domain in self.acme_domains() { + let domain = domain?; + if !domains.insert(domain.domain.to_lowercase()) { + bail!("duplicate domain '{}' in ACME config", domain.domain); + } + } + + Ok(()) + } +} + +pub struct AcmeDomainIter<'a> { + config: &'a NodeConfig, + index: usize, +} + +impl<'a> AcmeDomainIter<'a> { + fn new(config: &'a NodeConfig) -> Self { + Self { config, index: 0 } + } +} + +impl<'a> Iterator for AcmeDomainIter<'a> { + type Item = Result; + + fn next(&mut self) -> Option { + let domain = loop { + let index = self.index; + self.index += 1; + + let domain = match index { + 0 => self.config.acmedomain0.as_deref(), + 1 => self.config.acmedomain1.as_deref(), + 2 => self.config.acmedomain2.as_deref(), + 3 => self.config.acmedomain3.as_deref(), + 4 => self.config.acmedomain4.as_deref(), + _ => return None, + }; + + if let Some(domain) = domain { + break domain; + } + }; + + Some(crate::tools::config::from_property_string( + domain, + &AcmeDomain::API_SCHEMA, + )) + } +}