@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,197 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ fleet-guard-execute — executes one approved fleet-guard record.
4
+
5
+ reads a single json record on stdin (the approved hold). dispatches on
6
+ record["kind"]. on success, prints a short summary; on failure, prints
7
+ the error to stderr and exits non-zero.
8
+
9
+ supported kinds:
10
+ noop — for testing the queue
11
+ cf_pause_zone — pauses a cloudflare zone (dev mode style)
12
+ cf_unpause_zone — unpauses a cloudflare zone
13
+ cf_revert_dns_record — restores a dns record to its snapshot value
14
+ cf_block_ip — adds a custom waf rule blocking an ip
15
+
16
+ creds: /etc/fleet/guard.cf.json (root:fleet-guard 640)
17
+ snapshots: /var/lib/cf-snapshots (git-tracked, populated by cf-snapshot)
18
+ """
19
+
20
+ import json
21
+ import sys
22
+ import urllib.parse
23
+ import urllib.request
24
+ from pathlib import Path
25
+
26
+ CFG_PATH = Path("/etc/fleet/guard.cf.json")
27
+ SNAP_DIR = Path("/var/lib/cf-snapshots")
28
+
29
+
30
+ def load_cf():
31
+ cfg = json.loads(CFG_PATH.read_text())
32
+ if not cfg.get("apiKey") or not cfg.get("email"):
33
+ sys.exit("missing cloudflare creds in /etc/fleet/guard.cf.json")
34
+ return cfg["apiKey"], cfg["email"], cfg.get("accountId")
35
+
36
+
37
+ def cf_request(method, path, api_key, email, body=None):
38
+ url = f"https://api.cloudflare.com/client/v4{path}"
39
+ data = json.dumps(body).encode() if body is not None else None
40
+ req = urllib.request.Request(url, data=data, method=method, headers={
41
+ "X-Auth-Email": email,
42
+ "X-Auth-Key": api_key,
43
+ "Content-Type": "application/json",
44
+ })
45
+ with urllib.request.urlopen(req, timeout=20) as r:
46
+ return json.loads(r.read())
47
+
48
+
49
+ def find_zone_id(zone_name, api_key, email):
50
+ qs = urllib.parse.urlencode({"name": zone_name})
51
+ res = cf_request("GET", f"/zones?{qs}", api_key, email)
52
+ results = res.get("result") or []
53
+ if not results:
54
+ raise RuntimeError(f"no zone match for {zone_name}")
55
+ return results[0]["id"]
56
+
57
+
58
+ def kind_noop(payload):
59
+ print(f"noop ok: {payload.get('note', '')}")
60
+ return 0
61
+
62
+
63
+ def kind_cf_pause_zone(payload, api_key, email, account_id):
64
+ zone = payload["zone"]
65
+ zid = find_zone_id(zone, api_key, email)
66
+ res = cf_request("POST", f"/zones/{zid}/pause", api_key, email, body={})
67
+ if not res.get("success"):
68
+ print(json.dumps(res), file=sys.stderr)
69
+ return 1
70
+ print(f"paused {zone}")
71
+ return 0
72
+
73
+
74
+ def kind_cf_unpause_zone(payload, api_key, email, account_id):
75
+ zone = payload["zone"]
76
+ zid = find_zone_id(zone, api_key, email)
77
+ res = cf_request("POST", f"/zones/{zid}/unpause", api_key, email, body={})
78
+ if not res.get("success"):
79
+ print(json.dumps(res), file=sys.stderr)
80
+ return 1
81
+ print(f"unpaused {zone}")
82
+ return 0
83
+
84
+
85
+ def kind_cf_revert_dns_record(payload, api_key, email, account_id):
86
+ """payload: {zone, name, type} — restores from latest snapshot."""
87
+ zone = payload["zone"]
88
+ name = payload["name"]
89
+ rtype = payload["type"]
90
+ snap_path = SNAP_DIR / f"{zone}.json"
91
+ if not snap_path.exists():
92
+ raise RuntimeError(f"no snapshot for {zone}")
93
+ snap = json.loads(snap_path.read_text())
94
+ matches = [r for r in snap.get("records", [])
95
+ if r.get("type") == rtype and r.get("name") == name]
96
+ if not matches:
97
+ raise RuntimeError(f"no snapshot record for {name} {rtype}")
98
+ target = matches[0]
99
+
100
+ zid = find_zone_id(zone, api_key, email)
101
+ # find live record id, if any
102
+ qs = urllib.parse.urlencode({"name": name, "type": rtype})
103
+ live = cf_request("GET", f"/zones/{zid}/dns_records?{qs}", api_key, email)
104
+ live_records = live.get("result") or []
105
+
106
+ body = {
107
+ "type": target["type"],
108
+ "name": target["name"],
109
+ "content": target.get("content"),
110
+ "proxied": target.get("proxied", False),
111
+ "ttl": target.get("ttl", 1),
112
+ }
113
+ if target.get("priority") is not None:
114
+ body["priority"] = target["priority"]
115
+
116
+ if live_records:
117
+ rec_id = live_records[0]["id"]
118
+ res = cf_request("PUT", f"/zones/{zid}/dns_records/{rec_id}", api_key, email, body)
119
+ action = "updated"
120
+ else:
121
+ res = cf_request("POST", f"/zones/{zid}/dns_records", api_key, email, body)
122
+ action = "recreated"
123
+
124
+ if not res.get("success"):
125
+ print(json.dumps(res), file=sys.stderr)
126
+ return 1
127
+ print(f"{action} {rtype} {name} on {zone}")
128
+ return 0
129
+
130
+
131
+ def kind_cf_block_ip(payload, api_key, email, account_id):
132
+ """payload: {zone, ip, note?} — adds a block via cf rulesets."""
133
+ zone = payload["zone"]
134
+ ip = payload["ip"]
135
+ note = payload.get("note") or "fleet-guard auto-block"
136
+ zid = find_zone_id(zone, api_key, email)
137
+
138
+ rule = {
139
+ "action": "block",
140
+ "expression": f'(ip.src eq {ip})',
141
+ "description": f"fleet-guard: {note}",
142
+ "enabled": True,
143
+ }
144
+ body = {
145
+ "rules": [rule],
146
+ }
147
+ # rules go into the http_request_firewall_custom phase entrypoint
148
+ res = cf_request(
149
+ "PUT",
150
+ f"/zones/{zid}/rulesets/phases/http_request_firewall_custom/entrypoint",
151
+ api_key, email, body,
152
+ )
153
+ if not res.get("success"):
154
+ print(json.dumps(res), file=sys.stderr)
155
+ return 1
156
+ print(f"blocked {ip} on {zone}")
157
+ return 0
158
+
159
+
160
+ HANDLERS = {
161
+ "noop": kind_noop,
162
+ "cf_pause_zone": kind_cf_pause_zone,
163
+ "cf_unpause_zone": kind_cf_unpause_zone,
164
+ "cf_revert_dns_record": kind_cf_revert_dns_record,
165
+ "cf_block_ip": kind_cf_block_ip,
166
+ }
167
+
168
+
169
+ def main():
170
+ raw = sys.stdin.read()
171
+ if not raw.strip():
172
+ sys.exit("no record on stdin")
173
+ record = json.loads(raw)
174
+ kind = record.get("kind")
175
+ payload = record.get("payload") or {}
176
+ handler = HANDLERS.get(kind)
177
+ if not handler:
178
+ print(f"unknown kind: {kind}", file=sys.stderr)
179
+ return 1
180
+
181
+ if kind == "noop":
182
+ return handler(payload)
183
+
184
+ api_key, email, account_id = load_cf()
185
+ try:
186
+ return handler(payload, api_key, email, account_id)
187
+ except urllib.error.HTTPError as e:
188
+ body = e.read().decode("utf-8", errors="replace")[:500]
189
+ print(f"cf api {e.code}: {body}", file=sys.stderr)
190
+ return 1
191
+ except Exception as e:
192
+ print(f"executor error: {e}", file=sys.stderr)
193
+ return 1
194
+
195
+
196
+ if __name__ == "__main__":
197
+ sys.exit(main())
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ fan-out notifier. reads /etc/fleet/notify.json (an "adapters" list) and
4
+ dispatches the same message to every adapter that's configured. each
5
+ adapter failure is reported but doesn't block the others.
6
+
7
+ usage: notify "title" "body"
8
+ """
9
+
10
+ import json
11
+ import sys
12
+ import urllib.parse
13
+ import urllib.request
14
+ import uuid
15
+ from pathlib import Path
16
+
17
+ CFG = Path("/etc/fleet/notify.json")
18
+
19
+
20
+ def md_escape(text):
21
+ # markdownv2 escape for telegram
22
+ specials = "_*[]()~`>#+-=|{}.!"
23
+ return "".join("\\" + c if c in specials else c for c in text)
24
+
25
+
26
+ def send_telegram(adapter, title, body):
27
+ token = adapter["botToken"]
28
+ chat = adapter["chatId"]
29
+ text = "*" + md_escape(title) + "*"
30
+ if body:
31
+ text += "\n" + md_escape(body)
32
+ data = urllib.parse.urlencode({
33
+ "chat_id": chat,
34
+ "text": text,
35
+ "parse_mode": "MarkdownV2",
36
+ "disable_web_page_preview": "true",
37
+ }).encode()
38
+ req = urllib.request.Request(
39
+ f"https://api.telegram.org/bot{token}/sendMessage",
40
+ data=data,
41
+ method="POST",
42
+ )
43
+ with urllib.request.urlopen(req, timeout=15) as r:
44
+ return r.status == 200
45
+
46
+
47
+ def send_bluebubbles(adapter, title, body):
48
+ base = adapter["serverUrl"].rstrip("/")
49
+ pwd = adapter["password"]
50
+ chat = adapter["chatGuid"]
51
+ text = title if not body else f"{title}\n{body}"
52
+ payload = json.dumps({
53
+ "chatGuid": chat,
54
+ "message": text,
55
+ "method": "apple-script",
56
+ "tempGuid": str(uuid.uuid4()),
57
+ }).encode()
58
+ headers = {
59
+ "Content-Type": "application/json",
60
+ "User-Agent": "Mozilla/5.0 (server-notify) AppleWebKit/537.36",
61
+ }
62
+ if adapter.get("cfAccessClientId"):
63
+ headers["CF-Access-Client-Id"] = adapter["cfAccessClientId"]
64
+ headers["CF-Access-Client-Secret"] = adapter["cfAccessClientSecret"]
65
+ url = f"{base}/api/v1/message/text?password={urllib.parse.quote(pwd)}"
66
+ req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
67
+ with urllib.request.urlopen(req, timeout=45) as r:
68
+ return r.status == 200
69
+
70
+
71
+ DISPATCH = {
72
+ "telegram": send_telegram,
73
+ "bluebubbles": send_bluebubbles,
74
+ }
75
+
76
+
77
+ def main():
78
+ if len(sys.argv) < 2:
79
+ sys.exit("usage: notify <title> [body]")
80
+ title = sys.argv[1]
81
+ body = sys.argv[2] if len(sys.argv) > 2 else ""
82
+
83
+ if not CFG.exists():
84
+ sys.exit(f"missing {CFG}")
85
+ cfg = json.loads(CFG.read_text())
86
+ adapters = cfg.get("adapters") or []
87
+ failures = []
88
+ for ad in adapters:
89
+ kind = ad.get("type")
90
+ fn = DISPATCH.get(kind)
91
+ if not fn:
92
+ failures.append(f"unknown adapter: {kind}")
93
+ continue
94
+ try:
95
+ fn(ad, title, body)
96
+ except Exception as e:
97
+ failures.append(f"{kind}: {e}")
98
+ if failures:
99
+ # write to stderr but keep going; cron mail will surface it
100
+ for f in failures:
101
+ print(f, file=sys.stderr)
102
+ # only fail outright if every adapter failed
103
+ if len(failures) == len(adapters):
104
+ sys.exit(1)
105
+
106
+
107
+ if __name__ == "__main__":
108
+ main()