certops 0.0.1

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 ADDED
@@ -0,0 +1,27 @@
1
+ export function createApiClient(options) {
2
+ const base = (options.apiUrl ?? 'https://ssl-manager.dcom.at').replace(/\/$/, '');
3
+ async function request(path) {
4
+ const res = await fetch(`${base}/api/cli${path}`, {
5
+ headers: {
6
+ 'Authorization': `Bearer ${options.apiKey}`,
7
+ 'Content-Type': 'application/json',
8
+ },
9
+ });
10
+ const body = (await res.json());
11
+ if (!res.ok || 'errors' in body) {
12
+ const errBody = body;
13
+ const msg = errBody.errors?.[0]?.message ?? `HTTP ${res.status}`;
14
+ throw new Error(msg);
15
+ }
16
+ return body.data;
17
+ }
18
+ return {
19
+ async listCerts() {
20
+ const data = await request('/certs');
21
+ return data.certs;
22
+ },
23
+ async downloadCert(id) {
24
+ return request(`/certs/${id}/download`);
25
+ },
26
+ };
27
+ }
package/dist/client.js ADDED
@@ -0,0 +1,21 @@
1
+ import input from '@inquirer/input';
2
+ import { readConfig } from './config.js';
3
+ import { createApiClient } from './api.js';
4
+ export async function getConfiguredClient() {
5
+ let apiKey = process.env['SSL_PILOT_API_KEY'];
6
+ if (!apiKey) {
7
+ if (!process.stdin.isTTY) {
8
+ process.stderr.write('Error: SSL_PILOT_API_KEY is not set and no TTY is available for prompt.\n');
9
+ process.stderr.write(' export SSL_PILOT_API_KEY=\'sslpilot_...\'\n\n');
10
+ process.exit(1);
11
+ }
12
+ apiKey = await input({
13
+ message: 'API key (sslpilot_...):',
14
+ validate: (v) => v.startsWith('sslpilot_') && v.length > 10
15
+ ? true
16
+ : 'Key must start with sslpilot_',
17
+ });
18
+ }
19
+ const config = await readConfig();
20
+ return createApiClient({ apiKey, apiUrl: config.apiUrl });
21
+ }
@@ -0,0 +1,87 @@
1
+ import { Command } from 'commander';
2
+ import select from '@inquirer/select';
3
+ import {} from '../api.js';
4
+ import { getConfiguredClient } from '../client.js';
5
+ import { saveCert } from '../files.js';
6
+ async function performDownload(cert, client) {
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`);
14
+ }
15
+ process.stdout.write('\n');
16
+ }
17
+ 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
+ .option('-i, --id <id>', 'Download by certificate ID')
21
+ .addHelpText('after', `
22
+ Examples:
23
+ sudo certops download Interactive picker
24
+ sudo certops download '*.example.com' By domain name
25
+ sudo certops download --id 6643abc... By certificate ID
26
+ `)
27
+ .action(async (certName, opts) => {
28
+ const DOWNLOADABLE = new Set(['active', 'renewing']);
29
+ try {
30
+ const client = await getConfiguredClient();
31
+ if (opts.id) {
32
+ const certs = await client.listCerts();
33
+ const cert = certs.find(c => c._id === opts.id);
34
+ if (!cert) {
35
+ process.stderr.write(`\nError: no certificate found with ID "${opts.id}"\n\n`);
36
+ process.exit(1);
37
+ }
38
+ await performDownload(cert, client);
39
+ return;
40
+ }
41
+ const certs = await client.listCerts();
42
+ if (certs.length === 0) {
43
+ process.stdout.write('\nNo certificates found in your account.\n\n');
44
+ return;
45
+ }
46
+ if (certName) {
47
+ const match = certs.find(c => c.certName === certName);
48
+ if (!match) {
49
+ process.stderr.write(`\nError: no certificate found for "${certName}"\n`);
50
+ process.stderr.write('Run "sp list" to see available certificates.\n\n');
51
+ process.exit(1);
52
+ }
53
+ if (!DOWNLOADABLE.has(match.status)) {
54
+ process.stderr.write(`\nError: "${certName}" is not available for download (status: ${match.status})\n\n`);
55
+ process.exit(1);
56
+ }
57
+ await performDownload(match, client);
58
+ return;
59
+ }
60
+ const active = certs.filter(c => DOWNLOADABLE.has(c.status));
61
+ if (active.length === 0) {
62
+ process.stdout.write('\nNo active certificates available.\n\n');
63
+ return;
64
+ }
65
+ const chosen = await select({
66
+ message: 'Select a certificate to download:',
67
+ choices: active.map(c => {
68
+ const expiry = c.expiryDate
69
+ ? `expires ${new Date(c.expiryDate).toLocaleDateString()}`
70
+ : 'no expiry';
71
+ const renewingTag = c.status === 'renewing' ? ' [renewing]' : '';
72
+ const tag = c.certType === 'wildcard' ? ' [wildcard]' : c.certType === 'apex' ? ' [apex]' : '';
73
+ return {
74
+ name: `${c.certName}${tag}${renewingTag} — ${expiry}`,
75
+ value: c,
76
+ };
77
+ }),
78
+ });
79
+ await performDownload(chosen, client);
80
+ }
81
+ catch (err) {
82
+ if (err.name === 'ExitPromptError')
83
+ process.exit(0);
84
+ process.stderr.write(`\nError: ${err.message}\n\n`);
85
+ process.exit(1);
86
+ }
87
+ });
@@ -0,0 +1,40 @@
1
+ import { Command } from 'commander';
2
+ import { getConfiguredClient } from '../client.js';
3
+ export const listCommand = new Command('list')
4
+ .description('List all certificates in your organisation')
5
+ .action(async () => {
6
+ try {
7
+ const client = await getConfiguredClient();
8
+ const certs = await client.listCerts();
9
+ if (certs.length === 0) {
10
+ process.stdout.write('\nNo certificates found in your account.\n\n');
11
+ return;
12
+ }
13
+ const active = certs.filter(c => c.status === 'active');
14
+ const inactive = certs.filter(c => c.status !== 'active');
15
+ const maxLen = Math.max(...certs.map(c => c.certName.length), 10);
16
+ const pad = (s, n) => s.padEnd(n);
17
+ const typeTag = (type) => type === 'wildcard' ? '[wildcard]' : type === 'apex' ? '[apex] ' : '[single] ';
18
+ process.stdout.write(`\nFound ${certs.length} certificate(s)\n`);
19
+ if (active.length > 0) {
20
+ process.stdout.write(`\nACTIVE (${active.length})\n`);
21
+ 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`);
26
+ }
27
+ }
28
+ if (inactive.length > 0) {
29
+ process.stdout.write(`\nOTHER (${inactive.length})\n`);
30
+ for (const c of inactive) {
31
+ process.stdout.write(` ${pad(c.certName, maxLen)} ${typeTag(c.certType)} ${c.status}\n`);
32
+ }
33
+ }
34
+ process.stdout.write('\n');
35
+ }
36
+ catch (err) {
37
+ process.stderr.write(`\nError: ${err.message}\n\n`);
38
+ process.exit(1);
39
+ }
40
+ });
@@ -0,0 +1,67 @@
1
+ import { Command } from 'commander';
2
+ import input from '@inquirer/input';
3
+ import { spawnSync } from 'child_process';
4
+ import { readConfig, writeConfig } from '../../config.js';
5
+ export const configureCommand = new Command('configure')
6
+ .description('Update service config (watch domains, check interval) and restart')
7
+ .action(async () => {
8
+ try {
9
+ if (process.getuid?.() !== 0) {
10
+ console.error('Error: certops service configure must run as root.');
11
+ console.error('\n sudo sp service configure\n');
12
+ process.exit(1);
13
+ }
14
+ const existing = await readConfig();
15
+ console.log('\nSSL Pilot — Update Service Config\n');
16
+ console.log(' (API key is stored in the systemd unit, not changed here)\n');
17
+ const intervalStr = await input({
18
+ message: 'Check interval (hours):',
19
+ default: String(existing.checkIntervalHours),
20
+ validate: (v) => Number.isInteger(Number(v)) && Number(v) > 0 ? true : 'Must be a positive integer',
21
+ });
22
+ const thresholdStr = await input({
23
+ message: 'Download cert when expiry within (days):',
24
+ default: String(existing.renewalThresholdDays),
25
+ validate: (v) => Number.isInteger(Number(v)) && Number(v) > 0 ? true : 'Must be a positive integer',
26
+ });
27
+ const retriesStr = await input({
28
+ message: 'Max download retries per cert:',
29
+ default: String(existing.maxDownloadRetries),
30
+ validate: (v) => Number.isInteger(Number(v)) && Number(v) >= 0 ? true : 'Must be a non-negative integer',
31
+ });
32
+ const domainsRaw = await input({
33
+ message: 'Watch specific domains (comma-separated, blank = all active):',
34
+ default: existing.watchDomains.join(', '),
35
+ });
36
+ const watchDomains = domainsRaw
37
+ .split(',')
38
+ .map(d => d.trim())
39
+ .filter(Boolean);
40
+ await writeConfig({
41
+ ...existing,
42
+ checkIntervalHours: Number(intervalStr),
43
+ renewalThresholdDays: Number(thresholdStr),
44
+ maxDownloadRetries: Number(retriesStr),
45
+ watchDomains,
46
+ });
47
+ console.log('\n✓ Config updated at /etc/certops/config.json');
48
+ const result = spawnSync('systemctl', ['restart', 'certops'], {
49
+ stdio: ['inherit', 'inherit', 'pipe'],
50
+ encoding: 'utf8',
51
+ });
52
+ if (result.status !== 0) {
53
+ if (result.stderr?.trim())
54
+ process.stderr.write(result.stderr + '\n');
55
+ process.stderr.write('\nWarning: config saved but service restart failed.\n');
56
+ process.stderr.write(' Run: sudo sp service start\n\n');
57
+ process.exit(1);
58
+ }
59
+ console.log('✓ Service restarted with new config.\n');
60
+ }
61
+ catch (err) {
62
+ if (err.name === 'ExitPromptError')
63
+ process.exit(0);
64
+ console.error(`\nError: ${err.message}`);
65
+ process.exit(1);
66
+ }
67
+ });
@@ -0,0 +1,86 @@
1
+ import { Command } from 'commander';
2
+ import { spawnSync } from 'child_process';
3
+ import { unlink } from 'fs/promises';
4
+ import { resolveApiKey, runRenewalCycle } from './renew.js';
5
+ const UNIT = 'certops';
6
+ const UNIT_PATH = '/etc/systemd/system/certops.service';
7
+ /**
8
+ * Runs systemctl with stdout inherited (visible) and stderr captured.
9
+ * Stderr is only printed if the command fails, so noisy shim warnings
10
+ * from Python-based systemctl stubs don't pollute normal output.
11
+ */
12
+ function systemctl(args, exitOnFail = true) {
13
+ const result = spawnSync('systemctl', args, {
14
+ stdio: ['inherit', 'inherit', 'pipe'],
15
+ encoding: 'utf8',
16
+ });
17
+ if (result.status !== 0) {
18
+ if (result.stderr?.trim())
19
+ process.stderr.write(result.stderr + '\n');
20
+ if (exitOnFail)
21
+ process.exit(1);
22
+ return false;
23
+ }
24
+ return true;
25
+ }
26
+ function requireRoot() {
27
+ if (process.getuid?.() !== 0) {
28
+ process.stderr.write('\nError: this command requires root. Use sudo.\n\n');
29
+ process.exit(1);
30
+ }
31
+ }
32
+ export const startCommand = new Command('start')
33
+ .description('Start the SSL Pilot service')
34
+ .action(() => { requireRoot(); systemctl(['start', UNIT]); });
35
+ export const stopCommand = new Command('stop')
36
+ .description('Stop the SSL Pilot service')
37
+ .action(() => { requireRoot(); systemctl(['stop', UNIT]); });
38
+ export const restartCommand = new Command('restart')
39
+ .description('Restart the service — picks up config.json changes immediately')
40
+ .action(() => { requireRoot(); systemctl(['restart', UNIT]); });
41
+ export const statusCommand = new Command('status')
42
+ .description('Show SSL Pilot service status')
43
+ .action(() => { systemctl(['status', UNIT]); });
44
+ export const checkCommand = new Command('check')
45
+ .description('Run one renewal check cycle immediately (for testing)')
46
+ .action(async () => {
47
+ requireRoot();
48
+ const apiKey = await resolveApiKey();
49
+ if (!apiKey) {
50
+ process.stderr.write('\nError: SSL_PILOT_API_KEY not found in environment or service unit.\n');
51
+ process.stderr.write(' Set the env var or run: sudo sp service install\n\n');
52
+ process.exit(1);
53
+ }
54
+ const log = (msg) => process.stdout.write(` ${msg}\n`);
55
+ process.stdout.write('\nRunning renewal check…\n\n');
56
+ try {
57
+ const result = await runRenewalCycle(apiKey, log);
58
+ process.stdout.write(`\n Done — checked: ${result.checked}, downloaded: ${result.downloaded}, errors: ${result.errors}\n\n`);
59
+ if (result.errors > 0)
60
+ process.exit(1);
61
+ }
62
+ catch (err) {
63
+ process.stderr.write(`\nError: ${err.message}\n\n`);
64
+ process.exit(1);
65
+ }
66
+ });
67
+ export const uninstallServiceCommand = new Command('uninstall')
68
+ .description('Stop, disable, and remove the SSL Pilot service unit')
69
+ .action(async () => {
70
+ requireRoot();
71
+ process.stdout.write('\nRemoving SSL Pilot service…\n\n');
72
+ systemctl(['stop', UNIT], false);
73
+ systemctl(['disable', UNIT], false);
74
+ try {
75
+ await unlink(UNIT_PATH);
76
+ process.stdout.write(` ✓ Removed ${UNIT_PATH}\n`);
77
+ }
78
+ catch (err) {
79
+ if (err.code !== 'ENOENT')
80
+ throw err;
81
+ }
82
+ systemctl(['daemon-reload'], false);
83
+ process.stdout.write('\n ✓ Service uninstalled.\n');
84
+ process.stdout.write(' /etc/certops/ (config, state, certs, hooks) was NOT removed.\n');
85
+ process.stdout.write(' Run "sudo sp service install" to reinstall.\n\n');
86
+ });
@@ -0,0 +1,35 @@
1
+ import { Command } from 'commander';
2
+ import { readConfig } from '../../config.js';
3
+ import { resolveApiKey, runRenewalCycle } from './renew.js';
4
+ function log(msg) {
5
+ process.stdout.write(`[${new Date().toISOString()}] ${msg}\n`);
6
+ }
7
+ function sleep(ms) {
8
+ return new Promise(resolve => setTimeout(resolve, ms));
9
+ }
10
+ async function tick() {
11
+ const apiKey = await resolveApiKey();
12
+ if (!apiKey) {
13
+ log('SSL_PILOT_API_KEY not available — skipping cycle.');
14
+ return;
15
+ }
16
+ try {
17
+ const result = await runRenewalCycle(apiKey, log);
18
+ log(`Cycle complete — checked: ${result.checked}, downloaded: ${result.downloaded}, errors: ${result.errors}`);
19
+ }
20
+ catch (err) {
21
+ log(`Cycle failed: ${err.message}`);
22
+ }
23
+ }
24
+ export const daemonCommand = new Command('run')
25
+ .description('Run the monitoring daemon (managed by systemd)')
26
+ .action(async () => {
27
+ log('SSL Pilot daemon starting.');
28
+ process.on('SIGTERM', () => { log('SIGTERM — shutting down.'); process.exit(0); });
29
+ process.on('SIGINT', () => { log('SIGINT — shutting down.'); process.exit(0); });
30
+ while (true) {
31
+ await tick();
32
+ const config = await readConfig();
33
+ await sleep(config.checkIntervalHours * 60 * 60 * 1000);
34
+ }
35
+ });
@@ -0,0 +1,34 @@
1
+ import { Command } from 'commander';
2
+ import { installCommand } from './install.js';
3
+ import { configureCommand } from './configure.js';
4
+ import { daemonCommand } from './daemon.js';
5
+ import { startCommand, stopCommand, restartCommand, statusCommand, checkCommand, uninstallServiceCommand } from './control.js';
6
+ export const serviceCommand = new Command('service')
7
+ .description('Manage the SSL Pilot auto-renewal background service')
8
+ .addHelpText('after', `
9
+ Commands:
10
+ install Interactive setup — writes config, hooks, and installs systemd unit
11
+ configure Update watch domains, interval, thresholds — restarts service
12
+ start Start the service (requires sudo)
13
+ stop Stop the service (requires sudo)
14
+ restart Restart — picks up /etc/certops/config.json changes (requires sudo)
15
+ status Show service status
16
+ check Run one renewal cycle immediately (requires sudo)
17
+ uninstall Remove the systemd unit (config and certs are kept) (requires sudo)
18
+
19
+ Examples:
20
+ sudo certops service install
21
+ sudo certops service configure
22
+ sudo certops service restart
23
+ certops service status
24
+ journalctl -u certops -f
25
+ `);
26
+ serviceCommand.addCommand(installCommand);
27
+ serviceCommand.addCommand(configureCommand);
28
+ serviceCommand.addCommand(startCommand);
29
+ serviceCommand.addCommand(stopCommand);
30
+ serviceCommand.addCommand(restartCommand);
31
+ serviceCommand.addCommand(statusCommand);
32
+ serviceCommand.addCommand(checkCommand);
33
+ serviceCommand.addCommand(uninstallServiceCommand);
34
+ serviceCommand.addCommand(daemonCommand, { hidden: true });
@@ -0,0 +1,132 @@
1
+ import { Command } from 'commander';
2
+ import input from '@inquirer/input';
3
+ import { writeFile, mkdir } from 'fs/promises';
4
+ import { spawnSync } from 'child_process';
5
+ import { readConfig, writeConfig, CERTS_DIR, HOOKS_DIR } from '../../config.js';
6
+ import { domainHookPath, GLOBAL_HOOK } from '../../hooks.js';
7
+ const UNIT_PATH = '/etc/systemd/system/certops.service';
8
+ function buildUnitContent(apiKey) {
9
+ return `\
10
+ [Unit]
11
+ Description=SSL Pilot Certificate Monitor
12
+ After=network-online.target
13
+ Wants=network-online.target
14
+
15
+ [Service]
16
+ Type=simple
17
+ Environment=SSL_PILOT_API_KEY=${apiKey}
18
+ ExecStart=/usr/local/bin/certops service run
19
+ Restart=on-failure
20
+ RestartSec=30
21
+ StandardOutput=journal
22
+ StandardError=journal
23
+ SyslogIdentifier=certops
24
+
25
+ [Install]
26
+ WantedBy=multi-user.target
27
+ `;
28
+ }
29
+ const HOOK_TEMPLATE = `#!/usr/bin/env bash
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
35
+
36
+ # Example: reload nginx after cert update
37
+ # systemctl reload nginx
38
+ `;
39
+ export const installCommand = new Command('install')
40
+ .description('Install and configure the SSL Pilot background service')
41
+ .action(async () => {
42
+ try {
43
+ if (process.getuid?.() !== 0) {
44
+ console.error('Error: certops service install must run as root.');
45
+ console.error('\n sudo -E sp service install\n');
46
+ process.exit(1);
47
+ }
48
+ console.log('\nSSL Pilot Service Setup\n');
49
+ const existing = await readConfig();
50
+ // API key — env takes precedence, otherwise prompt
51
+ const apiKey = process.env['SSL_PILOT_API_KEY'] ?? await input({
52
+ message: 'SSL Pilot API key (sslpilot_...):',
53
+ validate: (v) => v.startsWith('sslpilot_') && v.length > 10
54
+ ? true
55
+ : 'Key must start with sslpilot_',
56
+ });
57
+ const apiUrl = await input({
58
+ message: 'API URL (leave blank for default https://ssl-manager.dcom.at):',
59
+ default: existing.apiUrl ?? '',
60
+ });
61
+ const intervalStr = await input({
62
+ message: 'Check interval (hours):',
63
+ default: String(existing.checkIntervalHours),
64
+ validate: (v) => Number.isInteger(Number(v)) && Number(v) > 0 ? true : 'Must be a positive integer',
65
+ });
66
+ const domainsRaw = await input({
67
+ message: 'Watch specific domains (comma-separated, blank = all active):',
68
+ default: existing.watchDomains.join(', '),
69
+ });
70
+ const watchDomains = domainsRaw
71
+ .split(',')
72
+ .map(d => d.trim())
73
+ .filter(Boolean);
74
+ const config = {
75
+ apiUrl: apiUrl.trim() || undefined,
76
+ renewalThresholdDays: existing.renewalThresholdDays,
77
+ checkIntervalHours: Number(intervalStr),
78
+ watchDomains,
79
+ maxDownloadRetries: existing.maxDownloadRetries,
80
+ };
81
+ // Write config + create directories
82
+ await writeConfig(config);
83
+ await mkdir(CERTS_DIR, { recursive: true });
84
+ await mkdir(HOOKS_DIR, { recursive: true });
85
+ console.log('\n✓ Config written to /etc/certops/config.json');
86
+ // Create hook stubs only if they don't exist yet
87
+ for (const domain of watchDomains) {
88
+ const hookFile = domainHookPath(domain);
89
+ try {
90
+ await writeFile(hookFile, HOOK_TEMPLATE, { flag: 'wx', mode: 0o755 });
91
+ console.log(`✓ Hook stub created: ${hookFile}`);
92
+ }
93
+ catch (err) {
94
+ if (err.code !== 'EEXIST')
95
+ throw err;
96
+ }
97
+ }
98
+ try {
99
+ await writeFile(GLOBAL_HOOK, HOOK_TEMPLATE, { flag: 'wx', mode: 0o755 });
100
+ console.log(`✓ Global hook stub created: ${GLOBAL_HOOK}`);
101
+ }
102
+ catch (err) {
103
+ if (err.code !== 'EEXIST')
104
+ throw err;
105
+ }
106
+ // Write systemd unit
107
+ await writeFile(UNIT_PATH, buildUnitContent(apiKey), { encoding: 'utf8', mode: 0o600 });
108
+ console.log(`✓ Systemd unit written to ${UNIT_PATH}`);
109
+ for (const args of [
110
+ ['daemon-reload'],
111
+ ['enable', 'certops'],
112
+ ['restart', 'certops'],
113
+ ]) {
114
+ const r = spawnSync('systemctl', args, { stdio: ['inherit', 'inherit', 'pipe'], encoding: 'utf8' });
115
+ if (r.status !== 0) {
116
+ if (r.stderr?.trim())
117
+ process.stderr.write(r.stderr + '\n');
118
+ process.exit(1);
119
+ }
120
+ }
121
+ console.log('\n✓ Service enabled and started.\n');
122
+ console.log(' Edit hooks in : /etc/certops/hooks/');
123
+ console.log(' Check status : certops service status');
124
+ console.log(' Follow logs : journalctl -u certops -f\n');
125
+ }
126
+ catch (err) {
127
+ if (err.name === 'ExitPromptError')
128
+ process.exit(0);
129
+ console.error(`\nError: ${err.message}`);
130
+ process.exit(1);
131
+ }
132
+ });
@@ -0,0 +1,89 @@
1
+ import { createApiClient } from '../../api.js';
2
+ import { saveCert } from '../../files.js';
3
+ import { readConfig } from '../../config.js';
4
+ import { readState, updateCertState } from '../../state.js';
5
+ import { runHooks } from '../../hooks.js';
6
+ const BASE_RETRY_DELAY_MS = 2_000; // 2 s → 4 s → 8 s …
7
+ function sleep(ms) {
8
+ return new Promise(resolve => setTimeout(resolve, ms));
9
+ }
10
+ export async function resolveApiKey() {
11
+ if (process.env['SSL_PILOT_API_KEY'])
12
+ return process.env['SSL_PILOT_API_KEY'];
13
+ try {
14
+ const { execSync } = await import('child_process');
15
+ const out = execSync('systemctl show certops -p Environment --value', { encoding: 'utf8' });
16
+ const match = out.match(/SSL_PILOT_API_KEY=(\S+)/);
17
+ return match?.[1] ?? null;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ export async function runRenewalCycle(apiKey, log) {
24
+ const config = await readConfig();
25
+ const client = createApiClient({ apiKey, apiUrl: config.apiUrl });
26
+ const allCerts = await client.listCerts();
27
+ const DOWNLOADABLE = new Set(['active', 'renewing']);
28
+ const certs = config.watchDomains.length > 0
29
+ ? allCerts.filter(c => config.watchDomains.includes(c.certName))
30
+ : allCerts.filter(c => DOWNLOADABLE.has(c.status));
31
+ const state = await readState();
32
+ const thresholdMs = config.renewalThresholdDays * 24 * 60 * 60 * 1000;
33
+ const maxRetries = Math.max(0, config.maxDownloadRetries);
34
+ const now = Date.now();
35
+ let downloaded = 0;
36
+ let errors = 0;
37
+ for (const cert of certs) {
38
+ if (!DOWNLOADABLE.has(cert.status)) {
39
+ log(`Skip ${cert.certName} — ${cert.status}`);
40
+ continue;
41
+ }
42
+ const local = state[cert.certName];
43
+ const parsed = cert.expiryDate ? new Date(cert.expiryDate).getTime() : 0;
44
+ const certExpiry = Number.isFinite(parsed) ? parsed : 0;
45
+ const neverDownloaded = !local;
46
+ const expiryChanged = !neverDownloaded && local.expiryDate !== cert.expiryDate;
47
+ const expiringInTime = certExpiry > 0 && certExpiry - now <= thresholdMs;
48
+ if (!neverDownloaded && !expiryChanged && !expiringInTime) {
49
+ const daysLeft = certExpiry ? Math.ceil((certExpiry - now) / 86_400_000) : 0;
50
+ log(`OK ${cert.certName} — ${daysLeft}d until expiry`);
51
+ continue;
52
+ }
53
+ const reason = neverDownloaded ? 'first download' : expiryChanged ? 'cert renewed' : 'expiring soon';
54
+ log(`Downloading ${cert.certName} (${reason})…`);
55
+ let lastError;
56
+ let attempt = 0;
57
+ while (attempt <= maxRetries) {
58
+ if (attempt > 0) {
59
+ const delay = BASE_RETRY_DELAY_MS * Math.pow(2, attempt - 1);
60
+ log(` retry ${attempt}/${maxRetries} in ${delay / 1000}s…`);
61
+ await sleep(delay);
62
+ }
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 ?? '');
69
+ await runHooks(cert.certName, {
70
+ SSL_PILOT_DOMAIN: cert.certName.replace(/^\*\./, ''),
71
+ SSL_PILOT_CERT_PATH: certPath,
72
+ SSL_PILOT_KEY_PATH: keyPath,
73
+ }, log);
74
+ downloaded++;
75
+ lastError = undefined;
76
+ break;
77
+ }
78
+ catch (err) {
79
+ lastError = err instanceof Error ? err : new Error(String(err));
80
+ attempt++;
81
+ }
82
+ }
83
+ if (lastError) {
84
+ log(` error (gave up after ${maxRetries + 1} attempt${maxRetries !== 0 ? 's' : ''}): ${lastError.message}`);
85
+ errors++;
86
+ }
87
+ }
88
+ return { checked: certs.length, downloaded, errors };
89
+ }
package/dist/config.js ADDED
@@ -0,0 +1,30 @@
1
+ import { readFile, writeFile, mkdir } from 'fs/promises';
2
+ import { join } from 'path';
3
+ export const SSL_PILOT_DIR = '/etc/certops';
4
+ export const CERTS_DIR = join(SSL_PILOT_DIR, 'certs');
5
+ export const HOOKS_DIR = join(SSL_PILOT_DIR, 'hooks');
6
+ const CONFIG_PATH = join(SSL_PILOT_DIR, 'config.json');
7
+ const DEFAULTS = {
8
+ renewalThresholdDays: 5,
9
+ checkIntervalHours: 12,
10
+ watchDomains: [],
11
+ maxDownloadRetries: 3,
12
+ };
13
+ export async function readConfig() {
14
+ try {
15
+ const raw = await readFile(CONFIG_PATH, 'utf8');
16
+ return { ...DEFAULTS, ...JSON.parse(raw) };
17
+ }
18
+ catch (err) {
19
+ if (err instanceof Error && err.code === 'ENOENT')
20
+ return { ...DEFAULTS };
21
+ throw err;
22
+ }
23
+ }
24
+ export async function writeConfig(config) {
25
+ await mkdir(SSL_PILOT_DIR, { recursive: true });
26
+ await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', {
27
+ encoding: 'utf8',
28
+ mode: 0o600,
29
+ });
30
+ }
package/dist/files.js ADDED
@@ -0,0 +1,43 @@
1
+ import { mkdir, writeFile, chmod } from 'fs/promises';
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');
11
+ try {
12
+ await mkdir(dir, { recursive: true });
13
+ }
14
+ catch (err) {
15
+ fsError(err, certName, dir);
16
+ }
17
+ try {
18
+ await writeFile(certPath, certPem, 'utf8');
19
+ await chmod(certPath, 0o644);
20
+ }
21
+ catch (err) {
22
+ fsError(err, certName, certPath);
23
+ }
24
+ try {
25
+ await writeFile(keyPath, keyPem, 'utf8');
26
+ await chmod(keyPath, 0o600);
27
+ }
28
+ catch (err) {
29
+ fsError(err, certName, keyPath);
30
+ }
31
+ return { certPath, keyPath };
32
+ }
33
+ function fsError(err, certName, path) {
34
+ const e = err instanceof Error ? err : null;
35
+ if (e?.code === 'EACCES' || e?.code === 'EPERM') {
36
+ const name = certName.startsWith('*.') ? `'${certName}'` : certName;
37
+ 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`);
40
+ process.exit(1);
41
+ }
42
+ throw err;
43
+ }
package/dist/hooks.js ADDED
@@ -0,0 +1,58 @@
1
+ import { spawn } from 'child_process';
2
+ import { access } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { HOOKS_DIR } from './config.js';
5
+ const GLOBAL_HOOK = join(HOOKS_DIR, 'global.sh');
6
+ function domainHookPath(certName) {
7
+ const safe = certName.replace(/^\*\./, 'wildcard.');
8
+ return join(HOOKS_DIR, `${safe}.sh`);
9
+ }
10
+ async function fileExists(path) {
11
+ try {
12
+ await access(path);
13
+ return true;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ async function execHook(scriptPath, vars) {
20
+ return new Promise((resolve, reject) => {
21
+ const child = spawn('bash', [scriptPath], {
22
+ env: { ...process.env, ...vars },
23
+ stdio: 'inherit',
24
+ });
25
+ child.on('close', (code) => {
26
+ if (code === 0)
27
+ resolve();
28
+ else
29
+ reject(new Error(`Hook exited with code ${code}`));
30
+ });
31
+ child.on('error', reject);
32
+ });
33
+ }
34
+ export async function runHooks(certName, vars, log) {
35
+ const domainHook = domainHookPath(certName);
36
+ const hookVars = { ...vars, SSL_PILOT_CERT_NAME: certName };
37
+ if (await fileExists(domainHook)) {
38
+ log(` Running domain hook: ${domainHook}`);
39
+ try {
40
+ await execHook(domainHook, hookVars);
41
+ log(' Domain hook OK');
42
+ }
43
+ catch (err) {
44
+ log(` Domain hook failed: ${err.message}`);
45
+ }
46
+ }
47
+ if (await fileExists(GLOBAL_HOOK)) {
48
+ log(` Running global hook: ${GLOBAL_HOOK}`);
49
+ try {
50
+ await execHook(GLOBAL_HOOK, hookVars);
51
+ log(' Global hook OK');
52
+ }
53
+ catch (err) {
54
+ log(` Global hook failed: ${err.message}`);
55
+ }
56
+ }
57
+ }
58
+ export { domainHookPath, GLOBAL_HOOK };
package/dist/index.js ADDED
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { listCommand } from './commands/list.js';
4
+ import { downloadCommand } from './commands/download.js';
5
+ import { serviceCommand } from './commands/service/index.js';
6
+ const program = new Command()
7
+ .name('certops')
8
+ .description('CertOps CLI — manage and auto-renew your SSL certificates')
9
+ .version('1.0.0')
10
+ .addHelpText('after', `
11
+ Examples:
12
+ certops list List all certificates
13
+ sudo certops download Pick and download interactively
14
+ sudo certops download '*.example.com' Download a specific domain
15
+ sudo certops service install Set up the auto-renewal service
16
+ certops service status Check service status
17
+
18
+ Documentation:
19
+ https://github.com/Vimosoftdev/ssl-checker-web/blob/main/SETUP.md
20
+ `);
21
+ program.addCommand(listCommand);
22
+ program.addCommand(downloadCommand);
23
+ program.addCommand(serviceCommand);
24
+ program.action(() => program.outputHelp());
25
+ program.parse();
package/dist/state.js ADDED
@@ -0,0 +1,25 @@
1
+ import { readFile, writeFile, rename, mkdir } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import { randomBytes } from 'crypto';
4
+ import { SSL_PILOT_DIR } from './config.js';
5
+ const STATE_PATH = join(SSL_PILOT_DIR, 'state.json');
6
+ export async function readState() {
7
+ try {
8
+ const raw = await readFile(STATE_PATH, 'utf8');
9
+ return JSON.parse(raw);
10
+ }
11
+ catch (err) {
12
+ if (err instanceof Error && err.code === 'ENOENT')
13
+ return {};
14
+ throw err;
15
+ }
16
+ }
17
+ export async function updateCertState(certName, expiryDate) {
18
+ await mkdir(SSL_PILOT_DIR, { recursive: true });
19
+ const state = await readState();
20
+ state[certName] = { expiryDate, downloadedAt: new Date().toISOString() };
21
+ const content = JSON.stringify(state, null, 2) + '\n';
22
+ const tmp = join(SSL_PILOT_DIR, `.state-${randomBytes(4).toString('hex')}.tmp`);
23
+ await writeFile(tmp, content, { encoding: 'utf8', mode: 0o600 });
24
+ await rename(tmp, STATE_PATH);
25
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "certops",
3
+ "version": "0.0.1",
4
+ "description": "SSL Pilot CLI — download and manage your SSL certificates",
5
+ "type": "module",
6
+ "files": [
7
+ "dist"
8
+ ],
9
+ "bin": {
10
+ "certops": "./dist/index.js"
11
+ },
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "dev": "tsx src/index.ts",
15
+ "typecheck": "tsc --noEmit"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "dependencies": {
21
+ "@inquirer/input": "^4.0.0",
22
+ "@inquirer/select": "^4.0.0",
23
+ "commander": "^12.0.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^20.0.0",
27
+ "tsx": "^4.7.1",
28
+ "typescript": "^5.8.3"
29
+ },
30
+ "engines": {
31
+ "node": ">=18"
32
+ }
33
+ }