From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id A83A778021 for ; Thu, 29 Apr 2021 12:53:05 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 9D9FE1AB94 for ; Thu, 29 Apr 2021 12:53:05 +0200 (CEST) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [94.136.29.106]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS id AAB221AB89 for ; Thu, 29 Apr 2021 12:53:03 +0200 (CEST) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id 8383A46367 for ; Thu, 29 Apr 2021 12:53:03 +0200 (CEST) To: Proxmox Backup Server development discussion , Wolfgang Bumiller References: <20210422140213.30989-1-w.bumiller@proxmox.com> <20210422140213.30989-15-w.bumiller@proxmox.com> From: Dietmar Maurer Message-ID: Date: Thu, 29 Apr 2021 12:53:01 +0200 User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 Thunderbird/78.10.0 MIME-Version: 1.0 In-Reply-To: <20210422140213.30989-15-w.bumiller@proxmox.com> Content-Type: text/plain; charset=utf-8; format=flowed Content-Transfer-Encoding: 8bit Content-Language: en-US X-SPAM-LEVEL: Spam detection results: 0 AWL 0.303 Adjusted score from AWL reputation of From: address KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment NICE_REPLY_A -0.001 Looks like a legit reply (A) SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [config.rs, plugin.rs, letsencrypt.org, mod.rs] Subject: Re: [pbs-devel] [PATCH v2 backup 14/27] add acme config X-BeenThere: pbs-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Backup Server development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Thu, 29 Apr 2021 10:53:05 -0000 I get a compile error: make cargo build --release    Compiling proxmox-backup v1.1.5 (/home/dietmar/rust/proxmox-backup) error[E0432]: unresolved import `crate::acme`   --> src/config/acme/plugin.rs:22:12    | 22 | use crate::acme::AcmeClient;    |            ^^^^    |            |    |            unresolved import    |            help: a similar path exists: `crate::config::acme` error[E0432]: unresolved import `crate::acme`   --> src/config/node.rs:12:12    | 12 | use crate::acme::AcmeClient;    |            ^^^^    |            |    |            unresolved import    |            help: a similar path exists: `crate::config::acme` error: aborting due to 2 previous errors On 4/22/21 4:02 PM, Wolfgang Bumiller wrote: > Signed-off-by: Wolfgang Bumiller > --- > src/config.rs | 1 + > src/config/acme/mod.rs | 198 ++++++++++++++++++++ > src/config/acme/plugin.rs | 380 ++++++++++++++++++++++++++++++++++++++ > 3 files changed, 579 insertions(+) > create mode 100644 src/config/acme/mod.rs > create mode 100644 src/config/acme/plugin.rs > > diff --git a/src/config.rs b/src/config.rs > index 717829e2..94b7fb6c 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..ac409c20 > --- /dev/null > +++ b/src/config/acme/mod.rs > @@ -0,0 +1,198 @@ > +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; > +use proxmox::sys::error::SysError; > + > +use crate::api2::types::{PROXMOX_SAFE_ID_FORMAT, PROXMOX_SAFE_ID_REGEX}; > +use crate::tools::ControlFlow; > + > +pub(crate) const ACME_ACCOUNT_DIR: &str = configdir!("/acme/accounts"); > + > +pub mod plugin; > + > +#[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 > + } > +} > + > +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..b0c655c0 > --- /dev/null > +++ b/src/config/acme/plugin.rs > @@ -0,0 +1,380 @@ > +use std::future::Future; > +use std::pin::Pin; > +use std::process::Stdio; > + > +use anyhow::{bail, format_err, Error}; > +use lazy_static::lazy_static; > +use serde::{Deserialize, Serialize}; > +use serde_json::Value; > +use tokio::io::AsyncWriteExt; > +use tokio::process::Command; > + > +use proxmox::api::{ > + api, > + schema::*, > + section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin}, > +}; > + > +use proxmox::tools::{fs::replace_file, fs::CreateOptions}; > + > +use proxmox_acme_rs::{Authorization, Challenge}; > + > +use crate::acme::AcmeClient; > +use crate::api2::types::PROXMOX_SAFE_ID_FORMAT; > +use crate::config::node::AcmeDomain; > + > +const ACME_PATH: &str = "/usr/share/proxmox-acme/proxmox-acme"; > + > +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(), > + } > + } > +} > + > +/// In PVE/PMG we store the plugin's "data" member as base64url encoded string. The UI sends > +/// regular base64 encoded data. We need to "fix" this up. > + > +#[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. > + 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)?) > + } > +} > + > +//impl DnsPluginUpdater { > +// // The UI passes regular base64 data, we need base64url data. In PVE/PMG this happens magically > +// // since perl parses both on decode... > +// pub fn api_fixup(&mut self) -> Result<(), Error> { > +// if let Some(data) = self.data.as_mut() { > +// let new = base64::encode_config(&base64::decode(&data)?, base64::URL_SAFE_NO_PAD); > +// *data = new; > +// } > +// Ok(()) > +// } > +//} > + > +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 > +} > + > +pub const ACME_PLUGIN_CFG_FILENAME: &str = "/etc/proxmox-backup/acme/plugins.cfg"; > +pub const ACME_PLUGIN_CFG_LOCKFILE: &str = "/etc/proxmox-backup/acme/.plugins.lck"; > +const LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); > + > +pub fn read_lock() -> Result { > + proxmox::tools::fs::open_file_locked(ACME_PLUGIN_CFG_LOCKFILE, LOCK_TIMEOUT, false) > +} > + > +pub fn write_lock() -> Result { > + 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> { > + 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, > +} > + > +impl PluginData { > + #[inline] > + pub fn remove(&mut self, name: &str) -> Option<(String, Value)> { > + self.data.sections.remove(name) > + } > + > + #[inline] > + pub fn contains_key(&mut self, name: &str) -> bool { > + self.data.sections.contains_key(name) > + } > + > + #[inline] > + pub fn get(&self, name: &str) -> Option<&(String, Value)> { > + self.data.sections.get(name) > + } > + > + #[inline] > + pub fn get_mut(&mut self, name: &str) -> Option<&mut (String, Value)> { > + self.data.sections.get_mut(name) > + } > + > + // FIXME: Verify the plugin type *exists* and check its config schema... > + pub fn insert(&mut self, id: String, ty: String, plugin: Value) { > + self.data.sections.insert(id, (ty, plugin)); > + } > + > + pub fn get_plugin( > + &self, > + name: &str, > + ) -> Result>, Error> { > + let (ty, data) = match self.get(name) { > + Some(plugin) => plugin, > + None => return Ok(None), > + }; > + > + Ok(Some(match ty.as_str() { > + "dns" => { > + let plugin: DnsPlugin = serde_json::from_value(data.clone())?; > + Box::new(plugin) > + } > + // "standalone" => todo!("standalone plugin"), > + other => bail!("missing implementation for plugin type '{}'", other), > + })) > + } > + > + pub fn iter(&self) -> impl Iterator + Send { > + self.data.sections.iter() > + } > +} > + > +pub trait AcmePlugin { > + /// Setup everything required to trigger the validation and return the corresponding validation > + /// URL. > + fn setup<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>( > + &'a self, > + client: &'b mut AcmeClient, > + authorization: &'c Authorization, > + domain: &'d AcmeDomain, > + ) -> Pin> + Send + 'fut>>; > + > + fn teardown<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>( > + &'a self, > + client: &'b mut AcmeClient, > + authorization: &'c Authorization, > + domain: &'d AcmeDomain, > + ) -> Pin> + Send + 'fut>>; > +} > + > +impl DnsPlugin { > + fn extract_challenge(authorization: &Authorization) -> Result<&Challenge, Error> { > + authorization > + .challenges > + .iter() > + .find(|ch| ch.ty == "dns-01") > + .ok_or_else(|| format_err!("no supported challenge type (dns-01) found")) > + } > + > + async fn action<'a>( > + &self, > + client: &mut AcmeClient, > + authorization: &'a Authorization, > + domain: &AcmeDomain, > + action: &str, > + ) -> Result<&'a str, Error> { > + let challenge = Self::extract_challenge(authorization)?; > + let mut stdin_data = client > + .dns_01_txt_value( > + challenge > + .token() > + .ok_or_else(|| format_err!("missing token in challenge"))?, > + )? > + .into_bytes(); > + stdin_data.push(b'\n'); > + stdin_data.extend(self.data.as_bytes()); > + if stdin_data.last() != Some(&b'\n') { > + stdin_data.push(b'\n'); > + } > + > + let mut command = Command::new("/usr/bin/setpriv"); > + > + #[rustfmt::skip] > + command.args(&[ > + "--reuid", "nobody", > + "--regid", "nogroup", > + "--clear-groups", > + "--reset-env", > + "--", > + "/bin/bash", > + ACME_PATH, > + action, > + &self.core.api, > + domain.alias.as_deref().unwrap_or(&domain.domain), > + ]); > + > + let mut child = command.stdin(Stdio::piped()).spawn()?; > + > + let mut stdin = child.stdin.take().expect("Stdio::piped()"); > + match async move { > + stdin.write_all(&stdin_data).await?; > + stdin.flush().await?; > + Ok::<_, std::io::Error>(()) > + }.await { > + Ok(()) => (), > + Err(err) => { > + if let Err(err) = child.kill().await { > + eprintln!("failed to kill '{} {}' command: {}", ACME_PATH, action, err); > + } > + bail!("'{}' failed: {}", ACME_PATH, err); > + } > + } > + > + let status = child.wait().await?; > + if !status.success() { > + bail!( > + "'{} {}' exited with error ({})", > + ACME_PATH, > + action, > + status.code().unwrap_or(-1) > + ); > + } > + > + Ok(&challenge.url) > + } > +} > + > +impl AcmePlugin for DnsPlugin { > + fn setup<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>( > + &'a self, > + client: &'b mut AcmeClient, > + authorization: &'c Authorization, > + domain: &'d AcmeDomain, > + ) -> Pin> + Send + 'fut>> { > + Box::pin(self.action(client, authorization, domain, "setup")) > + } > + > + fn teardown<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>( > + &'a self, > + client: &'b mut AcmeClient, > + authorization: &'c Authorization, > + domain: &'d AcmeDomain, > + ) -> Pin> + Send + 'fut>> { > + Box::pin(async move { > + self.action(client, authorization, domain, "teardown") > + .await > + .map(drop) > + }) > + } > +}