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
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
"""alerts.py - Alert rule engine for Squid Web Manager (P1-2).
|
||||
|
||||
Provides:
|
||||
- AlertRule dataclass - configurable metric threshold rule
|
||||
- evaluate_rules(entries, rules, now=None) - run all enabled rules against
|
||||
the current access.log stats and return a list of triggered alerts.
|
||||
- save_rules / load_rules - persist rule list as JSON
|
||||
- default_rules() - opinionated starting set that covers the common SRE cases
|
||||
|
||||
All metric computation uses only Python stdlib + existing log_parser statistics
|
||||
- no new dependencies. Audit writes use a lazy import of `app` so this module
|
||||
can be imported without forcing a circular dependency on app.py at startup.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any, Iterable
|
||||
|
||||
# --- Supported metrics ----------------------------------------------------
|
||||
# Each entry documents the operator semantics. Values are floats unless noted.
|
||||
METRICS: dict[str, str] = {
|
||||
"5xx_rate": "HTTP 5xx 错误占比 (0.0 - 1.0)",
|
||||
"4xx_rate": "HTTP 4xx 错误占比 (0.0 - 1.0)",
|
||||
"hit_ratio": "缓存命中率 (0.0 - 1.0)",
|
||||
"denied_rate": "ACL 拒绝请求占比 (0.0 - 1.0)",
|
||||
"disk_usage_pct": "缓存目录磁盘使用率 (0.0 - 100.0)",
|
||||
"client_bytes_per_min": "Top 客户端每分钟流量 (字节/分钟)",
|
||||
}
|
||||
|
||||
OPERATORS: dict[str, str] = {
|
||||
"gt": ">",
|
||||
"lt": "<",
|
||||
"gte": ">=",
|
||||
"lte": "<=",
|
||||
}
|
||||
|
||||
SEVERITIES = ("info", "warning", "critical")
|
||||
|
||||
|
||||
# --- Rule dataclass -------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class AlertRule:
|
||||
"""One alert rule. Persisted as JSON via save_rules / load_rules."""
|
||||
|
||||
name: str
|
||||
metric: str
|
||||
operator: str
|
||||
threshold: float
|
||||
window_minutes: int = 5
|
||||
enabled: bool = True
|
||||
cooldown_minutes: int = 30
|
||||
last_triggered: float = 0.0
|
||||
severity: str = "warning"
|
||||
notify_webhook: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = asdict(self)
|
||||
# JSON doesn't love bare floats like 0.0 - keep it simple.
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "AlertRule":
|
||||
"""Build from a dict (e.g. one loaded from JSON). Missing keys fall
|
||||
back to dataclass defaults so old config files keep working."""
|
||||
if not isinstance(d, dict):
|
||||
raise ValueError("AlertRule.from_dict expects a dict")
|
||||
# Only forward the keys we know about; ignore extras.
|
||||
allowed = {f for f in cls.__dataclass_fields__.keys()} # type: ignore[attr-defined]
|
||||
kwargs = {k: d[k] for k in d.keys() & allowed}
|
||||
# Coerce numerics - if the file is hand-edited we don't want to crash.
|
||||
for key in ("threshold", "last_triggered"):
|
||||
if key in kwargs:
|
||||
try:
|
||||
kwargs[key] = float(kwargs[key])
|
||||
except (TypeError, ValueError):
|
||||
kwargs[key] = 0.0
|
||||
for key in ("window_minutes", "cooldown_minutes"):
|
||||
if key in kwargs:
|
||||
try:
|
||||
kwargs[key] = int(kwargs[key])
|
||||
except (TypeError, ValueError):
|
||||
kwargs[key] = 5 if key == "window_minutes" else 30
|
||||
kwargs["enabled"] = bool(kwargs.get("enabled", True))
|
||||
# Severity / operator / metric - fall back if hand-edited to junk.
|
||||
sev = kwargs.get("severity", "warning")
|
||||
if sev not in SEVERITIES:
|
||||
sev = "warning"
|
||||
kwargs["severity"] = sev
|
||||
op = kwargs.get("operator", "gt")
|
||||
if op not in OPERATORS:
|
||||
op = "gt"
|
||||
kwargs["operator"] = op
|
||||
return cls(**kwargs)
|
||||
|
||||
|
||||
# --- Metric evaluation ---------------------------------------------------
|
||||
|
||||
def _apply_operator(value: float, op: str, threshold: float) -> bool:
|
||||
if op == "gt":
|
||||
return value > threshold
|
||||
if op == "lt":
|
||||
return value < threshold
|
||||
if op == "gte":
|
||||
return value >= threshold
|
||||
if op == "lte":
|
||||
return value <= threshold
|
||||
return False
|
||||
|
||||
|
||||
def _compute_metric(
|
||||
metric: str,
|
||||
stats: dict,
|
||||
cache_dir: str | None,
|
||||
window_minutes: int,
|
||||
) -> float:
|
||||
"""Compute the current value of a metric from pre-aggregated stats.
|
||||
|
||||
For most metrics this is just `stats[metric]`. For client_bytes_per_min we
|
||||
fold in the time window (which is configurable on the rule, so we can't
|
||||
pre-bake it into log_parser). disk_usage_pct needs shutil.disk_usage.
|
||||
"""
|
||||
if metric == "5xx_rate":
|
||||
return float(stats.get("errors_5xx_rate") or 0.0)
|
||||
if metric == "4xx_rate":
|
||||
return float(stats.get("errors_4xx_rate") or 0.0)
|
||||
if metric == "hit_ratio":
|
||||
return float(stats.get("hit_ratio") or 0.0)
|
||||
if metric == "denied_rate":
|
||||
return float(stats.get("denied_rate") or 0.0)
|
||||
if metric == "disk_usage_pct":
|
||||
target = cache_dir or "/var/spool/squid"
|
||||
try:
|
||||
total, used, free = shutil.disk_usage(target)
|
||||
except (FileNotFoundError, PermissionError, OSError):
|
||||
return 0.0
|
||||
if not total:
|
||||
return 0.0
|
||||
return round(used * 100.0 / total, 2)
|
||||
if metric == "client_bytes_per_min":
|
||||
tops = stats.get("top_clients") or []
|
||||
if not tops:
|
||||
return 0.0
|
||||
max_bytes = max((c.get("bytes") or 0) for c in tops)
|
||||
# Approximate bytes-per-minute for the top client over the rule window.
|
||||
# If the time span is < window_minutes we still divide by window so
|
||||
# the operator gets a comparable value (long window = low rate).
|
||||
w = max(1, int(window_minutes))
|
||||
return float(max_bytes) * 60.0 / w
|
||||
return 0.0
|
||||
|
||||
|
||||
def _humanise_metric(metric: str, value: float) -> str:
|
||||
if metric.endswith("_rate"):
|
||||
return f"{value * 100:.2f}%"
|
||||
if metric == "hit_ratio":
|
||||
return f"{value * 100:.1f}%"
|
||||
if metric == "disk_usage_pct":
|
||||
return f"{value:.1f}%"
|
||||
if metric == "client_bytes_per_min":
|
||||
# Display in KB/MB/GB - keep parity with log_parser.format_bytes shape.
|
||||
n = float(value)
|
||||
for unit in ("B/s", "KB/s", "MB/s", "GB/s"):
|
||||
if abs(n) < 1024.0:
|
||||
if unit == "B/s":
|
||||
return f"{n:.0f} {unit}"
|
||||
return f"{n:.1f} {unit}"
|
||||
n /= 1024.0
|
||||
return f"{n:.2f} TB/s"
|
||||
return f"{value:.4f}"
|
||||
|
||||
|
||||
def _format_message(rule: AlertRule, value: float, window_minutes: int) -> str:
|
||||
op = OPERATORS.get(rule.operator, rule.operator)
|
||||
human = _humanise_metric(rule.metric, value)
|
||||
metric_desc = METRICS.get(rule.metric, rule.metric)
|
||||
return (
|
||||
f"{rule.name}: {metric_desc} 当前值 {human} {op} 阈值 "
|
||||
f"{_humanise_metric(rule.metric, rule.threshold)} "
|
||||
f"(窗口 {window_minutes}m, 严重度 {rule.severity})"
|
||||
)
|
||||
|
||||
|
||||
# --- Audit (lazy import to avoid circular reference) ---------------------
|
||||
|
||||
def _audit_trigger(rule: AlertRule, value: float, message: str):
|
||||
"""Try to write a row into the audit_log. Silently swallow on failure
|
||||
so a missing app context never breaks evaluation."""
|
||||
try:
|
||||
from app import audit, db # type: ignore
|
||||
audit(
|
||||
"alert_triggered",
|
||||
f"name={rule.name} metric={rule.metric} "
|
||||
f"value={value:.6f} threshold={rule.threshold} "
|
||||
f"severity={rule.severity} msg={message[:300]}",
|
||||
)
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
# Never let an audit failure mask a real alert.
|
||||
try:
|
||||
from app import db # type: ignore
|
||||
db.session.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _post_webhook(rule: AlertRule, payload: dict):
|
||||
"""Fire-and-forget webhook POST. We import urllib lazily; if the URL is
|
||||
bad or the import fails we just log silently - alerts mustn't crash
|
||||
the calling request."""
|
||||
url = (rule.notify_webhook or "").strip()
|
||||
if not url:
|
||||
return
|
||||
try:
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=5).read()
|
||||
except Exception:
|
||||
# We deliberately don't surface webhook errors - delivery is
|
||||
# best-effort and the audit row is the source of truth.
|
||||
pass
|
||||
|
||||
|
||||
# --- Main entry point ----------------------------------------------------
|
||||
|
||||
def evaluate_rules(
|
||||
entries: list | None,
|
||||
rules: Iterable[AlertRule],
|
||||
*,
|
||||
cache_dir: str | None = None,
|
||||
now: float | None = None,
|
||||
) -> list[dict]:
|
||||
"""Evaluate all enabled rules against the current access.log parsing.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entries : list of log_parser.LogEntry (may be empty / None).
|
||||
rules : iterable of AlertRule to check.
|
||||
cache_dir : path to the Squid cache dir. Used for disk_usage_pct. If
|
||||
None, defaults to /var/spool/squid.
|
||||
now : epoch seconds to treat as "now". Defaults to time.time().
|
||||
|
||||
Returns
|
||||
-------
|
||||
list of dict:
|
||||
{rule, value, severity, message, ts, triggered}
|
||||
Each dict contains:
|
||||
- rule : the AlertRule instance that matched
|
||||
- value : the float value that triggered the rule
|
||||
- severity : copy of rule.severity
|
||||
- message : human-readable summary
|
||||
- ts : epoch seconds when this was evaluated
|
||||
- triggered : bool - True if outside cooldown, False if suppressed
|
||||
The list always contains one entry per *evaluated* rule; rows with
|
||||
triggered=False are useful for the UI to show "currently OK / cooldown".
|
||||
"""
|
||||
ts = float(now if now is not None else time.time())
|
||||
stats = _safe_stats(entries)
|
||||
out: list[dict] = []
|
||||
rules_list = list(rules)
|
||||
for rule in rules_list:
|
||||
if not getattr(rule, "enabled", True):
|
||||
continue
|
||||
metric = getattr(rule, "metric", "")
|
||||
op = getattr(rule, "operator", "gt")
|
||||
threshold = float(getattr(rule, "threshold", 0.0) or 0.0)
|
||||
window = max(1, int(getattr(rule, "window_minutes", 5) or 5))
|
||||
try:
|
||||
value = _compute_metric(metric, stats, cache_dir, window)
|
||||
except Exception:
|
||||
value = 0.0
|
||||
triggered = _apply_operator(value, op, threshold)
|
||||
in_cooldown = False
|
||||
if triggered:
|
||||
last = float(getattr(rule, "last_triggered", 0.0) or 0.0)
|
||||
cooldown = max(0, int(getattr(rule, "cooldown_minutes", 30) or 30))
|
||||
if last and (ts - last) < cooldown * 60:
|
||||
# Suppress but still report so the UI can show "cooling down".
|
||||
triggered = False
|
||||
in_cooldown = True
|
||||
else:
|
||||
rule.last_triggered = ts
|
||||
msg = _format_message(rule, value, window)
|
||||
if triggered:
|
||||
_audit_trigger(rule, value, msg)
|
||||
_post_webhook(
|
||||
rule,
|
||||
{
|
||||
"name": rule.name,
|
||||
"metric": rule.metric,
|
||||
"value": value,
|
||||
"threshold": threshold,
|
||||
"operator": op,
|
||||
"severity": rule.severity,
|
||||
"message": msg,
|
||||
"ts": ts,
|
||||
},
|
||||
)
|
||||
out.append({
|
||||
"rule": rule,
|
||||
"value": value,
|
||||
"threshold": threshold,
|
||||
"operator": op,
|
||||
"severity": rule.severity,
|
||||
"message": msg,
|
||||
"ts": ts,
|
||||
"triggered": triggered,
|
||||
"in_cooldown": in_cooldown,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _safe_stats(entries: list | None) -> dict:
|
||||
"""Run log_parser.aggregate_stats if available, otherwise return {}.
|
||||
Import log_parser lazily so unit tests / minimal contexts don't crash."""
|
||||
if not entries:
|
||||
return {}
|
||||
try:
|
||||
import log_parser # type: ignore
|
||||
return log_parser.aggregate_stats(entries) or {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
# --- Persistence --------------------------------------------------------
|
||||
|
||||
def save_rules(rules: list[AlertRule], path: str) -> None:
|
||||
"""Write the rule list to `path` as pretty-printed JSON."""
|
||||
payload = [r.to_dict() for r in (rules or [])]
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def load_rules(path: str) -> list[AlertRule]:
|
||||
"""Load rules from a JSON file. Missing / corrupt files yield the
|
||||
`default_rules()` set; that way the UI always has something to show."""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except FileNotFoundError:
|
||||
return default_rules()
|
||||
except (json.JSONDecodeError, OSError, ValueError):
|
||||
return default_rules()
|
||||
if not isinstance(data, list):
|
||||
return default_rules()
|
||||
out: list[AlertRule] = []
|
||||
for item in data:
|
||||
try:
|
||||
out.append(AlertRule.from_dict(item))
|
||||
except Exception:
|
||||
# Skip individual bad rows instead of nuking the whole file.
|
||||
continue
|
||||
return out or default_rules()
|
||||
|
||||
|
||||
def default_rules() -> list[AlertRule]:
|
||||
"""A balanced starter set: catches the common SRE cases without being
|
||||
noisy. Operators can tune thresholds or disable rules from the UI."""
|
||||
return [
|
||||
AlertRule(
|
||||
name="5xx 错误率突增",
|
||||
metric="5xx_rate",
|
||||
operator="gt",
|
||||
threshold=0.05, # > 5% 5xx
|
||||
window_minutes=5,
|
||||
cooldown_minutes=30,
|
||||
severity="critical",
|
||||
),
|
||||
AlertRule(
|
||||
name="缓存命中率下跌",
|
||||
metric="hit_ratio",
|
||||
operator="lt",
|
||||
threshold=0.20, # < 20%
|
||||
window_minutes=5,
|
||||
cooldown_minutes=60,
|
||||
severity="warning",
|
||||
),
|
||||
AlertRule(
|
||||
name="磁盘空间不足",
|
||||
metric="disk_usage_pct",
|
||||
operator="gte",
|
||||
threshold=90.0, # >= 90%
|
||||
window_minutes=5,
|
||||
cooldown_minutes=60,
|
||||
severity="critical",
|
||||
),
|
||||
AlertRule(
|
||||
name="客户端流量异常",
|
||||
metric="client_bytes_per_min",
|
||||
operator="gt",
|
||||
threshold=200 * 1024 * 1024, # > 200 MB/min
|
||||
window_minutes=5,
|
||||
cooldown_minutes=15,
|
||||
severity="warning",
|
||||
),
|
||||
AlertRule(
|
||||
name="拒绝率过高",
|
||||
metric="denied_rate",
|
||||
operator="gt",
|
||||
threshold=0.50, # > 50%
|
||||
window_minutes=5,
|
||||
cooldown_minutes=30,
|
||||
severity="warning",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# --- CLI smoke test ------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - manual sanity check
|
||||
import log_parser as _lp
|
||||
sample = os.path.join(os.path.dirname(__file__), "sample_access.log")
|
||||
text = open(sample, "r", encoding="utf-8").read() if os.path.exists(sample) else ""
|
||||
entries = _lp.parse_lines(text) if text else []
|
||||
triggered = evaluate_rules(entries, default_rules())
|
||||
print(f"triggered: {len(triggered)}")
|
||||
for t in triggered:
|
||||
marker = "*" if t["triggered"] else " "
|
||||
print(f" {marker} {t['rule'].name}: value={t['value']:.4f} threshold={t['threshold']} ({t['message']})")
|
||||
Reference in New Issue
Block a user