Files
graylog-ops/graylog_ops/commands.py
T
DeepFal 2d76ed6672 初始提交:恶意 IP 特征+行为融合检测系统
- 特征检测(规则/签名)与行为检测(24h 统计)单进程运行
- 融合名单按首次发现时间倒序,展示位置/归属(GeoIP + ASN)
- 钉钉融合推送(加签、24h 节流、去重)
- 页面状态灯、CSV 导出、Docker 镜像构建文件
2026-10-18 05:03:05 +08:00

1175 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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