mirror of
https://github.com/caprover/caprover
synced 2025-10-30 18:07:25 +00:00
452 lines
16 KiB
TypeScript
452 lines
16 KiB
TypeScript
import ApiStatusCodes from '../../api/ApiStatusCodes'
|
|
import DockerApi from '../../docker/DockerApi'
|
|
import CaptainConstants, { type CertbotCertCommandRule } from '../../utils/CaptainConstants'
|
|
import Logger from '../../utils/Logger'
|
|
import Utils from '../../utils/Utils'
|
|
import fs = require('fs-extra')
|
|
import ShellQuote = require('shell-quote')
|
|
|
|
const WEBROOT_PATH_IN_CERTBOT = '/captain-webroot'
|
|
const WEBROOT_PATH_IN_CAPTAIN =
|
|
CaptainConstants.captainStaticFilesDir +
|
|
CaptainConstants.nginxDomainSpecificHtmlDir
|
|
|
|
const shouldUseStaging = false // CaptainConstants.isDebug;
|
|
|
|
function isCertCommandSuccess(output: string) {
|
|
// https://github.com/certbot/certbot/blob/099c6c8b240400b928d6b349e023e5e8414611e6/certbot/certbot/_internal/main.py#L516
|
|
if (
|
|
output.indexOf(
|
|
'Congratulations! Your certificate and chain have been saved',
|
|
) >= 0
|
|
) {
|
|
return true
|
|
}
|
|
|
|
// https://github.com/certbot/certbot/blob/f4e031f5055fc6bf8c87eb0b18f927f7f5ba36a8/certbot/certbot/_internal/main.py#L632
|
|
if (output.indexOf('Successfully received certificate') >= 0) {
|
|
return true
|
|
}
|
|
|
|
// https://github.com/certbot/certbot/blob/f4e031f5055fc6bf8c87eb0b18f927f7f5ba36a8/certbot/certbot/_internal/main.py#L1596
|
|
if (
|
|
output.indexOf(
|
|
'Certificate not yet due for renewal; no action taken',
|
|
) >= 0
|
|
) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
class CertbotManager {
|
|
private isOperationInProcess: boolean
|
|
private certCommandGenerator = new CertCommandGenerator(CaptainConstants.configs.certbotCertCommandRules ?? [], 'certbot certonly --webroot -w ${webroot} -d ${domainName}')
|
|
|
|
constructor(private dockerApi: DockerApi) {
|
|
this.dockerApi = dockerApi
|
|
}
|
|
|
|
domainValidOrThrow(domainName: string) {
|
|
if (!domainName) {
|
|
throw new Error('Domain Name is empty')
|
|
}
|
|
|
|
const RegExpression = /^[a-z0-9\.\-]*$/
|
|
|
|
if (!RegExpression.test(domainName)) {
|
|
throw new Error('Bad Domain Name!')
|
|
}
|
|
}
|
|
|
|
getCertRelativePathForDomain(domainName: string) {
|
|
const self = this
|
|
|
|
self.domainValidOrThrow(domainName)
|
|
|
|
return `/live/${domainName}/fullchain.pem`
|
|
}
|
|
|
|
getKeyRelativePathForDomain(domainName: string) {
|
|
const self = this
|
|
|
|
self.domainValidOrThrow(domainName)
|
|
|
|
return `/live/${domainName}/privkey.pem`
|
|
}
|
|
enableSsl(domainName: string) {
|
|
const self = this
|
|
|
|
Logger.d(`Enabling SSL for ${domainName}`)
|
|
|
|
return Promise.resolve()
|
|
.then(function () {
|
|
self.domainValidOrThrow(domainName)
|
|
return self.ensureDomainHasDirectory(domainName)
|
|
})
|
|
.then(function () {
|
|
const cmd = self.certCommandGenerator.getCertbotCertCommand(domainName, WEBROOT_PATH_IN_CERTBOT + '/' + domainName);
|
|
|
|
if (shouldUseStaging) {
|
|
cmd.push('--staging')
|
|
}
|
|
|
|
return self.runCommand(cmd).then(function (output) {
|
|
Logger.d(output)
|
|
|
|
if (isCertCommandSuccess(output)) {
|
|
return true
|
|
}
|
|
|
|
throw ApiStatusCodes.createError(
|
|
ApiStatusCodes.VERIFICATION_FAILED,
|
|
`Unexpected output when enabling SSL for${domainName} with ACME Certbot \n ${output}`
|
|
)
|
|
})
|
|
})
|
|
}
|
|
|
|
ensureRegistered(emailAddress: string) {
|
|
const self = this
|
|
|
|
return Promise.resolve()
|
|
.then(function () {
|
|
// Creds used to be saved at
|
|
// /etc/letencrypt/accounts/acme-v01.api.letsencrypt.org/directory/9fc95dbca2f0b877
|
|
// After moving to 0.29.1, Certbot started using v2 API. and this path is no longer valid.
|
|
// Instead, they use v02 path. However, old installations who registered with v1, will remain in the same directory
|
|
const cmd = [
|
|
'certbot',
|
|
'register',
|
|
'--email',
|
|
emailAddress,
|
|
'--agree-tos',
|
|
'--no-eff-email',
|
|
]
|
|
|
|
if (shouldUseStaging) {
|
|
cmd.push('--staging')
|
|
}
|
|
|
|
return self.runCommand(cmd)
|
|
})
|
|
.then(function (registerOutput) {
|
|
if (
|
|
registerOutput.indexOf(
|
|
'Your account credentials have been saved in your Certbot'
|
|
) >= 0
|
|
) {
|
|
return true
|
|
}
|
|
|
|
if (
|
|
registerOutput.indexOf('There is an existing account') >= 0
|
|
) {
|
|
return true
|
|
}
|
|
|
|
throw new Error(
|
|
`Unexpected output when registering with ACME Certbot \n ${registerOutput}`
|
|
)
|
|
})
|
|
}
|
|
|
|
/*
|
|
Certificate Name: customdomain-another.hm2.caprover.com
|
|
Domains: customdomain-another.hm2.caprover.com
|
|
Expiry Date: 2019-03-22 04:22:55+00:00 (VALID: 81 days)
|
|
Certificate Path: /etc/letsencrypt/live/customdomain-another.hm2.caprover.com/fullchain.pem
|
|
Private Key Path: /etc/letsencrypt/live/customdomain-another.hm2.caprover.com/privkey.pem
|
|
Certificate Name: testing.cp.hm.caprover.com
|
|
Domains: testing.cp.hm.caprover.com
|
|
Expiry Date: 2019-03-21 18:42:17+00:00 (VALID: 81 days)
|
|
Certificate Path: /etc/letsencrypt/live/testing.cp.hm.caprover.com/fullchain.pem
|
|
Private Key Path: /etc/letsencrypt/live/testing.cp.hm.caprover.com/privkey.pem
|
|
Certificate Name: registry.cp.hm.caprover.com
|
|
Domains: registry.cp.hm.caprover.com
|
|
Expiry Date: 2019-03-25 04:56:45+00:00 (VALID: 84 days)
|
|
Certificate Path: /etc/letsencrypt/live/registry.cp.hm.caprover.com/fullchain.pem
|
|
Private Key Path: /etc/letsencrypt/live/registry.cp.hm.caprover.com/privkey.pem
|
|
Certificate Name: captain.cp.hm.caprover.com
|
|
Domains: captain.cp.hm.caprover.com
|
|
Expiry Date: 2019-03-20 22:25:50+00:00 (VALID: 80 days)
|
|
Certificate Path: /etc/letsencrypt/live/captain.cp.hm.caprover.com/fullchain.pem
|
|
Private Key Path: /etc/letsencrypt/live/captain.cp.hm.caprover.com/privkey.pem
|
|
Certificate Name: testing2.cp.hm.caprover.com
|
|
Domains: testing2.cp.hm.caprover.com
|
|
Expiry Date: 2019-03-21 18:42:55+00:00 (VALID: 81 days)
|
|
Certificate Path: /etc/letsencrypt/live/testing2.cp.hm.caprover.com/fullchain.pem
|
|
Private Key Path: /etc/letsencrypt/live/testing2.cp.hm.caprover.com/privkey.pem
|
|
|
|
*/
|
|
ensureAllCurrentlyRegisteredDomainsHaveDirs() {
|
|
const self = this
|
|
return Promise.resolve() //
|
|
.then(function () {
|
|
return self
|
|
.runCommand(['certbot', 'certificates'])
|
|
.then(function (output) {
|
|
const lines = output.split('\n')
|
|
const domains: string[] = []
|
|
lines.forEach((l) => {
|
|
if (l.indexOf('Certificate Name:') >= 0) {
|
|
domains.push(
|
|
l.replace('Certificate Name:', '').trim()
|
|
)
|
|
}
|
|
})
|
|
|
|
return domains
|
|
})
|
|
})
|
|
.then(function (allDomains) {
|
|
const p = Promise.resolve()
|
|
allDomains.forEach((d) => {
|
|
p.then(function () {
|
|
return self.ensureDomainHasDirectory(d)
|
|
})
|
|
})
|
|
|
|
return p
|
|
})
|
|
}
|
|
|
|
lock() {
|
|
if (this.isOperationInProcess) {
|
|
throw ApiStatusCodes.createError(
|
|
ApiStatusCodes.STATUS_ERROR_GENERIC,
|
|
'Another operation is in process for Certbot. Please wait a few seconds and try again.'
|
|
)
|
|
}
|
|
|
|
this.isOperationInProcess = true
|
|
}
|
|
|
|
unlock() {
|
|
this.isOperationInProcess = false
|
|
}
|
|
|
|
runCommand(cmd: string[]) {
|
|
const dockerApi = this.dockerApi
|
|
const self = this
|
|
|
|
return Promise.resolve().then(function () {
|
|
self.lock()
|
|
|
|
const nonInterActiveCommand = [...cmd, '--non-interactive']
|
|
return dockerApi
|
|
.executeCommand(
|
|
CaptainConstants.certbotServiceName,
|
|
nonInterActiveCommand
|
|
)
|
|
.then(function (data) {
|
|
self.unlock()
|
|
Logger.dev(data)
|
|
return data
|
|
})
|
|
.catch(function (error) {
|
|
self.unlock()
|
|
throw error
|
|
})
|
|
})
|
|
}
|
|
|
|
ensureDomainHasDirectory(domainName: string) {
|
|
return Promise.resolve() //
|
|
.then(function () {
|
|
return fs.ensureDir(`${WEBROOT_PATH_IN_CAPTAIN}/${domainName}`)
|
|
})
|
|
}
|
|
|
|
renewAllCerts() {
|
|
const self = this
|
|
|
|
/*
|
|
From Certbot docs:
|
|
This command attempts to renew all previously-obtained certificates that expire in less than 30 days.
|
|
The same plugin and options that were used at the time the certificate was originally issued will be
|
|
used for the renewal attempt, unless you specify other plugins or options. Unlike certonly, renew
|
|
acts on multiple certificates and always takes into account whether each one is near expiry. Because
|
|
of this, renew is suitable (and designed) for automated use, to allow your system to automatically
|
|
renew each certificate when appropriate. Since renew only renews certificates that are near expiry
|
|
it can be run as frequently as you want - since it will usually take no action.
|
|
*/
|
|
|
|
const cmd = ['certbot', 'renew']
|
|
|
|
if (shouldUseStaging) {
|
|
cmd.push('--staging')
|
|
}
|
|
|
|
return Promise.resolve() //
|
|
.then(function () {
|
|
return self.ensureAllCurrentlyRegisteredDomainsHaveDirs()
|
|
})
|
|
.then(function () {
|
|
return self.runCommand(cmd)
|
|
})
|
|
.then(function (output) {
|
|
// Ignore output :)
|
|
})
|
|
.catch(function (err) {
|
|
Logger.e(err)
|
|
})
|
|
}
|
|
|
|
init(myNodeId: string) {
|
|
const dockerApi = this.dockerApi
|
|
const self = this
|
|
|
|
function createCertbotServiceOnNode(nodeId: string) {
|
|
Logger.d('Creating Certbot service')
|
|
|
|
return dockerApi
|
|
.createServiceOnNodeId(
|
|
CaptainConstants.configs.certbotImageName,
|
|
CaptainConstants.certbotServiceName,
|
|
undefined,
|
|
nodeId,
|
|
undefined,
|
|
undefined,
|
|
undefined
|
|
)
|
|
.then(function () {
|
|
Logger.d('Waiting for Certbot...')
|
|
return Utils.getDelayedPromise(12000)
|
|
})
|
|
}
|
|
|
|
return Promise.resolve()
|
|
.then(function () {
|
|
return fs.ensureDir(CaptainConstants.letsEncryptEtcPath)
|
|
})
|
|
.then(function () {
|
|
return fs.ensureDir(CaptainConstants.letsEncryptLibPath)
|
|
})
|
|
.then(function () {
|
|
return fs.ensureDir(WEBROOT_PATH_IN_CAPTAIN)
|
|
})
|
|
.then(function () {
|
|
return dockerApi.isServiceRunningByName(
|
|
CaptainConstants.certbotServiceName
|
|
)
|
|
})
|
|
.then(function (isRunning) {
|
|
if (isRunning) {
|
|
Logger.d('Captain Certbot is already running.. ')
|
|
|
|
return dockerApi.getNodeIdByServiceName(
|
|
CaptainConstants.certbotServiceName,
|
|
0
|
|
)
|
|
} else {
|
|
Logger.d(
|
|
'No Captain Certbot service is running. Creating one...'
|
|
)
|
|
|
|
return createCertbotServiceOnNode(myNodeId) //
|
|
.then(function () {
|
|
return myNodeId
|
|
})
|
|
}
|
|
})
|
|
.then(function (nodeId) {
|
|
if (nodeId !== myNodeId) {
|
|
Logger.d(
|
|
'Captain Certbot is running on a different node. Removing...'
|
|
)
|
|
|
|
return dockerApi
|
|
.removeServiceByName(
|
|
CaptainConstants.certbotServiceName
|
|
)
|
|
.then(function () {
|
|
Logger.d('Waiting for Certbot to be removed...')
|
|
return Utils.getDelayedPromise(10000)
|
|
})
|
|
.then(function () {
|
|
return createCertbotServiceOnNode(myNodeId).then(
|
|
function () {
|
|
return true
|
|
}
|
|
)
|
|
})
|
|
} else {
|
|
return true
|
|
}
|
|
})
|
|
.then(function () {
|
|
Logger.d('Updating Certbot service...')
|
|
|
|
return dockerApi.updateService(
|
|
CaptainConstants.certbotServiceName,
|
|
CaptainConstants.configs.certbotImageName,
|
|
[
|
|
{
|
|
hostPath: CaptainConstants.letsEncryptEtcPath,
|
|
containerPath: '/etc/letsencrypt',
|
|
},
|
|
{
|
|
hostPath: CaptainConstants.letsEncryptLibPath,
|
|
containerPath: '/var/lib/letsencrypt',
|
|
},
|
|
{
|
|
hostPath: WEBROOT_PATH_IN_CAPTAIN,
|
|
containerPath: WEBROOT_PATH_IN_CERTBOT,
|
|
},
|
|
],
|
|
// No need to certbot to be connected to the network
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined
|
|
)
|
|
})
|
|
.then(function () {
|
|
return self.ensureAllCurrentlyRegisteredDomainsHaveDirs()
|
|
})
|
|
}
|
|
}
|
|
|
|
export default CertbotManager
|
|
|
|
export class CertCommandGenerator {
|
|
constructor(private rules: CertbotCertCommandRule[], private defaultCommand: string) {
|
|
}
|
|
|
|
private getCertbotCertCommandTemplate(domainName: string): string {
|
|
for (const rule of this.rules) {
|
|
if (rule.domain === '*'
|
|
|| domainName === rule.domain
|
|
|| domainName.endsWith('.' + rule.domain)
|
|
) {
|
|
return rule.command ?? this.defaultCommand
|
|
}
|
|
}
|
|
return this.defaultCommand
|
|
}
|
|
getCertbotCertCommand(domainName: string, webroot:string): string[] {
|
|
const variables: Record<string, string> = {
|
|
domainName,
|
|
webroot,
|
|
}
|
|
const command = this.getCertbotCertCommandTemplate(domainName)
|
|
const parsed = ShellQuote.parse(command, (key: string)=> {
|
|
return variables[key] ?? `\${${key}}`
|
|
})
|
|
if (parsed.some(x => typeof x !== 'string')) {
|
|
throw new Error(`Invalid command: ${command}`)
|
|
}
|
|
return parsed as string[];
|
|
}
|
|
}
|