@matthesketh/fleet 1.7.0 → 1.8.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.
@@ -17,6 +17,7 @@ const STATE_DIR = '/var/lib/fleet-guard';
17
17
  const LOG_DIR = '/var/log/fleet-guard';
18
18
  const SNAP_DIR = '/var/lib/cf-snapshots';
19
19
  const CRON_TARGET = '/etc/cron.d/cf-protect';
20
+ const LOGROTATE_TARGET = '/etc/logrotate.d/fleet-guard';
20
21
  function scriptsDir() {
21
22
  // dist/commands/guard.js -> ../../scripts/guard relative to compiled file
22
23
  const here = dirname(fileURLToPath(import.meta.url));
@@ -77,6 +78,16 @@ PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
77
78
  writeFileSync(CRON_TARGET, cron, { mode: 0o644 });
78
79
  info(`installed cron at ${CRON_TARGET}`);
79
80
  }
81
+ function installLogrotate() {
82
+ const src = join(scriptsDir(), 'logrotate.d-fleet-guard');
83
+ if (!existsSync(src)) {
84
+ info('logrotate template missing, skipping');
85
+ return;
86
+ }
87
+ copyFileSync(src, LOGROTATE_TARGET);
88
+ chmodSync(LOGROTATE_TARGET, 0o644);
89
+ info(`installed logrotate at ${LOGROTATE_TARGET}`);
90
+ }
80
91
  function installCommand() {
81
92
  requireRoot();
82
93
  ensureUser();
@@ -88,6 +99,7 @@ function installCommand() {
88
99
  ensureDir(SNAP_DIR, 0o700);
89
100
  installScripts();
90
101
  installCron();
102
+ installLogrotate();
91
103
  success('fleet guard installed.');
92
104
  info('next steps:');
93
105
  info(' 1. seed creds at /etc/fleet/guard.cf.json (cloudflare api key + email + accountId)');
@@ -113,6 +125,9 @@ function helpText() {
113
125
  ' reject <token> reject a pending action',
114
126
  ' show <token> dump one record',
115
127
  ' execute run all approved actions',
128
+ ' policy show [zone] print policy (or effective hold list for zone)',
129
+ ' policy set <zone|default> <list> override hold list (comma-separated)',
130
+ ' policy reset [zone] reset zone (or all) to defaults',
116
131
  ].join('\n');
117
132
  }
118
133
  export function guardCommand(args) {
@@ -131,7 +146,7 @@ export function guardCommand(args) {
131
146
  }
132
147
  return;
133
148
  }
134
- const passthrough = new Set(['status', 'list', 'hold', 'approve', 'reject', 'show', 'execute']);
149
+ const passthrough = new Set(['status', 'list', 'hold', 'approve', 'reject', 'show', 'execute', 'policy']);
135
150
  if (passthrough.has(sub)) {
136
151
  const code = delegate(sub, rest);
137
152
  if (code !== 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matthesketh/fleet",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Docker production management CLI + MCP server for Claude Code",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -34,15 +34,9 @@ NOISY_ACTIONS = {
34
34
  "search",
35
35
  }
36
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
- }
37
+ # hold/notify decision is delegated to `fleet-guard policy effective` so
38
+ # admins can override per-zone via /etc/fleet/guard.policy.json. defaults
39
+ # match the destructive / hijack-shaped action types and live in fleet-guard.
46
40
 
47
41
 
48
42
  def read_creds():
@@ -97,6 +91,20 @@ def classify(action_type):
97
91
  return "alert"
98
92
 
99
93
 
94
+ def policy_decision(action, zone):
95
+ """ask fleet-guard which bucket this action falls in for the given zone.
96
+ on any error fall back to 'notify' so we don't drop signals."""
97
+ try:
98
+ cmd = [GUARD, "policy", "effective", action]
99
+ if zone:
100
+ cmd.extend(["--zone", zone])
101
+ res = run(cmd, capture_output=True, text=True, timeout=10)
102
+ decision = (res.stdout or "").strip()
103
+ return decision if decision in ("hold", "notify") else "notify"
104
+ except Exception:
105
+ return "notify"
106
+
107
+
100
108
  def notify(title, body):
101
109
  run([NOTIFIER, title, body], check=False)
102
110
 
@@ -141,7 +149,8 @@ def main():
141
149
  alerts = [ev for ev in events if classify((ev.get("action") or {}).get("type")) == "alert"]
142
150
  for ev in alerts:
143
151
  action = (ev.get("action") or {}).get("type") or ""
144
- if action in HOLD_ACTIONS:
152
+ zone = (ev.get("metadata") or {}).get("zone") or (ev.get("resource") or {}).get("id", "") if (ev.get("resource") or {}).get("type") == "zone" else ""
153
+ if policy_decision(action, zone) == "hold":
145
154
  # destructive — create a fleet-guard hold instead of just notifying.
146
155
  # the cli itself fires the notification with approval token.
147
156
  payload = {
@@ -38,12 +38,24 @@ ROOT = Path("/var/lib/fleet-guard")
38
38
  LOG = Path("/var/log/fleet-guard/audit.jsonl")
39
39
  NOTIFY = "/usr/local/sbin/notify"
40
40
  GUARD_CFG = Path("/etc/fleet/guard.json")
41
+ POLICY_CFG = Path("/etc/fleet/guard.policy.json")
41
42
  DEFAULT_TTL = 600
42
43
 
43
44
  PENDING = ROOT / "pending"
44
45
  APPROVED = ROOT / "approved"
45
46
  PROCESSED = ROOT / "processed"
46
47
 
48
+ # baseline policy: which audit-log action types create a hold (require
49
+ # approval) vs just notify. cf-audit-monitor consults `policy effective`
50
+ # per-event so admins can override per-zone via /etc/fleet/guard.policy.json.
51
+ DEFAULT_HOLD_ACTIONS = [
52
+ "zone_delete", "zone_create", "zone_settings_change",
53
+ "ns_change", "transfer_in", "transfer_out",
54
+ "member_add", "member_remove",
55
+ "api_token_create", "api_token_delete",
56
+ "ssl_change",
57
+ ]
58
+
47
59
 
48
60
  def utcnow():
49
61
  return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -211,6 +223,78 @@ def cmd_status(args):
211
223
  return 0
212
224
 
213
225
 
226
+ def load_policy():
227
+ """returns {'default': {'hold': [...]}, 'zones': {<zone>: {'hold': [...]}}}.
228
+ missing file or invalid json → defaults only."""
229
+ if not POLICY_CFG.exists():
230
+ return {"default": {"hold": list(DEFAULT_HOLD_ACTIONS)}, "zones": {}}
231
+ try:
232
+ raw = json.loads(POLICY_CFG.read_text())
233
+ except Exception:
234
+ return {"default": {"hold": list(DEFAULT_HOLD_ACTIONS)}, "zones": {}}
235
+ raw.setdefault("default", {})
236
+ raw["default"].setdefault("hold", list(DEFAULT_HOLD_ACTIONS))
237
+ raw.setdefault("zones", {})
238
+ return raw
239
+
240
+
241
+ def save_policy(policy):
242
+ POLICY_CFG.parent.mkdir(parents=True, exist_ok=True)
243
+ tmp = POLICY_CFG.with_suffix(".json.tmp")
244
+ tmp.write_text(json.dumps(policy, indent=2, sort_keys=True))
245
+ tmp.rename(POLICY_CFG)
246
+ os.chmod(POLICY_CFG, 0o640)
247
+
248
+
249
+ def effective_hold_list(policy, zone):
250
+ """zone-level override wins; falls back to default."""
251
+ if zone:
252
+ z = policy.get("zones", {}).get(zone)
253
+ if z and "hold" in z:
254
+ return z["hold"]
255
+ return policy.get("default", {}).get("hold", DEFAULT_HOLD_ACTIONS)
256
+
257
+
258
+ def cmd_policy(args):
259
+ sub = args.policy_cmd
260
+ policy = load_policy()
261
+ if sub == "show":
262
+ if args.zone:
263
+ hold = effective_hold_list(policy, args.zone)
264
+ print(json.dumps({"zone": args.zone, "hold": hold}, indent=2))
265
+ else:
266
+ print(json.dumps(policy, indent=2, sort_keys=True))
267
+ return 0
268
+ if sub == "set":
269
+ actions = [a.strip() for a in args.actions.split(",") if a.strip()]
270
+ if args.zone == "default":
271
+ policy.setdefault("default", {})["hold"] = actions
272
+ else:
273
+ policy.setdefault("zones", {}).setdefault(args.zone, {})["hold"] = actions
274
+ save_policy(policy)
275
+ log_event({"event": "policy_set", "zone": args.zone, "actions": actions})
276
+ print(f"set {args.zone} hold={actions}")
277
+ return 0
278
+ if sub == "reset":
279
+ if args.zone == "default" or args.zone == "":
280
+ policy["default"] = {"hold": list(DEFAULT_HOLD_ACTIONS)}
281
+ policy["zones"] = {}
282
+ else:
283
+ policy.get("zones", {}).pop(args.zone, None)
284
+ save_policy(policy)
285
+ log_event({"event": "policy_reset", "zone": args.zone or "all"})
286
+ print(f"reset {args.zone or 'all'}")
287
+ return 0
288
+ if sub == "effective":
289
+ # used by cf-audit-monitor. prints "hold" or "notify" then exits 0.
290
+ hold = effective_hold_list(policy, args.zone)
291
+ decision = "hold" if args.action in hold else "notify"
292
+ print(decision)
293
+ return 0
294
+ print(f"unknown policy subcommand: {sub}", file=sys.stderr)
295
+ return 1
296
+
297
+
214
298
  def cmd_execute(args):
215
299
  """invoke executor for each approved record. external script handles each kind."""
216
300
  executor = "/usr/local/sbin/fleet-guard-execute"
@@ -274,6 +358,20 @@ def main():
274
358
  sp = sub.add_parser("execute")
275
359
  sp.set_defaults(fn=cmd_execute)
276
360
 
361
+ sp = sub.add_parser("policy", help="view/edit hold-action policy")
362
+ psub = sp.add_subparsers(dest="policy_cmd", required=True)
363
+ pshow = psub.add_parser("show", help="print policy (or effective hold list for zone)")
364
+ pshow.add_argument("zone", nargs="?", default="")
365
+ pset = psub.add_parser("set", help="set hold-action list for default or zone")
366
+ pset.add_argument("zone")
367
+ pset.add_argument("actions", help="comma-separated action types")
368
+ preset = psub.add_parser("reset", help="reset policy: omit zone to wipe all overrides")
369
+ preset.add_argument("zone", nargs="?", default="")
370
+ peff = psub.add_parser("effective", help="print 'hold' or 'notify' for an action+zone")
371
+ peff.add_argument("action")
372
+ peff.add_argument("--zone", default="")
373
+ sp.set_defaults(fn=cmd_policy)
374
+
277
375
  args = p.parse_args()
278
376
  sys.exit(args.fn(args))
279
377
 
@@ -0,0 +1,31 @@
1
+ /var/log/fleet-guard/*.log
2
+ /var/log/cf-audit-monitor.log
3
+ /var/log/cf-snapshot.log
4
+ /var/log/dns-drift-watch.log
5
+ /var/log/cert-expiry-watch.log
6
+ /var/log/refresh-cf-firewall.log
7
+ {
8
+ daily
9
+ rotate 30
10
+ compress
11
+ delaycompress
12
+ missingok
13
+ notifempty
14
+ create 600 fleet-guard fleet-guard
15
+ sharedscripts
16
+ su root root
17
+ }
18
+
19
+ /var/log/fleet-guard/audit.jsonl
20
+ /var/log/mcp-audit.jsonl
21
+ {
22
+ monthly
23
+ rotate 12
24
+ compress
25
+ delaycompress
26
+ missingok
27
+ notifempty
28
+ copytruncate
29
+ create 600 root root
30
+ su root root
31
+ }