certops 1.0.4 → 1.0.7

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/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # certops
2
+
3
+ CLI for managing and auto-renewing SSL certificates from the [SSL Pilot](https://ssl-manager.dcom.at) platform.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g certops
9
+ ```
10
+
11
+ ## Requirements
12
+
13
+ - Node.js 18+
14
+ - An SSL Pilot API key (`sslpilot_...`) from your dashboard
15
+
16
+ ## Quick start
17
+
18
+ ```bash
19
+ # Set your API key
20
+ export CERTOPS_API_KEY='sslpilot_...'
21
+
22
+ # List certificates
23
+ certops list
24
+
25
+ # Download a certificate (interactive picker)
26
+ sudo certops download
27
+
28
+ # Download a specific domain
29
+ sudo certops download '*.example.com'
30
+
31
+ # Download by ID
32
+ sudo certops download --id <cert-id>
33
+ ```
34
+
35
+ ## Commands
36
+
37
+ ### `certops list`
38
+
39
+ Lists all certificates in your organisation with status and expiry.
40
+
41
+ ### `certops download [certName]`
42
+
43
+ Downloads a certificate to `/etc/certops/<domain>/` in certbot-compatible format:
44
+
45
+ ```
46
+ /etc/certops/<domain>/
47
+ ├── fullchain.pem # cert + chain
48
+ ├── cert.pem # leaf cert only
49
+ ├── chain.pem # intermediate chain
50
+ └── privkey.pem # private key (chmod 600)
51
+ ```
52
+
53
+ Requires root (`sudo`).
54
+
55
+ Options:
56
+ - `-i, --id <id>` — download by certificate ID
57
+
58
+ ### `certops service install`
59
+
60
+ Installs and configures a systemd background service that monitors certificates and auto-renews them before expiry.
61
+
62
+ ```bash
63
+ sudo -E certops service install
64
+ ```
65
+
66
+ ### `certops service <status|start|stop|uninstall|check>`
67
+
68
+ Manage the background renewal service.
69
+
70
+ ```bash
71
+ certops service status
72
+ sudo certops service start
73
+ sudo certops service stop
74
+ sudo certops service check # run one renewal cycle now
75
+ sudo certops service uninstall
76
+ journalctl -u certops -f # follow logs
77
+ ```
78
+
79
+ ## Auto-renewal service
80
+
81
+ The service polls your SSL Pilot account, checks local expiry state, and re-downloads certificates approaching expiry. Hook scripts in `/etc/certops/hooks/` run after each download.
82
+
83
+ **Hook environment variables:**
84
+
85
+ | Variable | Value |
86
+ |----------|-------|
87
+ | `SSL_PILOT_CERT_NAME` | Certificate name |
88
+ | `SSL_PILOT_DOMAIN` | Domain (certName from API) |
89
+ | `SSL_PILOT_FULLCHAIN_PATH` | Path to `fullchain.pem` |
90
+ | `SSL_PILOT_CERT_PATH` | Path to `cert.pem` |
91
+ | `SSL_PILOT_CHAIN_PATH` | Path to `chain.pem` |
92
+ | `SSL_PILOT_KEY_PATH` | Path to `privkey.pem` |
93
+
94
+ **Example hook** (`/etc/certops/hooks/example.com.sh`):
95
+
96
+ ```bash
97
+ #!/usr/bin/env bash
98
+ systemctl reload nginx
99
+ ```
100
+
101
+ ## File layout
102
+
103
+ ```
104
+ /etc/certops/
105
+ config.json # service configuration
106
+ state.json # local expiry cache
107
+ hooks/
108
+ global.sh # runs after every download
109
+ example.com.sh # runs after example.com downloads
110
+ example.com/
111
+ fullchain.pem
112
+ cert.pem
113
+ chain.pem
114
+ privkey.pem
115
+ ```
116
+
117
+ ## Configuration (`/etc/certops/config.json`)
118
+
119
+ | Field | Default | Description |
120
+ |-------|---------|-------------|
121
+ | `renewalThresholdDays` | `5` | Days before expiry to trigger renewal |
122
+ | `checkIntervalHours` | `12` | How often the daemon checks |
123
+ | `watchDomains` | `[]` | Domains to watch (empty = all active) |
124
+ | `maxDownloadRetries` | `3` | Per-cert retry count on failure |
125
+ | `apiUrl` | ssl-manager.dcom.at | Custom API base URL |
126
+
127
+ ## Update
128
+
129
+ ```bash
130
+ npm install -g certops@latest
131
+ ```
package/dist/api.js CHANGED
@@ -1,32 +1,47 @@
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
  }
18
33
  async function requestBinary(path) {
19
34
  const res = await fetch(`${base}/api/cli${path}`, {
20
- headers: { 'Authorization': `Bearer ${options.apiKey}` },
35
+ headers: baseHeaders,
21
36
  });
22
37
  if (!res.ok) {
23
- let msg = `HTTP ${res.status}`;
38
+ let errors = [];
24
39
  try {
25
40
  const body = (await res.json());
26
- msg = body.errors?.[0]?.message ?? msg;
41
+ errors = body.errors ?? [];
27
42
  }
28
43
  catch { /* ignore */ }
29
- throw new Error(msg);
44
+ handleErrorResponse(res.status, errors);
30
45
  }
31
46
  return Buffer.from(await res.arrayBuffer());
32
47
  }
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
  }
@@ -3,6 +3,7 @@ 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,7 +23,7 @@ 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
29
  const certs = (config.watchDomains.length > 0
@@ -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.4",
3
+ "version": "1.0.7",
4
4
  "description": "SSL Pilot CLI — download and manage your SSL certificates",
5
5
  "type": "module",
6
6
  "files": [