@matthesketh/fleet 1.1.0 → 1.6.0
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 +183 -251
- package/dist/adapters/detector/index.d.ts +8 -0
- package/dist/adapters/detector/index.js +54 -0
- package/dist/adapters/notifier/index.d.ts +2 -0
- package/dist/adapters/notifier/index.js +2 -0
- package/dist/adapters/notifier/stdout.d.ts +2 -0
- package/dist/adapters/notifier/stdout.js +8 -0
- package/dist/adapters/notifier/webhook.d.ts +9 -0
- package/dist/adapters/notifier/webhook.js +38 -0
- package/dist/adapters/runner/claude-cli.d.ts +7 -0
- package/dist/adapters/runner/claude-cli.js +231 -0
- package/dist/adapters/runner/mcp-call.d.ts +8 -0
- package/dist/adapters/runner/mcp-call.js +82 -0
- package/dist/adapters/runner/shell.d.ts +2 -0
- package/dist/adapters/runner/shell.js +103 -0
- package/dist/adapters/scheduler/systemd-timer.d.ts +17 -0
- package/dist/adapters/scheduler/systemd-timer.js +149 -0
- package/dist/adapters/signals/ci-status.d.ts +2 -0
- package/dist/adapters/signals/ci-status.js +79 -0
- package/dist/adapters/signals/container-up.d.ts +5 -0
- package/dist/adapters/signals/container-up.js +54 -0
- package/dist/adapters/signals/git-clean.d.ts +2 -0
- package/dist/adapters/signals/git-clean.js +55 -0
- package/dist/adapters/signals/index.d.ts +6 -0
- package/dist/adapters/signals/index.js +7 -0
- package/dist/adapters/types.d.ts +52 -0
- package/dist/adapters/types.js +1 -0
- package/dist/cli.js +43 -2
- package/dist/commands/add.js +0 -6
- package/dist/commands/boot-start.d.ts +1 -0
- package/dist/commands/boot-start.js +51 -0
- package/dist/commands/deploy.js +13 -0
- package/dist/commands/deps.js +5 -0
- package/dist/commands/egress.d.ts +1 -0
- package/dist/commands/egress.js +106 -0
- package/dist/commands/freeze.d.ts +4 -0
- package/dist/commands/freeze.js +64 -0
- package/dist/commands/logs.d.ts +1 -1
- package/dist/commands/logs.js +237 -8
- package/dist/commands/patch-systemd.d.ts +1 -0
- package/dist/commands/patch-systemd.js +126 -0
- package/dist/commands/rollback.d.ts +1 -0
- package/dist/commands/rollback.js +58 -0
- package/dist/commands/routine-run.d.ts +1 -0
- package/dist/commands/routine-run.js +122 -0
- package/dist/commands/routines.d.ts +1 -0
- package/dist/commands/routines.js +25 -0
- package/dist/commands/secrets.js +449 -16
- package/dist/commands/status.js +7 -3
- package/dist/commands/watchdog.d.ts +1 -1
- package/dist/commands/watchdog.js +16 -40
- package/dist/core/boot-refresh.d.ts +57 -0
- package/dist/core/boot-refresh.js +116 -0
- package/dist/core/deps/actors/pr-creator.js +11 -9
- package/dist/core/deps/collectors/docker-running.js +2 -2
- package/dist/core/deps/collectors/github-pr.js +5 -2
- package/dist/core/deps/collectors/npm.js +10 -5
- package/dist/core/deps/collectors/vulnerability.js +10 -6
- package/dist/core/deps/reporters/motd.js +1 -1
- package/dist/core/deps/reporters/telegram.js +2 -29
- package/dist/core/docker.js +45 -15
- package/dist/core/egress.d.ts +41 -0
- package/dist/core/egress.js +161 -0
- package/dist/core/exec.d.ts +7 -1
- package/dist/core/exec.js +25 -17
- package/dist/core/git.d.ts +1 -0
- package/dist/core/git.js +36 -23
- package/dist/core/github.js +27 -8
- package/dist/core/health.d.ts +3 -0
- package/dist/core/health.js +15 -3
- package/dist/core/logs-multi.d.ts +73 -0
- package/dist/core/logs-multi.js +163 -0
- package/dist/core/logs-policy.d.ts +55 -0
- package/dist/core/logs-policy.js +148 -0
- package/dist/core/nginx.js +8 -4
- package/dist/core/notify.d.ts +15 -0
- package/dist/core/notify.js +55 -0
- package/dist/core/registry.d.ts +25 -0
- package/dist/core/registry.js +57 -10
- package/dist/core/routines/cost-queries.d.ts +24 -0
- package/dist/core/routines/cost-queries.js +65 -0
- package/dist/core/routines/db.d.ts +9 -0
- package/dist/core/routines/db.js +126 -0
- package/dist/core/routines/defaults.d.ts +2 -0
- package/dist/core/routines/defaults.js +72 -0
- package/dist/core/routines/engine.d.ts +59 -0
- package/dist/core/routines/engine.js +175 -0
- package/dist/core/routines/incidents.d.ts +13 -0
- package/dist/core/routines/incidents.js +35 -0
- package/dist/core/routines/schema.d.ts +418 -0
- package/dist/core/routines/schema.js +113 -0
- package/dist/core/routines/signals-collector.d.ts +35 -0
- package/dist/core/routines/signals-collector.js +114 -0
- package/dist/core/routines/store.d.ts +316 -0
- package/dist/core/routines/store.js +99 -0
- package/dist/core/routines/test-utils.d.ts +2 -0
- package/dist/core/routines/test-utils.js +13 -0
- package/dist/core/secrets-audit.d.ts +21 -0
- package/dist/core/secrets-audit.js +60 -0
- package/dist/core/secrets-metadata.d.ts +39 -0
- package/dist/core/secrets-metadata.js +82 -0
- package/dist/core/secrets-motd.d.ts +20 -0
- package/dist/core/secrets-motd.js +72 -0
- package/dist/core/secrets-ops.d.ts +3 -1
- package/dist/core/secrets-ops.js +78 -13
- package/dist/core/secrets-providers.d.ts +50 -0
- package/dist/core/secrets-providers.js +291 -0
- package/dist/core/secrets-rotation.d.ts +52 -0
- package/dist/core/secrets-rotation.js +165 -0
- package/dist/core/secrets-snapshots.d.ts +26 -0
- package/dist/core/secrets-snapshots.js +95 -0
- package/dist/core/secrets-validate.js +2 -1
- package/dist/core/secrets.d.ts +12 -1
- package/dist/core/secrets.js +35 -24
- package/dist/core/self-update.d.ts +41 -0
- package/dist/core/self-update.js +73 -0
- package/dist/core/systemd.js +29 -12
- package/dist/core/telegram.d.ts +6 -0
- package/dist/core/telegram.js +32 -0
- package/dist/core/validate.d.ts +7 -0
- package/dist/core/validate.js +42 -0
- package/dist/index.js +0 -4
- package/dist/mcp/deps-tools.js +9 -1
- package/dist/mcp/git-tools.js +4 -4
- package/dist/mcp/server.js +193 -8
- package/dist/templates/systemd.js +3 -3
- package/dist/templates/unseal.js +5 -1
- package/dist/tui/components/Confirm.js +3 -4
- package/dist/tui/components/Header.js +37 -8
- package/dist/tui/components/KeyHint.js +14 -5
- package/dist/tui/exec-bridge.js +26 -12
- package/dist/tui/hooks/use-fleet-data.js +5 -2
- package/dist/tui/hooks/use-health.js +5 -2
- package/dist/tui/hooks/use-terminal-size.d.ts +1 -0
- package/dist/tui/hooks/use-terminal-size.js +1 -0
- package/dist/tui/router.js +133 -8
- package/dist/tui/routines/RoutinesApp.d.ts +8 -0
- package/dist/tui/routines/RoutinesApp.js +277 -0
- package/dist/tui/routines/components/AlertsPanel.d.ts +7 -0
- package/dist/tui/routines/components/AlertsPanel.js +22 -0
- package/dist/tui/routines/components/AlertsPanel.test.d.ts +1 -0
- package/dist/tui/routines/components/AlertsPanel.test.js +52 -0
- package/dist/tui/routines/components/CommandPalette.d.ts +12 -0
- package/dist/tui/routines/components/CommandPalette.js +21 -0
- package/dist/tui/routines/components/LiveRunPanel.d.ts +12 -0
- package/dist/tui/routines/components/LiveRunPanel.js +107 -0
- package/dist/tui/routines/components/RoutineForm.d.ts +8 -0
- package/dist/tui/routines/components/RoutineForm.js +254 -0
- package/dist/tui/routines/components/SignalsGrid.d.ts +13 -0
- package/dist/tui/routines/components/SignalsGrid.js +34 -0
- package/dist/tui/routines/components/SignalsGrid.test.d.ts +1 -0
- package/dist/tui/routines/components/SignalsGrid.test.js +43 -0
- package/dist/tui/routines/format.d.ts +7 -0
- package/dist/tui/routines/format.js +51 -0
- package/dist/tui/routines/hooks/use-git-fleet.d.ts +33 -0
- package/dist/tui/routines/hooks/use-git-fleet.js +82 -0
- package/dist/tui/routines/hooks/use-logs-stream.d.ts +13 -0
- package/dist/tui/routines/hooks/use-logs-stream.js +64 -0
- package/dist/tui/routines/hooks/use-ops-fleet.d.ts +20 -0
- package/dist/tui/routines/hooks/use-ops-fleet.js +70 -0
- package/dist/tui/routines/hooks/use-repo-detail.d.ts +31 -0
- package/dist/tui/routines/hooks/use-repo-detail.js +104 -0
- package/dist/tui/routines/hooks/use-security.d.ts +33 -0
- package/dist/tui/routines/hooks/use-security.js +110 -0
- package/dist/tui/routines/hooks/use-signals.d.ts +9 -0
- package/dist/tui/routines/hooks/use-signals.js +60 -0
- package/dist/tui/routines/runtime.d.ts +20 -0
- package/dist/tui/routines/runtime.js +40 -0
- package/dist/tui/routines/tabs/CostTab.d.ts +7 -0
- package/dist/tui/routines/tabs/CostTab.js +24 -0
- package/dist/tui/routines/tabs/DashboardTab.d.ts +15 -0
- package/dist/tui/routines/tabs/DashboardTab.js +10 -0
- package/dist/tui/routines/tabs/GitTab.d.ts +6 -0
- package/dist/tui/routines/tabs/GitTab.js +39 -0
- package/dist/tui/routines/tabs/LogsTab.d.ts +6 -0
- package/dist/tui/routines/tabs/LogsTab.js +58 -0
- package/dist/tui/routines/tabs/OpsTab.d.ts +6 -0
- package/dist/tui/routines/tabs/OpsTab.js +34 -0
- package/dist/tui/routines/tabs/RepoDetailView.d.ts +6 -0
- package/dist/tui/routines/tabs/RepoDetailView.js +12 -0
- package/dist/tui/routines/tabs/RoutinesTab.d.ts +10 -0
- package/dist/tui/routines/tabs/RoutinesTab.js +58 -0
- package/dist/tui/routines/tabs/ScaffoldTab.d.ts +2 -0
- package/dist/tui/routines/tabs/ScaffoldTab.js +127 -0
- package/dist/tui/routines/tabs/SecurityTab.d.ts +6 -0
- package/dist/tui/routines/tabs/SecurityTab.js +31 -0
- package/dist/tui/routines/tabs/SettingsTab.d.ts +6 -0
- package/dist/tui/routines/tabs/SettingsTab.js +61 -0
- package/dist/tui/routines/tabs/TimelineTab.d.ts +7 -0
- package/dist/tui/routines/tabs/TimelineTab.js +26 -0
- package/dist/tui/state.js +16 -1
- package/dist/tui/tests/flicker.test.d.ts +1 -0
- package/dist/tui/tests/flicker.test.js +105 -0
- package/dist/tui/tests/keyboard-integration.test.d.ts +1 -0
- package/dist/tui/tests/keyboard-integration.test.js +120 -0
- package/dist/tui/tests/test-app.d.ts +4 -0
- package/dist/tui/tests/test-app.js +79 -0
- package/dist/tui/types.d.ts +14 -1
- package/dist/tui/views/AppDetail.js +40 -26
- package/dist/tui/views/Dashboard.js +34 -9
- package/dist/tui/views/HealthView.js +42 -12
- package/dist/tui/views/LogsView.js +38 -10
- package/dist/tui/views/MultiLogsView.d.ts +2 -0
- package/dist/tui/views/MultiLogsView.js +165 -0
- package/dist/tui/views/SecretEdit.js +18 -7
- package/dist/tui/views/SecretsView.js +55 -39
- package/dist/ui/prompt.d.ts +52 -0
- package/dist/ui/prompt.js +169 -0
- package/package.json +33 -5
- package/dist/commands/motd.d.ts +0 -1
- package/dist/commands/motd.js +0 -10
- package/dist/templates/motd.d.ts +0 -1
- package/dist/templates/motd.js +0 -7
- package/dist/tui/components/AppList.d.ts +0 -12
- package/dist/tui/components/AppList.js +0 -32
- package/dist/tui/hooks/use-keyboard.d.ts +0 -1
- package/dist/tui/hooks/use-keyboard.js +0 -44
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rotation engine: takes a parsed plaintext env, applies a per-secret rotation
|
|
3
|
+
* (immediate, dual-mode, etc.), re-seals, restarts the service, runs a health
|
|
4
|
+
* gate, and rolls back on failure. Pure functions where possible — I/O isolated
|
|
5
|
+
* to thin wrappers so tests can run without a real vault.
|
|
6
|
+
*/
|
|
7
|
+
import { decryptApp, sealApp, loadManifest } from './secrets.js';
|
|
8
|
+
import { snapshotApp, restoreSnapshot } from './secrets-snapshots.js';
|
|
9
|
+
import { auditLog } from './secrets-audit.js';
|
|
10
|
+
import { markRotated } from './secrets-metadata.js';
|
|
11
|
+
import { classifySecret } from './secrets-providers.js';
|
|
12
|
+
import { SecretsError } from './errors.js';
|
|
13
|
+
/** Mask a NEW (just-entered) secret value for confirmation display. */
|
|
14
|
+
export function maskNewValue(value) {
|
|
15
|
+
if (value.length <= 4)
|
|
16
|
+
return `*** (${value.length} chars)`;
|
|
17
|
+
if (value.length <= 12)
|
|
18
|
+
return `${value.slice(0, 2)}***${value.slice(-2)} (${value.length} chars)`;
|
|
19
|
+
return `${value.slice(0, 4)}…${value.slice(-4)} (${value.length} chars)`;
|
|
20
|
+
}
|
|
21
|
+
/** Validate against provider format if any. Returns null on pass, error string on fail. */
|
|
22
|
+
export function validateFormat(value, provider) {
|
|
23
|
+
if (!provider?.format)
|
|
24
|
+
return null;
|
|
25
|
+
if (provider.format.test(value))
|
|
26
|
+
return null;
|
|
27
|
+
return `Value does not match ${provider.name} format (${provider.format.source})`;
|
|
28
|
+
}
|
|
29
|
+
/** Reject obvious placeholders / low-entropy strings. */
|
|
30
|
+
export function checkEntropy(value) {
|
|
31
|
+
const lower = value.toLowerCase().trim();
|
|
32
|
+
const placeholders = [
|
|
33
|
+
'todo', 'changeme', 'change-me', 'change_me', 'placeholder',
|
|
34
|
+
'password', 'secret', 'changethis', 'change-this',
|
|
35
|
+
'foo', 'bar', 'baz', 'test', 'example', 'xxx', 'yyy', 'zzz',
|
|
36
|
+
'replace_me', 'replace-me', 'fixme',
|
|
37
|
+
];
|
|
38
|
+
if (placeholders.includes(lower)) {
|
|
39
|
+
// Don't echo the rejected value — even an obvious placeholder might be
|
|
40
|
+
// an unintended paste of a real secret that just happened to start with
|
|
41
|
+
// a placeholder substring.
|
|
42
|
+
return `Value looks like a placeholder, not a real secret`;
|
|
43
|
+
}
|
|
44
|
+
if (value.length < 8) {
|
|
45
|
+
return `Value too short (${value.length} chars) — secrets should be ≥ 8 chars`;
|
|
46
|
+
}
|
|
47
|
+
if (/^(.)\1+$/.test(value)) {
|
|
48
|
+
return `Value is all the same character`;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
export function parseEnv(plaintext) {
|
|
53
|
+
const lines = [];
|
|
54
|
+
for (const raw of plaintext.split('\n')) {
|
|
55
|
+
if (raw.trim() === '' || raw.trim().startsWith('#')) {
|
|
56
|
+
lines.push({ kind: 'raw', text: raw });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const eq = raw.indexOf('=');
|
|
60
|
+
if (eq < 0) {
|
|
61
|
+
lines.push({ kind: 'raw', text: raw });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
lines.push({ kind: 'kv', key: raw.substring(0, eq), value: raw.substring(eq + 1) });
|
|
65
|
+
}
|
|
66
|
+
return lines;
|
|
67
|
+
}
|
|
68
|
+
export function serialiseEnv(lines) {
|
|
69
|
+
return lines
|
|
70
|
+
.map(l => (l.kind === 'kv' ? `${l.key}=${l.value}` : l.text))
|
|
71
|
+
.join('\n');
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Apply a single secret update. For dual-mode strategies, the OLD value is
|
|
75
|
+
* preserved as <NAME>_PREVIOUS so the app can verify legacy tokens during
|
|
76
|
+
* the grace period. Returns the new env content.
|
|
77
|
+
*/
|
|
78
|
+
export function applyRotation(plaintext, key, newValue, strategy) {
|
|
79
|
+
const lines = parseEnv(plaintext);
|
|
80
|
+
const idx = lines.findIndex(l => l.kind === 'kv' && l.key === key);
|
|
81
|
+
if (idx < 0)
|
|
82
|
+
throw new SecretsError(`Key not found in env: ${key}`);
|
|
83
|
+
const existing = lines[idx];
|
|
84
|
+
if (existing.kind !== 'kv')
|
|
85
|
+
throw new SecretsError('Internal: kv expected');
|
|
86
|
+
if (strategy === 'dual-mode') {
|
|
87
|
+
const prevKey = `${key}_PREVIOUS`;
|
|
88
|
+
const oldValue = existing.value;
|
|
89
|
+
// Replace primary with new value
|
|
90
|
+
lines[idx] = { kind: 'kv', key, value: newValue };
|
|
91
|
+
// Insert/update the _PREVIOUS line right after the primary
|
|
92
|
+
const prevIdx = lines.findIndex(l => l.kind === 'kv' && l.key === prevKey);
|
|
93
|
+
if (prevIdx >= 0) {
|
|
94
|
+
lines[prevIdx] = { kind: 'kv', key: prevKey, value: oldValue };
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
lines.splice(idx + 1, 0, { kind: 'kv', key: prevKey, value: oldValue });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
lines[idx] = { kind: 'kv', key, value: newValue };
|
|
102
|
+
}
|
|
103
|
+
return serialiseEnv(lines);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Full rotation pipeline. Caller is expected to have already collected and
|
|
107
|
+
* validated the new value via the interactive prompts. This orchestrates
|
|
108
|
+
* snapshot → seal → audit. Restart + health-gate are caller's responsibility
|
|
109
|
+
* (we want the engine pure-ish so it's easy to test).
|
|
110
|
+
*/
|
|
111
|
+
export function performRotation(app, key, newValue, opts = {}) {
|
|
112
|
+
const manifest = loadManifest();
|
|
113
|
+
const entry = manifest.apps[app];
|
|
114
|
+
if (!entry)
|
|
115
|
+
throw new SecretsError(`No app in manifest: ${app}`);
|
|
116
|
+
if (entry.type !== 'env') {
|
|
117
|
+
throw new SecretsError(`Rotation only supports env-type apps, got ${entry.type}`);
|
|
118
|
+
}
|
|
119
|
+
const provider = classifySecret(key);
|
|
120
|
+
const strategy = provider?.strategy ?? 'immediate';
|
|
121
|
+
if (strategy === 'user-issued') {
|
|
122
|
+
throw new SecretsError(`${key} is a user-issued token. Rotating yours doesn't help — invalidate per-user instead.`);
|
|
123
|
+
}
|
|
124
|
+
// Strict typed opt — was previously a substring match on opts.notes which
|
|
125
|
+
// could be bypassed by any caller embedding the flag in free-text notes.
|
|
126
|
+
if (strategy === 'at-rest-key' && !opts.dataMigrated) {
|
|
127
|
+
throw new SecretsError(`${key} encrypts data at rest. Re-encrypt your data first, then pass --data-migrated.`);
|
|
128
|
+
}
|
|
129
|
+
if (opts.dryRun) {
|
|
130
|
+
auditLog({ op: 'rotate-attempted', app, secret: key, ok: true, details: 'dry-run' });
|
|
131
|
+
return { app, key, strategy, snapshot: '(dry-run)', rolledBack: false };
|
|
132
|
+
}
|
|
133
|
+
// 1. Snapshot before any change.
|
|
134
|
+
const snapshot = snapshotApp(app);
|
|
135
|
+
auditLog({ op: 'snapshot', app, secret: key, ok: true, details: snapshot });
|
|
136
|
+
try {
|
|
137
|
+
// 2. Decrypt, apply rotation, re-encrypt.
|
|
138
|
+
const plaintext = decryptApp(app);
|
|
139
|
+
const updated = applyRotation(plaintext, key, newValue, strategy);
|
|
140
|
+
sealApp(app, updated, entry.sourceFile);
|
|
141
|
+
// 3. Stamp metadata.
|
|
142
|
+
markRotated(app, key, { strategy, notes: opts.notes });
|
|
143
|
+
auditLog({ op: 'rotate', app, secret: key, ok: true, details: `strategy=${strategy}` });
|
|
144
|
+
return { app, key, strategy, snapshot, rolledBack: false };
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
148
|
+
// Restore from snapshot on any failure.
|
|
149
|
+
try {
|
|
150
|
+
restoreSnapshot(app);
|
|
151
|
+
auditLog({ op: 'rollback', app, secret: key, ok: true, details: `auto: ${reason}` });
|
|
152
|
+
}
|
|
153
|
+
catch (rollbackErr) {
|
|
154
|
+
auditLog({
|
|
155
|
+
op: 'rollback',
|
|
156
|
+
app,
|
|
157
|
+
secret: key,
|
|
158
|
+
ok: false,
|
|
159
|
+
details: `auto rollback also failed: ${rollbackErr}`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
auditLog({ op: 'rotate-failed', app, secret: key, ok: false, details: reason });
|
|
163
|
+
return { app, key, strategy, snapshot, rolledBack: true, reason };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-rotation vault snapshots. Every destructive operation copies the
|
|
3
|
+
* current encrypted file to vault/.snapshots/<app>-<timestamp>.env.age
|
|
4
|
+
* BEFORE making changes. Restoration is one command.
|
|
5
|
+
*
|
|
6
|
+
* Snapshots are immutable (copy + atomic-rename pattern). Cleanup is
|
|
7
|
+
* manual via `fleet secrets snapshots prune` — we never auto-delete.
|
|
8
|
+
*/
|
|
9
|
+
export interface Snapshot {
|
|
10
|
+
app: string;
|
|
11
|
+
timestamp: string;
|
|
12
|
+
path: string;
|
|
13
|
+
sizeBytes: number;
|
|
14
|
+
}
|
|
15
|
+
/** Pre-rotation copy. Returns the absolute path to the snapshot. */
|
|
16
|
+
export declare function snapshotApp(app: string): string;
|
|
17
|
+
/** All snapshots for an app, newest first. */
|
|
18
|
+
export declare function listSnapshots(app: string): Snapshot[];
|
|
19
|
+
/**
|
|
20
|
+
* Restore a snapshot. Without a timestamp, uses the newest. Replaces the live
|
|
21
|
+
* vault file in-place. Returns the snapshot that was used.
|
|
22
|
+
*/
|
|
23
|
+
export declare function restoreSnapshot(app: string, timestamp?: string): Snapshot;
|
|
24
|
+
/** Delete snapshots older than `keep` (count, newest kept). Returns # deleted. */
|
|
25
|
+
export declare function pruneSnapshots(app: string, keep: number): number;
|
|
26
|
+
export declare function getSnapshotDir(): string;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-rotation vault snapshots. Every destructive operation copies the
|
|
3
|
+
* current encrypted file to vault/.snapshots/<app>-<timestamp>.env.age
|
|
4
|
+
* BEFORE making changes. Restoration is one command.
|
|
5
|
+
*
|
|
6
|
+
* Snapshots are immutable (copy + atomic-rename pattern). Cleanup is
|
|
7
|
+
* manual via `fleet secrets snapshots prune` — we never auto-delete.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, mkdirSync, copyFileSync, readdirSync, statSync, renameSync, unlinkSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { loadManifest, VAULT_DIR } from './secrets.js';
|
|
12
|
+
import { SecretsError } from './errors.js';
|
|
13
|
+
// Computed lazily via snapshotDir() so test mocks of VAULT_DIR work cleanly.
|
|
14
|
+
function snapshotDir() {
|
|
15
|
+
return join(VAULT_DIR, '.snapshots');
|
|
16
|
+
}
|
|
17
|
+
function ensureSnapshotDir() {
|
|
18
|
+
if (!existsSync(snapshotDir())) {
|
|
19
|
+
mkdirSync(snapshotDir(), { recursive: true, mode: 0o700 });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Pre-rotation copy. Returns the absolute path to the snapshot. */
|
|
23
|
+
export function snapshotApp(app) {
|
|
24
|
+
ensureSnapshotDir();
|
|
25
|
+
const manifest = loadManifest();
|
|
26
|
+
const entry = manifest.apps[app];
|
|
27
|
+
if (!entry)
|
|
28
|
+
throw new SecretsError(`No app in manifest: ${app}`);
|
|
29
|
+
const src = join(VAULT_DIR, entry.encryptedFile);
|
|
30
|
+
if (!existsSync(src))
|
|
31
|
+
throw new SecretsError(`Vault file missing: ${entry.encryptedFile}`);
|
|
32
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
33
|
+
const dest = join(snapshotDir(), `${app}-${ts}.env.age`);
|
|
34
|
+
const tmp = dest + '.tmp';
|
|
35
|
+
copyFileSync(src, tmp);
|
|
36
|
+
renameSync(tmp, dest); // atomic
|
|
37
|
+
return dest;
|
|
38
|
+
}
|
|
39
|
+
/** All snapshots for an app, newest first. */
|
|
40
|
+
export function listSnapshots(app) {
|
|
41
|
+
if (!existsSync(snapshotDir()))
|
|
42
|
+
return [];
|
|
43
|
+
const prefix = `${app}-`;
|
|
44
|
+
return readdirSync(snapshotDir())
|
|
45
|
+
.filter(f => f.startsWith(prefix) && f.endsWith('.env.age'))
|
|
46
|
+
.map(f => {
|
|
47
|
+
const path = join(snapshotDir(), f);
|
|
48
|
+
const ts = f.substring(prefix.length, f.length - '.env.age'.length);
|
|
49
|
+
return {
|
|
50
|
+
app,
|
|
51
|
+
timestamp: ts,
|
|
52
|
+
path,
|
|
53
|
+
sizeBytes: statSync(path).size,
|
|
54
|
+
};
|
|
55
|
+
})
|
|
56
|
+
.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Restore a snapshot. Without a timestamp, uses the newest. Replaces the live
|
|
60
|
+
* vault file in-place. Returns the snapshot that was used.
|
|
61
|
+
*/
|
|
62
|
+
export function restoreSnapshot(app, timestamp) {
|
|
63
|
+
const snaps = listSnapshots(app);
|
|
64
|
+
if (snaps.length === 0)
|
|
65
|
+
throw new SecretsError(`No snapshots for ${app}`);
|
|
66
|
+
const target = timestamp
|
|
67
|
+
? snaps.find(s => s.timestamp === timestamp)
|
|
68
|
+
: snaps[0];
|
|
69
|
+
if (!target)
|
|
70
|
+
throw new SecretsError(`Snapshot not found: ${timestamp}`);
|
|
71
|
+
const manifest = loadManifest();
|
|
72
|
+
const entry = manifest.apps[app];
|
|
73
|
+
if (!entry)
|
|
74
|
+
throw new SecretsError(`No app in manifest: ${app}`);
|
|
75
|
+
const dest = join(VAULT_DIR, entry.encryptedFile);
|
|
76
|
+
// Atomic replace: copy → fsync → rename. A crash mid-restore leaves the
|
|
77
|
+
// original vault file intact (the tmp file is the only thing in flux).
|
|
78
|
+
const tmp = dest + '.restore.tmp';
|
|
79
|
+
copyFileSync(target.path, tmp);
|
|
80
|
+
renameSync(tmp, dest);
|
|
81
|
+
return target;
|
|
82
|
+
}
|
|
83
|
+
/** Delete snapshots older than `keep` (count, newest kept). Returns # deleted. */
|
|
84
|
+
export function pruneSnapshots(app, keep) {
|
|
85
|
+
const snaps = listSnapshots(app);
|
|
86
|
+
if (snaps.length <= keep)
|
|
87
|
+
return 0;
|
|
88
|
+
const drop = snaps.slice(keep);
|
|
89
|
+
for (const s of drop)
|
|
90
|
+
unlinkSync(s.path);
|
|
91
|
+
return drop.length;
|
|
92
|
+
}
|
|
93
|
+
export function getSnapshotDir() {
|
|
94
|
+
return snapshotDir();
|
|
95
|
+
}
|
|
@@ -43,7 +43,8 @@ export function validateApp(appName) {
|
|
|
43
43
|
let composePath;
|
|
44
44
|
let composeFile = null;
|
|
45
45
|
if (appName === 'docker-databases') {
|
|
46
|
-
|
|
46
|
+
const reg = load();
|
|
47
|
+
composePath = reg.infrastructure.databases.composePath;
|
|
47
48
|
}
|
|
48
49
|
else {
|
|
49
50
|
const reg = load();
|
package/dist/core/secrets.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
export declare const VAULT_DIR
|
|
1
|
+
export declare const VAULT_DIR: string;
|
|
2
2
|
export declare const KEY_PATH = "/etc/fleet/age.key";
|
|
3
3
|
export declare const RUNTIME_DIR = "/run/fleet-secrets";
|
|
4
|
+
export interface SecretMetadata {
|
|
5
|
+
lastRotated: string;
|
|
6
|
+
provider?: string;
|
|
7
|
+
strategy?: 'immediate' | 'dual-mode' | 'at-rest-key' | 'user-issued';
|
|
8
|
+
notes?: string;
|
|
9
|
+
}
|
|
4
10
|
export interface ManifestEntry {
|
|
5
11
|
type: 'env' | 'secrets-dir';
|
|
6
12
|
encryptedFile: string;
|
|
@@ -8,6 +14,11 @@ export interface ManifestEntry {
|
|
|
8
14
|
files?: string[];
|
|
9
15
|
lastSealedAt: string;
|
|
10
16
|
keyCount: number;
|
|
17
|
+
/** Per-secret metadata, keyed by secret name. Backwards-compatible: missing means
|
|
18
|
+
* lastRotated falls back to lastSealedAt and provider is auto-classified at read time. */
|
|
19
|
+
secrets?: Record<string, SecretMetadata>;
|
|
20
|
+
/** Per-app age recipient public key, used by harden --per-app to limit blast radius. */
|
|
21
|
+
recipient?: string;
|
|
11
22
|
}
|
|
12
23
|
export interface Manifest {
|
|
13
24
|
version: number;
|
package/dist/core/secrets.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, chmodSync, rmSync, copyFileSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { SecretsError, VaultNotInitializedError } from './errors.js';
|
|
5
|
-
|
|
5
|
+
import { execSafe } from './exec.js';
|
|
6
|
+
import { assertAppName, assertFilePath } from './validate.js';
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
export const VAULT_DIR = join(__dirname, '..', '..', 'vault');
|
|
6
9
|
export const KEY_PATH = '/etc/fleet/age.key';
|
|
7
10
|
export const RUNTIME_DIR = '/run/fleet-secrets';
|
|
8
11
|
const MANIFEST_PATH = join(VAULT_DIR, 'manifest.json');
|
|
9
12
|
const SECRET_DELIMITER = '---SECRET:';
|
|
10
13
|
export function ensureAge() {
|
|
11
|
-
|
|
12
|
-
execSync('which age', { stdio: 'pipe' });
|
|
13
|
-
}
|
|
14
|
-
catch {
|
|
14
|
+
if (!execSafe('which', ['age']).ok) {
|
|
15
15
|
throw new SecretsError('age not found. Install with: apt install age');
|
|
16
16
|
}
|
|
17
17
|
}
|
|
@@ -27,7 +27,10 @@ function requireInit() {
|
|
|
27
27
|
}
|
|
28
28
|
export function getPublicKey() {
|
|
29
29
|
requireInit();
|
|
30
|
-
|
|
30
|
+
const r = execSafe('age-keygen', ['-y', KEY_PATH]);
|
|
31
|
+
if (!r.ok)
|
|
32
|
+
throw new SecretsError(`Failed to read public key: ${r.stderr}`);
|
|
33
|
+
return r.stdout;
|
|
31
34
|
}
|
|
32
35
|
export function initVault() {
|
|
33
36
|
ensureAge();
|
|
@@ -37,7 +40,9 @@ export function initVault() {
|
|
|
37
40
|
if (!existsSync(keyDir)) {
|
|
38
41
|
mkdirSync(keyDir, { recursive: true, mode: 0o700 });
|
|
39
42
|
}
|
|
40
|
-
|
|
43
|
+
const keygen = execSafe('age-keygen', ['-o', KEY_PATH]);
|
|
44
|
+
if (!keygen.ok)
|
|
45
|
+
throw new SecretsError(`Failed to generate key: ${keygen.stderr}`);
|
|
41
46
|
chmodSync(KEY_PATH, 0o600);
|
|
42
47
|
if (!existsSync(VAULT_DIR)) {
|
|
43
48
|
mkdirSync(VAULT_DIR, { recursive: true });
|
|
@@ -49,7 +54,12 @@ export function loadManifest() {
|
|
|
49
54
|
requireInit();
|
|
50
55
|
if (!existsSync(MANIFEST_PATH))
|
|
51
56
|
return { version: 1, apps: {} };
|
|
52
|
-
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8'));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return { apps: {} };
|
|
62
|
+
}
|
|
53
63
|
}
|
|
54
64
|
export function saveManifest(manifest) {
|
|
55
65
|
writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n');
|
|
@@ -89,27 +99,27 @@ export function removeBackup(app) {
|
|
|
89
99
|
}
|
|
90
100
|
export function ageEncrypt(plaintext) {
|
|
91
101
|
const pubkey = getPublicKey();
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
});
|
|
102
|
+
const r = execSafe('age', ['-r', pubkey, '--armor'], { input: plaintext });
|
|
103
|
+
if (!r.ok)
|
|
104
|
+
throw new SecretsError(`age encrypt failed: ${r.stderr}`);
|
|
105
|
+
return r.stdout;
|
|
97
106
|
}
|
|
98
107
|
export function ageDecrypt(ciphertext) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
});
|
|
108
|
+
const r = execSafe('age', ['-d', '-i', KEY_PATH], { input: ciphertext.toString() });
|
|
109
|
+
if (!r.ok)
|
|
110
|
+
throw new SecretsError(`age decrypt failed: ${r.stderr}`);
|
|
111
|
+
return r.stdout;
|
|
104
112
|
}
|
|
105
113
|
export function ageDecryptFile(filePath) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
114
|
+
assertFilePath(filePath);
|
|
115
|
+
const r = execSafe('age', ['-d', '-i', KEY_PATH, filePath]);
|
|
116
|
+
if (!r.ok)
|
|
117
|
+
throw new SecretsError(`age decrypt file failed: ${r.stderr}`);
|
|
118
|
+
return r.stdout;
|
|
110
119
|
}
|
|
111
120
|
export function sealApp(app, envContent, sourceFile) {
|
|
112
121
|
requireInit();
|
|
122
|
+
assertAppName(app);
|
|
113
123
|
const encrypted = ageEncrypt(envContent);
|
|
114
124
|
const encFile = `${app}.env.age`;
|
|
115
125
|
writeFileSync(join(VAULT_DIR, encFile), encrypted);
|
|
@@ -126,6 +136,7 @@ export function sealApp(app, envContent, sourceFile) {
|
|
|
126
136
|
}
|
|
127
137
|
export function sealDbSecrets(app, secretsMap, sourceDir) {
|
|
128
138
|
requireInit();
|
|
139
|
+
assertAppName(app);
|
|
129
140
|
const filenames = Object.keys(secretsMap).sort();
|
|
130
141
|
const parts = filenames.map(f => `${SECRET_DELIMITER}${f}---\n${secretsMap[f]}`);
|
|
131
142
|
const bundle = parts.join('\n');
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-update check and apply for fleet itself.
|
|
3
|
+
*
|
|
4
|
+
* fleet is installed via `npm link`-style symlink from /usr/local/bin/fleet to
|
|
5
|
+
* /home/matt/fleet/dist/index.js. Updates are produced by:
|
|
6
|
+
* 1. git pull --ff-only origin develop in /home/matt/fleet
|
|
7
|
+
* 2. npm run build (rewrites dist/)
|
|
8
|
+
*
|
|
9
|
+
* checkForUpdate() does a non-blocking `git fetch` + compares HEAD with the
|
|
10
|
+
* remote. applyUpdate() runs the pull + build. Both are pure shell wrappers
|
|
11
|
+
* around execSafe — easy to mock in tests, easy to reason about under sudo.
|
|
12
|
+
*/
|
|
13
|
+
export interface UpdateInfo {
|
|
14
|
+
/** True if `git rev-parse @{u}` shows commits ahead of HEAD. */
|
|
15
|
+
available: boolean;
|
|
16
|
+
/** Number of commits HEAD is behind origin. 0 if up-to-date. */
|
|
17
|
+
behind: number;
|
|
18
|
+
/** Short subject of the latest remote commit (or empty string on failure). */
|
|
19
|
+
latestSubject: string;
|
|
20
|
+
/** Branch name in the local repo. */
|
|
21
|
+
branch: string;
|
|
22
|
+
/** Why the check failed, if it did. */
|
|
23
|
+
error?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface UpdateResult {
|
|
26
|
+
ok: boolean;
|
|
27
|
+
pulled: number;
|
|
28
|
+
buildOk: boolean;
|
|
29
|
+
output: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Non-blocking check. Does a `git fetch` (timeboxed) then compares.
|
|
33
|
+
* Returns a stable UpdateInfo even on failure (just `available=false`).
|
|
34
|
+
*/
|
|
35
|
+
export declare function checkForUpdate(): Promise<UpdateInfo>;
|
|
36
|
+
/**
|
|
37
|
+
* Apply: git pull --ff-only + npm run build. Refuses to run if the working
|
|
38
|
+
* tree is dirty (would clobber uncommitted changes). Returns aggregate output
|
|
39
|
+
* for the toast / TUI to surface.
|
|
40
|
+
*/
|
|
41
|
+
export declare function applyUpdate(): Promise<UpdateResult>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-update check and apply for fleet itself.
|
|
3
|
+
*
|
|
4
|
+
* fleet is installed via `npm link`-style symlink from /usr/local/bin/fleet to
|
|
5
|
+
* /home/matt/fleet/dist/index.js. Updates are produced by:
|
|
6
|
+
* 1. git pull --ff-only origin develop in /home/matt/fleet
|
|
7
|
+
* 2. npm run build (rewrites dist/)
|
|
8
|
+
*
|
|
9
|
+
* checkForUpdate() does a non-blocking `git fetch` + compares HEAD with the
|
|
10
|
+
* remote. applyUpdate() runs the pull + build. Both are pure shell wrappers
|
|
11
|
+
* around execSafe — easy to mock in tests, easy to reason about under sudo.
|
|
12
|
+
*/
|
|
13
|
+
import { dirname } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { execSafe } from './exec.js';
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
// dist/core/self-update.js → repo root is two ../
|
|
18
|
+
const FLEET_REPO = process.env.FLEET_REPO_PATH ?? `${__dirname}/../..`;
|
|
19
|
+
/**
|
|
20
|
+
* Non-blocking check. Does a `git fetch` (timeboxed) then compares.
|
|
21
|
+
* Returns a stable UpdateInfo even on failure (just `available=false`).
|
|
22
|
+
*/
|
|
23
|
+
export async function checkForUpdate() {
|
|
24
|
+
const branchR = execSafe('git', ['-C', FLEET_REPO, 'rev-parse', '--abbrev-ref', 'HEAD']);
|
|
25
|
+
if (!branchR.ok) {
|
|
26
|
+
return { available: false, behind: 0, latestSubject: '', branch: '?', error: branchR.stderr };
|
|
27
|
+
}
|
|
28
|
+
const branch = branchR.stdout;
|
|
29
|
+
// Fetch quietly, with a short timeout so we never block the TUI launch.
|
|
30
|
+
const fetchR = execSafe('git', ['-C', FLEET_REPO, 'fetch', '--quiet', 'origin', branch], { timeout: 8_000 });
|
|
31
|
+
if (!fetchR.ok) {
|
|
32
|
+
return { available: false, behind: 0, latestSubject: '', branch, error: 'fetch failed' };
|
|
33
|
+
}
|
|
34
|
+
const countR = execSafe('git', ['-C', FLEET_REPO, 'rev-list', '--count', `HEAD..origin/${branch}`]);
|
|
35
|
+
if (!countR.ok) {
|
|
36
|
+
return { available: false, behind: 0, latestSubject: '', branch, error: countR.stderr };
|
|
37
|
+
}
|
|
38
|
+
const behind = parseInt(countR.stdout, 10) || 0;
|
|
39
|
+
let latestSubject = '';
|
|
40
|
+
if (behind > 0) {
|
|
41
|
+
const subR = execSafe('git', ['-C', FLEET_REPO, 'log', '-1', '--pretty=%s', `origin/${branch}`]);
|
|
42
|
+
latestSubject = subR.ok ? subR.stdout : '';
|
|
43
|
+
}
|
|
44
|
+
return { available: behind > 0, behind, latestSubject, branch };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Apply: git pull --ff-only + npm run build. Refuses to run if the working
|
|
48
|
+
* tree is dirty (would clobber uncommitted changes). Returns aggregate output
|
|
49
|
+
* for the toast / TUI to surface.
|
|
50
|
+
*/
|
|
51
|
+
export async function applyUpdate() {
|
|
52
|
+
const dirty = execSafe('git', ['-C', FLEET_REPO, 'status', '--porcelain']);
|
|
53
|
+
if (dirty.ok && dirty.stdout.length > 0) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false, pulled: 0, buildOk: false,
|
|
56
|
+
output: 'Refusing to update: working tree is dirty. Commit or stash first.',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const pre = execSafe('git', ['-C', FLEET_REPO, 'rev-parse', 'HEAD']);
|
|
60
|
+
const pull = execSafe('git', ['-C', FLEET_REPO, 'pull', '--ff-only'], { timeout: 30_000 });
|
|
61
|
+
if (!pull.ok) {
|
|
62
|
+
return { ok: false, pulled: 0, buildOk: false, output: pull.stderr || pull.stdout };
|
|
63
|
+
}
|
|
64
|
+
const post = execSafe('git', ['-C', FLEET_REPO, 'rev-parse', 'HEAD']);
|
|
65
|
+
const pulled = pre.stdout !== post.stdout ? 1 : 0; // 1 = something updated
|
|
66
|
+
const build = execSafe('npm', ['run', 'build'], { cwd: FLEET_REPO, timeout: 120_000 });
|
|
67
|
+
return {
|
|
68
|
+
ok: pull.ok && build.ok,
|
|
69
|
+
pulled,
|
|
70
|
+
buildOk: build.ok,
|
|
71
|
+
output: pulled === 0 ? 'Already up to date.' : (build.ok ? 'Updated + rebuilt.' : build.stderr),
|
|
72
|
+
};
|
|
73
|
+
}
|
package/dist/core/systemd.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import { execSafe } from './exec.js';
|
|
3
|
+
import { assertServiceName } from './validate.js';
|
|
3
4
|
let _systemdAvailable = null;
|
|
4
5
|
export function systemdAvailable() {
|
|
5
6
|
if (_systemdAvailable === null) {
|
|
6
|
-
const result =
|
|
7
|
+
const result = execSafe('systemctl', ['is-system-running']);
|
|
7
8
|
// Returns "running", "degraded", etc. when systemd is PID 1.
|
|
8
9
|
// Returns "offline" when not booted with systemd.
|
|
9
10
|
_systemdAvailable = result.ok || result.stdout === 'degraded';
|
|
@@ -21,7 +22,11 @@ function parseSystemctlShow(output) {
|
|
|
21
22
|
return props;
|
|
22
23
|
}
|
|
23
24
|
export function getServiceStatus(serviceName) {
|
|
24
|
-
|
|
25
|
+
assertServiceName(serviceName);
|
|
26
|
+
const result = execSafe('systemctl', [
|
|
27
|
+
'show', `${serviceName}.service`,
|
|
28
|
+
'--property=ActiveState,UnitFileState,Description', '--no-pager',
|
|
29
|
+
]);
|
|
25
30
|
const props = parseSystemctlShow(result.stdout);
|
|
26
31
|
return {
|
|
27
32
|
name: serviceName,
|
|
@@ -34,8 +39,13 @@ export function getServiceStatus(serviceName) {
|
|
|
34
39
|
export function getMultipleServiceStatuses(serviceNames) {
|
|
35
40
|
if (serviceNames.length === 0)
|
|
36
41
|
return new Map();
|
|
37
|
-
const
|
|
38
|
-
|
|
42
|
+
for (const n of serviceNames)
|
|
43
|
+
assertServiceName(n);
|
|
44
|
+
const units = serviceNames.map(n => `${n}.service`);
|
|
45
|
+
const result = execSafe('systemctl', [
|
|
46
|
+
'show', ...units,
|
|
47
|
+
'--property=Id,ActiveState,UnitFileState,Description', '--no-pager',
|
|
48
|
+
], { timeout: 15_000 });
|
|
39
49
|
const map = new Map();
|
|
40
50
|
if (!result.stdout)
|
|
41
51
|
return map;
|
|
@@ -58,24 +68,29 @@ export function getMultipleServiceStatuses(serviceNames) {
|
|
|
58
68
|
return map;
|
|
59
69
|
}
|
|
60
70
|
export function startService(serviceName) {
|
|
61
|
-
|
|
71
|
+
assertServiceName(serviceName);
|
|
72
|
+
return execSafe('systemctl', ['start', `${serviceName}.service`], { timeout: 60_000 }).ok;
|
|
62
73
|
}
|
|
63
74
|
export function stopService(serviceName) {
|
|
64
|
-
|
|
75
|
+
assertServiceName(serviceName);
|
|
76
|
+
return execSafe('systemctl', ['stop', `${serviceName}.service`], { timeout: 60_000 }).ok;
|
|
65
77
|
}
|
|
66
78
|
export function restartService(serviceName) {
|
|
67
|
-
|
|
79
|
+
assertServiceName(serviceName);
|
|
80
|
+
return execSafe('systemctl', ['restart', `${serviceName}.service`], { timeout: 120_000 }).ok;
|
|
68
81
|
}
|
|
69
82
|
export function enableService(serviceName) {
|
|
70
|
-
|
|
83
|
+
assertServiceName(serviceName);
|
|
84
|
+
return execSafe('systemctl', ['enable', `${serviceName}.service`]).ok;
|
|
71
85
|
}
|
|
72
86
|
export function disableService(serviceName) {
|
|
73
|
-
|
|
87
|
+
assertServiceName(serviceName);
|
|
88
|
+
return execSafe('systemctl', ['disable', `${serviceName}.service`]).ok;
|
|
74
89
|
}
|
|
75
90
|
export function installServiceFile(serviceName, content) {
|
|
76
91
|
const path = `/etc/systemd/system/${serviceName}.service`;
|
|
77
92
|
writeFileSync(path, content);
|
|
78
|
-
|
|
93
|
+
execSafe('systemctl', ['daemon-reload']);
|
|
79
94
|
}
|
|
80
95
|
export function readServiceFile(serviceName) {
|
|
81
96
|
const path = `/etc/systemd/system/${serviceName}.service`;
|
|
@@ -84,7 +99,9 @@ export function readServiceFile(serviceName) {
|
|
|
84
99
|
return readFileSync(path, 'utf-8');
|
|
85
100
|
}
|
|
86
101
|
export function discoverServices() {
|
|
87
|
-
const result =
|
|
102
|
+
const result = execSafe('systemctl', [
|
|
103
|
+
'list-units', '--type=service', '--state=active', '--no-legend', '--no-pager',
|
|
104
|
+
], { timeout: 10_000 });
|
|
88
105
|
if (!result.ok)
|
|
89
106
|
return [];
|
|
90
107
|
return result.stdout.split('\n')
|