* [PATCH proxmox 01/16] acme-api/tls-certificates: add new crate collecting tls realted helpers
2026-07-30 13:31 [PATCH datacenter-manager/proxmox{,-backup} 00/16] TLS Certificate Rotation Shannon Sterz
@ 2026-07-30 13:31 ` Shannon Sterz
2026-07-30 13:31 ` [PATCH proxmox 02/16] tls-certificates: add days_valid parameter to create_self_signed_cert Shannon Sterz
` (15 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Shannon Sterz @ 2026-07-30 13:31 UTC (permalink / raw)
To: pbs-devel
and types. everything that is useful for dealing with tls certificates
but is not directly acme related should now be collected here. types
and functions that were previously part of proxmox-acme-api are
reexported there to avoid breakage. no functional changes intended for
users of proxmox-acme-api.
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
Notes:
feel free to bikeshed about the name of the new crate btw. imo the
name is a bit unwieldy even if it conveys the intended decently.
Cargo.toml | 2 +
proxmox-acme-api/Cargo.toml | 2 +
proxmox-acme-api/src/certificate_helpers.rs | 197 +-----------------
proxmox-acme-api/src/types.rs | 51 +----
proxmox-tls-certificates/Cargo.toml | 38 ++++
proxmox-tls-certificates/src/lib.rs | 7 +
proxmox-tls-certificates/src/types.rs | 54 +++++
proxmox-tls-certificates/src/util.rs | 209 ++++++++++++++++++++
8 files changed, 315 insertions(+), 245 deletions(-)
create mode 100644 proxmox-tls-certificates/Cargo.toml
create mode 100644 proxmox-tls-certificates/src/lib.rs
create mode 100644 proxmox-tls-certificates/src/types.rs
create mode 100644 proxmox-tls-certificates/src/util.rs
diff --git a/Cargo.toml b/Cargo.toml
index 16e91c94..c876267e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -63,6 +63,7 @@ members = [
"proxmox-tfa",
"proxmox-time",
"proxmox-time-api",
+ "proxmox-tls-certificates",
"proxmox-upgrade-checks",
"proxmox-uuid",
"proxmox-wireguard",
@@ -197,6 +198,7 @@ proxmox-sys = { version = "1.0.1", path = "proxmox-sys" }
proxmox-systemd = { version = "1.0.0", path = "proxmox-systemd" }
proxmox-tfa = { version = "6.0.0", path = "proxmox-tfa" }
proxmox-time = { version = "2.1.0", path = "proxmox-time" }
+proxmox-tls-certificates = { version = "1.0.0", path = "proxmox-tls-certificates" }
proxmox-uuid = { version = "1.1.0", path = "proxmox-uuid" }
proxmox-worker-task = { version = "1.0.0", path = "proxmox-worker-task" }
proxmox-node-status = { version = "1.0.0", path = "proxmox-node-status" }
diff --git a/proxmox-acme-api/Cargo.toml b/proxmox-acme-api/Cargo.toml
index 4bb1720b..741987e0 100644
--- a/proxmox-acme-api/Cargo.toml
+++ b/proxmox-acme-api/Cargo.toml
@@ -39,6 +39,7 @@ proxmox-section-config = { workspace = true, optional = true }
proxmox-serde.workspace = true
proxmox-sys = { workspace = true, optional = true }
proxmox-time = { workspace = true, optional = true }
+proxmox-tls-certificates = { workspace = true }
proxmox-uuid = { workspace = true, optional = true }
[features]
@@ -69,4 +70,5 @@ impl = [
"proxmox-acme/async-client",
"proxmox-acme/impl",
"proxmox-config-digest?/openssl",
+ "proxmox-tls-certificates/impl",
]
diff --git a/proxmox-acme-api/src/certificate_helpers.rs b/proxmox-acme-api/src/certificate_helpers.rs
index 3921b18e..47b72f36 100644
--- a/proxmox-acme-api/src/certificate_helpers.rs
+++ b/proxmox-acme-api/src/certificate_helpers.rs
@@ -1,19 +1,14 @@
-use std::mem::MaybeUninit;
use std::sync::Arc;
use std::time::Duration;
-use foreign_types::ForeignTypeRef;
-
use anyhow::{Error, bail, format_err};
-use openssl::pkey::{PKey, Private};
-use openssl::rsa::Rsa;
-use openssl::x509::{X509, X509Builder};
use proxmox_acme::async_client::AcmeClient;
use proxmox_log::{info, warn};
use proxmox_rest_server::WorkerTask;
-use crate::CertificateInfo;
+pub use proxmox_tls_certificates::create_self_signed_cert;
+
use crate::types::{AcmeConfig, AcmeDomain};
pub async fn revoke_certificate(acme_config: &AcmeConfig, certificate: &[u8]) -> Result<(), Error> {
@@ -210,191 +205,3 @@ async fn request_validation(
}
}
-pub fn create_self_signed_cert(
- product_name: &str,
- nodename: &str,
- domain: Option<&str>,
-) -> Result<(PKey<Private>, X509), Error> {
- let rsa = Rsa::generate(4096).unwrap();
-
- let mut x509 = X509Builder::new()?;
-
- x509.set_version(2)?;
-
- let today = openssl::asn1::Asn1Time::days_from_now(0)?;
- x509.set_not_before(&today)?;
- let expire = openssl::asn1::Asn1Time::days_from_now(365 * 1000)?;
- x509.set_not_after(&expire)?;
-
- let mut fqdn = nodename.to_owned();
-
- if let Some(domain) = domain {
- fqdn.push('.');
- fqdn.push_str(domain);
- }
-
- // we try to generate an unique 'subject' to avoid browser problems
- //(reused serial numbers, ..)
- let uuid = proxmox_uuid::Uuid::generate();
-
- let mut subject_name = openssl::x509::X509NameBuilder::new()?;
- subject_name.append_entry_by_text("O", product_name)?;
- subject_name.append_entry_by_text("OU", &format!("{uuid:X}"))?;
- subject_name.append_entry_by_text("CN", &fqdn)?;
- let subject_name = subject_name.build();
-
- x509.set_subject_name(&subject_name)?;
- x509.set_issuer_name(&subject_name)?;
-
- let bc = openssl::x509::extension::BasicConstraints::new(); // CA = false
- let bc = bc.build()?;
- x509.append_extension(bc)?;
-
- let usage = openssl::x509::extension::ExtendedKeyUsage::new()
- .server_auth()
- .build()?;
- x509.append_extension(usage)?;
-
- let context = x509.x509v3_context(None, None);
-
- let mut alt_names = openssl::x509::extension::SubjectAlternativeName::new();
-
- alt_names.ip("127.0.0.1");
- alt_names.ip("::1");
-
- alt_names.dns("localhost");
-
- if nodename != "localhost" {
- alt_names.dns(nodename);
- }
- if nodename != fqdn {
- alt_names.dns(&fqdn);
- }
-
- let alt_names = alt_names.build(&context)?;
-
- x509.append_extension(alt_names)?;
-
- let pub_pem = rsa.public_key_to_pem()?;
- let pubkey = PKey::public_key_from_pem(&pub_pem)?;
-
- x509.set_pubkey(&pubkey)?;
-
- let context = x509.x509v3_context(None, None);
- let ext = openssl::x509::extension::SubjectKeyIdentifier::new().build(&context)?;
- x509.append_extension(ext)?;
-
- let context = x509.x509v3_context(None, None);
- let ext = openssl::x509::extension::AuthorityKeyIdentifier::new()
- .keyid(true)
- .build(&context)?;
- x509.append_extension(ext)?;
-
- let privkey = PKey::from_rsa(rsa)?;
-
- x509.sign(&privkey, openssl::hash::MessageDigest::sha256())?;
-
- Ok((privkey, x509.build()))
-}
-
-impl CertificateInfo {
- pub fn from_pem(filename: &str, cert_pem: &[u8]) -> Result<Self, Error> {
- let x509 = openssl::x509::X509::from_pem(cert_pem)?;
-
- let cert_pem = String::from_utf8(cert_pem.to_vec())
- .map_err(|_| format_err!("certificate in {:?} is not a valid PEM file", filename))?;
-
- let pubkey = x509.public_key()?;
-
- let subject = x509name_to_string(x509.subject_name())?;
- let issuer = x509name_to_string(x509.issuer_name())?;
-
- let fingerprint = x509.digest(openssl::hash::MessageDigest::sha256())?;
- let fingerprint = hex::encode(fingerprint)
- .as_bytes()
- .chunks(2)
- .map(|v| std::str::from_utf8(v).unwrap())
- .collect::<Vec<&str>>()
- .join(":");
-
- let public_key_type = openssl::nid::Nid::from_raw(pubkey.id().as_raw())
- .long_name()
- .unwrap_or("<unsupported key type>")
- .to_owned();
-
- let san = x509
- .subject_alt_names()
- .map(|san| {
- san.into_iter()
- .filter_map(|name| {
- // this is not actually a map and we don't want to break the pattern
- #[allow(clippy::manual_map)]
- if let Some(name) = name.dnsname() {
- Some(format!("DNS: {name}"))
- } else if let Some(ip) = name.ipaddress() {
- Some(format!("IP: {ip:?}"))
- } else if let Some(email) = name.email() {
- Some(format!("EMAIL: {email}"))
- } else if let Some(uri) = name.uri() {
- Some(format!("URI: {uri}"))
- } else {
- None
- }
- })
- .collect()
- })
- .unwrap_or_default();
-
- Ok(CertificateInfo {
- filename: filename.to_string(),
- pem: Some(cert_pem),
- subject,
- issuer,
- fingerprint: Some(fingerprint),
- public_key_bits: Some(pubkey.bits()),
- notbefore: asn1_time_to_unix(x509.not_before()).ok(),
- notafter: asn1_time_to_unix(x509.not_after()).ok(),
- public_key_type,
- san,
- })
- }
-
- /// Check if the certificate is expired at or after a specific unix epoch.
- pub fn is_expired_after_epoch(&self, epoch: i64) -> Result<bool, Error> {
- if let Some(notafter) = self.notafter {
- Ok(notafter < epoch)
- } else {
- Ok(false)
- }
- }
-}
-
-fn x509name_to_string(name: &openssl::x509::X509NameRef) -> Result<String, Error> {
- let mut parts = Vec::new();
- for entry in name.entries() {
- parts.push(format!(
- "{} = {}",
- entry.object().nid().short_name()?,
- entry.data().as_utf8()?
- ));
- }
- Ok(parts.join(", "))
-}
-
-// C type:
-#[allow(non_camel_case_types)]
-type ASN1_TIME = <openssl::asn1::Asn1TimeRef as ForeignTypeRef>::CType;
-
-unsafe extern "C" {
- fn ASN1_TIME_to_tm(s: *const ASN1_TIME, tm: *mut libc::tm) -> libc::c_int;
-}
-
-fn asn1_time_to_unix(time: &openssl::asn1::Asn1TimeRef) -> Result<i64, Error> {
- let mut c_tm = MaybeUninit::<libc::tm>::uninit();
- let rc = unsafe { ASN1_TIME_to_tm(time.as_ptr(), c_tm.as_mut_ptr()) };
- if rc != 1 {
- bail!("failed to parse ASN1 time");
- }
- let mut c_tm = unsafe { c_tm.assume_init() };
- proxmox_time::timegm(&mut c_tm)
-}
diff --git a/proxmox-acme-api/src/types.rs b/proxmox-acme-api/src/types.rs
index 934e6ece..15b59908 100644
--- a/proxmox-acme-api/src/types.rs
+++ b/proxmox-acme-api/src/types.rs
@@ -11,56 +11,7 @@ use proxmox_schema::{ApiStringFormat, ApiType, Schema, StringSchema, Updater, ap
use proxmox_acme::types::AccountData as AcmeAccountData;
-#[api(
- properties: {
- san: {
- type: Array,
- items: {
- description: "A SubjectAlternateName entry.",
- type: String,
- },
- },
- },
-)]
-/// Certificate information.
-#[derive(PartialEq, Clone, Deserialize, Serialize)]
-#[serde(rename_all = "kebab-case")]
-pub struct CertificateInfo {
- /// Certificate file name.
- pub filename: String,
-
- /// Certificate subject name.
- pub subject: String,
-
- /// List of certificate's SubjectAlternativeName entries.
- pub san: Vec<String>,
-
- /// Certificate issuer name.
- pub issuer: String,
-
- /// Certificate's notBefore timestamp (UNIX epoch).
- #[serde(skip_serializing_if = "Option::is_none")]
- pub notbefore: Option<i64>,
-
- /// Certificate's notAfter timestamp (UNIX epoch).
- #[serde(skip_serializing_if = "Option::is_none")]
- pub notafter: Option<i64>,
-
- /// Certificate in PEM format.
- #[serde(skip_serializing_if = "Option::is_none")]
- pub pem: Option<String>,
-
- /// Certificate's public key algorithm.
- pub public_key_type: String,
-
- /// Certificate's public key size if available.
- #[serde(skip_serializing_if = "Option::is_none")]
- pub public_key_bits: Option<u32>,
-
- /// The SSL Fingerprint.
- #[serde(skip_serializing_if = "Option::is_none")]
- pub fingerprint: Option<String>,
-}
+pub use proxmox_tls_certificates::CertificateInfo;
proxmox_schema::api_string_type! {
#[api(format: &SAFE_ID_FORMAT)]
diff --git a/proxmox-tls-certificates/Cargo.toml b/proxmox-tls-certificates/Cargo.toml
new file mode 100644
index 00000000..105657f2
--- /dev/null
+++ b/proxmox-tls-certificates/Cargo.toml
@@ -0,0 +1,38 @@
+[package]
+name = "proxmox-tls-certificates"
+version = "1.0.0"
+authors.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+homepage.workspace = true
+exclude.workspace = true
+rust-version.workspace = true
+
+[dependencies]
+anyhow = { workspace = true, optional = true }
+foreign-types = { workspace = true, optional = true }
+hex = { workspace = true, optional = true }
+libc = { workspace = true, optional = true }
+openssl = { workspace = true, optional = true }
+serde = { workspace = true, features = [ "derive" ] }
+
+proxmox-schema = { workspace = true, features = [ "api-macro" ] }
+proxmox-sys = { workspace = true, optional = true }
+proxmox-time = { workspace = true, optional = true }
+proxmox-uuid = { workspace = true, optional = true }
+
+[features]
+default = []
+impl = [
+ "dep:anyhow",
+ "dep:const_format",
+ "dep:foreign-types",
+ "dep:hex",
+ "dep:libc",
+ "dep:openssl",
+
+ "dep:proxmox-time",
+ "dep:proxmox-uuid",
+ "dep:proxmox-sys",
+]
diff --git a/proxmox-tls-certificates/src/lib.rs b/proxmox-tls-certificates/src/lib.rs
new file mode 100644
index 00000000..454c700f
--- /dev/null
+++ b/proxmox-tls-certificates/src/lib.rs
@@ -0,0 +1,7 @@
+mod types;
+pub use types::CertificateInfo;
+
+#[cfg(feature = "impl")]
+mod util;
+#[cfg(feature = "impl")]
+pub use util::create_self_signed_cert;
diff --git a/proxmox-tls-certificates/src/types.rs b/proxmox-tls-certificates/src/types.rs
new file mode 100644
index 00000000..7ca2b004
--- /dev/null
+++ b/proxmox-tls-certificates/src/types.rs
@@ -0,0 +1,54 @@
+use serde::{Deserialize, Serialize};
+
+use proxmox_schema::api;
+
+#[api(
+ properties: {
+ san: {
+ type: Array,
+ items: {
+ description: "A SubjectAlternateName entry.",
+ type: String,
+ },
+ },
+ },
+)]
+/// Certificate information.
+#[derive(PartialEq, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct CertificateInfo {
+ /// Certificate file name.
+ pub filename: String,
+
+ /// Certificate subject name.
+ pub subject: String,
+
+ /// List of certificate's SubjectAlternativeName entries.
+ pub san: Vec<String>,
+
+ /// Certificate issuer name.
+ pub issuer: String,
+
+ /// Certificate's notBefore timestamp (UNIX epoch).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub notbefore: Option<i64>,
+
+ /// Certificate's notAfter timestamp (UNIX epoch).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub notafter: Option<i64>,
+
+ /// Certificate in PEM format.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub pem: Option<String>,
+
+ /// Certificate's public key algorithm.
+ pub public_key_type: String,
+
+ /// Certificate's public key size if available.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub public_key_bits: Option<u32>,
+
+ /// The SSL Fingerprint.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub fingerprint: Option<String>,
+}
diff --git a/proxmox-tls-certificates/src/util.rs b/proxmox-tls-certificates/src/util.rs
new file mode 100644
index 00000000..2eac2252
--- /dev/null
+++ b/proxmox-tls-certificates/src/util.rs
@@ -0,0 +1,209 @@
+use std::mem::MaybeUninit;
+
+use anyhow::{Error, bail, format_err};
+use foreign_types::ForeignTypeRef;
+use openssl::pkey::{PKey, Private};
+use openssl::rsa::Rsa;
+use openssl::x509::{X509, X509Builder};
+
+use crate::CertificateInfo;
+
+/// Create a new self-signed certificate and its private key.
+///
+/// * `product_name`: The name of the product, will be used as the organization of the certificate.
+/// * `nodename`: The name of the node where the certificate will be used. The common name and
+/// subject alternative names will be set based on this value.
+/// * `domain`: The optional domain which the node is in. If set, it will be appended to `nodename`
+/// to derive an FQDN as common and subject alternative name.
+pub fn create_self_signed_cert(
+ product_name: &str,
+ nodename: &str,
+ domain: Option<&str>,
+) -> Result<(PKey<Private>, X509), Error> {
+ let rsa = Rsa::generate(4096).unwrap();
+
+ let mut x509 = X509Builder::new()?;
+
+ x509.set_version(2)?;
+
+ let today = openssl::asn1::Asn1Time::days_from_now(0)?;
+ x509.set_not_before(&today)?;
+ let expire = openssl::asn1::Asn1Time::days_from_now(365 * 10)?;
+ x509.set_not_after(&expire)?;
+
+ let mut fqdn = nodename.to_owned();
+
+ if let Some(domain) = domain {
+ fqdn.push('.');
+ fqdn.push_str(domain);
+ }
+
+ // we try to generate an unique 'subject' to avoid browser problems
+ //(reused serial numbers, ..)
+ let uuid = proxmox_uuid::Uuid::generate();
+
+ let mut subject_name = openssl::x509::X509NameBuilder::new()?;
+ subject_name.append_entry_by_text("O", product_name)?;
+ subject_name.append_entry_by_text("OU", &format!("{uuid:X}"))?;
+ subject_name.append_entry_by_text("CN", &fqdn)?;
+ let subject_name = subject_name.build();
+
+ x509.set_subject_name(&subject_name)?;
+ x509.set_issuer_name(&subject_name)?;
+
+ let bc = openssl::x509::extension::BasicConstraints::new(); // CA = false
+ let bc = bc.build()?;
+ x509.append_extension(bc)?;
+
+ let usage = openssl::x509::extension::ExtendedKeyUsage::new()
+ .server_auth()
+ .build()?;
+ x509.append_extension(usage)?;
+
+ let context = x509.x509v3_context(None, None);
+
+ let mut alt_names = openssl::x509::extension::SubjectAlternativeName::new();
+
+ alt_names.ip("127.0.0.1");
+ alt_names.ip("::1");
+
+ alt_names.dns("localhost");
+
+ if nodename != "localhost" {
+ alt_names.dns(nodename);
+ }
+ if nodename != fqdn {
+ alt_names.dns(&fqdn);
+ }
+
+ let alt_names = alt_names.build(&context)?;
+
+ x509.append_extension(alt_names)?;
+
+ let pub_pem = rsa.public_key_to_pem()?;
+ let pubkey = PKey::public_key_from_pem(&pub_pem)?;
+
+ x509.set_pubkey(&pubkey)?;
+
+ let context = x509.x509v3_context(None, None);
+ let ext = openssl::x509::extension::SubjectKeyIdentifier::new().build(&context)?;
+ x509.append_extension(ext)?;
+
+ let context = x509.x509v3_context(None, None);
+ let ext = openssl::x509::extension::AuthorityKeyIdentifier::new()
+ .keyid(true)
+ .build(&context)?;
+ x509.append_extension(ext)?;
+
+ let privkey = PKey::from_rsa(rsa)?;
+
+ x509.sign(&privkey, openssl::hash::MessageDigest::sha256())?;
+
+ Ok((privkey, x509.build()))
+}
+
+impl CertificateInfo {
+ /// Parse a PEM-encoded file to a `CertificateInfo` struct.
+ ///
+ /// * `filename`: The location of the PEM file.
+ /// * `cert_pem`: The PEM data as bytes.
+ pub fn from_pem(filename: &str, cert_pem: &[u8]) -> Result<Self, Error> {
+ let x509 = openssl::x509::X509::from_pem(cert_pem)?;
+
+ let cert_pem = String::from_utf8(cert_pem.to_vec())
+ .map_err(|_| format_err!("certificate in {:?} is not a valid PEM file", filename))?;
+
+ let pubkey = x509.public_key()?;
+
+ let subject = x509name_to_string(x509.subject_name())?;
+ let issuer = x509name_to_string(x509.issuer_name())?;
+
+ let fingerprint = x509.digest(openssl::hash::MessageDigest::sha256())?;
+ let fingerprint = hex::encode(fingerprint)
+ .as_bytes()
+ .chunks(2)
+ .map(|v| std::str::from_utf8(v).unwrap())
+ .collect::<Vec<&str>>()
+ .join(":");
+
+ let public_key_type = openssl::nid::Nid::from_raw(pubkey.id().as_raw())
+ .long_name()
+ .unwrap_or("<unsupported key type>")
+ .to_owned();
+
+ let san = x509
+ .subject_alt_names()
+ .map(|san| {
+ san.into_iter()
+ .filter_map(|name| {
+ // this is not actually a map and we don't want to break the pattern
+ #[allow(clippy::manual_map)]
+ if let Some(name) = name.dnsname() {
+ Some(format!("DNS: {name}"))
+ } else if let Some(ip) = name.ipaddress() {
+ Some(format!("IP: {ip:?}"))
+ } else if let Some(email) = name.email() {
+ Some(format!("EMAIL: {email}"))
+ } else if let Some(uri) = name.uri() {
+ Some(format!("URI: {uri}"))
+ } else {
+ None
+ }
+ })
+ .collect()
+ })
+ .unwrap_or_default();
+
+ Ok(CertificateInfo {
+ filename: filename.to_string(),
+ pem: Some(cert_pem),
+ subject,
+ issuer,
+ fingerprint: Some(fingerprint),
+ public_key_bits: Some(pubkey.bits()),
+ notbefore: asn1_time_to_unix(x509.not_before()).ok(),
+ notafter: asn1_time_to_unix(x509.not_after()).ok(),
+ public_key_type,
+ san,
+ })
+ }
+
+ /// Check if the certificate is expired at or after a specific unix epoch.
+ pub fn is_expired_after_epoch(&self, epoch: i64) -> Result<bool, Error> {
+ if let Some(notafter) = self.notafter {
+ Ok(notafter < epoch)
+ } else {
+ Ok(false)
+ }
+ }
+}
+
+fn x509name_to_string(name: &openssl::x509::X509NameRef) -> Result<String, Error> {
+ let mut parts = Vec::new();
+ for entry in name.entries() {
+ parts.push(format!(
+ "{} = {}",
+ entry.object().nid().short_name()?,
+ entry.data().as_utf8()?
+ ));
+ }
+ Ok(parts.join(", "))
+}
+
+// C type:
+#[allow(non_camel_case_types)]
+type ASN1_TIME = <openssl::asn1::Asn1TimeRef as ForeignTypeRef>::CType;
+
+unsafe extern "C" {
+ fn ASN1_TIME_to_tm(s: *const ASN1_TIME, tm: *mut libc::tm) -> libc::c_int;
+}
+
+fn asn1_time_to_unix(time: &openssl::asn1::Asn1TimeRef) -> Result<i64, Error> {
+ let mut c_tm = MaybeUninit::<libc::tm>::uninit();
+ let rc = unsafe { ASN1_TIME_to_tm(time.as_ptr(), c_tm.as_mut_ptr()) };
+ if rc != 1 {
+ bail!("failed to parse ASN1 time");
+ }
+ let mut c_tm = unsafe { c_tm.assume_init() };
+ proxmox_time::timegm(&mut c_tm)
+}
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [PATCH proxmox 02/16] tls-certificates: add days_valid parameter to create_self_signed_cert
2026-07-30 13:31 [PATCH datacenter-manager/proxmox{,-backup} 00/16] TLS Certificate Rotation Shannon Sterz
2026-07-30 13:31 ` [PATCH proxmox 01/16] acme-api/tls-certificates: add new crate collecting tls realted helpers Shannon Sterz
@ 2026-07-30 13:31 ` Shannon Sterz
2026-07-30 13:31 ` [PATCH proxmox 03/16] tls-certificates: add self_signed_cert_expires_in to check certificates Shannon Sterz
` (14 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Shannon Sterz @ 2026-07-30 13:31 UTC (permalink / raw)
To: pbs-devel
to allow specifying how long a certificate should be valid for. also
adds unit tests to verify this behavior.
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
Notes:
imo, we could go down even more. as far as i am aware there is no real
limit that is being enforced here for self-signed certificates from a
browser perspective. they are already trusted on an exemption-basis
anyway. however, certificates signed by public CAs will only be valid
for a maximum of 47 days by 2029 [1].
hence, i would personally either adopt the same limit or go down to a
year, as a sensible middle-ground. certificate rotation should really
be automated even in self-signed scenarios. we also had cases in the
past, where customers already ran into issue because they wanted to
limit the lifetime of their certificates below 30 days [2]. meaning
that there is a need out there for shorter lived certificates (though,
in that case a custom CA & ACME setup was used).
[1]: https://github.com/cabforum/servercert/pull/553
[2]: https://bugzilla.proxmox.com/show_bug.cgi?id=6372
proxmox-tls-certificates/src/util.rs | 132 ++++++++++++++++++++++++++-
1 file changed, 131 insertions(+), 1 deletion(-)
diff --git a/proxmox-tls-certificates/src/util.rs b/proxmox-tls-certificates/src/util.rs
index 2eac2252..8a822956 100644
--- a/proxmox-tls-certificates/src/util.rs
+++ b/proxmox-tls-certificates/src/util.rs
@@ -15,10 +15,13 @@ use crate::CertificateInfo;
/// subject alternative names will be set based on this value.
/// * `domain`: The optional domain which the node is in. If set, it will be appended to `nodename`
/// to derive an FQDN as common and subject alternative name.
+/// * `days_valid`: The optional amount of days that the certificate should be valid for. If not
+/// set, the certificate will be valid for 3650 days, almost ten years.
pub fn create_self_signed_cert(
product_name: &str,
nodename: &str,
domain: Option<&str>,
+ days_valid: Option<u32>,
) -> Result<(PKey<Private>, X509), Error> {
let rsa = Rsa::generate(4096).unwrap();
@@ -28,7 +31,7 @@ pub fn create_self_signed_cert(
let today = openssl::asn1::Asn1Time::days_from_now(0)?;
x509.set_not_before(&today)?;
- let expire = openssl::asn1::Asn1Time::days_from_now(365 * 10)?;
+ let expire = openssl::asn1::Asn1Time::days_from_now(days_valid.unwrap_or(365 * 10))?;
x509.set_not_after(&expire)?;
let mut fqdn = nodename.to_owned();
@@ -207,3 +210,130 @@ fn asn1_time_to_unix(time: &openssl::asn1::Asn1TimeRef) -> Result<i64, Error> {
let mut c_tm = unsafe { c_tm.assume_init() };
proxmox_time::timegm(&mut c_tm)
}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+
+ fn get_self_signed_certificate_info(
+ product_name: &str,
+ nodename: &str,
+ domain: Option<&str>,
+ days_valid: Option<u32>,
+ ) -> CertificateInfo {
+ let cert = match create_self_signed_cert(product_name, nodename, domain, days_valid) {
+ Ok((_priv_key, certificate)) => certificate,
+ Err(e) => panic!("could not create self signed certificate - {e}"),
+ };
+
+ let pem_bytes = match cert.to_pem() {
+ Ok(pem) => pem,
+ Err(e) => panic!("could not get pem bytes from signed certificate - {e}"),
+ };
+
+ match CertificateInfo::from_pem("", &pem_bytes) {
+ Ok(cert_info) => cert_info,
+ Err(e) => panic!("could not parse cert pem to cert info - {e}"),
+ }
+ }
+
+ #[test]
+ fn self_signed_certificate_correct_with_product_and_name_only() {
+ let cert_info =
+ get_self_signed_certificate_info("Proxmox Test Product", "name", None, None);
+
+ assert!(cert_info.subject.contains("O = Proxmox Test Product"));
+ assert!(cert_info.subject.contains("CN = name"));
+ assert_eq!(
+ cert_info.san,
+ vec![
+ "IP: [127, 0, 0, 1]",
+ "IP: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]",
+ "DNS: localhost",
+ "DNS: name"
+ ]
+ );
+ }
+
+ #[test]
+ fn self_signed_certificate_correct_with_domain() {
+ let cert_info =
+ get_self_signed_certificate_info("Proxmox Test Product", "name", Some("fqdn"), None);
+
+ assert!(cert_info.subject.contains("O = Proxmox Test Product"));
+ assert!(cert_info.subject.contains("CN = name.fqdn"));
+ assert_eq!(
+ cert_info.san,
+ vec![
+ "IP: [127, 0, 0, 1]",
+ "IP: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]",
+ "DNS: localhost",
+ "DNS: name",
+ "DNS: name.fqdn"
+ ]
+ );
+ }
+
+ #[test]
+ fn self_signed_certificate_correct_with_days_valid() {
+ let in_two_days = proxmox_time::epoch_i64() + 2 * 24 * 60 * 60;
+ let cert_info =
+ get_self_signed_certificate_info("Proxmox Test Product", "name", None, Some(2));
+
+ assert!(
+ cert_info
+ .notafter
+ .map(|a| a >= in_two_days)
+ .unwrap_or_default()
+ );
+ assert!(
+ cert_info
+ .notafter
+ .map(|a| a < in_two_days + 24 * 60 * 60)
+ .unwrap_or_default()
+ );
+ assert!(cert_info.subject.contains("O = Proxmox Test Product"));
+ assert!(cert_info.subject.contains("CN = name"));
+ assert_eq!(
+ cert_info.san,
+ vec![
+ "IP: [127, 0, 0, 1]",
+ "IP: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]",
+ "DNS: localhost",
+ "DNS: name",
+ ]
+ );
+ }
+
+ #[test]
+ fn self_signed_certificate_correct_with_domain_and_days_valid() {
+ let in_two_days = proxmox_time::epoch_i64() + 2 * 24 * 60 * 60;
+ let cert_info =
+ get_self_signed_certificate_info("Proxmox Test Product", "name", Some("fqdn"), Some(2));
+
+ assert!(
+ cert_info
+ .notafter
+ .map(|a| a >= in_two_days)
+ .unwrap_or_default()
+ );
+ assert!(
+ cert_info
+ .notafter
+ .map(|a| a < in_two_days + 24 * 60 * 60)
+ .unwrap_or_default()
+ );
+ assert!(cert_info.subject.contains("O = Proxmox Test Product"));
+ assert!(cert_info.subject.contains("CN = name.fqdn"));
+ assert_eq!(
+ cert_info.san,
+ vec![
+ "IP: [127, 0, 0, 1]",
+ "IP: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]",
+ "DNS: localhost",
+ "DNS: name",
+ "DNS: name.fqdn"
+ ]
+ );
+ }
+}
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [PATCH proxmox 03/16] tls-certificates: add self_signed_cert_expires_in to check certificates
2026-07-30 13:31 [PATCH datacenter-manager/proxmox{,-backup} 00/16] TLS Certificate Rotation Shannon Sterz
2026-07-30 13:31 ` [PATCH proxmox 01/16] acme-api/tls-certificates: add new crate collecting tls realted helpers Shannon Sterz
2026-07-30 13:31 ` [PATCH proxmox 02/16] tls-certificates: add days_valid parameter to create_self_signed_cert Shannon Sterz
@ 2026-07-30 13:31 ` Shannon Sterz
2026-07-30 13:31 ` [PATCH proxmox 04/16] acme-api: stop re-exporting create_self_signed_cert Shannon Sterz
` (13 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Shannon Sterz @ 2026-07-30 13:31 UTC (permalink / raw)
To: pbs-devel
this is useful when self-signed certificates are in use. it
heuristically checks whether a certificate is self-signed by the
specified node and returns for how many more days the certificate is
valid if it is a self-signed certificate.
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
proxmox-tls-certificates/Cargo.toml | 3 ++
proxmox-tls-certificates/src/lib.rs | 2 +-
proxmox-tls-certificates/src/util.rs | 56 ++++++++++++++++++++++++++++
3 files changed, 60 insertions(+), 1 deletion(-)
diff --git a/proxmox-tls-certificates/Cargo.toml b/proxmox-tls-certificates/Cargo.toml
index 105657f2..3d5ee365 100644
--- a/proxmox-tls-certificates/Cargo.toml
+++ b/proxmox-tls-certificates/Cargo.toml
@@ -11,11 +11,13 @@ rust-version.workspace = true
[dependencies]
anyhow = { workspace = true, optional = true }
+const_format = { workspace = true, optional = true }
foreign-types = { workspace = true, optional = true }
hex = { workspace = true, optional = true }
libc = { workspace = true, optional = true }
openssl = { workspace = true, optional = true }
serde = { workspace = true, features = [ "derive" ] }
+regex = { workspace = true, optional = true }
proxmox-schema = { workspace = true, features = [ "api-macro" ] }
proxmox-sys = { workspace = true, optional = true }
@@ -31,6 +33,7 @@ impl = [
"dep:hex",
"dep:libc",
"dep:openssl",
+ "dep:regex",
"dep:proxmox-time",
"dep:proxmox-uuid",
diff --git a/proxmox-tls-certificates/src/lib.rs b/proxmox-tls-certificates/src/lib.rs
index 454c700f..66def7d6 100644
--- a/proxmox-tls-certificates/src/lib.rs
+++ b/proxmox-tls-certificates/src/lib.rs
@@ -4,4 +4,4 @@ pub use types::CertificateInfo;
#[cfg(feature = "impl")]
mod util;
#[cfg(feature = "impl")]
-pub use util::create_self_signed_cert;
+pub use util::{create_self_signed_cert, self_signed_cert_expires_in};
diff --git a/proxmox-tls-certificates/src/util.rs b/proxmox-tls-certificates/src/util.rs
index 8a822956..d627338c 100644
--- a/proxmox-tls-certificates/src/util.rs
+++ b/proxmox-tls-certificates/src/util.rs
@@ -1,13 +1,57 @@
use std::mem::MaybeUninit;
use anyhow::{Error, bail, format_err};
+use const_format::concatcp;
use foreign_types::ForeignTypeRef;
+use openssl::asn1::Asn1Time;
use openssl::pkey::{PKey, Private};
use openssl::rsa::Rsa;
use openssl::x509::{X509, X509Builder};
use crate::CertificateInfo;
+proxmox_schema::const_regex! {
+ SELF_SIGNED_REGEX = concatcp!(
+ // O = $product_name, OU = $uuid, CN = $nodename
+ r#"^O\s?=\s(?<product>.*)?, OU\s?=\s?[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}, CN\s?=\s?(?<fqdn>.*)$"#
+ );
+}
+
+/// Check whether the current certificate is self-signed and returns the remaining days the
+/// certificate is valid for.
+pub fn self_signed_cert_expires_in(
+ product_name: &str,
+ nodename: &str,
+ domain: Option<&str>,
+ cert: CertificateInfo,
+) -> Result<Option<i32>, Error> {
+ let Some(captures) = (SELF_SIGNED_REGEX.regex_obj)().captures(&cert.issuer) else {
+ return Ok(None);
+ };
+
+ let mut fqdn = nodename.to_string();
+
+ if let Some(domain) = domain {
+ fqdn = format!("{fqdn}.{domain}");
+ }
+
+ if captures["product"] != *product_name {
+ return Ok(None);
+ }
+
+ if captures["fqdn"] != fqdn {
+ return Ok(None);
+ }
+
+ let now = Asn1Time::from_unix(proxmox_time::epoch_i64())?;
+ let not_after = Asn1Time::from_unix(cert.notafter.ok_or_else(|| {
+ format_err!("Could not get \"not after\" epoch for current certificate.")
+ })?)?;
+
+ let diff = now.diff(¬_after)?;
+ Ok(Some(diff.days))
+}
+
/// Create a new self-signed certificate and its private key.
///
/// * `product_name`: The name of the product, will be used as the organization of the certificate.
@@ -237,6 +281,18 @@ mod test {
}
}
+ #[test]
+ fn self_signed_cert_expires_in_gets_correct_days() {
+ let cert_info =
+ get_self_signed_certificate_info("Proxmox Test Product", "name", Some("fqdn"), Some(2));
+
+ match self_signed_cert_expires_in("Proxmox Test Product", "name", Some("fqdn"), cert_info) {
+ Ok(Some(days)) => assert_eq!(days, 2i32),
+ Ok(None) => panic!("not a self-signed certificate"),
+ Err(e) => panic!("error occured checking certificate - {e:#}"),
+ }
+ }
+
#[test]
fn self_signed_certificate_correct_with_product_and_name_only() {
let cert_info =
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [PATCH proxmox 04/16] acme-api: stop re-exporting create_self_signed_cert
2026-07-30 13:31 [PATCH datacenter-manager/proxmox{,-backup} 00/16] TLS Certificate Rotation Shannon Sterz
` (2 preceding siblings ...)
2026-07-30 13:31 ` [PATCH proxmox 03/16] tls-certificates: add self_signed_cert_expires_in to check certificates Shannon Sterz
@ 2026-07-30 13:31 ` Shannon Sterz
2026-07-30 13:31 ` [PATCH proxmox-backup 05/16] config: use proxmox_tls_certificates for generating self-signed certificates Shannon Sterz
` (12 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Shannon Sterz @ 2026-07-30 13:31 UTC (permalink / raw)
To: pbs-devel
instead users should switch to proxmox-tls-certificates
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
Notes:
to be applied when the patch switching over pdm is applied.
proxmox-acme-api/src/certificate_helpers.rs | 2 --
proxmox-acme-api/src/lib.rs | 2 +-
2 files changed, 1 insertion(+), 3 deletions(-)
diff --git a/proxmox-acme-api/src/certificate_helpers.rs b/proxmox-acme-api/src/certificate_helpers.rs
index 47b72f36..c103470a 100644
--- a/proxmox-acme-api/src/certificate_helpers.rs
+++ b/proxmox-acme-api/src/certificate_helpers.rs
@@ -7,8 +7,6 @@ use proxmox_acme::async_client::AcmeClient;
use proxmox_log::{info, warn};
use proxmox_rest_server::WorkerTask;
-pub use proxmox_tls_certificates::create_self_signed_cert;
-
use crate::types::{AcmeConfig, AcmeDomain};
pub async fn revoke_certificate(acme_config: &AcmeConfig, certificate: &[u8]) -> Result<(), Error> {
diff --git a/proxmox-acme-api/src/lib.rs b/proxmox-acme-api/src/lib.rs
index c315d137..4dd56c72 100644
--- a/proxmox-acme-api/src/lib.rs
+++ b/proxmox-acme-api/src/lib.rs
@@ -45,7 +45,7 @@ pub(crate) mod acme_plugin;
#[cfg(feature = "impl")]
mod certificate_helpers;
#[cfg(feature = "impl")]
-pub use certificate_helpers::{create_self_signed_cert, order_certificate, revoke_certificate};
+pub use certificate_helpers::{order_certificate, revoke_certificate};
#[cfg(feature = "impl")]
pub mod completion {
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [PATCH proxmox-backup 05/16] config: use proxmox_tls_certificates for generating self-signed certificates
2026-07-30 13:31 [PATCH datacenter-manager/proxmox{,-backup} 00/16] TLS Certificate Rotation Shannon Sterz
` (3 preceding siblings ...)
2026-07-30 13:31 ` [PATCH proxmox 04/16] acme-api: stop re-exporting create_self_signed_cert Shannon Sterz
@ 2026-07-30 13:31 ` Shannon Sterz
2026-07-30 13:31 ` [PATCH proxmox-backup 06/16] config/server/api: add certificate renewal logic including notifications Shannon Sterz
` (11 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Shannon Sterz @ 2026-07-30 13:31 UTC (permalink / raw)
To: pbs-devel
to avoid duplicating almost identical code here, re-use the version
from `proxmox_tls_certificates::create_self_signed_cert`. for
`days_valid` specify `None` to opt into the default of 3650 days.
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
Cargo.toml | 3 ++
debian/control | 2 +
src/config/mod.rs | 94 ++++-------------------------------------------
3 files changed, 13 insertions(+), 86 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
index a2dcf85c3..df5f8942c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -97,6 +97,7 @@ proxmox-sys = "1"
proxmox-systemd = "1.0.1"
proxmox-tfa = { version = "6.0.3", features = [ "api", "api-types" ] }
proxmox-time = "2"
+proxmox-tls-certificates = { version = "1", features = [ "impl" ] }
proxmox-upgrade-checks = "1"
proxmox-uuid = { version = "1", features = [ "serde" ] }
proxmox-worker-task = "1"
@@ -255,6 +256,7 @@ proxmox-sys = { workspace = true, features = [ "timer" ] }
proxmox-systemd.workspace = true
proxmox-tfa.workspace = true
proxmox-time.workspace = true
+proxmox-tls-certificates.workspace = true
proxmox-upgrade-checks.workspace = true
proxmox-uuid.workspace = true
proxmox-worker-task.workspace = true
@@ -325,6 +327,7 @@ proxmox-rrd-api-types.workspace = true
#proxmox-systemd = { path = "../proxmox/proxmox-systemd" }
#proxmox-tfa = { path = "../proxmox/proxmox-tfa" }
#proxmox-time = { path = "../proxmox/proxmox-time" }
+#proxmox-tls-certificates = { path = "../proxmox/proxmox-tls-certificates" }
#proxmox-upgrade-checks = { path = "../proxmox/proxmox-upgrade-checks" }
#proxmox-uuid = { path = "../proxmox/proxmox-uuid" }
#proxmox-worker-task = { path = "../proxmox/proxmox-worker-task" }
diff --git a/debian/control b/debian/control
index d8dcabdb7..8b9607fff 100644
--- a/debian/control
+++ b/debian/control
@@ -132,6 +132,8 @@ Build-Depends: debhelper (>= 12~),
librust-proxmox-tfa-6+api-types-dev (>= 6.0.3-~~),
librust-proxmox-tfa-6+default-dev (>= 6.0.3-~~),
librust-proxmox-time-2+default-dev,
+ librust-proxmox-tls-certificates-1+default-dev,
+ librust-proxmox-tls-certificates-1+impl-dev,
librust-proxmox-upgrade-checks-1+default-dev,
librust-proxmox-uuid-1+default-dev,
librust-proxmox-uuid-1+serde-dev,
diff --git a/src/config/mod.rs b/src/config/mod.rs
index 98683186e..bbca5a9af 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -5,9 +5,6 @@
use anyhow::{Error, bail, format_err};
use nix::sys::stat::Mode;
-use openssl::pkey::PKey;
-use openssl::rsa::Rsa;
-use openssl::x509::X509Builder;
use std::path::Path;
use proxmox_lang::try_block;
@@ -90,92 +87,17 @@ pub fn update_self_signed_cert(force: bool) -> Result<(), Error> {
if key_path.exists() && cert_path.exists() && !force {
return Ok(());
}
-
- let rsa = Rsa::generate(4096).unwrap();
-
- let priv_pem = rsa.private_key_to_pem()?;
-
- let mut x509 = X509Builder::new()?;
-
- x509.set_version(2)?;
-
- let today = openssl::asn1::Asn1Time::days_from_now(0)?;
- x509.set_not_before(&today)?;
- let expire = openssl::asn1::Asn1Time::days_from_now(365 * 1000)?;
- x509.set_not_after(&expire)?;
-
- let nodename = proxmox_sys::nodename();
- let mut fqdn = nodename.to_owned();
-
let resolv_conf = crate::api2::node::dns::read_etc_resolv_conf()?;
- if let Some(search) = resolv_conf["search"].as_str() {
- fqdn.push('.');
- fqdn.push_str(search);
- }
- // we try to generate an unique 'subject' to avoid browser problems
- //(reused serial numbers, ..)
- let uuid = proxmox_uuid::Uuid::generate();
+ let (priv_key, cert) = proxmox_tls_certificates::create_self_signed_cert(
+ "Proxmox Backup Server",
+ proxmox_sys::nodename(),
+ resolv_conf["search"].as_str(),
+ None,
+ )?;
- let mut subject_name = openssl::x509::X509NameBuilder::new()?;
- subject_name.append_entry_by_text("O", "Proxmox Backup Server")?;
- subject_name.append_entry_by_text("OU", &format!("{uuid:X}"))?;
- subject_name.append_entry_by_text("CN", &fqdn)?;
- let subject_name = subject_name.build();
-
- x509.set_subject_name(&subject_name)?;
- x509.set_issuer_name(&subject_name)?;
-
- let bc = openssl::x509::extension::BasicConstraints::new(); // CA = false
- let bc = bc.build()?;
- x509.append_extension(bc)?;
-
- let usage = openssl::x509::extension::ExtendedKeyUsage::new()
- .server_auth()
- .build()?;
- x509.append_extension(usage)?;
-
- let context = x509.x509v3_context(None, None);
-
- let mut alt_names = openssl::x509::extension::SubjectAlternativeName::new();
-
- alt_names.ip("127.0.0.1");
- alt_names.ip("::1");
-
- alt_names.dns("localhost");
-
- if nodename != "localhost" {
- alt_names.dns(nodename);
- }
- if nodename != fqdn {
- alt_names.dns(&fqdn);
- }
-
- let alt_names = alt_names.build(&context)?;
-
- x509.append_extension(alt_names)?;
-
- let pub_pem = rsa.public_key_to_pem()?;
- let pubkey = PKey::public_key_from_pem(&pub_pem)?;
-
- x509.set_pubkey(&pubkey)?;
-
- let context = x509.x509v3_context(None, None);
- let ext = openssl::x509::extension::SubjectKeyIdentifier::new().build(&context)?;
- x509.append_extension(ext)?;
-
- let context = x509.x509v3_context(None, None);
- let ext = openssl::x509::extension::AuthorityKeyIdentifier::new()
- .keyid(true)
- .build(&context)?;
- x509.append_extension(ext)?;
-
- let privkey = PKey::from_rsa(rsa)?;
-
- x509.sign(&privkey, openssl::hash::MessageDigest::sha256())?;
-
- let x509 = x509.build();
- let cert_pem = x509.to_pem()?;
+ let cert_pem = cert.to_pem()?;
+ let priv_pem = priv_key.private_key_to_pem_pkcs8()?;
set_proxy_certificate(&cert_pem, &priv_pem)?;
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [PATCH proxmox-backup 06/16] config/server/api: add certificate renewal logic including notifications
2026-07-30 13:31 [PATCH datacenter-manager/proxmox{,-backup} 00/16] TLS Certificate Rotation Shannon Sterz
` (4 preceding siblings ...)
2026-07-30 13:31 ` [PATCH proxmox-backup 05/16] config: use proxmox_tls_certificates for generating self-signed certificates Shannon Sterz
@ 2026-07-30 13:31 ` Shannon Sterz
2026-07-30 13:31 ` [PATCH proxmox-backup 07/16] daily update: warn about excessive self-signed certificate lifetime Shannon Sterz
` (10 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Shannon Sterz @ 2026-07-30 13:31 UTC (permalink / raw)
To: pbs-devel
the daily-update service is used to check whether a self-signed
certificate is in use and renews it if it would expire within the next
15 days. it will also send out reminder notifications starting 15 days
before a certificate could be renewed.
a self-signed certificate is detected by parsing the issuer field of
the certificate. if it aligns with how self-signed certificates are
created by this instance of proxmox backup server, it will be deemed
self-signed.
15 days was chosen as that should give a reasonable trade-off between
not failing if the daily-update service can't run on the specific day
that the certificate would expire and not refreshing the certificate
too often. note that 15 days before expiry corresponds to let's
encrypt's recommendation for when to update new certificates that are
valid for 45 days:
> Acceptable behavior includes renewing certificates at approximately
> two thirds of the way through the current certificate’s lifetime.
>
> - https://letsencrypt.org/2025/12/02/from-90-to-45#action-required
15 days before expiry is about two thirds of the lifetime of a
certificate that lasts for 45 days.
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
Notes:
currently this will send out a notification every time the service is
run in the 15 days between a renewal being imminent and not being
renewed yet. this is probably excessive and we should limit the
notifications here to only go out once per week (or similar).
i chose 15 days instead of two thirds of ten years, because for a long
lasting certificate like that, trying to refresh it starting from 3.3
years before it expires seems excessive to me.
debian/proxmox-backup-server.install | 6 ++
src/api2/node/certificates.rs | 9 ++-
src/bin/proxmox-daily-update.rs | 39 ++++++++++-
src/config/mod.rs | 4 +-
src/server/notifications/mod.rs | 56 +++++++++++++++
src/server/notifications/template_data.rs | 11 +++
templates/Makefile | 68 ++++++++++---------
templates/default/cert-err-body.txt.hbs | 7 ++
templates/default/cert-err-subject.txt.hbs | 1 +
templates/default/cert-refresh-body.txt.hbs | 8 +++
.../default/cert-refresh-subject.txt.hbs | 1 +
.../cert-upcoming-refresh-body.txt.hbs | 10 +++
.../cert-upcoming-refresh-subject.txt.hbs | 1 +
13 files changed, 187 insertions(+), 34 deletions(-)
create mode 100644 templates/default/cert-err-body.txt.hbs
create mode 100644 templates/default/cert-err-subject.txt.hbs
create mode 100644 templates/default/cert-refresh-body.txt.hbs
create mode 100644 templates/default/cert-refresh-subject.txt.hbs
create mode 100644 templates/default/cert-upcoming-refresh-body.txt.hbs
create mode 100644 templates/default/cert-upcoming-refresh-subject.txt.hbs
diff --git a/debian/proxmox-backup-server.install b/debian/proxmox-backup-server.install
index 1f1d9b601..c485b28a9 100644
--- a/debian/proxmox-backup-server.install
+++ b/debian/proxmox-backup-server.install
@@ -45,6 +45,12 @@ usr/share/man/man5/user.cfg.5
usr/share/man/man5/verification.cfg.5
usr/share/proxmox-backup/templates/default/acme-err-body.txt.hbs
usr/share/proxmox-backup/templates/default/acme-err-subject.txt.hbs
+usr/share/proxmox-backup/templates/default/cert-err-body.txt.hbs
+usr/share/proxmox-backup/templates/default/cert-err-subject.txt.hbs
+usr/share/proxmox-backup/templates/default/cert-refresh-body.txt.hbs
+usr/share/proxmox-backup/templates/default/cert-refresh-subject.txt.hbs
+usr/share/proxmox-backup/templates/default/cert-upcoming-refresh-body.txt.hbs
+usr/share/proxmox-backup/templates/default/cert-upcoming-refresh-subject.txt.hbs
usr/share/proxmox-backup/templates/default/gc-err-body.txt.hbs
usr/share/proxmox-backup/templates/default/gc-err-subject.txt.hbs
usr/share/proxmox-backup/templates/default/gc-ok-body.txt.hbs
diff --git a/src/api2/node/certificates.rs b/src/api2/node/certificates.rs
index 3df05b020..7d19334f5 100644
--- a/src/api2/node/certificates.rs
+++ b/src/api2/node/certificates.rs
@@ -96,7 +96,7 @@ pub struct CertificateInfo {
pub fingerprint: Option<String>,
}
-fn get_certificate_pem() -> Result<String, Error> {
+pub fn get_certificate_pem() -> Result<String, Error> {
let cert_path = configdir!("/proxy.pem");
let cert_pem = proxmox_sys::fs::file_get_contents(cert_path)?;
String::from_utf8(cert_pem)
@@ -354,6 +354,13 @@ pub fn check_renewal_needed() -> Result<(bool, i64), Error> {
Ok((expires_soon, lead / SECONDS_PER_DAY))
}
+/// Renews the self signed certificate. The caller needs to make sure the current certificate is
+/// really a self-signed certificate and not an ACME or custom certificate.
+pub async fn renew_self_signed_cert() -> Result<(), Error> {
+ crate::config::update_self_signed_cert(true)?;
+ crate::server::reload_proxy_certificate().await
+}
+
fn spawn_certificate_worker(
name: &'static str,
force: bool,
diff --git a/src/bin/proxmox-daily-update.rs b/src/bin/proxmox-daily-update.rs
index 597722497..e1483488f 100644
--- a/src/bin/proxmox-daily-update.rs
+++ b/src/bin/proxmox-daily-update.rs
@@ -1,12 +1,15 @@
use anyhow::Error;
use serde_json::json;
+use proxmox_backup::server::notifications::{
+ send_self_signed_renewal_notification, send_upcoming_self_signed_renewal_notification,
+};
use proxmox_notify::context::pbs::PBS_CONTEXT;
use proxmox_router::{ApiHandler, RpcEnvironment, cli::*};
use proxmox_subscription::SubscriptionStatus;
use pbs_buildcfg::configdir;
-use proxmox_backup::api2;
+use proxmox_backup::{api2, config};
async fn wait_for_local_worker(upid_str: &str) -> Result<(), Error> {
let upid: pbs_api_types::UPID = upid_str.parse()?;
@@ -60,6 +63,10 @@ async fn do_update(rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
log::error!("error checking certificates: {err}");
}
+ if let Err(err) = renew_self_signed_certificate().await {
+ log::error!("error checking self-signed certificate renewal: {err:#}");
+ }
+
// TODO: cleanup tasks like in PVE?
Ok(())
@@ -89,6 +96,36 @@ async fn check_acme_certificates(rpcenv: &mut dyn RpcEnvironment) -> Result<(),
Ok(())
}
+async fn renew_self_signed_certificate() -> Result<(), Error> {
+ let resolv_conf = crate::api2::node::dns::read_etc_resolv_conf()?;
+ let pem = api2::node::certificates::get_certificate_pem()?;
+
+ let days = match proxmox_tls_certificates::self_signed_cert_expires_in(
+ config::PRODUCT_NAME,
+ proxmox_sys::nodename(),
+ resolv_conf["search"].as_str(),
+ proxmox_tls_certificates::CertificateInfo::from_pem("proxy.pem", pem.as_bytes())?,
+ )? {
+ None => {
+ log::debug!("Certificate is not self-signed, nothing to do.");
+ return Ok(());
+ }
+ Some(days) => days as i64,
+ };
+
+ if days <= 15 {
+ log::info!("Certificate expires within 15 days, renewing certificate...");
+ let err = &api2::node::certificates::renew_self_signed_cert().await;
+ send_self_signed_renewal_notification(&err)?;
+ } else if days <= 30 {
+ log::info!("Certificate expires within 30 days, notify about renewal.");
+ let earliest_renewal = proxmox_time::epoch_i64() + (days - 15) * 24 * 60 * 60;
+ send_upcoming_self_signed_renewal_notification(earliest_renewal)?;
+ }
+
+ Ok(())
+}
+
async fn run(rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
proxmox_product_config::init(pbs_config::backup_user()?, pbs_config::priv_user()?);
proxmox_rest_server::init_worker_tasks(
diff --git a/src/config/mod.rs b/src/config/mod.rs
index bbca5a9af..57a5c7a06 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -15,6 +15,8 @@ use pbs_buildcfg::{self, configdir};
pub mod tfa;
+pub const PRODUCT_NAME: &str = "Proxmox Backup Server";
+
/// Check configuration directory permissions
///
/// For security reasons, we want to make sure they are set correctly:
@@ -90,7 +92,7 @@ pub fn update_self_signed_cert(force: bool) -> Result<(), Error> {
let resolv_conf = crate::api2::node::dns::read_etc_resolv_conf()?;
let (priv_key, cert) = proxmox_tls_certificates::create_self_signed_cert(
- "Proxmox Backup Server",
+ PRODUCT_NAME,
proxmox_sys::nodename(),
resolv_conf["search"].as_str(),
None,
diff --git a/src/server/notifications/mod.rs b/src/server/notifications/mod.rs
index 181f12cbc..89ba0a209 100644
--- a/src/server/notifications/mod.rs
+++ b/src/server/notifications/mod.rs
@@ -10,6 +10,7 @@ use proxmox_notify::context::pbs::PBS_CONTEXT;
use proxmox_schema::ApiType;
use proxmox_sys::fs::{CreateOptions, create_path};
+use crate::server::notifications::template_data::CertUpcomingRefreshTemplateData;
use crate::tape::TapeNotificationMode;
use pbs_api_types::{
APTUpdateInfo, DataStoreConfig, DatastoreNotify, GarbageCollectionStatus, NotificationMode,
@@ -575,6 +576,61 @@ pub fn send_certificate_renewal_mail(result: &Result<(), Error>) -> Result<(), E
Ok(())
}
+/// Send email for upcoming self signed renewal.
+///
+/// * `earliest_renewal`: A Unix timestamp specifying the earliest a certificate may be renewed.
+pub fn send_upcoming_self_signed_renewal_notification(earliest_renewal: i64) -> Result<(), Error> {
+ let metadata = HashMap::from([
+ ("hostname".into(), proxmox_sys::nodename().into()),
+ ("type".into(), "cert".into()),
+ ]);
+
+ let notification = Notification::from_template(
+ Severity::Info,
+ "cert-upcoming-refresh",
+ serde_json::to_value(CertUpcomingRefreshTemplateData {
+ common: CommonData::new(),
+ earliest_renewal,
+ })?,
+ metadata,
+ );
+
+ send_notification(notification)?;
+ Ok(())
+}
+
+/// Send email renewed self-signed certificate
+pub fn send_self_signed_renewal_notification(result: &Result<(), Error>) -> Result<(), Error> {
+ let metadata = HashMap::from([
+ ("hostname".into(), proxmox_sys::nodename().into()),
+ ("type".into(), "cert-renewal".into()),
+ ]);
+
+ let notification = match result {
+ Err(e) => {
+ let template_data = AcmeErrTemplateData {
+ common: CommonData::new(),
+ error: format!("{e:#}"),
+ };
+
+ Notification::from_template(
+ Severity::Error,
+ "cert-err",
+ serde_json::to_value(template_data)?,
+ metadata,
+ )
+ }
+ _ => Notification::from_template(
+ Severity::Notice,
+ "cert-refresh",
+ serde_json::to_value(CommonData::new())?,
+ metadata,
+ ),
+ };
+
+ send_notification(notification)
+}
+
/// Send notification if datastore values are exceeding the set threshold limit.
pub fn send_datastore_threshold_exceeded(
datastore: &str,
diff --git a/src/server/notifications/template_data.rs b/src/server/notifications/template_data.rs
index e10215bdc..a869a2bbf 100644
--- a/src/server/notifications/template_data.rs
+++ b/src/server/notifications/template_data.rs
@@ -145,6 +145,17 @@ pub struct AcmeErrTemplateData {
pub error: String,
}
+/// Template data for the cert-upcoming-refresh template.
+#[derive(Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct CertUpcomingRefreshTemplateData {
+ /// Common properties.
+ #[serde(flatten)]
+ pub common: CommonData,
+ /// A Unix timestamp representing the earliest point in time a certificate may be renewed.
+ pub earliest_renewal: i64,
+}
+
#[derive(Serialize)]
#[serde(rename_all = "kebab-case")]
/// A single package which can be upgraded.
diff --git a/templates/Makefile b/templates/Makefile
index 8a4586d78..f086b927b 100644
--- a/templates/Makefile
+++ b/templates/Makefile
@@ -1,36 +1,42 @@
include ../defines.mk
-NOTIFICATION_TEMPLATES= \
- default/acme-err-body.txt.hbs \
- default/acme-err-subject.txt.hbs \
- default/gc-err-body.txt.hbs \
- default/gc-ok-body.txt.hbs \
- default/gc-err-subject.txt.hbs \
- default/gc-ok-subject.txt.hbs \
- default/package-updates-body.txt.hbs \
- default/package-updates-subject.txt.hbs \
- default/prune-err-body.txt.hbs \
- default/prune-ok-body.txt.hbs \
- default/prune-err-subject.txt.hbs \
- default/prune-ok-subject.txt.hbs \
- default/sync-err-body.txt.hbs \
- default/sync-ok-body.txt.hbs \
- default/sync-err-subject.txt.hbs \
- default/sync-ok-subject.txt.hbs \
- default/tape-backup-err-body.txt.hbs \
- default/tape-backup-err-subject.txt.hbs \
- default/tape-backup-ok-body.txt.hbs \
- default/tape-backup-ok-subject.txt.hbs \
- default/tape-load-body.txt.hbs \
- default/tape-load-subject.txt.hbs \
- default/test-body.txt.hbs \
- default/test-subject.txt.hbs \
- default/thresholds-exceeded-body.txt.hbs \
- default/thresholds-exceeded-subject.txt.hbs \
- default/verify-err-body.txt.hbs \
- default/verify-ok-body.txt.hbs \
- default/verify-err-subject.txt.hbs \
- default/verify-ok-subject.txt.hbs \
+NOTIFICATION_TEMPLATES= \
+ default/acme-err-body.txt.hbs \
+ default/acme-err-subject.txt.hbs \
+ default/cert-err-body.txt.hbs \
+ default/cert-err-subject.txt.hbs \
+ default/cert-refresh-body.txt.hbs \
+ default/cert-refresh-subject.txt.hbs \
+ default/cert-upcoming-refresh-body.txt.hbs \
+ default/cert-upcoming-refresh-subject.txt.hbs \
+ default/gc-err-body.txt.hbs \
+ default/gc-ok-body.txt.hbs \
+ default/gc-err-subject.txt.hbs \
+ default/gc-ok-subject.txt.hbs \
+ default/package-updates-body.txt.hbs \
+ default/package-updates-subject.txt.hbs \
+ default/prune-err-body.txt.hbs \
+ default/prune-ok-body.txt.hbs \
+ default/prune-err-subject.txt.hbs \
+ default/prune-ok-subject.txt.hbs \
+ default/sync-err-body.txt.hbs \
+ default/sync-ok-body.txt.hbs \
+ default/sync-err-subject.txt.hbs \
+ default/sync-ok-subject.txt.hbs \
+ default/tape-backup-err-body.txt.hbs \
+ default/tape-backup-err-subject.txt.hbs \
+ default/tape-backup-ok-body.txt.hbs \
+ default/tape-backup-ok-subject.txt.hbs \
+ default/tape-load-body.txt.hbs \
+ default/tape-load-subject.txt.hbs \
+ default/test-body.txt.hbs \
+ default/test-subject.txt.hbs \
+ default/thresholds-exceeded-body.txt.hbs \
+ default/thresholds-exceeded-subject.txt.hbs \
+ default/verify-err-body.txt.hbs \
+ default/verify-ok-body.txt.hbs \
+ default/verify-err-subject.txt.hbs \
+ default/verify-ok-subject.txt.hbs \
all:
diff --git a/templates/default/cert-err-body.txt.hbs b/templates/default/cert-err-body.txt.hbs
new file mode 100644
index 000000000..27f444517
--- /dev/null
+++ b/templates/default/cert-err-body.txt.hbs
@@ -0,0 +1,7 @@
+Proxmox Backup Server was not able to renew a self-signed TLS certificate.
+
+Error: {{error}}
+
+Please visit the web interface for further details:
+
+<{{base-url}}/#pbsCertificateConfiguration>
diff --git a/templates/default/cert-err-subject.txt.hbs b/templates/default/cert-err-subject.txt.hbs
new file mode 100644
index 000000000..7b8f2a20e
--- /dev/null
+++ b/templates/default/cert-err-subject.txt.hbs
@@ -0,0 +1 @@
+Could not renew self-signed certificate
diff --git a/templates/default/cert-refresh-body.txt.hbs b/templates/default/cert-refresh-body.txt.hbs
new file mode 100644
index 000000000..608ba30c0
--- /dev/null
+++ b/templates/default/cert-refresh-body.txt.hbs
@@ -0,0 +1,8 @@
+Proxmox Backup Server has refreshed its self-signed TLS certificate.
+
+The new certificate is now active. Please update any clients relying on the
+certificate fingerprint to verify their connection to the server.
+
+Please visit the web interface for further details:
+
+<{{base-url}}/#pbsCertificateConfiguration>
diff --git a/templates/default/cert-refresh-subject.txt.hbs b/templates/default/cert-refresh-subject.txt.hbs
new file mode 100644
index 000000000..e57f5cd4c
--- /dev/null
+++ b/templates/default/cert-refresh-subject.txt.hbs
@@ -0,0 +1 @@
+Self-Signed Certificate Has Been Refreshed
diff --git a/templates/default/cert-upcoming-refresh-body.txt.hbs b/templates/default/cert-upcoming-refresh-body.txt.hbs
new file mode 100644
index 000000000..a79db0299
--- /dev/null
+++ b/templates/default/cert-upcoming-refresh-body.txt.hbs
@@ -0,0 +1,10 @@
+Proxmox Backup Server will refresh its TLS certificate within the next 30 days.
+
+The earliest the certificate may be renewed is {{ timstamp earliest-renewal }}.
+If you rely on the certificate's fingerprint to verify TLS sessions between the
+server and a client, please update the fingerprint once the certificate was
+updated. Otherwise, no action is required.
+
+Please visit the web interface for further details:
+
+<{{base-url}}/#pbsCertificateConfiguration>
diff --git a/templates/default/cert-upcoming-refresh-subject.txt.hbs b/templates/default/cert-upcoming-refresh-subject.txt.hbs
new file mode 100644
index 000000000..f188e391c
--- /dev/null
+++ b/templates/default/cert-upcoming-refresh-subject.txt.hbs
@@ -0,0 +1 @@
+Self-signed Certificate is About to be Refreshed
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [PATCH proxmox-backup 07/16] daily update: warn about excessive self-signed certificate lifetime
2026-07-30 13:31 [PATCH datacenter-manager/proxmox{,-backup} 00/16] TLS Certificate Rotation Shannon Sterz
` (5 preceding siblings ...)
2026-07-30 13:31 ` [PATCH proxmox-backup 06/16] config/server/api: add certificate renewal logic including notifications Shannon Sterz
@ 2026-07-30 13:31 ` Shannon Sterz
2026-07-30 13:31 ` [PATCH proxmox-backup 08/16] docs: document force refreshing long-lived certificates Shannon Sterz
` (9 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Shannon Sterz @ 2026-07-30 13:31 UTC (permalink / raw)
To: pbs-devel
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
src/bin/proxmox-daily-update.rs | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/src/bin/proxmox-daily-update.rs b/src/bin/proxmox-daily-update.rs
index e1483488f..4023ddde6 100644
--- a/src/bin/proxmox-daily-update.rs
+++ b/src/bin/proxmox-daily-update.rs
@@ -121,6 +121,10 @@ async fn renew_self_signed_certificate() -> Result<(), Error> {
log::info!("Certificate expires within 30 days, notify about renewal.");
let earliest_renewal = proxmox_time::epoch_i64() + (days - 15) * 24 * 60 * 60;
send_upcoming_self_signed_renewal_notification(earliest_renewal)?;
+ } else if days > 365 * 10 {
+ log::warn!(
+ "Self-signed certificate is valid for an excessive amount of time. Please renew it."
+ );
}
Ok(())
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [PATCH proxmox-backup 08/16] docs: document force refreshing long-lived certificates
2026-07-30 13:31 [PATCH datacenter-manager/proxmox{,-backup} 00/16] TLS Certificate Rotation Shannon Sterz
` (6 preceding siblings ...)
2026-07-30 13:31 ` [PATCH proxmox-backup 07/16] daily update: warn about excessive self-signed certificate lifetime Shannon Sterz
@ 2026-07-30 13:31 ` Shannon Sterz
2026-07-30 13:31 ` [PATCH proxmox-backup 09/16] backup-manager cli: `cert update` can create auth and csrf key Shannon Sterz
` (8 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Shannon Sterz @ 2026-07-30 13:31 UTC (permalink / raw)
To: pbs-devel
to avoid log messages added previously
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
docs/certificate-management.rst | 31 +++++++++++++++++++++++++++++++
1 file changed, 31 insertions(+)
diff --git a/docs/certificate-management.rst b/docs/certificate-management.rst
index 89f628049..7cbc13bcd 100644
--- a/docs/certificate-management.rst
+++ b/docs/certificate-management.rst
@@ -333,3 +333,34 @@ Test your new certificate, using your browser.
.. [1]
acme.sh https://github.com/acmesh-official/acme.sh
+
+Manually Renew Self-signed Certificates
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Proxmox Backup Server creates and renews a self-signed certificate if no custom
+or ACME certificate is provided. Older versions issued a certificate that was
+valid for almost 1000 years and did not renew this certificate. Beginning with
+version 4.3, new setups use short-lived certificates that will be regularly
+renewed. Old self-signed certificates are not replaced in order to not disrupt
+existing backup setups. In such cases, the following line is logged:
+
+.. code-block:: console
+
+ Apr 04 12:17:51 pbs proxmox-daily-update[1170]: Self-signed certificate is valid for an excessive amount of time. Please renew it.
+
+To manually renew a certificate, navigate to Configuration -> Certificates.
+Select the certificate ``proxy.pem``. Then click the "Delete Custom
+Certificate" button. Alternatively, you can run the following command:
+
+.. code-block:: shell
+
+ proxmox-backup-manager cert update --force
+
+.. WARNING:: Any client using a fingerprint to verify TLS sessions with the
+ server will need to be updated with the new fingerprint. This includes any
+ Proxmox VE instance that may use it as a backup destination.
+
+After manually renewing the certificate once, Proxmox Backup Server will start
+renewing the certificate itself. A certificate will be renewed at the earliest
+15 days before it expires. Starting from 30 days before it expires,
+notifications will be issued with a reminder about the upcoming renewal.
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [PATCH proxmox-backup 09/16] backup-manager cli: `cert update` can create auth and csrf key
2026-07-30 13:31 [PATCH datacenter-manager/proxmox{,-backup} 00/16] TLS Certificate Rotation Shannon Sterz
` (7 preceding siblings ...)
2026-07-30 13:31 ` [PATCH proxmox-backup 08/16] docs: document force refreshing long-lived certificates Shannon Sterz
@ 2026-07-30 13:31 ` Shannon Sterz
2026-07-30 13:31 ` [PATCH proxmox-backup 10/16] notifications: use `Severity::Error` for acme renewal failures Shannon Sterz
` (7 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Shannon Sterz @ 2026-07-30 13:31 UTC (permalink / raw)
To: pbs-devel
when these don't exist yet, document that behavior.
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
src/bin/proxmox_backup_manager/cert.rs | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/bin/proxmox_backup_manager/cert.rs b/src/bin/proxmox_backup_manager/cert.rs
index ccf10e445..f9255a40f 100644
--- a/src/bin/proxmox_backup_manager/cert.rs
+++ b/src/bin/proxmox_backup_manager/cert.rs
@@ -56,6 +56,8 @@ fn cert_info() -> Result<(), Error> {
},
)]
/// Update node certificates and generate all needed files/directories.
+/// If either the authentication key or CSRF secret key does not exist, each will be generated.
+/// These two keys will go into effect the next time the `proxmox-backup.service` is started.
fn update_certs(force: Option<bool>) -> Result<(), Error> {
config::create_configdir()?;
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [PATCH proxmox-backup 10/16] notifications: use `Severity::Error` for acme renewal failures
2026-07-30 13:31 [PATCH datacenter-manager/proxmox{,-backup} 00/16] TLS Certificate Rotation Shannon Sterz
` (8 preceding siblings ...)
2026-07-30 13:31 ` [PATCH proxmox-backup 09/16] backup-manager cli: `cert update` can create auth and csrf key Shannon Sterz
@ 2026-07-30 13:31 ` Shannon Sterz
2026-07-30 13:31 ` [PATCH datacenter-manager 11/16] certs: use proxmox-tls-certificates directly, add days_valid paramter Shannon Sterz
` (6 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Shannon Sterz @ 2026-07-30 13:31 UTC (permalink / raw)
To: pbs-devel
users should not miss such renewal failures as it could lead to
failing backups. so increase the severity.
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
src/server/notifications/mod.rs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/server/notifications/mod.rs b/src/server/notifications/mod.rs
index 89ba0a209..a936d0f2c 100644
--- a/src/server/notifications/mod.rs
+++ b/src/server/notifications/mod.rs
@@ -566,7 +566,7 @@ pub fn send_certificate_renewal_mail(result: &Result<(), Error>) -> Result<(), E
};
let notification = Notification::from_template(
- Severity::Info,
+ Severity::Error,
"acme-err",
serde_json::to_value(template_data)?,
metadata,
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [PATCH datacenter-manager 11/16] certs: use proxmox-tls-certificates directly, add days_valid paramter
2026-07-30 13:31 [PATCH datacenter-manager/proxmox{,-backup} 00/16] TLS Certificate Rotation Shannon Sterz
` (9 preceding siblings ...)
2026-07-30 13:31 ` [PATCH proxmox-backup 10/16] notifications: use `Severity::Error` for acme renewal failures Shannon Sterz
@ 2026-07-30 13:31 ` Shannon Sterz
2026-07-30 13:31 ` [PATCH datacenter-manager 12/16] api/auth/bin: add certificate renewal logic Shannon Sterz
` (5 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Shannon Sterz @ 2026-07-30 13:31 UTC (permalink / raw)
To: pbs-devel
instead of using it through proxmox-acme-api. chosing `None` as days
valid parameter makes the certificate valid for 3650 days, almost ten
years.
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
Cargo.toml | 2 ++
debian/control | 2 ++
server/Cargo.toml | 1 +
server/src/auth/certs.rs | 3 ++-
4 files changed, 7 insertions(+), 1 deletion(-)
diff --git a/Cargo.toml b/Cargo.toml
index b5916b36..952a9573 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -66,6 +66,7 @@ proxmox-sys = "1"
proxmox-systemd = "1"
proxmox-tfa = { version = "6", features = [ "api-types" ], default-features = false }
proxmox-time = "2"
+proxmox-tls-certificates = { version = "1", features = [ "impl" ] }
proxmox-upgrade-checks = "1"
proxmox-uuid = "1"
proxmox-installer-types = "0.1"
@@ -184,6 +185,7 @@ zstd = { version = "0.13" }
# proxmox-tfa = { path = "../proxmox/proxmox-tfa" }
# proxmox-time-api = { path = "../proxmox/proxmox-time-api" }
# proxmox-time = { path = "../proxmox/proxmox-time" }
+# proxmox-tls-certificates = { path = "../proxmox/proxmox-tls-certificates" }
# proxmox-upgrade-checks = { path = "../proxmox/proxmox-upgrade-checks" }
# proxmox-uuid = { path = "../proxmox/proxmox-uuid" }
# proxmox-worker-task = { path = "../proxmox/proxmox-worker-task" }
diff --git a/debian/control b/debian/control
index e1430dca..5bd6ef8e 100644
--- a/debian/control
+++ b/debian/control
@@ -111,6 +111,8 @@ Build-Depends: debhelper-compat (= 13),
librust-proxmox-time-2+default-dev,
librust-proxmox-time-api-1+default-dev,
librust-proxmox-time-api-1+impl-dev,
+ librust-proxmox-tls-certificates-1+default-dev,
+ librust-proxmox-tls-certificates-1+impl-dev,
librust-proxmox-upgrade-checks-1+default-dev,
librust-proxmox-uuid-1+default-dev,
librust-pve-api-types-8+client-dev (>= 8.1.12-~~),
diff --git a/server/Cargo.toml b/server/Cargo.toml
index 663f21cb..d70e1d11 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -64,6 +64,7 @@ proxmox-sys = { workspace = true, features = [ "timer" ] }
proxmox-systemd.workspace = true
proxmox-tfa = { workspace = true, features = [ "api" ] }
proxmox-time.workspace = true
+proxmox-tls-certificates = { workspace = true, features = [ "impl" ]}
proxmox-uuid.workspace = true
proxmox-apt = { workspace = true, features = [ "cache" ] }
diff --git a/server/src/auth/certs.rs b/server/src/auth/certs.rs
index 49b2076b..c208cf4f 100644
--- a/server/src/auth/certs.rs
+++ b/server/src/auth/certs.rs
@@ -19,10 +19,11 @@ pub fn update_self_signed_cert(force: bool) -> Result<(), Error> {
let resolv_conf = read_etc_resolv_conf(None)?.config;
- let (priv_key, cert) = proxmox_acme_api::create_self_signed_cert(
+ let (priv_key, cert) = proxmox_tls_certificates::create_self_signed_cert(
"Proxmox Datacenter Manager",
proxmox_sys::nodename(),
resolv_conf.search.as_deref(),
+ None,
)?;
let cert_pem = cert.to_pem()?;
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [PATCH datacenter-manager 12/16] api/auth/bin: add certificate renewal logic
2026-07-30 13:31 [PATCH datacenter-manager/proxmox{,-backup} 00/16] TLS Certificate Rotation Shannon Sterz
` (10 preceding siblings ...)
2026-07-30 13:31 ` [PATCH datacenter-manager 11/16] certs: use proxmox-tls-certificates directly, add days_valid paramter Shannon Sterz
@ 2026-07-30 13:31 ` Shannon Sterz
2026-07-30 13:31 ` [PATCH datacenter-manager 13/16] cli: expose certificate management endpoints via the cli Shannon Sterz
` (4 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Shannon Sterz @ 2026-07-30 13:31 UTC (permalink / raw)
To: pbs-devel
the daily-update service is used to check whether a self-signed
certificate is in use and renews it if it would expire within the next
15 days.
a self-signed certificate is detected by parsing the issuer field of
the certificate. if it aligns with how self-signed certificates are
created by this instance of proxmox datacenter manager, it will be
deemed self-signed.
15 days was chosen as that should give a reasonable trade-off between
not failing if the daily-update service can't run on the specific day
that the certificate would expire and not refreshing the certificate
too often. note that 15 days before expiry corresponds to let's
encrypt's recommendation for when to update new certificates that are
valid for 45 days:
> Acceptable behavior includes renewing certificates at approximately
> two thirds of the way through the current certificate’s lifetime.
>
> - https://letsencrypt.org/2025/12/02/from-90-to-45#action-required
15 days before expiry is about two thirds of the lifetime of a
certificate that lasts for 45 days.
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
server/src/api/nodes/certificates.rs | 19 +++++------
server/src/auth/certs.rs | 16 ++++++++-
...proxmox-datacenter-manager-daily-update.rs | 34 +++++++++++++++++++
3 files changed, 57 insertions(+), 12 deletions(-)
diff --git a/server/src/api/nodes/certificates.rs b/server/src/api/nodes/certificates.rs
index 0f391499..515c4802 100644
--- a/server/src/api/nodes/certificates.rs
+++ b/server/src/api/nodes/certificates.rs
@@ -15,7 +15,7 @@ use proxmox_schema::api_types::NODE_SCHEMA;
use pdm_api_types::PRIV_SYS_MODIFY;
-use crate::auth::certs::{API_CERT_FN, API_KEY_FN};
+use crate::auth::certs::{API_CERT_FN, API_KEY_FN, get_certificate_info, get_certificate_pem};
pub const ROUTER: Router = Router::new()
.get(&list_subdirs_api_method!(SUBDIRS))
@@ -43,16 +43,6 @@ const ACME_SUBDIRS: SubdirMap = &[(
.put(&API_METHOD_RENEW_ACME_CERT),
)];
-fn get_certificate_pem() -> Result<Vec<u8>, Error> {
- let cert_pem = proxmox_sys::fs::file_get_contents(API_CERT_FN)?;
- Ok(cert_pem)
-}
-
-fn get_certificate_info() -> Result<CertificateInfo, Error> {
- let cert_pem = get_certificate_pem()?;
- CertificateInfo::from_pem("proxy.pem", &cert_pem)
-}
-
#[api(
input: {
properties: {
@@ -247,6 +237,13 @@ pub fn cert_expires_soon() -> Result<bool, Error> {
.map_err(|err| format_err!("Failed to check certificate expiration date: {}", err))
}
+/// Renews the self signed certificate. The caller needs to make sure the current certificate is
+/// really a self-signed certificate and not an ACME or custom certificate.
+pub async fn renew_self_signed_cert() -> Result<(), Error> {
+ crate::auth::certs::update_self_signed_cert(true)?;
+ crate::reload_api_certificate().await
+}
+
fn spawn_certificate_worker(
name: &'static str,
force: bool,
diff --git a/server/src/auth/certs.rs b/server/src/auth/certs.rs
index c208cf4f..4c7cafcb 100644
--- a/server/src/auth/certs.rs
+++ b/server/src/auth/certs.rs
@@ -2,11 +2,13 @@ use anyhow::{Error, format_err};
use std::path::PathBuf;
use proxmox_dns_api::read_etc_resolv_conf;
+use proxmox_tls_certificates::CertificateInfo;
use pdm_buildcfg::configdir;
pub const API_KEY_FN: &str = configdir!("/auth/api.key");
pub const API_CERT_FN: &str = configdir!("/auth/api.pem");
+pub const PRODUCT_NAME: &str = "Proxmox Datacenter Manager";
/// Update self signed node certificate.
pub fn update_self_signed_cert(force: bool) -> Result<(), Error> {
@@ -20,7 +22,7 @@ pub fn update_self_signed_cert(force: bool) -> Result<(), Error> {
let resolv_conf = read_etc_resolv_conf(None)?.config;
let (priv_key, cert) = proxmox_tls_certificates::create_self_signed_cert(
- "Proxmox Datacenter Manager",
+ PRODUCT_NAME,
proxmox_sys::nodename(),
resolv_conf.search.as_deref(),
None,
@@ -45,3 +47,15 @@ pub(crate) fn set_api_certificate(cert_pem: &[u8], key_pem: &[u8]) -> Result<(),
Ok(())
}
+
+/// Get the certificate as bytes in PEM format.
+pub fn get_certificate_pem() -> Result<Vec<u8>, Error> {
+ let cert_pem = proxmox_sys::fs::file_get_contents("proxy.pem")?;
+ Ok(cert_pem)
+}
+
+/// Get the `CertificateInfo` struct for the current certificate.
+pub fn get_certificate_info() -> Result<CertificateInfo, Error> {
+ let cert_pem = get_certificate_pem()?;
+ CertificateInfo::from_pem(API_CERT_FN, &cert_pem)
+}
diff --git a/server/src/bin/proxmox-datacenter-manager-daily-update.rs b/server/src/bin/proxmox-datacenter-manager-daily-update.rs
index 314b3399..803b2fc0 100644
--- a/server/src/bin/proxmox-datacenter-manager-daily-update.rs
+++ b/server/src/bin/proxmox-datacenter-manager-daily-update.rs
@@ -59,6 +59,11 @@ async fn do_update(rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
log::error!("error checking certificates: {err}");
}
+ println!("check if self-signed certificate requires renewal");
+ if let Err(err) = renew_self_signed_certificate().await {
+ log::error!("error checking self-signed certificate renewal: {err:#}");
+ }
+
// TODO: cleanup tasks like in PVE?
Ok(())
@@ -87,6 +92,35 @@ async fn check_acme_certificates(rpcenv: &mut dyn RpcEnvironment) -> Result<(),
Ok(())
}
+async fn renew_self_signed_certificate() -> Result<(), Error> {
+ let days = match proxmox_tls_certificates::self_signed_cert_expires_in(
+ server::auth::certs::PRODUCT_NAME,
+ proxmox_sys::nodename(),
+ proxmox_dns_api::read_etc_resolv_conf(None)?
+ .config
+ .search
+ .as_deref(),
+ server::auth::certs::get_certificate_info()?,
+ )? {
+ None => {
+ log::debug!("Certificate is not self-signed, nothing to do.");
+ return Ok(());
+ }
+ Some(days) => days,
+ };
+
+ if days <= 15 {
+ log::info!("Certificate expires within 15 days, renewing certificate...");
+ let _err = &api::nodes::certificates::renew_self_signed_cert().await;
+ // fixme: send_self_signed_renewal_notification(&err)?;
+ } else if days <= 30 {
+ log::info!("Certificate expires within 30 days.");
+ // fixme: send_upcoming_self_signed_renewal_notification(earliest_renewal)?;
+ }
+
+ Ok(())
+}
+
async fn run(rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
let api_user = pdm_config::api_user()?;
let file_opts = CreateOptions::new().owner(api_user.uid).group(api_user.gid);
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [PATCH datacenter-manager 13/16] cli: expose certificate management endpoints via the cli
2026-07-30 13:31 [PATCH datacenter-manager/proxmox{,-backup} 00/16] TLS Certificate Rotation Shannon Sterz
` (11 preceding siblings ...)
2026-07-30 13:31 ` [PATCH datacenter-manager 12/16] api/auth/bin: add certificate renewal logic Shannon Sterz
@ 2026-07-30 13:31 ` Shannon Sterz
2026-07-30 13:31 ` [PATCH datacenter-manager 14/16] daily-update: warn about certificates with excessive lifetimes Shannon Sterz
` (3 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Shannon Sterz @ 2026-07-30 13:31 UTC (permalink / raw)
To: pbs-devel
analogous to how this is handled in proxmox-backup-server.
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
cli/admin/Cargo.toml | 2 +
cli/admin/src/cert.rs | 86 +++++++++++++++++++++++++++++++++++++++++++
cli/admin/src/main.rs | 2 +
3 files changed, 90 insertions(+)
create mode 100644 cli/admin/src/cert.rs
diff --git a/cli/admin/Cargo.toml b/cli/admin/Cargo.toml
index 01afc88d..7aa0d2ef 100644
--- a/cli/admin/Cargo.toml
+++ b/cli/admin/Cargo.toml
@@ -10,6 +10,7 @@ repository.workspace = true
[dependencies]
anyhow.workspace = true
+openssl.workspace = true
serde.workspace = true
serde_json.workspace = true
@@ -29,3 +30,4 @@ pdm-api-types.workspace = true
pdm-config.workspace = true
pdm-buildcfg.workspace = true
server.workspace = true
+proxmox-time.workspace = true
diff --git a/cli/admin/src/cert.rs b/cli/admin/src/cert.rs
new file mode 100644
index 00000000..77d2ec5c
--- /dev/null
+++ b/cli/admin/src/cert.rs
@@ -0,0 +1,86 @@
+use anyhow::{Error, bail};
+
+use proxmox_router::cli::*;
+use proxmox_schema::api;
+use server::api;
+use server::auth::certs::update_self_signed_cert;
+use server::auth::csrf::generate_csrf_key;
+use server::auth::key::generate_auth_key;
+
+#[api]
+/// Display node certificate information.
+fn cert_info() -> Result<(), Error> {
+ let Some(cert) = api::nodes::certificates::get_info()?.pop() else {
+ return Ok(());
+ };
+
+ println!("Subject: {}", cert.subject);
+
+ for name in cert.san {
+ println!(" {name}");
+ }
+
+ let not_before = cert
+ .notbefore
+ .and_then(|e| proxmox_time::strftime_utc("%b %e %T %Y %Z", e).ok());
+
+ let not_after = cert
+ .notafter
+ .and_then(|e| proxmox_time::strftime_utc("%b %e %T %Y %Z", e).ok());
+
+ println!("Issuer: {}", cert.issuer);
+ println!("Validity:");
+ println!(" Not Before: {}", not_before.unwrap_or_default());
+ println!(" Not After : {}", not_after.unwrap_or_default());
+
+ println!(
+ "Fingerprint (sha256): {}",
+ cert.fingerprint.unwrap_or_default()
+ );
+
+ println!("Public key type: {}", cert.public_key_type);
+ println!(
+ "Public key bits: {}",
+ cert.public_key_bits.unwrap_or_default()
+ );
+
+ Ok(())
+}
+
+#[api(
+ input: {
+ properties: {
+ force: {
+ description: "Force generation of new SSL certificate.",
+ type: Boolean,
+ optional:true,
+ },
+ }
+ },
+)]
+/// Update node certificates and generate all needed files/directories.
+/// If no authentication key or CSRF secret key exist, this will also generate new ones. These two
+/// keys will go into effect the next time the `proxmox-backup.service` is started.
+fn update_certs(force: Option<bool>) -> Result<(), Error> {
+ pdm_config::setup::create_configdir()?;
+
+ if let Err(err) = generate_auth_key() {
+ bail!("unable to generate auth key - {err}");
+ }
+
+ if let Err(err) = generate_csrf_key() {
+ bail!("unable to generate csrf key - {err}");
+ }
+
+ update_self_signed_cert(force.unwrap_or(false))?;
+
+ Ok(())
+}
+
+pub fn cert_mgmt_cli() -> CommandLineInterface {
+ let cmd_def = CliCommandMap::new()
+ .insert("info", CliCommand::new(&API_METHOD_CERT_INFO))
+ .insert("update", CliCommand::new(&API_METHOD_UPDATE_CERTS));
+
+ cmd_def.into()
+}
diff --git a/cli/admin/src/main.rs b/cli/admin/src/main.rs
index 3102694e..a2c5c623 100644
--- a/cli/admin/src/main.rs
+++ b/cli/admin/src/main.rs
@@ -13,6 +13,7 @@ use proxmox_schema::api;
use proxmox_sys::fs::CreateOptions;
mod acme;
+mod cert;
mod remotes;
mod support_status;
@@ -39,6 +40,7 @@ async fn run() -> Result<(), Error> {
let cmd_def = CliCommandMap::new()
.insert("acme", acme::acme_mgmt_cli())
+ .insert("cert", cert::cert_mgmt_cli())
.insert("remote", remotes::cli())
.insert(
"report",
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [PATCH datacenter-manager 14/16] daily-update: warn about certificates with excessive lifetimes
2026-07-30 13:31 [PATCH datacenter-manager/proxmox{,-backup} 00/16] TLS Certificate Rotation Shannon Sterz
` (12 preceding siblings ...)
2026-07-30 13:31 ` [PATCH datacenter-manager 13/16] cli: expose certificate management endpoints via the cli Shannon Sterz
@ 2026-07-30 13:31 ` Shannon Sterz
2026-07-30 13:31 ` [PATCH datacenter-manager 15/16] docs: add section on forcing a new self-signed certificate Shannon Sterz
` (2 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Shannon Sterz @ 2026-07-30 13:31 UTC (permalink / raw)
To: pbs-devel
long certificate lifetimes pose a risk, so warn about a certificate
that is valid for more than 3650 days, almost ten years.
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
server/src/bin/proxmox-datacenter-manager-daily-update.rs | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/server/src/bin/proxmox-datacenter-manager-daily-update.rs b/server/src/bin/proxmox-datacenter-manager-daily-update.rs
index 803b2fc0..f86187e6 100644
--- a/server/src/bin/proxmox-datacenter-manager-daily-update.rs
+++ b/server/src/bin/proxmox-datacenter-manager-daily-update.rs
@@ -116,6 +116,10 @@ async fn renew_self_signed_certificate() -> Result<(), Error> {
} else if days <= 30 {
log::info!("Certificate expires within 30 days.");
// fixme: send_upcoming_self_signed_renewal_notification(earliest_renewal)?;
+ } else if days > 365 * 10 {
+ log::warn!(
+ "Self-signed certificate is valid for an excessive amount of time. Please renew it."
+ );
}
Ok(())
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [PATCH datacenter-manager 15/16] docs: add section on forcing a new self-signed certificate
2026-07-30 13:31 [PATCH datacenter-manager/proxmox{,-backup} 00/16] TLS Certificate Rotation Shannon Sterz
` (13 preceding siblings ...)
2026-07-30 13:31 ` [PATCH datacenter-manager 14/16] daily-update: warn about certificates with excessive lifetimes Shannon Sterz
@ 2026-07-30 13:31 ` Shannon Sterz
2026-07-30 13:31 ` [PATCH datacenter-manager 16/16] docs/certificates: use correct certificate file name Shannon Sterz
2026-07-30 13:44 ` [PATCH datacenter-manager/proxmox{,-backup} 00/16] TLS Certificate Rotation Shannon Sterz
16 siblings, 0 replies; 18+ messages in thread
From: Shannon Sterz @ 2026-07-30 13:31 UTC (permalink / raw)
To: pbs-devel
to avoid a warning added previously.
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
docs/certificate-management.rst | 31 +++++++++++++++++++++++++++++++
1 file changed, 31 insertions(+)
diff --git a/docs/certificate-management.rst b/docs/certificate-management.rst
index 652f6ca0..680d8af5 100644
--- a/docs/certificate-management.rst
+++ b/docs/certificate-management.rst
@@ -303,3 +303,34 @@ Test your new certificate, using your browser.
.. [1]
acme.sh https://github.com/acmesh-official/acme.sh
+
+Manually Renew Self-signed Certificates
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Proxmox Datacenter Manager creates and renews a self-signed certificate if no
+custom or ACME certificate is provided. Older versions issued a certificate
+that was valid for almost 1000 years and did not renew this certificate.
+Beginning with version 1.1, new setups use shorter lived certificates that will
+be regularly renewed. Old self-signed certificates are not replaced in order to
+not disrupt existing setups. In such cases, the following line is
+logged:
+
+.. code-block:: console
+
+ Apr 04 12:17:51 pdm proxmox-datacenter-manager-daily-update[1170]: Self-signed certificate is valid for an excessive amount of time. Please renew it.
+
+To manually renew a certificate, navigate to Configuration -> Certificates.
+Select the certificate ``proxy.pem``. Then click the "Delete Custom
+Certificate" button. Alternatively, you can run the following command:
+
+.. code-block:: shell
+
+ proxmox-datacenter-manager-admin cert update --force
+
+.. WARNING:: Any client using a fingerprint to verify TLS sessions with the
+ server will need to be updated with the new fingerprint.
+
+After manually renewing the certificate once, Proxmox Datacenter Manager will
+start renewing the certificate itself. A certificate will be renewed at the
+earliest 15 days before it expires. Starting from 30 days before it expires,
+notifications will be issued with a reminder about the upcoming renewal.
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* [PATCH datacenter-manager 16/16] docs/certificates: use correct certificate file name
2026-07-30 13:31 [PATCH datacenter-manager/proxmox{,-backup} 00/16] TLS Certificate Rotation Shannon Sterz
` (14 preceding siblings ...)
2026-07-30 13:31 ` [PATCH datacenter-manager 15/16] docs: add section on forcing a new self-signed certificate Shannon Sterz
@ 2026-07-30 13:31 ` Shannon Sterz
2026-07-30 13:44 ` [PATCH datacenter-manager/proxmox{,-backup} 00/16] TLS Certificate Rotation Shannon Sterz
16 siblings, 0 replies; 18+ messages in thread
From: Shannon Sterz @ 2026-07-30 13:31 UTC (permalink / raw)
To: pbs-devel
instead of the hard-coded `proxy.pem` that is inaccurate by now.
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
docs/certificate-management.rst | 5 +++--
server/src/auth/certs.rs | 2 +-
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/docs/certificate-management.rst b/docs/certificate-management.rst
index 680d8af5..0a53b1df 100644
--- a/docs/certificate-management.rst
+++ b/docs/certificate-management.rst
@@ -320,8 +320,9 @@ logged:
Apr 04 12:17:51 pdm proxmox-datacenter-manager-daily-update[1170]: Self-signed certificate is valid for an excessive amount of time. Please renew it.
To manually renew a certificate, navigate to Configuration -> Certificates.
-Select the certificate ``proxy.pem``. Then click the "Delete Custom
-Certificate" button. Alternatively, you can run the following command:
+Select the certificate ``/etc/proxmox-datacenter-manager/auth/api.pem``. Then
+click the "Delete Custom Certificate" button. Alternatively, you can run the
+following command:
.. code-block:: shell
diff --git a/server/src/auth/certs.rs b/server/src/auth/certs.rs
index 4c7cafcb..c08ecb6c 100644
--- a/server/src/auth/certs.rs
+++ b/server/src/auth/certs.rs
@@ -50,7 +50,7 @@ pub(crate) fn set_api_certificate(cert_pem: &[u8], key_pem: &[u8]) -> Result<(),
/// Get the certificate as bytes in PEM format.
pub fn get_certificate_pem() -> Result<Vec<u8>, Error> {
- let cert_pem = proxmox_sys::fs::file_get_contents("proxy.pem")?;
+ let cert_pem = proxmox_sys::fs::file_get_contents(API_CERT_FN)?;
Ok(cert_pem)
}
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread* Re: [PATCH datacenter-manager/proxmox{,-backup} 00/16] TLS Certificate Rotation
2026-07-30 13:31 [PATCH datacenter-manager/proxmox{,-backup} 00/16] TLS Certificate Rotation Shannon Sterz
` (15 preceding siblings ...)
2026-07-30 13:31 ` [PATCH datacenter-manager 16/16] docs/certificates: use correct certificate file name Shannon Sterz
@ 2026-07-30 13:44 ` Shannon Sterz
16 siblings, 0 replies; 18+ messages in thread
From: Shannon Sterz @ 2026-07-30 13:44 UTC (permalink / raw)
To: Shannon Sterz, pbs-devel
Ah sorry, i just noticed that the v2 here is missing. sorry about that.
On Thu Jul 30, 2026 at 3:31 PM CEST, Shannon Sterz wrote:
> this series adds certificate rotation to Proxmox Backup Server and Proxmox
> Datacenter Manager. currently, both products issue a certificate that is valid
> for almost 1000 years (365000 days). no cryptographic key can reasonably be
> considered secure for this amount of time. this series:
>
> - allows specifying the lifetime of the certificate when creating one via
> proxmox-acme-api and reduces the default to 3650 days (almost ten years).
> - sends and logs reminders 30 days before a certificate expires (pdm currently
> does not support the notification framework yet, so adding notifications is
> left as future work here).
> - refreshes a certificate at the earliest 15 days before it expires, logs
> and notifies when that happens.
> - warns on certificates with excessive lifetimes (>3650 days) and documents
> how to manually update them.
> - for pdm: exposes cert handling cli methods in proxmox-datacenter-manager-admin.
> - fixes up some inconsistencies in the ui and docs in regards to pdm's
> certificate location.
> - adjusts the severity of acme renewal error notifications for pbs
>
> ## Testing
>
> the easiest way to test this is to manipulate the date of the host with `date
> --set` and then manually trigger the daily update binary for each product:
>
> * PBS: `/usr/lib/x86_64-linux-gnu/proxmox-backup/proxmox-daily-update`
> * PDM: `/usr/libexec/proxmox/proxmox-datacenter-manager-daily-update`
>
> you can then check the logs and the certificate itself to see what happened.
> specifying the `PBS_LOG` environment variable with the parameter `trace` or
> `debug` will also enable debug logging here.
>
> ## Open Questions
>
> + 10 years is still a long time and i'd rather reduce that further down if
> possible. see the patch 02 for proxmox-tls-certificates for more info.
> + should we remove pre-existing long lasting certificates by ourselves? imo
> that is too risky at the moment given that an unplanned certificate rotation
> could cause backups to fail.
> + notifying every day for 15 days before the renewal might be excessive, see
> the second commit for pbs.
>
> ## Future Work
>
> - pve and pdm should be extended to allow automatically updating allowed
> fingerprints before a new self-signed certificate goes into action. a series
> demonstrating this for pve<->pdm has already been sent [1]. if this series gets
> approved, i'll happily adapt the mechanism for pbs<->pve and pbs<->pdm.
> - pdm should send notifications similar to pbs once support for notifications
> is added.
>
> ## Changelog
>
> * v1: https://lore.proxmox.com/pbs-devel/20260422124022.17952-1-s.sterz@proxmox.com/
>
> changes since v1 (thanks @ Lukas Wagner):
>
> + dropped a patch for proxmox-yew-comp that got applied already
> + rebased on current master for all three repos
> + created new `proxmox-tls-certificates` crate and moved several helpers
> there. reexposing functions in proxmox-acme-api to avoid breakage.
> + added unit tests and more documentation to tls helpers
> + adjusted the severity of error and renewal notifications
> + adapted the upcoming renewal template for pbs to inform about the
> earliest data a renewal could happen
>
> * rfc: https://lore.proxmox.com/pbs-devel/20260407135714.490747-1-s.sterz@proxmox.com/
>
> changes since rfc:
>
> + add patches that avoid hard-coding the certificate file name in yew-comp
> and use the proper filename in pdm
> + update pdm renewal docs patch to avoid confusion
> + re-base on current master
>
> [1]: https://lore.proxmox.com/pdm-devel/20260611120327.257523-1-s.sterz@proxmox.com/
>
>
> proxmox:
>
> Shannon Sterz (4):
> acme-api/tls-certificates: add new crate collecting tls realted
> helpers
> tls-certificates: add days_valid parameter to create_self_signed_cert
> tls-certificates: add self_signed_cert_expires_in to check
> certificates
> acme-api: stop re-exporting create_self_signed_cert
>
> Cargo.toml | 2 +
> proxmox-acme-api/Cargo.toml | 2 +
> proxmox-acme-api/src/certificate_helpers.rs | 195 ----------
> proxmox-acme-api/src/lib.rs | 2 +-
> proxmox-acme-api/src/types.rs | 51 +--
> proxmox-tls-certificates/Cargo.toml | 41 ++
> proxmox-tls-certificates/src/lib.rs | 7 +
> proxmox-tls-certificates/src/types.rs | 54 +++
> proxmox-tls-certificates/src/util.rs | 395 ++++++++++++++++++++
> 9 files changed, 503 insertions(+), 246 deletions(-)
> create mode 100644 proxmox-tls-certificates/Cargo.toml
> create mode 100644 proxmox-tls-certificates/src/lib.rs
> create mode 100644 proxmox-tls-certificates/src/types.rs
> create mode 100644 proxmox-tls-certificates/src/util.rs
>
>
> backup:
>
> Shannon Sterz (6):
> config: use proxmox_tls_certificates for generating self-signed
> certificates
> config/server/api: add certificate renewal logic including
> notifications
> daily update: warn about excessive self-signed certificate lifetime
> docs: document force refreshing long-lived certificates
> backup-manager cli: `cert update` can create auth and csrf key
> notifications: use `Severity::Error` for acme renewal failures
>
> Cargo.toml | 3 +
> debian/control | 2 +
> debian/proxmox-backup-server.install | 6 ++
> docs/certificate-management.rst | 31 ++++++
> src/api2/node/certificates.rs | 9 +-
> src/bin/proxmox-daily-update.rs | 43 ++++++++-
> src/bin/proxmox_backup_manager/cert.rs | 2 +
> src/config/mod.rs | 96 ++-----------------
> src/server/notifications/mod.rs | 58 ++++++++++-
> src/server/notifications/template_data.rs | 11 +++
> templates/Makefile | 68 +++++++------
> templates/default/cert-err-body.txt.hbs | 7 ++
> templates/default/cert-err-subject.txt.hbs | 1 +
> templates/default/cert-refresh-body.txt.hbs | 8 ++
> .../default/cert-refresh-subject.txt.hbs | 1 +
> .../cert-upcoming-refresh-body.txt.hbs | 10 ++
> .../cert-upcoming-refresh-subject.txt.hbs | 1 +
> 17 files changed, 237 insertions(+), 120 deletions(-)
> create mode 100644 templates/default/cert-err-body.txt.hbs
> create mode 100644 templates/default/cert-err-subject.txt.hbs
> create mode 100644 templates/default/cert-refresh-body.txt.hbs
> create mode 100644 templates/default/cert-refresh-subject.txt.hbs
> create mode 100644 templates/default/cert-upcoming-refresh-body.txt.hbs
> create mode 100644 templates/default/cert-upcoming-refresh-subject.txt.hbs
>
>
> datacenter-manager:
>
> Shannon Sterz (6):
> certs: use proxmox-tls-certificates directly, add days_valid paramter
> api/auth/bin: add certificate renewal logic
> cli: expose certificate management endpoints via the cli
> daily-update: warn about certificates with excessive lifetimes
> docs: add section on forcing a new self-signed certificate
> docs/certificates: use correct certificate file name
>
> Cargo.toml | 2 +
> cli/admin/Cargo.toml | 2 +
> cli/admin/src/cert.rs | 86 +++++++++++++++++++
> cli/admin/src/main.rs | 2 +
> debian/control | 2 +
> docs/certificate-management.rst | 32 +++++++
> server/Cargo.toml | 1 +
> server/src/api/nodes/certificates.rs | 19 ++--
> server/src/auth/certs.rs | 19 +++-
> ...proxmox-datacenter-manager-daily-update.rs | 38 ++++++++
> 10 files changed, 190 insertions(+), 13 deletions(-)
> create mode 100644 cli/admin/src/cert.rs
>
>
> Summary over all repositories:
> 36 files changed, 930 insertions(+), 379 deletions(-)
^ permalink raw reply [flat|nested] 18+ messages in thread