[pbs-devel] [REBASED v2 backup 1/9] add acme config
Wolfgang Bumiller
w.bumiller at proxmox.com
Mon May 3 11:39:51 CEST 2021
Signed-off-by: Wolfgang Bumiller <w.bumiller at proxmox.com>
---
src/config.rs | 1 +
src/config/acme/mod.rs | 273 ++++++++++++++++++++++++++++++++++++++
src/config/acme/plugin.rs | 213 +++++++++++++++++++++++++++++
3 files changed, 487 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 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<String>,
+
+ /// 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<String>,
+}
+
+#[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<Self, Error> {
+ 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<str> 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<F>(mut func: F) -> Result<(), Error>
+where
+ F: FnMut(AccountName) -> ControlFlow<Result<(), Error>>,
+{
+ 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<F>(mut func: F) -> Result<(), Error>
+where
+ F: FnMut(&str) -> ControlFlow<Result<(), Error>>,
+{
+ 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<String, String>) -> Vec<String> {
+ 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<String, String>) -> Vec<String> {
+ 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<String, String>) -> Vec<String> {
+ 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<u32>,
+
+ /// Flag to disable the config.
+ #[serde(skip_serializing_if = "Option::is_none", default)]
+ disable: Option<bool>,
+}
+
+#[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<u8>) -> 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<std::fs::File, Error> {
+ 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<Item = (&String, &(String, Value))> + Send {
+ self.data.sections.iter()
+ }
+}
--
2.20.1
More information about the pbs-devel
mailing list