From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 9F2211FF142 for ; Tue, 07 Apr 2026 15:56:54 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id BEC511C961; Tue, 7 Apr 2026 15:57:25 +0200 (CEST) From: Shannon Sterz To: pbs-devel@lists.proxmox.com Subject: [PATCH datacenter-manager 08/10] api/auth/bin: add certificate renewal logic Date: Tue, 7 Apr 2026 15:57:12 +0200 Message-ID: <20260407135714.490747-9-s.sterz@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260407135714.490747-1-s.sterz@proxmox.com> References: <20260407135714.490747-1-s.sterz@proxmox.com> MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1775570172839 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.125 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: BV5FYAAOQEVNY4I4WYKMY5B42EBUSQHI X-Message-ID-Hash: BV5FYAAOQEVNY4I4WYKMY5B42EBUSQHI X-MailFrom: s.sterz@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Backup Server development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: 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 --- server/Cargo.toml | 1 + server/src/api/nodes/certificates.rs | 48 +++++++++++++++++++ server/src/auth/certs.rs | 3 +- ...proxmox-datacenter-manager-daily-update.rs | 26 ++++++++++ 4 files changed, 77 insertions(+), 1 deletion(-) diff --git a/server/Cargo.toml b/server/Cargo.toml index 6969549..0874acd 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -25,6 +25,7 @@ nix.workspace = true once_cell.workspace = true openssl.workspace = true percent-encoding.workspace = true +regex.workspace = true serde.workspace = true serde_json.workspace = true serde_plain.workspace = true diff --git a/server/src/api/nodes/certificates.rs b/server/src/api/nodes/certificates.rs index 47aef7a..3b43f2e 100644 --- a/server/src/api/nodes/certificates.rs +++ b/server/src/api/nodes/certificates.rs @@ -1,7 +1,10 @@ use anyhow::{bail, format_err, Context, Error}; +use const_format::concatcp; +use openssl::asn1::Asn1Time; use openssl::pkey::PKey; use openssl::x509::X509; +use proxmox_dns_api::read_etc_resolv_conf; use proxmox_log::info; use proxmox_router::list_subdirs_api_method; use proxmox_router::SubdirMap; @@ -17,6 +20,14 @@ use pdm_api_types::PRIV_SYS_MODIFY; use crate::auth::certs::{API_CERT_FN, API_KEY_FN}; +proxmox_schema::const_regex! { + pub SELF_SIGNED_REGEX = concatcp!( + r#"^O\s?=\s?"#, + crate::auth::certs::PRODUCT_NAME, + r#", OU\s?=\s?[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}, CN\s?=\s?(?.*)$"# + ); +} + pub const ROUTER: Router = Router::new() .get(&list_subdirs_api_method!(SUBDIRS)) .subdirs(SUBDIRS); @@ -247,6 +258,43 @@ pub fn cert_expires_soon() -> Result { .map_err(|err| format_err!("Failed to check certificate expiration date: {}", err)) } +/// 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() -> Result, Error> { + let cert = get_certificate_info()?; + + let Some(captures) = (SELF_SIGNED_REGEX.regex_obj)().captures(&cert.issuer) else { + return Ok(None); + }; + + let mut fqdn = proxmox_sys::nodename().to_owned(); + let resolv_conf = read_etc_resolv_conf(None)?.config; + + if let Some(domain) = resolv_conf.search { + fqdn.push('.'); + fqdn.push_str(&domain); + } + + 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)) +} + +/// 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 c310593..ea561d0 100644 --- a/server/src/auth/certs.rs +++ b/server/src/auth/certs.rs @@ -7,6 +7,7 @@ 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 +21,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_acme_api::create_self_signed_cert( - "Proxmox Datacenter Manager", + PRODUCT_NAME, proxmox_sys::nodename(), resolv_conf.search.as_deref(), None, diff --git a/server/src/bin/proxmox-datacenter-manager-daily-update.rs b/server/src/bin/proxmox-datacenter-manager-daily-update.rs index 8b6641c..deed0be 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,27 @@ async fn check_acme_certificates(rpcenv: &mut dyn RpcEnvironment) -> Result<(), Ok(()) } +async fn renew_self_signed_certificate() -> Result<(), Error> { + let days = match api::nodes::certificates::self_signed_cert_expires_in()? { + 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()?; + } + + 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