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
577 lines
19 KiB
Python
577 lines
19 KiB
Python
"""P2-2: Export utilities for Squid Web Manager.
|
|
|
|
Provides CSV/JSON serialisation helpers and Flask response builders for
|
|
downloading log entries, KPI summaries, anomaly lists and audit log entries.
|
|
|
|
Conventions
|
|
-----------
|
|
- All CSV output starts with a UTF-8 BOM (``\ufeff``) so Microsoft Excel
|
|
opens it as UTF-8 rather than falling back to the local code page.
|
|
- Timestamps are serialised as ISO-8601 strings (UTC when the source
|
|
value carries tzinfo). ``None`` values become empty cells; ``inf``
|
|
becomes the literal string ``Infinity`` so downstream tools don't
|
|
silently choke.
|
|
- Every public function swallows exceptions and returns a safe empty
|
|
value (``""`` / ``{}``). This makes it safe to call from request
|
|
handlers - a broken export must never 500 the dashboard.
|
|
- No third-party dependencies. The module only uses ``csv``, ``json``,
|
|
``io`` and Flask's ``Response``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import io
|
|
import json
|
|
from datetime import date, datetime
|
|
from typing import Any, Iterable
|
|
|
|
from flask import Response
|
|
|
|
|
|
# --- Constants -------------------------------------------------------------
|
|
|
|
#: Default columns exported for a LogEntry. Order matters - this is the
|
|
#: column order Excel/LibreOffice will display.
|
|
DEFAULT_ENTRY_FIELDS: list[str] = [
|
|
"time",
|
|
"client",
|
|
"result_code",
|
|
"http_code",
|
|
"method",
|
|
"url",
|
|
"host",
|
|
"size_bytes",
|
|
"elapsed_ms",
|
|
"hier_code",
|
|
"content_type",
|
|
]
|
|
|
|
#: UTF-8 BOM. Excel needs this prefix to recognise the file as UTF-8.
|
|
_UTF8_BOM = "\ufeff"
|
|
|
|
|
|
# --- Internal helpers ------------------------------------------------------
|
|
|
|
|
|
def _get_field(entry: Any, key: str) -> Any:
|
|
"""Dict-or-attribute getter that never raises.
|
|
|
|
LogEntry subclasses dict, so ``entry.get(key)`` works, but we also
|
|
support plain attribute access (e.g. SQLAlchemy model instances).
|
|
"""
|
|
try:
|
|
if isinstance(entry, dict):
|
|
return entry.get(key)
|
|
return getattr(entry, key, None)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _serialise_value(value: Any) -> str:
|
|
"""Convert a single cell value into a CSV-friendly string.
|
|
|
|
- ``None`` -> empty cell (not the literal "None").
|
|
- ``datetime`` / ``date`` -> ISO-8601.
|
|
- ``float('inf')`` / ``float('-inf')`` -> literal "Infinity" / "-Infinity".
|
|
- everything else -> ``str(value)``.
|
|
"""
|
|
if value is None:
|
|
return ""
|
|
# datetime must come before date because datetime is a subclass of date
|
|
if isinstance(value, datetime):
|
|
try:
|
|
return value.isoformat()
|
|
except Exception:
|
|
return str(value)
|
|
if isinstance(value, date):
|
|
try:
|
|
return value.isoformat()
|
|
except Exception:
|
|
return str(value)
|
|
if isinstance(value, float):
|
|
# JSON-emitted inf / -inf / nan don't round-trip cleanly through
|
|
# CSV consumers; spell them out instead.
|
|
if value != value: # NaN
|
|
return "NaN"
|
|
if value == float("inf"):
|
|
return "Infinity"
|
|
if value == float("-inf"):
|
|
return "-Infinity"
|
|
# drop noisy trailing zeros for whole numbers
|
|
if value.is_integer():
|
|
return str(int(value))
|
|
return repr(value)
|
|
if isinstance(value, bool):
|
|
return "true" if value else "false"
|
|
if isinstance(value, (list, tuple)):
|
|
try:
|
|
return ",".join(str(v) for v in value)
|
|
except Exception:
|
|
return str(value)
|
|
try:
|
|
return str(value)
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def _timestamp_iso(entry: Any) -> str:
|
|
"""Best-effort ISO timestamp for ``entry`` - falls back to ``time`` field."""
|
|
try:
|
|
ts = _get_field(entry, "timestamp")
|
|
if isinstance(ts, datetime):
|
|
return ts.isoformat()
|
|
if isinstance(ts, (int, float)):
|
|
return datetime.utcfromtimestamp(float(ts)).isoformat() + "Z"
|
|
except Exception:
|
|
pass
|
|
try:
|
|
t = _get_field(entry, "time")
|
|
if isinstance(t, (int, float)):
|
|
return datetime.utcfromtimestamp(float(t)).isoformat() + "Z"
|
|
except Exception:
|
|
pass
|
|
return ""
|
|
|
|
|
|
def _write_csv(headers: list[str], rows: Iterable[list[Any]]) -> str:
|
|
"""Render ``headers`` + ``rows`` into a CSV string with UTF-8 BOM.
|
|
|
|
Uses ``csv.writer`` with QUOTE_MINIMAL so embedded newlines / quotes
|
|
inside URLs are escaped correctly. Output is a plain string (no
|
|
BytesIO shenanigans) so the caller can decide whether to wrap it in
|
|
a Flask ``Response`` or write to disk.
|
|
"""
|
|
buf = io.StringIO()
|
|
# Write the BOM first; CSV writer will then prepend the header row.
|
|
buf.write(_UTF8_BOM)
|
|
try:
|
|
writer = csv.writer(buf, quoting=csv.QUOTE_MINIMAL, lineterminator="\n")
|
|
writer.writerow(headers)
|
|
for row in rows:
|
|
try:
|
|
writer.writerow([_serialise_value(v) for v in row])
|
|
except Exception:
|
|
# one bad row shouldn't nuke the whole export
|
|
continue
|
|
except Exception:
|
|
# return at least the BOM + header line so callers get *something*
|
|
try:
|
|
buf.write(",".join(headers))
|
|
except Exception:
|
|
pass
|
|
return buf.getvalue()
|
|
|
|
|
|
# --- Entry export ----------------------------------------------------------
|
|
|
|
|
|
def entries_to_csv(entries: Any, fields: list[str] | None = None) -> str:
|
|
"""Serialise a list of log entries to a CSV string.
|
|
|
|
``entries`` may be any iterable of dict-like objects (LogEntry,
|
|
SQLAlchemy model, plain dict). ``fields`` defaults to
|
|
:data:`DEFAULT_ENTRY_FIELDS`. Returns an empty string on error.
|
|
"""
|
|
try:
|
|
if not entries:
|
|
# still emit the header so an empty download is a valid CSV
|
|
cols = list(fields) if fields else list(DEFAULT_ENTRY_FIELDS)
|
|
return _write_csv(cols, [])
|
|
|
|
cols = list(fields) if fields else list(DEFAULT_ENTRY_FIELDS)
|
|
|
|
def _row(entry: Any) -> list[Any]:
|
|
out: list[Any] = []
|
|
for col in cols:
|
|
if col == "time":
|
|
# Prefer ISO timestamp; fall back to raw time field
|
|
iso = _timestamp_iso(entry)
|
|
if iso:
|
|
out.append(iso)
|
|
else:
|
|
out.append(_get_field(entry, "time"))
|
|
elif col == "size_bytes":
|
|
# The DB column is ``size`` (LogEntry) or ``size`` (dict);
|
|
# allow ``size_bytes`` as a fallback for either shape.
|
|
val = _get_field(entry, "size_bytes")
|
|
if val is None:
|
|
val = _get_field(entry, "size")
|
|
out.append(val)
|
|
elif col == "elapsed_ms":
|
|
val = _get_field(entry, "elapsed_ms")
|
|
if val is None:
|
|
val = _get_field(entry, "elapsed")
|
|
out.append(val)
|
|
elif col == "hier_code":
|
|
val = _get_field(entry, "hier_code")
|
|
if val is None:
|
|
val = _get_field(entry, "hierarchy")
|
|
out.append(val)
|
|
else:
|
|
out.append(_get_field(entry, col))
|
|
return out
|
|
|
|
return _write_csv(cols, (_row(e) for e in entries))
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def entries_to_json(entries: Any, fields: list[str] | None = None) -> str:
|
|
"""Serialise a list of log entries to a pretty-printed JSON string.
|
|
|
|
Datetimes are converted to ISO-8601. ``fields`` constrains the
|
|
exported keys (per-entry filtering); ``None`` exports every key the
|
|
entry exposes. Returns an empty string on error.
|
|
"""
|
|
try:
|
|
out: list[dict[str, Any]] = []
|
|
for entry in entries or []:
|
|
try:
|
|
if fields is not None:
|
|
rec: dict[str, Any] = {}
|
|
for col in fields:
|
|
if col == "time":
|
|
iso = _timestamp_iso(entry)
|
|
rec["time"] = iso or _get_field(entry, "time")
|
|
elif col == "size_bytes":
|
|
v = _get_field(entry, "size_bytes")
|
|
rec["size_bytes"] = v if v is not None else _get_field(entry, "size")
|
|
elif col == "elapsed_ms":
|
|
v = _get_field(entry, "elapsed_ms")
|
|
rec["elapsed_ms"] = v if v is not None else _get_field(entry, "elapsed")
|
|
elif col == "hier_code":
|
|
v = _get_field(entry, "hier_code")
|
|
rec["hier_code"] = v if v is not None else _get_field(entry, "hierarchy")
|
|
else:
|
|
rec[col] = _get_field(entry, col)
|
|
else:
|
|
rec = _entry_to_dict(entry)
|
|
out.append(rec)
|
|
except Exception:
|
|
continue
|
|
return json.dumps(out, indent=2, ensure_ascii=False, default=_json_default)
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def _entry_to_dict(entry: Any) -> dict[str, Any]:
|
|
"""Flatten a LogEntry / SQLAlchemy row into a JSON-friendly dict."""
|
|
try:
|
|
if isinstance(entry, dict):
|
|
return {str(k): _json_safe(v) for k, v in entry.items()}
|
|
except Exception:
|
|
pass
|
|
# Fall back to per-attribute access
|
|
result: dict[str, Any] = {}
|
|
for col in DEFAULT_ENTRY_FIELDS:
|
|
try:
|
|
result[col] = _get_field(entry, col)
|
|
except Exception:
|
|
result[col] = None
|
|
return result
|
|
|
|
|
|
def _json_safe(value: Any) -> Any:
|
|
"""Make ``value`` JSON-serialisable. Datetimes -> ISO strings."""
|
|
if value is None or isinstance(value, (str, int, bool)):
|
|
return value
|
|
if isinstance(value, float):
|
|
if value != value:
|
|
return None # NaN -> null
|
|
if value == float("inf"):
|
|
return "Infinity"
|
|
if value == float("-inf"):
|
|
return "-Infinity"
|
|
return value
|
|
if isinstance(value, (datetime, date)):
|
|
try:
|
|
return value.isoformat()
|
|
except Exception:
|
|
return str(value)
|
|
if isinstance(value, (list, tuple)):
|
|
return [_json_safe(v) for v in value]
|
|
if isinstance(value, dict):
|
|
return {str(k): _json_safe(v) for k, v in value.items()}
|
|
return str(value)
|
|
|
|
|
|
def _json_default(value: Any) -> Any:
|
|
"""Fallback encoder for json.dumps - keeps the export robust."""
|
|
return _json_safe(value)
|
|
|
|
|
|
# --- Stats export ----------------------------------------------------------
|
|
|
|
|
|
def stats_to_json(stats: Any) -> str:
|
|
"""Serialise a KPI summary dict to a pretty-printed JSON string.
|
|
|
|
The dashboard's ``get_stats()`` returns a dict that may contain
|
|
nested structures (top_clients list, etc.). Datetimes nested inside
|
|
are normalised via :func:`_json_safe`. Returns ``""`` on error.
|
|
"""
|
|
try:
|
|
if stats is None:
|
|
stats = {}
|
|
return json.dumps(_json_safe(stats), indent=2,
|
|
ensure_ascii=False, default=_json_default)
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
# --- Anomaly export --------------------------------------------------------
|
|
|
|
|
|
#: Field order for the long-form anomaly CSV. ``type`` distinguishes the
|
|
#: detector; the rest of the columns are the union of useful fields.
|
|
ANOMALY_CSV_FIELDS: list[str] = [
|
|
"type",
|
|
"client",
|
|
"severity",
|
|
"time",
|
|
"url",
|
|
"host",
|
|
"method",
|
|
"result",
|
|
"result_code",
|
|
"http_code",
|
|
"size_bytes",
|
|
"size_mb",
|
|
"request_count",
|
|
"total_bytes",
|
|
"avg_size",
|
|
"baseline_rpm",
|
|
"current_rpm",
|
|
"baseline_count",
|
|
"current_count",
|
|
"ratio",
|
|
"first_seen",
|
|
"sample_url",
|
|
"sample_host",
|
|
]
|
|
|
|
|
|
def _anomaly_rows(summary: dict[str, Any]) -> list[dict[str, Any]]:
|
|
"""Flatten the four anomaly sub-lists into a single long table.
|
|
|
|
Each row gets a ``type`` column so consumers can pivot / filter.
|
|
Rows from different detectors carry different fields; missing
|
|
fields are simply left empty.
|
|
"""
|
|
rows: list[dict[str, Any]] = []
|
|
if not isinstance(summary, dict):
|
|
return rows
|
|
|
|
# 1. anomalous_clients (spikes)
|
|
for a in summary.get("anomalous_clients") or []:
|
|
if not isinstance(a, dict):
|
|
continue
|
|
rec = {k: a.get(k) for k in ANOMALY_CSV_FIELDS if k in a}
|
|
rec["type"] = "client_spike"
|
|
rec.setdefault("client", a.get("client"))
|
|
rec.setdefault("severity", a.get("severity"))
|
|
rec.setdefault("baseline_rpm", a.get("baseline_rpm"))
|
|
rec.setdefault("current_rpm", a.get("current_rpm"))
|
|
rec.setdefault("baseline_count", a.get("baseline_count"))
|
|
rec.setdefault("current_count", a.get("current_count"))
|
|
rec.setdefault("ratio", a.get("ratio"))
|
|
rows.append(rec)
|
|
|
|
# 2. large_requests
|
|
for a in summary.get("large_requests") or []:
|
|
if not isinstance(a, dict):
|
|
continue
|
|
rec = {k: a.get(k) for k in ANOMALY_CSV_FIELDS if k in a}
|
|
rec["type"] = "large_request"
|
|
rec.setdefault("client", a.get("client"))
|
|
rec.setdefault("severity", "warning")
|
|
rec.setdefault("size_bytes", a.get("size_bytes"))
|
|
rec.setdefault("size_mb", a.get("size_mb"))
|
|
rows.append(rec)
|
|
|
|
# 3. high_freq_small
|
|
for a in summary.get("high_freq_small") or []:
|
|
if not isinstance(a, dict):
|
|
continue
|
|
rec = {k: a.get(k) for k in ANOMALY_CSV_FIELDS if k in a}
|
|
rec["type"] = "high_freq_small"
|
|
rec.setdefault("client", a.get("client"))
|
|
rec.setdefault("severity", "warning")
|
|
rec.setdefault("sample_url", a.get("sample_url"))
|
|
rec.setdefault("sample_host", a.get("sample_host"))
|
|
rows.append(rec)
|
|
|
|
# 4. new_clients
|
|
for a in summary.get("new_clients") or []:
|
|
if not isinstance(a, dict):
|
|
continue
|
|
rec = {k: a.get(k) for k in ANOMALY_CSV_FIELDS if k in a}
|
|
rec["type"] = "new_client"
|
|
rec.setdefault("client", a.get("client"))
|
|
rec.setdefault("severity", "info")
|
|
rec.setdefault("first_seen", a.get("first_seen"))
|
|
rec.setdefault("request_count", a.get("request_count"))
|
|
rec.setdefault("sample_url", a.get("sample_url"))
|
|
rows.append(rec)
|
|
|
|
return rows
|
|
|
|
|
|
def anomalies_to_csv(summary: Any) -> str:
|
|
"""Serialise the anomaly summary to a long-form CSV.
|
|
|
|
``summary`` is the dict returned by ``_build_anomaly_summary`` (or
|
|
anything with the same four sub-list keys). All four detector
|
|
outputs are flattened into one table with a ``type`` column.
|
|
Returns ``""`` on error.
|
|
"""
|
|
try:
|
|
rows = _anomaly_rows(summary if isinstance(summary, dict) else {})
|
|
return _write_csv(ANOMALY_CSV_FIELDS, [
|
|
[r.get(col) for col in ANOMALY_CSV_FIELDS] for r in rows
|
|
])
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def anomalies_to_json(summary: Any) -> str:
|
|
"""Serialise the anomaly summary to pretty JSON (raw structure)."""
|
|
try:
|
|
if summary is None:
|
|
summary = {}
|
|
return json.dumps(_json_safe(summary), indent=2,
|
|
ensure_ascii=False, default=_json_default)
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
# --- Audit export ----------------------------------------------------------
|
|
|
|
|
|
#: Field order for the audit log CSV.
|
|
AUDIT_CSV_FIELDS: list[str] = [
|
|
"timestamp",
|
|
"username",
|
|
"action",
|
|
"detail",
|
|
]
|
|
|
|
|
|
def _audit_row(audit_obj: Any) -> list[Any]:
|
|
"""Convert an AuditLog row to a list aligned with AUDIT_CSV_FIELDS."""
|
|
try:
|
|
ts = _get_field(audit_obj, "timestamp")
|
|
if isinstance(ts, datetime):
|
|
ts_str = ts.isoformat()
|
|
elif isinstance(ts, (int, float)):
|
|
try:
|
|
ts_str = datetime.utcfromtimestamp(float(ts)).isoformat() + "Z"
|
|
except Exception:
|
|
ts_str = str(ts)
|
|
else:
|
|
ts_str = _serialise_value(ts)
|
|
except Exception:
|
|
ts_str = ""
|
|
return [
|
|
ts_str,
|
|
_get_field(audit_obj, "username"),
|
|
_get_field(audit_obj, "action"),
|
|
_get_field(audit_obj, "detail"),
|
|
]
|
|
|
|
|
|
def audit_to_csv(entries: Any) -> str:
|
|
"""Serialise a list of AuditLog rows to a CSV string."""
|
|
try:
|
|
items = list(entries) if entries else []
|
|
return _write_csv(AUDIT_CSV_FIELDS, (_audit_row(a) for a in items))
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def audit_to_json(entries: Any) -> str:
|
|
"""Serialise a list of AuditLog rows to pretty JSON."""
|
|
try:
|
|
out: list[dict[str, Any]] = []
|
|
for a in entries or []:
|
|
try:
|
|
out.append({
|
|
"timestamp": _json_safe(_get_field(a, "timestamp")),
|
|
"username": _get_field(a, "username"),
|
|
"action": _get_field(a, "action"),
|
|
"detail": _get_field(a, "detail"),
|
|
})
|
|
except Exception:
|
|
continue
|
|
return json.dumps(out, indent=2, ensure_ascii=False,
|
|
default=_json_default)
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
# --- Response builders -----------------------------------------------------
|
|
|
|
|
|
def csv_response(csv_text: str, filename: str) -> Response:
|
|
"""Wrap a CSV string in a Flask ``Response`` with download headers.
|
|
|
|
Adds ``Content-Disposition: attachment; filename=...`` so browsers
|
|
save the file rather than rendering it inline. ``Content-Type`` is
|
|
``text/csv; charset=utf-8`` (Flask appends the charset once).
|
|
"""
|
|
if not csv_text:
|
|
csv_text = _UTF8_BOM # at least the BOM, so the file isn't empty
|
|
# The CSV already has the UTF-8 BOM baked in; we hand Flask a plain
|
|
# str response and let it encode via utf-8.
|
|
safe_name = _safe_filename(filename, default="export.csv")
|
|
resp = Response(
|
|
csv_text,
|
|
status=200,
|
|
)
|
|
resp.headers["Content-Type"] = "text/csv; charset=utf-8"
|
|
resp.headers["Content-Disposition"] = f'attachment; filename="{safe_name}"'
|
|
resp.headers["Cache-Control"] = "no-store"
|
|
return resp
|
|
|
|
|
|
def json_response(json_text: str, filename: str) -> Response:
|
|
"""Wrap a JSON string in a Flask ``Response`` with download headers."""
|
|
if not json_text:
|
|
json_text = "{}"
|
|
safe_name = _safe_filename(filename, default="export.json")
|
|
resp = Response(
|
|
json_text,
|
|
status=200,
|
|
)
|
|
resp.headers["Content-Type"] = "application/json; charset=utf-8"
|
|
resp.headers["Content-Disposition"] = f'attachment; filename="{safe_name}"'
|
|
resp.headers["Cache-Control"] = "no-store"
|
|
return resp
|
|
|
|
|
|
def _safe_filename(name: str, default: str = "export") -> str:
|
|
"""Strip path separators / control chars from a download filename."""
|
|
try:
|
|
if not name:
|
|
return default
|
|
# Drop any directory components - browsers only care about the basename.
|
|
name = name.replace("\\", "/").split("/")[-1]
|
|
# Reject anything that isn't a basic filename char.
|
|
cleaned = "".join(c for c in name if c.isprintable() and c not in '"<>|:*?\n\r\t')
|
|
return cleaned or default
|
|
except Exception:
|
|
return default
|
|
|
|
|
|
# --- Filename helpers ------------------------------------------------------
|
|
|
|
|
|
def timestamped_filename(prefix: str, ext: str) -> str:
|
|
"""Build ``prefix_YYYYMMDD_HHMMSS.ext`` (local time)."""
|
|
try:
|
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
except Exception:
|
|
ts = "unknown"
|
|
# ext should be a plain suffix like "csv" or "json" (no leading dot)
|
|
ext = (ext or "").lstrip(".").lower() or "bin"
|
|
return f"{prefix}_{ts}.{ext}" |