mirror of
https://github.com/caprover/caprover
synced 2026-08-23 07:56:28 +00:00
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
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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}`
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user