- 特征检测(规则/签名)与行为检测(24h 统计)单进程运行 - 融合名单按首次发现时间倒序,展示位置/归属(GeoIP + ASN) - 钉钉融合推送(加签、24h 节流、去重) - 页面状态灯、CSV 导出、Docker 镜像构建文件
1175 lines
44 KiB
Python
1175 lines
44 KiB
Python
"""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
|