@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,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()
@@ -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()
@@ -1 +0,0 @@
1
- export declare function motdInstallCommand(): void;
@@ -1,10 +0,0 @@
1
- import { writeFileSync, chmodSync } from 'node:fs';
2
- import { generateMotdScript } from '../templates/motd.js';
3
- import { success } from '../ui/output.js';
4
- const MOTD_PATH = '/etc/update-motd.d/50-fleet-status';
5
- export function motdInstallCommand() {
6
- const script = generateMotdScript();
7
- writeFileSync(MOTD_PATH, script);
8
- chmodSync(MOTD_PATH, 0o755);
9
- success(`Installed MOTD script at ${MOTD_PATH}`);
10
- }
@@ -1 +0,0 @@
1
- export declare function generateMotdScript(): string;
@@ -1,7 +0,0 @@
1
- export function generateMotdScript() {
2
- return `#!/bin/bash
3
- # Fleet service health check — installed by "fleet motd install"
4
- # Shows service status on SSH login
5
- /usr/bin/node /home/matt/fleet/dist/index.js watchdog --motd 2>/dev/null || echo " Fleet: health check failed to run"
6
- `;
7
- }
@@ -1,12 +0,0 @@
1
- import React from 'react';
2
- interface AppListItem {
3
- name: string;
4
- label?: string;
5
- }
6
- interface AppListProps {
7
- items: AppListItem[];
8
- onSelect: (item: AppListItem) => void;
9
- renderItem?: (item: AppListItem, selected: boolean) => React.JSX.Element;
10
- }
11
- export declare function AppList({ items, onSelect, renderItem }: AppListProps): React.JSX.Element;
12
- export {};
@@ -1,32 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState } from 'react';
3
- import { Box, Text, useInput } from 'ink';
4
- import { colors } from '../theme.js';
5
- export function AppList({ items, onSelect, renderItem }) {
6
- const [selectedIndex, setSelectedIndex] = useState(0);
7
- useInput((input, key) => {
8
- if (items.length === 0)
9
- return;
10
- if (input === 'j' || key.downArrow) {
11
- setSelectedIndex(prev => Math.min(prev + 1, items.length - 1));
12
- }
13
- else if (input === 'k' || key.upArrow) {
14
- setSelectedIndex(prev => Math.max(prev - 1, 0));
15
- }
16
- else if (key.return) {
17
- if (items[selectedIndex]) {
18
- onSelect(items[selectedIndex]);
19
- }
20
- }
21
- });
22
- if (items.length === 0) {
23
- return _jsx(Text, { color: colors.muted, children: "No items" });
24
- }
25
- return (_jsx(Box, { flexDirection: "column", children: items.map((item, i) => {
26
- const selected = i === selectedIndex;
27
- if (renderItem) {
28
- return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: selected ? colors.primary : colors.muted, children: selected ? '> ' : ' ' }), renderItem(item, selected)] }, item.name));
29
- }
30
- return (_jsxs(Text, { bold: selected, color: selected ? colors.primary : colors.text, children: [selected ? '> ' : ' ', item.label ?? item.name] }, item.name));
31
- }) }));
32
- }
@@ -1 +0,0 @@
1
- export declare function useKeyboard(): void;