@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 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.6.0",
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()