@matthesketh/fleet 1.6.0 → 1.7.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/dist/cli.js +3 -0
- package/dist/commands/guard.d.ts +1 -0
- package/dist/commands/guard.js +144 -0
- package/dist/core/routines/schema.d.ts +8 -8
- package/dist/core/routines/store.d.ts +16 -16
- package/package.json +2 -1
- package/scripts/guard/cert-expiry-watch +109 -0
- package/scripts/guard/cf-audit-monitor +169 -0
- package/scripts/guard/cf-snapshot +124 -0
- package/scripts/guard/cron.d-cf-protect +11 -0
- package/scripts/guard/dns-drift-watch +138 -0
- package/scripts/guard/fleet-guard +282 -0
- package/scripts/guard/fleet-guard-execute +197 -0
- package/scripts/guard/notify +108 -0
package/dist/cli.js
CHANGED
|
@@ -21,6 +21,7 @@ import { watchdogCommand } from './commands/watchdog.js';
|
|
|
21
21
|
import { installMcpCommand } from './commands/install-mcp.js';
|
|
22
22
|
import { patchSystemdCommand } from './commands/patch-systemd.js';
|
|
23
23
|
import { freezeCommand, unfreezeCommand } from './commands/freeze.js';
|
|
24
|
+
import { guardCommand } from './commands/guard.js';
|
|
24
25
|
import { bootStartCommand } from './commands/boot-start.js';
|
|
25
26
|
import { rollbackCommand } from './commands/rollback.js';
|
|
26
27
|
import { routineRunCommand } from './commands/routine-run.js';
|
|
@@ -89,6 +90,7 @@ Commands:
|
|
|
89
90
|
freeze <app> Freeze a crash-looping service (stop + disable)
|
|
90
91
|
rollback <app> Roll back app to previous image
|
|
91
92
|
unfreeze <app> Unfreeze and restart a frozen service
|
|
93
|
+
guard <subcommand> Cloudflare protection layer (install/status/approve/reject/...)
|
|
92
94
|
|
|
93
95
|
Global flags:
|
|
94
96
|
--json Output as JSON
|
|
@@ -146,6 +148,7 @@ export async function run(argv) {
|
|
|
146
148
|
case 'freeze': return freezeCommand(rest);
|
|
147
149
|
case 'rollback': return rollbackCommand(rest);
|
|
148
150
|
case 'unfreeze': return unfreezeCommand(rest);
|
|
151
|
+
case 'guard': return guardCommand(rest);
|
|
149
152
|
case 'mcp': return startMcpServer();
|
|
150
153
|
case 'tui':
|
|
151
154
|
case 'dashboard': {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function guardCommand(args: string[]): void;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { error, info, success } from '../ui/output.js';
|
|
6
|
+
const SCRIPTS = [
|
|
7
|
+
{ name: 'notify', mode: 0o700 },
|
|
8
|
+
{ name: 'fleet-guard', mode: 0o750, group: 'fleet-guard' },
|
|
9
|
+
{ name: 'fleet-guard-execute', mode: 0o750, group: 'fleet-guard' },
|
|
10
|
+
{ name: 'cf-audit-monitor', mode: 0o700 },
|
|
11
|
+
{ name: 'cf-snapshot', mode: 0o700 },
|
|
12
|
+
{ name: 'dns-drift-watch', mode: 0o750, group: 'fleet-guard' },
|
|
13
|
+
{ name: 'cert-expiry-watch', mode: 0o750, group: 'fleet-guard' },
|
|
14
|
+
];
|
|
15
|
+
const TARGET_BIN = '/usr/local/sbin';
|
|
16
|
+
const STATE_DIR = '/var/lib/fleet-guard';
|
|
17
|
+
const LOG_DIR = '/var/log/fleet-guard';
|
|
18
|
+
const SNAP_DIR = '/var/lib/cf-snapshots';
|
|
19
|
+
const CRON_TARGET = '/etc/cron.d/cf-protect';
|
|
20
|
+
function scriptsDir() {
|
|
21
|
+
// dist/commands/guard.js -> ../../scripts/guard relative to compiled file
|
|
22
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
return join(here, '..', '..', 'scripts', 'guard');
|
|
24
|
+
}
|
|
25
|
+
function requireRoot() {
|
|
26
|
+
if (process.getuid && process.getuid() !== 0) {
|
|
27
|
+
throw new Error('this command needs root. try: sudo fleet guard install');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function run(cmd, args) {
|
|
31
|
+
const r = spawnSync(cmd, args, { stdio: 'inherit' });
|
|
32
|
+
if (r.status !== 0)
|
|
33
|
+
throw new Error(`${cmd} ${args.join(' ')} failed`);
|
|
34
|
+
}
|
|
35
|
+
function ensureUser() {
|
|
36
|
+
const r = spawnSync('id', ['fleet-guard'], { stdio: 'ignore' });
|
|
37
|
+
if (r.status === 0)
|
|
38
|
+
return;
|
|
39
|
+
run('useradd', ['--system', '--no-create-home', '--shell', '/usr/sbin/nologin', 'fleet-guard']);
|
|
40
|
+
info('created system user fleet-guard');
|
|
41
|
+
}
|
|
42
|
+
function ensureDir(path, mode, group) {
|
|
43
|
+
if (!existsSync(path))
|
|
44
|
+
mkdirSync(path, { recursive: true });
|
|
45
|
+
chmodSync(path, mode);
|
|
46
|
+
if (group)
|
|
47
|
+
run('chgrp', ['-R', group, path]);
|
|
48
|
+
}
|
|
49
|
+
function installScripts() {
|
|
50
|
+
const src = scriptsDir();
|
|
51
|
+
if (!existsSync(src)) {
|
|
52
|
+
throw new Error(`scripts not bundled at ${src} — broken install`);
|
|
53
|
+
}
|
|
54
|
+
for (const s of SCRIPTS) {
|
|
55
|
+
const from = join(src, s.name);
|
|
56
|
+
const to = join(TARGET_BIN, s.name);
|
|
57
|
+
if (!existsSync(from))
|
|
58
|
+
throw new Error(`missing bundled script: ${from}`);
|
|
59
|
+
copyFileSync(from, to);
|
|
60
|
+
chmodSync(to, s.mode);
|
|
61
|
+
if (s.group)
|
|
62
|
+
run('chown', [`root:${s.group}`, to]);
|
|
63
|
+
info(`installed ${to}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function installCron() {
|
|
67
|
+
const cron = `# fleet guard — auto-installed, edit with care
|
|
68
|
+
SHELL=/bin/bash
|
|
69
|
+
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
|
70
|
+
|
|
71
|
+
*/15 * * * * root /usr/local/sbin/cf-audit-monitor >> /var/log/cf-audit-monitor.log 2>&1
|
|
72
|
+
*/30 * * * * root /usr/local/sbin/cf-snapshot >> /var/log/cf-snapshot.log 2>&1
|
|
73
|
+
*/30 * * * * root /usr/local/sbin/dns-drift-watch >> /var/log/dns-drift-watch.log 2>&1
|
|
74
|
+
17 4 * * * root /usr/local/sbin/cert-expiry-watch >> /var/log/cert-expiry-watch.log 2>&1
|
|
75
|
+
* * * * * fleet-guard /usr/local/sbin/fleet-guard execute >> /var/log/fleet-guard/execute.log 2>&1
|
|
76
|
+
`;
|
|
77
|
+
writeFileSync(CRON_TARGET, cron, { mode: 0o644 });
|
|
78
|
+
info(`installed cron at ${CRON_TARGET}`);
|
|
79
|
+
}
|
|
80
|
+
function installCommand() {
|
|
81
|
+
requireRoot();
|
|
82
|
+
ensureUser();
|
|
83
|
+
ensureDir(STATE_DIR, 0o700, 'fleet-guard');
|
|
84
|
+
ensureDir(join(STATE_DIR, 'pending'), 0o700, 'fleet-guard');
|
|
85
|
+
ensureDir(join(STATE_DIR, 'approved'), 0o700, 'fleet-guard');
|
|
86
|
+
ensureDir(join(STATE_DIR, 'processed'), 0o700, 'fleet-guard');
|
|
87
|
+
ensureDir(LOG_DIR, 0o700, 'fleet-guard');
|
|
88
|
+
ensureDir(SNAP_DIR, 0o700);
|
|
89
|
+
installScripts();
|
|
90
|
+
installCron();
|
|
91
|
+
success('fleet guard installed.');
|
|
92
|
+
info('next steps:');
|
|
93
|
+
info(' 1. seed creds at /etc/fleet/guard.cf.json (cloudflare api key + email + accountId)');
|
|
94
|
+
info(' 2. ensure /etc/fleet/notify.json has telegram and/or bluebubbles adapters');
|
|
95
|
+
info(' 3. add /approve, /reject, /guard commands to fleet-bot (PR #60 in fleet repo)');
|
|
96
|
+
}
|
|
97
|
+
function delegate(verb, args) {
|
|
98
|
+
// every other verb just shells out to the host /usr/local/sbin/fleet-guard cli
|
|
99
|
+
// so we have a single source of truth for the queue logic.
|
|
100
|
+
const r = spawnSync('/usr/local/sbin/fleet-guard', [verb, ...args], { stdio: 'inherit' });
|
|
101
|
+
return r.status ?? 1;
|
|
102
|
+
}
|
|
103
|
+
function helpText() {
|
|
104
|
+
return [
|
|
105
|
+
'fleet guard <subcommand>',
|
|
106
|
+
'',
|
|
107
|
+
'subcommands:',
|
|
108
|
+
' install install scripts, user, cron, dirs (root)',
|
|
109
|
+
' status show queue counts + pending tokens',
|
|
110
|
+
' list [pending|approved|processed] list records',
|
|
111
|
+
' hold <kind> <summary> [--payload] create a pending action',
|
|
112
|
+
' approve <token> approve a pending action',
|
|
113
|
+
' reject <token> reject a pending action',
|
|
114
|
+
' show <token> dump one record',
|
|
115
|
+
' execute run all approved actions',
|
|
116
|
+
].join('\n');
|
|
117
|
+
}
|
|
118
|
+
export function guardCommand(args) {
|
|
119
|
+
const [sub, ...rest] = args;
|
|
120
|
+
if (!sub || sub === 'help' || sub === '--help' || sub === '-h') {
|
|
121
|
+
info(helpText());
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (sub === 'install') {
|
|
125
|
+
try {
|
|
126
|
+
installCommand();
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
error(e.message);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const passthrough = new Set(['status', 'list', 'hold', 'approve', 'reject', 'show', 'execute']);
|
|
135
|
+
if (passthrough.has(sub)) {
|
|
136
|
+
const code = delegate(sub, rest);
|
|
137
|
+
if (code !== 0)
|
|
138
|
+
process.exit(code);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
error(`unknown subcommand: ${sub}`);
|
|
142
|
+
console.log(helpText());
|
|
143
|
+
process.exit(2);
|
|
144
|
+
}
|
|
@@ -207,6 +207,11 @@ export declare const RoutineSchema: z.ZodObject<{
|
|
|
207
207
|
enabled: boolean;
|
|
208
208
|
name: string;
|
|
209
209
|
id: string;
|
|
210
|
+
notify: {
|
|
211
|
+
config: Record<string, unknown>;
|
|
212
|
+
kind: "email" | "stdout" | "webhook" | "slack";
|
|
213
|
+
on: "always" | "failure" | "success";
|
|
214
|
+
}[];
|
|
210
215
|
description: string;
|
|
211
216
|
schedule: {
|
|
212
217
|
kind: "manual";
|
|
@@ -239,11 +244,6 @@ export declare const RoutineSchema: z.ZodObject<{
|
|
|
239
244
|
tool: string;
|
|
240
245
|
args: Record<string, unknown>;
|
|
241
246
|
};
|
|
242
|
-
notify: {
|
|
243
|
-
config: Record<string, unknown>;
|
|
244
|
-
kind: "email" | "stdout" | "webhook" | "slack";
|
|
245
|
-
on: "always" | "failure" | "success";
|
|
246
|
-
}[];
|
|
247
247
|
tags: string[];
|
|
248
248
|
updatedAt?: string | undefined;
|
|
249
249
|
createdAt?: string | undefined;
|
|
@@ -281,14 +281,14 @@ export declare const RoutineSchema: z.ZodObject<{
|
|
|
281
281
|
};
|
|
282
282
|
enabled?: boolean | undefined;
|
|
283
283
|
updatedAt?: string | undefined;
|
|
284
|
-
description?: string | undefined;
|
|
285
|
-
targets?: string[] | undefined;
|
|
286
|
-
perTarget?: boolean | undefined;
|
|
287
284
|
notify?: {
|
|
288
285
|
kind: "email" | "stdout" | "webhook" | "slack";
|
|
289
286
|
config?: Record<string, unknown> | undefined;
|
|
290
287
|
on?: "always" | "failure" | "success" | undefined;
|
|
291
288
|
}[] | undefined;
|
|
289
|
+
description?: string | undefined;
|
|
290
|
+
targets?: string[] | undefined;
|
|
291
|
+
perTarget?: boolean | undefined;
|
|
292
292
|
tags?: string[] | undefined;
|
|
293
293
|
createdAt?: string | undefined;
|
|
294
294
|
}>;
|
|
@@ -112,6 +112,11 @@ declare const FileSchema: z.ZodObject<{
|
|
|
112
112
|
enabled: boolean;
|
|
113
113
|
name: string;
|
|
114
114
|
id: string;
|
|
115
|
+
notify: {
|
|
116
|
+
config: Record<string, unknown>;
|
|
117
|
+
kind: "email" | "stdout" | "webhook" | "slack";
|
|
118
|
+
on: "always" | "failure" | "success";
|
|
119
|
+
}[];
|
|
115
120
|
description: string;
|
|
116
121
|
schedule: {
|
|
117
122
|
kind: "manual";
|
|
@@ -144,11 +149,6 @@ declare const FileSchema: z.ZodObject<{
|
|
|
144
149
|
tool: string;
|
|
145
150
|
args: Record<string, unknown>;
|
|
146
151
|
};
|
|
147
|
-
notify: {
|
|
148
|
-
config: Record<string, unknown>;
|
|
149
|
-
kind: "email" | "stdout" | "webhook" | "slack";
|
|
150
|
-
on: "always" | "failure" | "success";
|
|
151
|
-
}[];
|
|
152
152
|
tags: string[];
|
|
153
153
|
updatedAt?: string | undefined;
|
|
154
154
|
createdAt?: string | undefined;
|
|
@@ -186,14 +186,14 @@ declare const FileSchema: z.ZodObject<{
|
|
|
186
186
|
};
|
|
187
187
|
enabled?: boolean | undefined;
|
|
188
188
|
updatedAt?: string | undefined;
|
|
189
|
-
description?: string | undefined;
|
|
190
|
-
targets?: string[] | undefined;
|
|
191
|
-
perTarget?: boolean | undefined;
|
|
192
189
|
notify?: {
|
|
193
190
|
kind: "email" | "stdout" | "webhook" | "slack";
|
|
194
191
|
config?: Record<string, unknown> | undefined;
|
|
195
192
|
on?: "always" | "failure" | "success" | undefined;
|
|
196
193
|
}[] | undefined;
|
|
194
|
+
description?: string | undefined;
|
|
195
|
+
targets?: string[] | undefined;
|
|
196
|
+
perTarget?: boolean | undefined;
|
|
197
197
|
tags?: string[] | undefined;
|
|
198
198
|
createdAt?: string | undefined;
|
|
199
199
|
}>, "many">;
|
|
@@ -204,6 +204,11 @@ declare const FileSchema: z.ZodObject<{
|
|
|
204
204
|
enabled: boolean;
|
|
205
205
|
name: string;
|
|
206
206
|
id: string;
|
|
207
|
+
notify: {
|
|
208
|
+
config: Record<string, unknown>;
|
|
209
|
+
kind: "email" | "stdout" | "webhook" | "slack";
|
|
210
|
+
on: "always" | "failure" | "success";
|
|
211
|
+
}[];
|
|
207
212
|
description: string;
|
|
208
213
|
schedule: {
|
|
209
214
|
kind: "manual";
|
|
@@ -236,11 +241,6 @@ declare const FileSchema: z.ZodObject<{
|
|
|
236
241
|
tool: string;
|
|
237
242
|
args: Record<string, unknown>;
|
|
238
243
|
};
|
|
239
|
-
notify: {
|
|
240
|
-
config: Record<string, unknown>;
|
|
241
|
-
kind: "email" | "stdout" | "webhook" | "slack";
|
|
242
|
-
on: "always" | "failure" | "success";
|
|
243
|
-
}[];
|
|
244
244
|
tags: string[];
|
|
245
245
|
updatedAt?: string | undefined;
|
|
246
246
|
createdAt?: string | undefined;
|
|
@@ -282,14 +282,14 @@ declare const FileSchema: z.ZodObject<{
|
|
|
282
282
|
};
|
|
283
283
|
enabled?: boolean | undefined;
|
|
284
284
|
updatedAt?: string | undefined;
|
|
285
|
-
description?: string | undefined;
|
|
286
|
-
targets?: string[] | undefined;
|
|
287
|
-
perTarget?: boolean | undefined;
|
|
288
285
|
notify?: {
|
|
289
286
|
kind: "email" | "stdout" | "webhook" | "slack";
|
|
290
287
|
config?: Record<string, unknown> | undefined;
|
|
291
288
|
on?: "always" | "failure" | "success" | undefined;
|
|
292
289
|
}[] | undefined;
|
|
290
|
+
description?: string | undefined;
|
|
291
|
+
targets?: string[] | undefined;
|
|
292
|
+
perTarget?: boolean | undefined;
|
|
293
293
|
tags?: string[] | undefined;
|
|
294
294
|
createdAt?: string | undefined;
|
|
295
295
|
}[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@matthesketh/fleet",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Docker production management CLI + MCP server for Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"dist/",
|
|
12
12
|
"data/registry.example.json",
|
|
13
|
+
"scripts/guard/",
|
|
13
14
|
"LICENSE",
|
|
14
15
|
"README.md"
|
|
15
16
|
],
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
cert-expiry-watch — sweeps /etc/letsencrypt/live and reports any cert
|
|
4
|
+
that's expiring soon. tiered alerts:
|
|
5
|
+
- < 14 days: notify (info)
|
|
6
|
+
- < 3 days: hold (renewal probably broken — needs eyes)
|
|
7
|
+
|
|
8
|
+
run from cron once a day.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
LIVE = Path("/etc/letsencrypt/live")
|
|
18
|
+
GUARD = "/usr/local/sbin/fleet-guard"
|
|
19
|
+
NOTIFY = "/usr/local/sbin/notify"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def active_cert_paths():
|
|
23
|
+
"""parse nginx -T output to find ssl_certificate paths actually in use.
|
|
24
|
+
returns set of resolved file paths (Path objects)."""
|
|
25
|
+
try:
|
|
26
|
+
out = subprocess.run(
|
|
27
|
+
["nginx", "-T"], capture_output=True, text=True, timeout=10,
|
|
28
|
+
)
|
|
29
|
+
except Exception:
|
|
30
|
+
return None # cannot determine — caller should fall back
|
|
31
|
+
if out.returncode != 0:
|
|
32
|
+
return None
|
|
33
|
+
paths = set()
|
|
34
|
+
for m in re.finditer(r"ssl_certificate\s+([^\s;]+);", out.stdout):
|
|
35
|
+
paths.add(Path(m.group(1)).resolve())
|
|
36
|
+
return paths
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cert_is_active(cert_dir, active):
|
|
40
|
+
"""check if any cert under cert_dir is referenced by nginx."""
|
|
41
|
+
if active is None:
|
|
42
|
+
return True # nginx not parseable — fall back to flagging everything
|
|
43
|
+
candidates = [
|
|
44
|
+
cert_dir / "fullchain.pem",
|
|
45
|
+
cert_dir / "cert.pem",
|
|
46
|
+
cert_dir / "chain.pem",
|
|
47
|
+
]
|
|
48
|
+
return any(p.resolve() in active for p in candidates if p.exists())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cert_not_after(path):
|
|
52
|
+
out = subprocess.run(
|
|
53
|
+
["openssl", "x509", "-noout", "-enddate", "-in", str(path)],
|
|
54
|
+
capture_output=True, text=True, timeout=5,
|
|
55
|
+
)
|
|
56
|
+
if out.returncode != 0:
|
|
57
|
+
return None
|
|
58
|
+
line = out.stdout.strip() # notAfter=Apr 25 12:34:56 2026 GMT
|
|
59
|
+
_, _, when = line.partition("=")
|
|
60
|
+
try:
|
|
61
|
+
return datetime.strptime(when.strip(), "%b %d %H:%M:%S %Y %Z").replace(tzinfo=timezone.utc)
|
|
62
|
+
except ValueError:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def main():
|
|
67
|
+
if not LIVE.exists():
|
|
68
|
+
return 0
|
|
69
|
+
now = datetime.now(timezone.utc)
|
|
70
|
+
active = active_cert_paths()
|
|
71
|
+
soon = []
|
|
72
|
+
critical = []
|
|
73
|
+
skipped_inactive = 0
|
|
74
|
+
for cert_dir in sorted(LIVE.iterdir()):
|
|
75
|
+
if not cert_dir.is_dir():
|
|
76
|
+
continue
|
|
77
|
+
cert_path = cert_dir / "cert.pem"
|
|
78
|
+
if not cert_path.exists():
|
|
79
|
+
continue
|
|
80
|
+
if not cert_is_active(cert_dir, active):
|
|
81
|
+
skipped_inactive += 1
|
|
82
|
+
continue
|
|
83
|
+
not_after = cert_not_after(cert_path)
|
|
84
|
+
if not not_after:
|
|
85
|
+
continue
|
|
86
|
+
days_left = (not_after - now).total_seconds() / 86400
|
|
87
|
+
item = {"name": cert_dir.name, "expires": not_after.isoformat(), "days": int(days_left)}
|
|
88
|
+
if days_left < 3:
|
|
89
|
+
critical.append(item)
|
|
90
|
+
elif days_left < 14:
|
|
91
|
+
soon.append(item)
|
|
92
|
+
|
|
93
|
+
if soon:
|
|
94
|
+
body = "\n".join(f"{i['name']}: {i['days']}d left ({i['expires']})" for i in soon)
|
|
95
|
+
subprocess.run([NOTIFY, "certs expiring soon", body], check=False)
|
|
96
|
+
|
|
97
|
+
for item in critical:
|
|
98
|
+
summary = f"cert {item['name']} expires in {item['days']}d — renewal probably broken"
|
|
99
|
+
subprocess.run(
|
|
100
|
+
[GUARD, "hold", "cert_expiry_critical", summary, "--payload",
|
|
101
|
+
'{"cert":"' + item["name"] + '"}'],
|
|
102
|
+
check=False,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
sys.exit(main())
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
poll cloudflare audit log api and notify via telegram on each new event.
|
|
4
|
+
- state: /var/lib/cf-audit/last-seen.txt (rfc3339 utc)
|
|
5
|
+
- creds: read from /root/.claude.json (cloudflare-mcp env block)
|
|
6
|
+
- runs from cron every 15m. on first run seeds the state to "now-1h".
|
|
7
|
+
- categorises events: noise events are silently logged; everything else
|
|
8
|
+
fires a telegram message.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
import urllib.request
|
|
16
|
+
import urllib.parse
|
|
17
|
+
from datetime import datetime, timedelta, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from subprocess import run
|
|
20
|
+
|
|
21
|
+
CLAUDE_CFG = Path("/root/.claude.json")
|
|
22
|
+
STATE_DIR = Path("/var/lib/cf-audit")
|
|
23
|
+
STATE_FILE = STATE_DIR / "last-seen.txt"
|
|
24
|
+
LOG_FILE = STATE_DIR / "events.jsonl"
|
|
25
|
+
NOTIFIER = "/usr/local/sbin/notify"
|
|
26
|
+
GUARD = "/usr/local/sbin/fleet-guard"
|
|
27
|
+
|
|
28
|
+
# events that happen routinely and shouldn't page us
|
|
29
|
+
NOISY_ACTIONS = {
|
|
30
|
+
"login",
|
|
31
|
+
"purge",
|
|
32
|
+
"challenge_solve",
|
|
33
|
+
"user_read",
|
|
34
|
+
"search",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# event action types that should always create a hold (require approval)
|
|
38
|
+
# instead of merely notifying. these are the destructive / hijack-shaped ones.
|
|
39
|
+
HOLD_ACTIONS = {
|
|
40
|
+
"zone_delete", "zone_create", "zone_settings_change",
|
|
41
|
+
"ns_change", "transfer_in", "transfer_out",
|
|
42
|
+
"member_add", "member_remove",
|
|
43
|
+
"api_token_create", "api_token_delete",
|
|
44
|
+
"ssl_change",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read_creds():
|
|
49
|
+
with CLAUDE_CFG.open() as f:
|
|
50
|
+
cfg = json.load(f)
|
|
51
|
+
env = cfg.get("mcpServers", {}).get("cloudflare-mcp", {}).get("env", {}) or {}
|
|
52
|
+
api_key = env.get("CLOUDFLARE_API_KEY")
|
|
53
|
+
email = env.get("CLOUDFLARE_EMAIL")
|
|
54
|
+
if not api_key or not email:
|
|
55
|
+
sys.exit("cf creds missing in /root/.claude.json (mcpServers.cloudflare-mcp.env)")
|
|
56
|
+
# account id needed for v2 audit endpoint
|
|
57
|
+
inf_env = cfg.get("mcpServers", {}).get("infrastructure-mcp", {}).get("env", {}) or {}
|
|
58
|
+
account_id = inf_env.get("CLOUDFLARE_ACCOUNT_ID")
|
|
59
|
+
if not account_id:
|
|
60
|
+
sys.exit("CLOUDFLARE_ACCOUNT_ID missing in infrastructure-mcp env")
|
|
61
|
+
return api_key, email, account_id
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_since():
|
|
65
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
if STATE_FILE.exists():
|
|
67
|
+
return STATE_FILE.read_text().strip()
|
|
68
|
+
# first run: pull last hour
|
|
69
|
+
return (datetime.now(timezone.utc) - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def save_since(ts):
|
|
73
|
+
STATE_FILE.write_text(ts + "\n")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def fetch_events(api_key, email, account_id, since):
|
|
77
|
+
# cf accounts audit log v1 endpoint (free plan compatible)
|
|
78
|
+
qs = urllib.parse.urlencode({
|
|
79
|
+
"since": since,
|
|
80
|
+
"before": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
81
|
+
"per_page": 100,
|
|
82
|
+
})
|
|
83
|
+
url = f"https://api.cloudflare.com/client/v4/accounts/{account_id}/audit_logs?{qs}"
|
|
84
|
+
req = urllib.request.Request(url, headers={
|
|
85
|
+
"X-Auth-Email": email,
|
|
86
|
+
"X-Auth-Key": api_key,
|
|
87
|
+
"Content-Type": "application/json",
|
|
88
|
+
})
|
|
89
|
+
with urllib.request.urlopen(req, timeout=20) as resp:
|
|
90
|
+
return json.loads(resp.read())
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def classify(action_type):
|
|
94
|
+
a = (action_type or "").lower()
|
|
95
|
+
if a in NOISY_ACTIONS:
|
|
96
|
+
return "noise"
|
|
97
|
+
return "alert"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def notify(title, body):
|
|
101
|
+
run([NOTIFIER, title, body], check=False)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def fmt_event(ev):
|
|
105
|
+
when = ev.get("when", "?")
|
|
106
|
+
who = (ev.get("actor") or {}).get("email") or "?"
|
|
107
|
+
ip = (ev.get("actor") or {}).get("ip") or "?"
|
|
108
|
+
action = (ev.get("action") or {}).get("type") or "?"
|
|
109
|
+
resource = (ev.get("resource") or {}).get("type") or "?"
|
|
110
|
+
res_id = (ev.get("resource") or {}).get("id") or ""
|
|
111
|
+
metadata = ev.get("metadata") or {}
|
|
112
|
+
extras = ", ".join(f"{k}={v}" for k, v in metadata.items() if k in ("name", "domain", "zone"))
|
|
113
|
+
line = f"{when}\n{action} {resource} by {who} from {ip}"
|
|
114
|
+
if res_id:
|
|
115
|
+
line += f"\nresource: {res_id}"
|
|
116
|
+
if extras:
|
|
117
|
+
line += f"\n{extras}"
|
|
118
|
+
return line
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def main():
|
|
122
|
+
api_key, email, account_id = read_creds()
|
|
123
|
+
since = load_since()
|
|
124
|
+
try:
|
|
125
|
+
data = fetch_events(api_key, email, account_id, since)
|
|
126
|
+
except Exception as e:
|
|
127
|
+
notify("cf-audit-monitor failed", f"fetch error: {e}")
|
|
128
|
+
sys.exit(1)
|
|
129
|
+
if not data.get("success", False):
|
|
130
|
+
errs = data.get("errors") or []
|
|
131
|
+
notify("cf-audit-monitor api error", json.dumps(errs)[:300])
|
|
132
|
+
sys.exit(1)
|
|
133
|
+
|
|
134
|
+
events = data.get("result") or []
|
|
135
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
if events:
|
|
137
|
+
with LOG_FILE.open("a") as f:
|
|
138
|
+
for ev in events:
|
|
139
|
+
f.write(json.dumps(ev) + "\n")
|
|
140
|
+
|
|
141
|
+
alerts = [ev for ev in events if classify((ev.get("action") or {}).get("type")) == "alert"]
|
|
142
|
+
for ev in alerts:
|
|
143
|
+
action = (ev.get("action") or {}).get("type") or ""
|
|
144
|
+
if action in HOLD_ACTIONS:
|
|
145
|
+
# destructive — create a fleet-guard hold instead of just notifying.
|
|
146
|
+
# the cli itself fires the notification with approval token.
|
|
147
|
+
payload = {
|
|
148
|
+
"cf_event": ev,
|
|
149
|
+
"suggested_action": "review and approve to acknowledge",
|
|
150
|
+
}
|
|
151
|
+
try:
|
|
152
|
+
run([GUARD, "hold", f"cf_{action}", fmt_event(ev), "--payload",
|
|
153
|
+
json.dumps(payload)], check=False, timeout=30)
|
|
154
|
+
except Exception as e:
|
|
155
|
+
notify("cf-audit hold-failed", f"{e}\n{fmt_event(ev)}")
|
|
156
|
+
else:
|
|
157
|
+
notify("cloudflare audit event", fmt_event(ev))
|
|
158
|
+
|
|
159
|
+
# advance cursor: latest "when" + 1s, or now if no events
|
|
160
|
+
if events:
|
|
161
|
+
latest = max(ev.get("when", "") for ev in events)
|
|
162
|
+
if latest:
|
|
163
|
+
save_since(latest)
|
|
164
|
+
else:
|
|
165
|
+
save_since(datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__":
|
|
169
|
+
main()
|