@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.
@@ -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())
@@ -0,0 +1,282 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ fleet-guard — pending-action queue with out-of-band approval.
4
+
5
+ state layout under /var/lib/fleet-guard/:
6
+ pending/<token>.json — awaiting approval. created by `hold`.
7
+ approved/<token>.json — owner approved. picked up by executor.
8
+ processed/<token>.json — executor finished (success or fail).
9
+
10
+ token format: 22-char base32 (random, single-use). 256 bits of entropy.
11
+
12
+ cli verbs (all log to /var/log/fleet-guard/audit.jsonl):
13
+ hold <kind> <summary> [--payload '<json>'] create a pending hold + notify
14
+ list [pending|approved|processed] list tokens with summaries
15
+ show <token> dump one record
16
+ approve <token> [--actor <name>] mark approved
17
+ reject <token> [--actor <name>] mark rejected (deletes record)
18
+ status human summary
19
+ execute run all approved actions
20
+
21
+ approval is also mediated by the daemon (telegram callbacks/messages).
22
+ this cli is the fallback path + ssh-session override.
23
+ """
24
+
25
+ import argparse
26
+ import base64
27
+ import json
28
+ import os
29
+ import secrets
30
+ import shutil
31
+ import subprocess
32
+ import sys
33
+ import time
34
+ from pathlib import Path
35
+ from datetime import datetime, timezone
36
+
37
+ ROOT = Path("/var/lib/fleet-guard")
38
+ LOG = Path("/var/log/fleet-guard/audit.jsonl")
39
+ NOTIFY = "/usr/local/sbin/notify"
40
+ GUARD_CFG = Path("/etc/fleet/guard.json")
41
+ DEFAULT_TTL = 600
42
+
43
+ PENDING = ROOT / "pending"
44
+ APPROVED = ROOT / "approved"
45
+ PROCESSED = ROOT / "processed"
46
+
47
+
48
+ def utcnow():
49
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
50
+
51
+
52
+ def epoch():
53
+ return int(time.time())
54
+
55
+
56
+ def log_event(event):
57
+ LOG.parent.mkdir(parents=True, exist_ok=True)
58
+ entry = {"ts": utcnow(), **event}
59
+ with LOG.open("a") as f:
60
+ f.write(json.dumps(entry, default=str) + "\n")
61
+
62
+
63
+ def gen_token():
64
+ return base64.b32encode(secrets.token_bytes(14)).decode().rstrip("=")
65
+
66
+
67
+ def load_cfg():
68
+ if not GUARD_CFG.exists():
69
+ return {}
70
+ try:
71
+ return json.loads(GUARD_CFG.read_text())
72
+ except Exception:
73
+ return {}
74
+
75
+
76
+ def write_record(dirpath, token, record):
77
+ dirpath.mkdir(parents=True, exist_ok=True)
78
+ path = dirpath / f"{token}.json"
79
+ tmp = path.with_suffix(".json.tmp")
80
+ tmp.write_text(json.dumps(record, indent=2, sort_keys=True))
81
+ tmp.rename(path)
82
+ os.chmod(path, 0o600)
83
+ return path
84
+
85
+
86
+ def find_token(token):
87
+ """search pending/approved/processed for a token"""
88
+ for d in (PENDING, APPROVED, PROCESSED):
89
+ p = d / f"{token}.json"
90
+ if p.exists():
91
+ return d, p
92
+ return None, None
93
+
94
+
95
+ def cmd_hold(args):
96
+ token = gen_token()
97
+ cfg = load_cfg()
98
+ ttl = int(cfg.get("tokenTtlSeconds") or DEFAULT_TTL)
99
+ record = {
100
+ "token": token,
101
+ "kind": args.kind,
102
+ "summary": args.summary,
103
+ "payload": json.loads(args.payload) if args.payload else {},
104
+ "created_at": utcnow(),
105
+ "expires_at_epoch": epoch() + ttl,
106
+ "creator": os.environ.get("SUDO_USER") or os.environ.get("USER") or "unknown",
107
+ "creator_uid": os.getuid(),
108
+ }
109
+ write_record(PENDING, token, record)
110
+ log_event({"event": "hold_created", "token": token, "kind": args.kind, "summary": args.summary})
111
+
112
+ # notify owner
113
+ title = f"action held: {args.kind}"
114
+ body = (
115
+ f"{args.summary}\n"
116
+ f"approve: send `/approve {token}` to bot\n"
117
+ f"or: fleet-guard approve {token}\n"
118
+ f"expires in {ttl // 60}m"
119
+ )
120
+ try:
121
+ subprocess.run([NOTIFY, title, body], check=False, timeout=15)
122
+ except Exception:
123
+ pass
124
+ print(token)
125
+ return 0
126
+
127
+
128
+ def cmd_list(args):
129
+ target = {"pending": PENDING, "approved": APPROVED, "processed": PROCESSED}.get(args.where, PENDING)
130
+ rows = []
131
+ if target.exists():
132
+ for path in sorted(target.glob("*.json")):
133
+ r = json.loads(path.read_text())
134
+ rows.append((r["token"], r.get("kind", "?"), r.get("summary", "")[:60]))
135
+ for t, k, s in rows:
136
+ print(f"{t} {k:<20} {s}")
137
+ if not rows:
138
+ print(f"(no records in {args.where})")
139
+ return 0
140
+
141
+
142
+ def cmd_show(args):
143
+ _, path = find_token(args.token)
144
+ if not path:
145
+ print(f"token not found: {args.token}", file=sys.stderr)
146
+ return 1
147
+ print(path.read_text())
148
+ return 0
149
+
150
+
151
+ def _expire_check(record):
152
+ return epoch() > int(record.get("expires_at_epoch", 0))
153
+
154
+
155
+ def cmd_approve(args):
156
+ src, path = find_token(args.token)
157
+ if path is None:
158
+ print(f"token not found: {args.token}", file=sys.stderr)
159
+ return 1
160
+ if src != PENDING:
161
+ print(f"token not in pending state (in {src.name})", file=sys.stderr)
162
+ return 1
163
+ record = json.loads(path.read_text())
164
+ if _expire_check(record):
165
+ log_event({"event": "approve_expired", "token": args.token, "actor": args.actor})
166
+ path.unlink()
167
+ print("token expired", file=sys.stderr)
168
+ return 1
169
+ record["approved_at"] = utcnow()
170
+ record["approver"] = args.actor or os.environ.get("SUDO_USER") or os.environ.get("USER") or "cli"
171
+ write_record(APPROVED, args.token, record)
172
+ path.unlink()
173
+ log_event({"event": "approved", "token": args.token, "kind": record.get("kind"),
174
+ "approver": record["approver"]})
175
+ # acknowledge
176
+ try:
177
+ subprocess.run([NOTIFY, "approval recorded",
178
+ f"{record.get('kind')} approved by {record['approver']}"],
179
+ check=False, timeout=10)
180
+ except Exception:
181
+ pass
182
+ print(f"approved {args.token}")
183
+ return 0
184
+
185
+
186
+ def cmd_reject(args):
187
+ src, path = find_token(args.token)
188
+ if path is None or src != PENDING:
189
+ print(f"token not in pending state", file=sys.stderr)
190
+ return 1
191
+ record = json.loads(path.read_text())
192
+ record["rejected_at"] = utcnow()
193
+ record["rejecter"] = args.actor or os.environ.get("SUDO_USER") or os.environ.get("USER") or "cli"
194
+ write_record(PROCESSED, args.token, {**record, "outcome": "rejected"})
195
+ path.unlink()
196
+ log_event({"event": "rejected", "token": args.token, "actor": record["rejecter"]})
197
+ print(f"rejected {args.token}")
198
+ return 0
199
+
200
+
201
+ def cmd_status(args):
202
+ counts = {}
203
+ for name, d in (("pending", PENDING), ("approved", APPROVED), ("processed", PROCESSED)):
204
+ counts[name] = len(list(d.glob("*.json"))) if d.exists() else 0
205
+ print(f"pending={counts['pending']} approved={counts['approved']} processed={counts['processed']}")
206
+ if PENDING.exists():
207
+ for path in sorted(PENDING.glob("*.json")):
208
+ r = json.loads(path.read_text())
209
+ ttl = int(r.get("expires_at_epoch", 0)) - epoch()
210
+ print(f" {r['token']} {r.get('kind')} ttl={ttl}s {r.get('summary', '')[:60]}")
211
+ return 0
212
+
213
+
214
+ def cmd_execute(args):
215
+ """invoke executor for each approved record. external script handles each kind."""
216
+ executor = "/usr/local/sbin/fleet-guard-execute"
217
+ if not Path(executor).exists():
218
+ print(f"executor not installed at {executor}", file=sys.stderr)
219
+ return 1
220
+ count = 0
221
+ for path in sorted(APPROVED.glob("*.json")):
222
+ record = json.loads(path.read_text())
223
+ try:
224
+ res = subprocess.run([executor], input=json.dumps(record), capture_output=True,
225
+ text=True, timeout=120)
226
+ ok = res.returncode == 0
227
+ outcome = "executed" if ok else "execute_failed"
228
+ record["executed_at"] = utcnow()
229
+ record["outcome"] = outcome
230
+ record["executor_stdout"] = res.stdout[-2000:]
231
+ record["executor_stderr"] = res.stderr[-2000:]
232
+ write_record(PROCESSED, record["token"], record)
233
+ path.unlink()
234
+ log_event({"event": outcome, "token": record["token"], "kind": record.get("kind")})
235
+ count += 1
236
+ except Exception as e:
237
+ log_event({"event": "execute_error", "token": record["token"], "err": str(e)})
238
+ print(f"processed {count} approvals")
239
+ return 0
240
+
241
+
242
+ def main():
243
+ p = argparse.ArgumentParser(prog="fleet-guard")
244
+ sub = p.add_subparsers(dest="cmd", required=True)
245
+
246
+ sp = sub.add_parser("hold")
247
+ sp.add_argument("kind")
248
+ sp.add_argument("summary")
249
+ sp.add_argument("--payload", default="")
250
+ sp.set_defaults(fn=cmd_hold)
251
+
252
+ sp = sub.add_parser("list")
253
+ sp.add_argument("where", nargs="?", default="pending",
254
+ choices=["pending", "approved", "processed"])
255
+ sp.set_defaults(fn=cmd_list)
256
+
257
+ sp = sub.add_parser("show")
258
+ sp.add_argument("token")
259
+ sp.set_defaults(fn=cmd_show)
260
+
261
+ sp = sub.add_parser("approve")
262
+ sp.add_argument("token")
263
+ sp.add_argument("--actor", default="")
264
+ sp.set_defaults(fn=cmd_approve)
265
+
266
+ sp = sub.add_parser("reject")
267
+ sp.add_argument("token")
268
+ sp.add_argument("--actor", default="")
269
+ sp.set_defaults(fn=cmd_reject)
270
+
271
+ sp = sub.add_parser("status")
272
+ sp.set_defaults(fn=cmd_status)
273
+
274
+ sp = sub.add_parser("execute")
275
+ sp.set_defaults(fn=cmd_execute)
276
+
277
+ args = p.parse_args()
278
+ sys.exit(args.fn(args))
279
+
280
+
281
+ if __name__ == "__main__":
282
+ main()