diff --git a/src/config.rs b/src/config.rs index 37df2fd2..83ea0461 100644 --- a/src/config.rs +++ b/src/config.rs @@ -16,6 +16,7 @@ use proxmox::try_block; use crate::buildcfg; pub mod acl; +pub mod acme; pub mod cached_user_info; pub mod datastore; pub mod network; diff --git a/src/config/acme/mod.rs b/src/config/acme/mod.rs new file mode 100644 index 00000000..5c018fa3 --- /dev/null +++ b/src/config/acme/mod.rs @@ -0,0 +1,273 @@ +use std::collections::HashMap; +use std::fmt; +use std::path::Path; + +use anyhow::{bail, format_err, Error}; +use serde::{Deserialize, Serialize}; + +use proxmox::api::{api, schema::Schema}; +use proxmox::sys::error::SysError; +use proxmox::tools::fs::CreateOptions; + +use crate::api2::types::{ + DNS_ALIAS_FORMAT, DNS_NAME_FORMAT, PROXMOX_SAFE_ID_FORMAT, PROXMOX_SAFE_ID_REGEX, +}; +use crate::tools::ControlFlow; + +pub(crate) const ACME_DIR: &str = configdir!("/acme"); +pub(crate) const ACME_ACCOUNT_DIR: &str = configdir!("/acme/accounts"); + +pub mod plugin; + +// `const fn`ify this once it is supported in `proxmox` +fn root_only() -> CreateOptions { + CreateOptions::new() + .owner(nix::unistd::ROOT) + .group(nix::unistd::Gid::from_raw(0)) + .perm(nix::sys::stat::Mode::from_bits_truncate(0o700)) +} + +fn create_acme_subdir(dir: &str) -> nix::Result<()> { + match proxmox::tools::fs::create_dir(dir, root_only()) { + Ok(()) => Ok(()), + Err(err) if err.already_exists() => Ok(()), + Err(err) => Err(err), + } +} + +pub(crate) fn make_acme_dir() -> nix::Result<()> { + create_acme_subdir(ACME_DIR) +} + +pub(crate) fn make_acme_account_dir() -> nix::Result<()> { + make_acme_dir()?; + create_acme_subdir(ACME_ACCOUNT_DIR) +} + +#[api( + properties: { + "domain": { format: &DNS_NAME_FORMAT }, + "alias": { + optional: true, + format: &DNS_ALIAS_FORMAT, + }, + "plugin": { + optional: true, + format: &PROXMOX_SAFE_ID_FORMAT, + }, + }, + default_key: "domain", +)] +#[derive(Deserialize, Serialize)] +/// A domain entry for an ACME certificate. +pub struct AcmeDomain { + /// The domain to certify for. + pub domain: String, + + /// The domain to use for challenges instead of the default acme challenge domain. + /// + /// This is useful if you use CNAME entries to redirect `_acme-challenge.*` domains to a + /// different DNS server. + #[serde(skip_serializing_if = "Option::is_none")] + pub alias: Option, + + /// The plugin to use to validate this domain. + /// + /// Empty means standalone HTTP validation is used. + #[serde(skip_serializing_if = "Option::is_none")] + pub plugin: Option, +} + +#[api( + properties: { + name: { type: String }, + url: { type: String }, + }, +)] +/// An ACME directory endpoint with a name and URL. +#[derive(Serialize)] +pub struct KnownAcmeDirectory { + /// The ACME directory's name. + pub name: &'static str, + + /// The ACME directory's endpoint URL. + pub url: &'static str, +} + +pub const KNOWN_ACME_DIRECTORIES: &[KnownAcmeDirectory] = &[ + KnownAcmeDirectory { + name: "Let's Encrypt V2", + url: "https://acme-v02.api.letsencrypt.org/directory", + }, + KnownAcmeDirectory { + name: "Let's Encrypt V2 Staging", + url: "https://acme-staging-v02.api.letsencrypt.org/directory", + }, +]; + +pub const DEFAULT_ACME_DIRECTORY_ENTRY: &KnownAcmeDirectory = &KNOWN_ACME_DIRECTORIES[0]; + +pub fn account_path(name: &str) -> String { + format!("{}/{}", ACME_ACCOUNT_DIR, name) +} + +#[api(format: &PROXMOX_SAFE_ID_FORMAT)] +/// ACME account name. +#[derive(Clone, Eq, PartialEq, Hash, Deserialize, Serialize)] +#[serde(transparent)] +pub struct AccountName(String); + +impl AccountName { + pub fn into_string(self) -> String { + self.0 + } + + pub fn from_string(name: String) -> Result { + match &Self::API_SCHEMA { + Schema::String(s) => s.check_constraints(&name)?, + _ => unreachable!(), + } + Ok(Self(name)) + } + + pub unsafe fn from_string_unchecked(name: String) -> Self { + Self(name) + } +} + +impl std::ops::Deref for AccountName { + type Target = str; + + #[inline] + fn deref(&self) -> &str { + &self.0 + } +} + +impl std::ops::DerefMut for AccountName { + #[inline] + fn deref_mut(&mut self) -> &mut str { + &mut self.0 + } +} + +impl AsRef for AccountName { + #[inline] + fn as_ref(&self) -> &str { + self.0.as_ref() + } +} + +impl fmt::Debug for AccountName { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fmt::Debug::fmt(&self.0, f) + } +} + +impl fmt::Display for AccountName { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fmt::Display::fmt(&self.0, f) + } +} + +pub fn foreach_acme_account(mut func: F) -> Result<(), Error> +where + F: FnMut(AccountName) -> ControlFlow>, +{ + match crate::tools::fs::scan_subdir(-1, ACME_ACCOUNT_DIR, &PROXMOX_SAFE_ID_REGEX) { + Ok(files) => { + for file in files { + let file = file?; + let file_name = unsafe { file.file_name_utf8_unchecked() }; + + if file_name.starts_with('_') { + continue; + } + + let account_name = AccountName(file_name.to_owned()); + + if let ControlFlow::Break(result) = func(account_name) { + return result; + } + } + Ok(()) + } + Err(err) if err.not_found() => Ok(()), + Err(err) => Err(err.into()), + } +} + +/// Run a function for each DNS plugin ID. +pub fn foreach_dns_plugin(mut func: F) -> Result<(), Error> +where + F: FnMut(&str) -> ControlFlow>, +{ + match crate::tools::fs::read_subdir(-1, "/usr/share/proxmox-acme/dnsapi") { + Ok(files) => { + for file in files.filter_map(Result::ok) { + if let Some(id) = file + .file_name() + .to_str() + .ok() + .and_then(|name| name.strip_prefix("dns_")) + .and_then(|name| name.strip_suffix(".sh")) + { + if let ControlFlow::Break(result) = func(id) { + return result; + } + } + } + + Ok(()) + } + Err(err) if err.not_found() => Ok(()), + Err(err) => Err(err.into()), + } +} + +pub fn mark_account_deactivated(name: &str) -> Result<(), Error> { + let from = account_path(name); + for i in 0..100 { + let to = account_path(&format!("_deactivated_{}_{}", name, i)); + if !Path::new(&to).exists() { + return std::fs::rename(&from, &to).map_err(|err| { + format_err!( + "failed to move account path {:?} to {:?} - {}", + from, + to, + err + ) + }); + } + } + bail!( + "No free slot to rename deactivated account {:?}, please cleanup {:?}", + from, + ACME_ACCOUNT_DIR + ); +} + +pub fn complete_acme_account(_arg: &str, _param: &HashMap) -> Vec { + let mut out = Vec::new(); + let _ = foreach_acme_account(|name| { + out.push(name.into_string()); + ControlFlow::CONTINUE + }); + out +} + +pub fn complete_acme_plugin(_arg: &str, _param: &HashMap) -> Vec { + match plugin::config() { + Ok((config, _digest)) => config + .iter() + .map(|(id, (_type, _cfg))| id.clone()) + .collect(), + Err(_) => Vec::new(), + } +} + +pub fn complete_acme_plugin_type(_arg: &str, _param: &HashMap) -> Vec { + vec!["dns".to_string(), "http".to_string()] +} diff --git a/src/config/acme/plugin.rs b/src/config/acme/plugin.rs new file mode 100644 index 00000000..4d197604 --- /dev/null +++ b/src/config/acme/plugin.rs @@ -0,0 +1,213 @@ +use anyhow::Error; +use lazy_static::lazy_static; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use proxmox::api::{ + api, + schema::*, + section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin}, +}; + +use proxmox::tools::{fs::replace_file, fs::CreateOptions}; + +use crate::api2::types::PROXMOX_SAFE_ID_FORMAT; + +pub const PLUGIN_ID_SCHEMA: Schema = StringSchema::new("ACME Challenge Plugin ID.") + .format(&PROXMOX_SAFE_ID_FORMAT) + .schema(); + +lazy_static! { + pub static ref CONFIG: SectionConfig = init(); +} + +#[api( + properties: { + id: { schema: PLUGIN_ID_SCHEMA }, + }, +)] +#[derive(Deserialize, Serialize)] +/// Standalone ACME Plugin for the http-1 challenge. +pub struct StandalonePlugin { + /// Plugin ID. + id: String, +} + +impl Default for StandalonePlugin { + fn default() -> Self { + Self { + id: "standalone".to_string(), + } + } +} + +#[api( + properties: { + id: { schema: PLUGIN_ID_SCHEMA }, + disable: { + optional: true, + default: false, + }, + "validation-delay": { + default: 30, + optional: true, + minimum: 0, + maximum: 2 * 24 * 60 * 60, + }, + }, +)] +/// DNS ACME Challenge Plugin core data. +#[derive(Deserialize, Serialize, Updater)] +#[serde(rename_all = "kebab-case")] +pub struct DnsPluginCore { + /// Plugin ID. + pub(crate) id: String, + + /// DNS API Plugin Id. + pub(crate) api: String, + + /// Extra delay in seconds to wait before requesting validation. + /// + /// Allows to cope with long TTL of DNS records. + #[serde(skip_serializing_if = "Option::is_none", default)] + validation_delay: Option, + + /// Flag to disable the config. + #[serde(skip_serializing_if = "Option::is_none", default)] + disable: Option, +} + +#[api( + properties: { + core: { type: DnsPluginCore }, + }, +)] +/// DNS ACME Challenge Plugin. +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct DnsPlugin { + #[serde(flatten)] + pub(crate) core: DnsPluginCore, + + // FIXME: The `Updater` should allow: + // * having different descriptions for this and the Updater version + // * having different `#[serde]` attributes for the Updater + // * or, well, leaving fields out completely in teh Updater but this means we may need to + // separate Updater and Builder deriving. + // We handle this property separately in the API calls. + /// DNS plugin data (base64url encoded without padding). + #[serde(with = "proxmox::tools::serde::string_as_base64url_nopad")] + pub(crate) data: String, +} + +impl DnsPlugin { + pub fn decode_data(&self, output: &mut Vec) -> Result<(), Error> { + Ok(base64::decode_config_buf( + &self.data, + base64::URL_SAFE_NO_PAD, + output, + )?) + } +} + +fn init() -> SectionConfig { + let mut config = SectionConfig::new(&PLUGIN_ID_SCHEMA); + + let standalone_schema = match &StandalonePlugin::API_SCHEMA { + Schema::Object(schema) => schema, + _ => unreachable!(), + }; + let standalone_plugin = SectionConfigPlugin::new( + "standalone".to_string(), + Some("id".to_string()), + standalone_schema, + ); + config.register_plugin(standalone_plugin); + + let dns_challenge_schema = match DnsPlugin::API_SCHEMA { + Schema::AllOf(ref schema) => schema, + _ => unreachable!(), + }; + let dns_challenge_plugin = SectionConfigPlugin::new( + "dns".to_string(), + Some("id".to_string()), + dns_challenge_schema, + ); + config.register_plugin(dns_challenge_plugin); + + config +} + +const ACME_PLUGIN_CFG_FILENAME: &str = configdir!("/acme/plugins.cfg"); +const ACME_PLUGIN_CFG_LOCKFILE: &str = configdir!("/acme/.plugins.lck"); +const LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +pub fn lock() -> Result { + super::make_acme_dir()?; + proxmox::tools::fs::open_file_locked(ACME_PLUGIN_CFG_LOCKFILE, LOCK_TIMEOUT, true) +} + +pub fn config() -> Result<(PluginData, [u8; 32]), Error> { + let content = proxmox::tools::fs::file_read_optional_string(ACME_PLUGIN_CFG_FILENAME)? + .unwrap_or_else(|| "".to_string()); + + let digest = openssl::sha::sha256(content.as_bytes()); + let mut data = CONFIG.parse(ACME_PLUGIN_CFG_FILENAME, &content)?; + + if data.sections.get("standalone").is_none() { + let standalone = StandalonePlugin::default(); + data.set_data("standalone", "standalone", &standalone) + .unwrap(); + } + + Ok((PluginData { data }, digest)) +} + +pub fn save_config(config: &PluginData) -> Result<(), Error> { + super::make_acme_dir()?; + let raw = CONFIG.write(ACME_PLUGIN_CFG_FILENAME, &config.data)?; + + let backup_user = crate::backup::backup_user()?; + let mode = nix::sys::stat::Mode::from_bits_truncate(0o0640); + // set the correct owner/group/permissions while saving file + // owner(rw) = root, group(r)= backup + let options = CreateOptions::new() + .perm(mode) + .owner(nix::unistd::ROOT) + .group(backup_user.gid); + + replace_file(ACME_PLUGIN_CFG_FILENAME, raw.as_bytes(), options)?; + + Ok(()) +} + +pub struct PluginData { + data: SectionConfigData, +} + +// And some convenience helpers. +impl PluginData { + pub fn remove(&mut self, name: &str) -> Option<(String, Value)> { + self.data.sections.remove(name) + } + + pub fn contains_key(&mut self, name: &str) -> bool { + self.data.sections.contains_key(name) + } + + pub fn get(&self, name: &str) -> Option<&(String, Value)> { + self.data.sections.get(name) + } + + pub fn get_mut(&mut self, name: &str) -> Option<&mut (String, Value)> { + self.data.sections.get_mut(name) + } + + pub fn insert(&mut self, id: String, ty: String, plugin: Value) { + self.data.sections.insert(id, (ty, plugin)); + } + + pub fn iter(&self) -> impl Iterator + Send { + self.data.sections.iter() + } +}