@matthesketh/fleet 1.2.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.
Files changed (218) hide show
  1. package/README.md +183 -251
  2. package/dist/adapters/detector/index.d.ts +8 -0
  3. package/dist/adapters/detector/index.js +54 -0
  4. package/dist/adapters/notifier/index.d.ts +2 -0
  5. package/dist/adapters/notifier/index.js +2 -0
  6. package/dist/adapters/notifier/stdout.d.ts +2 -0
  7. package/dist/adapters/notifier/stdout.js +8 -0
  8. package/dist/adapters/notifier/webhook.d.ts +9 -0
  9. package/dist/adapters/notifier/webhook.js +38 -0
  10. package/dist/adapters/runner/claude-cli.d.ts +7 -0
  11. package/dist/adapters/runner/claude-cli.js +231 -0
  12. package/dist/adapters/runner/mcp-call.d.ts +8 -0
  13. package/dist/adapters/runner/mcp-call.js +82 -0
  14. package/dist/adapters/runner/shell.d.ts +2 -0
  15. package/dist/adapters/runner/shell.js +103 -0
  16. package/dist/adapters/scheduler/systemd-timer.d.ts +17 -0
  17. package/dist/adapters/scheduler/systemd-timer.js +149 -0
  18. package/dist/adapters/signals/ci-status.d.ts +2 -0
  19. package/dist/adapters/signals/ci-status.js +79 -0
  20. package/dist/adapters/signals/container-up.d.ts +5 -0
  21. package/dist/adapters/signals/container-up.js +54 -0
  22. package/dist/adapters/signals/git-clean.d.ts +2 -0
  23. package/dist/adapters/signals/git-clean.js +55 -0
  24. package/dist/adapters/signals/index.d.ts +6 -0
  25. package/dist/adapters/signals/index.js +7 -0
  26. package/dist/adapters/types.d.ts +52 -0
  27. package/dist/adapters/types.js +1 -0
  28. package/dist/cli.js +46 -2
  29. package/dist/commands/add.js +0 -6
  30. package/dist/commands/boot-start.d.ts +1 -0
  31. package/dist/commands/boot-start.js +51 -0
  32. package/dist/commands/deploy.js +13 -0
  33. package/dist/commands/deps.js +5 -0
  34. package/dist/commands/egress.d.ts +1 -0
  35. package/dist/commands/egress.js +106 -0
  36. package/dist/commands/freeze.d.ts +4 -0
  37. package/dist/commands/freeze.js +64 -0
  38. package/dist/commands/guard.d.ts +1 -0
  39. package/dist/commands/guard.js +144 -0
  40. package/dist/commands/logs.d.ts +1 -1
  41. package/dist/commands/logs.js +237 -8
  42. package/dist/commands/patch-systemd.d.ts +1 -0
  43. package/dist/commands/patch-systemd.js +126 -0
  44. package/dist/commands/rollback.d.ts +1 -0
  45. package/dist/commands/rollback.js +58 -0
  46. package/dist/commands/routine-run.d.ts +1 -0
  47. package/dist/commands/routine-run.js +122 -0
  48. package/dist/commands/routines.d.ts +1 -0
  49. package/dist/commands/routines.js +25 -0
  50. package/dist/commands/secrets.js +449 -16
  51. package/dist/commands/status.js +7 -3
  52. package/dist/commands/watchdog.d.ts +1 -1
  53. package/dist/commands/watchdog.js +16 -40
  54. package/dist/core/boot-refresh.d.ts +57 -0
  55. package/dist/core/boot-refresh.js +116 -0
  56. package/dist/core/deps/actors/pr-creator.js +11 -9
  57. package/dist/core/deps/collectors/docker-running.js +2 -2
  58. package/dist/core/deps/collectors/github-pr.js +5 -2
  59. package/dist/core/deps/collectors/npm.js +10 -5
  60. package/dist/core/deps/collectors/vulnerability.js +10 -6
  61. package/dist/core/deps/reporters/motd.js +1 -1
  62. package/dist/core/deps/reporters/telegram.js +2 -29
  63. package/dist/core/docker.js +45 -15
  64. package/dist/core/egress.d.ts +41 -0
  65. package/dist/core/egress.js +161 -0
  66. package/dist/core/exec.d.ts +7 -1
  67. package/dist/core/exec.js +25 -17
  68. package/dist/core/git.d.ts +1 -0
  69. package/dist/core/git.js +36 -23
  70. package/dist/core/github.js +27 -8
  71. package/dist/core/health.d.ts +3 -0
  72. package/dist/core/health.js +15 -3
  73. package/dist/core/logs-multi.d.ts +73 -0
  74. package/dist/core/logs-multi.js +163 -0
  75. package/dist/core/logs-policy.d.ts +55 -0
  76. package/dist/core/logs-policy.js +148 -0
  77. package/dist/core/nginx.js +8 -4
  78. package/dist/core/notify.d.ts +15 -0
  79. package/dist/core/notify.js +55 -0
  80. package/dist/core/registry.d.ts +25 -0
  81. package/dist/core/registry.js +57 -10
  82. package/dist/core/routines/cost-queries.d.ts +24 -0
  83. package/dist/core/routines/cost-queries.js +65 -0
  84. package/dist/core/routines/db.d.ts +9 -0
  85. package/dist/core/routines/db.js +126 -0
  86. package/dist/core/routines/defaults.d.ts +2 -0
  87. package/dist/core/routines/defaults.js +72 -0
  88. package/dist/core/routines/engine.d.ts +59 -0
  89. package/dist/core/routines/engine.js +175 -0
  90. package/dist/core/routines/incidents.d.ts +13 -0
  91. package/dist/core/routines/incidents.js +35 -0
  92. package/dist/core/routines/schema.d.ts +418 -0
  93. package/dist/core/routines/schema.js +113 -0
  94. package/dist/core/routines/signals-collector.d.ts +35 -0
  95. package/dist/core/routines/signals-collector.js +114 -0
  96. package/dist/core/routines/store.d.ts +316 -0
  97. package/dist/core/routines/store.js +99 -0
  98. package/dist/core/routines/test-utils.d.ts +2 -0
  99. package/dist/core/routines/test-utils.js +13 -0
  100. package/dist/core/secrets-audit.d.ts +21 -0
  101. package/dist/core/secrets-audit.js +60 -0
  102. package/dist/core/secrets-metadata.d.ts +39 -0
  103. package/dist/core/secrets-metadata.js +82 -0
  104. package/dist/core/secrets-motd.d.ts +20 -0
  105. package/dist/core/secrets-motd.js +72 -0
  106. package/dist/core/secrets-ops.d.ts +3 -1
  107. package/dist/core/secrets-ops.js +78 -13
  108. package/dist/core/secrets-providers.d.ts +50 -0
  109. package/dist/core/secrets-providers.js +291 -0
  110. package/dist/core/secrets-rotation.d.ts +52 -0
  111. package/dist/core/secrets-rotation.js +165 -0
  112. package/dist/core/secrets-snapshots.d.ts +26 -0
  113. package/dist/core/secrets-snapshots.js +95 -0
  114. package/dist/core/secrets-validate.js +2 -1
  115. package/dist/core/secrets.d.ts +12 -1
  116. package/dist/core/secrets.js +35 -24
  117. package/dist/core/self-update.d.ts +41 -0
  118. package/dist/core/self-update.js +73 -0
  119. package/dist/core/systemd.js +29 -12
  120. package/dist/core/telegram.d.ts +6 -0
  121. package/dist/core/telegram.js +32 -0
  122. package/dist/core/validate.d.ts +7 -0
  123. package/dist/core/validate.js +42 -0
  124. package/dist/index.js +0 -4
  125. package/dist/mcp/deps-tools.js +9 -1
  126. package/dist/mcp/git-tools.js +4 -4
  127. package/dist/mcp/server.js +193 -8
  128. package/dist/templates/systemd.js +3 -3
  129. package/dist/templates/unseal.js +5 -1
  130. package/dist/tui/components/KeyHint.js +10 -0
  131. package/dist/tui/exec-bridge.js +26 -12
  132. package/dist/tui/hooks/use-fleet-data.js +5 -2
  133. package/dist/tui/hooks/use-health.js +5 -2
  134. package/dist/tui/router.js +60 -7
  135. package/dist/tui/routines/RoutinesApp.d.ts +8 -0
  136. package/dist/tui/routines/RoutinesApp.js +277 -0
  137. package/dist/tui/routines/components/AlertsPanel.d.ts +7 -0
  138. package/dist/tui/routines/components/AlertsPanel.js +22 -0
  139. package/dist/tui/routines/components/AlertsPanel.test.d.ts +1 -0
  140. package/dist/tui/routines/components/AlertsPanel.test.js +52 -0
  141. package/dist/tui/routines/components/CommandPalette.d.ts +12 -0
  142. package/dist/tui/routines/components/CommandPalette.js +21 -0
  143. package/dist/tui/routines/components/LiveRunPanel.d.ts +12 -0
  144. package/dist/tui/routines/components/LiveRunPanel.js +107 -0
  145. package/dist/tui/routines/components/RoutineForm.d.ts +8 -0
  146. package/dist/tui/routines/components/RoutineForm.js +254 -0
  147. package/dist/tui/routines/components/SignalsGrid.d.ts +13 -0
  148. package/dist/tui/routines/components/SignalsGrid.js +34 -0
  149. package/dist/tui/routines/components/SignalsGrid.test.d.ts +1 -0
  150. package/dist/tui/routines/components/SignalsGrid.test.js +43 -0
  151. package/dist/tui/routines/format.d.ts +7 -0
  152. package/dist/tui/routines/format.js +51 -0
  153. package/dist/tui/routines/hooks/use-git-fleet.d.ts +33 -0
  154. package/dist/tui/routines/hooks/use-git-fleet.js +82 -0
  155. package/dist/tui/routines/hooks/use-logs-stream.d.ts +13 -0
  156. package/dist/tui/routines/hooks/use-logs-stream.js +64 -0
  157. package/dist/tui/routines/hooks/use-ops-fleet.d.ts +20 -0
  158. package/dist/tui/routines/hooks/use-ops-fleet.js +70 -0
  159. package/dist/tui/routines/hooks/use-repo-detail.d.ts +31 -0
  160. package/dist/tui/routines/hooks/use-repo-detail.js +104 -0
  161. package/dist/tui/routines/hooks/use-security.d.ts +33 -0
  162. package/dist/tui/routines/hooks/use-security.js +110 -0
  163. package/dist/tui/routines/hooks/use-signals.d.ts +9 -0
  164. package/dist/tui/routines/hooks/use-signals.js +60 -0
  165. package/dist/tui/routines/runtime.d.ts +20 -0
  166. package/dist/tui/routines/runtime.js +40 -0
  167. package/dist/tui/routines/tabs/CostTab.d.ts +7 -0
  168. package/dist/tui/routines/tabs/CostTab.js +24 -0
  169. package/dist/tui/routines/tabs/DashboardTab.d.ts +15 -0
  170. package/dist/tui/routines/tabs/DashboardTab.js +10 -0
  171. package/dist/tui/routines/tabs/GitTab.d.ts +6 -0
  172. package/dist/tui/routines/tabs/GitTab.js +39 -0
  173. package/dist/tui/routines/tabs/LogsTab.d.ts +6 -0
  174. package/dist/tui/routines/tabs/LogsTab.js +58 -0
  175. package/dist/tui/routines/tabs/OpsTab.d.ts +6 -0
  176. package/dist/tui/routines/tabs/OpsTab.js +34 -0
  177. package/dist/tui/routines/tabs/RepoDetailView.d.ts +6 -0
  178. package/dist/tui/routines/tabs/RepoDetailView.js +12 -0
  179. package/dist/tui/routines/tabs/RoutinesTab.d.ts +10 -0
  180. package/dist/tui/routines/tabs/RoutinesTab.js +58 -0
  181. package/dist/tui/routines/tabs/ScaffoldTab.d.ts +2 -0
  182. package/dist/tui/routines/tabs/ScaffoldTab.js +127 -0
  183. package/dist/tui/routines/tabs/SecurityTab.d.ts +6 -0
  184. package/dist/tui/routines/tabs/SecurityTab.js +31 -0
  185. package/dist/tui/routines/tabs/SettingsTab.d.ts +6 -0
  186. package/dist/tui/routines/tabs/SettingsTab.js +61 -0
  187. package/dist/tui/routines/tabs/TimelineTab.d.ts +7 -0
  188. package/dist/tui/routines/tabs/TimelineTab.js +26 -0
  189. package/dist/tui/state.js +1 -1
  190. package/dist/tui/tests/keyboard-integration.test.js +3 -0
  191. package/dist/tui/tests/test-app.js +1 -1
  192. package/dist/tui/types.d.ts +2 -2
  193. package/dist/tui/views/AppDetail.js +3 -4
  194. package/dist/tui/views/HealthView.js +7 -1
  195. package/dist/tui/views/LogsView.js +24 -1
  196. package/dist/tui/views/MultiLogsView.d.ts +2 -0
  197. package/dist/tui/views/MultiLogsView.js +165 -0
  198. package/dist/tui/views/SecretEdit.js +10 -3
  199. package/dist/tui/views/SecretsView.js +6 -3
  200. package/dist/ui/prompt.d.ts +52 -0
  201. package/dist/ui/prompt.js +169 -0
  202. package/package.json +34 -21
  203. package/scripts/guard/cert-expiry-watch +109 -0
  204. package/scripts/guard/cf-audit-monitor +169 -0
  205. package/scripts/guard/cf-snapshot +124 -0
  206. package/scripts/guard/cron.d-cf-protect +11 -0
  207. package/scripts/guard/dns-drift-watch +138 -0
  208. package/scripts/guard/fleet-guard +282 -0
  209. package/scripts/guard/fleet-guard-execute +197 -0
  210. package/scripts/guard/notify +108 -0
  211. package/dist/commands/motd.d.ts +0 -1
  212. package/dist/commands/motd.js +0 -10
  213. package/dist/templates/motd.d.ts +0 -1
  214. package/dist/templates/motd.js +0 -7
  215. package/dist/tui/components/AppList.d.ts +0 -12
  216. package/dist/tui/components/AppList.js +0 -32
  217. package/dist/tui/hooks/use-keyboard.d.ts +0 -1
  218. package/dist/tui/hooks/use-keyboard.js +0 -44
@@ -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()
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ snapshot all cloudflare zone state (dns records + zone settings) into a
4
+ git repo at /var/lib/cf-snapshots/. each run commits a diff so we have a
5
+ full audit trail of what changed and when.
6
+
7
+ usage: cf-snapshot
8
+ """
9
+
10
+ import json
11
+ import subprocess
12
+ import sys
13
+ import urllib.request
14
+ from pathlib import Path
15
+
16
+ CLAUDE_CFG = Path("/root/.claude.json")
17
+ SNAP_DIR = Path("/var/lib/cf-snapshots")
18
+
19
+
20
+ def read_creds():
21
+ cfg = json.loads(CLAUDE_CFG.read_text())
22
+ env = cfg.get("mcpServers", {}).get("cloudflare-mcp", {}).get("env", {}) or {}
23
+ api_key = env.get("CLOUDFLARE_API_KEY")
24
+ email = env.get("CLOUDFLARE_EMAIL")
25
+ if not api_key or not email:
26
+ sys.exit("cf creds missing in /root/.claude.json")
27
+ return api_key, email
28
+
29
+
30
+ def cf_get(url, api_key, email):
31
+ req = urllib.request.Request(url, headers={
32
+ "X-Auth-Email": email,
33
+ "X-Auth-Key": api_key,
34
+ "Content-Type": "application/json",
35
+ })
36
+ with urllib.request.urlopen(req, timeout=20) as r:
37
+ return json.loads(r.read())
38
+
39
+
40
+ def list_zones(api_key, email):
41
+ out = []
42
+ page = 1
43
+ while True:
44
+ d = cf_get(
45
+ f"https://api.cloudflare.com/client/v4/zones?per_page=50&page={page}",
46
+ api_key, email,
47
+ )
48
+ out.extend(d.get("result") or [])
49
+ info = d.get("result_info") or {}
50
+ if page >= (info.get("total_pages") or 1):
51
+ break
52
+ page += 1
53
+ return out
54
+
55
+
56
+ def snapshot_zone(zone, api_key, email):
57
+ zid = zone["id"]
58
+ name = zone["name"]
59
+ records = cf_get(
60
+ f"https://api.cloudflare.com/client/v4/zones/{zid}/dns_records?per_page=100",
61
+ api_key, email,
62
+ ).get("result") or []
63
+ settings = cf_get(
64
+ f"https://api.cloudflare.com/client/v4/zones/{zid}/settings",
65
+ api_key, email,
66
+ ).get("result") or []
67
+ # strip volatile fields so diffs are meaningful
68
+ pruned_records = []
69
+ for r in records:
70
+ pruned_records.append({
71
+ k: r.get(k) for k in
72
+ ("id", "type", "name", "content", "proxied", "ttl", "priority", "data")
73
+ if r.get(k) is not None
74
+ })
75
+ pruned_settings = [
76
+ {k: s.get(k) for k in ("id", "value")} for s in settings
77
+ ]
78
+ return {
79
+ "zone": {"id": zid, "name": name, "status": zone.get("status")},
80
+ "records": sorted(pruned_records, key=lambda x: (x.get("type", ""), x.get("name", ""))),
81
+ "settings": sorted(pruned_settings, key=lambda x: x.get("id", "")),
82
+ }
83
+
84
+
85
+ def ensure_repo():
86
+ SNAP_DIR.mkdir(parents=True, exist_ok=True)
87
+ if not (SNAP_DIR / ".git").exists():
88
+ subprocess.run(["git", "init", "-q", "-b", "main", str(SNAP_DIR)], check=True)
89
+ subprocess.run(["git", "-C", str(SNAP_DIR), "config", "user.email", "cf-snapshot@local"], check=True)
90
+ subprocess.run(["git", "-C", str(SNAP_DIR), "config", "user.name", "cf-snapshot"], check=True)
91
+
92
+
93
+ def main():
94
+ api_key, email = read_creds()
95
+ ensure_repo()
96
+ zones = list_zones(api_key, email)
97
+ written = 0
98
+ for z in zones:
99
+ snap = snapshot_zone(z, api_key, email)
100
+ path = SNAP_DIR / f"{z['name']}.json"
101
+ path.write_text(json.dumps(snap, indent=2, sort_keys=True) + "\n")
102
+ written += 1
103
+ subprocess.run(["git", "-C", str(SNAP_DIR), "add", "-A"], check=True)
104
+ diff = subprocess.run(
105
+ ["git", "-C", str(SNAP_DIR), "diff", "--cached", "--quiet"],
106
+ check=False,
107
+ )
108
+ if diff.returncode == 0:
109
+ return 0
110
+ msg = subprocess.run(
111
+ ["git", "-C", str(SNAP_DIR), "diff", "--cached", "--stat"],
112
+ check=True, capture_output=True, text=True,
113
+ ).stdout.strip()
114
+ subprocess.run(
115
+ ["git", "-C", str(SNAP_DIR), "commit", "-q", "-m", f"snapshot: {written} zones"],
116
+ check=True,
117
+ )
118
+ # alert if a meaningful change happened
119
+ subprocess.run(["/usr/local/sbin/notify", "cf-snapshot diff",
120
+ msg[:1000] if msg else "(no diff stat)"], check=False)
121
+
122
+
123
+ if __name__ == "__main__":
124
+ main()
@@ -0,0 +1,11 @@
1
+ # cloudflare protection layer cron entries
2
+ # - audit log monitor: every 15 min
3
+ # - state snapshot: every 30 min (catches changes audit log misses)
4
+ SHELL=/bin/bash
5
+ PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
6
+
7
+ */15 * * * * root /usr/local/sbin/cf-audit-monitor >> /var/log/cf-audit-monitor.log 2>&1
8
+ */30 * * * * root /usr/local/sbin/cf-snapshot >> /var/log/cf-snapshot.log 2>&1
9
+ * * * * * fleet-guard /usr/local/sbin/fleet-guard execute >> /var/log/fleet-guard/execute.log 2>&1
10
+ 17 4 * * * root /usr/local/sbin/cert-expiry-watch >> /var/log/cert-expiry-watch.log 2>&1
11
+ */30 * * * * root /usr/local/sbin/dns-drift-watch >> /var/log/dns-drift-watch.log 2>&1
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ dns-drift-watch — verifies that public dns resolution for each cloudflare-
4
+ proxied hostname still resolves through cloudflare ip space. detects:
5
+ - attacker repointing a record to a non-cf ip
6
+ - accidental flipping of the proxied flag (orange to grey)
7
+ - nameserver hijack at registrar level
8
+
9
+ works against the latest snapshot in /var/lib/cf-snapshots. only checks A
10
+ records that were proxied=true at snapshot time. queries 1.1.1.1, 8.8.8.8,
11
+ and 9.9.9.9 for redundancy.
12
+
13
+ on drift, creates a fleet-guard hold (kind=dns_drift) so you can approve
14
+ or revert via telegram/imessage.
15
+ """
16
+
17
+ import json
18
+ import socket
19
+ import subprocess
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ SNAP_DIR = Path("/var/lib/cf-snapshots")
24
+ GUARD = "/usr/local/sbin/fleet-guard"
25
+ NOTIFY = "/usr/local/sbin/notify"
26
+ RESOLVERS = ["1.1.1.1", "8.8.8.8", "9.9.9.9"]
27
+
28
+ # cloudflare's known v4 ranges. updated by /usr/local/sbin/refresh-cf-firewall.sh
29
+ CF_V4_PATH = Path("/etc/iptables/rules.v4")
30
+
31
+
32
+ def cf_ranges():
33
+ """parse cf v4 ranges from the iptables guard chain. fallback to bundled."""
34
+ if CF_V4_PATH.exists():
35
+ ranges = []
36
+ for line in CF_V4_PATH.read_text().splitlines():
37
+ if "CF-DOCKER-GUARD" in line and "-s " in line and "RETURN" in line:
38
+ parts = line.split()
39
+ for i, p in enumerate(parts):
40
+ if p == "-s" and i + 1 < len(parts):
41
+ ranges.append(parts[i + 1])
42
+ if ranges:
43
+ return ranges
44
+ # fallback: hardcoded list (cf publishes <20 ranges)
45
+ return [
46
+ "173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22",
47
+ "103.31.4.0/22", "141.101.64.0/18", "108.162.192.0/18",
48
+ "190.93.240.0/20", "188.114.96.0/20", "197.234.240.0/22",
49
+ "198.41.128.0/17", "162.158.0.0/15", "104.16.0.0/13",
50
+ "104.24.0.0/14", "172.64.0.0/13", "131.0.72.0/22",
51
+ ]
52
+
53
+
54
+ def in_range(ip, ranges):
55
+ import ipaddress
56
+ addr = ipaddress.ip_address(ip)
57
+ for r in ranges:
58
+ try:
59
+ if addr in ipaddress.ip_network(r):
60
+ return True
61
+ except ValueError:
62
+ continue
63
+ return False
64
+
65
+
66
+ def resolve(host, resolver):
67
+ try:
68
+ out = subprocess.run(
69
+ ["dig", "+short", "+tries=1", "+time=2", "A", host, f"@{resolver}"],
70
+ capture_output=True, text=True, timeout=5,
71
+ )
72
+ ips = [line.strip() for line in out.stdout.splitlines() if line.strip()]
73
+ # filter out cnames (lines that don't look like ipv4)
74
+ return [ip for ip in ips if ip.count(".") == 3 and all(p.isdigit() for p in ip.split("."))]
75
+ except Exception:
76
+ return []
77
+
78
+
79
+ def check_zone(zone_path):
80
+ snap = json.loads(zone_path.read_text())
81
+ zone_name = snap["zone"]["name"]
82
+ drifts = []
83
+ proxied_a_records = [
84
+ r for r in snap.get("records", [])
85
+ if r.get("type") == "A" and r.get("proxied") is True
86
+ ]
87
+ cf = cf_ranges()
88
+ for r in proxied_a_records:
89
+ host = r.get("name")
90
+ if not host:
91
+ continue
92
+ all_ips = set()
93
+ for resolver in RESOLVERS:
94
+ all_ips.update(resolve(host, resolver))
95
+ if not all_ips:
96
+ # unresolvable — could be temporary, skip
97
+ continue
98
+ non_cf = [ip for ip in all_ips if not in_range(ip, cf)]
99
+ if non_cf:
100
+ drifts.append({
101
+ "host": host,
102
+ "expected": "cloudflare-proxied",
103
+ "got": sorted(all_ips),
104
+ "non_cf": non_cf,
105
+ "snapshot_content": r.get("content"),
106
+ })
107
+ return zone_name, drifts
108
+
109
+
110
+ def main():
111
+ if not SNAP_DIR.exists():
112
+ sys.exit("no snapshot directory at /var/lib/cf-snapshots")
113
+
114
+ all_drifts = []
115
+ for path in sorted(SNAP_DIR.glob("*.json")):
116
+ zone, drifts = check_zone(path)
117
+ for d in drifts:
118
+ d["zone"] = zone
119
+ all_drifts.append(d)
120
+
121
+ if not all_drifts:
122
+ return 0
123
+
124
+ # one hold per drift
125
+ for d in all_drifts:
126
+ summary = f"DNS drift: {d['host']} resolves to non-CF IPs {d['non_cf']}"
127
+ try:
128
+ subprocess.run(
129
+ [GUARD, "hold", "dns_drift", summary, "--payload", json.dumps(d)],
130
+ check=False, timeout=15,
131
+ )
132
+ except Exception as e:
133
+ subprocess.run([NOTIFY, "dns-drift-watch hold failed", f"{e}\n{summary}"], check=False)
134
+ return 0
135
+
136
+
137
+ if __name__ == "__main__":
138
+ sys.exit(main())