From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id 03FF91FF12C for ; Wed, 05 Aug 2026 15:19:07 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 3E489219D2; Wed, 05 Aug 2026 15:18:58 +0200 (CEST) From: Shannon Sterz To: pdm-devel@lists.proxmox.com Subject: [PATCH manager v2 03/16] bin/api: add a new staged certificate when renewing self-signed cert Date: Wed, 5 Aug 2026 15:18:26 +0200 Message-ID: <20260805131838.254723-5-s.sterz@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260805131838.254723-2-s.sterz@proxmox.com> References: <20260805131838.254723-2-s.sterz@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1785935918479 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.130 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_LOW -0.7 Sender listed at https://www.dnswl.org/, low trust 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: KWZ52A3OSYQYMYX6XPGZ52XQRF4AFXXW X-Message-ID-Hash: KWZ52A3OSYQYMYX6XPGZ52XQRF4AFXXW 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 Datacenter Manager development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: when a certificate is about to expire (within the next four weeks, up to two weeks before it expires), create a new staged "next" certificate. by including this certificate when querying the api, clients can prepare for when the certificate is about to be rotated. this allows them to update to the new fingerprint, without needing an additional out-of-band communication channel. Signed-off-by: Shannon Sterz --- PVE/API2/Certificates.pm | 5 ++- PVE/CertHelpers.pm | 6 ++++ bin/pveupdate | 72 +++++++++++++++++++++++++++++++++------- 3 files changed, 70 insertions(+), 13 deletions(-) diff --git a/PVE/API2/Certificates.pm b/PVE/API2/Certificates.pm index de8762c53..bcd650c3b 100644 --- a/PVE/API2/Certificates.pm +++ b/PVE/API2/Certificates.pm @@ -70,7 +70,10 @@ __PACKAGE__->register_method({ my $res = []; my $cert_paths = [ - '/etc/pve/pve-root-ca.pem', "$node_path/pve-ssl.pem", "$node_path/pveproxy-ssl.pem", + '/etc/pve/pve-root-ca.pem', + "$node_path/pve-ssl.pem", + "$node_path/pveproxy-ssl.pem", + "$node_path/pve-ssl-next.pem", ]; for my $path (@$cert_paths) { eval { diff --git a/PVE/CertHelpers.pm b/PVE/CertHelpers.pm index 202dec0ee..4272eaf57 100644 --- a/PVE/CertHelpers.pm +++ b/PVE/CertHelpers.pm @@ -55,6 +55,12 @@ sub default_cert_path_prefix { return "/etc/pve/nodes/${node}/pve-ssl"; } +sub default_next_cert_path_prefix { + my ($node) = @_; + + return "/etc/pve/nodes/${node}/pve-ssl-next"; +} + sub cert_lock { my ($timeout, $code, @param) = @_; diff --git a/bin/pveupdate b/bin/pveupdate index b1960c353..e44c614ee 100755 --- a/bin/pveupdate +++ b/bin/pveupdate @@ -103,10 +103,12 @@ syslog('err', "Renewing ACME certificate failed: $@") if $@; eval { my $certpath = PVE::CertHelpers::default_cert_path_prefix($nodename) . ".pem"; + my $next_certpath = PVE::CertHelpers::default_next_cert_path_prefix($nodename) . ".pem"; my $capath = "/etc/pve/pve-root-ca.pem"; + my $now = time(); my $renew = sub { - my ($msg) = @_; + my ($msg, $use_later) = @_; # get CA info my $cainfo = PVE::Certificate::get_certificate_info($capath); @@ -127,21 +129,67 @@ eval { print "PVE certificate $msg\n"; # create new certificate my $ip = PVE::Cluster::remote_node_ip($nodename); - PVE::Cluster::Setup::gen_pve_ssl_cert(1, $nodename, $ip); - print "Restarting pveproxy after renewing certificate\n"; - PVE::Tools::run_command(['systemctl', 'reload-or-restart', 'pveproxy']); + if ($use_later) { + PVE::Cluster::Setup::gen_pve_ssl_cert(1, $nodename, $ip, $next_certpath); + } else { + PVE::Cluster::Setup::gen_pve_ssl_cert(1, $nodename, $ip); + print "Restarting pveproxy after renewing certificate\n"; + PVE::Tools::run_command(['systemctl', 'reload-or-restart', 'pveproxy']); + } }; - if (PVE::Certificate::check_expiry($certpath)) { - # already expired - $renew->("expired, renewing..."); - } elsif (PVE::Certificate::check_expiry($certpath, time() + 14 * 24 * 60 * 60)) { - # expires in next 2 weeks - $renew->("expires soon, renewing..."); - } elsif (!PVE::Certificate::check_expiry($certpath, time() + 2 * 365 * 24 * 60 * 60)) { + my $rotate_in_next_certificate_or_renew = sub { + my ($msg) = @_; + + # if a staged certificate: + # + # 1. exists + # 2. does not expire within two weeks + # 3. does expire within two years + # 4. and is signed by the currently active ca + # + # rotate it in, otherwise create a new certificate + if ( + -f $next_certpath + && !PVE::Certificate::check_expiry($next_certpath, $now + 14 * 24 * 60 * 60) + && PVE::Certificate::check_expiry($next_certpath, $now + 2 * 365 * 24 * 60 * 60) + && PVE::Certificate::check_certificate_signed_by_ca($next_certpath, $capath) + ) { + rename($next_certpath, $certpath); + print("Restarting pveproxy after rotating certificate\n"); + PVE::Tools::run_command(['systemctl', 'reload-or-restart', 'pveproxy']); + } else { + $renew->($msg); + # clean up the now useless staged certificate, if there is one + if (-f $next_certpath) { + unlink $next_certpath + or $!{ENOENT} + or warn "failed to clean-up $next_certpath - $!\n"; + } + } + + }; + + if (PVE::Certificate::check_expiry($certpath, $now + 14 * 24 * 60 * 60)) { + $rotate_in_next_certificate_or_renew->("expired or expires soon, renewing..."); + } elsif (PVE::Certificate::check_expiry($certpath, $now + 28 * 24 * 60 * 60)) { + # if the certificate expires in 4 weeks, stage a new one if no + # certificate has been staged yet or the staged one is invalid by now + if ( + !-f $next_certpath + || PVE::Certificate::check_expiry($next_certpath, $now + 28 * 24 * 60 * 60) + || !PVE::Certificate::check_expiry($next_certpath, $now + 2 * 365 * 24 * 60 * 60) + || !PVE::Certificate::check_certificate_signed_by_ca($next_certpath, $capath) + ) { + unlink $next_certpath + or $!{ENOENT} + or warn "failed to clean-up $next_certpath - $!\n"; + $renew->("staging new certificate...", 1); + } + } elsif (!PVE::Certificate::check_expiry($certpath, $now + 2 * 365 * 24 * 60 * 60)) { # expires in more than 2 years - $renew->( + $rotate_in_next_certificate_or_renew->( "expires in more than 2 years, renewing to reduce certificate life-span for client compatibility..." ); } -- 2.47.3