diff --git a/package-lock.json b/package-lock.json index 7d7f6b8..9245d9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e3152bf..d544f73 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/user/system/CertbotManager.ts b/src/user/system/CertbotManager.ts index 6d303fc..6a440a1 100644 --- a/src/user/system/CertbotManager.ts +++ b/src/user/system/CertbotManager.ts @@ -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[] { + getCertbotCertCommand(domainName: string, webroot:string): string[] { + const variables: Record = { + 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[]; } } diff --git a/src/utils/CaptainConstants.ts b/src/utils/CaptainConstants.ts index 3fd02f6..a279e41 100644 --- a/src/utils/CaptainConstants.ts +++ b/src/utils/CaptainConstants.ts @@ -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 = { diff --git a/tests/CertCommandGenerator.test.ts b/tests/CertCommandGenerator.test.ts index f665258..02d5b3e 100644 --- a/tests/CertCommandGenerator.test.ts +++ b/tests/CertCommandGenerator.test.ts @@ -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' ]) })