@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
package/dist/commands/secrets.js
CHANGED
|
@@ -1,16 +1,28 @@
|
|
|
1
|
-
import { writeFileSync } from 'node:fs';
|
|
1
|
+
import { writeFileSync, chmodSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { execSafe } from '../core/exec.js';
|
|
4
4
|
import { SecretsError } from '../core/errors.js';
|
|
5
5
|
import { load, findApp } from '../core/registry.js';
|
|
6
6
|
import { initVault, loadManifest, listSecrets } from '../core/secrets.js';
|
|
7
|
-
import {
|
|
7
|
+
import { enumerateSecrets, enumerateAllSecrets } from '../core/secrets-metadata.js';
|
|
8
|
+
import { setSecret, getSecret, importEnvFile, importDbSecrets, exportApp, sealFromRuntime, rotateKey, getStatus, detectDrift, } from '../core/secrets-ops.js';
|
|
8
9
|
import { restoreVaultFile } from '../core/secrets.js';
|
|
9
10
|
import { generateUnsealService } from '../templates/unseal.js';
|
|
10
11
|
import { validateApp, validateAll } from '../core/secrets-validate.js';
|
|
11
12
|
import { confirm } from '../ui/confirm.js';
|
|
13
|
+
import { prompt, promptHidden } from '../ui/prompt.js';
|
|
12
14
|
import { c, heading, table, success, error, info, warn } from '../ui/output.js';
|
|
13
|
-
|
|
15
|
+
import { performRotation, validateFormat, checkEntropy, maskNewValue, } from '../core/secrets-rotation.js';
|
|
16
|
+
import { unsealAll } from '../core/secrets-ops.js';
|
|
17
|
+
import { restartService } from '../core/systemd.js';
|
|
18
|
+
import { checkHealth } from '../core/health.js';
|
|
19
|
+
import { listSnapshots, restoreSnapshot, snapshotApp } from '../core/secrets-snapshots.js';
|
|
20
|
+
import { auditLog } from '../core/secrets-audit.js';
|
|
21
|
+
import { summariseSecrets, formatSecretsMotd, generateSecretsMotdScript } from '../core/secrets-motd.js';
|
|
22
|
+
function getDbSecretsDir() {
|
|
23
|
+
const reg = load();
|
|
24
|
+
return join(reg.infrastructure.databases.composePath, 'secrets');
|
|
25
|
+
}
|
|
14
26
|
export async function secretsCommand(args) {
|
|
15
27
|
const sub = args[0];
|
|
16
28
|
const rest = args.slice(1);
|
|
@@ -24,13 +36,18 @@ export async function secretsCommand(args) {
|
|
|
24
36
|
case 'seal': return secretsSeal(rest);
|
|
25
37
|
case 'unseal': return secretsUnseal();
|
|
26
38
|
case 'rotate': return secretsRotate(rest);
|
|
39
|
+
case 'rotate-key': return secretsRotateKey(rest);
|
|
40
|
+
case 'ages': return secretsAges(rest);
|
|
27
41
|
case 'validate': return secretsValidate(rest);
|
|
28
42
|
case 'status': return secretsStatus(rest);
|
|
29
43
|
case 'drift': return secretsDrift(rest);
|
|
30
44
|
case 'restore': return secretsRestore(rest);
|
|
45
|
+
case 'rollback': return secretsRollback(rest);
|
|
46
|
+
case 'snapshots': return secretsSnapshots(rest);
|
|
47
|
+
case 'motd-init': return secretsMotdInit();
|
|
31
48
|
case 'seal-runtime': return secretsSeal(rest);
|
|
32
49
|
default:
|
|
33
|
-
error('Usage: fleet secrets <init|list|set|get|import|export|seal|unseal|rotate|validate|status|drift|restore>');
|
|
50
|
+
error('Usage: fleet secrets <init|list|set|get|import|export|seal|unseal|rotate|rotate-key|ages|rollback|snapshots|validate|status|drift|restore>');
|
|
34
51
|
process.exit(1);
|
|
35
52
|
}
|
|
36
53
|
}
|
|
@@ -41,8 +58,8 @@ function secretsInit() {
|
|
|
41
58
|
const serviceContent = generateUnsealService();
|
|
42
59
|
const servicePath = '/etc/systemd/system/fleet-unseal.service';
|
|
43
60
|
writeFileSync(servicePath, serviceContent);
|
|
44
|
-
|
|
45
|
-
|
|
61
|
+
execSafe('systemctl', ['daemon-reload']);
|
|
62
|
+
execSafe('systemctl', ['enable', 'fleet-unseal']);
|
|
46
63
|
success('Installed fleet-unseal.service');
|
|
47
64
|
}
|
|
48
65
|
function secretsList(args) {
|
|
@@ -76,14 +93,43 @@ function secretsList(args) {
|
|
|
76
93
|
table(['APP', 'TYPE', 'KEYS', 'LAST SEALED'], rows);
|
|
77
94
|
process.stdout.write('\n');
|
|
78
95
|
}
|
|
79
|
-
function secretsSet(args) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
96
|
+
async function secretsSet(args) {
|
|
97
|
+
// Strip flags. Positional layout: <app> <KEY>. Value comes from interactive
|
|
98
|
+
// prompt (default) or stdin (--from-stdin). The legacy 'value as argv' form
|
|
99
|
+
// is REJECTED — argv is world-readable via /proc/<pid>/cmdline + lands in
|
|
100
|
+
// shell history, the exact leak class this branch was built to prevent.
|
|
101
|
+
const fromStdin = args.includes('--from-stdin');
|
|
102
|
+
const allowWeak = args.includes('--allow-weak');
|
|
103
|
+
const positional = args.filter(a => !a.startsWith('-'));
|
|
104
|
+
const [app, key, ...rest] = positional;
|
|
105
|
+
if (!app || !key) {
|
|
106
|
+
error('Usage: fleet secrets set <app> <KEY> [--from-stdin] [--allow-weak]');
|
|
107
|
+
error(' (interactive paste is the default — value is NEVER passed in argv)');
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
if (rest.length > 0) {
|
|
111
|
+
error('Refusing to take a secret value from argv (visible in /proc/<pid>/cmdline + shell history).');
|
|
112
|
+
error('Use the interactive prompt or pipe via --from-stdin:');
|
|
113
|
+
error(` fleet secrets set ${app} ${key} # interactive`);
|
|
114
|
+
error(` printf '%s' "$NEW_VALUE" | fleet secrets set ${app} ${key} --from-stdin`);
|
|
84
115
|
process.exit(1);
|
|
85
116
|
}
|
|
86
|
-
|
|
117
|
+
let value;
|
|
118
|
+
if (fromStdin) {
|
|
119
|
+
const chunks = [];
|
|
120
|
+
process.stdin.setEncoding('utf8');
|
|
121
|
+
for await (const chunk of process.stdin)
|
|
122
|
+
chunks.push(chunk);
|
|
123
|
+
value = chunks.join('').replace(/\r?\n$/, ''); // strip trailing newline
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
value = await promptHidden(`Paste new value for ${key} (input hidden)`);
|
|
127
|
+
}
|
|
128
|
+
if (!value) {
|
|
129
|
+
error('Empty value — aborting');
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
setSecret(app, key, value, { allowWeak });
|
|
87
133
|
success(`Set ${key} for ${app}`);
|
|
88
134
|
}
|
|
89
135
|
function secretsGet(args) {
|
|
@@ -107,7 +153,7 @@ function secretsImport(args) {
|
|
|
107
153
|
process.exit(1);
|
|
108
154
|
}
|
|
109
155
|
if (app === 'docker-databases') {
|
|
110
|
-
const dir = pathArg ||
|
|
156
|
+
const dir = pathArg || getDbSecretsDir();
|
|
111
157
|
const count = importDbSecrets(app, dir);
|
|
112
158
|
success(`Imported ${count} secret files from ${dir}`);
|
|
113
159
|
return;
|
|
@@ -148,9 +194,9 @@ function secretsSeal(args) {
|
|
|
148
194
|
success(`Sealed ${a}`);
|
|
149
195
|
}
|
|
150
196
|
}
|
|
151
|
-
async function
|
|
197
|
+
async function secretsRotateKey(args) {
|
|
152
198
|
const yes = args.includes('-y') || args.includes('--yes');
|
|
153
|
-
if (!yes && !await confirm('Rotate
|
|
199
|
+
if (!yes && !await confirm('Rotate AGE master key? This will re-encrypt all secrets.')) {
|
|
154
200
|
info('Cancelled');
|
|
155
201
|
return;
|
|
156
202
|
}
|
|
@@ -161,6 +207,302 @@ async function secretsRotate(args) {
|
|
|
161
207
|
info(`Re-encrypted ${result.appsRotated.length} apps`);
|
|
162
208
|
warn('Run "fleet secrets unseal" to update runtime secrets');
|
|
163
209
|
}
|
|
210
|
+
function parseRotateArgs(args) {
|
|
211
|
+
const opts = {
|
|
212
|
+
dryRun: args.includes('--dry-run'),
|
|
213
|
+
noRestart: args.includes('--no-restart'),
|
|
214
|
+
dataMigrated: args.includes('--data-migrated'),
|
|
215
|
+
};
|
|
216
|
+
const positional = args.filter(a => !a.startsWith('-'));
|
|
217
|
+
return { app: positional[0], key: positional[1], opts };
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Walk one secret through the interactive rotation flow. Returns true if
|
|
221
|
+
* a rotation was performed (regardless of success/rollback), false on skip.
|
|
222
|
+
*/
|
|
223
|
+
async function rotateOneInteractive(app, secret, opts) {
|
|
224
|
+
const provider = secret.provider;
|
|
225
|
+
const sensTag = provider
|
|
226
|
+
? { critical: c.red, high: c.yellow, medium: c.blue, low: c.dim }[provider.sensitivity] + provider.sensitivity + c.reset
|
|
227
|
+
: `${c.dim}unclassified${c.reset}`;
|
|
228
|
+
process.stdout.write(`\n${c.bold}━━━ ${secret.name} ━━━${c.reset}\n`);
|
|
229
|
+
info(`Current: ${c.dim}${secret.maskedValue}${c.reset} age: ${secret.ageDays ?? '?'}d sens: ${sensTag}`);
|
|
230
|
+
if (provider) {
|
|
231
|
+
info(`Provider: ${provider.name}`);
|
|
232
|
+
info(`Strategy: ${provider.strategy}`);
|
|
233
|
+
if (provider.url)
|
|
234
|
+
info(`Regen URL: ${c.cyan}${provider.url}${c.reset}`);
|
|
235
|
+
}
|
|
236
|
+
const action = await prompt(' [r]otate / [s]kip / [q]uit', 's');
|
|
237
|
+
const a = action.toLowerCase().slice(0, 1);
|
|
238
|
+
if (a === 'q') {
|
|
239
|
+
info('Quitting rotation walkthrough.');
|
|
240
|
+
process.exit(0);
|
|
241
|
+
}
|
|
242
|
+
if (a !== 'r') {
|
|
243
|
+
info(`Skipped ${secret.name}`);
|
|
244
|
+
return { acted: false, succeeded: false };
|
|
245
|
+
}
|
|
246
|
+
// Strategy gates BEFORE asking for a value — saves user effort.
|
|
247
|
+
if (provider?.strategy === 'user-issued') {
|
|
248
|
+
error(`${secret.name} is user-issued. Rotate per-user inside your app, not here.`);
|
|
249
|
+
return { acted: false, succeeded: false };
|
|
250
|
+
}
|
|
251
|
+
if (provider?.strategy === 'at-rest-key' && !opts.dataMigrated) {
|
|
252
|
+
warn(`${secret.name} encrypts data at rest.`);
|
|
253
|
+
warn('Re-encrypt your data first, then re-run with --data-migrated');
|
|
254
|
+
return { acted: false, succeeded: false };
|
|
255
|
+
}
|
|
256
|
+
if (provider?.strategy === 'dual-mode') {
|
|
257
|
+
warn(`Dual-mode rotation: old value will be kept as ${secret.name}_PREVIOUS for the grace period.`);
|
|
258
|
+
warn('Your app MUST read both values for verification, otherwise existing tokens become invalid.');
|
|
259
|
+
if (!await confirm('Has your app been updated to read the _PREVIOUS variant?', false)) {
|
|
260
|
+
info('Skipping — update your app first, then re-run.');
|
|
261
|
+
return { acted: false, succeeded: false };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (provider?.instructions) {
|
|
265
|
+
process.stdout.write(`\n${c.bold}Steps:${c.reset}\n`);
|
|
266
|
+
for (const line of provider.instructions.split('\n'))
|
|
267
|
+
process.stdout.write(` ${line}\n`);
|
|
268
|
+
}
|
|
269
|
+
let newValue;
|
|
270
|
+
while (true) {
|
|
271
|
+
newValue = await promptHidden(`Paste new ${secret.name} (input hidden)`);
|
|
272
|
+
if (!newValue) {
|
|
273
|
+
info('Empty value — skipping');
|
|
274
|
+
return { acted: false, succeeded: false };
|
|
275
|
+
}
|
|
276
|
+
const formatErr = validateFormat(newValue, provider);
|
|
277
|
+
const entropyErr = checkEntropy(newValue);
|
|
278
|
+
if (formatErr) {
|
|
279
|
+
error(formatErr);
|
|
280
|
+
if (!await confirm('Try again?', true))
|
|
281
|
+
return { acted: false, succeeded: false };
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (entropyErr) {
|
|
285
|
+
error(entropyErr);
|
|
286
|
+
if (!await confirm('Try again?', true))
|
|
287
|
+
return { acted: false, succeeded: false };
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
info(`New value: ${maskNewValue(newValue)}`);
|
|
293
|
+
if (!await confirm('Apply rotation?', false)) {
|
|
294
|
+
info('Cancelled');
|
|
295
|
+
return { acted: false, succeeded: false };
|
|
296
|
+
}
|
|
297
|
+
const result = performRotation(app, secret.name, newValue, {
|
|
298
|
+
dryRun: opts.dryRun,
|
|
299
|
+
dataMigrated: opts.dataMigrated,
|
|
300
|
+
});
|
|
301
|
+
if (result.rolledBack) {
|
|
302
|
+
error(`${secret.name}: rotation FAILED — auto-rolled back. Reason: ${result.reason}`);
|
|
303
|
+
return { acted: true, succeeded: false };
|
|
304
|
+
}
|
|
305
|
+
if (opts.dryRun) {
|
|
306
|
+
success(`${secret.name}: dry-run — vault NOT modified`);
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
success(`${secret.name}: rotated (snapshot: ${result.snapshot.split('/').pop()})`);
|
|
310
|
+
}
|
|
311
|
+
return { acted: true, succeeded: true };
|
|
312
|
+
}
|
|
313
|
+
async function secretsRotate(args) {
|
|
314
|
+
const { app, key, opts } = parseRotateArgs(args);
|
|
315
|
+
if (!app) {
|
|
316
|
+
error('Usage: fleet secrets rotate <app> [<KEY>] [--dry-run] [--data-migrated] [--no-restart]');
|
|
317
|
+
error(' fleet secrets rotate-key (legacy: rotate the AGE master key)');
|
|
318
|
+
process.exit(1);
|
|
319
|
+
}
|
|
320
|
+
const manifest = loadManifest();
|
|
321
|
+
if (!manifest.apps[app]) {
|
|
322
|
+
error(`No app in vault: ${app}`);
|
|
323
|
+
process.exit(1);
|
|
324
|
+
}
|
|
325
|
+
let secrets = enumerateSecrets(app);
|
|
326
|
+
if (key) {
|
|
327
|
+
secrets = secrets.filter(s => s.name === key);
|
|
328
|
+
if (secrets.length === 0) {
|
|
329
|
+
error(`No secret named ${key} in ${app}`);
|
|
330
|
+
process.exit(1);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
heading(`Rotate ${key ? `${key} in ${app}` : `secrets in ${app}`}${opts.dryRun ? ' [DRY-RUN]' : ''}`);
|
|
334
|
+
info(`${secrets.length} secret(s) to walk through. Empty answer = skip; "q" = quit.`);
|
|
335
|
+
let acted = 0;
|
|
336
|
+
let succeeded = 0;
|
|
337
|
+
for (const s of secrets) {
|
|
338
|
+
const r = await rotateOneInteractive(app, s, opts);
|
|
339
|
+
if (r.acted)
|
|
340
|
+
acted++;
|
|
341
|
+
if (r.succeeded)
|
|
342
|
+
succeeded++;
|
|
343
|
+
}
|
|
344
|
+
process.stdout.write('\n');
|
|
345
|
+
if (acted === 0) {
|
|
346
|
+
info('No rotations performed.');
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (opts.dryRun) {
|
|
350
|
+
success(`Dry-run complete: ${succeeded}/${acted} would-rotate (no changes made)`);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
// Apply runtime: re-unseal so /run/fleet-secrets has the new values.
|
|
354
|
+
info('Re-unsealing vault to /run/fleet-secrets...');
|
|
355
|
+
unsealAll();
|
|
356
|
+
success('Runtime updated');
|
|
357
|
+
// Restart + health gate (unless --no-restart).
|
|
358
|
+
if (opts.noRestart) {
|
|
359
|
+
warn('Skipping restart (--no-restart). Restart manually with `fleet restart ' + app + '`');
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const reg = load();
|
|
363
|
+
const appEntry = findApp(reg, app);
|
|
364
|
+
if (!appEntry) {
|
|
365
|
+
warn(`App ${app} not in registry — skipping restart + health gate.`);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
info(`Restarting ${app}...`);
|
|
369
|
+
if (!restartService(appEntry.serviceName)) {
|
|
370
|
+
error(`Restart failed for ${app}. Check logs.`);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
success(`${app} restarted`);
|
|
374
|
+
// Brief health gate.
|
|
375
|
+
info('Waiting 5s then checking health...');
|
|
376
|
+
await new Promise(r => setTimeout(r, 5000));
|
|
377
|
+
try {
|
|
378
|
+
const h = checkHealth(appEntry);
|
|
379
|
+
if (h.containers.every(ct => ct.running && (ct.health === 'healthy' || ct.health === 'none' || ct.health === ''))) {
|
|
380
|
+
success(`${app} healthy after rotation`);
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
warn(`${app} health: not all containers happy. Run: fleet health ${app}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
catch (e) {
|
|
387
|
+
warn(`Could not check health: ${e instanceof Error ? e.message : String(e)}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function parseAgesOpts(args) {
|
|
391
|
+
const opts = {
|
|
392
|
+
json: args.includes('--json'),
|
|
393
|
+
staleOnly: args.includes('--stale-only') || args.includes('--stale'),
|
|
394
|
+
};
|
|
395
|
+
const app = args.find(a => !a.startsWith('-'));
|
|
396
|
+
return { app, opts };
|
|
397
|
+
}
|
|
398
|
+
function statusLabel(s) {
|
|
399
|
+
if (!s.provider)
|
|
400
|
+
return `${c.dim}unknown${c.reset}`;
|
|
401
|
+
if (s.stale)
|
|
402
|
+
return `${c.red}${c.bold}STALE${c.reset}`;
|
|
403
|
+
if (s.ageDays != null) {
|
|
404
|
+
const threshold = s.provider.rotationFrequencyDays * 0.8;
|
|
405
|
+
if (s.ageDays >= threshold)
|
|
406
|
+
return `${c.yellow}aging${c.reset}`;
|
|
407
|
+
}
|
|
408
|
+
return `${c.green}fresh${c.reset}`;
|
|
409
|
+
}
|
|
410
|
+
function ageString(days) {
|
|
411
|
+
if (days == null)
|
|
412
|
+
return '?';
|
|
413
|
+
if (days === 0)
|
|
414
|
+
return 'today';
|
|
415
|
+
if (days === 1)
|
|
416
|
+
return '1 day';
|
|
417
|
+
return `${days} days`;
|
|
418
|
+
}
|
|
419
|
+
function secretsAges(args) {
|
|
420
|
+
// --motd → short summary suitable for /etc/update-motd.d/
|
|
421
|
+
if (args.includes('--motd')) {
|
|
422
|
+
const summary = summariseSecrets();
|
|
423
|
+
process.stdout.write(formatSecretsMotd(summary) + '\n');
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const { app, opts } = parseAgesOpts(args);
|
|
427
|
+
let secrets;
|
|
428
|
+
if (app) {
|
|
429
|
+
secrets = enumerateSecrets(app).map(s => ({ app, ...s }));
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
secrets = enumerateAllSecrets();
|
|
433
|
+
}
|
|
434
|
+
if (opts.staleOnly) {
|
|
435
|
+
secrets = secrets.filter(s => s.stale);
|
|
436
|
+
}
|
|
437
|
+
if (opts.json) {
|
|
438
|
+
// Strip the `provider` ProviderDef object (RegExp inside) for JSON-safety;
|
|
439
|
+
// expose just its id, sensitivity, frequency.
|
|
440
|
+
const out = secrets.map(s => ({
|
|
441
|
+
app: s.app,
|
|
442
|
+
name: s.name,
|
|
443
|
+
lastRotated: s.lastRotated,
|
|
444
|
+
ageDays: s.ageDays,
|
|
445
|
+
stale: s.stale,
|
|
446
|
+
provider: s.provider
|
|
447
|
+
? {
|
|
448
|
+
id: s.provider.id,
|
|
449
|
+
name: s.provider.name,
|
|
450
|
+
sensitivity: s.provider.sensitivity,
|
|
451
|
+
rotationFrequencyDays: s.provider.rotationFrequencyDays,
|
|
452
|
+
strategy: s.provider.strategy,
|
|
453
|
+
}
|
|
454
|
+
: null,
|
|
455
|
+
}));
|
|
456
|
+
process.stdout.write(JSON.stringify(out, null, 2) + '\n');
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (secrets.length === 0) {
|
|
460
|
+
if (opts.staleOnly) {
|
|
461
|
+
success('No stale secrets — everything is within rotation frequency');
|
|
462
|
+
}
|
|
463
|
+
else if (app) {
|
|
464
|
+
warn(`No secrets in ${app}`);
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
warn('No secrets in vault');
|
|
468
|
+
}
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
// Sort: stale first (by sensitivity desc), then aging, then fresh.
|
|
472
|
+
const sensRank = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
473
|
+
secrets.sort((a, b) => {
|
|
474
|
+
if (a.stale !== b.stale)
|
|
475
|
+
return a.stale ? -1 : 1;
|
|
476
|
+
const sa = sensRank[a.provider?.sensitivity ?? 'low'] ?? 99;
|
|
477
|
+
const sb = sensRank[b.provider?.sensitivity ?? 'low'] ?? 99;
|
|
478
|
+
if (sa !== sb)
|
|
479
|
+
return sa - sb;
|
|
480
|
+
if ((b.ageDays ?? 0) !== (a.ageDays ?? 0))
|
|
481
|
+
return (b.ageDays ?? 0) - (a.ageDays ?? 0);
|
|
482
|
+
return a.app.localeCompare(b.app) || a.name.localeCompare(b.name);
|
|
483
|
+
});
|
|
484
|
+
const title = app ? `Secret ages: ${app}` : `Secret ages (${secrets.length} secrets)`;
|
|
485
|
+
heading(title);
|
|
486
|
+
const cols = app
|
|
487
|
+
? ['SECRET', 'AGE', 'ROTATE EVERY', 'PROVIDER', 'SENS', 'STATUS']
|
|
488
|
+
: ['APP', 'SECRET', 'AGE', 'ROTATE EVERY', 'PROVIDER', 'SENS', 'STATUS'];
|
|
489
|
+
const rows = secrets.map(s => {
|
|
490
|
+
const provider = s.provider?.name ?? `${c.dim}—${c.reset}`;
|
|
491
|
+
const freq = s.provider ? `${s.provider.rotationFrequencyDays}d` : '—';
|
|
492
|
+
const sens = s.provider?.sensitivity ?? '—';
|
|
493
|
+
const ageCol = ageString(s.ageDays);
|
|
494
|
+
const status = statusLabel(s);
|
|
495
|
+
return app
|
|
496
|
+
? [s.name, ageCol, freq, provider, sens, status]
|
|
497
|
+
: [`${c.bold}${s.app}${c.reset}`, s.name, ageCol, freq, provider, sens, status];
|
|
498
|
+
});
|
|
499
|
+
table(cols, rows);
|
|
500
|
+
process.stdout.write('\n');
|
|
501
|
+
const staleCount = secrets.filter(s => s.stale).length;
|
|
502
|
+
if (staleCount > 0) {
|
|
503
|
+
warn(`${staleCount} secret(s) need rotation. Run: fleet secrets rotate <app>`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
164
506
|
function secretsValidate(args) {
|
|
165
507
|
const json = args.includes('--json');
|
|
166
508
|
const appName = args.find(a => !a.startsWith('-'));
|
|
@@ -252,6 +594,97 @@ function secretsDrift(args) {
|
|
|
252
594
|
success('No drift detected');
|
|
253
595
|
}
|
|
254
596
|
}
|
|
597
|
+
function secretsMotdInit() {
|
|
598
|
+
const motdPath = '/etc/update-motd.d/99-fleet-secrets';
|
|
599
|
+
const script = generateSecretsMotdScript();
|
|
600
|
+
try {
|
|
601
|
+
writeFileSync(motdPath, script);
|
|
602
|
+
chmodSync(motdPath, 0o755);
|
|
603
|
+
success(`Installed MOTD script: ${motdPath}`);
|
|
604
|
+
info('Will print on next shell login.');
|
|
605
|
+
}
|
|
606
|
+
catch (err) {
|
|
607
|
+
error(`Failed to install MOTD: ${err instanceof Error ? err.message : String(err)}`);
|
|
608
|
+
error('Re-run with sudo if permission denied.');
|
|
609
|
+
process.exit(1);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
function secretsSnapshots(args) {
|
|
613
|
+
const json = args.includes('--json');
|
|
614
|
+
const app = args.find(a => !a.startsWith('-'));
|
|
615
|
+
if (!app) {
|
|
616
|
+
error('Usage: fleet secrets snapshots <app>');
|
|
617
|
+
process.exit(1);
|
|
618
|
+
}
|
|
619
|
+
const snaps = listSnapshots(app);
|
|
620
|
+
if (json) {
|
|
621
|
+
process.stdout.write(JSON.stringify(snaps, null, 2) + '\n');
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
if (snaps.length === 0) {
|
|
625
|
+
info(`No snapshots for ${app}`);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
heading(`Snapshots for ${app} (${snaps.length})`);
|
|
629
|
+
const rows = snaps.map(s => [
|
|
630
|
+
s.timestamp,
|
|
631
|
+
`${(s.sizeBytes / 1024).toFixed(1)}K`,
|
|
632
|
+
s.path.split('/').slice(-2).join('/'),
|
|
633
|
+
]);
|
|
634
|
+
table(['TIMESTAMP', 'SIZE', 'PATH'], rows);
|
|
635
|
+
process.stdout.write('\n');
|
|
636
|
+
info(`Restore the newest with: fleet secrets rollback ${app}`);
|
|
637
|
+
info(`Restore a specific one: fleet secrets rollback ${app} --to <TIMESTAMP>`);
|
|
638
|
+
}
|
|
639
|
+
async function secretsRollback(args) {
|
|
640
|
+
const yes = args.includes('-y') || args.includes('--yes');
|
|
641
|
+
const toIdx = args.indexOf('--to');
|
|
642
|
+
const to = toIdx >= 0 ? args[toIdx + 1] : undefined;
|
|
643
|
+
// Bug fix: previous logic excluded args[toIdx + 1] always (even when toIdx
|
|
644
|
+
// was -1, i.e. no --to flag), which silently skipped args[0] and grabbed
|
|
645
|
+
// the wrong positional. Only exclude the timestamp slot if --to was given.
|
|
646
|
+
const app = args.find((a, i) => !a.startsWith('-') && (toIdx < 0 || i !== toIdx + 1));
|
|
647
|
+
if (!app) {
|
|
648
|
+
error('Usage: fleet secrets rollback <app> [--to <TIMESTAMP>]');
|
|
649
|
+
error(' (use `fleet secrets snapshots <app>` to list available)');
|
|
650
|
+
process.exit(1);
|
|
651
|
+
}
|
|
652
|
+
const snaps = listSnapshots(app);
|
|
653
|
+
if (snaps.length === 0) {
|
|
654
|
+
error(`No snapshots for ${app}`);
|
|
655
|
+
process.exit(1);
|
|
656
|
+
}
|
|
657
|
+
const target = to ? snaps.find(s => s.timestamp === to) : snaps[0];
|
|
658
|
+
if (!target) {
|
|
659
|
+
error(`Snapshot not found for ${app}: ${to}`);
|
|
660
|
+
process.exit(1);
|
|
661
|
+
}
|
|
662
|
+
warn(`About to restore ${app} from snapshot ${target.timestamp}`);
|
|
663
|
+
warn('This will OVERWRITE the current vault file.');
|
|
664
|
+
if (!yes && !await confirm('Proceed?', false)) {
|
|
665
|
+
info('Cancelled');
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
// Snapshot the CURRENT state before overwriting (so we can roll the rollback back too).
|
|
669
|
+
const safety = snapshotApp(app);
|
|
670
|
+
info(`Pre-rollback safety snapshot: ${safety.split('/').pop()}`);
|
|
671
|
+
restoreSnapshot(app, target.timestamp);
|
|
672
|
+
auditLog({ op: 'rollback', app, ok: true, details: `to ${target.timestamp}` });
|
|
673
|
+
success(`Restored ${app} from ${target.timestamp}`);
|
|
674
|
+
info('Re-unsealing vault...');
|
|
675
|
+
unsealAll();
|
|
676
|
+
const reg = load();
|
|
677
|
+
const appEntry = findApp(reg, app);
|
|
678
|
+
if (appEntry) {
|
|
679
|
+
info(`Restarting ${app}...`);
|
|
680
|
+
if (restartService(appEntry.serviceName)) {
|
|
681
|
+
success(`${app} restarted`);
|
|
682
|
+
}
|
|
683
|
+
else {
|
|
684
|
+
warn(`Restart failed — restart manually with: fleet restart ${app}`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
255
688
|
function secretsRestore(args) {
|
|
256
689
|
const app = args.find(a => !a.startsWith('-'));
|
|
257
690
|
if (!app) {
|
package/dist/commands/status.js
CHANGED
|
@@ -16,7 +16,10 @@ export function getStatusData() {
|
|
|
16
16
|
appContainers.every(ct => ct.health === 'healthy' || ct.health === 'none');
|
|
17
17
|
const allRunning = appContainers.every(ct => ct.status.startsWith('Up'));
|
|
18
18
|
let health;
|
|
19
|
-
if (
|
|
19
|
+
if (app.frozenAt) {
|
|
20
|
+
health = 'frozen';
|
|
21
|
+
}
|
|
22
|
+
else if (svc && !svc.active) {
|
|
20
23
|
// systemd says service is not active — it's down
|
|
21
24
|
health = 'down';
|
|
22
25
|
}
|
|
@@ -55,8 +58,9 @@ export function statusCommand(args) {
|
|
|
55
58
|
info(`${data.totalApps} apps | ${c.green}${data.healthy} healthy${c.reset} | ${data.unhealthy > 0 ? c.red : c.dim}${data.unhealthy} unhealthy${c.reset}`);
|
|
56
59
|
const rows = data.apps.map(app => {
|
|
57
60
|
const healthIcon = app.health === 'healthy' ? icon.ok
|
|
58
|
-
: app.health === '
|
|
59
|
-
: icon.
|
|
61
|
+
: app.health === 'frozen' ? icon.info
|
|
62
|
+
: app.health === 'degraded' ? icon.warn
|
|
63
|
+
: icon.err;
|
|
60
64
|
const systemdColor = app.systemd === 'active' ? c.green : c.red;
|
|
61
65
|
return [
|
|
62
66
|
`${c.bold}${app.name}${c.reset}`,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function watchdogCommand(
|
|
1
|
+
export declare function watchdogCommand(args: string[]): Promise<void>;
|
|
@@ -1,37 +1,9 @@
|
|
|
1
|
-
import { readFileSync
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { load } from '../core/registry.js';
|
|
3
3
|
import { checkAllHealth } from '../core/health.js';
|
|
4
4
|
import { getServiceStatus } from '../core/systemd.js';
|
|
5
|
+
import { loadNotifyConfig, sendNotification } from '../core/notify.js';
|
|
5
6
|
import { error, success, warn } from '../ui/output.js';
|
|
6
|
-
const TELEGRAM_CONFIG_PATH = '/etc/fleet/telegram.json';
|
|
7
|
-
function loadTelegramConfig() {
|
|
8
|
-
if (!existsSync(TELEGRAM_CONFIG_PATH))
|
|
9
|
-
return null;
|
|
10
|
-
try {
|
|
11
|
-
return JSON.parse(readFileSync(TELEGRAM_CONFIG_PATH, 'utf-8'));
|
|
12
|
-
}
|
|
13
|
-
catch {
|
|
14
|
-
return null;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
async function sendTelegram(config, message) {
|
|
18
|
-
try {
|
|
19
|
-
const url = `https://api.telegram.org/bot${config.botToken}/sendMessage`;
|
|
20
|
-
const res = await fetch(url, {
|
|
21
|
-
method: 'POST',
|
|
22
|
-
headers: { 'Content-Type': 'application/json' },
|
|
23
|
-
body: JSON.stringify({
|
|
24
|
-
chat_id: config.chatId,
|
|
25
|
-
text: message,
|
|
26
|
-
parse_mode: 'HTML',
|
|
27
|
-
}),
|
|
28
|
-
});
|
|
29
|
-
return res.ok;
|
|
30
|
-
}
|
|
31
|
-
catch {
|
|
32
|
-
return false;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
7
|
function getHostname() {
|
|
36
8
|
try {
|
|
37
9
|
return readFileSync('/etc/hostname', 'utf-8').trim();
|
|
@@ -40,7 +12,8 @@ function getHostname() {
|
|
|
40
12
|
return 'unknown';
|
|
41
13
|
}
|
|
42
14
|
}
|
|
43
|
-
export async function watchdogCommand(
|
|
15
|
+
export async function watchdogCommand(args) {
|
|
16
|
+
const isMotd = args.includes('--motd');
|
|
44
17
|
const failures = [];
|
|
45
18
|
const hostname = getHostname();
|
|
46
19
|
// check docker-databases systemd status
|
|
@@ -76,25 +49,28 @@ export async function watchdogCommand(_args) {
|
|
|
76
49
|
for (const f of failures) {
|
|
77
50
|
error(` ${f}`);
|
|
78
51
|
}
|
|
79
|
-
//
|
|
80
|
-
|
|
52
|
+
// MOTD mode: display only, no alerts, always exit 0
|
|
53
|
+
if (isMotd)
|
|
54
|
+
return;
|
|
55
|
+
// send alert via notify adapters
|
|
56
|
+
const config = loadNotifyConfig();
|
|
81
57
|
if (!config) {
|
|
82
|
-
warn('No
|
|
58
|
+
warn('No notify config at /etc/fleet/notify.json — alert not sent');
|
|
83
59
|
process.exit(1);
|
|
84
60
|
}
|
|
85
61
|
const message = [
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
62
|
+
`fleet watchdog alert`,
|
|
63
|
+
`host: ${hostname}`,
|
|
64
|
+
`failures: ${failures.length}`,
|
|
89
65
|
'',
|
|
90
66
|
...failures.map(f => `- ${f}`),
|
|
91
67
|
].join('\n');
|
|
92
|
-
const sent = await
|
|
68
|
+
const sent = await sendNotification(config, message);
|
|
93
69
|
if (sent) {
|
|
94
|
-
success('
|
|
70
|
+
success('Alert sent');
|
|
95
71
|
}
|
|
96
72
|
else {
|
|
97
|
-
error('Failed to send
|
|
73
|
+
error('Failed to send alert');
|
|
98
74
|
}
|
|
99
75
|
process.exit(1);
|
|
100
76
|
}
|