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 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 request(`/certs/${id}/download`);
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 { saveCert } from '../files.js';
5
+ import { saveZip } from '../files.js';
6
6
  async function performDownload(cert, client) {
7
7
  process.stdout.write(`\nDownloading ${cert.certName}…\n`);
8
- const files = await client.downloadCert(cert._id);
9
- const { certPath, keyPath } = await saveCert(files.certName, files.certPem, files.keyPem);
10
- process.stdout.write(` ✓ Certificate : ${certPath}\n`);
11
- process.stdout.write(` ✓ Private key : ${keyPath}\n`);
12
- if (files.expiryDate) {
13
- process.stdout.write(` ✓ Expires : ${new Date(files.expiryDate).toLocaleDateString()}\n`);
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/certs/<domain>/ — requires sudo')
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 "sp list" to see available certificates.\n\n');
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 tag = c.certType === 'wildcard' ? ' [wildcard]' : c.certType === 'apex' ? ' [apex]' : '';
72
+ const wildcardTag = c.identifiers.some(id => id.startsWith('*.')) ? ' [wildcard]' : '';
73
73
  return {
74
- name: `${c.certName}${tag}${renewingTag} — ${expiry}`,
74
+ name: `${c.certName}${wildcardTag}${renewingTag} — ${expiry}`,
75
75
  value: c,
76
76
  };
77
77
  }),
@@ -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
- ? `expires ${new Date(c.expiryDate).toLocaleDateString()}`
24
- : 'no expiry info';
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.certType)} ${c.status}\n`);
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, CERTS_DIR, HOOKS_DIR } from '../../config.js';
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 — certificate name (e.g. *.example.com)
32
- # SSL_PILOT_DOMAIN — domain without wildcard (e.g. example.com)
33
- # SSL_PILOT_CERT_PATH — path to certificate.crt
34
- # SSL_PILOT_KEY_PATH — path to private.key
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: 'SSL Pilot API key (sslpilot_...):',
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(CERTS_DIR, { recursive: true });
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 { saveCert } from '../../files.js';
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 files = await client.downloadCert(cert._id);
65
- const { certPath, keyPath } = await saveCert(files.certName, files.certPem, files.keyPem);
66
- log(` cert : ${certPath}`);
67
- log(` key : ${keyPath}`);
68
- await updateCertState(cert.certName, files.expiryDate ?? cert.expiryDate ?? '');
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.replace(/^\*\./, ''),
71
- SSL_PILOT_CERT_PATH: certPath,
72
- SSL_PILOT_KEY_PATH: keyPath,
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 { CERTS_DIR } from './config.js';
4
- function domainDir(certName) {
5
- return join(CERTS_DIR, certName.replace(/^\*\./, ''));
6
- }
7
- export async function saveCert(certName, certPem, keyPem) {
8
- const dir = domainDir(certName);
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
- await mkdir(dir, { recursive: true });
10
+ entries = unzipSync(new Uint8Array(zipBuffer));
13
11
  }
14
12
  catch (err) {
15
- fsError(err, certName, dir);
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 writeFile(certPath, certPem, 'utf8');
19
- await chmod(certPath, 0o644);
22
+ await mkdir(dir, { recursive: true });
20
23
  }
21
24
  catch (err) {
22
- fsError(err, certName, certPath);
23
- }
24
- try {
25
- await writeFile(keyPath, keyPem, 'utf8');
26
- await chmod(keyPath, 0o600);
25
+ fsError(err, certName, dir);
27
26
  }
28
- catch (err) {
29
- fsError(err, certName, keyPath);
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 { certPath, keyPath };
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/certs requires root. Re-run:\n\n`);
39
- process.stderr.write(` sudo sp download ${name}\n\n`);
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.2",
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",