From 24afb9e1dc20eca131e63626d0995b4ee1f0189e Mon Sep 17 00:00:00 2001 From: DeepFal Date: Mon, 19 Oct 2026 13:29:30 +0800 Subject: [PATCH] =?UTF-8?q?=E8=87=AA=E5=8A=A8=E8=A1=A5=E6=B5=8B=E3=80=81?= =?UTF-8?q?=E6=8E=A8=E9=80=81=E5=AE=A1=E8=AE=A1/=E9=80=80=E9=81=BF?= =?UTF-8?q?=E9=87=8D=E8=AF=95=E3=80=81=E7=9B=91=E6=8E=A7=E6=8C=87=E6=A0=87?= =?UTF-8?q?=E4=B8=8E=E7=9B=91=E6=8E=A7=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 + docs/screenshots/monitor.png | Bin 0 -> 86792 bytes graylog_ops/backfill.py | 224 ++++++++++++++++++ graylog_ops/behavior.py | 86 +++++-- graylog_ops/cli.py | 6 + graylog_ops/watch.py | 432 ++++++++++++++++++++++++++++++++++- tests/test_behavior.py | 49 ++++ tests/test_watch.py | 47 ++++ tools/backfill_behavior.py | 4 +- 9 files changed, 831 insertions(+), 23 deletions(-) create mode 100644 docs/screenshots/monitor.png create mode 100644 graylog_ops/backfill.py diff --git a/README.md b/README.md index 4332e2a..20d9824 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,12 @@ cd ../.. && python3 -m graylog_ops.geo \ [查看详情](http://179.253.234.124:12139/s/301b.../#/ip/112.32.1.211) ``` +### 自动补测 / 推送审计 / 监控 + +- **启动自动补测**:启动时读取上次成功检测时间,缺口超过 30 分钟自动后台回扫(可用 `--backfill-threshold`、`--backfill-max-gap`、`--no-auto-backfill` 控制) +- **推送审计**:`notifications` 表记录每个 IP 的推送成功/失败、错误信息;失败按 60s→120s→…→1h 指数退避重试;连续失败 3 次会记录告警(可选 `NOTIFY_ALERT_URL` 发到独立 webhook) +- **监控**:页面右上角「监控」入口,展示 Graylog 状态、检测轮次/耗时、行为分析、推送成功率、名单和补测记录;`/metrics` 输出 Prometheus 格式,可直接被任意监控系统抓取 + ### 状态指示灯 页面右上角有一个圆点,表示后端与 Graylog 的连接状态: diff --git a/docs/screenshots/monitor.png b/docs/screenshots/monitor.png new file mode 100644 index 0000000000000000000000000000000000000000..fea4273bea39955f0a64a6c9d216e71a323b39c8 GIT binary patch literal 86792 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 s2>&;(2e7K`?<= size and (end - start).total_seconds() > 300: + middle = start + (end - start) / 2 + return collect(query, start, middle) + collect(query, middle, end) + return [(str(item.get("term") or ""), int(item.get("count") or 0)) + for item in terms if item.get("term")] + + for rule in watch.RULE_DEFS: + if rule.get("weight", 0) <= 0 and rule["name"] != "not_found": + continue + try: + pairs = collect(rule["query"](fields), from_dt, to_dt) + except Exception: + continue + merged = {} + for ip, count in pairs: + merged[ip] = merged.get(ip, 0) + count + rule_totals[rule["name"]] = sum(merged.values()) + for ip, count in merged.items(): + counts.setdefault(ip, {})[rule["name"]] = count + + now_iso = watch.now_iso() + candidates = [] + for ip, hits in counts.items(): + try: + if not ipaddress.ip_address(ip).is_global: + continue + except ValueError: + continue + total = sum(hits.values()) + tier, score, reasons = watch.classify(total, hits, window_scale=span / 900.0) + if tier != watch.TIER_MALICIOUS: + continue + candidates.append({"ip": ip, "hits": hits, "total": total, + "score": score, "reasons": reasons}) + + candidates.sort(key=lambda item: (-item["score"], -item["total"], item["ip"])) + touched = [] + created = upgraded = extended = 0 + for item in candidates: + ip = item["ip"] + first = last = None + try: + asc = client.search_absolute(query='%s:"%s"' % (ip_field, ip), + from_ts=iso(from_dt), to_ts=iso(to_dt), + limit=1, sort="timestamp:asc", + fields=["timestamp"], filter_str=filter_str) + desc = client.search_absolute(query='%s:"%s"' % (ip_field, ip), + from_ts=iso(from_dt), to_ts=iso(to_dt), + limit=1, sort="timestamp:desc", + fields=["timestamp"], filter_str=filter_str) + first = (asc or [{}])[0].get("timestamp") + last = (desc or [{}])[0].get("timestamp") + first = iso(parse_iso(first)) if parse_iso(first) else None + last = iso(parse_iso(last)) if parse_iso(last) else None + except Exception: + pass + first = first or iso(from_dt) + last = last or iso(to_dt) + record = { + "ip": ip, "tier": watch.TIER_MALICIOUS, "score": item["score"], + "total": item["total"], "rules": item["hits"], + "reasons": item["reasons"], "first_seen": first, "last_seen": last, + "last_malicious_at": last, + "risk": "high" if item["score"] >= 85 else "medium", + "window": span, + "geo": geo.lookup(ip) if geo else {}, + "asn": asn.lookup(ip) if asn else {}, + } + existing = store.get(ip) + if existing is None: + store.upsert(record, now_iso) + created += 1 + touched.append(ip) + elif existing.get("tier") == watch.TIER_MALICIOUS: + with store.lock, store.conn: + store.conn.execute( + "UPDATE ip_stats SET first_seen=?, last_seen=?, " + "last_malicious_at=?, ever_malicious=1 WHERE ip=?", + (min(first, existing.get("first_seen") or first), + max(last, existing.get("last_seen") or last), + max(last, existing.get("last_malicious_at") or last), ip)) + extended += 1 + touched.append(ip) + elif float(existing.get("score") or 0) < item["score"]: + store.upsert(record, now_iso) + upgraded += 1 + touched.append(ip) + else: + extended += 1 + touched.append(ip) + + if touched: + store.sync_fused() + _suppress(store, touched) + with store.lock, store.conn: + store.conn.executemany( + """UPDATE ip_fused SET first_banned_at = ( + SELECT s.first_seen FROM ip_stats s WHERE s.ip = ip_fused.ip) + WHERE ip=? AND EXISTS ( + SELECT 1 FROM ip_stats s WHERE s.ip = ip_fused.ip + AND s.first_seen < ip_fused.first_banned_at)""", + [(ip,) for ip in touched]) + + return {"candidates": len(candidates), "created": created, + "upgraded": upgraded, "extended": extended, + "rule_totals": rule_totals, + "ips": [item["ip"] for item in candidates]} + + +def behavior_backfill(client, store, fields, filter_str, from_dt, to_dt, + root, geo=None, asn=None): + """Run the independent behaviour analyzer over an absolute range.""" + behavior.set_range(iso(from_dt), iso(to_dt)) + try: + analyzer = behavior.BehaviorAnalyzer(client, store, fields, filter_str, + root=root, notifier=None) + items = analyzer.analyze() + finally: + behavior.clear_range() + for item in items: + try: + last = client.search_absolute( + query='%s:"%s"' % (fields["client_ip"], item["ip"]), + from_ts=iso(from_dt), to_ts=iso(to_dt), limit=1, + sort="timestamp:desc", fields=["timestamp"], + filter_str=filter_str) + stamp = (last or [{}])[0].get("timestamp") + parsed = parse_iso(stamp) + if parsed: + item["last_ts"] = parsed.strftime("%Y-%m-%dT%H:%M:%SZ") + except Exception: + pass + flagged = [item for item in items if item.get("verdict") == "建议封禁"] + store.upsert_historical_profiles(items) + before = {row["ip"] for row in store.conn.execute("SELECT ip FROM ip_fused")} + store.sync_fused() + added = [item["ip"] for item in flagged if item["ip"] not in before] + _suppress(store, added) + return {"violations": len(items), "flagged": len(flagged), + "newly_fused": len(added), "new_ips": sorted(added)} + + +def auto_backfill_gap(store, client, fields, filter_str, from_dt, to_dt, + root, behavior_enabled=True): + started = time.time() + geo = asn = None + try: + import os + geo_path = os.path.join(root, "data", "geo", "geo.db") + asn_path = os.path.join(root, "data", "geo", "asn.db") + if os.path.exists(geo_path): + geo = GeoIP(geo_path) + if os.path.exists(asn_path): + asn = AsnDB(asn_path) + except Exception: + pass + summary = {"from": iso(from_dt), "to": iso(to_dt)} + try: + summary["feature"] = feature_backfill( + client, store, fields, filter_str, from_dt, to_dt, geo=geo, asn=asn) + gap_hours = (to_dt - from_dt).total_seconds() / 3600.0 + if behavior_enabled and 1 <= gap_hours <= 48: + summary["behavior"] = behavior_backfill( + client, store, fields, filter_str, from_dt, to_dt, root, + geo=geo, asn=asn) + store.incr_meta("backfill_runs_total") + store.set_meta("backfill_last_at", watch.now_iso()) + store.set_meta("backfill_last_from", iso(from_dt)) + store.set_meta("backfill_last_to", iso(to_dt)) + store.set_meta("backfill_last_added", + len(summary["feature"].get("ips") or [])) + store.set_meta("backfill_last_error", "") + except Exception as exc: + summary["error"] = str(exc) + store.set_meta("backfill_last_error", str(exc)) + summary["seconds"] = round(time.time() - started, 1) + return summary diff --git a/graylog_ops/behavior.py b/graylog_ops/behavior.py index 4c70b71..d20432a 100644 --- a/graylog_ops/behavior.py +++ b/graylog_ops/behavior.py @@ -134,15 +134,28 @@ def asn_info(ip, root): return {"asn": "", "org": "未知", "shared": "unknown"} -RANGE_OVERRIDE = None +_RANGE_LOCAL = threading.local() + + +def set_range(from_ts, to_ts): + """Set an absolute analysis range for the current thread only.""" + _RANGE_LOCAL.value = {"from": from_ts, "to": to_ts} + + +def clear_range(): + _RANGE_LOCAL.value = None + + +def current_range(): + return getattr(_RANGE_LOCAL, "value", None) def pivot(client, series, row_groups, column_groups=None, query="*", limit=300, timeout=180000): - if RANGE_OVERRIDE: + override = current_range() + if override: timerange = {"type": "absolute", - "from": RANGE_OVERRIDE["from"], - "to": RANGE_OVERRIDE["to"]} + "from": override["from"], "to": override["to"]} else: timerange = {"type": "relative", "range": WINDOW} body = {"queries": [{ @@ -367,6 +380,9 @@ class BehaviorAnalyzer: self.notifier = notifier self.last_run = None self.last_error = None + self.last_duration = None + self.last_count = 0 + self.error_count = 0 self.runs = 0 self.asn = None try: @@ -411,9 +427,10 @@ class BehaviorAnalyzer: pass try: extra = {} - if RANGE_OVERRIDE: - extra = {"from_ts": RANGE_OVERRIDE["from"], - "to_ts": RANGE_OVERRIDE["to"]} + override = current_range() + if override: + extra = {"from_ts": override["from"], + "to_ts": override["to"]} hist = self.client.histogram(query=q, range_seconds=self.window, interval="5m", filter_str=self.filter_str, **extra) @@ -623,7 +640,14 @@ class BehaviorAnalyzer: self.store.sync_fused() self.last_run = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) self.last_error = None + self.last_duration = round(time.time() - started, 2) + self.last_count = len(items) self.runs += 1 + self.store.set_meta("behavior_last_success", self.last_run) + self.store.set_meta("behavior_last_duration", self.last_duration) + self.store.set_meta("behavior_last_count", self.last_count) + self.store.set_meta("behavior_last_error", "") + self.store.incr_meta("behavior_runs_total") print("[%s] 行为分析: %d 个违规 IP,%d 个建议封禁,耗时 %.1fs" % (self.last_run, len(items), sum(1 for item in items if item["verdict"] == "建议封禁"), @@ -633,6 +657,9 @@ class BehaviorAnalyzer: return items except Exception as exc: # keep the service alive self.last_error = str(exc) + self.error_count += 1 + self.store.set_meta("behavior_last_error", str(exc)) + self.store.incr_meta("behavior_errors_total") print("[行为分析失败] %s" % exc, flush=True) return [] @@ -694,15 +721,46 @@ def notify_fused(store, notifier, root=None): if link: lines.append("[查看详情](%s)" % link) lines.append("") + ips = [row["ip"] for row in due[:20]] try: notifier.post("\n".join(lines).strip()) except Exception as exc: - # 失败时不刷屏:每 5 分钟最多提示一次;下一轮会自动重试 + store.mark_push_failed(ips, str(exc), stamp=now) + fails = store.incr_meta("push_consecutive_failures") + store.set_meta("push_last_error", str(exc)) + store.set_meta("push_last_error_at", now) + # 失败日志限流:每 5 分钟最多提示一次 global _PUSH_ERROR_AT - now = time.time() - if now - _PUSH_ERROR_AT > 300: - print("推送失败(每 5 分钟最多提示一次): %s" % exc, flush=True) - _PUSH_ERROR_AT = now + now_epoch = time.time() + if now_epoch - _PUSH_ERROR_AT > 300: + print("推送失败(重试 #%d,每 5 分钟最多提示一次): %s" + % (fails, exc), flush=True) + _PUSH_ERROR_AT = now_epoch + if fails == 3 or (fails >= 3 and fails % 10 == 0): + print("[推送告警] 连续 %d 次推送失败,请检查 webhook / 网络" % fails, + flush=True) + _send_alert("钉钉推送连续 %d 次失败:%s" % (fails, exc)) return 0 - store.mark_pushed([row["ip"] for row in due[:20]], now) - return len(due[:20]) + store.mark_pushed(ips, now, channel=getattr(notifier, "kind", "dingtalk")) + store.set_meta("push_consecutive_failures", 0) + store.set_meta("push_last_error", "") + return len(ips) + + +def _send_alert(text): + """Optional independent alert webhook (NOTIFY_ALERT_URL).""" + import os + url = (os.environ.get("NOTIFY_ALERT_URL") or "").strip() + if not url: + return + try: + from .notify import Notifier + alert = Notifier({ + "type": os.environ.get("NOTIFY_ALERT_TYPE", "dingtalk"), + "url": url, + "secret": os.environ.get("NOTIFY_ALERT_SECRET", ""), + "title": "推送告警", + }) + alert.post(text, title="推送告警") + except Exception as exc: + print("告警发送失败: %s" % exc, flush=True) diff --git a/graylog_ops/cli.py b/graylog_ops/cli.py index fccee6d..009b694 100644 --- a/graylog_ops/cli.py +++ b/graylog_ops/cli.py @@ -148,6 +148,12 @@ def build_parser(): watch.add_argument("--host", default="127.0.0.1", help="面板监听地址") watch.add_argument("--port", type=int, default=8088, help="面板端口,默认 8088") watch.add_argument("--db", default="data/ips.db", help="SQLite 路径") + watch.add_argument("--backfill-threshold", type=int, default=1800, + help="启动时检测缺口超过多少秒自动补测(默认 1800)") + watch.add_argument("--backfill-max-gap", type=int, default=7 * 86400, + help="自动补测允许的最大缺口秒数(默认 7 天)") + watch.add_argument("--no-auto-backfill", action="store_true", + help="启动时不自动补测历史缺口") watch.add_argument("--behavior-interval", type=int, default=900, help="行为检测刷新间隔(秒,默认 900)") watch.add_argument("--no-behavior", action="store_true", diff --git a/graylog_ops/watch.py b/graylog_ops/watch.py index 641872c..6eeb270 100644 --- a/graylog_ops/watch.py +++ b/graylog_ops/watch.py @@ -21,6 +21,7 @@ Design """ import csv +import datetime import html import io import ipaddress @@ -362,9 +363,31 @@ class Store: fused_reason TEXT, updated_at TEXT )""" ) + for column in ("push_fail_count INTEGER DEFAULT 0", + "push_next_at TEXT"): + try: + self.conn.execute( + "ALTER TABLE ip_fused ADD COLUMN %s" % column) + except sqlite3.OperationalError: + pass self.conn.execute( "CREATE INDEX IF NOT EXISTS idx_ip_fused_first " "ON ip_fused(first_banned_at DESC, ip)") + self.conn.execute( + """CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, value TEXT + )""" + ) + self.conn.execute( + """CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip TEXT, channel TEXT, status TEXT, attempt INTEGER, + error TEXT, created_at TEXT + )""" + ) + self.conn.execute( + "CREATE INDEX IF NOT EXISTS idx_notifications_created " + "ON notifications(created_at DESC)") self.conn.execute( """CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -746,6 +769,85 @@ class Store: self.conn.execute("DELETE FROM ip_fused WHERE ip = ?", (ip,)) return len(wanted) + # ------------------------------------------------------------- meta + def get_meta(self, key, default=None): + with self.lock: + row = self.conn.execute( + "SELECT value FROM meta WHERE key=?", (key,)).fetchone() + return row["value"] if row else default + + def set_meta(self, key, value): + with self.lock, self.conn: + self.conn.execute( + "INSERT INTO meta(key, value) VALUES(?,?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value", + (key, str(value))) + + def incr_meta(self, key, amount=1): + with self.lock, self.conn: + self.conn.execute( + "INSERT INTO meta(key, value) VALUES(?,?) " + "ON CONFLICT(key) DO UPDATE SET value=CAST(" + "CAST(value AS INTEGER)+? AS TEXT)", + (key, str(amount), int(amount))) + row = self.conn.execute( + "SELECT value FROM meta WHERE key=?", (key,)).fetchone() + return int(row["value"] or 0) + + def meta_dict(self): + with self.lock: + rows = self.conn.execute("SELECT key, value FROM meta").fetchall() + return {row["key"]: row["value"] for row in rows} + + # ------------------------------------------------------- notifications + def log_notification(self, ip, channel, status, attempt, error=None, + stamp=None): + stamp = stamp or now_iso() + with self.lock, self.conn: + self.conn.execute( + "INSERT INTO notifications(ip, channel, status, attempt, error, " + "created_at) VALUES(?,?,?,?,?,?)", + (ip, channel, status, int(attempt or 0), error, stamp)) + + def mark_push_failed(self, ips, error, stamp=None, base=60, cap=3600): + stamp = stamp or now_iso() + base_time = time.time() + with self.lock, self.conn: + for ip in ips: + row = self.conn.execute( + "SELECT COALESCE(push_fail_count,0) AS n FROM ip_fused " + "WHERE ip=?", (ip,)).fetchone() + fails = int(row["n"] if row else 0) + 1 + delay = min(cap, base * (2 ** (fails - 1))) + next_at = time.strftime( + "%Y-%m-%dT%H:%M:%SZ", + time.gmtime(base_time + delay)) + self.conn.execute( + "UPDATE ip_fused SET push_fail_count=?, push_next_at=? " + "WHERE ip=?", (fails, next_at, ip)) + self.conn.execute( + "INSERT INTO notifications(ip, channel, status, attempt, " + "error, created_at) VALUES(?,?,?,?,?,?)", + (ip, "dingtalk", "failed", fails, error, stamp)) + return len(ips) + + def notification_stats(self, hours=24): + since = time.strftime("%Y-%m-%dT%H:%M:%SZ", + time.gmtime(time.time() - hours * 3600)) + with self.lock: + rows = self.conn.execute( + "SELECT status, COUNT(*) AS n FROM notifications " + "WHERE created_at >= ? GROUP BY status", (since,)).fetchall() + recent = self.conn.execute( + "SELECT * FROM notifications ORDER BY id DESC LIMIT 30").fetchall() + counts = {row["status"]: int(row["n"]) for row in rows} + sent = counts.get("sent", 0) + failed = counts.get("failed", 0) + total = sent + failed + return {"sent": sent, "failed": failed, + "success_ratio": round(sent / total, 4) if total else None, + "recent": [dict(row) for row in recent]} + def due_pushes(self, active_hours=24, cooldown_hours=24): now = time.time() active_cut = time.strftime("%Y-%m-%dT%H:%M:%SZ", @@ -767,8 +869,9 @@ class Store: LEFT JOIN behavior_profiles b ON b.ip = f.ip WHERE f.last_activity_at >= ? AND (f.last_pushed_at IS NULL OR f.last_pushed_at <= ?) + AND (f.push_next_at IS NULL OR f.push_next_at <= ?) ORDER BY f.first_banned_at DESC LIMIT 50""", - (active_cut, push_cut)).fetchall() + (active_cut, push_cut, now_iso())).fetchall() items = [] for row in rows: item = dict(row) @@ -785,12 +888,18 @@ class Store: items.append(item) return items - def mark_pushed(self, ips, stamp): + def mark_pushed(self, ips, stamp, channel="dingtalk"): with self.lock, self.conn: for ip in ips: self.conn.execute( - "UPDATE ip_fused SET last_pushed_at=?, push_count=push_count+1 " - "WHERE ip=?", (stamp, ip)) + "UPDATE ip_fused SET last_pushed_at=?, " + "push_count=push_count+1, push_fail_count=0, " + "push_next_at=NULL WHERE ip=?", (stamp, ip)) + self.conn.execute( + "INSERT INTO notifications(ip, channel, status, attempt, " + "error, created_at) VALUES(?,?,?,?,?,?)", + (ip, channel, "sent", 1, None, stamp)) + self.incr_meta("push_sent_total", len(ips)) def stats(self): with self.lock: @@ -946,6 +1055,8 @@ class Detector: self.last_run = None self.last_success = None self.last_error = None + self.last_duration = None + self.error_count = 0 self.runs = 0 def _terms(self, query, window_cap=None): @@ -1232,7 +1343,13 @@ class Detector: self.last_run = stamp self.last_success = time.time() + self.last_duration = round(time.time() - started, 2) + self.last_error = None self.runs += 1 + self.store.set_meta("detect_last_success", stamp) + self.store.set_meta("detect_last_duration", self.last_duration) + self.store.set_meta("detect_last_error", "") + self.store.incr_meta("detect_rounds_total") return { "window": self.window, "request_seconds": round(time.time() - started, 2), @@ -1779,6 +1896,32 @@ a:hover{text-decoration:underline} .notes div{font-size:12.5px;color:var(--ink-2);background:var(--surface-2); border:1px solid var(--line);border-radius:9px;padding:9px 12px;line-height:1.55} .hint{color:var(--muted);font-size:12px;margin-top:11px;line-height:1.7} +.monitor-head{display:flex;align-items:center;gap:14px;margin:6px 0 18px} +.monitor-title{font-size:21px;font-weight:600;color:var(--ink);margin-right:auto} +.mgrid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr)); + gap:12px;margin-bottom:18px} +@media (max-width:1100px){.mgrid{grid-template-columns:repeat(2,minmax(0,1fr))}} +@media (max-width:700px){.mgrid{grid-template-columns:1fr}} +.mcard{background:var(--surface);border:1px solid var(--line); + border-radius:14px;padding:15px 17px;box-shadow:0 1px 2px rgba(36,33,25,.03)} +.mcard h3{font-size:11.5px;color:var(--muted);font-weight:600; + letter-spacing:.06em;margin:0 0 7px} +.mbig{font-family:"Iowan Old Style",Palatino,Georgia,serif;font-size:27px; + color:var(--ink);line-height:1.15;margin-bottom:6px} +.mbig.mok{color:#3E8E63}.mbig.mbad{color:#C0503A}.mbig.mwarn{color:var(--amber)} +.mrow{display:flex;justify-content:space-between;gap:10px;font-size:12px; + color:var(--ink-2);margin-top:5px} +.mrow span:last-child{color:var(--muted);text-align:right} +.mtablewrap{overflow:auto;border:1px solid var(--line);border-radius:14px; + background:var(--surface);box-shadow:0 1px 2px rgba(36,33,25,.03)} +.mtable{width:100%;border-collapse:collapse;font-size:12.5px} +.mtable th{color:var(--muted);font-size:11.5px;font-weight:600;text-align:left; + padding:10px 14px;border-bottom:1px solid var(--line);background:var(--surface-2)} +.mtable td{padding:9px 14px;border-bottom:1px solid #EFE9DD;color:var(--ink-2)} +.mtable tr:last-child td{border-bottom:0} +.nbadge{display:inline-block;padding:2px 9px;border-radius:99px;font-size:11px} +.nbadge.ok{background:var(--green-soft,#E4EFE7);color:#2E6B4F} +.nbadge.bad{background:var(--red-soft);color:#93402D} .pager{display:flex;align-items:center;justify-content:center;gap:18px; margin:18px 0 2px;color:var(--muted);font-size:12.5px;flex-wrap:wrap} .pg-total b{color:var(--ink);font-weight:600} @@ -1895,6 +2038,7 @@ a:hover{text-decoration:underline} +
@@ -1956,7 +2100,7 @@ const RULE_TITLES = { bad_ua: '扫描器 UA', bot_ua: '爬虫 UA', not_found: '404 探测' }; const state = { q: '', items: [], page: 1, pageSize: 20, total: 0, pages: 1, - timer: null, inDetail: false, detailSamples: [] }; + timer: null, inDetail: false, inMonitor: false, detailSamples: [] }; const modal = $('#modal'); function openModal(title, text) { @@ -2020,8 +2164,9 @@ async function loadStats() {
${fmt(s.fused_both)}
双来源
${fmt(s.fused_behavior_only)}
行为新发现
`; $('#meta').innerHTML = `最近更新 ${localTime(s.updated_at)}
+ 监控 · JSON · - stats`; + metrics`; } catch (err) { $('#meta').textContent = '统计加载失败: ' + err.message; } @@ -2281,6 +2426,8 @@ function advice(item) { async function showDetail(ip) { state.inDetail = true; + state.inMonitor = false; + $('#monitorView').hidden = true; $('#listView').hidden = true; const view = $('#detailView'); view.hidden = false; @@ -2458,7 +2605,9 @@ async function showDetail(ip) { function showList() { state.inDetail = false; + state.inMonitor = false; document.title = '恶意 IP 清单'; + $('#monitorView').hidden = true; $('#detailView').hidden = true; $('#detailView').innerHTML = ''; $('#listView').hidden = false; @@ -2467,7 +2616,101 @@ function showList() { loadList(); } + +function monitorCard(title, value, rows, valueClass) { + const body = rows.map(row => + `
${esc(row[0])}${esc(row[1])}
` + ).join(''); + return `

${esc(title)}

+
${esc(value)}
${body}
`; +} + +function monitorHtml(m) { + const d = m.detect, b = m.behavior, p = m.push, l = m.list, bf = m.backfill; + const stateText = m.graylog === 'ok' ? '正常' : m.graylog === 'error' ? '失败' + : m.graylog === 'stale' ? '过期' : '未知'; + const stateCls = m.graylog === 'ok' ? 'mok' : m.graylog === 'error' ? 'mbad' + : 'mwarn'; + const ratio = p.success_ratio_24h == null ? '-' : + (p.success_ratio_24h * 100).toFixed(1) + '%'; + const cards = [ + monitorCard('Graylog 连接', stateText, [ + ['最近成功', d.last_success ? localTime(d.last_success) : '-'], + ['距上次成功', d.gap_seconds == null ? '-' : Math.round(d.gap_seconds) + ' 秒'], + ['最近失败', d.last_error ? d.last_error.slice(0, 24) : '无'], + ], stateCls), + monitorCard('特征检测', fmt(d.rounds) + ' 轮', [ + ['失败次数', fmt(d.errors)], + ['最近耗时', d.last_duration ? d.last_duration + ' 秒' : '-'], + ['检测间隔', '60 秒'], + ]), + monitorCard('行为分析', fmt(b.runs) + ' 次', [ + ['最近耗时', b.last_duration ? b.last_duration + ' 秒' : '-'], + ['最近违规', fmt(b.last_count)], + ['最近运行', b.last_success ? localTime(b.last_success) : '-'], + ], b.last_error ? 'mbad' : ''), + monitorCard('推送(24h)', ratio, [ + ['成功 / 失败', fmt(p.sent_24h) + ' / ' + fmt(p.failed_24h)], + ['连续失败', fmt(p.consecutive_failures)], + ['最近错误', p.last_error ? p.last_error.slice(0, 24) : '无'], + ], (p.consecutive_failures > 0) ? 'mbad' : 'mok'), + monitorCard('名单', fmt(l.fused_total) + ' 个', [ + ['今日新增', fmt(l.fused_new_24h)], + ['双来源', fmt(l.fused_both)], + ['行为新发现', fmt(l.fused_behavior_only)], + ]), + monitorCard('自动补测', fmt(bf.runs) + ' 次', [ + ['上次补测', bf.last_at ? localTime(bf.last_at) : '无'], + ['补测区间', bf.last_from ? (localTime(bf.last_from) + ' → ' + localTime(bf.last_to)) : '-'], + ['上次新增', fmt(bf.last_added)], + ], bf.last_error ? 'mbad' : ''), + ].join(''); + const rows = (m.recent_notifications || []).map(row => { + const cls = row.status === 'sent' ? 'ok' : 'bad'; + return `${localTime(row.created_at)} + ${esc(row.ip)} + ${row.status === 'sent' ? '成功' : '失败'} + ${esc(row.error || '-')}`; + }).join(''); + return `
+ ← 返回名单 +
运行监控
+ 每 30 秒刷新 +
+
${cards}
+
+ + + ${rows || ''} +
时间IP结果错误
暂无推送记录
+
`; +} + +async function showMonitor() { + state.inMonitor = true; + state.inDetail = false; + document.title = '运行监控'; + $('#listView').hidden = true; + $('#detailView').hidden = true; + $('#monitorView').hidden = false; + loadStatus(); + loadStats(); + await loadMonitor(); +} + +async function loadMonitor() { + const view = $('#monitorView'); + try { + const data = await jget(BASE + '/api/monitor'); + view.innerHTML = monitorHtml(data); + } catch (err) { + view.innerHTML = `← 返回名单 +
加载失败:${esc(err.message)}
`; + } +} + function route() { + if (location.hash === '#/monitor') { showMonitor(); return; } const match = location.hash.match(/^#\/ip\/(.+)$/); if (match) showDetail(decodeURIComponent(match[1])); else showList(); @@ -2524,7 +2767,8 @@ function startTimer() { clearInterval(state.timer); state.timer = setInterval(() => { loadStatus(); - if (!state.inDetail) { loadStats(); loadList(); } + if (state.inMonitor) { loadMonitor(); } + else if (!state.inDetail) { loadStats(); loadList(); } }, 30000); } window.addEventListener('hashchange', route); @@ -2584,6 +2828,133 @@ def make_handler(store, window, interval, detector=None, path_prefix=""): return self._send(200, {"ok": True}) if path == "/api/stats": return self._send(200, store.stats()) + if path in ("/metrics", "/api/monitor"): + stats = store.stats() + meta = store.meta_dict() + notif = store.notification_stats(24) + now_epoch = time.time() + + def stamp_epoch(value): + try: + return datetime.strptime( + value, "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=timezone.utc).timestamp() + except (TypeError, ValueError): + return None + + last_success = stamp_epoch(meta.get("detect_last_success")) + last_behavior = stamp_epoch(meta.get("behavior_last_success")) + age = round(now_epoch - last_success, 1) if last_success else None + behavior_age = (round(now_epoch - last_behavior, 1) + if last_behavior else None) + if age is None: + graylog = "unknown" + elif meta.get("detect_last_error") and age > interval * 2: + graylog = "error" + elif age <= max(120, interval * 2.5): + graylog = "ok" + else: + graylog = "stale" + + payload = { + "time": now_iso(), + "graylog": graylog, + "detect": { + "rounds": int(meta.get("detect_rounds_total") or 0), + "errors": int(meta.get("detect_errors_total") or 0), + "last_success": meta.get("detect_last_success"), + "gap_seconds": age, + "last_duration": float(meta.get("detect_last_duration") or 0), + "last_error": meta.get("detect_last_error") or "", + }, + "behavior": { + "runs": int(meta.get("behavior_runs_total") or 0), + "errors": int(meta.get("behavior_errors_total") or 0), + "last_success": meta.get("behavior_last_success"), + "age_seconds": behavior_age, + "last_duration": float(meta.get("behavior_last_duration") or 0), + "last_count": int(meta.get("behavior_last_count") or 0), + "last_error": meta.get("behavior_last_error") or "", + }, + "push": { + "sent_total": int(meta.get("push_sent_total") or 0), + "failed_total": int(meta.get("push_failed_total") or 0), + "sent_24h": notif["sent"], + "failed_24h": notif["failed"], + "success_ratio_24h": notif["success_ratio"], + "consecutive_failures": int( + meta.get("push_consecutive_failures") or 0), + "last_error": meta.get("push_last_error") or "", + "last_error_at": meta.get("push_last_error_at") or "", + }, + "list": { + "fused_total": stats.get("fused_total"), + "fused_new_24h": stats.get("fused_new_24h"), + "fused_both": stats.get("fused_both"), + "fused_behavior_only": stats.get("fused_behavior_only"), + "updated_at": stats.get("updated_at"), + }, + "backfill": { + "runs": int(meta.get("backfill_runs_total") or 0), + "last_at": meta.get("backfill_last_at") or "", + "last_from": meta.get("backfill_last_from") or "", + "last_to": meta.get("backfill_last_to") or "", + "last_added": int(meta.get("backfill_last_added") or 0), + "last_error": meta.get("backfill_last_error") or "", + }, + "recent_notifications": notif["recent"], + } + if path == "/api/monitor": + return self._send(200, payload) + + lines = [ + "# HELP ipsec_detect_rounds_total Successful detection rounds", + "# TYPE ipsec_detect_rounds_total counter", + "ipsec_detect_rounds_total %d" % payload["detect"]["rounds"], + "# TYPE ipsec_detect_errors_total counter", + "ipsec_detect_errors_total %d" % payload["detect"]["errors"], + "# TYPE ipsec_detect_last_success_timestamp gauge", + "ipsec_detect_last_success_timestamp %s" + % (last_success or 0), + "# TYPE ipsec_detect_gap_seconds gauge", + "ipsec_detect_gap_seconds %s" % (age if age is not None else -1), + "# TYPE ipsec_detect_last_duration_seconds gauge", + "ipsec_detect_last_duration_seconds %s" + % payload["detect"]["last_duration"], + "# TYPE ipsec_behavior_runs_total counter", + "ipsec_behavior_runs_total %d" % payload["behavior"]["runs"], + "# TYPE ipsec_behavior_errors_total counter", + "ipsec_behavior_errors_total %d" % payload["behavior"]["errors"], + "# TYPE ipsec_behavior_last_duration_seconds gauge", + "ipsec_behavior_last_duration_seconds %s" + % payload["behavior"]["last_duration"], + "# TYPE ipsec_behavior_last_violations gauge", + "ipsec_behavior_last_violations %d" + % payload["behavior"]["last_count"], + "# TYPE ipsec_fused_total gauge", + "ipsec_fused_total %s" % (payload["list"]["fused_total"] or 0), + "# TYPE ipsec_fused_new_24h gauge", + "ipsec_fused_new_24h %s" % (payload["list"]["fused_new_24h"] or 0), + "# TYPE ipsec_fused_both gauge", + "ipsec_fused_both %s" % (payload["list"]["fused_both"] or 0), + "# TYPE ipsec_push_sent_total counter", + "ipsec_push_sent_total %d" % payload["push"]["sent_total"], + "# TYPE ipsec_push_failed_total counter", + "ipsec_push_failed_total %d" % payload["push"]["failed_total"], + "# TYPE ipsec_push_consecutive_failures gauge", + "ipsec_push_consecutive_failures %d" + % payload["push"]["consecutive_failures"], + "# TYPE ipsec_push_success_ratio_24h gauge", + "ipsec_push_success_ratio_24h %s" + % (payload["push"]["success_ratio_24h"] + if payload["push"]["success_ratio_24h"] is not None else -1), + "# TYPE ipsec_graylog_up gauge", + "ipsec_graylog_up %d" % (1 if graylog == "ok" else 0), + "", + ] + return self._send(200, "\n".join(lines), + content_type="text/plain; version=0.0.4; charset=utf-8") + if path == "/api/status": status = {"graylog": "unknown", "last_run": None, "last_error": None, "age_seconds": None} @@ -2838,6 +3209,47 @@ def run(ctx, args): # Fused block list: existing feature detections first (historical rows keep # their original discovery time), then the behaviour thread keeps adding. store.sync_fused() + filter_str = ("streams:" + ctx.stream_id) if ctx.stream_id else None + + def auto_backfill_worker(from_dt, to_dt): + from .backfill import auto_backfill_gap + summary = auto_backfill_gap( + store, ctx.client, fields, filter_str, from_dt, to_dt, + os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + print("[自动补测] %s" % json.dumps(summary, ensure_ascii=False), + flush=True) + + auto_enabled = not getattr(args, "no_auto_backfill", False) + threshold = int(getattr(args, "backfill_threshold", 1800) or 0) + max_gap = int(getattr(args, "backfill_max_gap", 7 * 86400) or 0) + if (auto_enabled and threshold > 0 + and not getattr(args, "serve_only", False)): + from .backfill import parse_iso + last_meta = store.get_meta("detect_last_success") + if not last_meta: + row = store.conn.execute( + "SELECT MAX(updated_at) AS t FROM ip_stats").fetchone() + last_meta = row["t"] if row and row["t"] else None + last_dt = parse_iso(last_meta) if last_meta else None + if last_dt is not None: + now_dt = datetime.now(timezone.utc) + gap = (now_dt - last_dt).total_seconds() + if gap > max_gap: + print("检测缺口 %.1f 小时,超过自动补测上限 %d 小时,已跳过" + % (gap / 3600.0, max_gap // 3600), flush=True) + elif gap > threshold: + print("检测缺口 %.1f 小时,后台自动补测 %s → %s" + % (gap / 3600.0, last_dt.strftime("%m-%d %H:%M"), + now_dt.astimezone().strftime("%m-%d %H:%M")), + flush=True) + threading.Thread(target=auto_backfill_worker, + args=(last_dt, now_dt), daemon=True).start() + else: + print("检测连续(上次成功 %s),无需补测" + % last_dt.strftime("%m-%d %H:%M"), flush=True) + else: + print("没有历史检测记录,跳过自动补测", flush=True) + behavior_analyzer = None if not getattr(args, "no_behavior", False): behavior_analyzer = BehaviorAnalyzer( @@ -2915,9 +3327,15 @@ def run(ctx, args): print(" 封禁名单: %d 个 -> %s" % (len(chosen), args.export), flush=True) except GraylogError as exc: detector.last_error = str(exc) + detector.error_count += 1 + detector.store.set_meta("detect_last_error", str(exc)) + detector.store.incr_meta("detect_errors_total") print("[%s] 检测失败: %s" % (now_iso(), exc), flush=True) except Exception as exc: detector.last_error = str(exc) + detector.error_count += 1 + detector.store.set_meta("detect_last_error", str(exc)) + detector.store.incr_meta("detect_errors_total") print("[%s] 检测异常: %s" % (now_iso(), exc), flush=True) time.sleep(max(5, int(args.interval))) except KeyboardInterrupt: diff --git a/tests/test_behavior.py b/tests/test_behavior.py index 9c22adb..986b6ba 100644 --- a/tests/test_behavior.py +++ b/tests/test_behavior.py @@ -8,6 +8,7 @@ HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.dirname(HERE)) from graylog_ops import watch # noqa: E402 +from graylog_ops.behavior import notify_fused # noqa: E402 NOW = "2026-10-17T16:00:00Z" @@ -99,6 +100,54 @@ class FusedTests(unittest.TestCase): store.close() +class PushRetryTests(unittest.TestCase): + def test_failure_backoff_and_audit(self): + with tempfile.TemporaryDirectory() as directory: + store = watch.Store(os.path.join(directory, "ips.db")) + try: + store.replace_behavior_profiles([_behavior("198.51.100.8")]) + store.sync_fused() + with store.lock, store.conn: + store.conn.execute( + "UPDATE ip_fused SET last_pushed_at=NULL WHERE ip=?", + ("198.51.100.8",)) + + class Failing: + kind = "dingtalk" + def link_for(self, ip): return "" + def post(self, text, title=None): raise RuntimeError("boom") + + notify_fused(store, Failing()) + notif = store.notification_stats(24) + self.assertEqual(notif["failed"], 1) + self.assertEqual(notif["sent"], 0) + row = store.fused_row("198.51.100.8") + self.assertEqual(row["push_fail_count"], 1) + self.assertTrue(row["push_next_at"]) + self.assertEqual(store.due_pushes(24, 24), []) # backoff active + self.assertEqual(store.get_meta("push_consecutive_failures"), "1") + + with store.lock, store.conn: + store.conn.execute( + "UPDATE ip_fused SET push_next_at=NULL WHERE ip=?", + ("198.51.100.8",)) + + class Working: + kind = "dingtalk" + def link_for(self, ip): return "" + def post(self, text, title=None): return None + + notify_fused(store, Working()) + row = store.fused_row("198.51.100.8") + self.assertEqual(row["push_fail_count"], 0) + self.assertIsNone(row["push_next_at"]) + self.assertEqual(store.get_meta("push_consecutive_failures"), "0") + notif = store.notification_stats(24) + self.assertEqual(notif["sent"], 1) + finally: + store.close() + + class HistoricalBehaviourTests(unittest.TestCase): def test_historical_profile_survives_live_refresh(self): with tempfile.TemporaryDirectory() as directory: diff --git a/tests/test_watch.py b/tests/test_watch.py index 8b2092d..ad0a25e 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -341,6 +341,53 @@ class PathPrefixTests(unittest.TestCase): store.close() +class MetricsTests(unittest.TestCase): + def test_metrics_and_monitor(self): + with tempfile.TemporaryDirectory() as directory: + store = watch.Store(os.path.join(directory, "ips.db")) + now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + store.set_meta("detect_last_success", now) + store.set_meta("detect_last_error", "") + store.incr_meta("detect_rounds_total", 3) + store.incr_meta("detect_errors_total", 1) + store.set_meta("behavior_last_success", now) + store.set_meta("behavior_last_duration", 12.5) + store.set_meta("behavior_last_count", 7) + store.incr_meta("behavior_runs_total", 2) + store.incr_meta("push_sent_total", 5) + store.incr_meta("push_failed_total", 2) + store.upsert({"ip": "203.0.113.9", "tier": watch.TIER_MALICIOUS, + "score": 91.0, "total": 120, "window": 3600, + "rules": {"probe_java": 2}, + "reasons": ["探测"]}, "2026-10-17T16:00:00Z") + store.sync_fused() + server = ThreadingHTTPServer(("127.0.0.1", 0), + watch.make_handler(store, 3600, 60)) + threading.Thread(target=server.serve_forever, daemon=True).start() + base = "http://127.0.0.1:%d" % server.server_address[1] + try: + with urllib.request.urlopen(base + "/metrics", timeout=5) as response: + text = response.read().decode("utf-8") + self.assertIn("ipsec_detect_rounds_total 3", text) + self.assertIn("ipsec_detect_errors_total 1", text) + self.assertIn("ipsec_behavior_last_duration_seconds 12.5", text) + self.assertIn("ipsec_fused_total 1", text) + self.assertIn("ipsec_push_sent_total 5", text) + self.assertIn("ipsec_graylog_up 1", text) + + with urllib.request.urlopen(base + "/api/monitor", timeout=5) as response: + payload = json.loads(response.read().decode("utf-8")) + self.assertEqual(payload["graylog"], "ok") + self.assertEqual(payload["detect"]["rounds"], 3) + self.assertEqual(payload["behavior"]["last_duration"], 12.5) + self.assertEqual(payload["push"]["sent_total"], 5) + self.assertEqual(payload["list"]["fused_total"], 1) + finally: + server.shutdown() + server.server_close() + store.close() + + class PaginationTests(unittest.TestCase): def test_pagination_and_search(self): with tempfile.TemporaryDirectory() as directory: diff --git a/tools/backfill_behavior.py b/tools/backfill_behavior.py index 0b9104c..01f3080 100644 --- a/tools/backfill_behavior.py +++ b/tools/backfill_behavior.py @@ -59,14 +59,14 @@ def main(): fields = build_fields(load_profile(args.profile)) filter_str = "streams:" + args.stream - behavior.RANGE_OVERRIDE = {"from": utc_iso(from_dt), "to": utc_iso(to_dt)} + behavior.set_range(utc_iso(from_dt), utc_iso(to_dt)) store = watch.Store(args.db) analyzer = behavior.BehaviorAnalyzer(client, store, fields, filter_str, root=ROOT, notifier=None) try: items = analyzer.analyze() finally: - behavior.RANGE_OVERRIDE = None + behavior.clear_range() flagged = [item for item in items if item.get("verdict") == "建议封禁"] for item in items: