all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Wolfgang Bumiller <w.bumiller@proxmox.com>
To: pbs-devel@lists.proxmox.com
Subject: [pbs-devel] [REBASED v2 backup 5/9] add node/{node}/certificates api call
Date: Mon,  3 May 2021 11:39:55 +0200	[thread overview]
Message-ID: <20210503093959.14855-6-w.bumiller@proxmox.com> (raw)
In-Reply-To: <20210503093959.14855-1-w.bumiller@proxmox.com>

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





  parent reply	other threads:[~2021-05-03  9:40 UTC|newest]

Thread overview: 12+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2021-05-03  9:39 [pbs-devel] [REBASED v2 backup 0/9] rebased and reordered acme implementation Wolfgang Bumiller
2021-05-03  9:39 ` [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 ` Wolfgang Bumiller [this message]
2021-05-03  9:39 ` [pbs-devel] [REBASED v2 backup 6/9] add node/{node}/config " Wolfgang Bumiller
2021-05-03  9:39 ` [pbs-devel] [REBASED v2 backup 7/9] add acme commands to proxmox-backup-manager Wolfgang Bumiller
2021-05-03  9:39 ` [pbs-devel] [REBASED v2 backup 8/9] ui: add certificate & acme view Wolfgang Bumiller
2021-05-03  9:39 ` [pbs-devel] [REBASED v2 backup 9/9] daily-update: check acme certificates Wolfgang Bumiller
2021-05-04  7:57 ` [pbs-devel] applied: [REBASED v2 backup 0/9] rebased and reordered acme implementation Dietmar Maurer

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20210503093959.14855-6-w.bumiller@proxmox.com \
    --to=w.bumiller@proxmox.com \
    --cc=pbs-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal