@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.
- 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 +46 -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/guard.d.ts +1 -0
- package/dist/commands/guard.js +144 -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/KeyHint.js +10 -0
- 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/router.js +60 -7
- 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 +1 -1
- package/dist/tui/tests/keyboard-integration.test.js +3 -0
- package/dist/tui/tests/test-app.js +1 -1
- package/dist/tui/types.d.ts +2 -2
- package/dist/tui/views/AppDetail.js +3 -4
- package/dist/tui/views/HealthView.js +7 -1
- package/dist/tui/views/LogsView.js +24 -1
- package/dist/tui/views/MultiLogsView.d.ts +2 -0
- package/dist/tui/views/MultiLogsView.js +165 -0
- package/dist/tui/views/SecretEdit.js +10 -3
- package/dist/tui/views/SecretsView.js +6 -3
- package/dist/ui/prompt.d.ts +52 -0
- package/dist/ui/prompt.js +169 -0
- package/package.json +34 -21
- 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/commands/motd.d.ts +0 -1
- package/dist/commands/motd.js +0 -10
- package/dist/templates/motd.d.ts +0 -1
- package/dist/templates/motd.js +0 -7
- package/dist/tui/components/AppList.d.ts +0 -12
- package/dist/tui/components/AppList.js +0 -32
- package/dist/tui/hooks/use-keyboard.d.ts +0 -1
- package/dist/tui/hooks/use-keyboard.js +0 -44
|
@@ -0,0 +1,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())
|