Files
Hermes Agent 0d413cfeaf fix: 缓存目录保存 404 + 日志时间显示为本地时区 + 持久化同步
修复 4 个相关 bug:

1. **缓存目录配置保存后 404 (核心 bug)**
   - 原因: _show_config_preview 把 endpoint 名 'config_cache' 存进 session 作
     为 return_to,config_preview 把它当 URL 跳到 'config_cache' 报 404
   - 修法: _show_config_preview 自动用 url_for() 解析 endpoint 名

2. **日志时间显示 UTC 而不是本地时间 (次生 bug)**
   - 原因: log_parser 把 timestamp 解析成 UTC datetime,模板直接
     e.timestamp.strftime('%H:%M:%S') 显示 UTC
   - 修法: LogEntry 新增 timestamp_local 属性 (astimezone()),
     3 个模板改用 e.timestamp_local

3. **DB 同步永远不工作 (次生 bug)**
   - 原因: _should_auto_sync 调 get_config('log_sync_interval') ->
     _resolve_active_instance -> session.get 在 app_context 里炸,
     try/except 静默吞,永远返回 False
   - 修法: 新增 _resolve_access_log_path() 不走 session 链,
     _should_auto_sync 直接查 AppConfig 表

4. **default_struct 规则引用 Safe_ports/SSL_ports 但 ACL 是 safeports/sslports**
   - 大小写不一致导致 squid -k parse 报 FATAL
   - 修法: 改成小写统一

测试: cache save 流程全链路跑通 (POST -> preview -> confirm -> /config/cache 200)
所有 config 页面 (main/acls/rules/cache/refresh/auth/peers) 的 preview+confirm
return_to 字段都正确解析为 URL
2026-07-16 15:08:18 +08:00

707 lines
24 KiB
Python

"""Squid configuration (squid.conf) parser and generator.
Strategy:
- Parse the existing squid.conf into a list of directives with comments
- Expose structured getters for sections (network, cache, ACLs, rules, auth, ...)
- Allow web UI to update specific directives
- Generate a complete, well-formed squid.conf from the structured config
- Validate before applying (run `squid -k parse` if available)
Directive model:
- Each directive is a tuple (key, [args...], comment)
- Directives are grouped into named sections for the UI
Note: Squid supports many directives. We cover the most common ones used in
production deployments. A "raw editor" tab is always available for anything we
don't model explicitly.
"""
from __future__ import annotations
import os
import re
import subprocess
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class Directive:
"""A single squid.conf directive line, possibly with a comment."""
key: str
args: list[str]
comment: str = ""
# raw original line (for round-trip preservation)
raw: str = ""
def render(self) -> str:
if self.raw and not self.args and not self.comment:
return self.raw
line = self.key
if self.args:
line += " " + " ".join(self.args)
if self.comment:
line += " #" + self.comment
return line
@dataclass
class Acl:
"""An ACL entry: acl <name> <type> <value>..."""
name: str
type: str
values: list[str]
comment: str = ""
def render(self) -> str:
parts = ["acl", self.name, self.type] + self.values
line = " ".join(parts)
if self.comment:
line += " #" + self.comment
return line
@dataclass
class Rule:
"""An access rule: http_access <allow|deny> [!]acl..."""
action: str # allow / deny
acls: list[str] # e.g. ["!localhost", "allowed_net"]
comment: str = ""
directive: str = "http_access" # http_access, https_access, icp_access, ...
def render(self) -> str:
parts = [self.directive, self.action] + self.acls
line = " ".join(parts)
if self.comment:
line += " #" + self.comment
return line
@dataclass
class CacheDir:
"""A cache_dir directive: cache_dir <type> <path> <size> <L1> <L2> [options]"""
type: str = "ufs"
path: str = "/var/spool/squid"
size_mb: int = 100
l1: int = 16
l2: int = 256
options: list[str] = field(default_factory=list)
def render(self) -> str:
parts = ["cache_dir", self.type, self.path, str(self.size_mb), str(self.l1), str(self.l2)]
parts.extend(self.options)
return " ".join(parts)
@dataclass
class RefreshPattern:
"""refresh_pattern [-i] regex min percent max [{options}]"""
case_insensitive: bool = False
regex: str = "."
min_minutes: int = 0
percent: int = 20
max_minutes: int = 4320
options: list[str] = field(default_factory=list)
def render(self) -> str:
parts = ["refresh_pattern"]
if self.case_insensitive:
parts.append("-i")
parts.extend([self.regex, str(self.min_minutes), f"{self.percent}%", str(self.max_minutes)])
if self.options:
parts.append("{" + " ".join(self.options) + "}")
return " ".join(parts)
@dataclass
class AuthParam:
"""auth_param <scheme> <param> <value>..."""
scheme: str = "basic"
program: str = "/usr/lib/squid/basic_ncsa_auth /etc/squid/passwd"
children: int = 5
realm: str = "Squid Proxy"
credentialsttl: int = 2
# also stored as raw param dict for other schemes
extra: dict = field(default_factory=dict)
def render_all(self) -> list[str]:
lines = []
lines.append(f"auth_param {self.scheme} program {self.program}")
lines.append(f"auth_param {self.scheme} children {self.children}")
lines.append(f"auth_param {self.scheme} realm {self.realm}")
lines.append(f"auth_param {self.scheme} credentialsttl {self.credentialsttl} hours")
for k, v in self.extra.items():
lines.append(f"auth_param {self.scheme} {k} {v}")
return lines
@dataclass
class CachePeer:
"""cache_peer <host> <type> <http_port> <icp_port> [options]"""
host: str = ""
type: str = "parent"
http_port: int = 3128
icp_port: int = 0
options: list[str] = field(default_factory=list)
def render(self) -> str:
parts = ["cache_peer", self.host, self.type, str(self.http_port), str(self.icp_port)]
parts.extend(self.options)
return " ".join(parts)
@dataclass
class SslBumpRule:
"""ssl_bump directive: ssl_bump <action> [!]acl ...
action: peek, splice, bump, terminate, client-first, server-first, none
"""
action: str = "peek"
acls: list[str] = field(default_factory=list) # ACL names + optional ! negation
step: int = 1 # rule order
comment: str = ""
def render(self) -> str:
parts = ["ssl_bump", self.action] + self.acls
line = " ".join(parts)
if self.comment:
line += " #" + self.comment
return line
# ---------------------------------------------------------------------------
# Parsing
# ---------------------------------------------------------------------------
def parse_conf(text: str):
"""Parse a squid.conf string into structured groups.
Returns dict with keys: directives (raw), acls, rules, cache_dirs,
refresh_patterns, auth_params, cache_peers, ssl_bump_rules.
"""
acls = []
rules = []
cache_dirs = []
refresh_patterns = []
cache_peers = []
auth_lines = [] # raw auth_param lines, parsed into AuthParam later
ssl_bump_rules = [] # ssl_bump directives
# map of directive -> list of (args, comment) for simple single-value directives
simple: dict[str, list[tuple[list[str], str]]] = {}
for raw in text.splitlines():
line = raw.strip()
if not line:
continue
if line.startswith("#"):
continue
# split off trailing comment
comment = ""
if " #" in line:
line, _, comment = line.partition(" #")
line = line.rstrip()
comment = comment.strip()
tokens = line.split()
if not tokens:
continue
key = tokens[0]
args = tokens[1:]
if key == "acl":
if len(args) >= 3:
acls.append(Acl(
name=args[0], type=args[1], values=args[2:],
comment=comment,
))
continue
if key in ("http_access", "http_access2", "https_access",
"icp_access", "htcp_access", "miss_access",
"snmp_access", "ident_lookup_access",
"always_direct", "never_direct",
"reply_body_max_size", "reply_access"):
if args:
rules.append(Rule(
action=args[0], acls=args[1:], comment=comment,
directive=key,
))
continue
if key == "cache_dir":
if len(args) >= 5:
cache_dirs.append(CacheDir(
type=args[0], path=args[1],
size_mb=int(args[2]) if args[2].isdigit() else 100,
l1=int(args[3]) if args[3].isdigit() else 16,
l2=int(args[4]) if args[4].isdigit() else 256,
options=args[5:],
))
continue
if key == "refresh_pattern":
rp = RefreshPattern()
idx = 0
if idx < len(args) and args[idx] == "-i":
rp.case_insensitive = True
idx += 1
if idx < len(args):
rp.regex = args[idx]; idx += 1
if idx < len(args) and args[idx].lstrip("-").isdigit():
rp.min_minutes = int(args[idx]); idx += 1
if idx < len(args) and args[idx].endswith("%"):
try:
rp.percent = int(args[idx].rstrip("%"))
except ValueError:
pass
idx += 1
if idx < len(args) and args[idx].lstrip("-").isdigit():
rp.max_minutes = int(args[idx]); idx += 1
rp.options = args[idx:]
refresh_patterns.append(rp)
continue
if key == "cache_peer":
if len(args) >= 4:
cache_peers.append(CachePeer(
host=args[0], type=args[1],
http_port=int(args[2]) if args[2].isdigit() else 3128,
icp_port=int(args[3]) if args[3].isdigit() else 0,
options=args[4:],
))
continue
if key == "ssl_bump":
# ssl_bump <action> [!]acl...
if args:
action = args[0]
acls_for_rule = args[1:]
step = len(ssl_bump_rules) + 1
ssl_bump_rules.append(SslBumpRule(
action=action,
acls=acls_for_rule,
step=step,
comment=comment,
))
continue
if key == "auth_param":
auth_lines.append((args, comment))
continue
simple.setdefault(key, []).append((args, comment))
# parse auth_params into structured form (one per scheme)
auth_params = parse_auth_lines(auth_lines)
return {
"acls": acls,
"rules": rules,
"cache_dirs": cache_dirs,
"refresh_patterns": refresh_patterns,
"cache_peers": cache_peers,
"auth_params": auth_params,
"ssl_bump_rules": ssl_bump_rules,
"simple": simple, # raw simple directives, list of (args, comment)
}
def parse_auth_lines(lines: list[tuple[list[str], str]]) -> list[AuthParam]:
"""Group auth_param lines by scheme."""
by_scheme: dict[str, AuthParam] = {}
for args, comment in lines:
if len(args) < 2:
continue
scheme = args[0]
param = args[1]
value = " ".join(args[2:])
if scheme not in by_scheme:
ap = AuthParam(scheme=scheme)
by_scheme[scheme] = ap
ap = by_scheme[scheme]
if param == "program":
ap.program = value
elif param == "children":
try:
ap.children = int(args[2])
except (ValueError, IndexError):
pass
elif param == "realm":
ap.realm = value
elif param == "credentialsttl":
try:
ap.credentialsttl = int(args[2])
except (ValueError, IndexError):
pass
else:
ap.extra[param] = value
return list(by_scheme.values())
# ---------------------------------------------------------------------------
# Generation
# ---------------------------------------------------------------------------
# default simple directives the UI manages explicitly.
DEFAULT_SIMPLE = {
# network
"http_port": [["3128"], ""],
"https_port": [],
"icp_port": [["3130"], ""],
"snmp_port": [],
"visible_hostname": [["localhost"], ""],
"unique_hostname": [],
"hostname_aliases": [],
# cache
"cache_mem": [["256", "MB"], ""],
"maximum_object_size": [["4096", "KB"], ""],
"minimum_object_size": [["0", "KB"], ""],
"maximum_object_size_in_memory": [["512", "KB"], ""],
"cache_replacement_policy": [["lru"], ""],
"memory_replacement_policy": [["lru"], ""],
"cache_swap_low": [["90"], ""],
"cache_swap_high": [["95"], ""],
# logging
"access_log": [["/var/log/squid/access.log"], ""],
"cache_log": [["/var/log/squid/cache.log"], ""],
"cache_store_log": [["/var/log/squid/store.log"], ""],
"pid_filename": [["/var/run/squid.pid"], ""],
"logformat": [],
"debug_options": [],
"log_ip_on_direct": [["on"], ""],
"buffered_logs": [["on"], ""],
# dns
"dns_nameservers": [],
"dns_timeout": [["30", "seconds"], ""],
"hosts_file": [["/etc/hosts"], ""],
"dns_defnames": [["off"], ""],
# SSL / TLS (for ssl-bump)
"sslcrtd_program": [["security_file_certgen", "-s", "/var/lib/squid/ssl_db", "-M", "4", "MB"], ""],
"sslcrtd_children": [["5", "startup=1", "idle=1"], ""],
"sslproxy_ca_certfile": [],
"sslproxy_ca_keyfile": [],
"tls_outgoing_version": [],
# timeouts
"connect_timeout": [["60", "seconds"], ""],
"read_timeout": [["15", "minutes"], ""],
"client_lifetime": [["1", "day"], ""],
"shutdown_lifetime": [["30", "seconds"], ""],
# admin
"cache_mgr": [["root"], ""],
"err_html_language": [["en"], ""],
"ftp_user": [],
}
def generate_conf(struct: dict, raw_extra: str = "") -> str:
"""Generate a complete squid.conf string from the structured config."""
lines: list[str] = []
lines.append("# ============================================================")
lines.append("# squid.conf - managed by Squid Web Manager")
lines.append("# WARNING: this file is regenerated by the web UI.")
lines.append("# Manual edits may be lost - use the Raw Editor tab")
lines.append("# if you need to add directives not covered by the UI.")
lines.append("# ============================================================")
lines.append("")
# --- Network ---
lines.append("# ---- Network ----")
for key in ("http_port", "https_port", "icp_port", "snmp_port",
"visible_hostname", "unique_hostname", "hostname_aliases"):
for args, comment in struct["simple"].get(key, []):
lines.append(_render_simple(key, args, comment))
lines.append("")
# --- Cache ---
lines.append("# ---- Cache ----")
for key in ("cache_mem", "maximum_object_size", "minimum_object_size",
"maximum_object_size_in_memory",
"cache_replacement_policy", "memory_replacement_policy",
"cache_swap_low", "cache_swap_high"):
for args, comment in struct["simple"].get(key, []):
lines.append(_render_simple(key, args, comment))
for cd in struct["cache_dirs"]:
lines.append(cd.render())
lines.append("")
# --- Auth ---
if struct["auth_params"]:
lines.append("# ---- Authentication ----")
for ap in struct["auth_params"]:
for ln in ap.render_all():
lines.append(ln)
lines.append("")
# --- ACLs ---
lines.append("# ---- ACLs ----")
for acl in struct["acls"]:
lines.append(acl.render())
lines.append("")
# --- Refresh patterns ---
if struct["refresh_patterns"]:
lines.append("# ---- Refresh patterns ----")
for rp in struct["refresh_patterns"]:
lines.append(rp.render())
lines.append("")
# --- Cache peers ---
if struct["cache_peers"]:
lines.append("# ---- Cache peers ----")
for cp in struct["cache_peers"]:
lines.append(cp.render())
lines.append("")
# --- Access rules ---
lines.append("# ---- Access rules ----")
for r in struct["rules"]:
lines.append(r.render())
lines.append("")
# --- Logging ---
lines.append("# ---- Logging ----")
for key in ("access_log", "cache_log", "cache_store_log",
"pid_filename", "logformat", "debug_options",
"log_ip_on_direct", "buffered_logs"):
for args, comment in struct["simple"].get(key, []):
lines.append(_render_simple(key, args, comment))
lines.append("")
# --- DNS ---
lines.append("# ---- DNS ----")
for key in ("dns_nameservers", "dns_timeout", "hosts_file", "dns_defnames"):
for args, comment in struct["simple"].get(key, []):
lines.append(_render_simple(key, args, comment))
lines.append("")
# --- SSL/TLS ---
ssl_keys = ("sslcrtd_program", "sslcrtd_children", "sslproxy_ca_certfile",
"sslproxy_ca_keyfile", "tls_outgoing_version")
if any(struct["simple"].get(k) for k in ssl_keys) or struct.get("ssl_bump_rules"):
lines.append("# ---- SSL/TLS ----")
for key in ssl_keys:
for args, comment in struct["simple"].get(key, []):
lines.append(_render_simple(key, args, comment))
for rule in struct.get("ssl_bump_rules", []):
lines.append(rule.render())
lines.append("")
# --- Timeouts ---
lines.append("# ---- Timeouts ----")
for key in ("connect_timeout", "read_timeout", "client_lifetime",
"shutdown_lifetime"):
for args, comment in struct["simple"].get(key, []):
lines.append(_render_simple(key, args, comment))
lines.append("")
# --- Admin ---
lines.append("# ---- Admin ----")
for key in ("cache_mgr", "err_html_language", "ftp_user"):
for args, comment in struct["simple"].get(key, []):
lines.append(_render_simple(key, args, comment))
lines.append("")
# --- raw extra directives ---
if raw_extra.strip():
lines.append("# ---- Raw directives (manual) ----")
for ln in raw_extra.splitlines():
if ln.strip():
lines.append(ln.strip())
lines.append("")
return "\n".join(lines) + "\n"
def _render_simple(key: str, args: list[str], comment: str = "") -> str:
line = key + (" " + " ".join(args) if args else "")
if comment:
line += " #" + comment
return line
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
def validate_conf(text: str, squid_binary: str = "squid") -> tuple[bool, str]:
"""Run `squid -k parse` on the given config text.
Returns (ok, message). Falls back to syntax-only check if squid isn't
installed.
"""
if not squid_binary or not _which(squid_binary):
# no squid binary available - just check that we can parse it
try:
parse_conf(text)
return True, "Squid binary not found - parsed structure successfully " \
"(skipped runtime syntax check)."
except Exception as e:
return False, f"Parse error: {e}"
tmp_path = "/tmp/squid_conf_check.conf"
try:
with open(tmp_path, "w") as f:
f.write(text)
except OSError as e:
return False, f"Cannot write temp file: {e}"
try:
r = subprocess.run(
[squid_binary, "-k", "parse", "-f", tmp_path],
capture_output=True, text=True, timeout=10,
)
except subprocess.TimeoutExpired:
return False, "squid -k parse timed out"
except FileNotFoundError:
return False, "squid binary not found"
# squid -k parse exits 0 on success. Errors are written to stdout.
out = (r.stdout or "").strip()
err = (r.stderr or "").strip()
msg = out + ("\n" + err if err else "")
# squid typically prints "WARNING:" lines for non-fatal issues.
if r.returncode == 0:
return True, msg or "Configuration OK"
return False, msg or f"squid -k parse failed (exit {r.returncode})"
def _which(cmd: str) -> Optional[str]:
"""Lightweight which() that doesn't depend on shutil.which."""
for path in os.environ.get("PATH", "").split(":"):
if not path:
continue
candidate = os.path.join(path, cmd)
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
return None
# ---------------------------------------------------------------------------
# Defaults (used to bootstrap a brand new install)
# ---------------------------------------------------------------------------
def default_struct() -> dict:
"""Return a default structured config for a fresh install."""
simple = {k: [v] if isinstance(v, list) and v and isinstance(v[0], list)
else (v if isinstance(v, list) else [])
for k, v in DEFAULT_SIMPLE.items()}
# DEFAULT_SIMPLE values are already lists of [args, comment]
# but they are stored as [args_list, comment] tuples - normalize.
simple = {}
for k, v in DEFAULT_SIMPLE.items():
# v is [[args...], comment] or [] (empty)
if isinstance(v, list) and len(v) == 2 and isinstance(v[0], list):
simple[k] = [(v[0], v[1])]
else:
simple[k] = []
# Provide a sensible default sslcrtd_program bootstrap so freshly-deployed
# SSL Bump setups have a working cert generator out of the box.
# (Empty list would render "sslcrtd_program" with no args - leave it.)
# Default is intentionally commented out in DEFAULT_SIMPLE above
# (sslcrtd_program: []). Users override via the SSL Bump UI.
return {
"acls": [
Acl(name="localhost", type="src", values=["127.0.0.1/32"]),
Acl(name="to_localhost", type="dst", values=["127.0.0.0/8"]),
Acl(name="safeports", type="port", values=["80", "443", "21", "70", "80-1024"]),
Acl(name="sslports", type="port", values=["443", "563"]),
Acl(name="CONNECT", type="method", values=["CONNECT"]),
Acl(name="all", type="src", values=["0.0.0.0/0"]),
Acl(name="manager", type="proto", values=["cache_object"]),
],
"rules": [
Rule(action="deny", acls=["manager"], directive="http_access"),
Rule(action="deny", acls=["!safeports"], directive="http_access"),
Rule(action="deny", acls=["!sslports"], directive="http_access"),
Rule(action="allow", acls=["localhost"], directive="http_access"),
Rule(action="deny", acls=["all"], directive="http_access"),
],
"cache_dirs": [
CacheDir(type="ufs", path="/var/spool/squid",
size_mb=100, l1=16, l2=256)
],
"refresh_patterns": [
RefreshPattern(regex="^ftp:", min_minutes=1440, percent=20, max_minutes=10080),
RefreshPattern(regex="^gopher:", min_minutes=1440, percent=0, max_minutes=1440),
RefreshPattern(case_insensitive=True, regex=" cgi-bin",
min_minutes=0, percent=0, max_minutes=0),
RefreshPattern(regex=".", min_minutes=0, percent=20, max_minutes=4320),
],
"cache_peers": [],
"auth_params": [],
"ssl_bump_rules": [],
"simple": simple,
}
ACL_TYPES = [
("src", "Source IP/CIDR (src 192.168.1.0/24)"),
("dst", "Destination IP/CIDR"),
("dstdomain", "Destination domain (matches *.example.com)"),
("dstdom_regex", "Destination domain regex"),
("srcdomain", "Source domain"),
("srcdom_regex", "Source domain regex"),
("port", "Destination port"),
("method", "HTTP method (GET, POST, CONNECT, ...)"),
("proto", "Protocol (http, https, ftp, ...)"),
("time", "Time of day/week (SMTWHFA/HH:MM-HH:MM)"),
("url_regex", "URL regex"),
("urlpath_regex", "URL path regex"),
("url_login", "URL login part regex"),
("browser", "User-Agent regex"),
("referer_regex", "Referer regex"),
("ident", "User ident string"),
("ident_regex", "User ident regex"),
("proxy_auth", "Authenticated user / REQUIRED"),
("proxy_auth_regex", "Authenticated user regex"),
("maxconn", "Max connections per client"),
("max_user_ip", "Max IPs per user"),
("rep_mime_type", "Reply MIME type regex"),
("req_header", "Request header"),
("rep_header", "Reply header"),
("localip", "Local IP"),
("localnet", "Local network"),
("peername", "Peer name"),
("hierarchy_type", "Hierarchy type"),
]
RULE_DIRECTIVES = [
"http_access",
"http_access2",
"https_access",
"icp_access",
"htcp_access",
"miss_access",
"snmp_access",
"ident_lookup_access",
"reply_access",
"adaptation_access",
]
# Valid ssl_bump actions (Squid docs - https://www.squid-cache.org/Doc/config/ssl_bump/)
SSL_BUMP_ACTIONS = [
"peek",
"splice",
"bump",
"terminate",
"client-first",
"server-first",
"none",
]
SSL_BUMP_DEFAULT_EXAMPLE = (
"# Suggested starter set (rule order matters - top to bottom):\n"
"# ssl_bump peek step1 # peek at TLS client hello (step 1)\n"
"# ssl_bump splice all # otherwise pass-through tunnel\n"
"#\n"
"# Required prereqs before SSL Bump will work:\n"
"# 1) sslcrtd_program/children pointing at security_file_certgen\n"
"# and an initialised ssl_db directory\n"
"# 2) sslproxy_ca_certfile / sslproxy_ca_keyfile pointing at a CA\n"
"# that your client browsers trust (or you distribute the cert\n"
"# and instruct users to install it)\n"
"# 3) https_port configured with ssl-bump option"
)