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 +131 -0
- package/dist/api.js +25 -10
- package/dist/client.js +2 -1
- package/dist/commands/service/renew.js +2 -1
- package/dist/version.js +18 -0
- package/package.json +1 -1
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
|
-
|
|
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:
|
|
35
|
+
headers: baseHeaders,
|
|
21
36
|
});
|
|
22
37
|
if (!res.ok) {
|
|
23
|
-
let
|
|
38
|
+
let errors = [];
|
|
24
39
|
try {
|
|
25
40
|
const body = (await res.json());
|
|
26
|
-
|
|
41
|
+
errors = body.errors ?? [];
|
|
27
42
|
}
|
|
28
43
|
catch { /* ignore */ }
|
|
29
|
-
|
|
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
|
package/dist/version.js
ADDED
|
@@ -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();
|