"""Sub-command implementations. Every command builds one JSON-serialisable result dict and then either prints it (``--json`` / ``--out``) or renders it for a terminal. Keep stdout parseable when ``--json`` is set: diagnostics go to stderr. """ import datetime import json import os import sys import time from . import __version__, detect, render from .client import normalize_histogram, normalize_terms from .http import GraylogError from .judge import ( DEFAULT_THRESHOLDS, Allowlist, build_fields, build_signals, collect_signals, load_allowlist, load_profile, score_candidates, verify_candidates, ) # --------------------------------------------------------------------- utils def now_iso(): return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def write_json(path, payload): directory = os.path.dirname(os.path.abspath(path)) if directory and not os.path.isdir(directory): os.makedirs(directory, exist_ok=True) with open(path, "w", encoding="utf-8") as handle: json.dump(payload, handle, indent=2, ensure_ascii=False) handle.write("\n") def _safe(func, default): """Call *func*, returning *default* on API errors (for optional resources).""" try: return func() except GraylogError: return default def resolve_stream(client, value): """Accept a stream id or a (unique) substring of its title.""" if not value: return None streams = _safe(client.streams, []) for stream in streams: if stream.get("id") == value: return value needle = value.lower() matches = [s for s in streams if needle in (s.get("title") or "").lower()] if len(matches) == 1: return matches[0].get("id") if not matches: raise SystemExit( "找不到匹配的 stream: %r(先用 `graylog-ops streams` 查看可用 stream)" % value ) titles = ", ".join(repr(s.get("title", "?")) for s in matches[:5]) raise SystemExit("stream 匹配到多个(%s),请改用完整 stream id" % titles) def _filter_for(stream_id): return "streams:%s" % stream_id if stream_id else None class Context: def __init__(self, args, config, client): self.args = args self.config = config self.client = client self.json_mode = bool(getattr(args, "json", False)) self.out_path = getattr(args, "out", None) def emit(self, data, human=None): if self.out_path: write_json(self.out_path, data) if not self.json_mode: print("JSON 已写入 %s" % self.out_path) if self.json_mode: print(json.dumps(data, indent=2, ensure_ascii=False)) elif human is not None: print(human(data)) else: print(json.dumps(data, indent=2, ensure_ascii=False)) def note(self, text): if not self.json_mode: print(text) sys.stdout.flush() # ----------------------------------------------------------------- fingerprint def _short_version(value): return (value or "unknown").split("+", 1)[0] def cmd_probe(ctx, args): client = ctx.client started = time.time() try: system = client.system_info() except GraylogError as exc: print("无法连接 Graylog: %s" % exc, file=sys.stderr) print( "排查建议: 1) 内网/VPN 是否可达 2) --url 是否带 http(s):// 3) token 是否有效", file=sys.stderr, ) return 1 checks = [] def check(name, func, detail=None): begin = time.time() try: value = func() item = { "name": name, "ok": True, "ms": int((time.time() - begin) * 1000), "detail": detail(value) if detail else _describe(value), } checks.append(item) return value except GraylogError as exc: checks.append({ "name": name, "ok": False, "ms": int((time.time() - begin) * 1000), "status": exc.status, "error": exc.message, }) return None def count(value): if isinstance(value, (list, tuple)): return "%d 项" % len(value) if isinstance(value, dict): return "%d 个键" % len(value) return "ok" health = check("indexer health", client.indexer_health) check("cluster nodes", client.cluster_nodes, count) fields = check("fields", client.field_types, count) check("streams", client.streams, count) check("inputs", client.inputs, count) check("index sets", client.index_sets, count) check("event definitions", lambda: client.event_definitions(limit=1), count) check("notifications", lambda: client.notifications(limit=1), count) check("pipelines", client.pipelines, count) check("pipeline rules", client.pipeline_rules, count) check("sidecars", client.sidecars, count) check("license", client.license_info) check("views api", lambda: client.http.get("/api/views", {"page": 1, "per_page": 1}), count) relative = check( "search relative", lambda: client.search_relative(limit=1, range_seconds=300), lambda value: "%d 条样例消息" % len(value), ) terms_value = check( "search terms", lambda: client.terms("source", size=1, range_seconds=300), lambda value: "%d 个 term" % len(normalize_terms(value)), ) check( "search histogram", lambda: client.histogram(range_seconds=300, interval="1m"), lambda value: "%d 个时间桶" % len(normalize_histogram(value)), ) data = { "system": system, "indexer": health or {}, "aggregation": client.agg_backend or "views", "checks": checks, "elapsed_ms": int((time.time() - started) * 1000), } def human(payload): info = payload["system"] indexer = payload["indexer"] or {} missing = [item for item in payload["checks"] if not item["ok"] and item.get("status") == 404] failed = [item for item in payload["checks"] if not item["ok"] and item.get("status") != 404] lines = [] lines.append(render.kv_block([ ("版本", _short_version(info.get("version"))), ("主机", info.get("hostname", "-")), ("集群 ID", info.get("cluster_id", "-")), ("时区", info.get("timezone", "-")), ("索引器", "%s / %s" % (indexer.get("status", "?"), indexer.get("cluster_name", "?"))), ("聚合接口", payload["aggregation"]), ])) lines.append("") rows = [ (item["name"], "OK" if item["ok"] else "FAIL", item.get("ms", 0), item.get("detail") or item.get("error") or "") for item in payload["checks"] ] lines.append(render.table(["检查项", "结果", "耗时(ms)", "说明"], rows, ["l", "l", "r", "l"])) lines.append("") if failed: lines.append("失败 %d 项(权限不足或服务端异常): %s" % (len(failed), ", ".join(item["name"] for item in failed))) if missing: lines.append("接口不存在 %d 项(版本差异,正常): %s" % (len(missing), ", ".join(item["name"] for item in missing))) if not failed and not missing: lines.append("全部 %d 项检查通过" % len(payload["checks"])) return "\n".join(lines) ctx.emit(data, human) required_ok = system and relative is not None return 0 if required_ok else 1 def _describe(value): if isinstance(value, list): return "%d 项" % len(value) if isinstance(value, dict): status = value.get("status") if status: return str(status) return "ok" if value is None: return "-" return render.clip(value, 40) # ---------------------------------------------------------------------- system def cmd_system(ctx, args): client = ctx.client data = { "system": client.system_info(), "indexer": _safe(client.indexer_health, {}), "deflector": _safe(client.deflector, {}), "license": _safe(client.license_info, {}), "field_types": _safe(client.field_types, []), "nodes": _safe(client.cluster_nodes, []), "collected_at": now_iso(), } def human(payload): info = payload["system"] indexer = payload["indexer"] or {} deflector = payload["deflector"] or {} shards = indexer.get("shards") or {} shard_text = "" if shards: shard_text = "(active=%s, unassigned=%s)" % ( shards.get("active", "?"), shards.get("unassigned", "?")) lines = [ render.kv_block([ ("版本", info.get("version", "-")), ("主机名", info.get("hostname", "-")), ("集群 ID", info.get("cluster_id", "-")), ("操作系统", info.get("operating_system", "-")), ("时区", info.get("timezone", "-")), ("启动时间", info.get("started_at", "-")), ("生命周期 / LB", "%s / %s" % (info.get("lifecycle", "-"), info.get("lb_status", "-"))), ("处理中", info.get("is_processing")), ("索引器", "%s %s" % (indexer.get("status", "-"), shard_text)), ("Deflector", "%s (up=%s)" % ( deflector.get("current_target", deflector.get("index", "-")), deflector.get("is_up", "-"))), ("已注册字段", len(payload["field_types"])), ]) ] nodes = payload["nodes"] or [] if nodes: rows = [] for node in nodes: rows.append(( node.get("hostname", "-"), node.get("short_node_id", (node.get("node_id") or "-")[:8]), node.get("transport_address", "-"), "yes" if node.get("is_master") else "", )) lines.append("") lines.append(render.table(["节点", "short id", "transport", "master"], rows)) return "\n".join(lines) ctx.emit(data, human) return 0 # --------------------------------------------------------------------- streams def cmd_streams(ctx, args): client = ctx.client streams = client.streams() if getattr(args, "rules", False): for stream in streams: stream_id = stream.get("id") if not stream_id: continue try: detail = client.stream(stream_id) stream["rules"] = detail.get("rules", []) except GraylogError as exc: stream["rules"] = [] stream["rules_error"] = exc.message data = {"count": len(streams), "streams": streams} def human(payload): rows = [] for stream in payload["streams"]: rules = stream.get("rules") rule_count = "-" if rules is None else len(rules) if stream.get("rules_error"): rule_count = "err" rows.append(( stream.get("id", "-"), render.clip(stream.get("title", "-"), 28), "disabled" if stream.get("disabled") else "active", rule_count, render.clip(stream.get("description", ""), 40), )) return render.table(["stream id", "标题", "状态", "规则", "描述"], rows, ["l", "l", "l", "r", "l"]) ctx.emit(data, human) return 0 # ---------------------------------------------------------------------- inputs def cmd_inputs(ctx, args): client = ctx.client inputs = client.inputs() data = {"count": len(inputs), "inputs": inputs} def human(payload): rows = [] for item in payload["inputs"]: config = item.get("configuration") or {} bind = config.get("bind_address") or config.get("bindAddress") or "-" port = config.get("port") or config.get("Port") or "-" kind = (item.get("type") or "-").rsplit(".", 1)[-1] scope = "global" if item.get("global") else "node" rows.append(( render.clip(kind, 26), render.clip(item.get("title", "-"), 26), str(bind), str(port), scope, render.clip(item.get("id", "-"), 26), )) return render.table(["类型", "标题", "绑定", "端口", "作用域", "input id"], rows, ["l", "l", "l", "r", "l", "l"]) ctx.emit(data, human) return 0 # ---------------------------------------------------------------------- fields def cmd_fields(ctx, args): client = ctx.client fields = client.field_types() rows = [] for item in fields: if isinstance(item, dict): rows.append({"name": item.get("name", "-"), "type": item.get("type", "-")}) else: rows.append({"name": str(item), "type": "-"}) data = {"count": len(rows), "fields": rows} def human(payload): body = render.table( ["字段", "类型"], [(row["name"], row["type"]) for row in payload["fields"]], ) return body + "\n\n共 %d 个已注册字段" % payload["count"] ctx.emit(data, human) return 0 # --------------------------------------------------------------------- indices def cmd_indices(ctx, args): client = ctx.client index_sets = client.index_sets() indices = client.indices() data = {"index_sets": index_sets, "indices": indices} def human(payload): lines = [] set_rows = [] for item in payload["index_sets"]: rotation = item.get("rotation_strategy") or "-" rotation_cfg = item.get("rotation_strategy_configuration") or {} if rotation == "time": rotation = "%s/%s" % (rotation, rotation_cfg.get("period", "?")) else: rotation = "%s/%s" % (rotation, rotation_cfg.get("max_docs_per_index", rotation_cfg.get("max_size_per_index", "?"))) retention = item.get("retention_strategy") or "-" retention_cfg = item.get("retention_strategy_configuration") or {} keep = retention_cfg.get("max_number_of_indices", retention_cfg.get("max_time_per_index", "?")) set_rows.append(( item.get("id", "-"), render.clip(item.get("title", "-"), 22), item.get("index_prefix", "-"), "%s/%s" % (item.get("shards", "-"), item.get("replicas", "-")), render.clip(rotation, 24), "%s/%s" % (retention, keep), )) lines.append(render.table( ["index set id", "标题", "前缀", "分片/副本", "轮转", "保留"], set_rows, ["l", "l", "l", "l", "l", "l"], )) index_rows = [] total_size = 0 for item in payload["indices"]: size = item.get("size") or 0 if isinstance(size, (int, float)): total_size += size index_rows.append(( item.get("index_name", "-"), render.clip(item.get("index_set_id", "-"), 26), render.human_bytes(size), item.get("docs_count", item.get("documents_count", "-")), render.clip(item.get("status", item.get("state", "-")), 12), )) lines.append("") lines.append(render.table(["索引", "index set", "大小", "文档数", "状态"], index_rows, ["l", "l", "r", "r", "l"])) lines.append("") lines.append("共 %d 个索引,合计 %s" % (len(index_rows), render.human_bytes(total_size))) return "\n".join(lines) ctx.emit(data, human) return 0 # -------------------------------------------------------------------- discover def cmd_discover(ctx, args): client = ctx.client stream_id = resolve_stream(client, getattr(args, "stream", None)) filter_str = _filter_for(stream_id) query = getattr(args, "query", "*") or "*" range_seconds = int(getattr(args, "range", 3600) or 3600) wanted = int(getattr(args, "sample", 500) or 500) messages = [] offset = 0 while len(messages) < wanted: batch = client.search_relative( query=query, range_seconds=range_seconds, limit=min(500, wanted - len(messages)), offset=offset, filter_str=filter_str, ) if not batch: break messages.extend(batch) offset += len(batch) if offset > wanted * 4: break report = detect.analyse_messages(messages) report.update({ "query": query, "range_seconds": range_seconds, "stream_id": stream_id, "collected_at": now_iso(), }) profile_path = getattr(args, "save_profile", None) if profile_path: http_fields = report.get("http_fields") or {} def first(kind): names = http_fields.get(kind) or [] return names[0] if names else None write_json(profile_path, { "generated_at": now_iso(), "sampled": report["sampled"], "fields": { "client_ip": report["recommendation"].get("field"), "status": first("status"), "uri": first("uri"), "ua": first("user_agent"), "method": first("method"), }, }) ctx.note("profile 已写入 %s" % profile_path) data = report def human(payload): rec = payload["recommendation"] lines = [ "采样 %d 条消息 query=%s range=%ds stream=%s" % (payload["sampled"], payload["query"], payload["range_seconds"], payload["stream_id"] or "(all)"), "", ] if payload["candidates"]: rows = [] for item in payload["candidates"]: rows.append(( "%.1f" % item["score"], item["field"], "%.0f%%" % (item["presence"] * 100), "%.0f%%" % (item["ip_rate"] * 100), item["distinct_ips"], "%.0f%%" % (item["private_ratio"] * 100), "v%s" % "/".join(str(v) for v in item["versions"]), ", ".join(item["examples"][:2]), )) lines.append(render.table( ["分数", "字段", "出现率", "IP占比", "不同IP", "私有IP", "IP版本", "样例"], rows, ["r", "l", "r", "r", "r", "r", "l", "l"], )) else: lines.append("没有字段存放纯 IP 值。") lines.append("") http_fields = payload.get("http_fields") or {} if http_fields: lines.append("HTTP 相关字段:") for kind, names in sorted(http_fields.items()): lines.append(" %-10s -> %s" % (kind, ", ".join(names))) lines.append("") confidence = rec["confidence"] lines.append("推荐字段: %s (confidence=%s)" % (rec["field"] or "-", confidence)) for note in rec["notes"]: lines.append(" - %s" % note) for warning in rec["warnings"]: lines.append(" ! %s" % warning) if rec["field"]: lines.append("") lines.append("下一步: graylog-ops top-ips --field %s --range %d%s" % (rec["field"], payload["range_seconds"], (" --stream %s" % payload["stream_id"]) if payload["stream_id"] else "")) if payload["sample_message"]: lines.append("") lines.append("样例行:") sample = json.dumps(payload["sample_message"], indent=2, ensure_ascii=False) lines.extend(" " + line for line in sample.splitlines()) return "\n".join(lines) ctx.emit(data, human) return 0 # ----------------------------------------------------------------------- terms def cmd_terms(ctx, args): client = ctx.client stream_id = resolve_stream(client, getattr(args, "stream", None)) field = args.field raw = client.terms( field=field, query=getattr(args, "query", "*") or "*", size=int(getattr(args, "size", 50) or 50), range_seconds=getattr(args, "range", 300), from_ts=getattr(args, "from_ts", None), to_ts=getattr(args, "to_ts", None), filter_str=_filter_for(stream_id), ) rows = [row for row in normalize_terms(raw) if row["term"] not in (None, "")] min_count = getattr(args, "min_count", None) if min_count is not None: rows = [row for row in rows if (row["count"] or 0) >= int(min_count)] rows.sort(key=lambda row: (-(row["count"] or 0), str(row["term"]))) total = sum((row["count"] or 0) for row in rows) data = { "field": field, "query": getattr(args, "query", "*") or "*", "range_seconds": getattr(args, "range", 300), "stream_id": stream_id, "total": total, "terms": rows, "collected_at": now_iso(), } def human(payload): lines = ["字段 %s top %d total=%d range=%ss" % (payload["field"], len(payload["terms"]), payload["total"], payload["range_seconds"]), ""] table_rows = [] for index, row in enumerate(payload["terms"], 1): table_rows.append(( index, row["term"], row["count"], render.percent(row["count"] or 0, payload["total"]), )) lines.append(render.table(["#", "值", "计数", "占比"], table_rows, ["r", "l", "r", "r"])) return "\n".join(lines) ctx.emit(data, human) return 0 # -------------------------------------------------------------------- collect def cmd_collect(ctx, args): """Run probe/discover/judge/snapshot into one directory for offline review. Calling the CLI recursively keeps this command thin: every step reuses the exact same code path (and therefore the same connection/auth handling) as running it by hand. """ import io from contextlib import redirect_stderr, redirect_stdout from .cli import main as cli_main # lazy import: avoids a module cycle if getattr(args, "dir", None): out_dir = args.dir elif ctx.out_path: # --out points at a directory for this command, not at a JSON file. out_dir = ctx.out_path ctx.out_path = None else: out_dir = "graylog-collect-" + time.strftime("%Y%m%d-%H%M%S") os.makedirs(out_dir, exist_ok=True) connection = ["--url", ctx.config.url] if ctx.config.token: connection += ["--token", ctx.config.token] else: connection += ["--user", ctx.config.user or "", "--password", ctx.config.password or ""] if ctx.config.insecure: connection.append("--insecure") connection += ["--timeout", str(ctx.config.timeout), "--retries", str(ctx.config.retries)] window = int(getattr(args, "range", 3600) or 3600) judge_window = int(getattr(args, "judge_range", 300) or 300) profile_path = os.path.join(out_dir, "profile.json") stream = getattr(args, "stream", None) stream_args = ["--stream", stream] if stream else [] judge_args = ["judge", "--all", "--json", "--range", str(judge_window), "--profile", profile_path, "--out", os.path.join(out_dir, "judge.json")] + stream_args for allow in (getattr(args, "allow", None) or []): judge_args += ["--allow", allow] steps = ( ("probe", ["probe", "--json", "--out", os.path.join(out_dir, "probe.json")]), ("discover", ["discover", "--json", "--range", str(window), "--save-profile", profile_path, "--out", os.path.join(out_dir, "discover.json")] + stream_args), ("judge", judge_args), ("snapshot", ["snapshot", "--dir", os.path.join(out_dir, "snapshot")]), ) results = [] for name, argv in steps: buffer, error_buffer = io.StringIO(), io.StringIO() begin = time.time() with redirect_stdout(buffer), redirect_stderr(error_buffer): code = cli_main(connection + argv) results.append({ "step": name, "ok": code == 0, "ms": int((time.time() - begin) * 1000), "stderr": error_buffer.getvalue().strip()[-600:], }) def load(filename): try: with open(os.path.join(out_dir, filename), "r", encoding="utf-8") as handle: return json.load(handle) except (OSError, ValueError): return {} probe = load("probe.json") discover = load("discover.json") judge = load("judge.json") recommendation = discover.get("recommendation") or {} candidates = judge.get("candidates") or [] bans = [row for row in candidates if str(row.get("action", "")).startswith("ban")] summary = [ "graylog-ops collect", "generated_at: %s" % now_iso(), "graylog: %s (%s)" % ( _short_version((probe.get("system") or {}).get("version")), ctx.config.url, ), "", "字段推断: %s (confidence=%s, sampled=%s)" % ( recommendation.get("field") or "-", recommendation.get("confidence") or "none", discover.get("sampled", "-"), ), "封禁候选: %d 个(ban_1h/ban_30m),其余候选 %d 个,排除 %d 个" % ( len(bans), len(candidates) - len(bans), len(judge.get("excluded") or []), ), "", "步骤:", ] for item in results: summary.append(" %-10s %-4s %6dms %s" % ( item["step"], "OK" if item["ok"] else "FAIL", item["ms"], item["stderr"].splitlines()[-1] if item["stderr"] else "", )) summary.append("") summary.append("该目录不含凭据,可直接打包发送。") with open(os.path.join(out_dir, "SUMMARY.txt"), "w", encoding="utf-8") as handle: handle.write("\n".join(summary) + "\n") report = { "directory": os.path.abspath(out_dir), "steps": results, "recommendation": recommendation, "ban_candidates": len(bans), "candidate_total": len(candidates), "excluded": len(judge.get("excluded") or []), "collected_at": now_iso(), } def human(payload): lines = [ "采集目录: %s" % payload["directory"], "", render.table( ["步骤", "结果", "耗时(ms)", "备注"], [(item["step"], "OK" if item["ok"] else "FAIL", item["ms"], render.clip(item["stderr"].splitlines()[-1] if item["stderr"] else "", 60)) for item in payload["steps"]], ["l", "l", "r", "l"], ), "", "字段推断: %s (confidence=%s)" % (payload["recommendation"].get("field") or "-", payload["recommendation"].get("confidence") or "none"), "封禁候选: %d 个,其余候选 %d 个,排除 %d 个" % (payload["ban_candidates"], payload["candidate_total"] - payload["ban_candidates"], payload["excluded"]), "", "该目录不含凭据,压缩后发出来即可。", ] return "\n".join(lines) ctx.emit(report, human) return 0 # ----------------------------------------------------------------------- watch def cmd_watch(ctx, args): from . import watch ctx.stream_id = resolve_stream(ctx.client, getattr(args, "stream", None)) return watch.run(ctx, args) # ----------------------------------------------------------------------- judge def cmd_judge(ctx, args): client = ctx.client profile = load_profile(getattr(args, "profile", None)) fields = build_fields(profile, args) signals = build_signals(fields, profile.get("signals")) thresholds = dict(DEFAULT_THRESHOLDS) thresholds.update(profile.get("thresholds") or {}) if getattr(args, "min_requests", None) is not None: thresholds["min_requests"] = int(args.min_requests) if getattr(args, "min_score", None) is not None: thresholds["min_score"] = float(args.min_score) allowlist = Allowlist( load_allowlist(getattr(args, "allow", None)), exclude_internal=not getattr(args, "include_internal", False), ) stream_id = resolve_stream(client, getattr(args, "stream", None)) filter_str = _filter_for(stream_id) window = int(getattr(args, "range", 300) or 300) query = getattr(args, "query", "*") or "*" size = int(getattr(args, "size", 200) or 200) totals, per_signal, errors = collect_signals( client, fields["client_ip"], signals, window, query=query, filter_str=filter_str, size=size, ) report = score_candidates(totals, per_signal, signals, thresholds, allowlist) verify_count = int(getattr(args, "verify", 0) or 0) if verify_count > 0: verify_candidates(client, report["candidates"], fields, window, filter_str=filter_str, limit=verify_count) report["candidates"].sort( key=lambda row: (-row["score"], -row["requests"], row["ip"])) report.update({ "window_seconds": window, "query": query, "field": fields["client_ip"], "stream_id": stream_id, "fields": fields, "thresholds": dict(thresholds), "signals": [ {key: signal[key] for key in ("name", "title", "query", "weight", "saturate")} for signal in signals ], "errors": errors, "collected_at": now_iso(), }) explain = int(getattr(args, "explain", 0) or 0) if explain > 0: for candidate in report["candidates"][:explain]: try: samples = client.search_relative( query='%s:"%s"' % (fields["client_ip"], candidate["ip"]), range_seconds=window, limit=3, filter_str=filter_str, ) except GraylogError: samples = [] keep = {"timestamp", "message"} | {value for value in fields.values() if value} candidate["samples"] = [ {key: value for key, value in message.items() if key in keep} for message in samples ] total_candidates = len(report["candidates"]) shown = report["candidates"] if not getattr(args, "all", False): shown = [row for row in shown if row["score"] >= thresholds["min_score"]] report["total_candidates"] = total_candidates report["candidates"] = shown[: int(getattr(args, "top", 30) or 30)] report["shown"] = len(report["candidates"]) def human(payload): lines = [ "窗口 %ds query=%s field=%s stream=%s 候选池 %d(中位请求数 %s)" % (payload["window_seconds"], payload["query"], payload["field"], payload["stream_id"] or "(all)", payload["candidate_pool"], payload["median_requests"]), "", ] if payload["candidates"]: rows = [] for row in payload["candidates"]: rows.append(( "%.1f" % row["score"], row["action"], row["ip"], row["requests"], render.clip("; ".join(row["reasons"]), 74), )) lines.append(render.table(["得分", "动作", "客户端 IP", "请求数", "依据"], rows, ["r", "l", "l", "r", "l"])) else: lines.append("没有达到 --min-score 的候选。先跑 discover 确认字段,或降低阈值观察。") if payload["excluded"]: lines.append("") lines.append("已排除(不参与判分):") for row in payload["excluded"]: lines.append(" %-20s %-6s %s" % (row["ip"], row["requests"], row["reason"])) lines.append("") lines.append("信号命中:") for stat in payload["signal_stats"]: lines.append( " %-14s %-10s %3d 个 IP / %5d 次" % (stat["name"], stat["title"], stat["ips_matched"], stat["requests_matched"]) ) lines.append(" %-25s query: %s" % ("", render.clip(stat["query"], 66))) zero = [stat["name"] for stat in payload["signal_stats"] if stat["ips_matched"] == 0] if zero: lines.append("") lines.append("! 信号命中 0:%s —— 若窗口内确实有这类流量,说明查询模板与字段分词不匹配," "需要用 profile 覆盖 query" % ", ".join(zero)) for error in payload["errors"]: lines.append("! 信号 %s 查询失败: %s" % (error["signal"], error["error"])) samples = [(row["ip"], item) for row in payload["candidates"][:3] for item in (row.get("samples") or [])] seen = set() unique = [] for ip, item in samples: text = item.get("message", "") if text in seen: continue seen.add(text) unique.append((ip, item)) if unique: lines.append("") lines.append("样例消息(前 3 个候选):") for ip, item in unique[:6]: lines.append(" %-16s %s" % (ip, render.clip(item.get("message", ""), 88))) lines.append("") lines.append("本命令只读,不会下发封禁;下一步是把 action 交给执行侧(AD 黑名单 / WAF)。") return "\n".join(lines) ctx.emit(report, human) return 0 # -------------------------------------------------------------------- messages def cmd_messages(ctx, args): client = ctx.client stream_id = resolve_stream(client, getattr(args, "stream", None)) fields = None if getattr(args, "fields", None): fields = [name.strip() for name in args.fields.split(",") if name.strip()] messages = client.search_relative( query=getattr(args, "query", "*") or "*", range_seconds=getattr(args, "range", 300), limit=int(getattr(args, "limit", 50) or 50), offset=int(getattr(args, "offset", 0) or 0), fields=fields, filter_str=_filter_for(stream_id), ) data = {"count": len(messages), "query": getattr(args, "query", "*"), "stream_id": stream_id, "messages": messages} if getattr(args, "jsonl", False): for message in messages: print(json.dumps(message, ensure_ascii=False)) return 0 def human(payload): if not payload["messages"]: return "没有匹配的消息(检查时间范围 / 查询语法 / stream 权限)" columns = ["timestamp", "source"] + [name for name in (fields or []) if name not in ("timestamp", "source")] rows = [] for message in payload["messages"]: row = [render.clip(message.get(name, "-"), 22) for name in columns] row.append(render.clip(message.get("message", ""), 70)) rows.append(row) header = [render.clip(name, 22) for name in columns] + ["message"] return render.table(header, rows) + "\n\n共 %d 条" % payload["count"] ctx.emit(data, human) return 0 # ------------------------------------------------------------------- histogram def cmd_histogram(ctx, args): client = ctx.client stream_id = resolve_stream(client, getattr(args, "stream", None)) raw = client.histogram( query=getattr(args, "query", "*") or "*", range_seconds=getattr(args, "range", 3600), interval=getattr(args, "interval", "1m") or "1m", filter_str=_filter_for(stream_id), ) rows = normalize_histogram(raw) total = sum((row["count"] or 0) for row in rows) seconds = int(getattr(args, "range", 3600) or 3600) data = { "interval": getattr(args, "interval", "1m"), "range_seconds": seconds, "total": total, "average_per_second": round(total / seconds, 3) if seconds else 0, "buckets": rows, } def human(payload): table_rows = [(row["timestamp"], row["count"]) for row in payload["buckets"]] lines = [ render.table(["时间桶", "消息数"], table_rows, ["l", "r"]), "", "总计 %d 条,平均 %.2f 条/秒" % (payload["total"], payload["average_per_second"]), ] return "\n".join(lines) ctx.emit(data, human) return 0 # ---------------------------------------------------------------------- alerts def cmd_alerts(ctx, args): client = ctx.client definitions = client.event_definitions(limit=getattr(args, "limit", None)) notifications = client.notifications(limit=getattr(args, "limit", None)) data = {"event_definitions": definitions, "notifications": notifications} def human(payload): lines = [] rows = [] for item in payload["event_definitions"]: config = item.get("config") or {} rows.append(( render.clip(item.get("title", "-"), 30), item.get("priority", "-"), "disabled" if item.get("disabled") else "active", render.clip(str(config.get("type", "-")), 24), len((item.get("notifications") or [])), )) lines.append("事件定义") lines.append(render.table(["标题", "优先级", "状态", "类型", "通知数"], rows, ["l", "r", "l", "l", "r"])) lines.append("") notif_rows = [] for item in payload["notifications"]: config = item.get("config") or {} notif_rows.append(( render.clip(item.get("title", "-"), 30), render.clip(str(config.get("type", "-")), 26), render.clip(str(config.get("url", config.get("uri", ""))), 50), )) lines.append("通知方式") lines.append(render.table(["标题", "类型", "目标"], notif_rows, ["l", "l", "l"])) return "\n".join(lines) ctx.emit(data, human) return 0 # -------------------------------------------------------------------- snapshot SNAPSHOT_RESOURCES = ( ("system", lambda client: client.system_info()), ("cluster_nodes", lambda client: client.cluster_nodes()), ("indexer_health", lambda client: client.indexer_health()), ("deflector", lambda client: client.deflector()), ("field_types", lambda client: client.field_types()), ("license", lambda client: client.license_info()), ("streams", lambda client: client.streams()), ("inputs", lambda client: client.inputs()), ("index_sets", lambda client: client.index_sets()), ("indices", lambda client: client.indices()), ("pipelines", lambda client: client.pipelines()), ("pipeline_rules", lambda client: client.pipeline_rules()), ("event_definitions", lambda client: client.event_definitions()), ("notifications", lambda client: client.notifications()), ("sidecars", lambda client: client.sidecars()), ("views", lambda client: client.http.get("/api/views", {"page": 1, "per_page": 50})), ) def cmd_snapshot(ctx, args): client = ctx.client out_dir = (getattr(args, "dir", None) or ctx.out_path or ("graylog-snapshot-" + time.strftime("%Y%m%d-%H%M%S"))) os.makedirs(out_dir, exist_ok=True) resources = [] for name, fetch in SNAPSHOT_RESOURCES: started = time.time() try: payload = fetch(client) status, error = "ok", None except GraylogError as exc: payload, status, error = None, "error", str(exc) write_json(os.path.join(out_dir, name + ".json"), { "resource": name, "status": status, "error": error, "data": payload, }) resources.append({ "resource": name, "status": status, "error": error, "ms": int((time.time() - started) * 1000), }) sample = int(getattr(args, "sample", 0) or 0) sampled = 0 if sample > 0: try: messages = client.search_relative(limit=sample, range_seconds=3600) path = os.path.join(out_dir, "messages.jsonl") with open(path, "w", encoding="utf-8") as handle: for message in messages: handle.write(json.dumps(message, ensure_ascii=False) + "\n") sampled = len(messages) except GraylogError as exc: resources.append({"resource": "messages.jsonl", "status": "error", "error": str(exc), "ms": 0}) system = {} for item in resources: if item["resource"] == "system" and item["status"] == "ok": try: with open(os.path.join(out_dir, "system.json"), "r", encoding="utf-8") as handle: system = json.load(handle).get("data") or {} except (OSError, ValueError): system = {} manifest = { "tool": "graylog-ops", "tool_version": __version__, "generated_at": now_iso(), "graylog": { "url": ctx.config.url, "version": system.get("version"), "hostname": system.get("hostname"), "cluster_id": system.get("cluster_id"), }, "sampled_messages": sampled, "resources": resources, } write_json(os.path.join(out_dir, "manifest.json"), manifest) def human(payload): rows = [(item["resource"], item["status"], item.get("ms", 0), render.clip(item.get("error") or "", 60)) for item in payload["resources"]] lines = [ "Graylog %s @ %s" % (_short_version(payload["graylog"].get("version")), payload["graylog"].get("hostname", "-")), "", render.table(["资源", "状态", "耗时(ms)", "备注"], rows, ["l", "l", "r", "l"]), "", "快照目录: %s" % out_dir, ] if payload["sampled_messages"]: lines.append("附带采样消息: %d 条 -> messages.jsonl" % payload["sampled_messages"]) failed = [item for item in payload["resources"] if item["status"] != "ok"] lines.append("失败 %d 项(快照仍可用,缺失项见各自 json 的 error 字段)" % len(failed)) return "\n".join(lines) ctx.emit(manifest, human) return 0 # ------------------------------------------------------------------------- raw def cmd_raw(ctx, args): params = {} for item in (getattr(args, "param", None) or []): if "=" not in item: raise SystemExit("--param 需要 key=value 形式, 收到: %r" % item) key, value = item.split("=", 1) params[key] = value method = (getattr(args, "method", None) or "GET").upper() body = None raw_body = getattr(args, "body", None) if raw_body: if raw_body.startswith("@"): try: with open(raw_body[1:], "r", encoding="utf-8") as handle: raw_body = handle.read() except OSError as exc: raise SystemExit("无法读取 --body 文件 %s: %s" % (raw_body[1:], exc)) try: body = json.loads(raw_body) except ValueError as exc: raise SystemExit("--body 不是合法 JSON: %s" % exc) if getattr(args, "text", False): data = ctx.client.http.request(method, args.path, params=params, body=body, expect_json=False) print(data) return 0 payload = ctx.client.http.request(method, args.path, params=params, body=body) print(json.dumps(payload, indent=2, ensure_ascii=False)) return 0