diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e0441b..f0bf782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Improved: Descriptive error on installations on incompatible systems (e.g. Proxmox LXC) [issues-2326](https://github.com/caprover/caprover/issues/2326) - Improved: Moved one click app creation process to backend for more stability [PR-2334](https://github.com/caprover/caprover/pull/2334) - Improved: Reduced the Backup size by excluding the GoAccess logs [PR-2336](https://github.com/caprover/caprover/pull/2336) +- Improved: Logged expired or soon-to-expire orphaned SSL certificates for a safe observation period [Issue-2397](https://github.com/caprover/caprover/issues/2397) - Fixed: Prevented malformed `config-captain.json` from being overwritten [Issue-858](https://github.com/caprover/caprover/issues/858) - Fixed: Git webhook deployments failing when webhook payloads exceed 100 KB [Issue-1393](https://github.com/caprover/caprover/issues/1393) diff --git a/src/user/system/CertbotManager.ts b/src/user/system/CertbotManager.ts index ec26246..f9ead9c 100644 --- a/src/user/system/CertbotManager.ts +++ b/src/user/system/CertbotManager.ts @@ -1,3 +1,4 @@ +import { X509Certificate } from 'crypto' import ApiStatusCodes from '../../api/ApiStatusCodes' import DockerApi from '../../docker/DockerApi' import CaptainConstants, { @@ -14,6 +15,25 @@ const WEBROOT_PATH_IN_CAPTAIN = CaptainConstants.nginxDomainSpecificHtmlDir const shouldUseStaging = false // CaptainConstants.isDebug; +const ORPHAN_CERTIFICATE_EXPIRY_THRESHOLD_MS = 48 * 60 * 60 * 1000 +const CERTBOT_RENEWAL_CONFIG_DIRECTORY = + CaptainConstants.letsEncryptEtcPath + '/renewal' + +export function isExpiringOrphanedCertificate( + certificateName: string, + activeDomains: string[], + expiryDate: number, + currentTime = Date.now() +): boolean { + const activeDomainSet = new Set( + activeDomains.map((domain) => domain.toLowerCase()) + ) + + return ( + !activeDomainSet.has(certificateName.toLowerCase()) && + expiryDate <= currentTime + ORPHAN_CERTIFICATE_EXPIRY_THRESHOLD_MS + ) +} function isCertCommandSuccess(output: string) { // https://github.com/certbot/certbot/blob/099c6c8b240400b928d6b349e023e5e8414611e6/certbot/certbot/_internal/main.py#L516 @@ -268,6 +288,69 @@ class CertbotManager { }) } + logExpiringOrphanedCertificates(activeDomains: string[]) { + const self = this + + return fs + .readdir(CERTBOT_RENEWAL_CONFIG_DIRECTORY) + .then(function (renewalConfigFiles) { + let cleanupPromise = Promise.resolve() + + renewalConfigFiles + .filter((fileName) => fileName.endsWith('.conf')) + .map((fileName) => fileName.slice(0, -'.conf'.length)) + .forEach((certificateName) => { + cleanupPromise = cleanupPromise.then(function () { + try { + self.domainValidOrThrow(certificateName) + } catch (error) { + Logger.e( + `Skipping invalid Certbot certificate name ${certificateName}: ${error}` + ) + return + } + + const certificatePath = + CaptainConstants.letsEncryptEtcPath + + `/live/${certificateName}/cert.pem` + + return fs + .readFile(certificatePath) + .then(function (certificatePem) { + const certificate = new X509Certificate( + certificatePem + ) + const expiryDate = Date.parse( + certificate.validTo + ) + + if ( + Number.isNaN(expiryDate) || + !isExpiringOrphanedCertificate( + certificateName, + activeDomains, + expiryDate + ) + ) { + return + } + + Logger.d( + `Orphaned certificate eligible for deletion (no action taken): ${certificateName}` + ) + }) + .catch(function (error) { + Logger.e( + `Skipping orphan candidate check for certificate ${certificateName}: ${error}` + ) + }) + }) + }) + + return cleanupPromise + }) + } + renewAllCerts() { const self = this diff --git a/src/user/system/LoadBalancerManager.ts b/src/user/system/LoadBalancerManager.ts index 1c7b628..dee5f68 100644 --- a/src/user/system/LoadBalancerManager.ts +++ b/src/user/system/LoadBalancerManager.ts @@ -919,6 +919,35 @@ class LoadBalancerManager { }) } + getActiveSslDomains() { + const self = this + + return Promise.all([ + self.getServerList(), + self.dataStore.getHasRootSsl(), + self.dataStore.getHasRegistrySsl(), + ]).then(function ([servers, hasRootSsl, hasRegistrySsl]) { + const activeDomains: string[] = servers + .filter((server) => server.hasSsl) + .map((server) => server.publicDomain) + const rootDomain = self.dataStore.getRootDomain() + + if (hasRootSsl) { + activeDomains.push( + `${CaptainConstants.configs.captainSubDomain}.${rootDomain}` + ) + } + + if (hasRegistrySsl) { + activeDomains.push( + `${CaptainConstants.registrySubDomain}.${rootDomain}` + ) + } + + return Array.from(new Set(activeDomains)) + }) + } + renewAllCertsAndReload() { const self = this @@ -942,6 +971,21 @@ class LoadBalancerManager { Logger.d('Updating Load Balancer - renewAllCerts') return self.rePopulateNginxConfigFile() }) + .then(function () { + return self + .getActiveSslDomains() + .then(function (activeDomains) { + return self.certbotManager.logExpiringOrphanedCertificates( + activeDomains + ) + }) + .catch(function (error) { + // Observation must never affect certificate renewal or NGINX reload. + Logger.e( + `Orphaned certificate observation failed (no action taken): ${error}` + ) + }) + }) } } diff --git a/tests/CertCommandGenerator.test.ts b/tests/CertCommandGenerator.test.ts index 02d5b3e..6dd5e96 100644 --- a/tests/CertCommandGenerator.test.ts +++ b/tests/CertCommandGenerator.test.ts @@ -1,4 +1,7 @@ -import { CertCommandGenerator } from '../src/user/system/CertbotManager' +import { + CertCommandGenerator, + isExpiringOrphanedCertificate, +} from '../src/user/system/CertbotManager' const defaultCommand = 'certbot certonly --domain ${domainName}' const exampleRule = { @@ -59,3 +62,48 @@ test('falls back to default command when rule command is null', () => { expect(generator.getCertbotCertCommand('nullcommand.com', fakeWebroot)) .toEqual([ 'certbot', 'certonly', '--domain', 'nullcommand.com' ]) }) + +describe('orphaned certificate observation', () => { + const currentTime = Date.parse('2026-07-16T12:00:00Z') + + test('flags orphaned certificates expiring within 48 hours', () => { + expect( + isExpiringOrphanedCertificate( + 'expired.example.com', + [], + currentTime - 1, + currentTime + ) + ).toBe(true) + expect( + isExpiringOrphanedCertificate( + '48-hours.example.com', + [], + currentTime + 48 * 60 * 60 * 1000, + currentTime + ) + ).toBe(true) + }) + + test('does not flag orphaned certificates with more than 48 hours remaining', () => { + expect( + isExpiringOrphanedCertificate( + '49-hours.example.com', + [], + currentTime + 49 * 60 * 60 * 1000, + currentTime + ) + ).toBe(false) + }) + + test('does not flag active certificates even when they are expired', () => { + expect( + isExpiringOrphanedCertificate( + 'active.example.com', + ['ACTIVE.EXAMPLE.COM'], + currentTime - 1, + currentTime + ) + ).toBe(false) + }) +})