@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,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()
|
package/dist/commands/motd.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare function motdInstallCommand(): void;
|
package/dist/commands/motd.js
DELETED
|
@@ -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
|
-
}
|
package/dist/templates/motd.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare function generateMotdScript(): string;
|
package/dist/templates/motd.js
DELETED
|
@@ -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;
|