From: Wolfgang Bumiller <w.bumiller@proxmox.com>
To: pbs-devel@lists.proxmox.com
Subject: [pbs-devel] [REBASED v2 backup 1/9] add acme config
Date: Mon, 3 May 2021 11:39:51 +0200 [thread overview]
Message-ID: <20210503093959.14855-2-w.bumiller@proxmox.com> (raw)
In-Reply-To: <20210503093959.14855-1-w.bumiller@proxmox.com>
Signed-off-by: Wolfgang Bumiller <w.bumiller@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
next prev parent reply other threads:[~2021-05-03 9:40 UTC|newest]
Thread overview: 12+ messages / expand[flat|nested] mbox.gz Atom feed top
2021-05-03 9:39 [pbs-devel] [REBASED v2 backup 0/9] rebased and reordered acme implementation Wolfgang Bumiller
2021-05-03 9:39 ` Wolfgang Bumiller [this message]
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 2/9] add acme client Wolfgang Bumiller
2021-05-04 6:10 ` Dietmar Maurer
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 3/9] add node config Wolfgang Bumiller
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 4/9] add config/acme api path Wolfgang Bumiller
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 5/9] add node/{node}/certificates api call Wolfgang Bumiller
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 6/9] add node/{node}/config api path Wolfgang Bumiller
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 7/9] add acme commands to proxmox-backup-manager Wolfgang Bumiller
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 8/9] ui: add certificate & acme view Wolfgang Bumiller
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 9/9] daily-update: check acme certificates Wolfgang Bumiller
2021-05-04 7:57 ` [pbs-devel] applied: [REBASED v2 backup 0/9] rebased and reordered acme implementation Dietmar Maurer
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20210503093959.14855-2-w.bumiller@proxmox.com \
--to=w.bumiller@proxmox.com \
--cc=pbs-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.