Merge pull request #2415 from caprover/agent/cleanup-orphaned-certificates
Run build / build (push) Has been cancelled
Run formatter / check-code-formatting (push) Has been cancelled
Run lint / run-lint (push) Has been cancelled
Build and push the edge image / run-pre-checks (push) Has been cancelled
Build and push the edge image / build-publish-docker-hub (push) Has been cancelled

Observe expiring orphaned SSL certificates
This commit is contained in:
Kasra Bigdeli
2026-07-17 17:51:34 -07:00
committed by GitHub
4 changed files with 177 additions and 1 deletions
+1
View File
@@ -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)
+83
View File
@@ -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
+44
View File
@@ -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}`
)
})
})
}
}
+49 -1
View File
@@ -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)
})
})