Files
squid-manager/ssl_certs.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

496 lines
16 KiB
Python

"""TLS / HTTPS certificate management for Squid Web Manager.
Self-contained helper around the system ``openssl`` binary. We deliberately
avoid Python ``cryptography`` / ``pyOpenSSL`` to keep the dependency surface
small; everything goes through ``subprocess``.
Public API (all return a tuple ``(ok: bool, message: str, data: dict)``
on failure / success paths so callers can ``flash()`` directly):
- ``is_openssl_available()`` -> ``(ok, msg, data)`` with ``data["openssl"]`` path
- ``generate_self_signed(common_name, days, cert_dir)``
- ``parse_pem_info(cert_path)``
- ``list_certificates(cert_dir)``
- ``upload_certificate(uploaded_file, cert_dir)``
- ``delete_certificate(name, cert_dir)``
Security notes:
- File names are sanitised; ``..`` and path separators are rejected.
- Uploads are limited to 1 MiB and only ``.pem`` / ``.crt`` / ``.key`` accepted.
- Self-signed filenames embed CN + date + short hash, no spaces.
- Audit callers MUST NOT pass private-key material; only metadata + hash.
"""
from __future__ import annotations
import hashlib
import os
import re
import shutil
import subprocess
from datetime import datetime, timezone
# --- Limits / allowed extensions --------------------------------------------
MAX_UPLOAD_BYTES = 1 * 1024 * 1024 # 1 MiB
ALLOWED_EXTENSIONS = (".pem", ".crt", ".key")
SAFE_NAME_RE = re.compile(r"^[A-Za-z0-9_.\-]+$")
# Allow CN to contain dots/underscores but normalise whitespace to underscores.
CN_SAFE_RE = re.compile(r"[^A-Za-z0-9._-]")
# --- helpers ----------------------------------------------------------------
def _err(message: str, **extra) -> tuple[bool, str, dict]:
data = {"error": message}
data.update(extra)
return False, message, data
def _ok(message: str = "OK", **extra) -> tuple[bool, str, dict]:
data = {"ok": True}
data.update(extra)
return True, message, data
def _short_hash(data: bytes) -> str:
"""Return first 8 hex chars of SHA-256 for filename uniqueness."""
return hashlib.sha256(data).hexdigest()[:8]
def _safe_filename(name: str) -> str:
"""Strip directory components and reject anything non-ASCII-clean."""
if not name:
return ""
base = os.path.basename(name)
if base != name:
return ""
if not SAFE_NAME_RE.match(base):
return ""
return base
def _ensure_dir(cert_dir: str) -> tuple[bool, str, dict]:
"""Make sure ``cert_dir`` exists and is writable."""
if not cert_dir:
return _err("cert_dir 不能为空")
try:
os.makedirs(cert_dir, exist_ok=True)
except OSError as e:
return _err(f"无法创建目录 {cert_dir}: {e}")
if not os.path.isdir(cert_dir):
return _err(f"{cert_dir} 不是目录")
return _ok("dir-ok", dir=cert_dir)
def _safe_join(cert_dir: str, name: str) -> tuple[str | None, str | None]:
"""Join ``cert_dir`` + ``name`` and reject path traversal."""
clean = _safe_filename(name)
if not clean:
return None, "文件名包含非法字符 (只允许字母/数字/_-.)"
full = os.path.abspath(os.path.join(cert_dir, clean))
base = os.path.abspath(cert_dir)
# Use os.path.commonpath for traversal rejection.
try:
if os.path.commonpath([full, base]) != base:
return None, "不允许的路径"
except ValueError:
return None, "不允许的路径"
return full, None
# --- public API --------------------------------------------------------------
def is_openssl_available() -> tuple[bool, str, dict]:
"""Return (ok, message, data) where data["openssl"] is the binary path."""
path = shutil.which("openssl")
if not path:
return _err("系统未安装 openssl 或不在 PATH 中")
try:
r = subprocess.run(
[path, "version"], capture_output=True, text=True, timeout=5
)
if r.returncode != 0:
return _err(f"openssl 调用失败: {(r.stderr or '').strip()}")
version = (r.stdout or "").strip()
except (subprocess.TimeoutExpired, OSError) as e:
return _err(f"无法执行 openssl: {e}")
return _ok("openssl 可用", openssl=path, version=version)
def generate_self_signed(
common_name: str, days: int = 365, cert_dir: str = ""
) -> tuple[bool, str, dict]:
"""Generate a self-signed PEM cert+key with combined contents.
Output filename: ``{CN}_{YYYYMMDD}_{short_hash8}.pem`` containing both
certificate and private key sections, parsable by OpenSSL directly.
"""
available_ok, _, avail_data = is_openssl_available()
if not available_ok:
return _err(avail_data.get("error", "openssl 不可用"))
openssl = avail_data["openssl"]
ensured_ok, _, ensured_data = _ensure_dir(cert_dir)
if not ensured_ok:
return False, ensured_data["error"], ensured_data
cn = (common_name or "").strip()
if not cn:
return _err("Common Name (CN) 不能为空")
# File-system safe CN for filename use only; the certificate itself
# preserves the original CN via ``-subj``.
safe_cn = CN_SAFE_RE.sub("_", cn).strip("._-")
if not safe_cn:
return _err("CN 经清洗后为空,仅含非法字符")
if len(safe_cn) > 64:
safe_cn = safe_cn[:64].rstrip("._-")
try:
days_i = int(days)
except (TypeError, ValueError):
return _err("days 必须是整数")
if days_i < 1 or days_i > 3650:
return _err("days 必须在 1 ~ 3650 之间")
date_part = datetime.now().strftime("%Y%m%d")
# 8-char random hash ensures repeated runs with same CN don't collide.
rand_hash = hashlib.sha256(
f"{cn}|{datetime.now().timestamp()}|{os.urandom(8).hex()}".encode()
).hexdigest()[:8]
out_name = f"{safe_cn}_{date_part}_{rand_hash}.pem"
out_path = os.path.join(cert_dir, out_name)
# Escape the CN for the -subj argument: /CN=something
# Allow only printable ASCII in subject to avoid openssl quoting issues.
subj_cn = re.sub(r"[^\x20-\x7e]", "_", cn)
if "/" in subj_cn:
# '/' starts a new RDN component in -subj; replace with '_'
subj_cn = subj_cn.replace("/", "_")
subject = f"/CN={subj_cn}"
cmd = [
openssl, "req", "-x509", "-newkey", "rsa:2048",
"-keyout", out_path,
"-out", out_path,
"-days", str(days_i),
"-nodes",
"-subj", subject,
]
try:
r = subprocess.run(
cmd, capture_output=True, text=True, timeout=30
)
except subprocess.TimeoutExpired:
# best-effort cleanup of half-written file
if os.path.exists(out_path):
try:
os.remove(out_path)
except OSError:
pass
return _err("openssl 生成超时 (>30s)")
except OSError as e:
return _err(f"执行 openssl 失败: {e}")
if r.returncode != 0:
# clean any half-written file
if os.path.exists(out_path):
try:
os.remove(out_path)
except OSError:
pass
err = (r.stderr or r.stdout or "").strip()
return _err(f"openssl 失败: {err[:400]}")
if not os.path.isfile(out_path):
return _err(f"openssl 未生成文件: {out_path}")
# Validate the cert parses back.
parse_ok, parse_msg, parse_data = parse_pem_info(out_path)
if not parse_ok:
return False, f"生成的证书无法解析: {parse_msg}", parse_data
# Audit-friendly fingerprint of the cert portion only (no private key).
fingerprint = parse_data.get("sha256", "")
return _ok(
f"已生成自签名证书: {out_name}",
name=out_name,
path=out_path,
cn=parse_data.get("cn", cn),
days=days_i,
expires=parse_data.get("not_after", ""),
fingerprint=fingerprint,
size=os.path.getsize(out_path),
)
def parse_pem_info(cert_path: str) -> tuple[bool, str, dict]:
"""Parse a PEM file and return subject CN, validity, sha256 fingerprint.
If the file is not a recognised PEM certificate (e.g. a private key)
``ok`` is False and the data dict explains why.
"""
if not cert_path or not os.path.isfile(cert_path):
return _err(f"找不到证书: {cert_path}")
# Subject (CN)
cn = ""
try:
r = subprocess.run(
["openssl", "x509", "-noout", "-subject", "-nameopt", "RFC2253",
"-in", cert_path],
capture_output=True, text=True, timeout=10,
)
if r.returncode != 0:
return _err(
"openssl 无法解析 (文件可能不是证书, 或包含私钥): "
+ (r.stderr or "").strip()[:200]
)
# subject= /CN=foo
subj_line = (r.stdout or "").strip()
m = re.search(r"CN\s*=\s*([^,/\n]+)", subj_line)
if m:
cn = m.group(1).strip()
except (subprocess.TimeoutExpired, OSError) as e:
return _err(f"openssl x509 调用失败: {e}")
# Validity: notBefore / notAfter
not_before = ""
not_after = ""
try:
r = subprocess.run(
["openssl", "x509", "-noout", "-dates", "-in", cert_path],
capture_output=True, text=True, timeout=10,
)
if r.returncode == 0:
for line in (r.stdout or "").splitlines():
if line.startswith("notBefore="):
not_before = line.split("=", 1)[1].strip()
elif line.startswith("notAfter="):
not_after = line.split("=", 1)[1].strip()
except (subprocess.TimeoutExpired, OSError):
pass
# Serial
serial = ""
try:
r = subprocess.run(
["openssl", "x509", "-noout", "-serial", "-in", cert_path],
capture_output=True, text=True, timeout=10,
)
if r.returncode == 0:
m = re.search(r"=([0-9A-Fa-f]+)", r.stdout or "")
if m:
serial = m.group(1)
except (subprocess.TimeoutExpired, OSError):
pass
# SHA-256 fingerprint
sha256 = ""
try:
r = subprocess.run(
["openssl", "x509", "-noout", "-fingerprint", "-sha256",
"-in", cert_path],
capture_output=True, text=True, timeout=10,
)
if r.returncode == 0:
# output e.g. "sha256 Fingerprint=AA:BB:..."
line = (r.stdout or "").strip()
if "=" in line:
sha256 = line.split("=", 1)[1].replace(":", "").lower()
except (subprocess.TimeoutExpired, OSError):
pass
days_remaining = None
expires_iso = ""
status = "unknown"
if not_after:
try:
# notAfter format: e.g. "Jun 14 12:00:00 2026 GMT"
dt = datetime.strptime(not_after, "%b %d %H:%M:%S %Y %Z")
dt = dt.replace(tzinfo=timezone.utc)
expires_iso = dt.strftime("%Y-%m-%dT%H:%M:%SZ")
now = datetime.now(timezone.utc)
days_remaining = int((dt - now).total_seconds() // 86400)
if days_remaining < 0:
status = "expired"
elif days_remaining < 30:
status = "expiring"
else:
status = "valid"
except ValueError:
status = "unknown"
return _ok(
"parsed",
cn=cn,
not_before=not_before,
not_after=not_after,
expires_iso=expires_iso,
days_remaining=days_remaining,
status=status,
serial=serial,
sha256=sha256,
path=cert_path,
)
def list_certificates(cert_dir: str) -> tuple[bool, str, list]:
"""List certificates in ``cert_dir``. Returns plain list on success,
not the (ok, msg, data) tuple — for easier template iteration.
Returns a list of dicts with all fields; non-certificate files are skipped.
"""
ensured_ok, _, _ = _ensure_dir(cert_dir)
if not ensured_ok:
return False, "目录不可访问", []
items: list[dict] = []
try:
names = sorted(os.listdir(cert_dir))
except OSError as e:
return False, f"无法列出目录: {e}", []
for n in names:
full = os.path.join(cert_dir, n)
if not os.path.isfile(full):
continue
# only show files with allowed extensions
ext = os.path.splitext(n)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
continue
st = os.stat(full)
try:
ok, msg, data = parse_pem_info(full)
except Exception:
ok, msg, data = False, "解析异常", {}
items.append({
"name": n,
"path": full,
"size": st.st_size,
"mtime": st.st_mtime,
"ext": ext,
"is_key": (ext == ".key"),
"parsed_ok": ok,
"parse_error": "" if ok else msg,
"cn": data.get("cn", ""),
"not_after": data.get("not_after", ""),
"expires_iso": data.get("expires_iso", ""),
"days_remaining": data.get("days_remaining"),
"status": data.get("status", "unknown"),
"serial": data.get("serial", ""),
"sha256": data.get("sha256", ""),
})
return True, "OK", items
def upload_certificate(
uploaded_file, cert_dir: str
) -> tuple[bool, str, dict]:
"""Accept a Werkzeug FileStorage (or any object with .filename + .read)
and save it into ``cert_dir``.
Limits: 1 MiB, allowed extensions: .pem / .crt / .key.
"""
if not uploaded_file:
return _err("未提供上传文件")
# Many test/CLI paths pass a path string or None.
filename = getattr(uploaded_file, "filename", "") or ""
name = _safe_filename(filename)
if not name:
return _err("文件名不合法 (不允许 / 或 .. 或特殊字符)")
ext = os.path.splitext(name)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
return _err(
f"不支持的扩展名 '{ext}', 仅接受 {', '.join(ALLOWED_EXTENSIONS)}"
)
full, err = _safe_join(cert_dir, name)
if err or full is None:
return _err(err or "路径不安全")
# Read bytes (size cap).
try:
if hasattr(uploaded_file, "read"):
data = uploaded_file.read()
elif isinstance(uploaded_file, (bytes, bytearray)):
data = bytes(uploaded_file)
elif isinstance(uploaded_file, str) and os.path.isfile(uploaded_file):
with open(uploaded_file, "rb") as fh:
data = fh.read()
else:
return _err("无法读取上传文件内容")
except OSError as e:
return _err(f"读取失败: {e}")
if data is None:
return _err("上传内容为空")
if not isinstance(data, (bytes, bytearray)):
return _err("上传内容必须是二进制")
if len(data) == 0:
return _err("上传内容为空")
if len(data) > MAX_UPLOAD_BYTES:
return _err(
f"文件过大 ({len(data)} 字节), 上限 {MAX_UPLOAD_BYTES} 字节 (1MB)"
)
# Sanity check: PEM must contain BEGIN ... header for certs/keys.
head = data[:120].decode("ascii", errors="replace")
looks_like_pem = "-----BEGIN" in head
if ext in (".pem", ".crt") and not looks_like_pem:
return _err(
"文件内容不像有效 PEM (缺少 '-----BEGIN' 头), 上传被拒绝"
)
# Avoid overwriting existing file silently.
if os.path.exists(full):
return _err(f"目标已存在: {name}, 请先删除或重命名后再上传")
try:
with open(full, "wb") as fh:
fh.write(data)
except OSError as e:
return _err(f"保存失败: {e}")
fingerprint = hashlib.sha256(data).hexdigest().lower()
kind = "key" if ext == ".key" else (
"cert+key" if ext == ".pem" else "cert"
)
return _ok(
f"已上传 {name} ({len(data)} 字节)",
name=name,
path=full,
size=len(data),
sha256=fingerprint,
kind=kind,
ext=ext,
)
def delete_certificate(name: str, cert_dir: str) -> tuple[bool, str, dict]:
"""Delete a single file (cert or key) from ``cert_dir``."""
full, err = _safe_join(cert_dir, name)
if err or full is None:
return _err(err or "路径不安全")
if not os.path.isfile(full):
return _err(f"文件不存在: {name}")
try:
# Compute SHA-256 of content before deletion for audit trail.
with open(full, "rb") as fh:
blob = fh.read()
fingerprint = hashlib.sha256(blob).hexdigest().lower()
os.remove(full)
except OSError as e:
return _err(f"删除失败: {e}")
return _ok(
f"已删除 {name}",
name=name,
sha256=fingerprint,
size=len(blob),
)