Files
squid-manager/logformat_parser.py
T
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

527 lines
22 KiB
Python

"""Squid custom logformat parser.
Squid's ``access.log`` is normally written in *native* format, but operators
can override it with a ``logformat`` directive, e.g.::
logformat squid %ts.%03tu %6tr %>a %Ss/%03>Hs %<st %rm %ru %un %Sh/%<a %mt
This module turns such a format string into a regular expression and applies
it to individual log lines, returning a ``LogEntry``-compatible dict whose
field names line up with :mod:`log_parser` so the rest of the application
(dashboard, alerts, anomaly detection) does not need to change.
The two important design choices:
1. **Field name parity.** ``parse_with_format`` returns the same keys as
``log_parser.parse_line`` so ``get_parsed_logs`` can return a single
uniform ``LogEntry`` regardless of which path produced it.
2. **Compose-then-split.** The combined tokens such as ``%Ss/%03>Hs`` or
``%Sh/%<a`` are matched as a single field (one whitespace-delimited
token) and then split on ``/`` internally. This lets us handle the
native-style "slash-joined" log line shape no matter which ``logformat``
the operator uses.
"""
from __future__ import annotations
import re
from datetime import datetime, timezone
from urllib.parse import urlsplit
# ---------------------------------------------------------------------------
# Token map
# ---------------------------------------------------------------------------
#
# Each entry is ``(token_text, canonical_group_name, inner_pattern)``.
# ``canonical_group_name`` is a valid Python regex identifier that maps onto
# the field name used by :mod:`log_parser`. The order of ``_TOKEN_DEFS``
# matters: longer / more specific tokens (with width specifiers like
# ``%03tu``) MUST be tried before shorter variants of the same prefix so
# we don't accidentally swallow them as ``%tu``.
#
# Tokens covered:
# %ts - Unix timestamp seconds (with optional fractional part)
# %03tu / %tu - Sub-second fraction (milliseconds)
# %6tr / %tr - Response time (microseconds with %6, otherwise raw)
# %>a - Client IP / source address
# %<a - Server IP / peer address
# %Ss - Squid result code (TCP_HIT etc)
# %03>Hs / %>Hs - HTTP status code (with / without width spec)
# %<st / %st - Reply size in bytes
# %rm - Request method (GET/POST/CONNECT ...)
# %ru - Request URL
# %un - Auth user / ident ("-" when anonymous)
# %Sh - Hierarchy code (HIER_DIRECT etc)
# %mt - MIME content type (greedy at the end)
# (token_text, canonical_group_name, regex_inner)
# ``regex_inner`` is the regex WITHOUT the ``(?P<name>...)`` wrapper so we
# can reuse it for compound tokens (where multiple tokens share the same
# captured string).
_TOKEN_DEFS: list[tuple[str, str, str]] = [
# Timestamp: integer seconds. The optional fractional part is
# handled by %03tu / %tu if the operator's format splits it out
# (e.g. "%ts.%03tu"); otherwise the native parser captures both
# halves via its \d+(?:\.\d+)? pattern.
("%ts", "time", r"\d+"),
("%03tu", "msec", r"\d{1,3}"),
("%tu", "msec", r"\d+"),
# Response time (microseconds with %6, otherwise raw)
("%6tr", "elapsed", r"\d+"),
("%tr", "elapsed", r"\d+"),
# Client / peer address
("%>a", "client", r"\S+"),
("%<a", "peer", r"\S+"),
# Result code (squid code / http status combined)
("%Ss", "result", r"\S+"),
# HTTP status code field (becomes http_code_field; combined with result
# in parse_with_format to yield http_code)
("%03>Hs", "http_code_field", r"\d+"),
("%>Hs", "http_code_field", r"\d+"),
# Sizes
("%<st", "size", r"\d+"),
("%st", "size", r"\d+"),
# Request
("%rm", "method", r"\S+"),
("%ru", "url", r"\S+"),
("%un", "ident", r"\S+"),
# Hierarchy
("%Sh", "hier_code", r"\S+"),
# Content type (greedy because it's the last column)
("%mt", "content_type", r".*"),
]
# Lookup helpers - built once at import time
_TOKEN_TO_GROUP: dict[str, str] = {tok: grp for tok, grp, _ in _TOKEN_DEFS}
_GROUP_TO_INNER: dict[str, str] = {grp: inner for _, grp, inner in _TOKEN_DEFS}
# Order to try matching tokens at any given '%' position: longest first.
_TOKEN_ORDER: list[str] = sorted(_TOKEN_TO_GROUP.keys(), key=lambda x: -len(x))
# Built-in logformat examples presented in the UI dropdown. "native" is
# the default Squid format and is handled by log_parser directly - it is
# listed here so the dropdown has a consistent shape and the user can
# explicitly choose "go back to native" from the picker.
PRESET_FORMATS: dict[str, str] = {
"native": "", # empty = fall back to log_parser (Squid native)
"squid": "%ts.%03tu %6tr %>a %Ss/%03>Hs %<st %rm %ru %un %Sh/%<a %mt",
"common": '%>a %[ui] [%{%d/%b/%Y:%H:%M:%S +0000}] "%rm %ru HTTP/%rv" %>Hs %<st "%{Referer}>h" "%{User-Agent}>h"',
"combined": '%>a %[ui] [%{%d/%b/%Y:%H:%M:%S +0000}] "%rm %ru HTTP/%rv" %>Hs %<st "%{Referer}>h" "%{User-Agent}>h" %Sh/%<a %mt',
"referer": '%ts.%03tu %>a "%{Referer}>h" "%rm %ru" %>Hs %<st',
"useragent": '%ts.%03tu %>a "%rm %ru" %>Hs %<st "%{User-Agent}>h"',
}
# ---------------------------------------------------------------------------
# Compilation
# ---------------------------------------------------------------------------
class CompiledFormat:
"""Bundles a compiled regex with the list of named groups it produces.
The caller can introspect ``fields`` to decide what to display in the
UI without re-running the parser.
"""
__slots__ = ("pattern", "fields", "source")
def __init__(self, pattern: re.Pattern, fields: list[str], source: str):
self.pattern = pattern
self.fields = fields
self.source = source
def __repr__(self) -> str: # pragma: no cover - debug only
return f"CompiledFormat(fields={self.fields!r})"
def _sanitize_group_name(name: str) -> str:
"""Turn an arbitrary string into a valid Python regex group identifier."""
# Group names in Python's re module must match [A-Za-z_][A-Za-z0-9_]*
# and must not be a pure integer. Strip invalid chars and prefix
# if the result would otherwise start with a digit.
out = re.sub(r"[^A-Za-z0-9_]", "_", name)
if not out or out[0].isdigit():
out = "g_" + out
return out
def _inner_without_slash(inner: str) -> str:
"""Return a variant of ``inner`` that does not match ``/``.
Used for the first half of a compound token so the regex engine
knows where to place the literal ``/`` separator. We rewrite the
common character classes to exclude ``/``:
- ``\\S+`` -> ``[^/\\s]+``
- ``\\S*`` -> ``[^/\\s]*``
- ``.*`` -> ``[^/]*`` (only used for trailing content_type)
"""
out = inner
out = out.replace(r"\S+", r"[^/\s]+")
out = out.replace(r"\S*", r"[^/\s]*")
out = out.replace(r".*", r"[^/]*")
return out
def compile_logformat(fmt: str) -> CompiledFormat:
"""Compile a Squid ``logformat`` string into a regex.
The algorithm:
1. Walk the format string, looking for ``%`` and trying to match the
longest known token at each position.
2. Replace each matched token with its regex fragment (which carries a
named capture group).
3. Escape any literal text between tokens, but treat **whitespace as a
separator** rather than a literal character - we expect log lines
to be whitespace-separated and want the regex to remain robust
even if the operator accidentally uses tabs vs. spaces.
4. Return a :class:`CompiledFormat` carrying the compiled pattern, the
ordered list of named fields, and the original source string.
Notes / limitations:
- Squid has hundreds of format codes; we only know the subset listed in
:data:`_TOKEN_DEFS`. Unknown ``%xxx`` sequences are passed through
as ``\\S+`` so the line still parses.
- Compound tokens like ``%Ss/%03>Hs`` are detected at compile time
and rendered as a single compound field (``hierarchy_blob``) that
is split on ``/`` in :func:`parse_with_format`. The compound
shares the first half's group name so existing field-name lookups
still work.
"""
fmt = (fmt or "").strip()
if not fmt:
raise ValueError("empty logformat string")
# Detect whether the operator splits the timestamp via ``%ts.%03tu``
# - if so, we constrain ``%ts`` to integer seconds so the literal
# dot between the two halves is unambiguous. Otherwise we accept
# the bare ``seconds.msec`` shape from the native format too.
has_split_ts = bool(re.search(r"%ts\s*\.\s*%(03)?tu", fmt))
# Pass 1: tokenise into a stream of (kind, value) pairs.
# kind in {"tok","lit","unk","slash"} - "slash" is a marker we emit
# when a literal begins with "/" so we can later detect compound
# sequences like "%A/%B".
tokens: list[tuple[str, str]] = []
i = 0
n = len(fmt)
while i < n:
if fmt[i] == "%":
# Try longest-known-token first
matched_tok: str | None = None
for tok in _TOKEN_ORDER:
if fmt.startswith(tok, i):
matched_tok = tok
break
if matched_tok is not None:
grp = _TOKEN_TO_GROUP[matched_tok]
# ``%ts`` gets a context-sensitive inner pattern: if the
# format splits it (``%ts.%03tu``), only integer seconds
# are valid; otherwise we accept either integer or
# ``seconds.msec`` to also cover the native format.
if matched_tok == "%ts" and not has_split_ts:
grp = "time_loose"
tokens.append(("tok", grp))
i += len(matched_tok)
continue
# Try special %>xx / %<xx forms that aren't in the literal
# table but we still want to capture (e.g. %>ru, %>ha).
if i + 1 < n and fmt[i + 1] in (">", "<"):
end = i + 2
while end < n and (fmt[end].isalpha() or fmt[end] == "_"):
end += 1
candidate = fmt[i + 1:end] # e.g. "a", "ru", "Hs"
name = _sanitize_group_name("ext_" + candidate.lower())
# Emit as a known field if we happen to map it
mapped = _GROUP_TO_INNER.get(name)
# Generic capture; cannot recover semantics so just \S+
tokens.append(("tok", name))
i = end
continue
# Unknown %-code: consume the % and the following
# identifier characters; emit a generic "unknown" field.
end = i + 1
while end < n and (fmt[end].isalpha() or fmt[end].isdigit()
or fmt[end] in "<>_{}[]"):
end += 1
if end == i + 1:
# bare '%' with nothing after it: emit literally
tokens.append(("lit", "%"))
i += 1
else:
name = _sanitize_group_name("u_" + fmt[i + 1:end].lower())
tokens.append(("unk", name))
i = end
else:
# Literal run - collect everything until the next % or end
j = i
while j < n and fmt[j] != "%":
j += 1
lit = fmt[i:j]
# If a token was just emitted and the next literal begins
# with "/", mark this as a compound separator.
if lit.startswith("/") and tokens and tokens[-1][0] == "tok":
tokens.append(("slash", ""))
lit = lit[1:]
tokens.append(("lit", lit))
i = j
# Pass 2: detect compound tokens (tok - slash - [lit] - tok) and
# merge them into a single field that captures the slash-joined
# string. The result is rendered as one capture group whose name is
# the first half's name; the post-processor in parse_with_format
# splits it.
merged: list[tuple[str, str]] = []
j = 0
while j < len(tokens):
kind, value = tokens[j]
# Look ahead for: tok - slash - [optional empty literal] - tok
is_compound = False
if kind == "tok" and j + 1 < len(tokens) and tokens[j + 1][0] == "slash":
# Skip any empty literal between slash and second half.
k = j + 2
while k < len(tokens) and tokens[k][0] == "lit" and tokens[k][1] == "":
k += 1
if k < len(tokens) and tokens[k][0] in ("tok", "unk"):
v1 = tokens[j][1]
v2 = tokens[k][1]
inner1 = _GROUP_TO_INNER.get(v1, r"\S+")
inner2 = _GROUP_TO_INNER.get(v2, r"\S+")
merged.append(("compound", f"{v1}|{inner1}|{inner2}"))
j = k + 1
is_compound = True
if is_compound:
continue
if kind == "slash":
# Stray slash: shouldn't happen given the tokeniser but
# be defensive.
j += 1
continue
merged.append((kind, value))
j += 1
# Pass 3: translate into regex
parts: list[str] = []
fields: list[str] = []
seen_groups: set[str] = set()
for kind, value in merged:
if kind == "lit":
# Whitespace runs -> \s+, anything else -> re.escape
k = 0
while k < len(value):
ch = value[k]
if ch.isspace():
m = k
while m < len(value) and value[m].isspace():
m += 1
parts.append(r"\s+")
k = m
else:
parts.append(re.escape(ch))
k += 1
elif kind == "tok":
inner = _GROUP_TO_INNER.get(value, r"\S+")
grp_name = value
if grp_name in seen_groups:
# Duplicate group name (shouldn't happen for our token
# table but be defensive). Use a suffixed alias.
grp_name = grp_name + "_dup"
seen_groups.add(grp_name)
parts.append(rf"(?P<{grp_name}>{inner})")
if grp_name not in fields:
fields.append(grp_name)
elif kind == "unk":
parts.append(rf"(?P<{value}>\S+)")
if value not in fields:
fields.append(value)
elif kind == "compound":
# value is "v1|inner1|inner2" - encode the slash-joined
# capture under v1's group name.
v1, inner1, inner2 = value.split("|", 2)
grp_name = v1
if grp_name in seen_groups:
grp_name = grp_name + "_c"
seen_groups.add(grp_name)
# The combined pattern captures the full slash-joined value.
# The *first* half must be non-greedy and constrained to
# *not* contain "/" so the "/" separator falls in the right
# place. E.g. ``\S+/`` would otherwise over-eat the
# ``HIER_DIRECT/13.107.5.93`` style string and return
# ``HIER_DIRECT/13.107.5.9`` (3 captured).
#
# We rewrite inner1 to disallow "/" and make it non-greedy
# so the engine backtracks to the first valid separator.
non_greedy_inner1 = _inner_without_slash(inner1)
parts.append(rf"(?P<{grp_name}>{non_greedy_inner1}/{inner2})")
if grp_name not in fields:
fields.append(grp_name)
pattern = re.compile("^" + "".join(parts) + r"\s*$")
return CompiledFormat(pattern=pattern, fields=fields, source=fmt)
# ---------------------------------------------------------------------------
# Parsing
# ---------------------------------------------------------------------------
def parse_with_format(line: str, compiled) -> dict | None:
"""Parse a single log line using a compiled logformat.
``compiled`` may be either a :class:`CompiledFormat` or the bare
``(pattern, fields)`` tuple returned by older call sites. Returns a
dict shaped exactly like :func:`log_parser.parse_line` so the result
can flow through the same downstream code.
"""
if not line or not line.strip():
return None
if isinstance(compiled, tuple):
pattern = compiled[0]
else:
pattern = compiled.pattern
m = pattern.match(line.strip())
if not m:
return None
g = m.groupdict()
# --- timestamp ------------------------------------------------------
# If the format splits the timestamp into integer seconds and a
# separate msec field (%ts.%03tu), recombine them so consumers
# always see the canonical ``time`` string in ``seconds.msec`` form
# - matching log_parser's output and avoiding surprising downstream
# code that expects ``float(time)`` to give a sub-second timestamp.
time_str = g.get("time") or "0"
msec = g.get("msec")
if msec and "." not in time_str:
# pad msec to 3 digits to match Squid's native formatting
time_str = f"{time_str}.{int(msec):03d}"
try:
ts = float(time_str)
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
except (ValueError, OSError):
dt = None
# --- elapsed --------------------------------------------------------
# log_parser treats this field as milliseconds directly. Squid's
# ``%6tr`` width specifier is just left-padding - the value itself
# is reported in milliseconds. Mirror that behaviour so downstream
# code can compare LogEntry dicts interchangeably.
elapsed_raw = g.get("elapsed") or "0"
try:
elapsed_ms = int(elapsed_raw)
except ValueError:
elapsed_ms = 0
# --- result / http code --------------------------------------------
# Compound tokens like %Ss/%03>Hs end up captured under the *first*
# half's group name (e.g. "result"). Split on "/" to recover the
# two halves. The http_code_field group is used when the result is
# not slash-joined but the http code is a standalone token.
raw_result = g.get("result", "") or ""
http_code_field = g.get("http_code_field", "") or ""
if "/" in raw_result:
squid_code, _, http_code = raw_result.partition("/")
if not http_code:
http_code = http_code_field
else:
squid_code = raw_result
http_code = http_code_field
# --- hierarchy / peer ----------------------------------------------
# The compound %Sh/%<a lives in the "hier_code" group as
# "HIER_DIRECT/13.107.5.93". Split on "/" to recover both halves.
# If the user only specified %Sh (no compound), the value is the
# bare code and peer stays empty.
raw_hier = g.get("hier_code", "") or ""
if "/" in raw_hier:
hier_code, _, peer = raw_hier.partition("/")
else:
hier_code, peer = raw_hier, ""
# If the user explicitly captured a peer separately (e.g. via %>a
# followed by %<a) prefer that over the compound value.
if g.get("peer") and not peer:
peer = g["peer"]
# --- URL / host -----------------------------------------------------
url = g.get("url", "") or ""
host = _extract_host(url)
# --- ident ----------------------------------------------------------
ident = g.get("ident", "") or ""
if ident == "-":
ident = ""
# --- category -------------------------------------------------------
# Imported lazily to avoid a circular import at module load time.
from log_parser import RESULT_CATEGORIES
category = RESULT_CATEGORIES.get(squid_code, "Other")
# --- size / content_type -------------------------------------------
try:
size = int(g.get("size") or 0)
except ValueError:
size = 0
content_type = (g.get("content_type") or "").strip()
return {
# Mirror log_parser.LogEntry keys exactly
"time": time_str,
"timestamp": dt,
"elapsed_ms": elapsed_ms,
"client": g.get("client", "") or "",
"result": raw_result,
"result_code": squid_code,
"http_code": http_code,
"category": category,
"size": size,
"method": g.get("method", "") or "",
"url": url,
"host": host,
"ident": ident,
"hierarchy": f"{hier_code}/{peer}" if (hier_code or peer) else "",
"hier_code": hier_code,
"peer": peer,
"content_type": content_type,
}
# ---------------------------------------------------------------------------
# Host extraction (mirrors log_parser._extract_host so we behave the same)
# ---------------------------------------------------------------------------
def _extract_host(url: str) -> str:
"""Extract hostname from a Squid log URL.
Mirrors :func:`log_parser._extract_host`. Squid logs URLs in two
forms: ``http(s)://host/path?query`` for normal GET/POST, and
``host:port`` for CONNECT tunnels. ``urlsplit`` chokes on the second
form so we detect it explicitly.
"""
if not url or url == "-":
return ""
if "://" not in url:
if ":" in url:
host, _, _port = url.rpartition(":")
return host.strip()
return url.strip()
try:
h = urlsplit(url).hostname
return h or ""
except Exception:
return ""
# ---------------------------------------------------------------------------
# Convenience: parse multi-line text
# ---------------------------------------------------------------------------
def parse_lines_with_format(text: str, compiled) -> list[dict]:
"""Parse a multi-line log blob. Unparseable lines are silently skipped."""
out = []
for line in text.splitlines():
e = parse_with_format(line, compiled)
if e:
out.append(e)
return out