wtftools 0.0.0__py3-none-any.whl
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.
- wtftools/__init__.py +55 -0
- wtftools/__main__.py +10 -0
- wtftools/audit.py +809 -0
- wtftools/colors.py +111 -0
- wtftools/config.py +249 -0
- wtftools/cron.py +388 -0
- wtftools/events.py +220 -0
- wtftools/explain.py +290 -0
- wtftools/info.py +90 -0
- wtftools/llm.py +129 -0
- wtftools/main.py +1328 -0
- wtftools/snapshot.py +203 -0
- wtftools/sysinfo.py +1608 -0
- wtftools-0.0.0.data/data/share/bash-completion/completions/wtf.bash-completion +134 -0
- wtftools-0.0.0.dist-info/METADATA +246 -0
- wtftools-0.0.0.dist-info/RECORD +20 -0
- wtftools-0.0.0.dist-info/WHEEL +5 -0
- wtftools-0.0.0.dist-info/entry_points.txt +3 -0
- wtftools-0.0.0.dist-info/licenses/LICENSE +21 -0
- wtftools-0.0.0.dist-info/top_level.txt +1 -0
wtftools/main.py
ADDED
|
@@ -0,0 +1,1328 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""CLI entrypoint for `wtf` / `wtftools`."""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
from dataclasses import asdict
|
|
14
|
+
from typing import Dict, List, Optional, Tuple
|
|
15
|
+
|
|
16
|
+
from wtftools import (
|
|
17
|
+
__description__,
|
|
18
|
+
__url__,
|
|
19
|
+
__version__,
|
|
20
|
+
colors,
|
|
21
|
+
cron,
|
|
22
|
+
sysinfo,
|
|
23
|
+
)
|
|
24
|
+
from wtftools import (
|
|
25
|
+
audit as audit_mod,
|
|
26
|
+
)
|
|
27
|
+
from wtftools import (
|
|
28
|
+
config as config_mod,
|
|
29
|
+
)
|
|
30
|
+
from wtftools import (
|
|
31
|
+
events as events_mod,
|
|
32
|
+
)
|
|
33
|
+
from wtftools import (
|
|
34
|
+
explain as explain_mod,
|
|
35
|
+
)
|
|
36
|
+
from wtftools import (
|
|
37
|
+
info as info_mod,
|
|
38
|
+
)
|
|
39
|
+
from wtftools import (
|
|
40
|
+
llm as llm_mod,
|
|
41
|
+
)
|
|
42
|
+
from wtftools import (
|
|
43
|
+
snapshot as snapshot_mod,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
LOGGING = {
|
|
49
|
+
"format": "%(asctime)s.%(msecs)03d [%(levelname)s]: (%(name)s) %(message)s",
|
|
50
|
+
"level": logging.WARNING,
|
|
51
|
+
"datefmt": "%Y-%m-%d %H:%M:%S",
|
|
52
|
+
}
|
|
53
|
+
logging.basicConfig(**LOGGING)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
STATUS_FILTERS = {
|
|
57
|
+
"fail": ["fail"],
|
|
58
|
+
"warn": ["warn", "fail"], # "warn" implies "everything not-OK"
|
|
59
|
+
"problems": ["warn", "fail"],
|
|
60
|
+
"problem": ["warn", "fail"], # singular alias for plural form
|
|
61
|
+
"skip": ["skip"],
|
|
62
|
+
"ok": ["ok"],
|
|
63
|
+
"all": ["ok", "warn", "fail", "skip"],
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cmd_top(args: argparse.Namespace) -> int:
|
|
68
|
+
"""Focused live top — by CPU or RSS, optionally filtered by user/name."""
|
|
69
|
+
by = args.sort
|
|
70
|
+
procs = sysinfo.get_top_processes(by=by, limit=args.limit * 4)
|
|
71
|
+
if args.user:
|
|
72
|
+
procs = [p for p in procs if str(p.get("user") or "").startswith(args.user)]
|
|
73
|
+
if args.name:
|
|
74
|
+
pattern = args.name.lower()
|
|
75
|
+
procs = [p for p in procs if pattern in str(p.get("name") or "").lower()]
|
|
76
|
+
procs = procs[: args.limit]
|
|
77
|
+
|
|
78
|
+
if args.format == "json":
|
|
79
|
+
print(json.dumps(procs, indent=2, default=str))
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
if not procs:
|
|
83
|
+
print(colors.dim("(no matching processes)"))
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
title = f"TOP {args.limit} BY {by.upper()}"
|
|
87
|
+
if args.user:
|
|
88
|
+
title += f" · user={args.user}"
|
|
89
|
+
if args.name:
|
|
90
|
+
title += f" · name~{args.name}"
|
|
91
|
+
print(colors.section(title))
|
|
92
|
+
print(f" {'PID':>7} {'USER':<12} {'CPU%':>5} {'RSS':>8} COMMAND")
|
|
93
|
+
for p in procs:
|
|
94
|
+
rss = sysinfo.format_bytes(p.get("rss", 0))
|
|
95
|
+
cpu = p.get("cpu_percent", 0.0)
|
|
96
|
+
user = str(p.get("user") or "")[:12]
|
|
97
|
+
print(f" {p['pid']:>7} {user:<12} {cpu:5.1f} {rss:>8} {p.get('name', '')}")
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def cmd_ports(args: argparse.Namespace) -> int:
|
|
102
|
+
"""Listening sockets with owning process info."""
|
|
103
|
+
try:
|
|
104
|
+
import psutil # type: ignore
|
|
105
|
+
except ImportError:
|
|
106
|
+
msg = "psutil is required for `wtf ports` (install via `pip install wtftools[full]`)"
|
|
107
|
+
if args.format == "json":
|
|
108
|
+
print(json.dumps({"error": msg}))
|
|
109
|
+
else:
|
|
110
|
+
print(colors.red(msg))
|
|
111
|
+
return 2
|
|
112
|
+
|
|
113
|
+
import socket as _socket
|
|
114
|
+
|
|
115
|
+
rows: List[dict] = []
|
|
116
|
+
try:
|
|
117
|
+
for conn in psutil.net_connections(kind="inet"):
|
|
118
|
+
if conn.status != psutil.CONN_LISTEN and conn.type != _socket.SOCK_DGRAM:
|
|
119
|
+
continue
|
|
120
|
+
if conn.type == _socket.SOCK_DGRAM and conn.status != "NONE":
|
|
121
|
+
continue
|
|
122
|
+
if not conn.laddr:
|
|
123
|
+
continue
|
|
124
|
+
proto = "udp" if conn.type == _socket.SOCK_DGRAM else "tcp"
|
|
125
|
+
if args.proto != "all" and args.proto != proto:
|
|
126
|
+
continue
|
|
127
|
+
addr = conn.laddr.ip
|
|
128
|
+
if args.public_only and (addr in ("127.0.0.1", "::1", "0.0.0.0", "::") or addr.startswith("127.")):
|
|
129
|
+
# public_only means: exclude loopback specifically. We keep wildcard.
|
|
130
|
+
if addr.startswith("127."):
|
|
131
|
+
continue
|
|
132
|
+
pid = conn.pid
|
|
133
|
+
name = ""
|
|
134
|
+
user = ""
|
|
135
|
+
if pid:
|
|
136
|
+
try:
|
|
137
|
+
proc = psutil.Process(pid)
|
|
138
|
+
name = proc.name()
|
|
139
|
+
user = proc.username()
|
|
140
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
141
|
+
pass
|
|
142
|
+
rows.append(
|
|
143
|
+
{
|
|
144
|
+
"port": conn.laddr.port,
|
|
145
|
+
"proto": proto,
|
|
146
|
+
"addr": addr,
|
|
147
|
+
"pid": pid,
|
|
148
|
+
"user": user,
|
|
149
|
+
"command": name,
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
except Exception as exc:
|
|
153
|
+
msg = f"failed to enumerate sockets: {type(exc).__name__}: {exc}"
|
|
154
|
+
if args.format == "json":
|
|
155
|
+
print(json.dumps({"error": msg}))
|
|
156
|
+
else:
|
|
157
|
+
print(colors.red(msg))
|
|
158
|
+
return 1
|
|
159
|
+
|
|
160
|
+
rows.sort(key=lambda r: (r["proto"], r["port"]))
|
|
161
|
+
|
|
162
|
+
if args.format == "json":
|
|
163
|
+
print(json.dumps(rows, indent=2, default=str))
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
print(colors.section("LISTENING PORTS"))
|
|
167
|
+
if not rows:
|
|
168
|
+
print(colors.dim(" (none)"))
|
|
169
|
+
return 0
|
|
170
|
+
print(f" {'PORT':>5} {'PROTO':<5} {'ADDR':<20} {'PID':>7} " f"{'USER':<14} COMMAND")
|
|
171
|
+
for r in rows:
|
|
172
|
+
addr = r["addr"] or "*"
|
|
173
|
+
pid_s = str(r["pid"]) if r["pid"] else "-"
|
|
174
|
+
print(f" {r['port']:>5} {r['proto']:<5} {addr:<20} {pid_s:>7} " f"{(r['user'] or '-')[:14]:<14} {r['command']}")
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def cmd_diff(args: argparse.Namespace) -> int:
|
|
179
|
+
"""Standalone diff: compare a stored snapshot against the latest live audit.
|
|
180
|
+
|
|
181
|
+
Without arguments → latest-saved vs current. With --snapshot N → Nth-most-
|
|
182
|
+
recent vs current. With --against PATH1 PATH2 → two snapshot files.
|
|
183
|
+
"""
|
|
184
|
+
if args.against:
|
|
185
|
+
if len(args.against) != 2:
|
|
186
|
+
print(colors.red("--against takes exactly 2 paths"))
|
|
187
|
+
return 2
|
|
188
|
+
old_path, new_path = args.against
|
|
189
|
+
old = snapshot_mod.load_snapshot(old_path)
|
|
190
|
+
new_data = snapshot_mod.load_snapshot(new_path)
|
|
191
|
+
if old is None or new_data is None:
|
|
192
|
+
print(colors.red("cannot read one or both snapshot files"))
|
|
193
|
+
return 1
|
|
194
|
+
# rebuild CheckResult list from new snapshot
|
|
195
|
+
new_results = [
|
|
196
|
+
audit_mod.CheckResult(name=r["name"], status=r["status"], message=r.get("message", ""), detail=r.get("detail", []) or []) for r in new_data.get("results", [])
|
|
197
|
+
]
|
|
198
|
+
return _render_diff(args, old, new_results, old_path=old_path, new_path=new_path)
|
|
199
|
+
|
|
200
|
+
# Pick the snapshot to diff against: latest by default, or N back.
|
|
201
|
+
paths = snapshot_mod.list_snapshots()
|
|
202
|
+
if not paths:
|
|
203
|
+
if args.format == "json":
|
|
204
|
+
print(json.dumps({"diff": [], "reason": "no snapshots"}, indent=2))
|
|
205
|
+
else:
|
|
206
|
+
print(colors.dim("no snapshots yet — run `wtf audit --save` to create one"))
|
|
207
|
+
return 0
|
|
208
|
+
idx = max(0, len(paths) - 1 - args.snapshot)
|
|
209
|
+
chosen = paths[idx]
|
|
210
|
+
old = snapshot_mod.load_snapshot(chosen)
|
|
211
|
+
if old is None:
|
|
212
|
+
print(colors.red(f"cannot read {chosen}"))
|
|
213
|
+
return 1
|
|
214
|
+
# Run a fresh audit to get the "new" side
|
|
215
|
+
args.check = None
|
|
216
|
+
args.ignore = []
|
|
217
|
+
args.since = 24
|
|
218
|
+
args.serial = False
|
|
219
|
+
args.check_timeout = None
|
|
220
|
+
args.only = None
|
|
221
|
+
results, _ = _run_audit_once(args)
|
|
222
|
+
return _render_diff(args, old, results, old_path=chosen)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _render_diff(args: argparse.Namespace, old: dict, new_results: List[audit_mod.CheckResult], old_path: Optional[str] = None, new_path: Optional[str] = None) -> int:
|
|
226
|
+
events = snapshot_mod.diff_snapshots(old, new_results)
|
|
227
|
+
if args.format == "json":
|
|
228
|
+
print(
|
|
229
|
+
json.dumps(
|
|
230
|
+
{
|
|
231
|
+
"old": old_path,
|
|
232
|
+
"new": new_path,
|
|
233
|
+
"old_timestamp": old.get("timestamp"),
|
|
234
|
+
"changes": events,
|
|
235
|
+
},
|
|
236
|
+
indent=2,
|
|
237
|
+
default=str,
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
return 0
|
|
241
|
+
label = old.get("timestamp", "previous") if old_path else "previous"
|
|
242
|
+
if old_path:
|
|
243
|
+
label += f" ({os.path.basename(old_path)})"
|
|
244
|
+
print(colors.section(f"DIFF vs {label}"))
|
|
245
|
+
if not events:
|
|
246
|
+
print(colors.green(" (nothing changed)"))
|
|
247
|
+
return 0
|
|
248
|
+
kind_marker = {
|
|
249
|
+
"regression": colors.red("REG ", bold=True),
|
|
250
|
+
"worsened": colors.yellow("WRSE", bold=True),
|
|
251
|
+
"new": colors.cyan("NEW ", bold=True),
|
|
252
|
+
"improved": colors.green("IMP ", bold=True),
|
|
253
|
+
"recovery": colors.green("FIX ", bold=True),
|
|
254
|
+
"removed": colors.dim("GONE"),
|
|
255
|
+
}
|
|
256
|
+
name_width = max((len(e["name"]) for e in events), default=20)
|
|
257
|
+
for ev in events:
|
|
258
|
+
marker = kind_marker.get(ev["kind"], ev["kind"])
|
|
259
|
+
if ev["kind"] == "new":
|
|
260
|
+
transition = colors.dim(f" ↘ {ev['new_status']}")
|
|
261
|
+
elif ev["kind"] == "removed":
|
|
262
|
+
transition = colors.dim(f"{ev['old_status']} ↗")
|
|
263
|
+
else:
|
|
264
|
+
transition = f"{ev['old_status']:>4} → {ev['new_status']:<4}"
|
|
265
|
+
msg = ev.get("new_message") or ev.get("old_message") or ""
|
|
266
|
+
print(f" {marker} {ev['name'].ljust(name_width)} " f"{transition} {colors.dim(msg)}")
|
|
267
|
+
return 0
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def cmd_history(args: argparse.Namespace) -> int:
|
|
271
|
+
"""List stored audit snapshots with summaries."""
|
|
272
|
+
paths = snapshot_mod.list_snapshots()
|
|
273
|
+
if args.format == "json":
|
|
274
|
+
payload = []
|
|
275
|
+
for p in paths:
|
|
276
|
+
data = snapshot_mod.load_snapshot(p) or {}
|
|
277
|
+
results = data.get("results", []) or []
|
|
278
|
+
counts = {"ok": 0, "warn": 0, "fail": 0, "skip": 0}
|
|
279
|
+
for r in results:
|
|
280
|
+
counts[r.get("status", "skip")] = counts.get(r.get("status", "skip"), 0) + 1
|
|
281
|
+
payload.append(
|
|
282
|
+
{
|
|
283
|
+
"path": p,
|
|
284
|
+
"timestamp": data.get("timestamp"),
|
|
285
|
+
"host": data.get("host"),
|
|
286
|
+
"totals": counts,
|
|
287
|
+
}
|
|
288
|
+
)
|
|
289
|
+
print(json.dumps(payload, indent=2, default=str))
|
|
290
|
+
return 0
|
|
291
|
+
if not paths:
|
|
292
|
+
print(colors.dim("no snapshots yet — run `wtf audit --save` to create one"))
|
|
293
|
+
print(colors.dim(f"snapshot dir: {snapshot_mod.default_snapshot_dir()}"))
|
|
294
|
+
return 0
|
|
295
|
+
print(colors.section("HISTORY"))
|
|
296
|
+
print(colors.dim(f" snapshot dir: {snapshot_mod.default_snapshot_dir()}"))
|
|
297
|
+
print(colors.dim(f" {len(paths)} snapshot(s) — newest first\n"))
|
|
298
|
+
for path in reversed(paths[-args.limit :]):
|
|
299
|
+
data = snapshot_mod.load_snapshot(path)
|
|
300
|
+
if data is None:
|
|
301
|
+
print(f" {colors.red('(corrupt)')} {path}")
|
|
302
|
+
continue
|
|
303
|
+
results = data.get("results", []) or []
|
|
304
|
+
ok = sum(1 for r in results if r.get("status") == "ok")
|
|
305
|
+
warn = sum(1 for r in results if r.get("status") == "warn")
|
|
306
|
+
fail = sum(1 for r in results if r.get("status") == "fail")
|
|
307
|
+
skip = sum(1 for r in results if r.get("status") == "skip")
|
|
308
|
+
ts = data.get("timestamp", "?")
|
|
309
|
+
summary = f"{colors.green(f'{ok} ok')} · " f"{colors.yellow(f'{warn} warn')} · " f"{colors.red(f'{fail} fail')} · " f"{colors.dim(f'{skip} skip')}"
|
|
310
|
+
print(f" {colors.cyan(ts):<35} {summary} {colors.dim(os.path.basename(path))}")
|
|
311
|
+
return 0
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def cmd_info(args: argparse.Namespace) -> int:
|
|
315
|
+
if args.format == "json":
|
|
316
|
+
from wtftools import sysinfo
|
|
317
|
+
|
|
318
|
+
payload = {
|
|
319
|
+
"hostname": sysinfo.get_hostname(),
|
|
320
|
+
"os": sysinfo.get_os_release(),
|
|
321
|
+
"kernel": sysinfo.get_kernel(),
|
|
322
|
+
"uptime_seconds": sysinfo.get_uptime_seconds(),
|
|
323
|
+
"cpu_model": sysinfo.get_cpu_model(),
|
|
324
|
+
"cpu_count": sysinfo.get_cpu_count(),
|
|
325
|
+
"loadavg": sysinfo.get_loadavg(),
|
|
326
|
+
"memory": sysinfo.get_memory_summary(),
|
|
327
|
+
"disks": sysinfo.get_disks(),
|
|
328
|
+
"top_cpu": sysinfo.get_top_processes(by="cpu", limit=5),
|
|
329
|
+
"top_rss": sysinfo.get_top_processes(by="rss", limit=5),
|
|
330
|
+
"network": sysinfo.get_network_interfaces(),
|
|
331
|
+
"listening_ports": sysinfo.get_listening_ports(),
|
|
332
|
+
}
|
|
333
|
+
print(json.dumps(payload, indent=2, default=str))
|
|
334
|
+
return 0
|
|
335
|
+
|
|
336
|
+
print(info_mod.render_info())
|
|
337
|
+
return 0
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _print_audit_results(results: List[audit_mod.CheckResult], verbose: bool) -> None:
|
|
341
|
+
name_width = max((len(r.name) for r in results), default=20)
|
|
342
|
+
for result in results:
|
|
343
|
+
marker = colors.status_marker(result.status)
|
|
344
|
+
print(f"{marker} {result.name.ljust(name_width)} {result.message}")
|
|
345
|
+
if verbose and result.detail:
|
|
346
|
+
for line in result.detail:
|
|
347
|
+
print(f" {colors.dim('└')} {line}")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _run_audit_once(args: argparse.Namespace) -> Tuple[List[audit_mod.CheckResult], int]:
|
|
351
|
+
"""Run audit once based on parsed args. Returns (results, exit_code)."""
|
|
352
|
+
if getattr(args, "since", None):
|
|
353
|
+
audit_mod.set_since_hours(args.since)
|
|
354
|
+
# CLI overrides for parallel/timeout knobs live on the active Config.
|
|
355
|
+
cfg = config_mod.get_config()
|
|
356
|
+
if getattr(args, "serial", False):
|
|
357
|
+
cfg.parallel_workers = 1
|
|
358
|
+
if getattr(args, "check_timeout", None) is not None:
|
|
359
|
+
cfg.check_timeout_seconds = float(args.check_timeout)
|
|
360
|
+
names = args.check or None
|
|
361
|
+
ignore = args.ignore or None
|
|
362
|
+
results = audit_mod.run_audit(names=names, ignore=ignore)
|
|
363
|
+
only = getattr(args, "only", None)
|
|
364
|
+
if only:
|
|
365
|
+
statuses = STATUS_FILTERS.get(only, [only])
|
|
366
|
+
results = audit_mod.filter_by_status(results, statuses)
|
|
367
|
+
return results, 0
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def cmd_audit(args: argparse.Namespace) -> int:
|
|
371
|
+
if getattr(args, "list_checks", False):
|
|
372
|
+
for name in audit_mod.list_check_names():
|
|
373
|
+
print(name)
|
|
374
|
+
return 0
|
|
375
|
+
|
|
376
|
+
results, _ = _run_audit_once(args)
|
|
377
|
+
if getattr(args, "alert", None):
|
|
378
|
+
_maybe_fire_alert(args, results)
|
|
379
|
+
if getattr(args, "save", False):
|
|
380
|
+
path = snapshot_mod.save_snapshot(results, host=sysinfo.get_hostname())
|
|
381
|
+
if path and args.format == "text":
|
|
382
|
+
print(colors.dim(f"snapshot saved: {path}"))
|
|
383
|
+
if getattr(args, "brief", False):
|
|
384
|
+
return _emit_brief(results)
|
|
385
|
+
if not results:
|
|
386
|
+
if args.format == "json":
|
|
387
|
+
print(json.dumps({"results": [], "summary": audit_mod.summarize([])}, indent=2, default=str))
|
|
388
|
+
else:
|
|
389
|
+
print(colors.section("AUDIT"))
|
|
390
|
+
print(colors.dim(" (no checks matched the filter)"))
|
|
391
|
+
return 0
|
|
392
|
+
return _emit_audit(args, results)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _emit_audit(args: argparse.Namespace, results: List[audit_mod.CheckResult]) -> int:
|
|
396
|
+
output_path = getattr(args, "output", None)
|
|
397
|
+
sink = _OutputSink(output_path)
|
|
398
|
+
try:
|
|
399
|
+
with sink:
|
|
400
|
+
return _emit_audit_to(args, results, sink)
|
|
401
|
+
except OSError as exc:
|
|
402
|
+
print(colors.red(f"cannot write {output_path}: {exc}"))
|
|
403
|
+
return 1
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _emit_audit_to(args, results, sink) -> int:
|
|
407
|
+
"""Render audit results into `sink`. Sink is a file-like wrapper."""
|
|
408
|
+
if args.format == "prometheus":
|
|
409
|
+
sink.write(audit_mod.render_prometheus(results))
|
|
410
|
+
return _audit_exit_code(args, results)
|
|
411
|
+
if args.format == "html":
|
|
412
|
+
sink.write(audit_mod.render_html(results, host=sysinfo.get_hostname()))
|
|
413
|
+
return _audit_exit_code(args, results)
|
|
414
|
+
if args.format == "csv":
|
|
415
|
+
import csv
|
|
416
|
+
|
|
417
|
+
writer = csv.writer(sink.stream)
|
|
418
|
+
writer.writerow(["name", "status", "message", "detail"])
|
|
419
|
+
for r in results:
|
|
420
|
+
writer.writerow([r.name, r.status, r.message, " | ".join(r.detail)])
|
|
421
|
+
return _audit_exit_code(args, results)
|
|
422
|
+
if args.format == "plain":
|
|
423
|
+
# Tab-separated, no headers/colors/summary — best for shell pipelines:
|
|
424
|
+
# wtf audit --format plain | awk '$1=="fail"'
|
|
425
|
+
for r in results:
|
|
426
|
+
sink.writeln(f"{r.status}\t{r.name}\t{r.message}")
|
|
427
|
+
return _audit_exit_code(args, results)
|
|
428
|
+
if args.format == "json":
|
|
429
|
+
payload = {
|
|
430
|
+
"results": [asdict(r) for r in results],
|
|
431
|
+
"summary": audit_mod.summarize(results),
|
|
432
|
+
}
|
|
433
|
+
sink.write(json.dumps(payload, indent=2, default=str) + "\n")
|
|
434
|
+
else:
|
|
435
|
+
sink.writeln(colors.section("AUDIT"))
|
|
436
|
+
for line in _audit_text_lines(results, verbose=args.verbose):
|
|
437
|
+
sink.writeln(line)
|
|
438
|
+
totals = audit_mod.summarize(results)
|
|
439
|
+
sink.writeln("")
|
|
440
|
+
summary_parts = [
|
|
441
|
+
colors.green(f"{totals['ok']} ok", bold=True),
|
|
442
|
+
colors.yellow(f"{totals['warn']} warn", bold=True),
|
|
443
|
+
colors.red(f"{totals['fail']} fail", bold=True),
|
|
444
|
+
colors.dim(f"{totals['skip']} skip"),
|
|
445
|
+
]
|
|
446
|
+
sink.writeln(f" Summary: {' · '.join(summary_parts)}")
|
|
447
|
+
return _audit_exit_code(args, results)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
class _OutputSink:
|
|
451
|
+
"""File-or-stdout writer used by `--output FILE`. Context-managed."""
|
|
452
|
+
|
|
453
|
+
def __init__(self, path: Optional[str]):
|
|
454
|
+
self.path = path
|
|
455
|
+
self.stream = sys.stdout
|
|
456
|
+
self._opened = False
|
|
457
|
+
|
|
458
|
+
def __enter__(self):
|
|
459
|
+
if self.path:
|
|
460
|
+
# When writing to a file, drop ANSI escapes for sanity.
|
|
461
|
+
colors.init_colors(force_no_color=True)
|
|
462
|
+
self.stream = open(self.path, "w", encoding="utf-8", newline="")
|
|
463
|
+
self._opened = True
|
|
464
|
+
return self
|
|
465
|
+
|
|
466
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
467
|
+
if self._opened:
|
|
468
|
+
self.stream.close()
|
|
469
|
+
return False
|
|
470
|
+
|
|
471
|
+
def write(self, text: str) -> None:
|
|
472
|
+
self.stream.write(text)
|
|
473
|
+
|
|
474
|
+
def writeln(self, text: str = "") -> None:
|
|
475
|
+
self.stream.write(text + "\n")
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _audit_text_lines(results, verbose: bool):
|
|
479
|
+
"""Yield rendered audit lines (the same content `_print_audit_results` prints)."""
|
|
480
|
+
name_width = max((len(r.name) for r in results), default=20)
|
|
481
|
+
for result in results:
|
|
482
|
+
marker = colors.status_marker(result.status)
|
|
483
|
+
yield f"{marker} {result.name.ljust(name_width)} {result.message}"
|
|
484
|
+
if verbose and result.detail:
|
|
485
|
+
for line in result.detail:
|
|
486
|
+
yield f" {colors.dim('└')} {line}"
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _audit_exit_code(args: argparse.Namespace, results: List[audit_mod.CheckResult]) -> int:
|
|
490
|
+
failed = sum(1 for r in results if r.status == "fail")
|
|
491
|
+
warns = sum(1 for r in results if r.status == "warn")
|
|
492
|
+
if getattr(args, "exit_zero", False):
|
|
493
|
+
return 0
|
|
494
|
+
if failed:
|
|
495
|
+
return 2
|
|
496
|
+
if getattr(args, "strict", False) and warns:
|
|
497
|
+
return 1
|
|
498
|
+
return 0
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _maybe_fire_alert(args: argparse.Namespace, results: List[audit_mod.CheckResult]) -> None:
|
|
502
|
+
"""Run the user's alert command when audit severity passes the threshold.
|
|
503
|
+
|
|
504
|
+
The audit summary (text or JSON depending on --format) is piped into the
|
|
505
|
+
command's stdin. Useful for cron-driven monitoring without bundling a
|
|
506
|
+
notification client.
|
|
507
|
+
"""
|
|
508
|
+
import subprocess
|
|
509
|
+
|
|
510
|
+
totals = audit_mod.summarize(results)
|
|
511
|
+
fail = totals.get("fail", 0)
|
|
512
|
+
warn = totals.get("warn", 0)
|
|
513
|
+
threshold = getattr(args, "alert_on", "fail")
|
|
514
|
+
should_fire = (threshold == "fail" and fail > 0) or (threshold == "warn" and (fail > 0 or warn > 0)) or (threshold == "any" and (fail + warn > 0))
|
|
515
|
+
if not should_fire:
|
|
516
|
+
return
|
|
517
|
+
|
|
518
|
+
summary_lines = []
|
|
519
|
+
for r in results:
|
|
520
|
+
if r.status in ("fail", "warn"):
|
|
521
|
+
summary_lines.append(f"[{r.status.upper()}] {r.name}: {r.message}")
|
|
522
|
+
body = "\n".join(summary_lines) + "\n" if summary_lines else ""
|
|
523
|
+
|
|
524
|
+
env = dict(os.environ)
|
|
525
|
+
env["WTF_FAIL_COUNT"] = str(fail)
|
|
526
|
+
env["WTF_WARN_COUNT"] = str(warn)
|
|
527
|
+
env["WTF_HOST"] = sysinfo.get_hostname()
|
|
528
|
+
try:
|
|
529
|
+
subprocess.run(args.alert, shell=True, input=body, text=True, timeout=30, env=env, check=False)
|
|
530
|
+
except subprocess.TimeoutExpired:
|
|
531
|
+
logger.warning("alert command timed out")
|
|
532
|
+
except Exception as exc:
|
|
533
|
+
logger.warning(f"alert command failed: {type(exc).__name__}: {exc}")
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _emit_brief(results: List[audit_mod.CheckResult]) -> int:
|
|
537
|
+
"""One-line summary for MOTDs / ssh-login banners."""
|
|
538
|
+
totals = audit_mod.summarize(results)
|
|
539
|
+
problems = [r for r in results if r.status in ("fail", "warn")]
|
|
540
|
+
if not problems:
|
|
541
|
+
print(colors.green(f"wtf: all good · {totals['ok']} ok · {totals['skip']} skip", bold=True))
|
|
542
|
+
return 0
|
|
543
|
+
|
|
544
|
+
def _short(text: str, limit: int = 40) -> str:
|
|
545
|
+
text = text.replace("\n", " ").strip()
|
|
546
|
+
return text if len(text) <= limit else text[: limit - 1] + "…"
|
|
547
|
+
|
|
548
|
+
parts = [f"{r.name}: {_short(r.message)}" for r in problems[:3]]
|
|
549
|
+
if len(problems) > 3:
|
|
550
|
+
parts.append(f"+{len(problems) - 3} more")
|
|
551
|
+
head = f"wtf: {totals['fail']} fail, {totals['warn']} warn"
|
|
552
|
+
line = f"{head} — {' · '.join(parts)}"
|
|
553
|
+
if totals["fail"]:
|
|
554
|
+
print(colors.red(line, bold=True))
|
|
555
|
+
return 2
|
|
556
|
+
print(colors.yellow(line, bold=True))
|
|
557
|
+
return 1
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def cmd_explain(args: argparse.Namespace) -> int:
|
|
561
|
+
"""Turn audit findings into per-check actionable suggestions or an LLM prompt."""
|
|
562
|
+
# Reuse the audit pipeline so --check/--ignore/--since all work for free.
|
|
563
|
+
results, _ = _run_audit_once(args)
|
|
564
|
+
|
|
565
|
+
if getattr(args, "llm", None):
|
|
566
|
+
return _explain_via_llm(args, results)
|
|
567
|
+
|
|
568
|
+
if args.prompt:
|
|
569
|
+
host = sysinfo.get_hostname()
|
|
570
|
+
print(explain_mod.render_prompt(results, host=host))
|
|
571
|
+
return 0
|
|
572
|
+
|
|
573
|
+
deep = getattr(args, "deep", False)
|
|
574
|
+
suggestions = explain_mod.explain_results(results, include_ok=args.all, deep=deep)
|
|
575
|
+
if args.format == "json":
|
|
576
|
+
payload = [{"name": s.name, "status": s.status, "message": s.message, "advice": s.advice, "investigation": s.investigation} for s in suggestions]
|
|
577
|
+
print(json.dumps(payload, indent=2, default=str))
|
|
578
|
+
return 0
|
|
579
|
+
|
|
580
|
+
if not suggestions:
|
|
581
|
+
print(colors.green("wtf explain: nothing to explain — no WARN/FAIL findings."))
|
|
582
|
+
return 0
|
|
583
|
+
|
|
584
|
+
print(colors.section("EXPLAIN"))
|
|
585
|
+
for s in suggestions:
|
|
586
|
+
marker = colors.status_marker(s.status)
|
|
587
|
+
print(f"{marker} {colors.bold(s.name)} {colors.dim(s.message)}")
|
|
588
|
+
# Wrap advice loosely; keep it readable on 80-col terminals.
|
|
589
|
+
for paragraph in s.advice.split("\n"):
|
|
590
|
+
print(f" {paragraph}")
|
|
591
|
+
if s.investigation:
|
|
592
|
+
print(f" {colors.dim('── investigation ──')}")
|
|
593
|
+
for line in s.investigation:
|
|
594
|
+
print(f" {line}")
|
|
595
|
+
print("")
|
|
596
|
+
return 0
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _explain_via_llm(args: argparse.Namespace, results: List[audit_mod.CheckResult]) -> int:
|
|
600
|
+
"""Render the structured prompt, ship it through the chosen LLM backend."""
|
|
601
|
+
host = sysinfo.get_hostname()
|
|
602
|
+
prompt = explain_mod.render_prompt(results, host=host)
|
|
603
|
+
text, info = llm_mod.call_llm(args.llm, prompt, model=args.llm_model, timeout=args.llm_timeout)
|
|
604
|
+
if text is None:
|
|
605
|
+
msg = f"LLM call failed: {info}"
|
|
606
|
+
if args.format == "json":
|
|
607
|
+
print(json.dumps({"error": msg, "backend": args.llm}))
|
|
608
|
+
else:
|
|
609
|
+
print(colors.red(msg))
|
|
610
|
+
return 2
|
|
611
|
+
if args.format == "json":
|
|
612
|
+
print(json.dumps({"backend": args.llm, "via": info, "advice": text}, indent=2))
|
|
613
|
+
return 0
|
|
614
|
+
print(colors.section(f"EXPLAIN · {args.llm}"))
|
|
615
|
+
if info:
|
|
616
|
+
print(colors.dim(f" {info}"))
|
|
617
|
+
print("")
|
|
618
|
+
print(text.rstrip())
|
|
619
|
+
return 0
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def cmd_events(args: argparse.Namespace) -> int:
|
|
623
|
+
"""Chronological timeline of significant host events."""
|
|
624
|
+
kinds = args.kind or None
|
|
625
|
+
events = events_mod.collect_events(hours=args.since, kinds=kinds)
|
|
626
|
+
if args.limit:
|
|
627
|
+
events = events[: args.limit]
|
|
628
|
+
|
|
629
|
+
if args.format == "json":
|
|
630
|
+
payload = {
|
|
631
|
+
"since_hours": args.since,
|
|
632
|
+
"kinds": list(kinds) if kinds else list(events_mod.EVENT_KINDS),
|
|
633
|
+
"events": [{"timestamp": e.timestamp, "iso": e.iso(), "kind": e.kind, "message": e.message, "detail": e.detail} for e in events],
|
|
634
|
+
}
|
|
635
|
+
print(json.dumps(payload, indent=2, default=str))
|
|
636
|
+
return 0
|
|
637
|
+
|
|
638
|
+
print(colors.section(f"EVENTS · last {args.since}h"))
|
|
639
|
+
if not events:
|
|
640
|
+
print(colors.dim(" (no events in this window)"))
|
|
641
|
+
return 0
|
|
642
|
+
|
|
643
|
+
kind_icon = {
|
|
644
|
+
"reboot": colors.cyan("⟲ ", bold=True),
|
|
645
|
+
"oom": colors.red("✗ ", bold=True),
|
|
646
|
+
"failed-unit": colors.red("✗ ", bold=True),
|
|
647
|
+
"kernel-err": colors.yellow("⚠ ", bold=True),
|
|
648
|
+
"auth-fail": colors.yellow("⚠ ", bold=True),
|
|
649
|
+
"login": colors.dim("ⓘ "),
|
|
650
|
+
}
|
|
651
|
+
kind_width = max((len(e.kind) for e in events), default=10)
|
|
652
|
+
for e in events:
|
|
653
|
+
icon = kind_icon.get(e.kind, "• ")
|
|
654
|
+
msg = e.message
|
|
655
|
+
if len(msg) > 110:
|
|
656
|
+
msg = msg[:109] + "…"
|
|
657
|
+
print(f" {colors.dim(e.iso())} {icon}{e.kind.ljust(kind_width)} {msg}")
|
|
658
|
+
return 0
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def cmd_logs(args: argparse.Namespace) -> int:
|
|
662
|
+
"""Recent ERROR-level (and worse) journal entries grouped by service."""
|
|
663
|
+
import collections
|
|
664
|
+
|
|
665
|
+
if not shutil.which("journalctl"):
|
|
666
|
+
msg = "journalctl not available on this host"
|
|
667
|
+
if args.format == "json":
|
|
668
|
+
print(json.dumps({"error": msg}))
|
|
669
|
+
else:
|
|
670
|
+
print(colors.red(msg))
|
|
671
|
+
return 2
|
|
672
|
+
rc, out, _ = sysinfo.run(
|
|
673
|
+
["journalctl", "-p", args.priority, "--since", args.since, "-o", "json", "--no-pager", "-q"],
|
|
674
|
+
timeout=20,
|
|
675
|
+
)
|
|
676
|
+
if rc != 0:
|
|
677
|
+
msg = f"journalctl failed (rc={rc})"
|
|
678
|
+
if args.format == "json":
|
|
679
|
+
print(json.dumps({"error": msg}))
|
|
680
|
+
else:
|
|
681
|
+
print(colors.red(msg))
|
|
682
|
+
return 1
|
|
683
|
+
|
|
684
|
+
by_unit: Dict[str, List[str]] = collections.defaultdict(list)
|
|
685
|
+
for line in out.splitlines():
|
|
686
|
+
if not line.strip():
|
|
687
|
+
continue
|
|
688
|
+
try:
|
|
689
|
+
entry = json.loads(line)
|
|
690
|
+
except (json.JSONDecodeError, ValueError):
|
|
691
|
+
continue
|
|
692
|
+
unit = entry.get("_SYSTEMD_UNIT") or entry.get("SYSLOG_IDENTIFIER") or entry.get("_COMM") or "(unknown)"
|
|
693
|
+
message = entry.get("MESSAGE", "")
|
|
694
|
+
if isinstance(message, list):
|
|
695
|
+
try:
|
|
696
|
+
message = bytes(message).decode("utf-8", errors="replace")
|
|
697
|
+
except Exception:
|
|
698
|
+
message = str(message)
|
|
699
|
+
by_unit[unit].append(str(message).strip())
|
|
700
|
+
|
|
701
|
+
if args.format == "json":
|
|
702
|
+
payload = {"since": args.since, "priority": args.priority, "by_unit": dict(by_unit)}
|
|
703
|
+
print(json.dumps(payload, indent=2))
|
|
704
|
+
return 0
|
|
705
|
+
|
|
706
|
+
print(colors.section(f"LOGS — last {args.since}, priority {args.priority}+"))
|
|
707
|
+
if not by_unit:
|
|
708
|
+
print(colors.green(" (none)"))
|
|
709
|
+
return 0
|
|
710
|
+
sorted_units = sorted(by_unit.items(), key=lambda kv: -len(kv[1]))
|
|
711
|
+
total = sum(len(v) for v in by_unit.values())
|
|
712
|
+
print(colors.bold(f" {total} entries across {len(by_unit)} unit(s):"))
|
|
713
|
+
for unit, msgs in sorted_units[: args.units]:
|
|
714
|
+
unit_short = unit.replace(".service", "")
|
|
715
|
+
print(f" {colors.cyan(unit_short)} {colors.dim(f'({len(msgs)} msg)')}")
|
|
716
|
+
for m in msgs[: args.lines]:
|
|
717
|
+
print(f" {colors.dim('│')} {m}")
|
|
718
|
+
if len(msgs) > args.lines:
|
|
719
|
+
print(colors.dim(f" ... +{len(msgs) - args.lines} more"))
|
|
720
|
+
if len(sorted_units) > args.units:
|
|
721
|
+
print(colors.dim(f" ... +{len(sorted_units) - args.units} more unit(s)"))
|
|
722
|
+
return 0
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def cmd_services(args: argparse.Namespace) -> int:
|
|
726
|
+
"""Drilldown for a single systemd service: state + recent journal lines."""
|
|
727
|
+
if not shutil.which("systemctl"):
|
|
728
|
+
msg = "systemctl not available on this host"
|
|
729
|
+
if args.format == "json":
|
|
730
|
+
print(json.dumps({"error": msg}))
|
|
731
|
+
else:
|
|
732
|
+
print(colors.red(msg))
|
|
733
|
+
return 2
|
|
734
|
+
details = sysinfo.get_service_details(args.name)
|
|
735
|
+
if details is None:
|
|
736
|
+
msg = f"service '{args.name}' not found"
|
|
737
|
+
if args.format == "json":
|
|
738
|
+
print(json.dumps({"error": msg}))
|
|
739
|
+
else:
|
|
740
|
+
print(colors.red(msg))
|
|
741
|
+
return 1
|
|
742
|
+
journal = sysinfo.get_service_journal(args.name, lines=args.lines)
|
|
743
|
+
listening = _ports_for_pid(details.get("MainPID", "0"))
|
|
744
|
+
|
|
745
|
+
if args.format == "json":
|
|
746
|
+
payload = {"details": details, "listening_ports": listening, "journal": journal}
|
|
747
|
+
print(json.dumps(payload, indent=2, default=str))
|
|
748
|
+
return 0
|
|
749
|
+
|
|
750
|
+
print(colors.section(details.get("Id", args.name).upper()))
|
|
751
|
+
active = details.get("ActiveState", "?")
|
|
752
|
+
sub = details.get("SubState", "?")
|
|
753
|
+
result = details.get("Result", "?")
|
|
754
|
+
color_fn = colors.green if active == "active" else (colors.red if active in ("failed",) else colors.yellow)
|
|
755
|
+
print(f" state : {color_fn(f'{active} ({sub})', bold=True)} " f"{colors.dim(f'· result={result}')}")
|
|
756
|
+
if details.get("Description"):
|
|
757
|
+
print(f" desc : {details['Description']}")
|
|
758
|
+
if details.get("UnitFileState"):
|
|
759
|
+
print(f" enabled : {details['UnitFileState']}")
|
|
760
|
+
if details.get("FragmentPath"):
|
|
761
|
+
print(f" unit file: {colors.dim(details['FragmentPath'])}")
|
|
762
|
+
pid = details.get("MainPID")
|
|
763
|
+
if pid and pid != "0":
|
|
764
|
+
print(f" main pid : {pid}")
|
|
765
|
+
nrestarts = details.get("NRestarts")
|
|
766
|
+
if nrestarts and nrestarts != "0":
|
|
767
|
+
marker = colors.yellow if int(nrestarts) < 10 else colors.red
|
|
768
|
+
print(f" restarts : {marker(nrestarts, bold=True)}")
|
|
769
|
+
try:
|
|
770
|
+
mem_bytes = int(details.get("MemoryCurrent", "0"))
|
|
771
|
+
if mem_bytes > 0:
|
|
772
|
+
print(f" memory : {sysinfo.format_bytes(mem_bytes)}")
|
|
773
|
+
except ValueError:
|
|
774
|
+
pass
|
|
775
|
+
if details.get("TasksCurrent") and details["TasksCurrent"] != "[not set]":
|
|
776
|
+
print(f" tasks : {details['TasksCurrent']}")
|
|
777
|
+
if listening:
|
|
778
|
+
ports = ", ".join(f"{p['port']}" for p in listening)
|
|
779
|
+
print(f" ports : {ports}")
|
|
780
|
+
|
|
781
|
+
if journal:
|
|
782
|
+
print("")
|
|
783
|
+
print(colors.bold(f" recent journal (last {len(journal)} lines):"))
|
|
784
|
+
for line in journal:
|
|
785
|
+
print(f" {colors.dim('│')} {line}")
|
|
786
|
+
return 0 if active in ("active", "activating", "reloading") else 1
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _ports_for_pid(pid_str: str) -> List[dict]:
|
|
790
|
+
"""Return listening ports owned by the given PID (best-effort, via psutil)."""
|
|
791
|
+
if not pid_str or pid_str == "0":
|
|
792
|
+
return []
|
|
793
|
+
try:
|
|
794
|
+
pid = int(pid_str)
|
|
795
|
+
except ValueError:
|
|
796
|
+
return []
|
|
797
|
+
if not sysinfo.HAS_PSUTIL:
|
|
798
|
+
return []
|
|
799
|
+
try:
|
|
800
|
+
import psutil # type: ignore
|
|
801
|
+
|
|
802
|
+
result = []
|
|
803
|
+
for conn in psutil.net_connections(kind="inet"):
|
|
804
|
+
if conn.pid != pid:
|
|
805
|
+
continue
|
|
806
|
+
if conn.status != psutil.CONN_LISTEN:
|
|
807
|
+
continue
|
|
808
|
+
if not conn.laddr:
|
|
809
|
+
continue
|
|
810
|
+
result.append({"port": conn.laddr.port, "addr": conn.laddr.ip})
|
|
811
|
+
return result
|
|
812
|
+
except Exception:
|
|
813
|
+
return []
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def cmd_config(args: argparse.Namespace) -> int:
|
|
817
|
+
"""Show effective config, the example template, or where files are searched."""
|
|
818
|
+
if args.example:
|
|
819
|
+
print(config_mod.example_config())
|
|
820
|
+
return 0
|
|
821
|
+
if args.format == "json":
|
|
822
|
+
cfg = config_mod.get_config()
|
|
823
|
+
payload = {
|
|
824
|
+
"search_paths": list(config_mod.DEFAULT_CONFIG_PATHS),
|
|
825
|
+
"effective": {k: (list(v) if isinstance(v, set) else v) for k, v in cfg.__dict__.items()},
|
|
826
|
+
}
|
|
827
|
+
print(json.dumps(payload, indent=2, default=str))
|
|
828
|
+
return 0
|
|
829
|
+
cfg = config_mod.get_config()
|
|
830
|
+
print(colors.section("CONFIG"))
|
|
831
|
+
print(" search paths:")
|
|
832
|
+
for p in config_mod.DEFAULT_CONFIG_PATHS:
|
|
833
|
+
marker = colors.green("●") if os.path.exists(p) else colors.dim("○")
|
|
834
|
+
print(f" {marker} {p}")
|
|
835
|
+
print("")
|
|
836
|
+
print(colors.bold(" effective values:"))
|
|
837
|
+
for key, value in cfg.__dict__.items():
|
|
838
|
+
if isinstance(value, set):
|
|
839
|
+
value = ", ".join(sorted(value)) if value else "(none)"
|
|
840
|
+
print(f" {key:<26} {value}")
|
|
841
|
+
print("")
|
|
842
|
+
print(colors.dim(" tip: `wtf config --example > /etc/wtftools/config.ini`"))
|
|
843
|
+
return 0
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def cmd_problems(args: argparse.Namespace) -> int:
|
|
847
|
+
"""Alias for `wtf audit --only problems` — show WARN+FAIL results only.
|
|
848
|
+
|
|
849
|
+
This is the most common audit invocation during an incident, surfaced as
|
|
850
|
+
its own subcommand for typing comfort.
|
|
851
|
+
"""
|
|
852
|
+
args.only = "problems"
|
|
853
|
+
return cmd_audit(args)
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
857
|
+
"""Self-diagnostic: which capabilities are available on this host."""
|
|
858
|
+
report = _gather_doctor_report(check_updates=getattr(args, "check_updates", False))
|
|
859
|
+
if args.format == "json":
|
|
860
|
+
print(json.dumps(report, indent=2, default=str))
|
|
861
|
+
return 0
|
|
862
|
+
print(colors.section("DOCTOR"))
|
|
863
|
+
name_width = max((len(item["name"]) for item in report["checks"]), default=20)
|
|
864
|
+
for item in report["checks"]:
|
|
865
|
+
marker = colors.status_marker(item["status"])
|
|
866
|
+
print(f" {marker} {item['name'].ljust(name_width)} {item['detail']}")
|
|
867
|
+
return 0
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _fetch_pypi_version(package: str = "wtftools", timeout: float = 3.0) -> Optional[str]:
|
|
871
|
+
"""Query PyPI for the latest published version. Returns None on failure."""
|
|
872
|
+
import urllib.request
|
|
873
|
+
|
|
874
|
+
try:
|
|
875
|
+
with urllib.request.urlopen(f"https://pypi.org/pypi/{package}/json", timeout=timeout) as resp:
|
|
876
|
+
data = json.loads(resp.read().decode("utf-8", errors="replace"))
|
|
877
|
+
return data.get("info", {}).get("version")
|
|
878
|
+
except Exception:
|
|
879
|
+
return None
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _version_tuple(v: str) -> tuple:
|
|
883
|
+
"""Cheap semver-ish parse — for ordering only. Non-numeric chunks → 0."""
|
|
884
|
+
parts = []
|
|
885
|
+
for chunk in v.split("."):
|
|
886
|
+
digits = ""
|
|
887
|
+
for ch in chunk:
|
|
888
|
+
if ch.isdigit():
|
|
889
|
+
digits += ch
|
|
890
|
+
else:
|
|
891
|
+
break
|
|
892
|
+
parts.append(int(digits) if digits else 0)
|
|
893
|
+
return tuple(parts)
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def _gather_doctor_report(check_updates: bool = False) -> dict:
|
|
897
|
+
"""Probe the environment for tools wtf relies on and return a report dict."""
|
|
898
|
+
items = []
|
|
899
|
+
|
|
900
|
+
def add(name, present, detail, status_when_ok="ok", status_when_missing="warn"):
|
|
901
|
+
items.append(
|
|
902
|
+
{
|
|
903
|
+
"name": name,
|
|
904
|
+
"status": status_when_ok if present else status_when_missing,
|
|
905
|
+
"present": bool(present),
|
|
906
|
+
"detail": detail,
|
|
907
|
+
}
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
py = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
|
911
|
+
items.append({"name": "python", "status": "ok", "present": True, "detail": py})
|
|
912
|
+
|
|
913
|
+
add("psutil", sysinfo.HAS_PSUTIL, "available — richer top/sockets data" if sysinfo.HAS_PSUTIL else "missing — install `python3-psutil` or `pip install wtftools[full]`")
|
|
914
|
+
|
|
915
|
+
for bin_name, hint in (
|
|
916
|
+
("systemctl", "systemd-based distro recommended; failed-units/enabled checks degrade without it"),
|
|
917
|
+
("journalctl", "OOM/kernel/auth checks fall back to /var/log when missing"),
|
|
918
|
+
("crontab", "needed for `wtf crontab -u <user>`"),
|
|
919
|
+
("apt", "pending updates check is skipped without it"),
|
|
920
|
+
("timedatectl", "time-sync check needs it"),
|
|
921
|
+
("ss", "fallback for listening-ports check when psutil is missing"),
|
|
922
|
+
("ps", "fallback for top processes"),
|
|
923
|
+
("ip", "fallback for network interface listing"),
|
|
924
|
+
("last", "recent logins (info)"),
|
|
925
|
+
("dmesg", "fallback OOM source"),
|
|
926
|
+
):
|
|
927
|
+
path = shutil.which(bin_name)
|
|
928
|
+
items.append(
|
|
929
|
+
{
|
|
930
|
+
"name": bin_name,
|
|
931
|
+
"status": "ok" if path else "warn",
|
|
932
|
+
"present": bool(path),
|
|
933
|
+
"detail": path or f"missing — {hint}",
|
|
934
|
+
}
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
for path, label in (("/etc/os-release", "OS metadata"), ("/proc/meminfo", "memory data"), ("/proc/loadavg", "load average"), ("/proc/stat", "iowait sampling")):
|
|
938
|
+
present = os.path.exists(path)
|
|
939
|
+
items.append(
|
|
940
|
+
{
|
|
941
|
+
"name": path,
|
|
942
|
+
"status": "ok" if present else "fail",
|
|
943
|
+
"present": present,
|
|
944
|
+
"detail": label if present else f"{label} not readable",
|
|
945
|
+
}
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
if check_updates:
|
|
949
|
+
latest = _fetch_pypi_version()
|
|
950
|
+
if latest is None:
|
|
951
|
+
items.append(
|
|
952
|
+
{
|
|
953
|
+
"name": "pypi update check",
|
|
954
|
+
"status": "skip",
|
|
955
|
+
"present": False,
|
|
956
|
+
"detail": "could not reach PyPI",
|
|
957
|
+
}
|
|
958
|
+
)
|
|
959
|
+
else:
|
|
960
|
+
try:
|
|
961
|
+
outdated = _version_tuple(latest) > _version_tuple(__version__)
|
|
962
|
+
except Exception:
|
|
963
|
+
outdated = False
|
|
964
|
+
items.append(
|
|
965
|
+
{
|
|
966
|
+
"name": "pypi update check",
|
|
967
|
+
"status": "warn" if outdated else "ok",
|
|
968
|
+
"present": True,
|
|
969
|
+
"detail": (f"installed {__version__}, PyPI has {latest}" if outdated else f"up to date ({__version__})"),
|
|
970
|
+
}
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
return {"version": __version__, "platform": platform.platform(), "checks": items}
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _collect_crontab_targets(args: argparse.Namespace) -> Tuple[List[Tuple[str, bool]], List[str]]:
|
|
977
|
+
targets: List[Tuple[str, bool]] = []
|
|
978
|
+
temp_files: List[str] = []
|
|
979
|
+
|
|
980
|
+
if args.system:
|
|
981
|
+
for path in args.system:
|
|
982
|
+
targets.append((path, True))
|
|
983
|
+
if args.user_file:
|
|
984
|
+
for path in args.user_file:
|
|
985
|
+
targets.append((path, False))
|
|
986
|
+
if args.username:
|
|
987
|
+
for name in args.username:
|
|
988
|
+
path = cron.find_user_crontab(name)
|
|
989
|
+
if path:
|
|
990
|
+
temp_files.append(path)
|
|
991
|
+
targets.append((path, False))
|
|
992
|
+
else:
|
|
993
|
+
logger.warning(f"crontab for user '{name}' not found")
|
|
994
|
+
|
|
995
|
+
for arg in args.targets:
|
|
996
|
+
if os.path.isfile(arg):
|
|
997
|
+
full = os.path.abspath(arg)
|
|
998
|
+
is_system = full == "/etc/crontab" or full.startswith("/etc/cron.d")
|
|
999
|
+
targets.append((full, is_system))
|
|
1000
|
+
elif os.path.isdir(arg):
|
|
1001
|
+
for name in sorted(os.listdir(arg)):
|
|
1002
|
+
path = os.path.join(arg, name)
|
|
1003
|
+
if os.path.isfile(path) and not cron.check_filename(name):
|
|
1004
|
+
full = os.path.abspath(path)
|
|
1005
|
+
is_system = full.startswith("/etc/cron.d") or full == "/etc/crontab"
|
|
1006
|
+
targets.append((full, is_system))
|
|
1007
|
+
elif re.match(r"^[a-zA-Z][a-zA-Z0-9_-]{0,31}$", arg):
|
|
1008
|
+
path = cron.find_user_crontab(arg)
|
|
1009
|
+
if path:
|
|
1010
|
+
temp_files.append(path)
|
|
1011
|
+
targets.append((path, False))
|
|
1012
|
+
else:
|
|
1013
|
+
logger.warning(f"'{arg}' is not a file and has no crontab")
|
|
1014
|
+
else:
|
|
1015
|
+
logger.warning(f"'{arg}' not found, skipping")
|
|
1016
|
+
|
|
1017
|
+
if not targets:
|
|
1018
|
+
targets = cron.discover_default_targets()
|
|
1019
|
+
|
|
1020
|
+
seen = set()
|
|
1021
|
+
unique: List[Tuple[str, bool]] = []
|
|
1022
|
+
for path, is_sys in targets:
|
|
1023
|
+
if path in seen:
|
|
1024
|
+
continue
|
|
1025
|
+
seen.add(path)
|
|
1026
|
+
unique.append((path, is_sys))
|
|
1027
|
+
return unique, temp_files
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def cmd_crontab(args: argparse.Namespace) -> int:
|
|
1031
|
+
targets, temp_files = _collect_crontab_targets(args)
|
|
1032
|
+
|
|
1033
|
+
total_rows = 0
|
|
1034
|
+
total_errors = 0
|
|
1035
|
+
total_warnings = 0
|
|
1036
|
+
file_reports: List[dict] = []
|
|
1037
|
+
|
|
1038
|
+
if platform.system().lower() == "linux" and not os.getenv("GITHUB_ACTIONS"):
|
|
1039
|
+
for warning in cron.check_daemon():
|
|
1040
|
+
if args.format != "json":
|
|
1041
|
+
print(colors.yellow(f"warning: {warning}"))
|
|
1042
|
+
|
|
1043
|
+
if not targets:
|
|
1044
|
+
if args.format == "json":
|
|
1045
|
+
print(json.dumps({"success": True, "files": [], "total_errors": 0}, indent=2))
|
|
1046
|
+
else:
|
|
1047
|
+
print(colors.dim("no crontab files visible"))
|
|
1048
|
+
return 0
|
|
1049
|
+
|
|
1050
|
+
for path, is_system in targets:
|
|
1051
|
+
rows = 0
|
|
1052
|
+
errors: List[str] = []
|
|
1053
|
+
warnings: List[str] = []
|
|
1054
|
+
if not os.path.exists(path):
|
|
1055
|
+
errors.append(f"{path}: does not exist")
|
|
1056
|
+
else:
|
|
1057
|
+
if is_system and platform.system().lower() == "linux":
|
|
1058
|
+
errors.extend(cron.check_owner_and_permissions(path))
|
|
1059
|
+
r, e, w = cron.check_file(path, is_system_crontab=is_system)
|
|
1060
|
+
rows = r
|
|
1061
|
+
errors.extend(e)
|
|
1062
|
+
warnings.extend(w)
|
|
1063
|
+
file_reports.append(
|
|
1064
|
+
{
|
|
1065
|
+
"file": path,
|
|
1066
|
+
"is_system_crontab": is_system,
|
|
1067
|
+
"rows": rows,
|
|
1068
|
+
"errors": errors,
|
|
1069
|
+
"warnings": warnings,
|
|
1070
|
+
}
|
|
1071
|
+
)
|
|
1072
|
+
total_rows += rows
|
|
1073
|
+
total_errors += len(errors)
|
|
1074
|
+
total_warnings += len(warnings)
|
|
1075
|
+
|
|
1076
|
+
if args.format == "json":
|
|
1077
|
+
payload = {
|
|
1078
|
+
"success": total_errors == 0,
|
|
1079
|
+
"total_files": len(file_reports),
|
|
1080
|
+
"total_rows": total_rows,
|
|
1081
|
+
"total_errors": total_errors,
|
|
1082
|
+
"total_warnings": total_warnings,
|
|
1083
|
+
"files": file_reports,
|
|
1084
|
+
}
|
|
1085
|
+
print(json.dumps(payload, indent=2))
|
|
1086
|
+
else:
|
|
1087
|
+
print(colors.section("CRONTAB"))
|
|
1088
|
+
for report in file_reports:
|
|
1089
|
+
tag = colors.cyan("[system]" if report["is_system_crontab"] else "[user] ")
|
|
1090
|
+
rows_label = colors.dim(f"({report['rows']} lines)")
|
|
1091
|
+
if not report["errors"] and not report["warnings"]:
|
|
1092
|
+
print(f" {colors.status_marker('OK')} {tag} {report['file']} {rows_label}")
|
|
1093
|
+
else:
|
|
1094
|
+
marker = "FAIL" if report["errors"] else "WARN"
|
|
1095
|
+
print(f" {colors.status_marker(marker)} {tag} {report['file']} {rows_label}")
|
|
1096
|
+
for error in report["errors"]:
|
|
1097
|
+
print(f" {colors.red('✗')} {error}")
|
|
1098
|
+
for warning in report["warnings"]:
|
|
1099
|
+
print(f" {colors.yellow('⚠')} {warning}")
|
|
1100
|
+
print("")
|
|
1101
|
+
summary = [
|
|
1102
|
+
colors.bold(f"{len(file_reports)} files"),
|
|
1103
|
+
colors.bold(f"{total_rows} lines"),
|
|
1104
|
+
colors.red(f"{total_errors} errors") if total_errors else colors.green("0 errors"),
|
|
1105
|
+
colors.yellow(f"{total_warnings} warnings") if total_warnings else colors.dim("0 warnings"),
|
|
1106
|
+
]
|
|
1107
|
+
print(f" Summary: {' · '.join(summary)}")
|
|
1108
|
+
|
|
1109
|
+
for path in temp_files:
|
|
1110
|
+
try:
|
|
1111
|
+
os.unlink(path)
|
|
1112
|
+
except Exception:
|
|
1113
|
+
pass
|
|
1114
|
+
|
|
1115
|
+
if args.exit_zero:
|
|
1116
|
+
return 0
|
|
1117
|
+
if total_errors:
|
|
1118
|
+
return 1
|
|
1119
|
+
if args.strict and total_warnings:
|
|
1120
|
+
return 1
|
|
1121
|
+
return 0
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1125
|
+
parser = argparse.ArgumentParser(
|
|
1126
|
+
prog="wtf",
|
|
1127
|
+
description=__description__,
|
|
1128
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1129
|
+
epilog=f"Project: {__url__}\n"
|
|
1130
|
+
"Examples:\n"
|
|
1131
|
+
" wtf # default: short audit summary\n"
|
|
1132
|
+
" wtf info # detailed system snapshot\n"
|
|
1133
|
+
" wtf audit -v # audit with extra detail per check\n"
|
|
1134
|
+
" wtf crontab # check standard crontab locations\n"
|
|
1135
|
+
" wtf crontab -u myuser # check a specific user's crontab\n"
|
|
1136
|
+
" wtf audit --format json # machine-readable output\n",
|
|
1137
|
+
)
|
|
1138
|
+
parser.add_argument("-V", "--version", action="version", version=f"wtftools {__version__}")
|
|
1139
|
+
parser.add_argument("--no-color", action="store_true", help="Disable colored output")
|
|
1140
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="Show extra detail")
|
|
1141
|
+
parser.add_argument("-q", "--quiet", action="store_true", help="Reduce logging output")
|
|
1142
|
+
parser.add_argument("--config", metavar="PATH", help="Path to an extra wtftools config file (INI). Stacks on top " "of the default discovery paths.")
|
|
1143
|
+
|
|
1144
|
+
subparsers = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
1145
|
+
|
|
1146
|
+
info = subparsers.add_parser("info", help="Show summary of system state")
|
|
1147
|
+
info.add_argument("--format", choices=["text", "json"], default="text")
|
|
1148
|
+
info.set_defaults(func=cmd_info)
|
|
1149
|
+
|
|
1150
|
+
audit = subparsers.add_parser("audit", help="Run health audit and show OK/WARN/FAIL")
|
|
1151
|
+
audit.add_argument("--format", choices=["text", "json", "prometheus", "csv", "plain", "html"], default="text")
|
|
1152
|
+
audit.add_argument("--output", "-o", metavar="FILE", help="Write the audit to FILE instead of stdout (drops ANSI colors)")
|
|
1153
|
+
audit.add_argument("--strict", action="store_true", help="Exit non-zero on warnings too")
|
|
1154
|
+
audit.add_argument("--exit-zero", action="store_true", help="Always exit with code 0")
|
|
1155
|
+
audit.add_argument("--check", action="append", metavar="NAME", help="Run only the named check (repeatable). See `--list-checks`.")
|
|
1156
|
+
audit.add_argument("--only", choices=list(STATUS_FILTERS.keys()), help="Show only results with the given status (fail/warn/problems/skip/ok/all)")
|
|
1157
|
+
audit.add_argument("--since", type=int, metavar="HOURS", default=24, help="Look-back window in hours for OOM/auth/kernel checks (default: 24)")
|
|
1158
|
+
audit.add_argument("--list-checks", action="store_true", dest="list_checks", help="List all check short-names and exit")
|
|
1159
|
+
audit.add_argument("--brief", "-b", action="store_true", help="One-line summary suitable for MOTD / SSH banners")
|
|
1160
|
+
audit.add_argument(
|
|
1161
|
+
"--ignore",
|
|
1162
|
+
action="append",
|
|
1163
|
+
metavar="NAME",
|
|
1164
|
+
default=[],
|
|
1165
|
+
help="Skip a check (short-name) or result-name (repeatable). " "e.g. --ignore swap or --ignore 'disk /mnt/Backup'",
|
|
1166
|
+
)
|
|
1167
|
+
audit.add_argument("--serial", action="store_true", help="Run checks sequentially (for debugging; default is parallel)")
|
|
1168
|
+
audit.add_argument("--check-timeout", type=float, metavar="SECONDS", help="Per-check timeout in seconds (default: 10, overrides config)")
|
|
1169
|
+
audit.add_argument(
|
|
1170
|
+
"--alert", metavar="CMD", help="Shell command to invoke when FAIL results exist. " "Audit text is piped to stdin. " "Env: WTF_FAIL_COUNT, WTF_WARN_COUNT, WTF_HOST."
|
|
1171
|
+
)
|
|
1172
|
+
audit.add_argument("--alert-on", choices=["fail", "warn", "any"], default="fail", help="When to fire --alert (default: only on FAIL)")
|
|
1173
|
+
audit.add_argument("--save", action="store_true", help="Persist the audit result as a snapshot for history/diff")
|
|
1174
|
+
audit.set_defaults(func=cmd_audit)
|
|
1175
|
+
|
|
1176
|
+
top = subparsers.add_parser("top", help="Top processes (focused live snapshot)")
|
|
1177
|
+
top.add_argument("--sort", choices=["cpu", "rss"], default="cpu", help="Sort key (default: cpu)")
|
|
1178
|
+
top.add_argument("--limit", type=int, default=10, help="Number to show (default: 10)")
|
|
1179
|
+
top.add_argument("--user", metavar="PREFIX", help="Filter by username prefix")
|
|
1180
|
+
top.add_argument("--name", metavar="PATTERN", help="Filter by command-name substring (case-insensitive)")
|
|
1181
|
+
top.add_argument("--format", choices=["text", "json"], default="text")
|
|
1182
|
+
top.set_defaults(func=cmd_top)
|
|
1183
|
+
|
|
1184
|
+
ports = subparsers.add_parser("ports", help="Listening ports with owning process info")
|
|
1185
|
+
ports.add_argument("--proto", choices=["tcp", "udp", "all"], default="tcp", help="Protocol filter (default: tcp)")
|
|
1186
|
+
ports.add_argument("--public-only", action="store_true", help="Skip loopback addresses (127.x)")
|
|
1187
|
+
ports.add_argument("--format", choices=["text", "json"], default="text")
|
|
1188
|
+
ports.set_defaults(func=cmd_ports)
|
|
1189
|
+
|
|
1190
|
+
problems = subparsers.add_parser("problems", help="Show only WARN+FAIL results (alias: audit --only problem)")
|
|
1191
|
+
problems.add_argument("--check", action="append", metavar="NAME", help="Run only the named check (repeatable). See `--list-checks`.")
|
|
1192
|
+
problems.add_argument("--ignore", action="append", metavar="NAME", default=[], help="Skip a check (short-name) or result-name")
|
|
1193
|
+
problems.add_argument("--since", type=int, metavar="HOURS", default=24, help="Look-back window for OOM/auth/kernel checks")
|
|
1194
|
+
problems.add_argument("--format", choices=["text", "json", "prometheus", "csv", "plain", "html"], default="text")
|
|
1195
|
+
problems.add_argument("--output", "-o", metavar="FILE", help="Write to FILE instead of stdout")
|
|
1196
|
+
problems.add_argument("--strict", action="store_true", help="Exit non-zero on warnings too")
|
|
1197
|
+
problems.add_argument("--exit-zero", action="store_true", help="Always exit with code 0")
|
|
1198
|
+
problems.add_argument("--serial", action="store_true", help="Run checks sequentially")
|
|
1199
|
+
problems.add_argument("--check-timeout", type=float, metavar="SECONDS", help="Per-check timeout in seconds")
|
|
1200
|
+
problems.add_argument("--verbose", "-v", action="store_true", help="Show extra detail")
|
|
1201
|
+
problems.set_defaults(func=cmd_problems, list_checks=False, brief=False, save=False, alert=None, alert_on="fail")
|
|
1202
|
+
|
|
1203
|
+
diff = subparsers.add_parser("diff", help="Compare current audit (or a snapshot) against a stored snapshot")
|
|
1204
|
+
diff.add_argument("--snapshot", type=int, default=0, metavar="N", help="Compare against the Nth-most-recent snapshot " "(0=latest, 1=one before, …). Default: 0.")
|
|
1205
|
+
diff.add_argument("--against", nargs=2, metavar=("OLD", "NEW"), help="Diff two snapshot files directly, no live audit")
|
|
1206
|
+
diff.add_argument("--format", choices=["text", "json"], default="text")
|
|
1207
|
+
diff.set_defaults(func=cmd_diff)
|
|
1208
|
+
|
|
1209
|
+
history = subparsers.add_parser("history", help="List saved audit snapshots")
|
|
1210
|
+
history.add_argument("--limit", type=int, default=20, help="Number of most-recent snapshots to show (default: 20)")
|
|
1211
|
+
history.add_argument("--format", choices=["text", "json"], default="text")
|
|
1212
|
+
history.set_defaults(func=cmd_history)
|
|
1213
|
+
|
|
1214
|
+
explain = subparsers.add_parser("explain", help="Suggest actions for current audit findings")
|
|
1215
|
+
explain.add_argument("--check", action="append", metavar="NAME", help="Limit to specific checks")
|
|
1216
|
+
explain.add_argument("--ignore", action="append", metavar="NAME", default=[], help="Skip a check or result-name")
|
|
1217
|
+
explain.add_argument("--since", type=int, default=24, metavar="HOURS", help="Look-back window for time-bounded checks (default: 24)")
|
|
1218
|
+
explain.add_argument("--all", action="store_true", help="Also explain OK results (default: only WARN/FAIL)")
|
|
1219
|
+
explain.add_argument(
|
|
1220
|
+
"--deep", action="store_true", help="Run dynamic investigation per finding (du -d1 on the " "filling mount, docker system df, container/log sizes). " "Slower; opt-in."
|
|
1221
|
+
)
|
|
1222
|
+
explain.add_argument(
|
|
1223
|
+
"--prompt", action="store_true", help="Print an LLM-ready prompt instead of built-in advice. " "Pipe it: `wtf explain --prompt | claude` or `| ollama run llama3`."
|
|
1224
|
+
)
|
|
1225
|
+
explain.add_argument(
|
|
1226
|
+
"--llm",
|
|
1227
|
+
choices=["ollama", "claude", "openai", "auto"],
|
|
1228
|
+
help="Call an LLM directly with the structured prompt and " "print its response. ollama needs the binary; " "claude/openai need the matching Python SDK + API key env.",
|
|
1229
|
+
)
|
|
1230
|
+
explain.add_argument("--llm-model", metavar="MODEL", help="Override default model name for --llm")
|
|
1231
|
+
explain.add_argument("--llm-timeout", type=int, default=60, metavar="SECONDS", help="LLM call timeout (default: 60s)")
|
|
1232
|
+
explain.add_argument("--format", choices=["text", "json"], default="text")
|
|
1233
|
+
explain.add_argument("--serial", action="store_true", help="Run audit sequentially (passes through to underlying audit)")
|
|
1234
|
+
explain.add_argument("--check-timeout", type=float, metavar="SECONDS", help="Per-check timeout in seconds (passes through to audit)")
|
|
1235
|
+
explain.set_defaults(func=cmd_explain, only=None)
|
|
1236
|
+
|
|
1237
|
+
doctor = subparsers.add_parser("doctor", help="Self-diagnostic: which tools/files wtf can use")
|
|
1238
|
+
doctor.add_argument("--format", choices=["text", "json"], default="text")
|
|
1239
|
+
doctor.add_argument("--check-updates", action="store_true", help="Query PyPI for a newer wtftools version (network call)")
|
|
1240
|
+
doctor.set_defaults(func=cmd_doctor)
|
|
1241
|
+
|
|
1242
|
+
events = subparsers.add_parser("events", help="Chronological timeline: reboots, OOM kills, failed units, kernel errors")
|
|
1243
|
+
events.add_argument("--since", type=int, default=24, metavar="HOURS", help="Look-back window in hours (default: 24)")
|
|
1244
|
+
events.add_argument(
|
|
1245
|
+
"--kind", action="append", metavar="KIND", choices=list(events_mod.EVENT_KINDS), help="Filter to one kind (repeatable). Choices: " + ", ".join(events_mod.EVENT_KINDS)
|
|
1246
|
+
)
|
|
1247
|
+
events.add_argument("--limit", type=int, default=0, help="Max events to show (0 = unlimited)")
|
|
1248
|
+
events.add_argument("--format", choices=["text", "json"], default="text")
|
|
1249
|
+
events.set_defaults(func=cmd_events)
|
|
1250
|
+
|
|
1251
|
+
logs = subparsers.add_parser("logs", help="Recent ERROR-level journal entries grouped by service")
|
|
1252
|
+
logs.add_argument("--since", default="1 hour ago", help="journalctl --since value (default: '1 hour ago')")
|
|
1253
|
+
logs.add_argument("--priority", "-p", default="err", help="journalctl priority filter (default: 'err' = err+crit+alert+emerg)")
|
|
1254
|
+
logs.add_argument("--units", type=int, default=10, help="Number of top units to show (default: 10)")
|
|
1255
|
+
logs.add_argument("--lines", "-n", type=int, default=5, help="Lines per unit (default: 5)")
|
|
1256
|
+
logs.add_argument("--format", choices=["text", "json"], default="text")
|
|
1257
|
+
logs.set_defaults(func=cmd_logs)
|
|
1258
|
+
|
|
1259
|
+
services = subparsers.add_parser("services", help="Drilldown for one systemd service")
|
|
1260
|
+
services.add_argument("name", help="Service unit name (e.g. nginx or nginx.service)")
|
|
1261
|
+
services.add_argument("-n", "--lines", type=int, default=20, help="Recent journal lines to show (default: 20)")
|
|
1262
|
+
services.add_argument("--format", choices=["text", "json"], default="text")
|
|
1263
|
+
services.set_defaults(func=cmd_services)
|
|
1264
|
+
|
|
1265
|
+
cfg = subparsers.add_parser("config", help="Show or generate the wtftools config")
|
|
1266
|
+
cfg.add_argument("--example", action="store_true", help="Print a fully-commented example config and exit")
|
|
1267
|
+
cfg.add_argument("--format", choices=["text", "json"], default="text")
|
|
1268
|
+
cfg.set_defaults(func=cmd_config)
|
|
1269
|
+
|
|
1270
|
+
crontab = subparsers.add_parser("crontab", help="Validate crontab files (system + user)")
|
|
1271
|
+
crontab.add_argument("targets", nargs="*", help="Files, directories, or usernames")
|
|
1272
|
+
crontab.add_argument("-S", "--system", action="append", metavar="FILE", help="System crontab file")
|
|
1273
|
+
crontab.add_argument("-U", "--user-file", action="append", metavar="FILE", help="User crontab file")
|
|
1274
|
+
crontab.add_argument("-u", "--username", action="append", metavar="USER", help="Username")
|
|
1275
|
+
crontab.add_argument("--format", choices=["text", "json"], default="text")
|
|
1276
|
+
crontab.add_argument("--strict", action="store_true", help="Exit non-zero on warnings too")
|
|
1277
|
+
crontab.add_argument("--exit-zero", action="store_true", help="Always exit with code 0")
|
|
1278
|
+
crontab.set_defaults(func=cmd_crontab)
|
|
1279
|
+
|
|
1280
|
+
return parser
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
1284
|
+
parser = build_parser()
|
|
1285
|
+
args = parser.parse_args(argv)
|
|
1286
|
+
|
|
1287
|
+
colors.init_colors(force_no_color=args.no_color)
|
|
1288
|
+
|
|
1289
|
+
# Resolve config. CLI --config stacks on top of default paths.
|
|
1290
|
+
paths = list(config_mod.DEFAULT_CONFIG_PATHS)
|
|
1291
|
+
if args.config:
|
|
1292
|
+
paths.append(args.config)
|
|
1293
|
+
config_mod.set_config(config_mod.load_config(paths))
|
|
1294
|
+
|
|
1295
|
+
if args.verbose:
|
|
1296
|
+
logging.getLogger().setLevel(logging.INFO)
|
|
1297
|
+
elif args.quiet:
|
|
1298
|
+
logging.getLogger().setLevel(logging.ERROR)
|
|
1299
|
+
|
|
1300
|
+
if not args.command:
|
|
1301
|
+
# default action: short audit summary
|
|
1302
|
+
args.command = "audit"
|
|
1303
|
+
args.format = "text"
|
|
1304
|
+
args.strict = False
|
|
1305
|
+
args.exit_zero = False
|
|
1306
|
+
args.check = None
|
|
1307
|
+
args.only = None
|
|
1308
|
+
args.since = 24
|
|
1309
|
+
args.list_checks = False
|
|
1310
|
+
args.brief = False
|
|
1311
|
+
args.ignore = []
|
|
1312
|
+
args.serial = False
|
|
1313
|
+
args.check_timeout = None
|
|
1314
|
+
args.alert = None
|
|
1315
|
+
args.alert_on = "fail"
|
|
1316
|
+
args.save = False
|
|
1317
|
+
args.output = None
|
|
1318
|
+
args.func = cmd_audit
|
|
1319
|
+
|
|
1320
|
+
try:
|
|
1321
|
+
return args.func(args)
|
|
1322
|
+
except KeyboardInterrupt:
|
|
1323
|
+
print("\ninterrupted")
|
|
1324
|
+
return 130
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
if __name__ == "__main__":
|
|
1328
|
+
sys.exit(main())
|