certops 1.0.2 → 1.0.6

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
@@ -1,27 +1,57 @@
1
1
  export function createApiClient(options) {
2
2
  const base = (options.apiUrl ?? 'https://ssl-manager.dcom.at').replace(/\/$/, '');
3
+ const baseHeaders = {
4
+ 'Authorization': `Bearer ${options.apiKey}`,
5
+ 'X-CLI-Version': options.cliVersion,
6
+ };
7
+ function handleErrorResponse(status, errors) {
8
+ const code = errors?.[0]?.code;
9
+ const msg = errors?.[0]?.message ?? `HTTP ${status}`;
10
+ if (status === 426 || code === 'CLI_UPDATE_REQUIRED') {
11
+ throw new Error(`Your certops CLI is out of date (current: v${options.cliVersion}).\n\n` +
12
+ ` Update now:\n` +
13
+ ` npm install -g certops@latest\n`);
14
+ }
15
+ if (status === 400 && code === 'INVALID_CLI_VERSION') {
16
+ throw new Error(`Server rejected CLI version header (sent: v${options.cliVersion}).\n\n` +
17
+ ` Update now:\n` +
18
+ ` npm install -g certops@latest\n`);
19
+ }
20
+ throw new Error(msg);
21
+ }
3
22
  async function request(path) {
4
23
  const res = await fetch(`${base}/api/cli${path}`, {
5
- headers: {
6
- 'Authorization': `Bearer ${options.apiKey}`,
7
- 'Content-Type': 'application/json',
8
- },
24
+ headers: { ...baseHeaders, 'Content-Type': 'application/json' },
9
25
  });
10
26
  const body = (await res.json());
11
27
  if (!res.ok || 'errors' in body) {
12
28
  const errBody = body;
13
- const msg = errBody.errors?.[0]?.message ?? `HTTP ${res.status}`;
14
- throw new Error(msg);
29
+ handleErrorResponse(res.status, errBody.errors);
15
30
  }
16
31
  return body.data;
17
32
  }
33
+ async function requestBinary(path) {
34
+ const res = await fetch(`${base}/api/cli${path}`, {
35
+ headers: baseHeaders,
36
+ });
37
+ if (!res.ok) {
38
+ let errors = [];
39
+ try {
40
+ const body = (await res.json());
41
+ errors = body.errors ?? [];
42
+ }
43
+ catch { /* ignore */ }
44
+ handleErrorResponse(res.status, errors);
45
+ }
46
+ return Buffer.from(await res.arrayBuffer());
47
+ }
18
48
  return {
19
49
  async listCerts() {
20
50
  const data = await request('/certs');
21
51
  return data.certs;
22
52
  },
23
53
  async downloadCert(id) {
24
- return request(`/certs/${id}/download`);
54
+ return requestBinary(`/certs/${id}/download`);
25
55
  },
26
56
  };
27
57
  }
package/dist/client.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import input from '@inquirer/input';
2
2
  import { readConfig } from './config.js';
3
3
  import { createApiClient } from './api.js';
4
+ import { CLI_VERSION } from './version.js';
4
5
  export async function getConfiguredClient() {
5
6
  let apiKey = process.env['CERTOPS_API_KEY'];
6
7
  if (!apiKey) {
@@ -17,5 +18,5 @@ export async function getConfiguredClient() {
17
18
  });
18
19
  }
19
20
  const config = await readConfig();
20
- return createApiClient({ apiKey, apiUrl: config.apiUrl });
21
+ return createApiClient({ apiKey, apiUrl: config.apiUrl, cliVersion: CLI_VERSION });
21
22
  }
@@ -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,8 +1,9 @@
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';
6
+ import { CLI_VERSION } from '../../version.js';
6
7
  const BASE_RETRY_DELAY_MS = 2_000; // 2 s → 4 s → 8 s …
7
8
  function sleep(ms) {
8
9
  return new Promise(resolve => setTimeout(resolve, ms));
@@ -22,12 +23,12 @@ export async function resolveApiKey() {
22
23
  }
23
24
  export async function runRenewalCycle(apiKey, log) {
24
25
  const config = await readConfig();
25
- const client = createApiClient({ apiKey, apiUrl: config.apiUrl });
26
+ const client = createApiClient({ apiKey, apiUrl: config.apiUrl, cliVersion: CLI_VERSION });
26
27
  const allCerts = await client.listCerts();
27
28
  const DOWNLOADABLE = new Set(['active', 'renewing']);
28
- const certs = config.watchDomains.length > 0
29
+ const certs = (config.watchDomains.length > 0
29
30
  ? allCerts.filter(c => config.watchDomains.includes(c.certName))
30
- : allCerts.filter(c => DOWNLOADABLE.has(c.status));
31
+ : allCerts.filter(c => DOWNLOADABLE.has(c.status))).filter(c => !c.coveredByWildcardId);
31
32
  const state = await readState();
32
33
  const thresholdMs = config.renewalThresholdDays * 24 * 60 * 60 * 1000;
33
34
  const maxRetries = Math.max(0, config.maxDownloadRetries);
@@ -61,15 +62,17 @@ export async function runRenewalCycle(apiKey, log) {
61
62
  await sleep(delay);
62
63
  }
63
64
  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 ?? '');
65
+ const zipBuffer = await client.downloadCert(cert._id);
66
+ const paths = await saveZip(cert.certName, zipBuffer);
67
+ log(` fullchain : ${paths.fullchainPath}`);
68
+ log(` key : ${paths.keyPath}`);
69
+ await updateCertState(cert.certName, cert.expiryDate ?? '');
69
70
  await runHooks(cert.certName, {
70
- SSL_PILOT_DOMAIN: cert.certName.replace(/^\*\./, ''),
71
- SSL_PILOT_CERT_PATH: certPath,
72
- SSL_PILOT_KEY_PATH: keyPath,
71
+ SSL_PILOT_DOMAIN: cert.certName,
72
+ SSL_PILOT_FULLCHAIN_PATH: paths.fullchainPath,
73
+ SSL_PILOT_CERT_PATH: paths.certPath,
74
+ SSL_PILOT_CHAIN_PATH: paths.chainPath,
75
+ SSL_PILOT_KEY_PATH: paths.keyPath,
73
76
  }, log);
74
77
  downloaded++;
75
78
  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;
@@ -0,0 +1,18 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ function isPackageJson(value) {
5
+ return (typeof value === 'object' &&
6
+ value !== null &&
7
+ 'version' in value &&
8
+ typeof value.version === 'string');
9
+ }
10
+ function readCliVersion() {
11
+ const dir = dirname(fileURLToPath(import.meta.url));
12
+ const path = join(dir, '..', 'package.json');
13
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
14
+ if (!isPackageJson(raw))
15
+ throw new Error('package.json is missing a valid version field');
16
+ return raw.version;
17
+ }
18
+ export const CLI_VERSION = readCliVersion();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "certops",
3
- "version": "1.0.2",
3
+ "version": "1.0.6",
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",