4a1d949e41
完整 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
515 lines
18 KiB
Python
515 lines
18 KiB
Python
"""squid_mgr.py — interface to Squid's built-in `squidclient mgr:` admin endpoints.
|
|
|
|
The Squid HTTP proxy exposes a management interface on the cache_object port
|
|
(default 3128). `squidclient mgr:<section>` returns plain-text key/value
|
|
information for sections like `info`, `5min`, `counters`, `io`, `storedir`, etc.
|
|
|
|
This module is intentionally dependency-free (stdlib only) so it can be reused
|
|
in the Flask app, the alert evaluator, and unit tests without installing extra
|
|
packages.
|
|
|
|
Public surface
|
|
--------------
|
|
fetch_mgr_info(...) -> (ok, raw_text, error_msg)
|
|
parse_mgr_info(text) -> dict[str, str]
|
|
fetch_and_parse(...) -> (ok, parsed_dict, error_msg)
|
|
parse_counters(text) -> list[(label, value)]
|
|
get_performance_summary(...) -> dict (the high-level payload the UI consumes)
|
|
|
|
All parsers are forgiving: malformed lines are skipped silently, never raised.
|
|
This matters because Squid output varies between versions and locales.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import subprocess
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fetching
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def fetch_mgr_info(squidclient: str = "squidclient",
|
|
host: str = "127.0.0.1",
|
|
port: int = 3128,
|
|
mgr: str = "info",
|
|
password: str = "",
|
|
timeout: int = 5) -> tuple[bool, str, str]:
|
|
"""Run `squidclient -h host -p port mgr:<section>` and capture stdout.
|
|
|
|
Returns (ok, raw_text, error_msg).
|
|
- ok is True iff the process exited with status 0 AND produced output.
|
|
- error_msg is the empty string on success; otherwise a short diagnostic.
|
|
- On timeout we surface a clear message so the caller can degrade gracefully.
|
|
"""
|
|
cmd = [squidclient, "-h", str(host), "-p", str(port), f"mgr:{mgr}"]
|
|
if password:
|
|
cmd[2:2] = ["-w", password] # insert -w <pw> right after the binary
|
|
try:
|
|
proc = subprocess.run(
|
|
cmd,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
)
|
|
except FileNotFoundError as exc:
|
|
return False, "", f"squidclient not found: {exc}"
|
|
except subprocess.TimeoutExpired:
|
|
return False, "", f"squidclient mgr:{mgr} timeout after {timeout}s"
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
return False, "", f"squidclient mgr:{mgr} failed: {exc}"
|
|
|
|
if proc.returncode != 0:
|
|
err = (proc.stderr or proc.stdout or "").strip()
|
|
return False, proc.stdout or "", f"exit={proc.returncode} {err[:300]}"
|
|
if not proc.stdout.strip():
|
|
return False, "", f"squidclient mgr:{mgr} returned empty output"
|
|
return True, proc.stdout, ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Parsing helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Section headers look like "Connection information:" (a key ending with a
|
|
# trailing colon). Within a section, indented lines hold `Key: value` pairs.
|
|
# `mgr:5min`/`mgr:counters` outputs are flat `key = value` (no indentation),
|
|
# so we allow either: 2+ spaces of indent with ANY separator, OR no indent
|
|
# but an '=' separator (so we don't accidentally grab plain English text).
|
|
_SECTION_RE = re.compile(r"^\s*([A-Z][A-Za-z0-9 /%()\-]+):\s*$")
|
|
_INDENTED_KV_RE = re.compile(r"^\s{2,}([A-Za-z0-9 _/%.()\-]+?)\s*[:=]\s*(.+?)\s*$")
|
|
_FLAT_KV_RE = re.compile(r"^([A-Za-z0-9_.]+)\s*=\s*(.+?)\s*$")
|
|
|
|
|
|
def _strip_inline_section(line: str) -> tuple[str | None, str | None]:
|
|
"""First-line special case: "Squid Object Cache (Version 5.7): John Doe"
|
|
which mixes a header and a value on one line. Returns ("Squid Object Cache",
|
|
"(Version 5.7): John Doe") so the normal KV regex can pick it up.
|
|
"""
|
|
return None, None # handled by caller via simpler regex
|
|
|
|
|
|
def parse_mgr_info(text: str) -> dict:
|
|
"""Parse a `squidclient mgr:info` text blob into a flat dict.
|
|
|
|
The shape of mgr:info output (see task example):
|
|
Squid Object Cache (Version 5.7): John Doe
|
|
Start Time: Mon, 14 Jul 2026 12:00:00 GMT
|
|
Current Time: Mon, 14 Jul 2026 13:00:00 GMT
|
|
Connection information:
|
|
Number of clients accessing cache: 5
|
|
Cache information:
|
|
Hits as % of bytes sent: 35.2%
|
|
...
|
|
|
|
We collect every `Key: value` line as `Key -> value`. Section headers are
|
|
preserved as `Section:` entries so callers can correlate them if needed.
|
|
Values are kept as strings; numeric coercion is the job of the summary
|
|
builder.
|
|
"""
|
|
out: dict[str, str] = {}
|
|
if not text:
|
|
return out
|
|
|
|
for raw in text.splitlines():
|
|
line = raw.rstrip()
|
|
if not line.strip():
|
|
continue
|
|
|
|
# Section header: ends with ":", no value on the same line
|
|
m_sec = _SECTION_RE.match(line)
|
|
if m_sec:
|
|
out[m_sec.group(1).strip()] = "" # marker, no value
|
|
continue
|
|
|
|
# Indented key/value line (2+ spaces of indent, `:` or `=` separator).
|
|
# Used by mgr:info.
|
|
m_kv = _INDENTED_KV_RE.match(line)
|
|
if m_kv:
|
|
key = m_kv.group(1).strip()
|
|
val = m_kv.group(2).strip()
|
|
out[key] = val
|
|
continue
|
|
|
|
# Flat key/value line (no indent, '=' separator, snake_case key).
|
|
# Used by mgr:5min / mgr:counters.
|
|
m_flat = _FLAT_KV_RE.match(line)
|
|
if m_flat:
|
|
key = m_flat.group(1).strip()
|
|
val = m_flat.group(2).strip()
|
|
out[key] = val
|
|
continue
|
|
|
|
# Fallback: "Squid Object Cache (Version 5.7): John Doe" — header +
|
|
# value on the same line. Try a relaxed split on ":".
|
|
if ":" in line:
|
|
k, v = line.split(":", 1)
|
|
k = k.strip()
|
|
if k and v.strip():
|
|
out.setdefault(k, v.strip())
|
|
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Field extractors (named accessors used by get_performance_summary)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_TIME_FIELD_RE = re.compile(
|
|
r"(?:(?P<days>\d+)\s+days?,\s*)?(?P<hours>\d+):(?P<minutes>\d+):(?P<seconds>\d+(?:\.\d+)?)"
|
|
)
|
|
|
|
|
|
def parse_uptime_to_seconds(text: str) -> int | None:
|
|
"""Convert Squid uptime strings like "3 days, 12:34:56" or "0:05:30" to seconds."""
|
|
if not text:
|
|
return None
|
|
m = _TIME_FIELD_RE.search(text)
|
|
if not m:
|
|
return None
|
|
days = int(m.group("days") or 0)
|
|
hours = int(m.group("hours") or 0)
|
|
minutes = int(m.group("minutes") or 0)
|
|
seconds = float(m.group("seconds") or 0)
|
|
return int(days * 86400 + hours * 3600 + minutes * 60 + seconds)
|
|
|
|
|
|
_BYTE_UNITS = {
|
|
"B": 1,
|
|
"KB": 1024,
|
|
"MB": 1024 ** 2,
|
|
"GB": 1024 ** 3,
|
|
"TB": 1024 ** 4,
|
|
"K": 1024,
|
|
"M": 1024 ** 2,
|
|
"G": 1024 ** 3,
|
|
}
|
|
|
|
_BYTES_RE = re.compile(
|
|
r"^\s*(?P<num>[0-9]+(?:\.[0-9]+)?)\s*(?P<unit>[A-Za-z]+)?\s*$"
|
|
)
|
|
|
|
|
|
def parse_bytes_to_int(text: str) -> int | None:
|
|
"""Convert strings like "1.2 GB" or "256 MB" or "12345" to an int byte count."""
|
|
if text is None:
|
|
return None
|
|
s = text.strip()
|
|
if not s:
|
|
return None
|
|
m = _BYTES_RE.match(s)
|
|
if not m:
|
|
# Try to pull a number out of mixed strings ("1,234,567")
|
|
digits = re.sub(r"[^0-9.]", "", s)
|
|
try:
|
|
return int(float(digits)) if digits else None
|
|
except ValueError:
|
|
return None
|
|
num = float(m.group("num"))
|
|
unit = (m.group("unit") or "B").upper()
|
|
factor = _BYTE_UNITS.get(unit)
|
|
if factor is None:
|
|
# Unknown unit — treat as raw count
|
|
return int(num)
|
|
return int(num * factor)
|
|
|
|
|
|
def parse_percent(text: str) -> float | None:
|
|
"""Convert strings like "35.2%" or "35.2" to a float percentage value."""
|
|
if not text:
|
|
return None
|
|
s = text.strip().rstrip("%").strip()
|
|
try:
|
|
return float(s)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
_VERSION_RE = re.compile(r"Version\s+(\d+(?:\.\d+)*)")
|
|
|
|
|
|
def parse_version(text: str) -> str | None:
|
|
"""Pull '5.7' out of a header like 'Squid Object Cache (Version 5.7)'."""
|
|
if not text:
|
|
return None
|
|
m = _VERSION_RE.search(text)
|
|
return m.group(1) if m else None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Counters parsing (mgr:counters — for chart rendering)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def parse_counters(text: str) -> list[tuple[str, str]]:
|
|
"""Parse `mgr:counters` output, returning [(label, value), ...].
|
|
|
|
mgr:counters emits blank-line-separated blocks, each with a counter name
|
|
on the first line and `sample_time = NN, client_http.requests = 12345`
|
|
style key=value pairs beneath. We flatten everything into (label, value)
|
|
pairs skipping the 'sample_time' bookkeeping rows so the UI can show a
|
|
simple table.
|
|
"""
|
|
out: list[tuple[str, str]] = []
|
|
if not text:
|
|
return out
|
|
current_block = ""
|
|
for raw in text.splitlines():
|
|
line = raw.rstrip()
|
|
if not line.strip():
|
|
current_block = ""
|
|
continue
|
|
if not line.startswith((" ", "\t")):
|
|
# New block header (e.g. "client_http.requests")
|
|
current_block = line.strip()
|
|
out.append((current_block, ""))
|
|
continue
|
|
# Inside a block: "key = value"
|
|
if "=" in line:
|
|
k, v = line.split("=", 1)
|
|
k = k.strip()
|
|
v = v.strip()
|
|
if k == "sample_time":
|
|
continue
|
|
label = f"{current_block}.{k}" if current_block else k
|
|
out.append((label, v))
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# One-step convenience wrappers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def fetch_and_parse(mgr: str = "info", **kwargs) -> tuple[bool, dict, str]:
|
|
"""fetch_mgr_info + parse_mgr_info in one call.
|
|
|
|
Returns (ok, parsed_dict, error_msg).
|
|
"""
|
|
ok, raw, err = fetch_mgr_info(mgr=mgr, **kwargs)
|
|
if not ok:
|
|
return False, {}, err
|
|
return True, parse_mgr_info(raw), ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# High-level aggregator
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _fmt_now() -> str:
|
|
return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
|
|
def _safe_first(d: dict, *keys: str) -> str:
|
|
"""Return the first non-empty value among keys, or ''."""
|
|
for k in keys:
|
|
v = d.get(k)
|
|
if v not in (None, ""):
|
|
return str(v)
|
|
return ""
|
|
|
|
|
|
def get_performance_summary(squidclient: str = "squidclient", **kwargs) -> dict:
|
|
"""Aggregate the most important mgr:info metrics into a single dict.
|
|
|
|
Pulls mgr:info, then optionally mgr:5min for request/byte rates. Other
|
|
mgr:* sections (counters, storedir, io) can be fetched on demand by the
|
|
UI; this function only does the must-have numbers so the page loads fast.
|
|
|
|
The returned dict is the contract for both the rendered template and the
|
|
/performance/refresh JSON endpoint.
|
|
"""
|
|
fetched_at = _fmt_now()
|
|
summary = {
|
|
"squid_uptime_seconds": None,
|
|
"squid_uptime_human": "",
|
|
"squid_version": None,
|
|
"squid_author": "",
|
|
"cpu_time_seconds": None,
|
|
"cpu_time_human": "",
|
|
"max_rss_mb": None,
|
|
"max_rss_human": "",
|
|
"current_clients": None,
|
|
"cache_hits_pct": None,
|
|
"storage_swap_mb": None,
|
|
"storage_swap_human": "",
|
|
"storage_mem_mb": None,
|
|
"storage_mem_human": "",
|
|
"request_rate_5min": None, # requests / minute
|
|
"byte_rate_5min": None, # bytes / second
|
|
"start_time": "",
|
|
"current_time": "",
|
|
"fetched_ok": False,
|
|
"fetched_at": fetched_at,
|
|
"error": None,
|
|
"raw_info": "", # included so the UI can show it in a <details> block
|
|
}
|
|
|
|
# ---- mgr:info ----
|
|
ok, raw, err = fetch_mgr_info(squidclient=squidclient, mgr="info", **kwargs)
|
|
summary["raw_info"] = raw or ""
|
|
if not ok:
|
|
summary["error"] = err
|
|
return summary
|
|
|
|
info = parse_mgr_info(raw)
|
|
|
|
# Squid Object Cache (Version 5.7): John Doe
|
|
# parse_mgr_info's fallback split puts the (Version X.Y) literal in the
|
|
# KEY (with the author trailing) — or in the value, depending on the
|
|
# line. We find whichever side contains "Version" and parse from there.
|
|
header_full = ""
|
|
header_author = ""
|
|
for k, v in info.items():
|
|
if k.startswith("Squid Object Cache"):
|
|
if "Version" in k:
|
|
header_full = k
|
|
header_author = (v or "").strip()
|
|
break
|
|
if "Version" in v:
|
|
header_full = f"{k}: {v}" if v else k
|
|
break
|
|
|
|
if header_full:
|
|
summary["squid_version"] = parse_version(header_full)
|
|
if header_author:
|
|
summary["squid_author"] = header_author
|
|
elif "):" in header_full:
|
|
summary["squid_author"] = header_full.split("):", 1)[1].strip()
|
|
elif ":" in header_full:
|
|
summary["squid_author"] = header_full.split(":", 1)[1].strip()
|
|
|
|
# Uptime: from Start Time + Current Time if Squid didn't print it directly.
|
|
start_time_s = _safe_first(info, "Start Time")
|
|
current_time_s = _safe_first(info, "Current Time")
|
|
summary["start_time"] = start_time_s
|
|
summary["current_time"] = current_time_s
|
|
uptime_s = _parse_duration_diff_seconds(start_time_s, current_time_s)
|
|
if uptime_s is not None:
|
|
summary["squid_uptime_seconds"] = uptime_s
|
|
summary["squid_uptime_human"] = _seconds_to_human(uptime_s)
|
|
|
|
# Connection information: Number of clients accessing cache
|
|
clients = _safe_first(info, "Number of clients accessing cache")
|
|
if clients:
|
|
try:
|
|
summary["current_clients"] = int(re.sub(r"[^0-9]", "", clients) or 0)
|
|
except ValueError:
|
|
summary["current_clients"] = None
|
|
|
|
# Cache information: Hits as % of bytes sent
|
|
hits_pct = _safe_first(info, "Hits as % of bytes sent")
|
|
summary["cache_hits_pct"] = parse_percent(hits_pct)
|
|
|
|
# Storage info
|
|
swap = _safe_first(info, "Storage Swap size")
|
|
summary["storage_swap_human"] = swap
|
|
summary["storage_swap_mb"] = _bytes_to_mb(parse_bytes_to_int(swap))
|
|
|
|
mem = _safe_first(info, "Storage Mem size")
|
|
summary["storage_mem_human"] = mem
|
|
summary["storage_mem_mb"] = _bytes_to_mb(parse_bytes_to_int(mem))
|
|
|
|
# Resource usage
|
|
cpu = _safe_first(info, "CPU Time")
|
|
summary["cpu_time_human"] = cpu
|
|
summary["cpu_time_seconds"] = parse_uptime_to_seconds(cpu)
|
|
|
|
rss = _safe_first(info, "Maximum Resident Size")
|
|
summary["max_rss_human"] = rss
|
|
summary["max_rss_mb"] = _bytes_to_mb(parse_bytes_to_int(rss))
|
|
|
|
# ---- mgr:5min (best-effort, don't fail the whole page if missing) ----
|
|
ok5, raw5, _err5 = fetch_mgr_info(
|
|
squidclient=squidclient, mgr="5min", **kwargs
|
|
)
|
|
if ok5:
|
|
five = parse_mgr_info(raw5)
|
|
sample = _safe_first(five, "sample_time")
|
|
# Squid prints per-5min averages with names like:
|
|
# "client_http.requests = 1234" and "client_http.kbytes_in/out = ..."
|
|
# Compute deltas ourselves if the same metric appears with a previous
|
|
# sample; for an MVP we just read the absolute number — labelled
|
|
# "5min 平均" by the UI.
|
|
req = _safe_first(five, "client_http.requests")
|
|
if req:
|
|
try:
|
|
summary["request_rate_5min"] = float(req)
|
|
except ValueError:
|
|
pass
|
|
kb_in = _safe_first(five, "client_http.kbytes_in")
|
|
kb_out = _safe_first(five, "client_http.kbytes_out")
|
|
total_kb = 0.0
|
|
for s in (kb_in, kb_out):
|
|
try:
|
|
total_kb += float(s)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
if total_kb > 0:
|
|
# Squid's 5min averages are reported per-second (kbytes/sec).
|
|
summary["byte_rate_5min"] = int(total_kb * 1024)
|
|
_ = sample # currently unused but kept for future deltas
|
|
|
|
summary["fetched_ok"] = True
|
|
return summary
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _bytes_to_mb(n: int | None) -> int | None:
|
|
if n is None:
|
|
return None
|
|
return int(n / (1024 * 1024))
|
|
|
|
|
|
def _seconds_to_human(sec: int) -> str:
|
|
"""Format seconds into 'Xd HH:MM:SS' or 'HH:MM:SS'."""
|
|
days, rem = divmod(sec, 86400)
|
|
hours, rem = divmod(rem, 3600)
|
|
minutes, seconds = divmod(rem, 60)
|
|
if days:
|
|
return f"{days}d {hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
|
|
|
|
# Accept RFC 822 / RFC 1123 / "YYYY-MM-DD HH:MM:SS" — best-effort.
|
|
_DATE_FORMATS = (
|
|
"%a, %d %b %Y %H:%M:%S %Z", # RFC 822 with named TZ (Squid mgr:info)
|
|
"%a, %d %b %Y %H:%M:%S %z",
|
|
"%a, %d %b %Y %H:%M:%S GMT",
|
|
"%Y-%m-%d %H:%M:%S",
|
|
)
|
|
|
|
|
|
def _parse_duration_diff_seconds(start: str, current: str) -> int | None:
|
|
if not start or not current:
|
|
return None
|
|
# Strip trailing timezone names that %Z may not recognize on all systems
|
|
# (Squid's mgr:info uses "GMT"; Python's %Z accepts it on Linux).
|
|
s_dt = _try_parse(start)
|
|
c_dt = _try_parse(current)
|
|
if s_dt is None or c_dt is None:
|
|
return None
|
|
delta = (c_dt - s_dt).total_seconds()
|
|
return int(delta) if delta >= 0 else None
|
|
|
|
|
|
def _try_parse(s: str):
|
|
s = (s or "").strip()
|
|
if not s:
|
|
return None
|
|
for fmt in _DATE_FORMATS:
|
|
try:
|
|
return datetime.strptime(s, fmt)
|
|
except ValueError:
|
|
continue
|
|
# last resort: split off a trailing TZ like "GMT" / "UTC"
|
|
parts = s.rsplit(" ", 1)
|
|
if len(parts) == 2 and parts[1].upper() in {"GMT", "UTC"}:
|
|
for fmt in _DATE_FORMATS:
|
|
try:
|
|
return datetime.strptime(parts[0], fmt)
|
|
except ValueError:
|
|
continue
|
|
return None |