Files
Hermes Agent 4a1d949e41 feat: 完整 Squid Web Manager 平台 - P0-P3 全功能
完整 Web 管理平台,涵盖:

P0 生产加固:
- 会话超时(空闲+绝对) + CSRF 防护 + 登录失败限流
- HTTPS/TLS 证书管理(自签生成/上传/删除/过期提醒)
- SSL Bump (HTTPS 透明代理) 可视化配置
- 配置文件 diff 预览 + 二次确认才写入
- 服务控制二次确认 + 操作期间按钮锁定

P1 运维能力:
- 日志轮转管理(logrotate) + 日志状态监控
- 告警规则(5xx/命中率/磁盘/客户端流量)
- 多 Squid 实例管理 + 一键切换
- squidclient mgr 实时性能指标
- 流量异常检测(突增/大文件/高频小包/新客户端)

P2 日志分析增强:
- 日志持久化到 SQLite + 历史时间范围查询
- 导出 CSV/JSON(日志/KPI/异常/审计)
- GeoIP 地图(Leaflet + ip-api.com 离线可选 GeoLite2)
- 每客户端 24h 趋势图
- 自定义 Squid logformat 解析器

P3 体验锦上添花:
- 暗/亮主题切换(cookie 持久化)
- WebSSH 终端(xterm.js + paramiko)
- 完整审计日志 + 用户管理

技术栈: Flask 3.0 + SQLAlchemy 2.0 + SQLite + Chart.js + Leaflet
总代码量: ~16500 行 (15 Python 模块 + 34 模板 + CSS)
路由数: 73
2026-07-15 10:19:21 +08:00

553 lines
19 KiB
Python

"""P1-5: 流量异常检测 (traffic anomaly detection).
Reads ``list[LogEntry]`` from :mod:`log_parser` and flags:
1. **Sudden traffic spikes** per client
Compare a baseline window (first ``BASELINE_RATIO`` of entries) to the
current window (last ``CURRENT_RATIO`` of entries) and flag clients whose
request-per-minute rate grew more than ``SPIKE_WARN_RATIO`` (default 3x).
2. **Abnormally large responses**
Single responses larger than ``size_threshold_mb`` (default 100 MB).
3. **High-frequency, small payload clients**
Clients that emit lots of tiny responses (< ``size_threshold_kb``).
4. **First-time-seen clients**
Clients whose first request happens in the current window.
All helpers defensively swallow exceptions and return safe empty values so a
bad log line never breaks the dashboard.
"""
from __future__ import annotations
import statistics
from collections import Counter, defaultdict
from datetime import datetime
from typing import Iterable
# --- Default thresholds (overridable via kwargs) ----------------------------
#: Fraction of entries used to compute the baseline (the "past")
BASELINE_RATIO = 0.8
#: Fraction of entries used to compute the current state (the "now")
CURRENT_RATIO = 0.2
#: Spike ratio above which a client is flagged as anomaly (warning)
SPIKE_WARN_RATIO = 3.0
#: Spike ratio above which a client is flagged as critical
SPIKE_CRIT_RATIO = 10.0
#: Minimum samples required for baseline; below this the result is unreliable
MIN_SAMPLES = 5
# Worst case fallback so we don't return monstrous lists for giant logs
DEFAULT_LARGE_LIMIT = 100
DEFAULT_SMALL_LIMIT = 50
DEFAULT_NEW_LIMIT = 50
# --- Internal helpers --------------------------------------------------------
def _safe_float(value, default: float = 0.0) -> float:
"""Return ``value`` as float, or ``default`` on conversion failure."""
try:
return float(value)
except (TypeError, ValueError):
return default
def _entry_ts(entry) -> float | None:
"""Pull a numeric timestamp from a LogEntry, falling back to float(time)."""
try:
ts = entry.get("timestamp") if isinstance(entry, dict) else None
if isinstance(ts, datetime):
return ts.timestamp()
if ts is not None:
return _safe_float(ts)
return _safe_float(entry.get("time"), 0.0) if isinstance(entry, dict) else None
except Exception:
return None
def _entry_field(entry, key: str, default=""):
"""Dict-or-attribute getter that never raises."""
try:
if isinstance(entry, dict):
return entry.get(key, default)
return getattr(entry, key, default)
except Exception:
return default
def _window_split(entries: list) -> tuple[list, list]:
"""Split ``entries`` into (baseline, current).
First BASELINE_RATIO of entries go to the baseline, the remainder into the
current window. The current window is dropped entirely for tiny logs.
"""
if not entries:
return [], []
try:
n = len(entries)
cut = max(1, int(n * BASELINE_RATIO))
baseline = entries[:cut]
current = entries[cut:]
# ensure both windows have a minimum sample size to be meaningful
if len(current) < max(2, MIN_SAMPLES // 2):
return baseline, []
return baseline, current
except Exception:
return [], []
def _window_duration(window: list) -> float:
"""Compute the spanned seconds in ``window`` using timestamp field.
Returns 0.0 when timestamps are unavailable so callers can avoid div-by-zero.
"""
if not window:
return 0.0
try:
timestamps: list[float] = []
for e in window:
ts = _entry_ts(e)
if ts is not None and ts > 0:
timestamps.append(ts)
if len(timestamps) < 2:
return 0.0
span = max(timestamps) - min(timestamps)
return span if span > 0 else 0.0
except Exception:
return 0.0
def _rate_per_minute(count: int, duration_sec: float) -> float:
"""Convert ``count over duration_sec`` to requests-per-minute."""
try:
if duration_sec <= 0:
return float(count)
return count / (duration_sec / 60.0)
except Exception:
return 0.0
def _client_counts(window: Iterable) -> Counter:
"""Build a Counter {client: count} for ``window``."""
out: Counter = Counter()
if not window:
return out
try:
for e in window:
client = _entry_field(e, "client", "")
if not client:
continue
out[client] += 1
except Exception:
return out
return out
# --- Public API --------------------------------------------------------------
def detect_client_anomalies(
entries: list,
baseline_window: list | None = None,
current_window: list | None = None,
) -> list[dict]:
"""Detect clients whose recent request rate spikes vs. their own past.
If ``baseline_window`` / ``current_window`` are omitted we split the given
``entries`` using :data:`BASELINE_RATIO` / :data:`CURRENT_RATIO`.
Returns a list of dicts sorted by severity (critical first), then by ratio::
{
"client": "172.16.x.y",
"baseline_rpm": 12.3, # requests/min in baseline window
"current_rpm": 180.0, # requests/min in current window
"baseline_count": 50,
"current_count": 200,
"ratio": 14.6, # current / baseline
"is_anomaly": True,
"severity": "critical" | "warning" | None,
"baseline_window_sec": 244.0,
"current_window_sec": 66.5,
}
"""
try:
if baseline_window is None or current_window is None:
baseline_window, current_window = _window_split(entries)
if not baseline_window or not current_window:
return []
base_counts = _client_counts(baseline_window)
cur_counts = _client_counts(current_window)
if not base_counts or not cur_counts:
return []
base_dur = _window_duration(baseline_window)
cur_dur = _window_duration(current_window)
anomalies: list[dict] = []
# only consider clients that appear in BOTH windows for ratio math
for client, cur_n in cur_counts.items():
try:
base_n = base_counts.get(client, 0)
if base_n < MIN_SAMPLES:
# New client; show as anomaly but skip ratio math
anomalies.append({
"client": client,
"baseline_rpm": 0.0,
"current_rpm": _rate_per_minute(cur_n, cur_dur),
"baseline_count": 0,
"current_count": cur_n,
"ratio": float("inf"),
"is_anomaly": True,
"severity": "warning",
"baseline_window_sec": round(base_dur, 2),
"current_window_sec": round(cur_dur, 2),
})
continue
base_rpm = _rate_per_minute(base_n, base_dur)
cur_rpm = _rate_per_minute(cur_n, cur_dur)
# avoid div-by-zero: if base_rpm is zero, treat as infinite ratio
if base_rpm <= 0:
ratio = float("inf") if cur_rpm > 0 else 0.0
else:
ratio = cur_rpm / base_rpm
if ratio > SPIKE_WARN_RATIO:
severity = "critical" if ratio > SPIKE_CRIT_RATIO else "warning"
anomalies.append({
"client": client,
"baseline_rpm": round(base_rpm, 2),
"current_rpm": round(cur_rpm, 2),
"baseline_count": base_n,
"current_count": cur_n,
"ratio": round(ratio, 2) if ratio != float("inf") else ratio,
"is_anomaly": True,
"severity": severity,
"baseline_window_sec": round(base_dur, 2),
"current_window_sec": round(cur_dur, 2),
})
except Exception:
# single client failure doesn't kill the whole result
continue
# sort: critical first, then highest ratio
def _sort_key(a: dict):
sev_rank = 0 if a.get("severity") == "critical" else 1
ratio = a.get("ratio", 0)
ratio_key = -ratio if isinstance(ratio, (int, float)) and ratio != float("inf") else -1e18
return (sev_rank, ratio_key)
try:
anomalies.sort(key=_sort_key)
except Exception:
pass
return anomalies
except Exception:
return []
def detect_large_requests(
entries: list,
size_threshold_mb: float = 100,
limit: int = DEFAULT_LARGE_LIMIT,
) -> list[dict]:
"""Find individual responses larger than ``size_threshold_mb`` MB.
Returned dicts are sorted by size descending and capped at ``limit``::
{
"time": "2024-01-01 12:34:56",
"timestamp": datetime(...),
"client": "172.16.x.y",
"url": "https://...",
"host": "example.com",
"method": "GET",
"result": "TCP_MISS/200",
"size_bytes": 157286400,
"size_mb": 150.0,
}
"""
try:
if not entries:
return []
threshold_bytes = float(size_threshold_mb) * 1024.0 * 1024.0
out: list[dict] = []
for e in entries:
try:
size = _safe_float(_entry_field(e, "size", 0), 0.0)
if size <= threshold_bytes:
continue
ts = _entry_ts(e)
dt = None
try:
dt = _entry_field(e, "timestamp", None)
if not isinstance(dt, datetime):
dt = datetime.fromtimestamp(ts) if ts else None
except Exception:
dt = None
out.append({
"time": dt.strftime("%Y-%m-%d %H:%M:%S") if dt else "",
"timestamp": dt.isoformat() if dt else None,
"client": _entry_field(e, "client", ""),
"url": _entry_field(e, "url", ""),
"host": _entry_field(e, "host", ""),
"method": _entry_field(e, "method", ""),
"result": _entry_field(e, "result", ""),
"result_code": _entry_field(e, "result_code", ""),
"http_code": _entry_field(e, "http_code", ""),
"size_bytes": int(size),
"size_mb": round(size / (1024.0 * 1024.0), 2),
})
except Exception:
continue
try:
out.sort(key=lambda x: x.get("size_bytes", 0), reverse=True)
except Exception:
pass
return out[: max(1, int(limit))]
except Exception:
return []
def detect_high_freq_small(
entries: list,
size_threshold_kb: float = 1,
min_requests: int = 100,
limit: int = DEFAULT_SMALL_LIMIT,
) -> list[dict]:
"""Find clients that emit lots of tiny responses (< ``size_threshold_kb``).
Tiny-response spam is often a fingerprint of polling bots, beaconing
implants, or chatty telemetry agents.
Returns dicts sorted by request count desc::
{
"client": "172.16.x.y",
"request_count": 540,
"total_bytes": 420000,
"avg_size": 778,
"sample_url": "https://...",
"sample_host": "example.com",
}
"""
try:
if not entries:
return []
threshold_bytes = float(size_threshold_kb) * 1024.0
counts: Counter = Counter()
total_bytes: dict[str, int] = defaultdict(int)
sample_url: dict[str, str] = {}
sample_host: dict[str, str] = {}
for e in entries:
try:
size = _safe_float(_entry_field(e, "size", 0), 0.0)
if size >= threshold_bytes:
continue
client = _entry_field(e, "client", "")
if not client:
continue
counts[client] += 1
total_bytes[client] += int(size)
if client not in sample_url:
sample_url[client] = _entry_field(e, "url", "")
sample_host[client] = _entry_field(e, "host", "")
except Exception:
continue
out: list[dict] = []
for client, n in counts.most_common():
if n < max(1, int(min_requests)):
continue
tb = total_bytes.get(client, 0)
avg = tb / n if n else 0
out.append({
"client": client,
"request_count": n,
"total_bytes": tb,
"avg_size": round(avg, 1),
"sample_url": sample_url.get(client, ""),
"sample_host": sample_host.get(client, ""),
})
try:
out.sort(key=lambda x: x.get("request_count", 0), reverse=True)
except Exception:
pass
return out[: max(1, int(limit))]
except Exception:
return []
def detect_new_clients(
entries: list,
known_clients: set[str] | list[str] | None = None,
limit: int = DEFAULT_NEW_LIMIT,
) -> list[dict]:
"""Find clients that appear for the first time within ``entries``.
"First time" means: their first LogEntry timestamp is among the entries
we are inspecting AND they aren't in ``known_clients`` (when supplied).
Returns::
{
"client": "172.16.x.y",
"first_seen": "2024-...",
"first_ts": float,
"request_count": int,
"sample_url": "...",
}
"""
try:
if not entries:
return []
if known_clients is None:
known = set()
elif isinstance(known_clients, set):
known = known_clients
else:
known = set(known_clients or [])
# find earliest timestamp per client
first_ts: dict[str, float] = {}
first_dt: dict[str, datetime] = {}
counts: Counter = Counter()
sample_url: dict[str, str] = {}
for e in entries:
try:
client = _entry_field(e, "client", "")
if not client:
continue
ts = _entry_ts(e) or 0.0
dt = _entry_field(e, "timestamp", None)
if ts and (client not in first_ts or ts < first_ts[client]):
first_ts[client] = ts
if isinstance(dt, datetime) and (client not in first_dt or dt < first_dt[client]):
first_dt[client] = dt
counts[client] += 1
if client not in sample_url:
sample_url[client] = _entry_field(e, "url", "")
except Exception:
continue
# min timestamp across all entries = baseline cutoff
all_ts = [t for t in first_ts.values() if t > 0]
if not all_ts:
return []
global_min_ts = min(all_ts)
out: list[dict] = []
for client, ts in first_ts.items():
try:
# first-seen in entries exactly equal to global min means
# they've never been seen before — anything <= min qualifies
# as a "new" arrival within this log slice
if ts > global_min_ts:
continue
if client in known:
continue
dt = first_dt.get(client)
out.append({
"client": client,
"first_seen": dt.strftime("%Y-%m-%d %H:%M:%S") if dt else "",
"first_ts": ts,
"request_count": counts.get(client, 1),
"sample_url": sample_url.get(client, ""),
})
except Exception:
continue
try:
out.sort(key=lambda x: x.get("first_ts", 0))
except Exception:
pass
return out[: max(1, int(limit))]
except Exception:
return []
def get_anomaly_summary(entries: list) -> dict:
"""Run all four detectors and bundle the results.
Layout::
{
"anomalous_clients": [...], # detect_client_anomalies
"large_requests": [...], # detect_large_requests
"high_freq_small": [...], # detect_high_freq_small
"new_clients": [...], # detect_new_clients
"total_anomalies": int,
"thresholds": {...},
}
"""
try:
anomalous = detect_client_anomalies(entries)
except Exception:
anomalous = []
try:
large = detect_large_requests(entries)
except Exception:
large = []
try:
small = detect_high_freq_small(entries)
except Exception:
small = []
try:
new = detect_new_clients(entries)
except Exception:
new = []
total = len(anomalous) + len(large) + len(small) + len(new)
# quick stats over the (current) window for the page header
header_stats: dict = {}
try:
if entries:
n = len(entries)
cut = max(1, int(n * CURRENT_RATIO))
current = entries[cut:]
header_stats = {
"current_window_count": len(current),
"current_window_clients": len({_entry_field(e, "client", "") for e in current}),
}
except Exception:
pass
return {
"anomalous_clients": anomalous,
"large_requests": large,
"high_freq_small": small,
"new_clients": new,
"total_anomalies": total,
"thresholds": {
"spike_warn_ratio": SPIKE_WARN_RATIO,
"spike_crit_ratio": SPIKE_CRIT_RATIO,
"large_size_mb": 100,
"small_size_kb": 1,
"min_small_requests": 100,
},
"header_stats": header_stats,
}
# --- Tiny self-test when run directly --------------------------------------
if __name__ == "__main__": # pragma: no cover
import json
import log_parser
sample_path = "/root/squid-manager/sample_access.log"
try:
with open(sample_path, encoding="utf-8") as f:
text = f.read()
entries = log_parser.parse_lines(text)
except OSError:
entries = []
summary = get_anomaly_summary(entries)
print(json.dumps(summary, default=str, ensure_ascii=False, indent=2)[:2000])