* [pbs-devel] [REBASED v2 backup 0/9] rebased and reordered acme implementation
@ 2021-05-03 9:39 Wolfgang Bumiller
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 1/9] add acme config Wolfgang Bumiller
` (9 more replies)
0 siblings, 10 replies; 12+ messages in thread
From: Wolfgang Bumiller @ 2021-05-03 9:39 UTC (permalink / raw)
To: pbs-devel
Only minor changes, a split and some merges:
* moved the validation plugin implementations from config::acme::plugin
to acme::plugin, in order to separate the config again.
* dropped the `read_lock` functions and renamed `write_lock` -> `lock`
* removed some leftover commented out code
* changed lock timeouts to 10s like in most other config parts
* lock file name consistency ('.lock' -> '.lck')
* kept AccountName for now, we still need to discuss how to
approach this definitively
* for less noise I merged the patches at the end of the series into the
earlier ones, since they're mostly fixup commits:
- creating acme directories before placing files in there
- validate config before writing
- pipe plugin output to task log
- make account name optional in api call
Also: standalone validation, since introducing dns validation together
with the plugin config but adding this one later isn't consistent
anyway.
Wolfgang Bumiller (9):
add acme config
add acme client
add node config
add config/acme api path
add node/{node}/certificates api call
add node/{node}/config api path
add acme commands to proxmox-backup-manager
ui: add certificate & acme view
daily-update: check acme certificates
src/acme/client.rs | 673 +++++++++++++++++++++++
src/acme/mod.rs | 5 +
src/acme/plugin.rs | 299 ++++++++++
src/api2/config.rs | 2 +
src/api2/config/acme.rs | 725 +++++++++++++++++++++++++
src/api2/node.rs | 4 +
src/api2/node/certificates.rs | 579 ++++++++++++++++++++
src/api2/node/config.rs | 80 +++
src/bin/proxmox-backup-manager.rs | 1 +
src/bin/proxmox-daily-update.rs | 30 +-
src/bin/proxmox_backup_manager/acme.rs | 415 ++++++++++++++
src/bin/proxmox_backup_manager/mod.rs | 2 +
src/config.rs | 20 +-
src/config/acme/mod.rs | 273 ++++++++++
src/config/acme/plugin.rs | 213 ++++++++
src/config/node.rs | 202 +++++++
src/lib.rs | 2 +
www/Makefile | 1 +
www/NavigationTree.js | 6 +
www/config/CertificateView.js | 80 +++
20 files changed, 3609 insertions(+), 3 deletions(-)
create mode 100644 src/acme/client.rs
create mode 100644 src/acme/mod.rs
create mode 100644 src/acme/plugin.rs
create mode 100644 src/api2/config/acme.rs
create mode 100644 src/api2/node/certificates.rs
create mode 100644 src/api2/node/config.rs
create mode 100644 src/bin/proxmox_backup_manager/acme.rs
create mode 100644 src/config/acme/mod.rs
create mode 100644 src/config/acme/plugin.rs
create mode 100644 src/config/node.rs
create mode 100644 www/config/CertificateView.js
--
2.20.1
^ permalink raw reply [flat|nested] 12+ messages in thread
* [pbs-devel] [REBASED v2 backup 1/9] add acme config
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
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 2/9] add acme client Wolfgang Bumiller
` (8 subsequent siblings)
9 siblings, 0 replies; 12+ messages in thread
From: Wolfgang Bumiller @ 2021-05-03 9:39 UTC (permalink / raw)
To: pbs-devel
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
^ permalink raw reply [flat|nested] 12+ messages in thread
* [pbs-devel] [REBASED v2 backup 2/9] add acme client
2021-05-03 9:39 [pbs-devel] [REBASED v2 backup 0/9] rebased and reordered acme implementation Wolfgang Bumiller
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 1/9] add acme config Wolfgang Bumiller
@ 2021-05-03 9:39 ` 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
` (7 subsequent siblings)
9 siblings, 1 reply; 12+ messages in thread
From: Wolfgang Bumiller @ 2021-05-03 9:39 UTC (permalink / raw)
To: pbs-devel
This is the highlevel part using proxmox-acme-rs to create
requests and our hyper code to issue them to the acme
server.
Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
src/acme/client.rs | 673 +++++++++++++++++++++++++++++++++++++++++++++
src/acme/mod.rs | 5 +
src/acme/plugin.rs | 299 ++++++++++++++++++++
src/lib.rs | 2 +
4 files changed, 979 insertions(+)
create mode 100644 src/acme/client.rs
create mode 100644 src/acme/mod.rs
create mode 100644 src/acme/plugin.rs
diff --git a/src/acme/client.rs b/src/acme/client.rs
new file mode 100644
index 00000000..7f88bbf9
--- /dev/null
+++ b/src/acme/client.rs
@@ -0,0 +1,673 @@
+//! HTTP Client for the ACME protocol.
+
+use std::fs::OpenOptions;
+use std::io;
+use std::os::unix::fs::OpenOptionsExt;
+
+use anyhow::format_err;
+use bytes::Bytes;
+use hyper::{Body, Request};
+use nix::sys::stat::Mode;
+use serde::{Deserialize, Serialize};
+
+use proxmox::tools::fs::{replace_file, CreateOptions};
+use proxmox_acme_rs::account::AccountCreator;
+use proxmox_acme_rs::account::AccountData as AcmeAccountData;
+use proxmox_acme_rs::order::{Order, OrderData};
+use proxmox_acme_rs::Request as AcmeRequest;
+use proxmox_acme_rs::{Account, Authorization, Challenge, Directory, Error, ErrorResponse};
+
+use crate::config::acme::{account_path, AccountName};
+use crate::tools::http::SimpleHttp;
+
+/// Our on-disk format inherited from PVE's proxmox-acme code.
+#[derive(Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct AccountData {
+ /// The account's location URL.
+ location: String,
+
+ /// The account data.
+ account: AcmeAccountData,
+
+ /// The private key as PEM formatted string.
+ key: String,
+
+ /// ToS URL the user agreed to.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ tos: Option<String>,
+
+ #[serde(skip_serializing_if = "is_false", default)]
+ debug: bool,
+
+ /// The directory's URL.
+ directory_url: String,
+}
+
+#[inline]
+fn is_false(b: &bool) -> bool {
+ !*b
+}
+
+pub struct AcmeClient {
+ directory_url: String,
+ debug: bool,
+ account_path: Option<String>,
+ tos: Option<String>,
+ account: Option<Account>,
+ directory: Option<Directory>,
+ nonce: Option<String>,
+ http_client: Option<SimpleHttp>,
+}
+
+impl AcmeClient {
+ /// Create a new ACME client for a given ACME directory URL.
+ pub fn new(directory_url: String) -> Self {
+ Self {
+ directory_url,
+ debug: false,
+ account_path: None,
+ tos: None,
+ account: None,
+ directory: None,
+ nonce: None,
+ http_client: None,
+ }
+ }
+
+ /// Load an existing ACME account by name.
+ pub async fn load(account_name: &AccountName) -> Result<Self, anyhow::Error> {
+ Self::load_path(account_path(account_name.as_ref())).await
+ }
+
+ /// Load an existing ACME account by path.
+ async fn load_path(account_path: String) -> Result<Self, anyhow::Error> {
+ let data = tokio::fs::read(&account_path).await?;
+ let data: AccountData = serde_json::from_slice(&data)?;
+
+ let account = Account::from_parts(data.location, data.key, data.account);
+
+ Ok(Self {
+ directory_url: data.directory_url,
+ debug: data.debug,
+ account_path: Some(account_path),
+ tos: data.tos,
+ account: Some(account),
+ directory: None,
+ nonce: None,
+ http_client: None,
+ })
+ }
+
+ pub async fn new_account<'a>(
+ &'a mut self,
+ account_name: &AccountName,
+ tos_agreed: bool,
+ contact: Vec<String>,
+ rsa_bits: Option<u32>,
+ ) -> Result<&'a Account, anyhow::Error> {
+ self.tos = if tos_agreed {
+ self.terms_of_service_url().await?.map(str::to_owned)
+ } else {
+ None
+ };
+
+ let account = Account::creator()
+ .set_contacts(contact)
+ .agree_to_tos(tos_agreed);
+
+ let account = if let Some(bits) = rsa_bits {
+ account.generate_rsa_key(bits)?
+ } else {
+ account.generate_ec_key()?
+ };
+
+ let _ = self.register_account(account).await?;
+
+ crate::config::acme::make_acme_account_dir()?;
+ let account_path = account_path(account_name.as_ref());
+ let file = OpenOptions::new()
+ .write(true)
+ .create(true)
+ .mode(0o600)
+ .open(&account_path)
+ .map_err(|err| format_err!("failed to open {:?} for writing: {}", account_path, err))?;
+ self.write_to(file).map_err(|err| {
+ format_err!(
+ "failed to write acme account to {:?}: {}",
+ account_path,
+ err
+ )
+ })?;
+ self.account_path = Some(account_path);
+
+ // unwrap: Setting `self.account` is literally this function's job, we just can't keep
+ // the borrow from from `self.register_account()` active due to clashes.
+ Ok(self.account.as_ref().unwrap())
+ }
+
+ fn save(&self) -> Result<(), anyhow::Error> {
+ let mut data = Vec::<u8>::new();
+ self.write_to(&mut data)?;
+ let account_path = self.account_path.as_ref().ok_or_else(|| {
+ format_err!("no account path set, cannot save upated account information")
+ })?;
+ crate::config::acme::make_acme_account_dir()?;
+ replace_file(
+ account_path,
+ &data,
+ CreateOptions::new()
+ .perm(Mode::from_bits_truncate(0o600))
+ .owner(nix::unistd::ROOT)
+ .group(nix::unistd::Gid::from_raw(0)),
+ )
+ }
+
+ /// Shortcut to `account().ok_or_else(...).key_authorization()`.
+ pub fn key_authorization(&self, token: &str) -> Result<String, anyhow::Error> {
+ Ok(Self::need_account(&self.account)?.key_authorization(token)?)
+ }
+
+ /// Shortcut to `account().ok_or_else(...).dns_01_txt_value()`.
+ /// the key authorization value.
+ pub fn dns_01_txt_value(&self, token: &str) -> Result<String, anyhow::Error> {
+ Ok(Self::need_account(&self.account)?.dns_01_txt_value(token)?)
+ }
+
+ async fn register_account(
+ &mut self,
+ account: AccountCreator,
+ ) -> Result<&Account, anyhow::Error> {
+ let mut retry = retry();
+ let mut response = loop {
+ retry.tick()?;
+
+ let (directory, nonce) = Self::get_dir_nonce(
+ &mut self.http_client,
+ &self.directory_url,
+ &mut self.directory,
+ &mut self.nonce,
+ )
+ .await?;
+ let request = account.request(directory, nonce)?;
+ match self.run_request(request).await {
+ Ok(response) => break response,
+ Err(err) if err.is_bad_nonce() => continue,
+ Err(err) => return Err(err.into()),
+ }
+ };
+
+ let account = account.response(response.location_required()?, &response.body)?;
+
+ self.account = Some(account);
+ Ok(self.account.as_ref().unwrap())
+ }
+
+ pub async fn update_account<T: Serialize>(
+ &mut self,
+ data: &T,
+ ) -> Result<&Account, anyhow::Error> {
+ let account = Self::need_account(&self.account)?;
+
+ let mut retry = retry();
+ let response = loop {
+ retry.tick()?;
+
+ let (_directory, nonce) = Self::get_dir_nonce(
+ &mut self.http_client,
+ &self.directory_url,
+ &mut self.directory,
+ &mut self.nonce,
+ )
+ .await?;
+
+ let request = account.post_request(&account.location, &nonce, data)?;
+ match Self::execute(&mut self.http_client, request, &mut self.nonce).await {
+ Ok(response) => break response,
+ Err(err) if err.is_bad_nonce() => continue,
+ Err(err) => return Err(err.into()),
+ }
+ };
+
+ // unwrap: we've been keeping an immutable reference to it from the top of the method
+ let _ = account;
+ self.account.as_mut().unwrap().data = response.json()?;
+ self.save()?;
+ Ok(self.account.as_ref().unwrap())
+ }
+
+ pub async fn new_order<I>(&mut self, domains: I) -> Result<Order, anyhow::Error>
+ where
+ I: IntoIterator<Item = String>,
+ {
+ let account = Self::need_account(&self.account)?;
+
+ let order = domains
+ .into_iter()
+ .fold(OrderData::new(), |order, domain| order.domain(domain));
+
+ let mut retry = retry();
+ loop {
+ retry.tick()?;
+
+ let (directory, nonce) = Self::get_dir_nonce(
+ &mut self.http_client,
+ &self.directory_url,
+ &mut self.directory,
+ &mut self.nonce,
+ )
+ .await?;
+
+ let mut new_order = account.new_order(&order, directory, nonce)?;
+ let mut response = match Self::execute(
+ &mut self.http_client,
+ new_order.request.take().unwrap(),
+ &mut self.nonce,
+ )
+ .await
+ {
+ Ok(response) => response,
+ Err(err) if err.is_bad_nonce() => continue,
+ Err(err) => return Err(err.into()),
+ };
+
+ return Ok(
+ new_order.response(response.location_required()?, response.bytes().as_ref())?
+ );
+ }
+ }
+
+ /// Low level "POST-as-GET" request.
+ async fn post_as_get(&mut self, url: &str) -> Result<AcmeResponse, anyhow::Error> {
+ let account = Self::need_account(&self.account)?;
+
+ let mut retry = retry();
+ loop {
+ retry.tick()?;
+
+ let (_directory, nonce) = Self::get_dir_nonce(
+ &mut self.http_client,
+ &self.directory_url,
+ &mut self.directory,
+ &mut self.nonce,
+ )
+ .await?;
+
+ let request = account.get_request(url, nonce)?;
+ match Self::execute(&mut self.http_client, request, &mut self.nonce).await {
+ Ok(response) => return Ok(response),
+ Err(err) if err.is_bad_nonce() => continue,
+ Err(err) => return Err(err.into()),
+ }
+ }
+ }
+
+ /// Low level POST request.
+ async fn post<T: Serialize>(
+ &mut self,
+ url: &str,
+ data: &T,
+ ) -> Result<AcmeResponse, anyhow::Error> {
+ let account = Self::need_account(&self.account)?;
+
+ let mut retry = retry();
+ loop {
+ retry.tick()?;
+
+ let (_directory, nonce) = Self::get_dir_nonce(
+ &mut self.http_client,
+ &self.directory_url,
+ &mut self.directory,
+ &mut self.nonce,
+ )
+ .await?;
+
+ let request = account.post_request(url, nonce, data)?;
+ match Self::execute(&mut self.http_client, request, &mut self.nonce).await {
+ Ok(response) => return Ok(response),
+ Err(err) if err.is_bad_nonce() => continue,
+ Err(err) => return Err(err.into()),
+ }
+ }
+ }
+
+ /// Request challenge validation. Afterwards, the challenge should be polled.
+ pub async fn request_challenge_validation(
+ &mut self,
+ url: &str,
+ ) -> Result<Challenge, anyhow::Error> {
+ Ok(self
+ .post(url, &serde_json::Value::Object(Default::default()))
+ .await?
+ .json()?)
+ }
+
+ /// Assuming the provided URL is an 'Authorization' URL, get and deserialize it.
+ pub async fn get_authorization(&mut self, url: &str) -> Result<Authorization, anyhow::Error> {
+ Ok(self.post_as_get(url).await?.json()?)
+ }
+
+ /// Assuming the provided URL is an 'Order' URL, get and deserialize it.
+ pub async fn get_order(&mut self, url: &str) -> Result<OrderData, anyhow::Error> {
+ Ok(self.post_as_get(url).await?.json()?)
+ }
+
+ /// Finalize an Order via its `finalize` URL property and the DER encoded CSR.
+ pub async fn finalize(&mut self, url: &str, csr: &[u8]) -> Result<(), anyhow::Error> {
+ let csr = base64::encode_config(csr, base64::URL_SAFE_NO_PAD);
+ let data = serde_json::json!({ "csr": csr });
+ self.post(url, &data).await?;
+ Ok(())
+ }
+
+ /// Download a certificate via its 'certificate' URL property.
+ ///
+ /// The certificate will be a PEM certificate chain.
+ pub async fn get_certificate(&mut self, url: &str) -> Result<Bytes, anyhow::Error> {
+ Ok(self.post_as_get(url).await?.body)
+ }
+
+ /// Revoke an existing certificate (PEM or DER formatted).
+ pub async fn revoke_certificate(
+ &mut self,
+ certificate: &[u8],
+ reason: Option<u32>,
+ ) -> Result<(), anyhow::Error> {
+ // TODO: This can also work without an account.
+ let account = Self::need_account(&self.account)?;
+
+ let revocation = account.revoke_certificate(certificate, reason)?;
+
+ let mut retry = retry();
+ loop {
+ retry.tick()?;
+
+ let (directory, nonce) = Self::get_dir_nonce(
+ &mut self.http_client,
+ &self.directory_url,
+ &mut self.directory,
+ &mut self.nonce,
+ )
+ .await?;
+
+ let request = revocation.request(&directory, nonce)?;
+ match Self::execute(&mut self.http_client, request, &mut self.nonce).await {
+ Ok(_response) => return Ok(()),
+ Err(err) if err.is_bad_nonce() => continue,
+ Err(err) => return Err(err.into()),
+ }
+ }
+ }
+
+ fn need_account(account: &Option<Account>) -> Result<&Account, anyhow::Error> {
+ account
+ .as_ref()
+ .ok_or_else(|| format_err!("cannot use client without an account"))
+ }
+
+ pub(crate) fn account(&self) -> Result<&Account, anyhow::Error> {
+ Self::need_account(&self.account)
+ }
+
+ pub fn tos(&self) -> Option<&str> {
+ self.tos.as_deref()
+ }
+
+ pub fn directory_url(&self) -> &str {
+ &self.directory_url
+ }
+
+ fn to_account_data(&self) -> Result<AccountData, anyhow::Error> {
+ let account = self.account()?;
+
+ Ok(AccountData {
+ location: account.location.clone(),
+ key: account.private_key.clone(),
+ account: AcmeAccountData {
+ only_return_existing: false, // don't actually write this out in case it's set
+ ..account.data.clone()
+ },
+ tos: self.tos.clone(),
+ debug: self.debug,
+ directory_url: self.directory_url.clone(),
+ })
+ }
+
+ fn write_to<T: io::Write>(&self, out: T) -> Result<(), anyhow::Error> {
+ let data = self.to_account_data()?;
+
+ Ok(serde_json::to_writer_pretty(out, &data)?)
+ }
+}
+
+struct AcmeResponse {
+ body: Bytes,
+ location: Option<String>,
+ got_nonce: bool,
+}
+
+impl AcmeResponse {
+ /// Convenience helper to assert that a location header was part of the response.
+ fn location_required(&mut self) -> Result<String, anyhow::Error> {
+ self.location
+ .take()
+ .ok_or_else(|| format_err!("missing Location header"))
+ }
+
+ /// Convenience shortcut to perform json deserialization of the returned body.
+ fn json<T: for<'a> Deserialize<'a>>(&self) -> Result<T, Error> {
+ Ok(serde_json::from_slice(&self.body)?)
+ }
+
+ /// Convenience shortcut to get the body as bytes.
+ fn bytes(&self) -> &[u8] {
+ &self.body
+ }
+}
+
+impl AcmeClient {
+ /// Non-self-borrowing run_request version for borrow workarounds.
+ async fn execute(
+ http_client: &mut Option<SimpleHttp>,
+ request: AcmeRequest,
+ nonce: &mut Option<String>,
+ ) -> Result<AcmeResponse, Error> {
+ let req_builder = Request::builder().method(request.method).uri(&request.url);
+
+ let http_request = if !request.content_type.is_empty() {
+ req_builder
+ .header("Content-Type", request.content_type)
+ .header("Content-Length", request.body.len())
+ .body(request.body.into())
+ } else {
+ req_builder.body(Body::empty())
+ }
+ .map_err(|err| Error::Custom(format!("failed to create http request: {}", err)))?;
+
+ let response = http_client
+ .get_or_insert_with(|| SimpleHttp::new(None))
+ .request(http_request)
+ .await
+ .map_err(|err| Error::Custom(err.to_string()))?;
+ let (parts, body) = response.into_parts();
+
+ let status = parts.status.as_u16();
+ let body = hyper::body::to_bytes(body)
+ .await
+ .map_err(|err| Error::Custom(format!("failed to retrieve response body: {}", err)))?;
+
+ let got_nonce = if let Some(new_nonce) = parts.headers.get(proxmox_acme_rs::REPLAY_NONCE) {
+ let new_nonce = new_nonce.to_str().map_err(|err| {
+ Error::Client(format!(
+ "received invalid replay-nonce header from ACME server: {}",
+ err
+ ))
+ })?;
+ *nonce = Some(new_nonce.to_owned());
+ true
+ } else {
+ false
+ };
+
+ if parts.status.is_success() {
+ if status != request.expected {
+ return Err(Error::InvalidApi(format!(
+ "ACME server responded with unexpected status code: {:?}",
+ parts.status
+ )));
+ }
+
+ let location = parts
+ .headers
+ .get("Location")
+ .map(|header| {
+ header.to_str().map(str::to_owned).map_err(|err| {
+ Error::Client(format!(
+ "received invalid location header from ACME server: {}",
+ err
+ ))
+ })
+ })
+ .transpose()?;
+
+ return Ok(AcmeResponse {
+ body,
+ location,
+ got_nonce,
+ });
+ }
+
+ let error: ErrorResponse = serde_json::from_slice(&body).map_err(|err| {
+ Error::Client(format!(
+ "error status with improper error ACME response: {}",
+ err
+ ))
+ })?;
+
+ if error.ty == proxmox_acme_rs::error::BAD_NONCE {
+ if !got_nonce {
+ return Err(Error::InvalidApi(
+ "badNonce without a new Replay-Nonce header".to_string(),
+ ));
+ }
+ return Err(Error::BadNonce);
+ }
+
+ Err(Error::Api(error))
+ }
+
+ /// Low-level API to run an n API request. This automatically updates the current nonce!
+ async fn run_request(&mut self, request: AcmeRequest) -> Result<AcmeResponse, Error> {
+ Self::execute(&mut self.http_client, request, &mut self.nonce).await
+ }
+
+ async fn directory(&mut self) -> Result<&Directory, Error> {
+ Ok(Self::get_directory(
+ &mut self.http_client,
+ &self.directory_url,
+ &mut self.directory,
+ &mut self.nonce,
+ )
+ .await?
+ .0)
+ }
+
+ async fn get_directory<'a, 'b>(
+ http_client: &mut Option<SimpleHttp>,
+ directory_url: &str,
+ directory: &'a mut Option<Directory>,
+ nonce: &'b mut Option<String>,
+ ) -> Result<(&'a Directory, Option<&'b str>), Error> {
+ if let Some(d) = directory {
+ return Ok((d, nonce.as_deref()));
+ }
+
+ let response = Self::execute(
+ http_client,
+ AcmeRequest {
+ url: directory_url.to_string(),
+ method: "GET",
+ content_type: "",
+ body: String::new(),
+ expected: 200,
+ },
+ nonce,
+ )
+ .await?;
+
+ *directory = Some(Directory::from_parts(
+ directory_url.to_string(),
+ response.json()?,
+ ));
+
+ Ok((directory.as_ref().unwrap(), nonce.as_deref()))
+ }
+
+ /// Like `get_directory`, but if the directory provides no nonce, also performs a `HEAD`
+ /// request on the new nonce URL.
+ async fn get_dir_nonce<'a, 'b>(
+ http_client: &mut Option<SimpleHttp>,
+ directory_url: &str,
+ directory: &'a mut Option<Directory>,
+ nonce: &'b mut Option<String>,
+ ) -> Result<(&'a Directory, &'b str), Error> {
+ // this let construct is a lifetime workaround:
+ let _ = Self::get_directory(http_client, directory_url, directory, nonce).await?;
+ let dir = directory.as_ref().unwrap(); // the above fails if it couldn't fill this option
+ if nonce.is_none() {
+ // this is also a lifetime issue...
+ let _ = Self::get_nonce(http_client, nonce, dir.new_nonce_url()).await?;
+ };
+ Ok((dir, nonce.as_deref().unwrap()))
+ }
+
+ pub async fn terms_of_service_url(&mut self) -> Result<Option<&str>, Error> {
+ Ok(self.directory().await?.terms_of_service_url())
+ }
+
+ async fn get_nonce<'a>(
+ http_client: &mut Option<SimpleHttp>,
+ nonce: &'a mut Option<String>,
+ new_nonce_url: &str,
+ ) -> Result<&'a str, Error> {
+ let response = Self::execute(
+ http_client,
+ AcmeRequest {
+ url: new_nonce_url.to_owned(),
+ method: "HEAD",
+ content_type: "",
+ body: String::new(),
+ expected: 200,
+ },
+ nonce,
+ )
+ .await?;
+
+ if !response.got_nonce {
+ return Err(Error::InvalidApi(
+ "no new nonce received from new nonce URL".to_string(),
+ ));
+ }
+
+ nonce
+ .as_deref()
+ .ok_or_else(|| Error::Client("failed to update nonce".to_string()))
+ }
+}
+
+/// bad nonce retry count helper
+struct Retry(usize);
+
+const fn retry() -> Retry {
+ Retry(0)
+}
+
+impl Retry {
+ fn tick(&mut self) -> Result<(), Error> {
+ if self.0 >= 3 {
+ Error::Client(format!("kept getting a badNonce error!"));
+ }
+ self.0 += 1;
+ Ok(())
+ }
+}
diff --git a/src/acme/mod.rs b/src/acme/mod.rs
new file mode 100644
index 00000000..bf61811c
--- /dev/null
+++ b/src/acme/mod.rs
@@ -0,0 +1,5 @@
+mod client;
+pub use client::AcmeClient;
+
+pub(crate) mod plugin;
+pub(crate) use plugin::get_acme_plugin;
diff --git a/src/acme/plugin.rs b/src/acme/plugin.rs
new file mode 100644
index 00000000..860e7750
--- /dev/null
+++ b/src/acme/plugin.rs
@@ -0,0 +1,299 @@
+use std::future::Future;
+use std::pin::Pin;
+use std::process::Stdio;
+use std::sync::Arc;
+
+use anyhow::{bail, format_err, Error};
+use hyper::{Body, Request, Response};
+use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWriteExt, BufReader};
+use tokio::process::Command;
+
+use proxmox_acme_rs::{Authorization, Challenge};
+
+use crate::acme::AcmeClient;
+use crate::config::acme::AcmeDomain;
+use crate::server::WorkerTask;
+
+use crate::config::acme::plugin::{DnsPlugin, PluginData};
+
+const PROXMOX_ACME_SH_PATH: &str = "/usr/share/proxmox-acme/proxmox-acme";
+
+pub(crate) fn get_acme_plugin(
+ plugin_data: &PluginData,
+ name: &str,
+) -> Result<Option<Box<dyn AcmePlugin + Send + Sync + 'static>>, Error> {
+ let (ty, data) = match plugin_data.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" => {
+ // this one has no config
+ Box::new(StandaloneServer::default())
+ }
+ other => bail!("missing implementation for plugin type '{}'", other),
+ }))
+}
+
+pub(crate) 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 mut self,
+ client: &'b mut AcmeClient,
+ authorization: &'c Authorization,
+ domain: &'d AcmeDomain,
+ task: Arc<WorkerTask>,
+ ) -> Pin<Box<dyn Future<Output = Result<&'c str, Error>> + Send + 'fut>>;
+
+ fn teardown<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>(
+ &'a mut self,
+ client: &'b mut AcmeClient,
+ authorization: &'c Authorization,
+ domain: &'d AcmeDomain,
+ task: Arc<WorkerTask>,
+ ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'fut>>;
+}
+
+fn extract_challenge<'a>(
+ authorization: &'a Authorization,
+ ty: &str,
+) -> Result<&'a Challenge, Error> {
+ authorization
+ .challenges
+ .iter()
+ .find(|ch| ch.ty == ty)
+ .ok_or_else(|| format_err!("no supported challenge type (dns-01) found"))
+}
+
+async fn pipe_to_tasklog<T: AsyncRead + Unpin>(
+ pipe: T,
+ task: Arc<WorkerTask>,
+) -> Result<(), std::io::Error> {
+ let mut pipe = BufReader::new(pipe);
+ let mut line = String::new();
+ loop {
+ line.clear();
+ match pipe.read_line(&mut line).await {
+ Ok(0) => return Ok(()),
+ Ok(_) => task.log(line.as_str()),
+ Err(err) => return Err(err),
+ }
+ }
+}
+
+impl DnsPlugin {
+ async fn action<'a>(
+ &self,
+ client: &mut AcmeClient,
+ authorization: &'a Authorization,
+ domain: &AcmeDomain,
+ task: Arc<WorkerTask>,
+ action: &str,
+ ) -> Result<&'a str, Error> {
+ let challenge = extract_challenge(authorization, "dns-01")?;
+ 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",
+ PROXMOX_ACME_SH_PATH,
+ action,
+ &self.core.api,
+ domain.alias.as_deref().unwrap_or(&domain.domain),
+ ]);
+
+ // We could use 1 socketpair, but tokio wraps them all in `File` internally causing `close`
+ // to be called separately on all of them without exception, so we need 3 pipes :-(
+
+ let mut child = command
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()?;
+
+ let mut stdin = child.stdin.take().expect("Stdio::piped()");
+ let stdout = child.stdout.take().expect("Stdio::piped() failed?");
+ let stdout = pipe_to_tasklog(stdout, Arc::clone(&task));
+ let stderr = child.stderr.take().expect("Stdio::piped() failed?");
+ let stderr = pipe_to_tasklog(stderr, Arc::clone(&task));
+ let stdin = async move {
+ stdin.write_all(&stdin_data).await?;
+ stdin.flush().await?;
+ Ok::<_, std::io::Error>(())
+ };
+ match futures::try_join!(stdin, stdout, stderr) {
+ Ok(((), (), ())) => (),
+ Err(err) => {
+ if let Err(err) = child.kill().await {
+ task.log(format!(
+ "failed to kill '{} {}' command: {}",
+ PROXMOX_ACME_SH_PATH, action, err
+ ));
+ }
+ bail!("'{}' failed: {}", PROXMOX_ACME_SH_PATH, err);
+ }
+ }
+
+ let status = child.wait().await?;
+ if !status.success() {
+ bail!(
+ "'{} {}' exited with error ({})",
+ PROXMOX_ACME_SH_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 mut self,
+ client: &'b mut AcmeClient,
+ authorization: &'c Authorization,
+ domain: &'d AcmeDomain,
+ task: Arc<WorkerTask>,
+ ) -> Pin<Box<dyn Future<Output = Result<&'c str, Error>> + Send + 'fut>> {
+ Box::pin(self.action(client, authorization, domain, task, "setup"))
+ }
+
+ fn teardown<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>(
+ &'a mut self,
+ client: &'b mut AcmeClient,
+ authorization: &'c Authorization,
+ domain: &'d AcmeDomain,
+ task: Arc<WorkerTask>,
+ ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'fut>> {
+ Box::pin(async move {
+ self.action(client, authorization, domain, task, "teardown")
+ .await
+ .map(drop)
+ })
+ }
+}
+
+#[derive(Default)]
+struct StandaloneServer {
+ abort_handle: Option<futures::future::AbortHandle>,
+}
+
+// In case the "order_certificates" future gets dropped between setup & teardown, let's also cancel
+// the HTTP listener on Drop:
+impl Drop for StandaloneServer {
+ fn drop(&mut self) {
+ self.stop();
+ }
+}
+
+impl StandaloneServer {
+ fn stop(&mut self) {
+ if let Some(abort) = self.abort_handle.take() {
+ abort.abort();
+ }
+ }
+}
+
+async fn standalone_respond(
+ req: Request<Body>,
+ path: Arc<String>,
+ key_auth: Arc<String>,
+) -> Result<Response<Body>, hyper::Error> {
+ if req.method() == hyper::Method::GET && req.uri().path() == path.as_str() {
+ Ok(Response::builder()
+ .status(http::StatusCode::OK)
+ .body(key_auth.as_bytes().to_vec().into())
+ .unwrap())
+ } else {
+ Ok(Response::builder()
+ .status(http::StatusCode::NOT_FOUND)
+ .body("Not found.".into())
+ .unwrap())
+ }
+}
+
+impl AcmePlugin for StandaloneServer {
+ fn setup<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>(
+ &'a mut self,
+ client: &'b mut AcmeClient,
+ authorization: &'c Authorization,
+ _domain: &'d AcmeDomain,
+ _task: Arc<WorkerTask>,
+ ) -> Pin<Box<dyn Future<Output = Result<&'c str, Error>> + Send + 'fut>> {
+ use hyper::server::conn::AddrIncoming;
+ use hyper::service::{make_service_fn, service_fn};
+
+ Box::pin(async move {
+ self.stop();
+
+ let challenge = extract_challenge(authorization, "http-01")?;
+ let token = challenge
+ .token()
+ .ok_or_else(|| format_err!("missing token in challenge"))?;
+ let key_auth = Arc::new(client.key_authorization(&token)?);
+ let path = Arc::new(format!("/.well-known/acme-challenge/{}", token));
+
+ let service = make_service_fn(move |_| {
+ let path = Arc::clone(&path);
+ let key_auth = Arc::clone(&key_auth);
+ async move {
+ Ok::<_, hyper::Error>(service_fn(move |request| {
+ standalone_respond(request, Arc::clone(&path), Arc::clone(&key_auth))
+ }))
+ }
+ });
+
+ // `[::]:80` first, then `*:80`
+ let incoming = AddrIncoming::bind(&(([0u16; 8], 80).into()))
+ .or_else(|_| AddrIncoming::bind(&(([0u8; 4], 80).into())))?;
+
+ let server = hyper::Server::builder(incoming).serve(service);
+
+ let (future, abort) = futures::future::abortable(server);
+ self.abort_handle = Some(abort);
+ tokio::spawn(future);
+
+ Ok(challenge.url.as_str())
+ })
+ }
+
+ fn teardown<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>(
+ &'a mut self,
+ _client: &'b mut AcmeClient,
+ _authorization: &'c Authorization,
+ _domain: &'d AcmeDomain,
+ _task: Arc<WorkerTask>,
+ ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'fut>> {
+ Box::pin(async move {
+ if let Some(abort) = self.abort_handle.take() {
+ abort.abort();
+ }
+ Ok(())
+ })
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 200cf496..1b1de527 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -32,3 +32,5 @@ pub mod auth;
pub mod rrd;
pub mod tape;
+
+pub mod acme;
--
2.20.1
^ permalink raw reply [flat|nested] 12+ messages in thread
* [pbs-devel] [REBASED v2 backup 3/9] add node config
2021-05-03 9:39 [pbs-devel] [REBASED v2 backup 0/9] rebased and reordered acme implementation Wolfgang Bumiller
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 1/9] add acme config Wolfgang Bumiller
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 2/9] add acme client Wolfgang Bumiller
@ 2021-05-03 9:39 ` Wolfgang Bumiller
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 4/9] add config/acme api path Wolfgang Bumiller
` (6 subsequent siblings)
9 siblings, 0 replies; 12+ messages in thread
From: Wolfgang Bumiller @ 2021-05-03 9:39 UTC (permalink / raw)
To: pbs-devel
Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
src/config.rs | 1 +
src/config/node.rs | 202 +++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 203 insertions(+)
create mode 100644 src/config/node.rs
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<File, Error> {
+ 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<String>,
+
+ /// ACME domain to get a certificate for for this node.
+ #[serde(skip_serializing_if = "Updater::is_empty")]
+ acmedomain0: Option<String>,
+
+ /// ACME domain to get a certificate for for this node.
+ #[serde(skip_serializing_if = "Updater::is_empty")]
+ acmedomain1: Option<String>,
+
+ /// ACME domain to get a certificate for for this node.
+ #[serde(skip_serializing_if = "Updater::is_empty")]
+ acmedomain2: Option<String>,
+
+ /// ACME domain to get a certificate for for this node.
+ #[serde(skip_serializing_if = "Updater::is_empty")]
+ acmedomain3: Option<String>,
+
+ /// ACME domain to get a certificate for for this node.
+ #[serde(skip_serializing_if = "Updater::is_empty")]
+ acmedomain4: Option<String>,
+}
+
+impl NodeConfig {
+ pub fn acme_config(&self) -> Option<Result<AcmeConfig, Error>> {
+ 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, Error> {
+ 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<AcmeDomain, Error>;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ 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,
+ ))
+ }
+}
--
2.20.1
^ permalink raw reply [flat|nested] 12+ messages in thread
* [pbs-devel] [REBASED v2 backup 4/9] add config/acme api path
2021-05-03 9:39 [pbs-devel] [REBASED v2 backup 0/9] rebased and reordered acme implementation Wolfgang Bumiller
` (2 preceding siblings ...)
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 3/9] add node config Wolfgang Bumiller
@ 2021-05-03 9:39 ` Wolfgang Bumiller
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 5/9] add node/{node}/certificates api call Wolfgang Bumiller
` (5 subsequent siblings)
9 siblings, 0 replies; 12+ messages in thread
From: Wolfgang Bumiller @ 2021-05-03 9:39 UTC (permalink / raw)
To: pbs-devel
Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
src/api2/config.rs | 2 +
src/api2/config/acme.rs | 725 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 727 insertions(+)
create mode 100644 src/api2/config/acme.rs
diff --git a/src/api2/config.rs b/src/api2/config.rs
index 996ec268..9befa0e5 100644
--- a/src/api2/config.rs
+++ b/src/api2/config.rs
@@ -4,6 +4,7 @@ use proxmox::api::router::{Router, SubdirMap};
use proxmox::list_subdirs_api_method;
pub mod access;
+pub mod acme;
pub mod datastore;
pub mod remote;
pub mod sync;
@@ -16,6 +17,7 @@ pub mod tape_backup_job;
const SUBDIRS: SubdirMap = &[
("access", &access::ROUTER),
+ ("acme", &acme::ROUTER),
("changer", &changer::ROUTER),
("datastore", &datastore::ROUTER),
("drive", &drive::ROUTER),
diff --git a/src/api2/config/acme.rs b/src/api2/config/acme.rs
new file mode 100644
index 00000000..14a749d1
--- /dev/null
+++ b/src/api2/config/acme.rs
@@ -0,0 +1,725 @@
+use std::path::Path;
+
+use anyhow::{bail, format_err, Error};
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+
+use proxmox::api::router::SubdirMap;
+use proxmox::api::schema::Updatable;
+use proxmox::api::{api, Permission, Router, RpcEnvironment};
+use proxmox::http_bail;
+use proxmox::list_subdirs_api_method;
+
+use proxmox_acme_rs::account::AccountData as AcmeAccountData;
+use proxmox_acme_rs::Account;
+
+use crate::acme::AcmeClient;
+use crate::api2::types::Authid;
+use crate::config::acl::PRIV_SYS_MODIFY;
+use crate::config::acme::plugin::{
+ DnsPlugin, DnsPluginCore, DnsPluginCoreUpdater, PLUGIN_ID_SCHEMA,
+};
+use crate::config::acme::{AccountName, KnownAcmeDirectory};
+use crate::server::WorkerTask;
+use crate::tools::ControlFlow;
+
+pub(crate) const ROUTER: Router = Router::new()
+ .get(&list_subdirs_api_method!(SUBDIRS))
+ .subdirs(SUBDIRS);
+
+const SUBDIRS: SubdirMap = &[
+ (
+ "account",
+ &Router::new()
+ .get(&API_METHOD_LIST_ACCOUNTS)
+ .post(&API_METHOD_REGISTER_ACCOUNT)
+ .match_all("name", &ACCOUNT_ITEM_ROUTER),
+ ),
+ (
+ "challenge-schema",
+ &Router::new().get(&API_METHOD_GET_CHALLENGE_SCHEMA),
+ ),
+ (
+ "directories",
+ &Router::new().get(&API_METHOD_GET_DIRECTORIES),
+ ),
+ (
+ "plugins",
+ &Router::new()
+ .get(&API_METHOD_LIST_PLUGINS)
+ .post(&API_METHOD_ADD_PLUGIN)
+ .match_all("id", &PLUGIN_ITEM_ROUTER),
+ ),
+ ("tos", &Router::new().get(&API_METHOD_GET_TOS)),
+];
+
+const ACCOUNT_ITEM_ROUTER: Router = Router::new()
+ .get(&API_METHOD_GET_ACCOUNT)
+ .put(&API_METHOD_UPDATE_ACCOUNT)
+ .delete(&API_METHOD_DEACTIVATE_ACCOUNT);
+
+const PLUGIN_ITEM_ROUTER: Router = Router::new()
+ .get(&API_METHOD_GET_PLUGIN)
+ .put(&API_METHOD_UPDATE_PLUGIN)
+ .delete(&API_METHOD_DELETE_PLUGIN);
+
+#[api(
+ properties: {
+ name: { type: AccountName },
+ },
+)]
+/// An ACME Account entry.
+///
+/// Currently only contains a 'name' property.
+#[derive(Serialize)]
+pub struct AccountEntry {
+ name: AccountName,
+}
+
+#[api(
+ access: {
+ permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
+ },
+ returns: {
+ type: Array,
+ items: { type: AccountEntry },
+ description: "List of ACME accounts.",
+ },
+ protected: true,
+)]
+/// List ACME accounts.
+pub fn list_accounts() -> Result<Vec<AccountEntry>, Error> {
+ let mut entries = Vec::new();
+ crate::config::acme::foreach_acme_account(|name| {
+ entries.push(AccountEntry { name });
+ ControlFlow::Continue(())
+ })?;
+ Ok(entries)
+}
+
+#[api(
+ properties: {
+ account: { type: Object, properties: {}, additional_properties: true },
+ tos: {
+ type: String,
+ optional: true,
+ },
+ },
+)]
+/// ACME Account information.
+///
+/// This is what we return via the API.
+#[derive(Serialize)]
+pub struct AccountInfo {
+ /// Raw account data.
+ account: AcmeAccountData,
+
+ /// The ACME directory URL the account was created at.
+ directory: String,
+
+ /// The account's own URL within the ACME directory.
+ location: String,
+
+ /// The ToS URL, if the user agreed to one.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ tos: Option<String>,
+}
+
+#[api(
+ input: {
+ properties: {
+ name: { type: AccountName },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
+ },
+ returns: { type: AccountInfo },
+ protected: true,
+)]
+/// Return existing ACME account information.
+pub async fn get_account(name: AccountName) -> Result<AccountInfo, Error> {
+ let client = AcmeClient::load(&name).await?;
+ let account = client.account()?;
+ Ok(AccountInfo {
+ location: account.location.clone(),
+ tos: client.tos().map(str::to_owned),
+ directory: client.directory_url().to_owned(),
+ account: AcmeAccountData {
+ only_return_existing: false, // don't actually write this out in case it's set
+ ..account.data.clone()
+ },
+ })
+}
+
+fn account_contact_from_string(s: &str) -> Vec<String> {
+ s.split(&[' ', ';', ',', '\0'][..])
+ .map(|s| format!("mailto:{}", s))
+ .collect()
+}
+
+#[api(
+ input: {
+ properties: {
+ name: {
+ type: AccountName,
+ optional: true,
+ },
+ contact: {
+ description: "List of email addresses.",
+ },
+ tos_url: {
+ description: "URL of CA TermsOfService - setting this indicates agreement.",
+ optional: true,
+ },
+ directory: {
+ type: String,
+ description: "The ACME Directory.",
+ optional: true,
+ },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
+ },
+ protected: true,
+)]
+/// Register an ACME account.
+fn register_account(
+ name: Option<AccountName>,
+ // Todo: email & email-list schema
+ contact: String,
+ tos_url: Option<String>,
+ directory: Option<String>,
+ rpcenv: &mut dyn RpcEnvironment,
+) -> Result<String, Error> {
+ let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
+
+ let name = name
+ .unwrap_or_else(|| unsafe { AccountName::from_string_unchecked("default".to_string()) });
+
+ if Path::new(&crate::config::acme::account_path(&name)).exists() {
+ http_bail!(BAD_REQUEST, "account {:?} already exists", name);
+ }
+
+ let directory = directory.unwrap_or_else(|| {
+ crate::config::acme::DEFAULT_ACME_DIRECTORY_ENTRY
+ .url
+ .to_owned()
+ });
+
+ WorkerTask::spawn(
+ "acme-register",
+ None,
+ auth_id,
+ true,
+ move |worker| async move {
+ let mut client = AcmeClient::new(directory);
+
+ worker.log("Registering ACME account...");
+
+ let account =
+ do_register_account(&mut client, &name, tos_url.is_some(), contact, None).await?;
+
+ worker.log(format!(
+ "Registration successful, account URL: {}",
+ account.location
+ ));
+
+ Ok(())
+ },
+ )
+}
+
+pub async fn do_register_account<'a>(
+ client: &'a mut AcmeClient,
+ name: &AccountName,
+ agree_to_tos: bool,
+ contact: String,
+ rsa_bits: Option<u32>,
+) -> Result<&'a Account, Error> {
+ let contact = account_contact_from_string(&contact);
+ Ok(client
+ .new_account(name, agree_to_tos, contact, rsa_bits)
+ .await?)
+}
+
+#[api(
+ input: {
+ properties: {
+ name: { type: AccountName },
+ contact: {
+ description: "List of email addresses.",
+ optional: true,
+ },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
+ },
+ protected: true,
+)]
+/// Update an ACME account.
+pub fn update_account(
+ name: AccountName,
+ // Todo: email & email-list schema
+ contact: Option<String>,
+ rpcenv: &mut dyn RpcEnvironment,
+) -> Result<String, Error> {
+ let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
+
+ WorkerTask::spawn(
+ "acme-update",
+ None,
+ auth_id,
+ true,
+ move |_worker| async move {
+ let data = match contact {
+ Some(data) => json!({
+ "contact": account_contact_from_string(&data),
+ }),
+ None => json!({}),
+ };
+
+ AcmeClient::load(&name).await?.update_account(&data).await?;
+
+ Ok(())
+ },
+ )
+}
+
+#[api(
+ input: {
+ properties: {
+ name: { type: AccountName },
+ force: {
+ description:
+ "Delete account data even if the server refuses to deactivate the account.",
+ optional: true,
+ default: false,
+ },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
+ },
+ protected: true,
+)]
+/// Deactivate an ACME account.
+pub fn deactivate_account(
+ name: AccountName,
+ force: bool,
+ rpcenv: &mut dyn RpcEnvironment,
+) -> Result<String, Error> {
+ let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
+
+ WorkerTask::spawn(
+ "acme-deactivate",
+ None,
+ auth_id,
+ true,
+ move |worker| async move {
+ match AcmeClient::load(&name)
+ .await?
+ .update_account(&json!({"status": "deactivated"}))
+ .await
+ {
+ Ok(_account) => (),
+ Err(err) if !force => return Err(err),
+ Err(err) => {
+ worker.warn(format!(
+ "error deactivating account {:?}, proceedeing anyway - {}",
+ name, err,
+ ));
+ }
+ }
+ crate::config::acme::mark_account_deactivated(&name)?;
+ Ok(())
+ },
+ )
+}
+
+#[api(
+ input: {
+ properties: {
+ directory: {
+ type: String,
+ description: "The ACME Directory.",
+ optional: true,
+ },
+ },
+ },
+ access: {
+ permission: &Permission::Anybody,
+ },
+ returns: {
+ type: String,
+ optional: true,
+ description: "The ACME Directory's ToS URL, if any.",
+ },
+)]
+/// Get the Terms of Service URL for an ACME directory.
+async fn get_tos(directory: Option<String>) -> Result<Option<String>, Error> {
+ let directory = directory.unwrap_or_else(|| {
+ crate::config::acme::DEFAULT_ACME_DIRECTORY_ENTRY
+ .url
+ .to_owned()
+ });
+ Ok(AcmeClient::new(directory)
+ .terms_of_service_url()
+ .await?
+ .map(str::to_owned))
+}
+
+#[api(
+ access: {
+ permission: &Permission::Anybody,
+ },
+ returns: {
+ description: "List of known ACME directories.",
+ type: Array,
+ items: { type: KnownAcmeDirectory },
+ },
+)]
+/// Get named known ACME directory endpoints.
+fn get_directories() -> Result<&'static [KnownAcmeDirectory], Error> {
+ Ok(crate::config::acme::KNOWN_ACME_DIRECTORIES)
+}
+
+#[api(
+ properties: {
+ schema: {
+ type: Object,
+ additional_properties: true,
+ properties: {},
+ },
+ type: {
+ type: String,
+ },
+ },
+)]
+#[derive(Serialize)]
+/// Schema for an ACME challenge plugin.
+pub struct ChallengeSchema {
+ /// Plugin ID.
+ id: String,
+
+ /// Human readable name, falls back to id.
+ name: String,
+
+ /// Plugin Type.
+ #[serde(rename = "type")]
+ ty: &'static str,
+
+ /// The plugin's parameter schema.
+ schema: Value,
+}
+
+#[api(
+ access: {
+ permission: &Permission::Anybody,
+ },
+ returns: {
+ description: "ACME Challenge Plugin Shema.",
+ type: Array,
+ items: { type: ChallengeSchema },
+ },
+)]
+/// Get named known ACME directory endpoints.
+fn get_challenge_schema() -> Result<Vec<ChallengeSchema>, Error> {
+ let mut out = Vec::new();
+ crate::config::acme::foreach_dns_plugin(|id| {
+ out.push(ChallengeSchema {
+ id: id.to_owned(),
+ name: id.to_owned(),
+ ty: "dns",
+ schema: Value::Object(Default::default()),
+ });
+ ControlFlow::Continue(())
+ })?;
+ Ok(out)
+}
+
+#[api]
+#[derive(Default, Deserialize, Serialize)]
+#[serde(rename_all = "kebab-case")]
+/// The API's format is inherited from PVE/PMG:
+pub struct PluginConfig {
+ /// Plugin ID.
+ plugin: String,
+
+ /// Plugin type.
+ #[serde(rename = "type")]
+ ty: String,
+
+ /// DNS Api name.
+ api: Option<String>,
+
+ /// Plugin configuration data.
+ data: Option<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>,
+}
+
+// See PMG/PVE's $modify_cfg_for_api sub
+fn modify_cfg_for_api(id: &str, ty: &str, data: &Value) -> PluginConfig {
+ let mut entry = data.clone();
+
+ let obj = entry.as_object_mut().unwrap();
+ obj.remove("id");
+ obj.insert("plugin".to_string(), Value::String(id.to_owned()));
+ obj.insert("type".to_string(), Value::String(ty.to_owned()));
+
+ // FIXME: This needs to go once the `Updater` is fixed.
+ // None of these should be able to fail unless the user changed the files by hand, in which
+ // case we leave the unmodified string in the Value for now. This will be handled with an error
+ // later.
+ if let Some(Value::String(ref mut data)) = obj.get_mut("data") {
+ if let Ok(new) = base64::decode_config(&data, base64::URL_SAFE_NO_PAD) {
+ if let Ok(utf8) = String::from_utf8(new) {
+ *data = utf8;
+ }
+ }
+ }
+
+ // PVE/PMG do this explicitly for ACME plugins...
+ // obj.insert("digest".to_string(), Value::String(digest.clone()));
+
+ serde_json::from_value(entry).unwrap_or_else(|_| PluginConfig {
+ plugin: "*Error*".to_string(),
+ ty: "*Error*".to_string(),
+ ..Default::default()
+ })
+}
+
+#[api(
+ access: {
+ permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
+ },
+ protected: true,
+ returns: {
+ type: Array,
+ description: "List of ACME plugin configurations.",
+ items: { type: PluginConfig },
+ },
+)]
+/// List ACME challenge plugins.
+pub fn list_plugins(mut rpcenv: &mut dyn RpcEnvironment) -> Result<Vec<PluginConfig>, Error> {
+ use crate::config::acme::plugin;
+
+ let (plugins, digest) = plugin::config()?;
+ rpcenv["digest"] = proxmox::tools::digest_to_hex(&digest).into();
+ Ok(plugins
+ .iter()
+ .map(|(id, (ty, data))| modify_cfg_for_api(&id, &ty, data))
+ .collect())
+}
+
+#[api(
+ input: {
+ properties: {
+ id: { schema: PLUGIN_ID_SCHEMA },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
+ },
+ protected: true,
+ returns: { type: PluginConfig },
+)]
+/// List ACME challenge plugins.
+pub fn get_plugin(id: String, mut rpcenv: &mut dyn RpcEnvironment) -> Result<PluginConfig, Error> {
+ use crate::config::acme::plugin;
+
+ let (plugins, digest) = plugin::config()?;
+ rpcenv["digest"] = proxmox::tools::digest_to_hex(&digest).into();
+
+ match plugins.get(&id) {
+ Some((ty, data)) => Ok(modify_cfg_for_api(&id, &ty, &data)),
+ None => http_bail!(NOT_FOUND, "no such plugin"),
+ }
+}
+
+// Currently we only have "the" standalone plugin and DNS plugins so we can just flatten a
+// DnsPluginUpdater:
+//
+// FIXME: The 'id' parameter should not be "optional" in the schema.
+#[api(
+ input: {
+ properties: {
+ type: {
+ type: String,
+ description: "The ACME challenge plugin type.",
+ },
+ core: {
+ type: DnsPluginCoreUpdater,
+ flatten: true,
+ },
+ data: {
+ type: String,
+ // This is different in the API!
+ description: "DNS plugin data (base64 encoded with padding).",
+ },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
+ },
+ protected: true,
+)]
+/// Add ACME plugin configuration.
+pub fn add_plugin(r#type: String, core: DnsPluginCoreUpdater, data: String) -> Result<(), Error> {
+ use crate::config::acme::plugin;
+
+ // Currently we only support DNS plugins and the standalone plugin is "fixed":
+ if r#type != "dns" {
+ bail!("invalid ACME plugin type: {:?}", r#type);
+ }
+
+ let data = String::from_utf8(base64::decode(&data)?)
+ .map_err(|_| format_err!("data must be valid UTF-8"))?;
+ //core.api_fixup()?;
+
+ // FIXME: Solve the Updater with non-optional fields thing...
+ let id = core
+ .id
+ .clone()
+ .ok_or_else(|| format_err!("missing required 'id' parameter"))?;
+
+ let _lock = plugin::lock()?;
+
+ let (mut plugins, _digest) = plugin::config()?;
+ if plugins.contains_key(&id) {
+ bail!("ACME plugin ID {:?} already exists", id);
+ }
+
+ let plugin = serde_json::to_value(DnsPlugin {
+ core: DnsPluginCore::try_build_from(core)?,
+ data,
+ })?;
+
+ plugins.insert(id, r#type, plugin);
+
+ plugin::save_config(&plugins)?;
+
+ Ok(())
+}
+
+#[api(
+ input: {
+ properties: {
+ id: { schema: PLUGIN_ID_SCHEMA },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
+ },
+ protected: true,
+)]
+/// Delete an ACME plugin configuration.
+pub fn delete_plugin(id: String) -> Result<(), Error> {
+ use crate::config::acme::plugin;
+
+ let _lock = plugin::lock()?;
+
+ let (mut plugins, _digest) = plugin::config()?;
+ if plugins.remove(&id).is_none() {
+ http_bail!(NOT_FOUND, "no such plugin");
+ }
+ plugin::save_config(&plugins)?;
+
+ Ok(())
+}
+
+#[api(
+ input: {
+ properties: {
+ core_update: {
+ type: DnsPluginCoreUpdater,
+ flatten: true,
+ },
+ data: {
+ type: String,
+ optional: true,
+ // This is different in the API!
+ description: "DNS plugin data (base64 encoded with padding).",
+ },
+ digest: {
+ description: "Digest to protect against concurrent updates",
+ optional: true,
+ },
+ delete: {
+ description: "Options to remove from the configuration",
+ optional: true,
+ },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
+ },
+ protected: true,
+)]
+/// Update an ACME plugin configuration.
+pub fn update_plugin(
+ core_update: DnsPluginCoreUpdater,
+ data: Option<String>,
+ delete: Option<String>,
+ digest: Option<String>,
+) -> Result<(), Error> {
+ use crate::config::acme::plugin;
+
+ let data = data
+ .as_deref()
+ .map(base64::decode)
+ .transpose()?
+ .map(String::from_utf8)
+ .transpose()
+ .map_err(|_| format_err!("data must be valid UTF-8"))?;
+ //core_update.api_fixup()?;
+
+ // unwrap: the id is matched by this method's API path
+ let id = core_update.id.clone().unwrap();
+
+ let delete: Vec<&str> = delete
+ .as_deref()
+ .unwrap_or("")
+ .split(&[' ', ',', ';', '\0'][..])
+ .collect();
+
+ let _lock = plugin::lock()?;
+
+ let (mut plugins, expected_digest) = plugin::config()?;
+
+ if let Some(digest) = digest {
+ let digest = proxmox::tools::hex_to_digest(&digest)?;
+ crate::tools::detect_modified_configuration_file(&digest, &expected_digest)?;
+ }
+
+ match plugins.get_mut(&id) {
+ Some((ty, ref mut entry)) => {
+ if ty != "dns" {
+ bail!("cannot update plugin of type {:?}", ty);
+ }
+
+ let mut plugin: DnsPlugin = serde_json::from_value(entry.clone())?;
+ plugin.core.update_from(core_update, &delete)?;
+ if let Some(data) = data {
+ plugin.data = data;
+ }
+ *entry = serde_json::to_value(plugin)?;
+ }
+ None => http_bail!(NOT_FOUND, "no such plugin"),
+ }
+
+ plugin::save_config(&plugins)?;
+
+ Ok(())
+}
--
2.20.1
^ permalink raw reply [flat|nested] 12+ messages in thread
* [pbs-devel] [REBASED v2 backup 5/9] add node/{node}/certificates api call
2021-05-03 9:39 [pbs-devel] [REBASED v2 backup 0/9] rebased and reordered acme implementation Wolfgang Bumiller
` (3 preceding siblings ...)
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 4/9] add config/acme api path Wolfgang Bumiller
@ 2021-05-03 9:39 ` Wolfgang Bumiller
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 6/9] add node/{node}/config api path Wolfgang Bumiller
` (4 subsequent siblings)
9 siblings, 0 replies; 12+ messages in thread
From: Wolfgang Bumiller @ 2021-05-03 9:39 UTC (permalink / raw)
To: pbs-devel
API like in PVE:
GET .../info => current cert information
POST .../custom => upload custom certificate
DELETE .../custom => delete custom certificate
POST .../acme/certificate => order acme certificate
PUT .../acme/certificate => renew expiring acme cert
Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
src/api2/node.rs | 2 +
src/api2/node/certificates.rs | 579 ++++++++++++++++++++++++++++++++++
src/config.rs | 18 +-
3 files changed, 597 insertions(+), 2 deletions(-)
create mode 100644 src/api2/node/certificates.rs
diff --git a/src/api2/node.rs b/src/api2/node.rs
index 1f3e46a9..ebb51aaf 100644
--- a/src/api2/node.rs
+++ b/src/api2/node.rs
@@ -27,6 +27,7 @@ use crate::tools;
use crate::tools::ticket::{self, Empty, Ticket};
pub mod apt;
+pub mod certificates;
pub mod disks;
pub mod dns;
pub mod network;
@@ -314,6 +315,7 @@ fn upgrade_to_websocket(
pub const SUBDIRS: SubdirMap = &[
("apt", &apt::ROUTER),
+ ("certificates", &certificates::ROUTER),
("disks", &disks::ROUTER),
("dns", &dns::ROUTER),
("journal", &journal::ROUTER),
diff --git a/src/api2/node/certificates.rs b/src/api2/node/certificates.rs
new file mode 100644
index 00000000..edc6e536
--- /dev/null
+++ b/src/api2/node/certificates.rs
@@ -0,0 +1,579 @@
+use std::convert::TryFrom;
+use std::sync::Arc;
+use std::time::Duration;
+
+use anyhow::{bail, format_err, Error};
+use openssl::pkey::PKey;
+use openssl::x509::X509;
+use serde::{Deserialize, Serialize};
+
+use proxmox::api::router::SubdirMap;
+use proxmox::api::{api, Permission, Router, RpcEnvironment};
+use proxmox::list_subdirs_api_method;
+
+use crate::acme::AcmeClient;
+use crate::api2::types::Authid;
+use crate::api2::types::NODE_SCHEMA;
+use crate::config::acl::PRIV_SYS_MODIFY;
+use crate::config::acme::AcmeDomain;
+use crate::config::node::NodeConfig;
+use crate::server::WorkerTask;
+use crate::tools::cert;
+
+pub const ROUTER: Router = Router::new()
+ .get(&list_subdirs_api_method!(SUBDIRS))
+ .subdirs(SUBDIRS);
+
+const SUBDIRS: SubdirMap = &[
+ ("acme", &ACME_ROUTER),
+ (
+ "custom",
+ &Router::new()
+ .post(&API_METHOD_UPLOAD_CUSTOM_CERTIFICATE)
+ .delete(&API_METHOD_DELETE_CUSTOM_CERTIFICATE),
+ ),
+ ("info", &Router::new().get(&API_METHOD_GET_INFO)),
+];
+
+const ACME_ROUTER: Router = Router::new()
+ .get(&list_subdirs_api_method!(ACME_SUBDIRS))
+ .subdirs(ACME_SUBDIRS);
+
+const ACME_SUBDIRS: SubdirMap = &[(
+ "certificate",
+ &Router::new()
+ .post(&API_METHOD_NEW_ACME_CERT)
+ .put(&API_METHOD_RENEW_ACME_CERT),
+)];
+
+#[api(
+ properties: {
+ san: {
+ type: Array,
+ items: {
+ description: "A SubjectAlternateName entry.",
+ type: String,
+ },
+ },
+ },
+)]
+/// Certificate information.
+#[derive(Deserialize, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct CertificateInfo {
+ /// Certificate file name.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ filename: Option<String>,
+
+ /// Certificate subject name.
+ subject: String,
+
+ /// List of certificate's SubjectAlternativeName entries.
+ san: Vec<String>,
+
+ /// Certificate issuer name.
+ issuer: String,
+
+ /// Certificate's notBefore timestamp (UNIX epoch).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ notbefore: Option<i64>,
+
+ /// Certificate's notAfter timestamp (UNIX epoch).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ notafter: Option<i64>,
+
+ /// Certificate in PEM format.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pem: Option<String>,
+
+ /// Certificate's public key algorithm.
+ public_key_type: String,
+
+ /// Certificate's public key size if available.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ public_key_bits: Option<u32>,
+
+ /// The SSL Fingerprint.
+ fingerprint: Option<String>,
+}
+
+impl TryFrom<&cert::CertInfo> for CertificateInfo {
+ type Error = Error;
+
+ fn try_from(info: &cert::CertInfo) -> Result<Self, Self::Error> {
+ let pubkey = info.public_key()?;
+
+ Ok(Self {
+ filename: None,
+ subject: info.subject_name()?,
+ san: info
+ .subject_alt_names()
+ .map(|san| {
+ san.into_iter()
+ // FIXME: Support `.ipaddress()`?
+ .filter_map(|name| name.dnsname().map(str::to_owned))
+ .collect()
+ })
+ .unwrap_or_default(),
+ issuer: info.issuer_name()?,
+ notbefore: info.not_before_unix().ok(),
+ notafter: info.not_after_unix().ok(),
+ pem: None,
+ public_key_type: openssl::nid::Nid::from_raw(pubkey.id().as_raw())
+ .long_name()
+ .unwrap_or("<unsupported key type>")
+ .to_owned(),
+ public_key_bits: Some(pubkey.bits()),
+ fingerprint: Some(info.fingerprint()?),
+ })
+ }
+}
+
+fn get_certificate_pem() -> Result<String, Error> {
+ let cert_path = configdir!("/proxy.pem");
+ let cert_pem = proxmox::tools::fs::file_get_contents(&cert_path)?;
+ String::from_utf8(cert_pem)
+ .map_err(|_| format_err!("certificate in {:?} is not a valid PEM file", cert_path))
+}
+
+// to deduplicate error messages
+fn pem_to_cert_info(pem: &[u8]) -> Result<cert::CertInfo, Error> {
+ cert::CertInfo::from_pem(pem)
+ .map_err(|err| format_err!("error loading proxy certificate: {}", err))
+}
+
+#[api(
+ input: {
+ properties: {
+ node: { schema: NODE_SCHEMA },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
+ },
+ returns: {
+ type: Array,
+ items: { type: CertificateInfo },
+ description: "List of certificate infos.",
+ },
+)]
+/// Get certificate info.
+pub fn get_info() -> Result<Vec<CertificateInfo>, Error> {
+ let cert_pem = get_certificate_pem()?;
+ let cert = pem_to_cert_info(cert_pem.as_bytes())?;
+
+ Ok(vec![CertificateInfo {
+ filename: Some("proxy.pem".to_string()), // we only have the one
+ pem: Some(cert_pem),
+ ..CertificateInfo::try_from(&cert)?
+ }])
+}
+
+#[api(
+ input: {
+ properties: {
+ node: { schema: NODE_SCHEMA },
+ certificates: { description: "PEM encoded certificate (chain)." },
+ key: { description: "PEM encoded private key." },
+ restart: {
+ description: "Restart proxmox-backup-proxy",
+ optional: true,
+ default: false,
+ },
+ // FIXME: widget-toolkit should have an option to disable using this parameter...
+ force: {
+ description: "Force replacement of existing files.",
+ type: Boolean,
+ optional: true,
+ default: false,
+ },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
+ },
+ returns: {
+ type: Array,
+ items: { type: CertificateInfo },
+ description: "List of certificate infos.",
+ },
+ protected: true,
+)]
+/// Upload a custom certificate.
+pub fn upload_custom_certificate(
+ certificates: String,
+ key: String,
+ restart: bool,
+) -> Result<Vec<CertificateInfo>, Error> {
+ let certificates = X509::stack_from_pem(certificates.as_bytes())
+ .map_err(|err| format_err!("failed to decode certificate chain: {}", err))?;
+ let key = PKey::private_key_from_pem(key.as_bytes())
+ .map_err(|err| format_err!("failed to parse private key: {}", err))?;
+
+ let certificates = certificates
+ .into_iter()
+ .try_fold(Vec::<u8>::new(), |mut stack, cert| -> Result<_, Error> {
+ if !stack.is_empty() {
+ stack.push(b'\n');
+ }
+ stack.extend(cert.to_pem()?);
+ Ok(stack)
+ })
+ .map_err(|err| format_err!("error formatting certificate chain as PEM: {}", err))?;
+
+ let key = key.private_key_to_pem_pkcs8()?;
+
+ crate::config::set_proxy_certificate(&certificates, &key, restart)?;
+
+ get_info()
+}
+
+#[api(
+ input: {
+ properties: {
+ node: { schema: NODE_SCHEMA },
+ restart: {
+ description: "Restart proxmox-backup-proxy",
+ optional: true,
+ default: false,
+ },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
+ },
+ protected: true,
+)]
+/// Delete the current certificate and regenerate a self signed one.
+pub fn delete_custom_certificate(restart: bool) -> Result<(), Error> {
+ let cert_path = configdir!("/proxy.pem");
+ // Here we fail since if this fails nothing else breaks anyway
+ std::fs::remove_file(&cert_path)
+ .map_err(|err| format_err!("failed to unlink {:?} - {}", cert_path, err))?;
+
+ let key_path = configdir!("/proxy.key");
+ if let Err(err) = std::fs::remove_file(&key_path) {
+ // Here we just log since the certificate is already gone and we'd rather try to generate
+ // the self-signed certificate even if this fails:
+ log::error!(
+ "failed to remove certificate private key {:?} - {}",
+ key_path,
+ err
+ );
+ }
+
+ crate::config::update_self_signed_cert(true)?;
+
+ if restart {
+ crate::config::reload_proxy()?;
+ }
+
+ Ok(())
+}
+
+struct OrderedCertificate {
+ certificate: hyper::body::Bytes,
+ private_key_pem: Vec<u8>,
+}
+
+async fn order_certificate(
+ worker: Arc<WorkerTask>,
+ node_config: &NodeConfig,
+) -> Result<Option<OrderedCertificate>, Error> {
+ use proxmox_acme_rs::authorization::Status;
+ use proxmox_acme_rs::order::Identifier;
+
+ let domains = node_config.acme_domains().try_fold(
+ Vec::<AcmeDomain>::new(),
+ |mut acc, domain| -> Result<_, Error> {
+ let mut domain = domain?;
+ domain.domain.make_ascii_lowercase();
+ if let Some(alias) = &mut domain.alias {
+ alias.make_ascii_lowercase();
+ }
+ acc.push(domain);
+ Ok(acc)
+ },
+ )?;
+
+ let get_domain_config = |domain: &str| {
+ domains
+ .iter()
+ .find(|d| d.domain == domain)
+ .ok_or_else(|| format_err!("no config for domain '{}'", domain))
+ };
+
+ if domains.is_empty() {
+ worker.log("No domains configured to be ordered from an ACME server.");
+ return Ok(None);
+ }
+
+ let (plugins, _) = crate::config::acme::plugin::config()?;
+
+ let mut acme = node_config.acme_client().await?;
+
+ worker.log("Placing ACME order");
+ let order = acme
+ .new_order(domains.iter().map(|d| d.domain.to_ascii_lowercase()))
+ .await?;
+ worker.log(format!("Order URL: {}", order.location));
+
+ let identifiers: Vec<String> = order
+ .data
+ .identifiers
+ .iter()
+ .map(|identifier| match identifier {
+ Identifier::Dns(domain) => domain.clone(),
+ })
+ .collect();
+
+ for auth_url in &order.data.authorizations {
+ worker.log(format!("Getting authorization details from '{}'", auth_url));
+ let mut auth = acme.get_authorization(&auth_url).await?;
+
+ let domain = match &mut auth.identifier {
+ Identifier::Dns(domain) => domain.to_ascii_lowercase(),
+ };
+
+ if auth.status == Status::Valid {
+ worker.log(format!("{} is already validated!", domain));
+ continue;
+ }
+
+ worker.log(format!("The validation for {} is pending", domain));
+ let domain_config: &AcmeDomain = get_domain_config(&domain)?;
+ let plugin_id = domain_config.plugin.as_deref().unwrap_or("standalone");
+ let mut plugin_cfg =
+ crate::acme::get_acme_plugin(&plugins, plugin_id)?.ok_or_else(|| {
+ format_err!("plugin '{}' for domain '{}' not found!", plugin_id, domain)
+ })?;
+
+ worker.log("Setting up validation plugin");
+ let validation_url = plugin_cfg
+ .setup(&mut acme, &auth, domain_config, Arc::clone(&worker))
+ .await?;
+
+ let result = request_validation(&worker, &mut acme, auth_url, validation_url).await;
+
+ if let Err(err) = plugin_cfg
+ .teardown(&mut acme, &auth, domain_config, Arc::clone(&worker))
+ .await
+ {
+ worker.warn(format!(
+ "Failed to teardown plugin '{}' for domain '{}' - {}",
+ plugin_id, domain, err
+ ));
+ }
+
+ let _: () = result?;
+ }
+
+ worker.log("All domains validated");
+ worker.log("Creating CSR");
+
+ let csr = proxmox_acme_rs::util::Csr::generate(&identifiers, &Default::default())?;
+ let mut finalize_error_cnt = 0u8;
+ let order_url = &order.location;
+ let mut order;
+ loop {
+ use proxmox_acme_rs::order::Status;
+
+ order = acme.get_order(order_url).await?;
+
+ match order.status {
+ Status::Pending => {
+ worker.log("still pending, trying to finalize anyway");
+ let finalize = order
+ .finalize
+ .as_deref()
+ .ok_or_else(|| format_err!("missing 'finalize' URL in order"))?;
+ if let Err(err) = acme.finalize(finalize, &csr.data).await {
+ if finalize_error_cnt >= 5 {
+ return Err(err.into());
+ }
+
+ finalize_error_cnt += 1;
+ }
+ tokio::time::sleep(Duration::from_secs(5)).await;
+ }
+ Status::Ready => {
+ worker.log("order is ready, finalizing");
+ let finalize = order
+ .finalize
+ .as_deref()
+ .ok_or_else(|| format_err!("missing 'finalize' URL in order"))?;
+ acme.finalize(finalize, &csr.data).await?;
+ tokio::time::sleep(Duration::from_secs(5)).await;
+ }
+ Status::Processing => {
+ worker.log("still processing, trying again in 30 seconds");
+ tokio::time::sleep(Duration::from_secs(30)).await;
+ }
+ Status::Valid => {
+ worker.log("valid");
+ break;
+ }
+ other => bail!("order status: {:?}", other),
+ }
+ }
+
+ worker.log("Downloading certificate");
+ let certificate = acme
+ .get_certificate(
+ order
+ .certificate
+ .as_deref()
+ .ok_or_else(|| format_err!("missing certificate url in finalized order"))?,
+ )
+ .await?;
+
+ Ok(Some(OrderedCertificate {
+ certificate,
+ private_key_pem: csr.private_key_pem,
+ }))
+}
+
+async fn request_validation(
+ worker: &WorkerTask,
+ acme: &mut AcmeClient,
+ auth_url: &str,
+ validation_url: &str,
+) -> Result<(), Error> {
+ worker.log("Triggering validation");
+ acme.request_challenge_validation(&validation_url).await?;
+
+ worker.log("Sleeping for 5 seconds");
+ tokio::time::sleep(Duration::from_secs(5)).await;
+
+ loop {
+ use proxmox_acme_rs::authorization::Status;
+
+ let auth = acme.get_authorization(&auth_url).await?;
+ match auth.status {
+ Status::Pending => {
+ worker.log("Status is still 'pending', trying again in 10 seconds");
+ tokio::time::sleep(Duration::from_secs(10)).await;
+ }
+ Status::Valid => return Ok(()),
+ other => bail!(
+ "validating challenge '{}' failed - status: {:?}",
+ validation_url,
+ other
+ ),
+ }
+ }
+}
+
+#[api(
+ input: {
+ properties: {
+ node: { schema: NODE_SCHEMA },
+ force: {
+ description: "Force replacement of existing files.",
+ type: Boolean,
+ optional: true,
+ default: false,
+ },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
+ },
+ protected: true,
+)]
+/// Order a new ACME certificate.
+pub fn new_acme_cert(force: bool, rpcenv: &mut dyn RpcEnvironment) -> Result<String, Error> {
+ spawn_certificate_worker("acme-new-cert", force, rpcenv)
+}
+
+#[api(
+ input: {
+ properties: {
+ node: { schema: NODE_SCHEMA },
+ force: {
+ description: "Force replacement of existing files.",
+ type: Boolean,
+ optional: true,
+ default: false,
+ },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
+ },
+ protected: true,
+)]
+/// Renew the current ACME certificate if it expires within 30 days (or always if the `force`
+/// parameter is set).
+pub fn renew_acme_cert(force: bool, rpcenv: &mut dyn RpcEnvironment) -> Result<String, Error> {
+ if !cert_expires_soon()? && !force {
+ bail!("Certificate does not expire within the next 30 days and 'force' is not set.")
+ }
+
+ spawn_certificate_worker("acme-renew-cert", force, rpcenv)
+}
+
+/// Check whether the current certificate expires within the next 30 days.
+pub fn cert_expires_soon() -> Result<bool, Error> {
+ let cert = pem_to_cert_info(get_certificate_pem()?.as_bytes())?;
+ cert.is_expired_after_epoch(proxmox::tools::time::epoch_i64() + 30 * 24 * 60 * 60)
+ .map_err(|err| format_err!("Failed to check certificate expiration date: {}", err))
+}
+
+fn spawn_certificate_worker(
+ name: &'static str,
+ force: bool,
+ rpcenv: &mut dyn RpcEnvironment,
+) -> Result<String, Error> {
+ // We only have 1 certificate path in PBS which makes figuring out whether or not it is a
+ // custom one too hard... We keep the parameter because the widget-toolkit may be using it...
+ let _ = force;
+
+ let (node_config, _digest) = crate::config::node::config()?;
+
+ let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
+
+ WorkerTask::spawn(name, None, auth_id, true, move |worker| async move {
+ if let Some(cert) = order_certificate(worker, &node_config).await? {
+ crate::config::set_proxy_certificate(&cert.certificate, &cert.private_key_pem, true)?;
+ }
+ Ok(())
+ })
+}
+
+#[api(
+ input: {
+ properties: {
+ node: { schema: NODE_SCHEMA },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
+ },
+ protected: true,
+)]
+/// Renew the current ACME certificate if it expires within 30 days (or always if the `force`
+/// parameter is set).
+pub fn revoke_acme_cert(rpcenv: &mut dyn RpcEnvironment) -> Result<String, Error> {
+ let (node_config, _digest) = crate::config::node::config()?;
+
+ let cert_pem = get_certificate_pem()?;
+
+ let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
+
+ WorkerTask::spawn(
+ "acme-revoke-cert",
+ None,
+ auth_id,
+ true,
+ move |worker| async move {
+ worker.log("Loading ACME account");
+ let mut acme = node_config.acme_client().await?;
+ worker.log("Revoking old certificate");
+ acme.revoke_certificate(cert_pem.as_bytes(), None).await?;
+ worker.log("Deleting certificate and regenerating a self-signed one");
+ delete_custom_certificate(true)?;
+ Ok(())
+ },
+ )
+}
diff --git a/src/config.rs b/src/config.rs
index 94b7fb6c..22c293c9 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -187,12 +187,16 @@ pub fn update_self_signed_cert(force: bool) -> Result<(), Error> {
let x509 = x509.build();
let cert_pem = x509.to_pem()?;
- set_proxy_certificate(&cert_pem, &priv_pem)?;
+ set_proxy_certificate(&cert_pem, &priv_pem, false)?;
Ok(())
}
-pub(crate) fn set_proxy_certificate(cert_pem: &[u8], key_pem: &[u8]) -> Result<(), Error> {
+pub(crate) fn set_proxy_certificate(
+ cert_pem: &[u8],
+ key_pem: &[u8],
+ reload: bool,
+) -> Result<(), Error> {
let backup_user = crate::backup::backup_user()?;
let options = CreateOptions::new()
.perm(Mode::from_bits_truncate(0o0640))
@@ -206,5 +210,15 @@ pub(crate) fn set_proxy_certificate(cert_pem: &[u8], key_pem: &[u8]) -> Result<(
.map_err(|err| format_err!("error writing certificate private key - {}", err))?;
replace_file(&cert_path, &cert_pem, options)
.map_err(|err| format_err!("error writing certificate file - {}", err))?;
+
+ if reload {
+ reload_proxy()?;
+ }
+
Ok(())
}
+
+pub(crate) fn reload_proxy() -> Result<(), Error> {
+ crate::tools::systemd::reload_unit("proxmox-backup-proxy")
+ .map_err(|err| format_err!("error signaling reload to pbs proxy: {}", err))
+}
--
2.20.1
^ permalink raw reply [flat|nested] 12+ messages in thread
* [pbs-devel] [REBASED v2 backup 6/9] add node/{node}/config api path
2021-05-03 9:39 [pbs-devel] [REBASED v2 backup 0/9] rebased and reordered acme implementation Wolfgang Bumiller
` (4 preceding siblings ...)
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 ` Wolfgang Bumiller
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 7/9] add acme commands to proxmox-backup-manager Wolfgang Bumiller
` (3 subsequent siblings)
9 siblings, 0 replies; 12+ messages in thread
From: Wolfgang Bumiller @ 2021-05-03 9:39 UTC (permalink / raw)
To: pbs-devel
Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
src/api2/node.rs | 2 ++
src/api2/node/config.rs | 80 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 82 insertions(+)
create mode 100644 src/api2/node/config.rs
diff --git a/src/api2/node.rs b/src/api2/node.rs
index ebb51aaf..75271cd5 100644
--- a/src/api2/node.rs
+++ b/src/api2/node.rs
@@ -28,6 +28,7 @@ use crate::tools::ticket::{self, Empty, Ticket};
pub mod apt;
pub mod certificates;
+pub mod config;
pub mod disks;
pub mod dns;
pub mod network;
@@ -316,6 +317,7 @@ fn upgrade_to_websocket(
pub const SUBDIRS: SubdirMap = &[
("apt", &apt::ROUTER),
("certificates", &certificates::ROUTER),
+ ("config", &config::ROUTER),
("disks", &disks::ROUTER),
("dns", &dns::ROUTER),
("journal", &journal::ROUTER),
diff --git a/src/api2/node/config.rs b/src/api2/node/config.rs
new file mode 100644
index 00000000..fd997062
--- /dev/null
+++ b/src/api2/node/config.rs
@@ -0,0 +1,80 @@
+use anyhow::Error;
+use serde_json::Value;
+
+use proxmox::api::schema::Updatable;
+use proxmox::api::{api, Permission, Router, RpcEnvironment};
+
+use crate::api2::types::NODE_SCHEMA;
+use crate::config::acl::PRIV_SYS_MODIFY;
+use crate::config::node::NodeConfigUpdater;
+
+pub const ROUTER: Router = Router::new()
+ .get(&API_METHOD_GET_NODE_CONFIG)
+ .put(&API_METHOD_UPDATE_NODE_CONFIG);
+
+#[api(
+ input: {
+ properties: {
+ node: { schema: NODE_SCHEMA },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system"], PRIV_SYS_MODIFY, false),
+ },
+)]
+/// Create a new changer device.
+pub fn get_node_config(mut rpcenv: &mut dyn RpcEnvironment) -> Result<Value, Error> {
+ let (config, digest) = crate::config::node::config()?;
+ rpcenv["digest"] = proxmox::tools::digest_to_hex(&digest).into();
+ Ok(serde_json::to_value(config)?)
+}
+
+#[api(
+ input: {
+ properties: {
+ node: { schema: NODE_SCHEMA },
+ digest: {
+ description: "Digest to protect against concurrent updates",
+ optional: true,
+ },
+ updater: {
+ type: NodeConfigUpdater,
+ flatten: true,
+ },
+ delete: {
+ description: "Options to remove from the configuration",
+ optional: true,
+ },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system"], PRIV_SYS_MODIFY, false),
+ },
+ protected: true,
+)]
+/// Create a new changer device.
+pub fn update_node_config(
+ updater: NodeConfigUpdater,
+ delete: Option<String>,
+ digest: Option<String>,
+) -> Result<(), Error> {
+ let _lock = crate::config::node::lock()?;
+ let (mut config, expected_digest) = crate::config::node::config()?;
+ if let Some(digest) = digest {
+ // FIXME: GUI doesn't handle our non-inlined digest part here properly...
+ if !digest.is_empty() {
+ let digest = proxmox::tools::hex_to_digest(&digest)?;
+ crate::tools::detect_modified_configuration_file(&digest, &expected_digest)?;
+ }
+ }
+
+ let delete: Vec<&str> = delete
+ .as_deref()
+ .unwrap_or("")
+ .split(&[' ', ',', ';', '\0'][..])
+ .collect();
+
+ config.update_from(updater, &delete)?;
+
+ crate::config::node::save_config(&config)
+}
--
2.20.1
^ permalink raw reply [flat|nested] 12+ messages in thread
* [pbs-devel] [REBASED v2 backup 7/9] add acme commands to proxmox-backup-manager
2021-05-03 9:39 [pbs-devel] [REBASED v2 backup 0/9] rebased and reordered acme implementation Wolfgang Bumiller
` (5 preceding siblings ...)
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 ` Wolfgang Bumiller
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 8/9] ui: add certificate & acme view Wolfgang Bumiller
` (2 subsequent siblings)
9 siblings, 0 replies; 12+ messages in thread
From: Wolfgang Bumiller @ 2021-05-03 9:39 UTC (permalink / raw)
To: pbs-devel
Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
src/bin/proxmox-backup-manager.rs | 1 +
src/bin/proxmox_backup_manager/acme.rs | 415 +++++++++++++++++++++++++
src/bin/proxmox_backup_manager/mod.rs | 2 +
3 files changed, 418 insertions(+)
create mode 100644 src/bin/proxmox_backup_manager/acme.rs
diff --git a/src/bin/proxmox-backup-manager.rs b/src/bin/proxmox-backup-manager.rs
index 105a11f8..522c800e 100644
--- a/src/bin/proxmox-backup-manager.rs
+++ b/src/bin/proxmox-backup-manager.rs
@@ -355,6 +355,7 @@ fn main() {
.insert("user", user_commands())
.insert("remote", remote_commands())
.insert("garbage-collection", garbage_collection_commands())
+ .insert("acme", acme_mgmt_cli())
.insert("cert", cert_mgmt_cli())
.insert("subscription", subscription_commands())
.insert("sync-job", sync_job_commands())
diff --git a/src/bin/proxmox_backup_manager/acme.rs b/src/bin/proxmox_backup_manager/acme.rs
new file mode 100644
index 00000000..317473cb
--- /dev/null
+++ b/src/bin/proxmox_backup_manager/acme.rs
@@ -0,0 +1,415 @@
+use std::io::Write;
+
+use anyhow::{bail, Error};
+use serde_json::Value;
+
+use proxmox::api::{api, cli::*, ApiHandler, RpcEnvironment};
+use proxmox::tools::fs::file_get_contents;
+
+use proxmox_backup::acme::AcmeClient;
+use proxmox_backup::api2;
+use proxmox_backup::config::acme::plugin::DnsPluginCoreUpdater;
+use proxmox_backup::config::acme::{AccountName, KNOWN_ACME_DIRECTORIES};
+
+pub fn acme_mgmt_cli() -> CommandLineInterface {
+ let cmd_def = CliCommandMap::new()
+ .insert("account", account_cli())
+ .insert("cert", cert_cli())
+ .insert("plugin", plugin_cli());
+
+ cmd_def.into()
+}
+
+#[api(
+ input: {
+ properties: {
+ "output-format": {
+ schema: OUTPUT_FORMAT,
+ optional: true,
+ },
+ }
+ }
+)]
+/// List acme accounts.
+fn list_accounts(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
+ let output_format = get_output_format(¶m);
+
+ let info = &api2::config::acme::API_METHOD_LIST_ACCOUNTS;
+ let mut data = match info.handler {
+ ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?,
+ _ => unreachable!(),
+ };
+
+ let options = default_table_format_options();
+ format_and_print_result_full(&mut data, &info.returns, &output_format, &options);
+
+ Ok(())
+}
+
+#[api(
+ input: {
+ properties: {
+ name: { type: AccountName },
+ "output-format": {
+ schema: OUTPUT_FORMAT,
+ optional: true,
+ },
+ }
+ }
+)]
+/// Show acme account information.
+async fn get_account(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
+ let output_format = get_output_format(¶m);
+
+ let info = &api2::config::acme::API_METHOD_GET_ACCOUNT;
+ let mut data = match info.handler {
+ ApiHandler::Async(handler) => (handler)(param, info, rpcenv).await?,
+ _ => unreachable!(),
+ };
+
+ let options = default_table_format_options()
+ .column(
+ ColumnConfig::new("account")
+ .renderer(|value, _record| Ok(serde_json::to_string_pretty(value)?)),
+ )
+ .column(ColumnConfig::new("directory"))
+ .column(ColumnConfig::new("location"))
+ .column(ColumnConfig::new("tos"));
+ format_and_print_result_full(&mut data, &info.returns, &output_format, &options);
+
+ Ok(())
+}
+
+#[api(
+ input: {
+ properties: {
+ name: { type: AccountName },
+ contact: {
+ description: "List of email addresses.",
+ },
+ directory: {
+ type: String,
+ description: "The ACME Directory.",
+ optional: true,
+ },
+ }
+ }
+)]
+/// Register an ACME account.
+async fn register_account(
+ name: AccountName,
+ contact: String,
+ directory: Option<String>,
+) -> Result<(), Error> {
+ let directory = match directory {
+ Some(directory) => directory,
+ None => {
+ println!("Directory endpoints:");
+ for (i, dir) in KNOWN_ACME_DIRECTORIES.iter().enumerate() {
+ println!("{}) {}", i, dir.url);
+ }
+
+ println!("{}) Custom", KNOWN_ACME_DIRECTORIES.len());
+ let mut attempt = 0;
+ loop {
+ print!("Enter selection: ");
+ std::io::stdout().flush()?;
+
+ let mut input = String::new();
+ std::io::stdin().read_line(&mut input)?;
+
+ match input.trim().parse::<usize>() {
+ Ok(n) if n < KNOWN_ACME_DIRECTORIES.len() => {
+ break KNOWN_ACME_DIRECTORIES[n].url.to_owned();
+ }
+ Ok(n) if n == KNOWN_ACME_DIRECTORIES.len() => {
+ input.clear();
+ std::io::stdin().read_line(&mut input)?;
+ break input.trim().to_owned();
+ }
+ _ => eprintln!("Invalid selection."),
+ }
+
+ attempt += 1;
+ if attempt >= 3 {
+ bail!("Aborting.");
+ }
+ }
+ }
+ };
+
+ println!("Attempting to fetch Terms of Service from {:?}", directory);
+ let mut client = AcmeClient::new(directory.clone());
+ let tos_agreed = if let Some(tos_url) = client.terms_of_service_url().await? {
+ println!("Terms of Service: {}", tos_url);
+ print!("Do you agree to the above terms? [y|N]: ");
+ std::io::stdout().flush()?;
+ let mut input = String::new();
+ std::io::stdin().read_line(&mut input)?;
+ if input.trim().eq_ignore_ascii_case("y") {
+ true
+ } else {
+ false
+ }
+ } else {
+ false
+ };
+
+ println!("Attempting to register account with {:?}...", directory);
+
+ let account =
+ api2::config::acme::do_register_account(&mut client, &name, tos_agreed, contact, None)
+ .await?;
+
+ println!("Registration successful, account URL: {}", account.location);
+
+ Ok(())
+}
+
+#[api(
+ input: {
+ properties: {
+ name: { type: AccountName },
+ contact: {
+ description: "List of email addresses.",
+ type: String,
+ optional: true,
+ },
+ }
+ }
+)]
+/// Update an ACME account.
+async fn update_account(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
+ let info = &api2::config::acme::API_METHOD_UPDATE_ACCOUNT;
+ let result = match info.handler {
+ ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?,
+ _ => unreachable!(),
+ };
+
+ crate::wait_for_local_worker(result.as_str().unwrap()).await?;
+
+ Ok(())
+}
+
+#[api(
+ input: {
+ properties: {
+ name: { type: AccountName },
+ force: {
+ description:
+ "Delete account data even if the server refuses to deactivate the account.",
+ type: Boolean,
+ optional: true,
+ default: false,
+ },
+ }
+ }
+)]
+/// Deactivate an ACME account.
+async fn deactivate_account(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
+ let info = &api2::config::acme::API_METHOD_DEACTIVATE_ACCOUNT;
+ let result = match info.handler {
+ ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?,
+ _ => unreachable!(),
+ };
+
+ crate::wait_for_local_worker(result.as_str().unwrap()).await?;
+
+ Ok(())
+}
+
+pub fn account_cli() -> CommandLineInterface {
+ let cmd_def = CliCommandMap::new()
+ .insert("list", CliCommand::new(&API_METHOD_LIST_ACCOUNTS))
+ .insert(
+ "register",
+ CliCommand::new(&API_METHOD_REGISTER_ACCOUNT).arg_param(&["name", "contact"]),
+ )
+ .insert(
+ "deactivate",
+ CliCommand::new(&API_METHOD_DEACTIVATE_ACCOUNT)
+ .arg_param(&["name"])
+ .completion_cb("name", crate::config::acme::complete_acme_account),
+ )
+ .insert(
+ "info",
+ CliCommand::new(&API_METHOD_GET_ACCOUNT)
+ .arg_param(&["name"])
+ .completion_cb("name", crate::config::acme::complete_acme_account),
+ )
+ .insert(
+ "update",
+ CliCommand::new(&API_METHOD_UPDATE_ACCOUNT)
+ .arg_param(&["name"])
+ .completion_cb("name", crate::config::acme::complete_acme_account),
+ );
+
+ cmd_def.into()
+}
+
+#[api(
+ input: {
+ properties: {
+ "output-format": {
+ schema: OUTPUT_FORMAT,
+ optional: true,
+ },
+ }
+ }
+)]
+/// List acme plugins.
+fn list_plugins(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
+ let output_format = get_output_format(¶m);
+
+ let info = &api2::config::acme::API_METHOD_LIST_PLUGINS;
+ let mut data = match info.handler {
+ ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?,
+ _ => unreachable!(),
+ };
+
+ let options = default_table_format_options();
+ format_and_print_result_full(&mut data, &info.returns, &output_format, &options);
+
+ Ok(())
+}
+
+#[api(
+ input: {
+ properties: {
+ id: {
+ type: String,
+ description: "Plugin ID",
+ },
+ "output-format": {
+ schema: OUTPUT_FORMAT,
+ optional: true,
+ },
+ }
+ }
+)]
+/// Show acme account information.
+fn get_plugin(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
+ let output_format = get_output_format(¶m);
+
+ let info = &api2::config::acme::API_METHOD_GET_PLUGIN;
+ let mut data = match info.handler {
+ ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?,
+ _ => unreachable!(),
+ };
+
+ let options = default_table_format_options();
+ format_and_print_result_full(&mut data, &info.returns, &output_format, &options);
+
+ Ok(())
+}
+
+#[api(
+ input: {
+ properties: {
+ type: {
+ type: String,
+ description: "The ACME challenge plugin type.",
+ },
+ core: {
+ type: DnsPluginCoreUpdater,
+ flatten: true,
+ },
+ data: {
+ type: String,
+ description: "File containing the plugin data.",
+ },
+ }
+ }
+)]
+/// Show acme account information.
+fn add_plugin(r#type: String, core: DnsPluginCoreUpdater, data: String) -> Result<(), Error> {
+ let data = base64::encode(&file_get_contents(&data)?);
+ api2::config::acme::add_plugin(r#type, core, data)?;
+ Ok(())
+}
+
+pub fn plugin_cli() -> CommandLineInterface {
+ use proxmox_backup::api2::config::acme;
+ let cmd_def = CliCommandMap::new()
+ .insert("list", CliCommand::new(&API_METHOD_LIST_PLUGINS))
+ .insert(
+ "config", // name comes from pve/pmg
+ CliCommand::new(&API_METHOD_GET_PLUGIN)
+ .arg_param(&["id"])
+ .completion_cb("id", crate::config::acme::complete_acme_plugin),
+ )
+ .insert(
+ "add",
+ CliCommand::new(&API_METHOD_ADD_PLUGIN)
+ .arg_param(&["type", "id"])
+ .completion_cb("id", crate::config::acme::complete_acme_plugin)
+ .completion_cb("type", crate::config::acme::complete_acme_plugin_type),
+ )
+ .insert(
+ "remove",
+ CliCommand::new(&acme::API_METHOD_DELETE_PLUGIN)
+ .arg_param(&["id"])
+ .completion_cb("id", crate::config::acme::complete_acme_plugin),
+ )
+ .insert(
+ "set",
+ CliCommand::new(&acme::API_METHOD_UPDATE_PLUGIN)
+ .arg_param(&["id"])
+ .completion_cb("id", crate::config::acme::complete_acme_plugin),
+ );
+
+ cmd_def.into()
+}
+
+#[api(
+ input: {
+ properties: {
+ force: {
+ description: "Force renewal even if the certificate does not expire soon.",
+ type: Boolean,
+ optional: true,
+ default: false,
+ },
+ },
+ },
+)]
+/// Order a new ACME certificate.
+async fn order_acme_cert(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
+ if !param["force"].as_bool().unwrap_or(false) && !api2::node::certificates::cert_expires_soon()?
+ {
+ println!("Certificate does not expire within the next 30 days, not renewing.");
+ return Ok(());
+ }
+
+ let info = &api2::node::certificates::API_METHOD_RENEW_ACME_CERT;
+ let result = match info.handler {
+ ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?,
+ _ => unreachable!(),
+ };
+
+ crate::wait_for_local_worker(result.as_str().unwrap()).await?;
+
+ Ok(())
+}
+
+#[api]
+/// Order a new ACME certificate.
+async fn revoke_acme_cert(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
+ let info = &api2::node::certificates::API_METHOD_REVOKE_ACME_CERT;
+ let result = match info.handler {
+ ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?,
+ _ => unreachable!(),
+ };
+
+ crate::wait_for_local_worker(result.as_str().unwrap()).await?;
+
+ Ok(())
+}
+
+pub fn cert_cli() -> CommandLineInterface {
+ let cmd_def = CliCommandMap::new()
+ .insert("order", CliCommand::new(&API_METHOD_ORDER_ACME_CERT))
+ .insert("revoke", CliCommand::new(&API_METHOD_REVOKE_ACME_CERT));
+
+ cmd_def.into()
+}
diff --git a/src/bin/proxmox_backup_manager/mod.rs b/src/bin/proxmox_backup_manager/mod.rs
index 900144aa..e574e4d4 100644
--- a/src/bin/proxmox_backup_manager/mod.rs
+++ b/src/bin/proxmox_backup_manager/mod.rs
@@ -1,5 +1,7 @@
mod acl;
pub use acl::*;
+mod acme;
+pub use acme::*;
mod cert;
pub use cert::*;
mod datastore;
--
2.20.1
^ permalink raw reply [flat|nested] 12+ messages in thread
* [pbs-devel] [REBASED v2 backup 8/9] ui: add certificate & acme view
2021-05-03 9:39 [pbs-devel] [REBASED v2 backup 0/9] rebased and reordered acme implementation Wolfgang Bumiller
` (6 preceding siblings ...)
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 ` 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
9 siblings, 0 replies; 12+ messages in thread
From: Wolfgang Bumiller @ 2021-05-03 9:39 UTC (permalink / raw)
To: pbs-devel
Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
www/Makefile | 1 +
www/NavigationTree.js | 6 +++
www/config/CertificateView.js | 80 +++++++++++++++++++++++++++++++++++
3 files changed, 87 insertions(+)
create mode 100644 www/config/CertificateView.js
diff --git a/www/Makefile b/www/Makefile
index 2b847e74..f0b795ca 100644
--- a/www/Makefile
+++ b/www/Makefile
@@ -53,6 +53,7 @@ JSSRC= \
config/SyncView.js \
config/VerifyView.js \
config/WebauthnView.js \
+ config/CertificateView.js \
window/ACLEdit.js \
window/AddTfaRecovery.js \
window/AddTotp.js \
diff --git a/www/NavigationTree.js b/www/NavigationTree.js
index 8b1b96d9..6035526c 100644
--- a/www/NavigationTree.js
+++ b/www/NavigationTree.js
@@ -50,6 +50,12 @@ Ext.define('PBS.store.NavigationStore', {
path: 'pbsRemoteView',
leaf: true,
},
+ {
+ text: gettext('Certificates'),
+ iconCls: 'fa fa-certificate',
+ path: 'pbsCertificateConfiguration',
+ leaf: true,
+ },
{
text: gettext('Subscription'),
iconCls: 'fa fa-support',
diff --git a/www/config/CertificateView.js b/www/config/CertificateView.js
new file mode 100644
index 00000000..d1e26632
--- /dev/null
+++ b/www/config/CertificateView.js
@@ -0,0 +1,80 @@
+Ext.define('PBS.config.CertificateConfiguration', {
+ extend: 'Ext.tab.Panel',
+ alias: 'widget.pbsCertificateConfiguration',
+
+ title: gettext('Certificates'),
+
+ border: false,
+ defaults: { border: false },
+
+ items: [
+ {
+ itemId: 'certificates',
+ xtype: 'pbsCertificatesView',
+ },
+ {
+ itemId: 'acme',
+ xtype: 'pbsACMEConfigView',
+ },
+ ],
+});
+
+Ext.define('PBS.config.CertificatesView', {
+ extend: 'Ext.panel.Panel',
+ alias: 'widget.pbsCertificatesView',
+
+ title: gettext('Certificates'),
+ border: false,
+ defaults: {
+ border: false,
+ },
+
+ items: [
+ {
+ xtype: 'pmxCertificates',
+ nodename: 'localhost',
+ infoUrl: '/nodes/localhost/certificates/info',
+ uploadButtons: [
+ {
+ id: 'proxy.pem',
+ url: '/nodes/localhost/certificates/custom',
+ deletable: true,
+ reloadUi: true,
+ },
+ ],
+ },
+ {
+ xtype: 'pmxACMEDomains',
+ border: 0,
+ url: `/nodes/localhost/config`,
+ nodename: 'localhost',
+ acmeUrl: '/config/acme',
+ orderUrl: `/nodes/localhost/certificates/acme/certificate`,
+ separateDomainEntries: true,
+ },
+ ],
+});
+
+Ext.define('PBS.ACMEConfigView', {
+ extend: 'Ext.panel.Panel',
+ alias: 'widget.pbsACMEConfigView',
+
+ title: gettext('ACME Accounts'),
+
+ //onlineHelp: 'sysadmin_certificate_management',
+
+ items: [
+ {
+ region: 'north',
+ border: false,
+ xtype: 'pmxACMEAccounts',
+ acmeUrl: '/config/acme',
+ },
+ {
+ region: 'center',
+ border: false,
+ xtype: 'pmxACMEPluginView',
+ acmeUrl: '/config/acme',
+ },
+ ],
+});
--
2.20.1
^ permalink raw reply [flat|nested] 12+ messages in thread
* [pbs-devel] [REBASED v2 backup 9/9] daily-update: check acme certificates
2021-05-03 9:39 [pbs-devel] [REBASED v2 backup 0/9] rebased and reordered acme implementation Wolfgang Bumiller
` (7 preceding siblings ...)
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 8/9] ui: add certificate & acme view Wolfgang Bumiller
@ 2021-05-03 9:39 ` Wolfgang Bumiller
2021-05-04 7:57 ` [pbs-devel] applied: [REBASED v2 backup 0/9] rebased and reordered acme implementation Dietmar Maurer
9 siblings, 0 replies; 12+ messages in thread
From: Wolfgang Bumiller @ 2021-05-03 9:39 UTC (permalink / raw)
To: pbs-devel
Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
---
src/bin/proxmox-daily-update.rs | 30 +++++++++++++++++++++++++++++-
1 file changed, 29 insertions(+), 1 deletion(-)
diff --git a/src/bin/proxmox-daily-update.rs b/src/bin/proxmox-daily-update.rs
index 83c6b80c..be3bfe44 100644
--- a/src/bin/proxmox-daily-update.rs
+++ b/src/bin/proxmox-daily-update.rs
@@ -50,13 +50,41 @@ async fn do_update(
};
wait_for_local_worker(upid.as_str().unwrap()).await?;
- // TODO: certificate checks/renewal/... ?
+ match check_acme_certificates(rpcenv).await {
+ Ok(()) => (),
+ Err(err) => {
+ eprintln!("error checking certificates: {}", err);
+ }
+ }
// TODO: cleanup tasks like in PVE?
Ok(Value::Null)
}
+async fn check_acme_certificates(rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
+ let (config, _) = proxmox_backup::config::node::config()?;
+
+ // do we even have any acme domains configures?
+ if config.acme_domains().next().is_none() {
+ return Ok(());
+ }
+
+ if !api2::node::certificates::cert_expires_soon()? {
+ println!("Certificate does not expire within the next 30 days, not renewing.");
+ return Ok(());
+ }
+
+ let info = &api2::node::certificates::API_METHOD_RENEW_ACME_CERT;
+ let result = match info.handler {
+ ApiHandler::Sync(handler) => (handler)(json!({}), info, rpcenv)?,
+ _ => unreachable!(),
+ };
+ wait_for_local_worker(result.as_str().unwrap()).await?;
+
+ Ok(())
+}
+
fn main() {
proxmox_backup::tools::setup_safe_path_env();
--
2.20.1
^ permalink raw reply [flat|nested] 12+ messages in thread
* Re: [pbs-devel] [REBASED v2 backup 2/9] add acme client
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 2/9] add acme client Wolfgang Bumiller
@ 2021-05-04 6:10 ` Dietmar Maurer
0 siblings, 0 replies; 12+ messages in thread
From: Dietmar Maurer @ 2021-05-04 6:10 UTC (permalink / raw)
To: Proxmox Backup Server development discussion, Wolfgang Bumiller
Stupid question, but why is http_client an Option?
On 5/3/21 11:39 AM, Wolfgang Bumiller wrote:
> T
> +pub struct AcmeClient {
> + directory_url: String,
> + debug: bool,
> + account_path: Option<String>,
> + tos: Option<String>,
> + account: Option<Account>,
> + directory: Option<Directory>,
> + nonce: Option<String>,
> + http_client: Option<SimpleHttp>,
> +}
^ permalink raw reply [flat|nested] 12+ messages in thread
* [pbs-devel] applied: [REBASED v2 backup 0/9] rebased and reordered acme implementation
2021-05-03 9:39 [pbs-devel] [REBASED v2 backup 0/9] rebased and reordered acme implementation Wolfgang Bumiller
` (8 preceding siblings ...)
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 9/9] daily-update: check acme certificates Wolfgang Bumiller
@ 2021-05-04 7:57 ` Dietmar Maurer
9 siblings, 0 replies; 12+ messages in thread
From: Dietmar Maurer @ 2021-05-04 7:57 UTC (permalink / raw)
To: Proxmox Backup Server development discussion, Wolfgang Bumiller
applied with minor cleanups
On 5/3/21 11:39 AM, Wolfgang Bumiller wrote:
> Only minor changes, a split and some merges:
>
> * moved the validation plugin implementations from config::acme::plugin
> to acme::plugin, in order to separate the config again.
> * dropped the `read_lock` functions and renamed `write_lock` -> `lock`
> * removed some leftover commented out code
> * changed lock timeouts to 10s like in most other config parts
> * lock file name consistency ('.lock' -> '.lck')
> * kept AccountName for now, we still need to discuss how to
> approach this definitively
> * for less noise I merged the patches at the end of the series into the
> earlier ones, since they're mostly fixup commits:
> - creating acme directories before placing files in there
> - validate config before writing
> - pipe plugin output to task log
> - make account name optional in api call
> Also: standalone validation, since introducing dns validation together
> with the plugin config but adding this one later isn't consistent
> anyway.
>
> Wolfgang Bumiller (9):
> add acme config
> add acme client
> add node config
> add config/acme api path
> add node/{node}/certificates api call
> add node/{node}/config api path
> add acme commands to proxmox-backup-manager
> ui: add certificate & acme view
> daily-update: check acme certificates
>
> src/acme/client.rs | 673 +++++++++++++++++++++++
> src/acme/mod.rs | 5 +
> src/acme/plugin.rs | 299 ++++++++++
> src/api2/config.rs | 2 +
> src/api2/config/acme.rs | 725 +++++++++++++++++++++++++
> src/api2/node.rs | 4 +
> src/api2/node/certificates.rs | 579 ++++++++++++++++++++
> src/api2/node/config.rs | 80 +++
> src/bin/proxmox-backup-manager.rs | 1 +
> src/bin/proxmox-daily-update.rs | 30 +-
> src/bin/proxmox_backup_manager/acme.rs | 415 ++++++++++++++
> src/bin/proxmox_backup_manager/mod.rs | 2 +
> src/config.rs | 20 +-
> src/config/acme/mod.rs | 273 ++++++++++
> src/config/acme/plugin.rs | 213 ++++++++
> src/config/node.rs | 202 +++++++
> src/lib.rs | 2 +
> www/Makefile | 1 +
> www/NavigationTree.js | 6 +
> www/config/CertificateView.js | 80 +++
> 20 files changed, 3609 insertions(+), 3 deletions(-)
> create mode 100644 src/acme/client.rs
> create mode 100644 src/acme/mod.rs
> create mode 100644 src/acme/plugin.rs
> create mode 100644 src/api2/config/acme.rs
> create mode 100644 src/api2/node/certificates.rs
> create mode 100644 src/api2/node/config.rs
> create mode 100644 src/bin/proxmox_backup_manager/acme.rs
> create mode 100644 src/config/acme/mod.rs
> create mode 100644 src/config/acme/plugin.rs
> create mode 100644 src/config/node.rs
> create mode 100644 www/config/CertificateView.js
>
^ permalink raw reply [flat|nested] 12+ messages in thread
end of thread, other threads:[~2021-05-04 7:58 UTC | newest]
Thread overview: 12+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2021-05-03 9:39 [pbs-devel] [REBASED v2 backup 0/9] rebased and reordered acme implementation Wolfgang Bumiller
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 1/9] add acme config Wolfgang Bumiller
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
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.