refactor: use shell-quote to parse cert command rule

This commit is contained in:
ZeekoZhu
2024-05-26 22:33:27 +08:00
parent a5e87d6e82
commit ab2668da8c
5 changed files with 56 additions and 24 deletions

16
package-lock.json generated
View File

@@ -32,6 +32,7 @@
"request": "^2.88.2",
"require-from-string": "^2.0.2",
"serve-favicon": "~2.5.0",
"shell-quote": "^1.8.1",
"simple-git": "^2.45.0",
"ssh2": "^1.4.0",
"tar": "^6.1.11",
@@ -60,6 +61,7 @@
"@types/request": "^2.48.7",
"@types/require-from-string": "^1.2.1",
"@types/serve-favicon": "^2.5.3",
"@types/shell-quote": "^1.7.5",
"@types/ssh2": "^0.5.47",
"@types/tar": "^4.0.5",
"@types/uuid": "^8.3.1",
@@ -1524,6 +1526,12 @@
"@types/node": "*"
}
},
"node_modules/@types/shell-quote": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@types/shell-quote/-/shell-quote-1.7.5.tgz",
"integrity": "sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==",
"dev": true
},
"node_modules/@types/ssh2": {
"version": "0.5.52",
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.52.tgz",
@@ -7580,6 +7588,14 @@
"node": ">=8"
}
},
"node_modules/shell-quote": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz",
"integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",

View File

@@ -38,6 +38,7 @@
"request": "^2.88.2",
"require-from-string": "^2.0.2",
"serve-favicon": "~2.5.0",
"shell-quote": "^1.8.1",
"simple-git": "^2.45.0",
"ssh2": "^1.4.0",
"tar": "^6.1.11",
@@ -66,6 +67,7 @@
"@types/request": "^2.48.7",
"@types/require-from-string": "^1.2.1",
"@types/serve-favicon": "^2.5.3",
"@types/shell-quote": "^1.7.5",
"@types/ssh2": "^0.5.47",
"@types/tar": "^4.0.5",
"@types/uuid": "^8.3.1",

View File

@@ -4,6 +4,7 @@ import CaptainConstants, { type CertbotCertCommandRule } from '../../utils/Capta
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 =
@@ -41,7 +42,7 @@ function isCertCommandSuccess(output: string) {
class CertbotManager {
private isOperationInProcess: boolean
private certCommandGenerator = new CertCommandGenerator(CaptainConstants.configs.certbotCertCommand ?? [], ['certbot', 'certonly', '--webroot', '-w', '${webroot}', '-d', '${domain}'])
private certCommandGenerator = new CertCommandGenerator(CaptainConstants.configs.certbotCertCommandRules ?? [], 'certbot certonly --webroot -w ${webroot} -d ${domainName}')
constructor(private dockerApi: DockerApi) {
this.dockerApi = dockerApi
@@ -85,10 +86,7 @@ class CertbotManager {
return self.ensureDomainHasDirectory(domainName)
})
.then(function () {
const cmd = self.certCommandGenerator.getCertbotCertCommand(domainName, {
domain: domainName,
webroot: WEBROOT_PATH_IN_CERTBOT + '/' + domainName
})
const cmd = self.certCommandGenerator.getCertbotCertCommand(domainName, WEBROOT_PATH_IN_CERTBOT + '/' + domainName);
if (shouldUseStaging) {
cmd.push('--staging')
@@ -422,10 +420,10 @@ class CertbotManager {
export default CertbotManager
export class CertCommandGenerator {
constructor(private rules: CertbotCertCommandRule[], private defaultCommand: string[]) {
constructor(private rules: CertbotCertCommandRule[], private defaultCommand: string) {
}
private getCertbotCertCommandTemplate(domainName: string): string[] {
private getCertbotCertCommandTemplate(domainName: string): string {
for (const rule of this.rules) {
if (rule.domain === '*'
|| domainName === rule.domain
@@ -436,8 +434,18 @@ export class CertCommandGenerator {
}
return this.defaultCommand
}
getCertbotCertCommand(domainName: string, variables: Record<string, string> = {}): string[] {
getCertbotCertCommand(domainName: string, webroot:string): string[] {
const variables: Record<string, string> = {
domainName,
webroot,
}
const command = this.getCertbotCertCommandTemplate(domainName)
return command.map(c => c.replace(/\$\{(\w+)}/g, (match, p1) => variables[p1] ?? match))
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[];
}
}

View File

@@ -57,7 +57,7 @@ const configs = {
certbotImageName: 'caprover/certbot-sleeping:v1.6.0',
certbotCertCommand: undefined as CertbotCertCommandRule[] | undefined,
certbotCertCommandRules: undefined as CertbotCertCommandRule[] | undefined,
}
export interface CertbotCertCommandRule {
@@ -66,9 +66,9 @@ export interface CertbotCertCommandRule {
*/
domain: string;
/**
* The Certbot command to execute, in Docker exec form, uses `${domain}` as the placeholder for the actual domain name
* The Certbot command to execute, will be parsed using `shell-quote`, available variables are `${domainName}` and `${subdomain}`
*/
command?: string[];
command?: string;
}
const data = {

View File

@@ -1,55 +1,61 @@
import { CertCommandGenerator } from '../src/user/system/CertbotManager'
const defaultCommand = [ 'certbot', 'certonly', '--domain', '${domain}' ]
const defaultCommand = 'certbot certonly --domain ${domainName}'
const exampleRule = {
domain: 'example.com',
command: [ 'certbot', 'certonly', '--manual', '--preferred-challenges=dns', '--domain', '${domain}' ],
command: 'certbot certonly --manual --preferred-challenges=dns --domain ${domainName}',
}
const wildcardRule = {
domain: '*',
command: [ 'certbot', 'renew' ],
command: 'certbot renew',
}
const nullCommandRule = {
domain: 'fallback.com',
}
const fakeWebroot = '/path/to/webroot'
test('uses default command when no rules match', () => {
const generator = new CertCommandGenerator([], defaultCommand)
expect(generator.getCertbotCertCommand('nonmatching.com')).toEqual(defaultCommand)
expect(generator.getCertbotCertCommand('nonmatching.com', fakeWebroot))
.toEqual([ 'certbot', 'certonly', '--domain', 'nonmatching.com' ])
})
test('uses specific rule when domain matches exactly', () => {
const generator = new CertCommandGenerator([ exampleRule ], defaultCommand)
expect(generator.getCertbotCertCommand('example.com')).toEqual(exampleRule.command)
expect(generator.getCertbotCertCommand('example.com', fakeWebroot))
.toEqual([ 'certbot', 'certonly', '--manual', '--preferred-challenges=dns', '--domain', 'example.com' ])
})
test('uses wildcard rule for any domain', () => {
const generator = new CertCommandGenerator([ wildcardRule ], defaultCommand)
expect(generator.getCertbotCertCommand('anything.com')).toEqual(wildcardRule.command)
expect(generator.getCertbotCertCommand('anything.com', fakeWebroot)).toEqual([ 'certbot', 'renew' ])
})
test('matches subdomain to rule', () => {
const generator = new CertCommandGenerator([ exampleRule ], defaultCommand)
expect(generator.getCertbotCertCommand('sub.example.com')).toEqual(exampleRule.command)
expect(generator.getCertbotCertCommand('sub.example.com', fakeWebroot))
.toEqual([ 'certbot', 'certonly', '--manual', '--preferred-challenges=dns', '--domain', 'sub.example.com' ])
})
test('replaces variables in command template', () => {
const generator = new CertCommandGenerator([], defaultCommand)
expect(generator.getCertbotCertCommand('example.com', { 'domain': 'example.com' }))
expect(generator.getCertbotCertCommand('example.com', fakeWebroot))
.toEqual([ 'certbot', 'certonly', '--domain', 'example.com' ])
})
test('leaves unreplaced placeholders unchanged', () => {
const generator = new CertCommandGenerator([], [ 'echo', '${missing}' ])
expect(generator.getCertbotCertCommand('example.com')).toEqual([ 'echo', '${missing}' ])
const generator = new CertCommandGenerator([], 'echo ${missing}')
expect(generator.getCertbotCertCommand('example.com', fakeWebroot)).toEqual([ 'echo', '${missing}' ])
})
test('first matching rule is used when multiple rules could apply', () => {
const generator = new CertCommandGenerator([ exampleRule, wildcardRule ], defaultCommand)
expect(generator.getCertbotCertCommand('example.com')).toEqual(exampleRule.command)
expect(generator.getCertbotCertCommand('example.com', fakeWebroot))
.toEqual([ 'certbot', 'certonly', '--manual', '--preferred-challenges=dns', '--domain', 'example.com' ])
})
test('falls back to default command when rule command is null', () => {
const generator = new CertCommandGenerator([ nullCommandRule ], defaultCommand)
expect(generator.getCertbotCertCommand('nullcommand.com')).toEqual(defaultCommand)
expect(generator.getCertbotCertCommand('nullcommand.com', fakeWebroot))
.toEqual([ 'certbot', 'certonly', '--domain', 'nullcommand.com' ])
})