certops 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.js +16 -1
- package/dist/commands/download.js +17 -17
- package/dist/commands/list.js +11 -6
- package/dist/commands/service/install.js +9 -7
- package/dist/commands/service/renew.js +13 -11
- package/dist/config.js +0 -1
- package/dist/files.js +33 -23
- package/package.json +3 -2
package/dist/api.js
CHANGED
|
@@ -15,13 +15,28 @@ export function createApiClient(options) {
|
|
|
15
15
|
}
|
|
16
16
|
return body.data;
|
|
17
17
|
}
|
|
18
|
+
async function requestBinary(path) {
|
|
19
|
+
const res = await fetch(`${base}/api/cli${path}`, {
|
|
20
|
+
headers: { 'Authorization': `Bearer ${options.apiKey}` },
|
|
21
|
+
});
|
|
22
|
+
if (!res.ok) {
|
|
23
|
+
let msg = `HTTP ${res.status}`;
|
|
24
|
+
try {
|
|
25
|
+
const body = (await res.json());
|
|
26
|
+
msg = body.errors?.[0]?.message ?? msg;
|
|
27
|
+
}
|
|
28
|
+
catch { /* ignore */ }
|
|
29
|
+
throw new Error(msg);
|
|
30
|
+
}
|
|
31
|
+
return Buffer.from(await res.arrayBuffer());
|
|
32
|
+
}
|
|
18
33
|
return {
|
|
19
34
|
async listCerts() {
|
|
20
35
|
const data = await request('/certs');
|
|
21
36
|
return data.certs;
|
|
22
37
|
},
|
|
23
38
|
async downloadCert(id) {
|
|
24
|
-
return
|
|
39
|
+
return requestBinary(`/certs/${id}/download`);
|
|
25
40
|
},
|
|
26
41
|
};
|
|
27
42
|
}
|
|
@@ -2,26 +2,28 @@ import { Command } from 'commander';
|
|
|
2
2
|
import select from '@inquirer/select';
|
|
3
3
|
import {} from '../api.js';
|
|
4
4
|
import { getConfiguredClient } from '../client.js';
|
|
5
|
-
import {
|
|
5
|
+
import { saveZip } from '../files.js';
|
|
6
6
|
async function performDownload(cert, client) {
|
|
7
7
|
process.stdout.write(`\nDownloading ${cert.certName}…\n`);
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
process.stdout.write(` ✓
|
|
11
|
-
process.stdout.write(` ✓
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
const zipBuffer = await client.downloadCert(cert._id);
|
|
9
|
+
const paths = await saveZip(cert.certName, zipBuffer);
|
|
10
|
+
process.stdout.write(` ✓ Fullchain : ${paths.fullchainPath}\n`);
|
|
11
|
+
process.stdout.write(` ✓ Certificate : ${paths.certPath}\n`);
|
|
12
|
+
process.stdout.write(` ✓ Chain : ${paths.chainPath}\n`);
|
|
13
|
+
process.stdout.write(` ✓ Private key : ${paths.keyPath}\n`);
|
|
14
|
+
if (cert.expiryDate) {
|
|
15
|
+
process.stdout.write(` ✓ Expires : ${new Date(cert.expiryDate).toLocaleDateString()}\n`);
|
|
14
16
|
}
|
|
15
17
|
process.stdout.write('\n');
|
|
16
18
|
}
|
|
17
19
|
export const downloadCommand = new Command('download')
|
|
18
|
-
.description('Download a certificate to /etc/certops
|
|
19
|
-
.argument('[certName]', 'Certificate name, e.g. *.example.com (omit to pick interactively)')
|
|
20
|
+
.description('Download a certificate to /etc/certops/<domain>/ — requires sudo')
|
|
21
|
+
.argument('[certName]', 'Certificate name or identifier, e.g. *.example.com (omit to pick interactively)')
|
|
20
22
|
.option('-i, --id <id>', 'Download by certificate ID')
|
|
21
23
|
.addHelpText('after', `
|
|
22
24
|
Examples:
|
|
23
25
|
sudo certops download Interactive picker
|
|
24
|
-
sudo certops download '*.example.com' By domain name
|
|
26
|
+
sudo certops download '*.example.com' By domain name or identifier
|
|
25
27
|
sudo certops download --id 6643abc... By certificate ID
|
|
26
28
|
`)
|
|
27
29
|
.action(async (certName, opts) => {
|
|
@@ -44,10 +46,10 @@ Examples:
|
|
|
44
46
|
return;
|
|
45
47
|
}
|
|
46
48
|
if (certName) {
|
|
47
|
-
const match = certs.find(c => c.certName === certName);
|
|
49
|
+
const match = certs.find(c => c.certName === certName || c.identifiers.includes(certName));
|
|
48
50
|
if (!match) {
|
|
49
51
|
process.stderr.write(`\nError: no certificate found for "${certName}"\n`);
|
|
50
|
-
process.stderr.write('Run "
|
|
52
|
+
process.stderr.write('Run "certops list" to see available certificates.\n\n');
|
|
51
53
|
process.exit(1);
|
|
52
54
|
}
|
|
53
55
|
if (!DOWNLOADABLE.has(match.status)) {
|
|
@@ -65,13 +67,11 @@ Examples:
|
|
|
65
67
|
const chosen = await select({
|
|
66
68
|
message: 'Select a certificate to download:',
|
|
67
69
|
choices: active.map(c => {
|
|
68
|
-
const expiry = c.expiryDate
|
|
69
|
-
? `expires ${new Date(c.expiryDate).toLocaleDateString()}`
|
|
70
|
-
: 'no expiry';
|
|
70
|
+
const expiry = c.expiryDate ? `expires ${new Date(c.expiryDate).toLocaleDateString()}` : 'no expiry';
|
|
71
71
|
const renewingTag = c.status === 'renewing' ? ' [renewing]' : '';
|
|
72
|
-
const
|
|
72
|
+
const wildcardTag = c.identifiers.some(id => id.startsWith('*.')) ? ' [wildcard]' : '';
|
|
73
73
|
return {
|
|
74
|
-
name: `${c.certName}${
|
|
74
|
+
name: `${c.certName}${wildcardTag}${renewingTag} — ${expiry}`,
|
|
75
75
|
value: c,
|
|
76
76
|
};
|
|
77
77
|
}),
|
package/dist/commands/list.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
+
import {} from '../api.js';
|
|
2
3
|
import { getConfiguredClient } from '../client.js';
|
|
4
|
+
function isWildcard(c) {
|
|
5
|
+
return c.identifiers?.some(id => id.startsWith('*.')) ?? false;
|
|
6
|
+
}
|
|
7
|
+
function typeTag(c) {
|
|
8
|
+
return isWildcard(c) ? '[wildcard]' : '[single] ';
|
|
9
|
+
}
|
|
3
10
|
export const listCommand = new Command('list')
|
|
4
11
|
.description('List all certificates in your organisation')
|
|
5
12
|
.action(async () => {
|
|
@@ -14,21 +21,19 @@ export const listCommand = new Command('list')
|
|
|
14
21
|
const inactive = certs.filter(c => c.status !== 'active');
|
|
15
22
|
const maxLen = Math.max(...certs.map(c => c.certName.length), 10);
|
|
16
23
|
const pad = (s, n) => s.padEnd(n);
|
|
17
|
-
const typeTag = (type) => type === 'wildcard' ? '[wildcard]' : type === 'apex' ? '[apex] ' : '[single] ';
|
|
18
24
|
process.stdout.write(`\nFound ${certs.length} certificate(s)\n`);
|
|
19
25
|
if (active.length > 0) {
|
|
20
26
|
process.stdout.write(`\nACTIVE (${active.length})\n`);
|
|
21
27
|
for (const c of active) {
|
|
22
|
-
const expiry = c.expiryDate
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
process.stdout.write(` ${pad(c.certName, maxLen)} ${typeTag(c.certType)} ${expiry}\n`);
|
|
28
|
+
const expiry = c.expiryDate ? `expires ${new Date(c.expiryDate).toLocaleDateString()}` : 'no expiry info';
|
|
29
|
+
const covered = c.coveredByWildcardId ? ' [covered by wildcard]' : '';
|
|
30
|
+
process.stdout.write(` ${pad(c.certName, maxLen)} ${typeTag(c)} ${expiry}${covered}\n`);
|
|
26
31
|
}
|
|
27
32
|
}
|
|
28
33
|
if (inactive.length > 0) {
|
|
29
34
|
process.stdout.write(`\nOTHER (${inactive.length})\n`);
|
|
30
35
|
for (const c of inactive) {
|
|
31
|
-
process.stdout.write(` ${pad(c.certName, maxLen)} ${typeTag(c
|
|
36
|
+
process.stdout.write(` ${pad(c.certName, maxLen)} ${typeTag(c)} ${c.status}\n`);
|
|
32
37
|
}
|
|
33
38
|
}
|
|
34
39
|
process.stdout.write('\n');
|
|
@@ -2,7 +2,7 @@ import { Command } from 'commander';
|
|
|
2
2
|
import input from '@inquirer/input';
|
|
3
3
|
import { writeFile, mkdir } from 'fs/promises';
|
|
4
4
|
import { spawnSync } from 'child_process';
|
|
5
|
-
import { readConfig, writeConfig,
|
|
5
|
+
import { readConfig, writeConfig, SSL_PILOT_DIR, HOOKS_DIR } from '../../config.js';
|
|
6
6
|
import { domainHookPath, GLOBAL_HOOK } from '../../hooks.js';
|
|
7
7
|
const UNIT_PATH = '/etc/systemd/system/certops.service';
|
|
8
8
|
function buildUnitContent(apiKey) {
|
|
@@ -28,10 +28,12 @@ WantedBy=multi-user.target
|
|
|
28
28
|
}
|
|
29
29
|
const HOOK_TEMPLATE = `#!/usr/bin/env bash
|
|
30
30
|
# Available env vars:
|
|
31
|
-
# SSL_PILOT_CERT_NAME
|
|
32
|
-
# SSL_PILOT_DOMAIN
|
|
33
|
-
#
|
|
34
|
-
#
|
|
31
|
+
# SSL_PILOT_CERT_NAME — certificate name (e.g. *.example.com)
|
|
32
|
+
# SSL_PILOT_DOMAIN — domain name (certName from API)
|
|
33
|
+
# SSL_PILOT_FULLCHAIN_PATH — path to fullchain.pem
|
|
34
|
+
# SSL_PILOT_CERT_PATH — path to cert.pem
|
|
35
|
+
# SSL_PILOT_CHAIN_PATH — path to chain.pem
|
|
36
|
+
# SSL_PILOT_KEY_PATH — path to privkey.pem
|
|
35
37
|
|
|
36
38
|
# Example: reload nginx after cert update
|
|
37
39
|
# systemctl reload nginx
|
|
@@ -49,7 +51,7 @@ export const installCommand = new Command('install')
|
|
|
49
51
|
const existing = await readConfig();
|
|
50
52
|
// API key — env takes precedence, otherwise prompt
|
|
51
53
|
const apiKey = process.env['CERTOPS_API_KEY'] ?? await input({
|
|
52
|
-
message: '
|
|
54
|
+
message: 'API key (sslpilot_...):',
|
|
53
55
|
validate: (v) => v.startsWith('sslpilot_') && v.length > 10
|
|
54
56
|
? true
|
|
55
57
|
: 'Key must start with sslpilot_',
|
|
@@ -80,7 +82,7 @@ export const installCommand = new Command('install')
|
|
|
80
82
|
};
|
|
81
83
|
// Write config + create directories
|
|
82
84
|
await writeConfig(config);
|
|
83
|
-
await mkdir(
|
|
85
|
+
await mkdir(SSL_PILOT_DIR, { recursive: true });
|
|
84
86
|
await mkdir(HOOKS_DIR, { recursive: true });
|
|
85
87
|
console.log('\n✓ Config written to /etc/certops/config.json');
|
|
86
88
|
// Create hook stubs only if they don't exist yet
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createApiClient } from '../../api.js';
|
|
2
|
-
import {
|
|
2
|
+
import { saveZip } from '../../files.js';
|
|
3
3
|
import { readConfig } from '../../config.js';
|
|
4
4
|
import { readState, updateCertState } from '../../state.js';
|
|
5
5
|
import { runHooks } from '../../hooks.js';
|
|
@@ -25,9 +25,9 @@ export async function runRenewalCycle(apiKey, log) {
|
|
|
25
25
|
const client = createApiClient({ apiKey, apiUrl: config.apiUrl });
|
|
26
26
|
const allCerts = await client.listCerts();
|
|
27
27
|
const DOWNLOADABLE = new Set(['active', 'renewing']);
|
|
28
|
-
const certs = config.watchDomains.length > 0
|
|
28
|
+
const certs = (config.watchDomains.length > 0
|
|
29
29
|
? allCerts.filter(c => config.watchDomains.includes(c.certName))
|
|
30
|
-
: allCerts.filter(c => DOWNLOADABLE.has(c.status));
|
|
30
|
+
: allCerts.filter(c => DOWNLOADABLE.has(c.status))).filter(c => !c.coveredByWildcardId);
|
|
31
31
|
const state = await readState();
|
|
32
32
|
const thresholdMs = config.renewalThresholdDays * 24 * 60 * 60 * 1000;
|
|
33
33
|
const maxRetries = Math.max(0, config.maxDownloadRetries);
|
|
@@ -61,15 +61,17 @@ export async function runRenewalCycle(apiKey, log) {
|
|
|
61
61
|
await sleep(delay);
|
|
62
62
|
}
|
|
63
63
|
try {
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
log(`
|
|
67
|
-
log(` key
|
|
68
|
-
await updateCertState(cert.certName,
|
|
64
|
+
const zipBuffer = await client.downloadCert(cert._id);
|
|
65
|
+
const paths = await saveZip(cert.certName, zipBuffer);
|
|
66
|
+
log(` fullchain : ${paths.fullchainPath}`);
|
|
67
|
+
log(` key : ${paths.keyPath}`);
|
|
68
|
+
await updateCertState(cert.certName, cert.expiryDate ?? '');
|
|
69
69
|
await runHooks(cert.certName, {
|
|
70
|
-
SSL_PILOT_DOMAIN: cert.certName
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
SSL_PILOT_DOMAIN: cert.certName,
|
|
71
|
+
SSL_PILOT_FULLCHAIN_PATH: paths.fullchainPath,
|
|
72
|
+
SSL_PILOT_CERT_PATH: paths.certPath,
|
|
73
|
+
SSL_PILOT_CHAIN_PATH: paths.chainPath,
|
|
74
|
+
SSL_PILOT_KEY_PATH: paths.keyPath,
|
|
73
75
|
}, log);
|
|
74
76
|
downloaded++;
|
|
75
77
|
lastError = undefined;
|
package/dist/config.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
export const SSL_PILOT_DIR = '/etc/certops';
|
|
4
|
-
export const CERTS_DIR = join(SSL_PILOT_DIR, 'certs');
|
|
5
4
|
export const HOOKS_DIR = join(SSL_PILOT_DIR, 'hooks');
|
|
6
5
|
const CONFIG_PATH = join(SSL_PILOT_DIR, 'config.json');
|
|
7
6
|
const DEFAULTS = {
|
package/dist/files.js
CHANGED
|
@@ -1,42 +1,52 @@
|
|
|
1
1
|
import { mkdir, writeFile, chmod } from 'fs/promises';
|
|
2
2
|
import { join } from 'path';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const certPath = join(dir, 'certificate.crt');
|
|
10
|
-
const keyPath = join(dir, 'private.key');
|
|
3
|
+
import { unzipSync } from 'fflate';
|
|
4
|
+
import { SSL_PILOT_DIR } from './config.js';
|
|
5
|
+
export async function saveZip(certName, zipBuffer) {
|
|
6
|
+
const zipDirName = certName.replace(/^\*\./, 'wildcard.');
|
|
7
|
+
const dir = join(SSL_PILOT_DIR, zipDirName);
|
|
8
|
+
let entries;
|
|
11
9
|
try {
|
|
12
|
-
|
|
10
|
+
entries = unzipSync(new Uint8Array(zipBuffer));
|
|
13
11
|
}
|
|
14
12
|
catch (err) {
|
|
15
|
-
|
|
13
|
+
throw new Error(`Failed to extract ZIP: ${err.message}`);
|
|
16
14
|
}
|
|
15
|
+
const filemap = [
|
|
16
|
+
{ key: 'fullchainPath', zipName: 'fullchain.pem', mode: 0o644 },
|
|
17
|
+
{ key: 'certPath', zipName: 'cert.pem', mode: 0o644 },
|
|
18
|
+
{ key: 'chainPath', zipName: 'chain.pem', mode: 0o644 },
|
|
19
|
+
{ key: 'keyPath', zipName: 'privkey.pem', mode: 0o600 },
|
|
20
|
+
];
|
|
17
21
|
try {
|
|
18
|
-
await
|
|
19
|
-
await chmod(certPath, 0o644);
|
|
22
|
+
await mkdir(dir, { recursive: true });
|
|
20
23
|
}
|
|
21
24
|
catch (err) {
|
|
22
|
-
fsError(err, certName,
|
|
23
|
-
}
|
|
24
|
-
try {
|
|
25
|
-
await writeFile(keyPath, keyPem, 'utf8');
|
|
26
|
-
await chmod(keyPath, 0o600);
|
|
25
|
+
fsError(err, certName, dir);
|
|
27
26
|
}
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
const paths = {};
|
|
28
|
+
for (const { key, zipName, mode } of filemap) {
|
|
29
|
+
const data = entries[`${zipDirName}/${zipName}`];
|
|
30
|
+
if (!data)
|
|
31
|
+
throw new Error(`ZIP missing expected file: ${zipDirName}/${zipName}`);
|
|
32
|
+
const filePath = join(dir, zipName);
|
|
33
|
+
paths[key] = filePath;
|
|
34
|
+
try {
|
|
35
|
+
await writeFile(filePath, data);
|
|
36
|
+
await chmod(filePath, mode);
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
fsError(err, certName, filePath);
|
|
40
|
+
}
|
|
30
41
|
}
|
|
31
|
-
return
|
|
42
|
+
return paths;
|
|
32
43
|
}
|
|
33
44
|
function fsError(err, certName, path) {
|
|
34
45
|
const e = err instanceof Error ? err : null;
|
|
35
46
|
if (e?.code === 'EACCES' || e?.code === 'EPERM') {
|
|
36
|
-
const name = certName.startsWith('*.') ? `'${certName}'` : certName;
|
|
37
47
|
process.stderr.write(`\nPermission denied: ${path}\n`);
|
|
38
|
-
process.stderr.write(`/etc/certops
|
|
39
|
-
process.stderr.write(` sudo
|
|
48
|
+
process.stderr.write(`/etc/certops requires root. Re-run:\n\n`);
|
|
49
|
+
process.stderr.write(` sudo certops download '${certName}'\n\n`);
|
|
40
50
|
process.exit(1);
|
|
41
51
|
}
|
|
42
52
|
throw err;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "certops",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "SSL Pilot CLI — download and manage your SSL certificates",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@inquirer/input": "^4.0.0",
|
|
22
22
|
"@inquirer/select": "^4.0.0",
|
|
23
|
-
"commander": "^12.0.0"
|
|
23
|
+
"commander": "^12.0.0",
|
|
24
|
+
"fflate": "^0.8.0"
|
|
24
25
|
},
|
|
25
26
|
"devDependencies": {
|
|
26
27
|
"@types/node": "^20.0.0",
|