Files
squid-manager/app.py
T
Hermes Agent 057d72b264 fix: 上次同步时间转为本地时区显示,修正时区混淆问题
AppConfig 存储 UTC 时间,模板直接输出导致用户在上海(UTC+8)看到错误时刻。
新增 get_last_sync_at_local() 转本地时区,logs_view 采用本地时间显示。
2026-07-16 15:50:03 +08:00

4240 lines
159 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Squid Web Manager - main Flask application.
Provides a web UI for managing squid.conf and analyzing access.log.
Architecture:
- Flask + SQLite for users / app config / audit log
- access.log is parsed on demand (with in-memory cache) - not stored in DB
- Configured via /config page; squid paths default to /etc/squid, /var/log/squid
- Service control via systemctl (squid.service) - graceful when squid missing
"""
from __future__ import annotations
import os
import re
import shlex
import shutil
import socket
import subprocess
import threading
import time
import json
from collections import deque
from datetime import datetime, timezone, timedelta
from functools import wraps
import log_parser
import squid_config
import security
import squid_mgr
import geoip_lookup
from flask import (
Flask, Response, abort, flash, g, jsonify, redirect, render_template,
request, send_from_directory, session, url_for,
)
from flask_sqlalchemy import SQLAlchemy
from werkzeug.security import check_password_hash, generate_password_hash
# ---------------------------------------------------------------------------
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
INSTANCE_DIR = os.path.join(BASE_DIR, "instance")
BACKUP_DIR = os.path.join(BASE_DIR, "backups")
UPLOAD_DIR = os.path.join(BASE_DIR, "uploads")
for d in (INSTANCE_DIR, BACKUP_DIR, UPLOAD_DIR):
os.makedirs(d, exist_ok=True)
app = Flask(__name__, static_folder="static", template_folder="templates")
app.config.update(
SECRET_KEY=os.environ.get("SECRET_KEY", "squid-mgr-change-me-in-prod"),
SQLALCHEMY_DATABASE_URI=f"sqlite:///{os.path.join(INSTANCE_DIR, 'squid_mgr.db')}",
SQLALCHEMY_TRACK_MODIFICATIONS=False,
MAX_CONTENT_LENGTH=20 * 1024 * 1024,
SQUID_BINARY=os.environ.get("SQUID_BINARY", "squid"),
SYSTEMCTL=os.environ.get("SYSTEMCTL", "systemctl"),
# security
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
PERMANENT_SESSION_LIFETIME=28800, # absolute ceiling
SECURITY_SESSION_IDLE_TIMEOUT=int(os.environ.get("SQUIDMGR_IDLE_TO", "1800")),
SECURITY_SESSION_ABSOLUTE_TIMEOUT=int(os.environ.get("SQUIDMGR_ABS_TO", "28800")),
SECURITY_LOGIN_THROTTLE_WINDOW=int(os.environ.get("SQUIDMGR_THROTTLE_WIN", "300")),
SECURITY_LOGIN_THROTTLE_MAX=int(os.environ.get("SQUIDMGR_THROTTLE_MAX", "5")),
SECURITY_LOGIN_THROTTLE_LOCKOUT=int(os.environ.get("SQUIDMGR_THROTTLE_LOCK", "900")),
SECURITY_CSRF_ENABLED=os.environ.get("SQUIDMGR_CSRF", "1") == "1",
)
db = SQLAlchemy(app)
# Wire security: enforce session lifetime + CSRF on every request
@app.before_request
def _security_gate():
security.enforce_session_lifetime()
security.csrf_protect()
# Make CSRF token + security state available to all templates
@app.context_processor
def _security_globals():
return {
# csrf_token as a function (for `{{ csrf_token() }}` calls)
"csrf_token": security.generate_csrf_token,
"password_strength": security.password_strength,
"session_idle_to": app.config["SECURITY_SESSION_IDLE_TIMEOUT"],
"current_username": session.get("username", ""),
"current_user_id": session.get("user_id"),
}
@app.template_global()
def csrf_input():
"""Render <input type=hidden name=_csrf ...> for use in Jinja forms."""
return f'<input type="hidden" name="_csrf" value="{security.generate_csrf_token()}">'
@app.template_global()
def csrf_meta():
"""Render <meta name=csrf-token ...> for AJAX use."""
return f'<meta name="csrf-token" content="{security.generate_csrf_token()}">'
# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------
class User(db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
password_hash = db.Column(db.String(255), nullable=False)
role = db.Column(db.String(20), default="admin")
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
def set_password(self, pw):
# pbkdf2:sha256 to avoid scrypt portability issues (see flask skill)
self.password_hash = generate_password_hash(pw, method="pbkdf2:sha256")
def check_password(self, pw):
try:
return check_password_hash(self.password_hash, pw)
except Exception:
return False
class AppConfig(db.Model):
"""Key-value store for app-level settings (squid paths, log behavior)."""
__tablename__ = "app_config"
id = db.Column(db.Integer, primary_key=True)
key = db.Column(db.String(60), unique=True, nullable=False)
value = db.Column(db.Text, default="")
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
class AuditLog(db.Model):
__tablename__ = "audit_log"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), default="system")
action = db.Column(db.String(120), nullable=False)
detail = db.Column(db.Text, default="")
timestamp = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
index=True)
@property
def timestamp_local(self):
if not self.timestamp:
return ""
# stored UTC, render as UTC for consistency
return self.timestamp.strftime("%Y-%m-%d %H:%M:%S UTC")
class SquidInstance(db.Model):
"""A managed Squid instance (local or remote via SSH).
Multiple rows can coexist; one row is marked is_active=True (or selected
via session['active_instance_id']). All pages (config/*, logs, service)
resolve paths/service/binary through get_config() which prefers the
active instance over the global AppConfig table.
"""
__tablename__ = "squid_instances"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(80), unique=True, nullable=False)
description = db.Column(db.String(200), default="")
squid_conf_path = db.Column(db.String(500), default="/etc/squid/squid.conf")
access_log_path = db.Column(db.String(500), default="/var/log/squid/access.log")
cache_log_path = db.Column(db.String(500), default="/var/log/squid/cache.log")
squid_binary = db.Column(db.String(100), default="squid")
systemctl = db.Column(db.String(100), default="systemctl")
squid_service = db.Column(db.String(100), default="squid")
ssh_host = db.Column(db.String(200), default="")
ssh_user = db.Column(db.String(80), default="")
ssh_port = db.Column(db.Integer, default=22)
ssh_key_path = db.Column(db.String(500), default="")
is_local = db.Column(db.Boolean, default=True)
is_active = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
class LogEntry(db.Model):
"""A parsed squid access.log line, persisted to SQLite.
Rows are written by :mod:`log_storage` (via :func:`sync_log_to_db`) and
read by the logs UI, statistics page, alerts, anomaly detection and the
real-time dashboard. Indexes are tuned for the common access patterns:
- single-column indexes on ``log_time``, ``client``, ``result_code``,
``method``, ``host`` to keep filter-by-X queries fast.
- composite ``(log_time, client)`` and ``(log_time, host)`` indexes for
"top clients in time window T" / "top hosts in time window T" style
aggregations done via ``GROUP BY``.
- we do *not* add a unique constraint at the SQL layer (SQLite would
reject bulk inserts that violate it, and we want ON CONFLICT DO
NOTHING to gracefully drop duplicates). Dedup is enforced by an
in-memory ``(instance_id, log_time, client, raw_line)`` check inside
:func:`log_storage.sync_log_to_db`.
"""
__tablename__ = "log_entries"
id = db.Column(db.Integer, primary_key=True)
instance_id = db.Column(db.Integer, default=0, index=True) # SquidInstance.id, 0=default
log_time = db.Column(db.Float, nullable=False, index=True) # unix timestamp
elapsed_ms = db.Column(db.Integer, default=0)
client = db.Column(db.String(64), default='', index=True)
result_code = db.Column(db.String(32), default='', index=True)
http_code = db.Column(db.String(8), default='')
size_bytes = db.Column(db.BigInteger, default=0)
method = db.Column(db.String(16), default='', index=True)
url = db.Column(db.Text, default='')
host = db.Column(db.String(256), default='', index=True)
hier_code = db.Column(db.String(32), default='')
content_type = db.Column(db.String(128), default='')
raw_line = db.Column(db.Text, default='')
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.Index('idx_log_time_client', 'log_time', 'client'),
db.Index('idx_log_time_host', 'log_time', 'host'),
)
# ---------------------------------------------------------------------------
# Default config
# ---------------------------------------------------------------------------
DEFAULTS = {
"squid_conf_path": "/etc/squid/squid.conf",
"squid_binary": "squid",
"systemctl": "systemctl",
"squid_service": "squid",
"access_log_path": "/var/log/squid/access.log",
"cache_log_path": "/var/log/squid/cache.log",
"log_parse_limit": "10000", # max lines to parse from access.log
"log_cache_seconds": "30", # in-memory parse cache TTL
"live_tail_lines": "100", # default for /logs live view
"auto_refresh_seconds": "30",
# P2-1: persistent log storage switch. 1 = read from SQLite LogEntry
# table (imported via /logs/sync); 0 = legacy behaviour - read tail of
# access.log on every request. Default-on so existing single-user
# installs get history retention for free.
"log_use_persistent": "1",
# P2-1: auto-sync interval (seconds). 0 = manual sync only.
"log_sync_interval": "0",
# P1-4: squidclient mgr: connection settings for the real-time dashboard
"squidclient_binary": "squidclient",
"squid_mgr_host": "127.0.0.1",
"squid_mgr_port": "3128",
"squid_mgr_password": "",
"squid_mgr_timeout": "5",
}
# Keys whose value comes from the active SquidInstance (if any) rather than
# the global AppConfig table. All other keys continue to be resolved from
# AppConfig → DEFAULTS as before, so multi-instance support is purely
# additive and app-level knobs (log parsing, refresh, etc.) stay global.
_INSTANCE_KEYS = {
"squid_conf_path",
"squid_binary",
"systemctl",
"squid_service",
"access_log_path",
"cache_log_path",
}
def _resolve_active_instance():
"""Return the active SquidInstance row for this request, or None.
Resolution order:
1. session['active_instance_id'] (if set and still exists)
2. SquidInstance.is_active == True (first one)
3. None (no instances configured / none active)
Cached on flask.g for the lifetime of the request so template renders
don't repeatedly hit the DB.
"""
if "active_instance" in g.__dict__:
return g.active_instance
inst = None
sess_id = session.get("active_instance_id")
if sess_id is not None:
try:
inst = db.session.get(SquidInstance, int(sess_id))
except (TypeError, ValueError):
inst = None
if inst is None:
inst = SquidInstance.query.filter_by(is_active=True).first()
if inst is not None:
# materialise the choice in the session so subsequent requests
# stay sticky even when multiple instances are flagged active
session["active_instance_id"] = inst.id
g.active_instance = inst
return inst
def active_instance_name() -> str:
"""Display name of the active instance, or '(default)' if none."""
inst = _resolve_active_instance()
if inst is None:
return "(default)"
return inst.name or f"#{inst.id}"
def get_config(key: str, default: str = "") -> str:
# 1. Active instance overrides AppConfig for instance-specific keys
if key in _INSTANCE_KEYS:
inst = _resolve_active_instance()
if inst is not None:
val = getattr(inst, key, None)
if val not in (None, ""):
return str(val)
# 2. AppConfig table (per-key override)
row = AppConfig.query.filter_by(key=key).first()
if row and row.value is not None and row.value != "":
return row.value
# 3. Built-in defaults
return DEFAULTS.get(key, default)
def get_all_config() -> dict:
out = dict(DEFAULTS)
for row in AppConfig.query.all():
if row.value is not None and row.value != "":
out[row.key] = row.value
# Active instance wins over AppConfig for instance-specific keys
inst = _resolve_active_instance()
if inst is not None:
for k in _INSTANCE_KEYS:
val = getattr(inst, k, None)
if val not in (None, ""):
out[k] = str(val)
return out
def set_config(key: str, value: str):
row = AppConfig.query.filter_by(key=key).first()
if row:
row.value = value
else:
db.session.add(AppConfig(key=key, value=value))
def audit(action: str, detail: str = ""):
db.session.add(AuditLog(
username=session.get("username", "system"),
action=action, detail=detail[:2000],
))
# ---------------------------------------------------------------------------
# Auth helpers
# ---------------------------------------------------------------------------
def current_user():
uid = session.get("user_id")
if not uid:
return None
return db.session.get(User, uid)
def login_required(func):
@wraps(func)
def wrapper(*a, **kw):
if not session.get("user_id"):
return redirect(url_for("login", next=request.path))
return func(*a, **kw)
return wrapper
@app.context_processor
def inject_globals():
cu = current_user()
# Expose instance list + active name so the sidebar switcher and the
# /instances page can render without each route re-querying. Querying
# outside an auth context (e.g. login page) returns an empty list.
instances = []
active_id = None
active_name = "(default)"
if session.get("user_id"):
try:
instances = SquidInstance.query.order_by(SquidInstance.id.asc()).all()
except Exception:
instances = []
try:
inst = _resolve_active_instance()
if inst is not None:
active_id = inst.id
active_name = inst.name or f"#{inst.id}"
except Exception:
pass
return {
"current_user": cu,
"all_config": get_all_config(),
"instances": instances,
"active_instance_id": active_id,
"active_instance_name": active_name,
}
# ---------------------------------------------------------------------------
# Jinja filters
# ---------------------------------------------------------------------------
@app.template_filter("thousands")
def thousands_filter(n):
"""Format integer with thousands separator."""
try:
return f"{int(n):,}"
except (TypeError, ValueError):
return str(n)
# ---------------------------------------------------------------------------
# Squid paths & service control
# ---------------------------------------------------------------------------
def squid_conf_path() -> str:
return get_config("squid_conf_path")
def access_log_path() -> str:
return get_config("access_log_path")
def cache_log_path() -> str:
return get_config("cache_log_path")
def run_cmd(cmd: list[str] | str, timeout: int = 10) -> tuple[int, str, str]:
"""Run command, return (exit_code, stdout, stderr)."""
shell = isinstance(cmd, str)
try:
r = subprocess.run(cmd, shell=shell, capture_output=True,
text=True, timeout=timeout)
return r.returncode, (r.stdout or "").strip(), (r.stderr or "").strip()
except subprocess.TimeoutExpired:
return 124, "", f"Command timed out after {timeout}s"
except FileNotFoundError as e:
return 127, "", str(e)
except Exception as e:
return 1, "", f"{type(e).__name__}: {e}"
def service_status() -> dict:
"""Get squid service status via systemctl."""
systemctl = get_config("systemctl", "systemctl")
svc = get_config("squid_service", "squid")
rc, out, err = run_cmd([systemctl, "is-active", svc])
active = (rc == 0)
state = out.strip() if out else "unknown"
if state == "inactive":
state = "stopped"
# fetch version
binary = get_config("squid_binary", "squid")
rc_v, out_v, _ = run_cmd([binary, "--version"])
version = ""
if rc_v == 0 and out_v:
m = re.search(r"Version\s+(\S+)", out_v)
version = m.group(1) if m else out_v.split("\n")[0][:80]
# uptime / pid
rc_s, out_s, _ = run_cmd([systemctl, "show", svc,
"--property=MainPID,ActiveEnterTimestamp,FragmentPath"])
meta = {}
if rc_s == 0 and out_s:
for kv in out_s.split("\n"):
if "=" in kv:
k, _, v = kv.partition("=")
meta[k] = v
return {
"active": active,
"state": state,
"version": version,
"pid": meta.get("MainPID", ""),
"since": meta.get("ActiveEnterTimestamp", ""),
"unit_file": meta.get("FragmentPath", ""),
"binary_available": bool(shutil.which(binary)),
}
def service_control(action: str) -> tuple[bool, str]:
"""start / stop / restart / reload / reconfigure."""
systemctl = get_config("systemctl", "systemctl")
svc = get_config("squid_service", "squid")
valid = {"start", "stop", "restart", "reload", "try-reload-or-restart"}
if action not in valid:
return False, f"Unknown action: {action}"
# reload for sysv-compat services may fail; try reload then restart if needed
rc, out, err = run_cmd([systemctl, action, svc], timeout=30)
msg = (out + " " + err).strip()
time.sleep(1.5)
if action in ("reload", "try-reload-or-restart"):
rc2, _, _ = run_cmd([systemctl, "is-active", svc])
if rc2 != 0:
# fall back to restart
rc3, out3, err3 = run_cmd([systemctl, "restart", svc], timeout=30)
msg += " | fallback restart: " + (out3 + " " + err3).strip()
rc = rc3
if rc == 0:
return True, msg or "OK"
return False, msg or f"{systemctl} {action} {svc} failed (exit {rc})"
# ---------------------------------------------------------------------------
# squid.conf read/write
# ---------------------------------------------------------------------------
def read_squid_conf() -> tuple[str, str]:
"""Return (content, error_message)."""
path = squid_conf_path()
if not os.path.isfile(path):
return "", f"配置文件不存在: {path}"
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
return f.read(), ""
except OSError as e:
return "", f"读取失败: {e}"
def backup_conf(reason: str = "manual") -> str | None:
"""Back up current squid.conf to backups/ dir, return backup path."""
path = squid_conf_path()
if not os.path.isfile(path):
return None
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_reason = re.sub(r"[^A-Za-z0-9_]", "_", reason)[:40]
backup_name = f"squid.conf.{ts}.{safe_reason}"
backup_path = os.path.join(BACKUP_DIR, backup_name)
try:
shutil.copy2(path, backup_path)
return backup_path
except OSError:
return None
def _show_config_preview(pending_content: str, return_to: str):
"""Store pending content in session, redirect to GET /config/preview for confirmation.
``return_to`` may be either an endpoint name (e.g. ``"config_cache"``)
or a URL path (e.g. ``"/config/cache"``). Endpoint names get resolved
via ``url_for`` so the preview page's "返回" link is always a valid URL.
"""
import base64
# Normalize return_to: if it doesn't look like a URL, treat it as an
# endpoint name and resolve to a URL. This was the root cause of the
# 404 bug after saving a config - we stored "config_cache" and
# ``redirect("config_cache")`` produced a 404.
if return_to and not return_to.startswith("/") and not return_to.startswith("http"):
try:
return_to = url_for(return_to)
except Exception:
# unknown endpoint - fall back to dashboard
return_to = url_for("dashboard")
session["_pending_conf"] = base64.b64encode(pending_content.encode()).decode()
session["_pending_return"] = return_to
return redirect(url_for("config_preview"))
def write_squid_conf(content: str, validate: bool = True) -> tuple[bool, str]:
"""Write new content to squid.conf. Validates first if requested."""
binary = get_config("squid_binary", "squid")
if validate:
ok, msg = squid_config.validate_conf(content, squid_binary=binary)
if not ok:
return False, f"配置验证失败: {msg}"
path = squid_conf_path()
backup = backup_conf("before-write")
try:
# write to temp then rename
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
f.write(content)
os.replace(tmp, path)
return True, f"已写入 {path}" + (f" (备份: {os.path.basename(backup)})" if backup else "")
except OSError as e:
return False, f"写入失败: {e}"
# ---------------------------------------------------------------------------
# Log reading & parsing
# ---------------------------------------------------------------------------
_log_cache_lock = threading.Lock()
_log_cache: dict[str, tuple[float, list[log_parser.LogEntry]]] = {}
def read_access_log_tail(max_lines: int = 10000) -> str:
"""Read the last N lines of access.log."""
path = access_log_path()
if not os.path.isfile(path):
return ""
try:
# Use deque for efficient tail
with open(path, "rb") as f:
try:
f.seek(0, 2)
size = f.tell()
# Read backwards in chunks for efficiency
chunks: list[bytes] = []
lines_found = 0
block_size = 65536
pos = size
while pos > 0 and lines_found <= max_lines:
read_size = min(block_size, pos)
pos -= read_size
f.seek(pos)
chunk = f.read(read_size)
chunks.append(chunk)
lines_found += chunk.count(b"\n")
data = b"".join(reversed(chunks))
except OSError:
f.seek(0)
data = f.read()
text = data.decode("utf-8", errors="replace")
lines = text.splitlines()
if len(lines) > max_lines:
lines = lines[-max_lines:]
return "\n".join(lines)
except OSError:
return ""
def get_parsed_logs(force: bool = False) -> list[log_parser.LogEntry]:
"""Get parsed access.log entries.
Behaviour is driven by ``log_use_persistent`` in :class:`AppConfig`:
- ``1`` (default): sync the access.log tail into SQLite on demand
(with a small per-request throttle so we don't re-import the same
chunk repeatedly), then read from the persistent table via
:func:`log_storage.query_logs`. The result is cached in-memory using
the legacy ``_log_cache`` so subsequent page renders are fast.
- ``0``: legacy behaviour - tail the access.log file via
:func:`read_access_log_tail` and parse in-memory. Used as an
escape hatch if the persistent backend has issues.
The return shape is always :class:`log_parser.LogEntry` (a dict
subclass) with ``time``, ``client``, ``size`` etc. so consumers
(templates, alert rules, anomaly detection) keep working unmodified.
"""
import log_storage
use_persistent = get_config("log_use_persistent", "1") not in ("0", "false", "False")
if not use_persistent:
# legacy path
limit = int(get_config("log_parse_limit", "10000"))
ttl = int(get_config("log_cache_seconds", "30"))
key = f"access:legacy:{limit}"
now = time.time()
with _log_cache_lock:
cached = _log_cache.get(key)
if cached and not force and (now - cached[0]) < ttl:
return cached[1]
text = read_access_log_tail(max_lines=limit)
entries = log_parser.parse_lines(text)
with _log_cache_lock:
_log_cache[key] = (now, entries)
return entries
# persistent path: pull from SQLite
instance_id = 0
try:
inst = _resolve_active_instance()
if inst is not None:
instance_id = inst.id
except Exception:
instance_id = 0
# Best-effort auto-sync (throttled by the file mtime + last sync stamp)
if force or _should_auto_sync(instance_id):
try:
path = _resolve_access_log_path()
if os.path.isfile(path):
log_storage.sync_log_to_db(
path=path, instance_id=instance_id,
batch_size=500, max_lines=100000,
)
except Exception:
# never break a request because of a sync hiccup
pass
# cache the query result like before so template rendering is cheap
limit = int(get_config("log_parse_limit", "10000"))
ttl = int(get_config("log_cache_seconds", "30"))
key = f"access:db:{instance_id}:{limit}"
now = time.time()
with _log_cache_lock:
cached = _log_cache.get(key)
if cached and not force and (now - cached[0]) < ttl:
return cached[1]
rows = log_storage.query_logs({
"instance_id": instance_id,
"limit": limit,
})
# Wrap as LogEntry so getattr(d, "category") etc. keep working
wrapped = [log_parser.LogEntry(**r) for r in rows]
# reverse so chronological order (oldest first) matches legacy
wrapped = list(reversed(wrapped))
with _log_cache_lock:
_log_cache[key] = (now, wrapped)
return wrapped
def _resolve_access_log_path() -> str:
"""Look up the access log path WITHOUT going through session-bound code.
``get_config`` -> ``_resolve_active_instance`` -> ``session.get`` raises
outside a request context, and ``_should_auto_sync`` is called from many
places (background threads, app shell) that don't have a request context.
Here we hit the DB directly using a DB-session that does not require
session-binding.
"""
try:
# 1) instance row, if any is active
active = SquidInstance.query.filter_by(is_active=True).first()
if active and active.access_log_path:
return active.access_log_path
# 2) AppConfig override
row = AppConfig.query.filter_by(key="access_log_path").first()
if row and row.value:
return row.value
except Exception:
pass
return DEFAULTS.get("access_log_path", "/var/log/squid/access.log")
def _should_auto_sync(instance_id: int) -> bool:
"""Decide whether to re-sync the access.log tail for ``instance_id``.
Cheap heuristic: check the file's mtime against the last sync stamp
kept in :class:`AppConfig`. Always returns False when
``log_sync_interval`` is 0 (manual sync only).
Must NOT depend on ``session`` (called outside request context).
"""
try:
# Use raw AppConfig instead of get_config() - get_config touches
# session which raises outside request context.
row = AppConfig.query.filter_by(key="log_sync_interval").first()
interval = int(row.value) if (row and row.value) else 0
if interval <= 0:
return False
path = _resolve_access_log_path()
if not os.path.isfile(path):
return False
mtime = os.path.getmtime(path)
last_sync = log_storage.get_last_sync_at() or ""
if not last_sync:
return True
try:
last_dt = datetime.fromisoformat(last_sync.replace("Z", "+00:00"))
except ValueError:
return True
# re-sync if mtime is newer than last_sync, but throttle to once
# per ``interval`` seconds between requests
now = time.time()
if now - _auto_sync_last_call < interval:
return False
_auto_sync_last_call = now
return mtime > last_dt.timestamp()
except Exception as e:
# Don't use current_app here - this code can run outside app context
# (e.g. in test scripts). Just swallow.
return False
_auto_sync_last_call = 0.0
def get_stats() -> dict:
"""Return aggregate stats. Drives off :func:`log_storage.get_log_stats`
when the persistent backend is enabled, otherwise falls back to the
legacy ``log_parser.aggregate_stats(get_parsed_logs())`` path.
"""
import log_storage
use_persistent = get_config("log_use_persistent", "1") not in ("0", "false", "False")
if not use_persistent:
entries = get_parsed_logs()
return log_parser.aggregate_stats(entries)
instance_id = 0
try:
inst = _resolve_active_instance()
if inst is not None:
instance_id = inst.id
except Exception:
instance_id = 0
return log_storage.get_log_stats(instance_id=instance_id)
# ---------------------------------------------------------------------------
# Routes - auth
# ---------------------------------------------------------------------------
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
# throttle check
throttled, secs = security.is_throttled()
if throttled:
flash(f"登录失败次数过多,请等待 {secs} 秒后再试", "error")
audit("login_throttled", f"ip={security.client_ip()}")
db.session.commit()
return render_template("login.html", next=request.args.get("next", ""),
throttle_remaining=secs)
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
nxt = request.form.get("next") or url_for("dashboard")
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
session.clear()
session["user_id"] = user.id
session["username"] = user.username
session["_csrf"] = security.generate_csrf_token()
session["_logged_in_at"] = time.time()
session["_last_seen"] = time.time()
security.record_successful_login()
audit("login", f"user={username} ip={security.client_ip()}")
db.session.commit()
return redirect(nxt)
# failed login
locked, lockout = security.record_failed_login()
if locked:
flash(f"连续登录失败,已锁定 {lockout}", "error")
audit("login_locked", f"user={username} ip={security.client_ip()}")
else:
flash("用户名或密码错误", "error")
audit("login_fail", f"user={username} ip={security.client_ip()}")
db.session.commit()
return render_template("login.html", next=request.args.get("next", ""),
throttle_remaining=0)
@app.route("/logout")
def logout():
audit("logout", f"user={session.get('username', '')}")
db.session.commit()
session.clear()
return redirect(url_for("login"))
@app.route("/change-password", methods=["GET", "POST"])
@login_required
def change_password():
if request.method == "POST":
old = request.form.get("old_password", "")
new = request.form.get("new_password", "")
confirm = request.form.get("confirm_password", "")
u = current_user()
if not u.check_password(old):
flash("旧密码错误", "error")
elif len(new) < 6:
flash("新密码至少 6 位", "error")
elif new != confirm:
flash("两次输入不一致", "error")
else:
u.set_password(new)
audit("change_password")
db.session.commit()
flash("密码已更新", "ok")
return redirect(url_for("dashboard"))
return render_template("change_password.html")
# ---------------------------------------------------------------------------
# Routes - dashboard (log analytics)
# ---------------------------------------------------------------------------
@app.route("/")
@login_required
def dashboard():
# Trigger auto-sync first (get_parsed_logs checks _should_auto_sync),
# then compute stats from the now-fresh SQLite data.
entries = get_parsed_logs()
stats = get_stats()
cfg = get_all_config()
# P1-2: surface any active alert rules at the top of the dashboard so
# operators see them without having to navigate to /alerts.
try:
import alerts as _alerts # local import - keeps top imports stable
_rules = _alerts.load_rules(_alerts_path())
statuses = _alerts.evaluate_rules(entries, _rules)
active_alerts = [s for s in statuses if s["triggered"]]
if active_alerts:
flash(
"当前有 %d 条活跃告警,详情见"
"<a href='%s'>告警规则</a>"
% (len(active_alerts), url_for("alerts_view")),
"warning",
)
except Exception:
active_alerts = []
return render_template("dashboard.html", stats=stats, entries_count=len(entries),
cfg=cfg, format_bytes=log_parser.format_bytes,
format_duration=log_parser.format_duration,
active_alerts=active_alerts)
@app.route("/api/stats")
@login_required
def api_stats():
return jsonify(get_stats())
@app.route("/api/logs/refresh", methods=["POST"])
@login_required
def api_logs_refresh():
"""Force re-read of access.log."""
entries = get_parsed_logs(force=True)
# P2-1: when persistent mode is on, the legacy aggregate_stats path
# is *not* equivalent to get_stats() (which uses SQL aggregation).
# Return whichever the user has configured.
use_persistent = get_config("log_use_persistent", "1") not in ("0", "false", "False")
if use_persistent:
stats = get_stats()
return jsonify({"ok": True, "count": len(entries), "stats": stats})
stats = log_parser.aggregate_stats(entries)
return jsonify({"ok": True, "count": len(entries), "stats": stats})
# ---------------------------------------------------------------------------
# Routes - logs (search, filter, live tail, import)
# ---------------------------------------------------------------------------
@app.route("/logs")
@login_required
def logs_view():
"""Logs search page.
With P2-1 persistent storage enabled (``log_use_persistent=1``), this
route pushes every filter down to the SQLite layer so we get accurate
counts and pagination over multi-MB / multi-day ranges. The legacy
file-tail + in-Python filter path is still available as a fallback
when the user explicitly disables persistence.
"""
cfg = get_all_config()
import log_storage
use_persistent = cfg.get("log_use_persistent", "1") not in ("0", "false", "False")
# ---- filters from query string ----------------------------------------
f_client = request.args.get("client", "").strip()
f_method = request.args.get("method", "").strip()
f_result = request.args.get("result_code", "").strip()
f_host = request.args.get("host", "").strip()
f_url = request.args.get("url", "").strip()
f_min_size = request.args.get("min_size", type=int)
f_max_size = request.args.get("max_size", type=int)
f_min_elapsed = request.args.get("min_elapsed", type=int)
f_max_elapsed = request.args.get("max_elapsed", type=int)
f_start = request.args.get("start_time", "").strip()
f_end = request.args.get("end_time", "").strip()
page = max(1, request.args.get("page", 1, type=int))
per_page = 100
instance_id = 0
try:
inst = _resolve_active_instance()
if inst is not None:
instance_id = inst.id
except Exception:
instance_id = 0
base_filters = {
"instance_id": instance_id,
"client": f_client or None,
"method": f_method or None,
"result_code": f_result or None,
"host": f_host or None,
"url": f_url or None,
"min_size": f_min_size,
"max_size": f_max_size,
"min_elapsed": f_min_elapsed,
"max_elapsed": f_max_elapsed,
"start_time": f_start or None,
"end_time": f_end or None,
}
if use_persistent:
# best-effort sync before serving (skipped if the file is unchanged)
try:
if _should_auto_sync(instance_id):
path = _resolve_access_log_path()
if os.path.isfile(path):
log_storage.sync_log_to_db(path=path, instance_id=instance_id)
except Exception:
pass
# count first for accurate totals, then fetch the page slice
total = log_storage.count_logs(base_filters)
page_items = log_storage.query_logs({
**base_filters,
"limit": per_page,
"offset": (page - 1) * per_page,
})
# Wrap as LogEntry so the template's e.timestamp_local property
# works (query_logs returns plain dicts, which would otherwise
# silently fall through to the e.time branch in Jinja).
entries = [log_parser.LogEntry(**r) for r in page_items]
# query_logs returns newest-first, so no re-reverse needed
else:
entries = get_parsed_logs()
f = dict(base_filters)
# legacy filter util uses positional kwargs, not a dict
filtered = log_parser.filter_entries(
entries,
client=f_client or None, method=f_method or None,
result_code=f_result or None, host=f_host or None, url=f_url or None,
min_size=f_min_size, max_size=f_max_size,
min_elapsed=f_min_elapsed, max_elapsed=f_max_elapsed,
limit=10000,
)
filtered = list(reversed(filtered))
total = len(filtered)
start = (page - 1) * per_page
entries = filtered[start:start + per_page]
return render_template("logs.html",
entries=entries, total=total, page=page,
per_page=per_page,
total_pages=(total + per_page - 1) // per_page,
filters={
"client": f_client, "method": f_method,
"result_code": f_result, "host": f_host, "url": f_url,
"min_size": f_min_size or "", "max_size": f_max_size or "",
"min_elapsed": f_min_elapsed or "", "max_elapsed": f_max_elapsed or "",
"start_time": f_start, "end_time": f_end,
},
cfg=cfg,
last_sync=log_storage.get_last_sync_at_local(),
last_sync_path=log_storage.get_last_sync_path(),
use_persistent=use_persistent,
format_bytes=log_parser.format_bytes,
format_duration=log_parser.format_duration)
@app.route("/logs/sync", methods=["GET", "POST"])
@login_required
def logs_sync():
"""Manually trigger access.log -> SQLite sync.
GET: redirect to /logs (avoids accidental GET-driven state change)
POST: imports new lines from access.log and flashes a summary. The
``?full=1`` query parameter forces a full-history import (10M-line
ceiling) instead of the incremental tail import.
"""
import log_storage
if request.method == "GET":
return redirect(url_for("logs_view"))
full = request.args.get("full") == "1" or request.form.get("full") == "1"
instance_id = 0
try:
inst = _resolve_active_instance()
if inst is not None:
instance_id = inst.id
except Exception:
instance_id = 0
path = access_log_path()
if full:
inserted, skipped, err = log_storage.import_full_history(
path=path, instance_id=instance_id,
)
audit("logs_sync_full", f"path={path} inserted={inserted} skipped={skipped} err={err or ''}")
else:
inserted, skipped, err = log_storage.sync_log_to_db(
path=path, instance_id=instance_id,
)
audit("logs_sync", f"path={path} inserted={inserted} skipped={skipped} err={err or ''}")
try:
db.session.commit()
except Exception:
pass
if err:
flash(f"同步失败: {err}", "error")
else:
scope = "全量导入" if full else "增量同步"
flash(f"{scope}完成: 新增 {inserted} 条,跳过 {skipped} 条。", "ok")
# If posted via AJAX, return JSON; otherwise redirect back to /logs
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return jsonify({
"ok": err is None,
"inserted": inserted,
"skipped": skipped,
"error": err,
"full": full,
"last_sync": log_storage.get_last_sync_at(),
})
return redirect(url_for("logs_view"))
@app.route("/logs/import", methods=["GET", "POST"])
@login_required
def logs_import():
if request.method == "POST":
text = request.form.get("log_text", "")
if not text.strip():
flash("请粘贴日志内容", "error")
return redirect(url_for("logs_import"))
entries = log_parser.parse_lines(text)
if not entries:
flash("未能解析任何有效日志行,请检查格式", "error")
return redirect(url_for("logs_import"))
stats = log_parser.aggregate_stats(entries)
audit("logs_import", f"lines={len(text.splitlines())} parsed={len(entries)}")
db.session.commit()
return render_template("logs_import.html", imported=True,
entries=entries, stats=stats,
format_bytes=log_parser.format_bytes,
format_duration=log_parser.format_duration,
raw_lines=text.splitlines())
return render_template("logs_import.html", imported=False,
entries=None, stats=None,
format_bytes=log_parser.format_bytes,
format_duration=log_parser.format_duration)
@app.route("/logs/live")
@login_required
def logs_live():
"""Live tail view (auto-refresh). Reads directly from file for real-time."""
cfg = get_all_config()
n = int(cfg.get("live_tail_lines", 100))
# Read directly from file - bypass SQLite cache for true real-time
limit = max(n * 3, 500)
text = read_access_log_tail(max_lines=limit)
entries = log_parser.parse_lines(text)
entries = entries[-n:]
entries = list(reversed(entries))
return render_template("logs_live.html", entries=entries,
cfg=cfg, n=n,
format_bytes=log_parser.format_bytes,
format_duration=log_parser.format_duration)
# ---------------------------------------------------------------------------
# Routes - service control
# ---------------------------------------------------------------------------
@app.route("/service")
@login_required
def service_view():
status = service_status()
return render_template("service.html", status=status,
conf=squid_conf_path(),
access_log=access_log_path(),
cache_log=cache_log_path())
@app.route("/api/service/<action>", methods=["POST"])
@login_required
def api_service_control(action):
# P0-5: verify per-action confirmation token for dangerous operations
expected = session.pop(f'_svc_token_{action}', None)
sent = request.form.get("confirm_token", "")
if action in ('stop', 'restart') and (not expected or sent != expected):
audit(f"service_{action}_confirm_fail", f"ip={security.client_ip()}")
db.session.commit()
return jsonify({"ok": False, "message": "确认码无效或已过期,请刷新页面"}), 400
t0 = time.time()
ok, msg = service_control(action)
elapsed = time.time() - t0
audit(f"service_{action}", f"elapsed={elapsed:.2f}s msg={msg[:300]}")
db.session.commit()
return jsonify({"ok": ok, "message": msg, "status": service_status(),
"elapsed": round(elapsed, 2)})
@app.route("/api/service/status")
@login_required
def api_service_status():
return jsonify(service_status())
# ---------------------------------------------------------------------------
# Routes - config: paths / app settings
# ---------------------------------------------------------------------------
@app.route("/config/paths", methods=["GET", "POST"])
@login_required
def config_paths():
if request.method == "POST":
keys = ["squid_conf_path", "squid_binary", "systemctl", "squid_service",
"access_log_path", "cache_log_path",
"log_parse_limit", "log_cache_seconds",
"live_tail_lines", "auto_refresh_seconds",
"log_use_persistent", "log_sync_interval"]
for k in keys:
v = request.form.get(k, "").strip()
set_config(k, v)
# Flush in-memory log cache so the next page render reflects the
# new log_use_persistent / log_sync_interval value.
with _log_cache_lock:
_log_cache.clear()
audit("config_paths", "updated paths & app settings")
db.session.commit()
flash("应用配置已保存", "ok")
return redirect(url_for("config_paths"))
return render_template("config_paths.html", cfg=get_all_config())
# ---------------------------------------------------------------------------
# Routes - squid.conf config editor (full structured editor)
# ---------------------------------------------------------------------------
def get_struct() -> dict:
"""Read and parse current squid.conf. Returns default struct on failure."""
text, err = read_squid_conf()
if err or not text.strip():
return squid_config.default_struct()
return squid_config.parse_conf(text)
def save_struct(struct: dict, raw_extra: str = "") -> tuple[bool, str, str]:
"""Generate squid.conf from struct.
Returns (ok, message, content). Does NOT write to disk.
Writing is done via /config/preview + /config/confirm.
"""
try:
content = squid_config.generate_conf(struct, raw_extra)
return True, "OK", content
except Exception as e:
return False, f"生成配置失败: {e}", ""
@app.route("/config/main", methods=["GET", "POST"])
@login_required
def config_main():
if request.method == "POST":
struct = get_struct()
simple_keys = ["http_port", "https_port", "icp_port", "snmp_port",
"visible_hostname", "unique_hostname",
"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",
"connect_timeout", "read_timeout",
"client_lifetime", "shutdown_lifetime",
"cache_mgr", "err_html_language", "ftp_user",
"access_log", "cache_log", "cache_store_log",
"pid_filename", "logformat", "debug_options",
"log_ip_on_direct", "buffered_logs",
"dns_nameservers", "dns_timeout", "hosts_file",
"dns_defnames"]
new_simple = {}
for k in simple_keys:
v = request.form.get(k, "").strip()
if v:
# split on whitespace
new_simple[k] = [(v.split(), "")]
else:
new_simple[k] = []
struct["simple"] = new_simple
raw_extra = request.form.get("raw_extra", "")
ok, msg, content = save_struct(struct, raw_extra)
if not ok:
audit("config_main_preview_fail", msg[:500])
db.session.commit()
flash(msg, "error")
return redirect(url_for("config_main"))
audit("config_main_preview", f"size={len(content)}")
db.session.commit()
return _show_config_preview(content, "config_main")
struct = get_struct()
simple = {k: " ".join(args[0]) if struct["simple"].get(k) else ""
for k, args_data in struct["simple"].items()
for args in [args_data]} if False else {}
# simpler approach
simple = {}
for k in ["http_port", "https_port", "icp_port", "snmp_port",
"visible_hostname", "unique_hostname",
"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",
"connect_timeout", "read_timeout", "client_lifetime",
"shutdown_lifetime", "cache_mgr", "err_html_language", "ftp_user",
"access_log", "cache_log", "cache_store_log", "pid_filename",
"logformat", "debug_options", "log_ip_on_direct", "buffered_logs",
"dns_nameservers", "dns_timeout", "hosts_file", "dns_defnames"]:
lst = struct["simple"].get(k, [])
if lst and isinstance(lst[0], tuple) and lst[0][0]:
simple[k] = " ".join(lst[0][0])
else:
simple[k] = ""
return render_template("config_main.html", simple=simple,
squid_conf_path=squid_conf_path())
@app.route("/config/acls", methods=["GET", "POST"])
@login_required
def config_acls():
if request.method == "POST":
action = request.form.get("action", "save")
struct = get_struct()
if action == "delete":
name = request.form.get("name", "")
struct["acls"] = [a for a in struct["acls"] if a.name != name]
elif action == "add":
name = request.form.get("name", "").strip()
atype = request.form.get("type", "").strip()
values_raw = request.form.get("values", "").strip()
comment = request.form.get("comment", "").strip()
if not name or not atype:
flash("ACL 名称和类型必填", "error")
return redirect(url_for("config_acls"))
values = values_raw.split()
if not values:
flash("至少需要一个值", "error")
return redirect(url_for("config_acls"))
struct["acls"].append(squid_config.Acl(
name=name, type=atype, values=values, comment=comment))
elif action == "save":
# rebuild ACL list from form fields (id, name, type, values, comment)
ids = request.form.getlist("id[]")
names = request.form.getlist("name[]")
types = request.form.getlist("type[]")
values_list = request.form.getlist("values[]")
comments = request.form.getlist("comment[]")
new_acls = []
for i in range(len(ids)):
if i < len(names) and names[i].strip():
vals = (values_list[i] if i < len(values_list) else "").split()
cmnt = comments[i] if i < len(comments) else ""
new_acls.append(squid_config.Acl(
name=names[i].strip(),
type=types[i] if i < len(types) else "src",
values=vals, comment=cmnt))
struct["acls"] = new_acls
ok, msg, content_preview = save_struct(struct)
if not ok:
flash(msg, "error")
return redirect(url_for("config_acls"))
# Preview before write
return _show_config_preview(content_preview, "config_acls")
struct = get_struct()
return render_template("config_acls.html", acls=struct["acls"],
acl_types=squid_config.ACL_TYPES)
@app.route("/config/rules", methods=["GET", "POST"])
@login_required
def config_rules():
if request.method == "POST":
action = request.form.get("action", "save")
struct = get_struct()
if action == "add":
directive = request.form.get("directive", "http_access")
act = request.form.get("action_type", "allow")
acls_raw = request.form.get("acls", "").strip()
comment = request.form.get("comment", "").strip()
if act not in ("allow", "deny"):
flash("动作必须是 allow 或 deny", "error")
return redirect(url_for("config_rules"))
acls = acls_raw.split()
struct["rules"].append(squid_config.Rule(
action=act, acls=acls, comment=comment, directive=directive))
elif action == "save":
ids = request.form.getlist("id[]")
directives = request.form.getlist("directive[]")
acts = request.form.getlist("action_type[]")
acls_list = request.form.getlist("acls[]")
comments = request.form.getlist("comment[]")
new_rules = []
for i in range(len(ids)):
if i < len(directives):
acl_vals = (acls_list[i] if i < len(acls_list) else "").split()
cmnt = comments[i] if i < len(comments) else ""
act = acts[i] if i < len(acts) else "allow"
if act not in ("allow", "deny"):
act = "allow"
new_rules.append(squid_config.Rule(
action=act, acls=acl_vals, comment=cmnt,
directive=directives[i]))
struct["rules"] = new_rules
elif action == "delete":
idx = int(request.form.get("index", "-1"))
if 0 <= idx < len(struct["rules"]):
del struct["rules"][idx]
ok, msg, content_preview = save_struct(struct)
if not ok:
flash(msg, "error")
return redirect(url_for("config_rules"))
# Preview before write
return _show_config_preview(content_preview, "config_rules")
struct = get_struct()
# group rules by directive for display
acls_by_name = {a.name: a for a in struct["acls"]}
return render_template("config_rules.html", rules=struct["rules"],
rule_directives=squid_config.RULE_DIRECTIVES,
acls_by_name=acls_by_name)
@app.route("/config/cache", methods=["GET", "POST"])
@login_required
def config_cache():
if request.method == "POST":
action = request.form.get("action", "save")
struct = get_struct()
if action == "add_dir":
cd_type = request.form.get("type", "ufs")
path = request.form.get("path", "/var/spool/squid").strip()
size = int(request.form.get("size_mb", "100") or "100")
l1 = int(request.form.get("l1", "16") or "16")
l2 = int(request.form.get("l2", "256") or "256")
struct["cache_dirs"].append(squid_config.CacheDir(
type=cd_type, path=path, size_mb=size, l1=l1, l2=l2))
elif action == "save":
ids = request.form.getlist("id[]")
types = request.form.getlist("type[]")
paths = request.form.getlist("path[]")
sizes = request.form.getlist("size_mb[]")
l1s = request.form.getlist("l1[]")
l2s = request.form.getlist("l2[]")
new_dirs = []
for i in range(len(ids)):
if i < len(paths) and paths[i].strip():
new_dirs.append(squid_config.CacheDir(
type=types[i] if i < len(types) else "ufs",
path=paths[i].strip(),
size_mb=int(sizes[i]) if i < len(sizes) and sizes[i].isdigit() else 100,
l1=int(l1s[i]) if i < len(l1s) and l1s[i].isdigit() else 16,
l2=int(l2s[i]) if i < len(l2s) and l2s[i].isdigit() else 256,
))
struct["cache_dirs"] = new_dirs
elif action == "delete_dir":
idx = int(request.form.get("index", "-1"))
if 0 <= idx < len(struct["cache_dirs"]):
del struct["cache_dirs"][idx]
ok, msg, content_preview = save_struct(struct)
if not ok:
flash(msg, "error")
return redirect(url_for("config_cache"))
# Preview before write
return _show_config_preview(content_preview, "config_cache")
struct = get_struct()
return render_template("config_cache.html", cache_dirs=struct["cache_dirs"])
@app.route("/config/refresh", methods=["GET", "POST"])
@login_required
def config_refresh():
if request.method == "POST":
action = request.form.get("action", "save")
struct = get_struct()
if action == "add":
rp = squid_config.RefreshPattern()
rp.case_insensitive = request.form.get("case_insensitive") == "on"
rp.regex = request.form.get("regex", ".").strip()
try:
rp.min_minutes = int(request.form.get("min_minutes", "0") or "0")
rp.percent = int(request.form.get("percent", "20") or "20")
rp.max_minutes = int(request.form.get("max_minutes", "4320") or "4320")
except ValueError:
flash("数值格式错误", "error")
return redirect(url_for("config_refresh"))
struct["refresh_patterns"].append(rp)
elif action == "save":
ids = request.form.getlist("id[]")
cis = request.form.getlist("case_insensitive[]")
regexes = request.form.getlist("regex[]")
mins = request.form.getlist("min_minutes[]")
pcts = request.form.getlist("percent[]")
maxs = request.form.getlist("max_minutes[]")
new_rps = []
for i in range(len(ids)):
if i < len(regexes) and regexes[i].strip():
rp = squid_config.RefreshPattern(
case_insensitive=(str(i) in cis),
regex=regexes[i].strip(),
min_minutes=int(mins[i]) if i < len(mins) and mins[i].lstrip("-").isdigit() else 0,
percent=int(pcts[i].rstrip("%")) if i < len(pcts) and pcts[i].rstrip("%").lstrip("-").isdigit() else 20,
max_minutes=int(maxs[i]) if i < len(maxs) and maxs[i].lstrip("-").isdigit() else 4320,
)
new_rps.append(rp)
struct["refresh_patterns"] = new_rps
elif action == "delete":
idx = int(request.form.get("index", "-1"))
if 0 <= idx < len(struct["refresh_patterns"]):
del struct["refresh_patterns"][idx]
ok, msg, content_preview = save_struct(struct)
if not ok:
flash(msg, "error")
return redirect(url_for("config_refresh"))
# Preview before write
return _show_config_preview(content_preview, "config_refresh")
struct = get_struct()
return render_template("config_refresh.html", patterns=struct["refresh_patterns"])
@app.route("/config/auth", methods=["GET", "POST"])
@login_required
def config_auth():
if request.method == "POST":
action = request.form.get("action", "save")
struct = get_struct()
if action == "add":
scheme = request.form.get("scheme", "basic")
program = request.form.get("program", "").strip()
realm = request.form.get("realm", "Squid Proxy").strip()
try:
children = int(request.form.get("children", "5") or "5")
credentialsttl = int(request.form.get("credentialsttl", "2") or "2")
except ValueError:
children, credentialsttl = 5, 2
struct["auth_params"].append(squid_config.AuthParam(
scheme=scheme, program=program, realm=realm,
children=children, credentialsttl=credentialsttl))
elif action == "save":
ids = request.form.getlist("id[]")
schemes = request.form.getlist("scheme[]")
programs = request.form.getlist("program[]")
realms = request.form.getlist("realm[]")
childrens = request.form.getlist("children[]")
ttls = request.form.getlist("credentialsttl[]")
new_aps = []
for i in range(len(ids)):
if i < len(schemes) and schemes[i].strip():
try:
ch = int(childrens[i]) if i < len(childrens) and childrens[i].isdigit() else 5
tt = int(ttls[i]) if i < len(ttls) and ttls[i].isdigit() else 2
except ValueError:
ch, tt = 5, 2
new_aps.append(squid_config.AuthParam(
scheme=schemes[i].strip(),
program=(programs[i] if i < len(programs) else "").strip(),
realm=(realms[i] if i < len(realms) else "Squid Proxy").strip(),
children=ch, credentialsttl=tt))
struct["auth_params"] = new_aps
elif action == "delete":
idx = int(request.form.get("index", "-1"))
if 0 <= idx < len(struct["auth_params"]):
del struct["auth_params"][idx]
ok, msg, content_preview = save_struct(struct)
if not ok:
flash(msg, "error")
return redirect(url_for("config_auth"))
# Preview before write
return _show_config_preview(content_preview, "config_auth")
struct = get_struct()
return render_template("config_auth.html", auth_params=struct["auth_params"])
@app.route("/config/peers", methods=["GET", "POST"])
@login_required
def config_peers():
if request.method == "POST":
action = request.form.get("action", "save")
struct = get_struct()
if action == "add":
host = request.form.get("host", "").strip()
if not host:
flash("Peer 主机必填", "error")
return redirect(url_for("config_peers"))
ptype = request.form.get("type", "parent")
try:
http_port = int(request.form.get("http_port", "3128") or "3128")
icp_port = int(request.form.get("icp_port", "0") or "0")
except ValueError:
http_port, icp_port = 3128, 0
options = request.form.get("options", "").strip().split()
struct["cache_peers"].append(squid_config.CachePeer(
host=host, type=ptype, http_port=http_port,
icp_port=icp_port, options=options))
elif action == "save":
ids = request.form.getlist("id[]")
hosts = request.form.getlist("host[]")
types = request.form.getlist("type[]")
http_ports = request.form.getlist("http_port[]")
icp_ports = request.form.getlist("icp_port[]")
options_list = request.form.getlist("options[]")
new_peers = []
for i in range(len(ids)):
if i < len(hosts) and hosts[i].strip():
new_peers.append(squid_config.CachePeer(
host=hosts[i].strip(),
type=types[i] if i < len(types) else "parent",
http_port=int(http_ports[i]) if i < len(http_ports) and http_ports[i].isdigit() else 3128,
icp_port=int(icp_ports[i]) if i < len(icp_ports) and icp_ports[i].isdigit() else 0,
options=(options_list[i] if i < len(options_list) else "").split(),
))
struct["cache_peers"] = new_peers
elif action == "delete":
idx = int(request.form.get("index", "-1"))
if 0 <= idx < len(struct["cache_peers"]):
del struct["cache_peers"][idx]
ok, msg, content_preview = save_struct(struct)
if not ok:
flash(msg, "error")
return redirect(url_for("config_peers"))
# Preview before write
return _show_config_preview(content_preview, "config_peers")
struct = get_struct()
return render_template("config_peers.html", peers=struct["cache_peers"])
@app.route("/config/raw", methods=["GET", "POST"])
@login_required
def config_raw():
if request.method == "POST":
content = request.form.get("content", "")
# Validate first if user opted in
if request.form.get("validate") == "on":
binary = get_config("squid_binary", "squid")
ok, vmsg = squid_config.validate_conf(content, squid_binary=binary)
if not ok:
flash(f"校验失败: {vmsg}", "error")
audit("config_raw_validate_fail", vmsg[:500])
db.session.commit()
return redirect(url_for("config_raw"))
# Go through preview before writing
return _show_config_preview(content, "config_raw")
content, err = read_squid_conf()
return render_template("config_raw.html", content=content, error=err,
squid_conf_path=squid_conf_path())
@app.route("/api/validate", methods=["POST"])
@login_required
def api_validate():
text = request.form.get("content") or (request.get_json(silent=True) or {}).get("content", "")
if not text:
return jsonify({"ok": False, "message": "空内容"})
binary = get_config("squid_binary", "squid")
ok, msg = squid_config.validate_conf(text, squid_binary=binary)
return jsonify({"ok": ok, "message": msg})
@app.route("/api/reload", methods=["POST"])
@login_required
def api_reload():
ok, msg = service_control("reload")
audit("service_reload", msg[:500])
db.session.commit()
return jsonify({"ok": ok, "message": msg})
# ---------------------------------------------------------------------------
# Audit log
# ---------------------------------------------------------------------------
@app.route("/audit")
@login_required
def audit_view():
page = max(1, request.args.get("page", 1, type=int))
per_page = 100
q = AuditLog.query.order_by(AuditLog.timestamp.desc())
total = q.count()
items = q.offset((page - 1) * per_page).limit(per_page).all()
return render_template("audit.html", items=items, total=total, page=page,
per_page=per_page,
total_pages=(total + per_page - 1) // per_page)
# ---------------------------------------------------------------------------
# Backups
# ---------------------------------------------------------------------------
@app.route("/backups")
@login_required
def backups_view():
files = []
if os.path.isdir(BACKUP_DIR):
for name in sorted(os.listdir(BACKUP_DIR), reverse=True):
if not name.startswith("squid.conf"):
continue
p = os.path.join(BACKUP_DIR, name)
if os.path.isfile(p):
st = os.stat(p)
files.append({
"name": name,
"size": st.st_size,
"mtime": datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
"path": p,
})
return render_template("backups.html", files=files)
@app.route("/backups/<path:name>")
@login_required
def backup_download(name):
if "/" in name or ".." in name:
abort(404)
return send_from_directory(BACKUP_DIR, name, as_attachment=True)
@app.route("/backups/restore/<path:name>", methods=["POST"])
@login_required
def backup_restore(name):
if "/" in name or ".." in name:
abort(404)
src = os.path.join(BACKUP_DIR, name)
if not os.path.isfile(src):
flash("备份不存在", "error")
return redirect(url_for("backups_view"))
# back up current before restore
backup_conf("before-restore")
try:
shutil.copy2(src, squid_conf_path())
audit("backup_restore", name)
db.session.commit()
flash(f"已从备份 {name} 恢复", "ok")
except OSError as e:
flash(f"恢复失败: {e}", "error")
return redirect(url_for("config_raw"))
# ---------------------------------------------------------------------------
# Static uploads (none yet, but reserved)
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Seed / init
# ---------------------------------------------------------------------------
def seed_admin():
if not User.query.filter_by(username="admin").first():
a = User(username="admin", role="admin")
a.set_password("admin")
db.session.add(a)
db.session.commit()
print("[init] seeded admin user (admin/admin)")
@app.errorhandler(404)
def not_found(e):
return render_template("error.html", code=404, message="页面不存在"), 404
@app.errorhandler(403)
def forbidden(e):
return render_template("error.html", code=403, message="无权限"), 403
@app.errorhandler(500)
def server_error(e):
return render_template("error.html", code=500, message="服务器内部错误"), 500
# ---------------------------------------------------------------------------
# Entry points
# ---------------------------------------------------------------------------
if __name__ == "__main__":
with app.app_context():
db.create_all()
seed_admin()
app.run(host="0.0.0.0", port=5200, debug=True)
# ---------------------------------------------------------------------------
# P0-4: config diff preview
# ---------------------------------------------------------------------------
import diff_tool
@app.route("/config/preview", methods=["GET", "POST"])
@login_required
def config_preview():
"""Show unified diff of the proposed config vs current. User must POST again to confirm."""
import base64
if request.method == "POST":
pending = request.form.get("_pending_content", "")
if pending:
session["_pending_conf"] = base64.b64encode(pending.encode()).decode()
session["_pending_return"] = request.form.get("return_to", url_for("dashboard"))
pending_b64 = session.pop("_pending_conf", "")
if not pending_b64:
flash("无待保存内容或会话已过期", "error")
return redirect(url_for("dashboard"))
try:
pending = base64.b64decode(pending_b64).decode("utf-8")
except Exception:
flash("内容解析失败", "error")
return redirect(url_for("dashboard"))
return_to = session.pop("_pending_return", url_for("dashboard"))
current, err = read_squid_conf()
if err:
flash(err, "error")
return redirect(return_to)
diff = diff_tool.unified_diff(current, pending)
summary = diff_tool.summarize(diff)
html = diff_tool.render_html(diff)
return render_template("config_preview.html", diff_html=html, summary=summary,
pending_b64=pending_b64, return_to=return_to)
@app.route("/config/confirm", methods=["POST"])
@login_required
def config_confirm():
"""Actually write the config that the user previewed."""
import base64
pending_b64 = request.form.get("pending", "")
try:
content = base64.b64decode(pending_b64).decode("utf-8")
except Exception:
flash("内容解析失败", "error")
return redirect(url_for("dashboard"))
return_to = request.form.get("return_to", url_for("dashboard"))
validate = request.form.get("validate") == "1"
ok, msg = write_squid_conf(content, validate=validate)
audit("config_write", msg[:500])
db.session.commit()
if ok:
flash("配置已应用: " + msg, "ok")
else:
flash("写入失败: " + msg, "error")
return redirect(return_to)
# ---------------------------------------------------------------------------
# P0-2: TLS / HTTPS certificate management
# ---------------------------------------------------------------------------
# Imports deferred to avoid touching the existing import block; ssl_certs only
# uses the standard library + the openssl CLI binary.
import ssl_certs # noqa: E402 (intentionally at end of module per task spec)
import hashlib # noqa: E402 (used only by TLS audit fingerprints)
SSL_CERT_DIR = os.path.join(BASE_DIR, "ssl_certs")
@app.route("/config/tls", methods=["GET"])
@login_required
def config_tls():
"""List all certificates stored under ``ssl_certs/`` with metadata."""
ok, msg, items = ssl_certs.list_certificates(SSL_CERT_DIR)
if not ok:
flash(f"无法读取证书目录: {msg}", "error")
items = []
return render_template(
"config_tls.html",
certs=items,
cert_dir=SSL_CERT_DIR,
openssl_info=ssl_certs.is_openssl_available(),
)
@app.route("/config/tls/generate", methods=["POST"])
@login_required
def config_tls_generate():
"""Generate a self-signed certificate via openssl."""
common_name = request.form.get("common_name", "").strip()
days_raw = request.form.get("days", "365").strip() or "365"
try:
days = int(days_raw)
except ValueError:
flash("有效期必须是整数", "error")
return redirect(url_for("config_tls"))
ok, msg, data = ssl_certs.generate_self_signed(common_name, days, SSL_CERT_DIR)
if ok:
# Audit record: name + SHA-256 only, NEVER private key contents.
fp = data.get("fingerprint", "") or hashlib.sha256(
data.get("name", "").encode()
).hexdigest()
cn_audit = data.get("cn", common_name) or common_name
days_audit = data.get("days", days)
name_audit = data.get("name", "")
audit(
"tls_generate",
f'name={name_audit} cn={cn_audit} days={days_audit} sha256={fp}',
)
db.session.commit()
flash(
f"已生成: {msg} ({cn_audit}, {days_audit} 天)",
"ok",
)
else:
audit("tls_generate_fail", f"cn={common_name} days={days} err={msg[:120]}")
db.session.commit()
flash(f"生成失败: {msg}", "error")
return redirect(url_for("config_tls"))
@app.route("/config/tls/upload", methods=["POST"])
@login_required
def config_tls_upload():
"""Accept an uploaded PEM/CRT/KEY file and save it under ssl_certs/."""
f = request.files.get("certfile")
if not f:
flash("未选择文件", "error")
return redirect(url_for("config_tls"))
ok, msg, data = ssl_certs.upload_certificate(f, SSL_CERT_DIR)
if ok:
up_name = data.get("name", "")
up_size = data.get("size", 0)
up_kind = data.get("kind", "")
up_sha = data.get("sha256", "")
audit(
"tls_upload",
f'name={up_name} size={up_size} kind={up_kind} sha256={up_sha}',
)
db.session.commit()
flash(msg, "ok")
else:
fname = getattr(f, "filename", "") or ""
audit("tls_upload_fail", f"filename={fname} err={msg[:120]}")
db.session.commit()
flash(f"上传失败: {msg}", "error")
return redirect(url_for("config_tls"))
@app.route("/config/tls/delete", methods=["POST"])
@login_required
def config_tls_delete():
"""Delete a single cert / key file by name."""
name = (request.form.get("name") or "").strip()
ok, msg, data = ssl_certs.delete_certificate(name, SSL_CERT_DIR)
if ok:
del_name = data.get("name", name) or name
del_size = data.get("size", 0)
del_sha = data.get("sha256", "")
audit(
"tls_delete",
f'name={del_name} size={del_size} sha256={del_sha}',
)
db.session.commit()
flash(msg, "ok")
else:
audit("tls_delete_fail", f"name={name} err={msg[:120]}")
db.session.commit()
flash(f"删除失败: {msg}", "error")
return redirect(url_for("config_tls"))
# ---------------------------------------------------------------------------
# P0-5: Service control confirmation tokens
# ---------------------------------------------------------------------------
import secrets as _secrets
def confirm_tokens():
"""Generate per-action confirmation tokens for the service page.
Stores them in the session so /api/service/<action> can verify
that the user actually came through the rendered service page
(mitigates trivial CSRF / script-kiddie attempts).
"""
tokens = {}
for action in ('start', 'stop', 'restart', 'reload'):
tokens[action] = _secrets.token_urlsafe(8)
session[f'_svc_token_{action}'] = tokens[action]
return tokens
@app.context_processor
def _service_confirm_globals():
return {
"confirm_tokens": confirm_tokens,
}
# ---------------------------------------------------------------------------
# P0-3: SSL Bump (HTTPS interception) configuration UI
# ---------------------------------------------------------------------------
SSL_BUMP_SIMPLE_KEYS = (
"sslcrtd_program",
"sslcrtd_children",
"sslproxy_ca_certfile",
"sslproxy_ca_keyfile",
)
def _ssl_bump_simple_to_form(struct: dict) -> dict:
"""Convert struct['simple'] ssl_bump entries into form-string form.
Returns dict like {key: "arg1 arg2 arg3", ...}.
"""
out = {}
for k in SSL_BUMP_SIMPLE_KEYS:
lst = struct.get("simple", {}).get(k, [])
if lst and isinstance(lst[0], tuple) and lst[0][0]:
out[k] = " ".join(lst[0][0])
else:
out[k] = ""
return out
def _ssl_bump_form_to_simple(form_values: dict) -> dict:
"""Parse the four sslcrtd/sslproxy_ca form fields into struct simple dict.
Returns: {key: [(args_list, ""), ...] or [] }
"""
out = {}
for k in SSL_BUMP_SIMPLE_KEYS:
raw = (form_values.get(k) or "").strip()
if raw:
out[k] = [(raw.split(), "")]
else:
out[k] = []
return out
@app.route("/config/ssl_bump", methods=["GET", "POST"])
@login_required
def config_ssl_bump():
"""SSL Bump (HTTPS interception) rule editor.
GET - render the rules table + add form + sslcrtd settings form
POST action=add - append a new SslBumpRule
POST action=save - replace entire list from form rows
POST action=delete - drop a rule by index
POST action=generate-cert - stub: emit a self-signed CA into ssl_certs/
"""
if request.method == "POST":
action = request.form.get("action", "save")
struct = get_struct()
if action == "add":
act = request.form.get("action_type", "peek").strip()
if act not in squid_config.SSL_BUMP_ACTIONS:
flash(f"未知动作: {act}(合法值: {', '.join(squid_config.SSL_BUMP_ACTIONS)}", "error")
return redirect(url_for("config_ssl_bump"))
acls_raw = request.form.get("acls", "").strip()
comment = request.form.get("comment", "").strip()
acls = acls_raw.split()
struct.setdefault("ssl_bump_rules", [])
struct["ssl_bump_rules"].append(squid_config.SslBumpRule(
action=act,
acls=acls,
step=len(struct["ssl_bump_rules"]) + 1,
comment=comment,
))
audit("config_sslbump_add", f"action={act} acls={acls}")
db.session.commit()
elif action == "save":
# Replace ssl_bump_rules list using form arrays, AND persist the
# four sslcrtd/sslproxy_ca_* simple fields alongside the rules.
ids = request.form.getlist("id[]")
actions = request.form.getlist("action_type[]")
acls_list = request.form.getlist("acls[]")
comments = request.form.getlist("comment[]")
new_rules = []
for i in range(len(ids)):
act = (actions[i] if i < len(actions) else "peek").strip() or "peek"
if act not in squid_config.SSL_BUMP_ACTIONS:
act = "peek"
acl_vals = (acls_list[i] if i < len(acls_list) else "").split()
cmnt = comments[i] if i < len(comments) else ""
new_rules.append(squid_config.SslBumpRule(
action=act,
acls=acl_vals,
step=i + 1,
comment=cmnt,
))
struct["ssl_bump_rules"] = new_rules
# simple sslcrtd / sslproxy_ca_* fields
new_simple_updates = _ssl_bump_form_to_simple(request.form)
simple = struct.setdefault("simple", {})
simple.update(new_simple_updates)
audit("config_sslbump_save", f"rules={len(new_rules)}")
db.session.commit()
elif action == "delete":
try:
idx = int(request.form.get("index", "-1"))
except (TypeError, ValueError):
idx = -1
rules = struct.setdefault("ssl_bump_rules", [])
if 0 <= idx < len(rules):
del rules[idx]
# renumber steps
for i, r in enumerate(rules, start=1):
r.step = i
audit("config_sslbump_delete", f"index={idx}")
db.session.commit()
else:
flash("无效的规则索引", "error")
return redirect(url_for("config_ssl_bump"))
elif action == "generate-cert":
# Optional: emit a self-signed CA cert under ssl_certs/ that admins
# can later wire in via sslproxy_ca_certfile / sslproxy_ca_keyfile.
cn = (request.form.get("common_name") or "").strip() or "Squid Bump CA"
try:
days = int(request.form.get("days", "3650"))
except (TypeError, ValueError):
days = 3650
if days < 1:
days = 1
if days > 36500:
days = 36500
try:
import ssl_certs as _ssl_certs
ok, msg, data = _ssl_certs.generate_self_signed(
common_name=cn, days=days,
cert_dir=os.path.join(BASE_DIR, "ssl_certs"),
)
except Exception as e: # pragma: no cover - defensive
ok, msg = False, f"证书生成异常: {e}"
if ok:
audit("config_sslbump_gencert",
f"cn={cn} days={days} cert={data.get('cert_path','')}")
db.session.commit()
flash(f"CA 证书已生成: {data.get('cert_path','')} (CN={cn})", "ok")
else:
audit("config_sslbump_gencert_fail", f"cn={cn} msg={msg[:160]}")
db.session.commit()
flash(f"证书生成失败: {msg}", "error")
return redirect(url_for("config_ssl_bump"))
# Persist via the standard preview path (3-tuple from save_struct).
ok, msg, content = save_struct(struct)
if not ok:
audit("config_sslbump_fail", msg[:500])
db.session.commit()
flash(msg, "error")
return redirect(url_for("config_ssl_bump"))
audit("config_sslbump_preview", f"size={len(content)}")
db.session.commit()
return _show_config_preview(content, "config_ssl_bump")
struct = get_struct()
acls_by_name = {a.name: a for a in struct.get("acls", [])}
simple = _ssl_bump_simple_to_form(struct)
return render_template(
"config_ssl_bump.html",
rules=struct.get("ssl_bump_rules", []),
simple=simple,
actions=squid_config.SSL_BUMP_ACTIONS,
default_example=squid_config.SSL_BUMP_DEFAULT_EXAMPLE,
acls_by_name=acls_by_name,
)
# ---------------------------------------------------------------------------
# Routes - P1-1: log status & logrotate management
# ---------------------------------------------------------------------------
# Local import to keep the existing top-of-file imports untouched.
import log_rotate # noqa: E402
def _collect_log_paths() -> list[str]:
"""Pull the log paths Squid Manager knows about, in display order.
Reads from get_all_config() so we stay in sync with the path-settings UI.
Empty / blank entries are skipped. cache_store_log is included if set.
"""
cfg = get_all_config()
paths: list[str] = []
for key in ("access_log_path", "cache_log_path", "cache_store_log"):
v = (cfg.get(key) or "").strip()
if v:
paths.append(v)
return paths
@app.route("/ops/logs")
@login_required
def ops_logs():
"""Show size/mtime/age/writability for every known Squid log file."""
paths = _collect_log_paths()
entries = [log_rotate.get_log_file_info(p) for p in paths]
total_bytes = sum(e.get("size_bytes", 0) or 0 for e in entries if e.get("exists"))
return render_template(
"ops_logs.html",
entries=entries,
total_bytes=total_bytes,
total_human=log_rotate._human_size(total_bytes),
binary=get_config("squid_binary", "squid"),
logrotate_available=log_rotate.is_logrotate_available(),
warn_bytes=log_rotate.SIZE_WARN_BYTES,
crit_bytes=log_rotate.SIZE_CRIT_BYTES,
)
@app.route("/ops/logs/rotate", methods=["POST"])
@login_required
def ops_logs_rotate():
"""Force ``squid -k rotate`` to rotate Squid's logs right now."""
binary = get_config("squid_binary", "squid")
ok, msg = log_rotate.trigger_squid_rotate(binary=binary)
audit("logs_rotate", f"binary={binary} ok={ok} msg={msg[:300]}")
db.session.commit()
if ok:
flash(f"日志轮转已触发: {msg}", "ok")
else:
flash(f"日志轮转失败: {msg}", "error")
return redirect(url_for("ops_logs"))
@app.route("/ops/logrotate", methods=["GET"])
@login_required
def ops_logrotate():
"""Display the active /etc/logrotate.d/squid file (if any) + install form."""
cfg = get_all_config()
default_size = "100M"
default_count = 7
install_size = (cfg.get("logrotate_size") or default_size).strip() or default_size
install_count = (cfg.get("logrotate_count") or str(default_count)).strip() or str(default_count)
install_compress = (cfg.get("logrotate_compress") or "1").strip() not in ("0", "false", "no", "")
paths = _collect_log_paths()
ok, content, msg = log_rotate.read_logrotate_config()
is_installed = ok and bool(content.strip())
preview = ""
if paths:
preview = log_rotate.generate_logrotate_config(
log_paths=paths,
compress=install_compress,
rotate_count=int(install_count),
rotate_size=install_size,
postrotate_cmd=f"{get_config('squid_binary', 'squid')} -k rotate",
)
return render_template(
"ops_logrotate.html",
cfg=cfg,
paths=paths,
install_size=install_size,
install_count=install_count,
install_compress=install_compress,
logrotate_available=log_rotate.is_logrotate_available(),
logrotate_path=log_rotate.LOGROTATE_PATH,
is_installed=is_installed,
current_content=content if is_installed else "",
current_msg=msg,
preview=preview,
)
@app.route("/ops/logrotate/install", methods=["POST"])
@login_required
def ops_logrotate_install():
"""Write a freshly-generated /etc/logrotate.d/squid file."""
paths = _collect_log_paths()
if not paths:
audit("logrotate_install_fail", "no log paths configured")
db.session.commit()
flash("未在「路径设置」里配置任何日志路径,无法生成配置。", "error")
return redirect(url_for("ops_logrotate"))
rotate_size = (request.form.get("rotate_size") or "100M").strip() or "100M"
raw_count = (request.form.get("rotate_count") or "7").strip()
try:
rotate_count = max(1, min(365, int(raw_count)))
except ValueError:
rotate_count = 7
compress = (request.form.get("compress") or "0").strip() not in ("0", "", "false", "no")
postrotate_cmd = f"{get_config('squid_binary', 'squid')} -k rotate"
# Persist chosen settings so the user doesn't re-enter them next time.
set_config("logrotate_size", rotate_size)
set_config("logrotate_count", str(rotate_count))
set_config("logrotate_compress", "1" if compress else "0")
config_content = log_rotate.generate_logrotate_config(
log_paths=paths,
compress=compress,
rotate_count=rotate_count,
rotate_size=rotate_size,
postrotate_cmd=postrotate_cmd,
)
ok, msg = log_rotate.install_logrotate(config_content)
audit(
"logrotate_install",
f"ok={ok} size={rotate_size} count={rotate_count} compress={compress} "
f"paths={len(paths)} msg={msg[:200]}",
)
db.session.commit()
if ok:
flash(f"logrotate 配置已写入: {msg}", "ok")
else:
flash(f"写入失败: {msg}", "error")
return redirect(url_for("ops_logrotate"))
@app.route("/ops/logrotate/uninstall", methods=["POST"])
@login_required
def ops_logrotate_uninstall():
"""Delete /etc/logrotate.d/squid."""
ok, msg = log_rotate.uninstall_logrotate()
audit("logrotate_uninstall", f"ok={ok} msg={msg[:200]}")
db.session.commit()
if ok:
flash(f"已删除 logrotate 配置: {msg}", "ok")
else:
flash(f"删除失败: {msg}", "error")
return redirect(url_for("ops_logrotate"))
# ---------------------------------------------------------------------------
# Routes - P1-2: Alert rule engine (metrics + thresholds)
# ---------------------------------------------------------------------------
# Lazy import keeps the existing top-of-file imports untouched and avoids a
# circular reference (alerts.py also lazy-imports `app` for audit writes).
import alerts as _alerts_mod # noqa: E402
# Where to persist the rule list. Lives under instance/ so it's not part of
# squid.conf backups and survives restarts.
_ALERTS_RULES_PATH = os.path.join(INSTANCE_DIR, "alert_rules.json")
def _alerts_path() -> str:
return _ALERTS_RULES_PATH
def _rule_from_form(name: str = "", metric: str = "", operator: str = "",
threshold: str = "", window_minutes: str = "",
cooldown_minutes: str = "", severity: str = "",
enabled: str = "", notify_webhook: str = "") -> "_alerts_mod.AlertRule":
"""Build an AlertRule from raw form values; coerce/validate so a bad
hand-typed form doesn't corrupt the persisted rules file."""
try:
thr = float(threshold or 0.0)
except (TypeError, ValueError):
thr = 0.0
try:
wm = max(1, int(window_minutes or 5))
except (TypeError, ValueError):
wm = 5
try:
cm = max(0, int(cooldown_minutes or 30))
except (TypeError, ValueError):
cm = 30
sev = severity if severity in _alerts_mod.SEVERITIES else "warning"
op = operator if operator in _alerts_mod.OPERATORS else "gt"
return _alerts_mod.AlertRule(
name=name or "未命名规则",
metric=metric if metric in _alerts_mod.METRICS else "5xx_rate",
operator=op,
threshold=thr,
window_minutes=wm,
enabled=str(enabled) not in ("0", "", "false", "False"),
cooldown_minutes=cm,
severity=sev,
notify_webhook=(notify_webhook or "").strip(),
)
@app.route("/alerts")
@login_required
def alerts_view():
"""List all rules + snapshot of their current values and trigger state."""
rules = _alerts_mod.load_rules(_alerts_path())
try:
status = _alerts_mod.evaluate_rules(
get_parsed_logs(), rules,
cache_dir=get_config("cache_dir", "/var/spool/squid"),
)
except Exception as exc:
status = []
flash(f"评估规则失败: {exc}", "error")
status_map = {idx: s for idx, s in enumerate(status)}
return render_template(
"alerts.html",
rules=rules,
current_status=status,
status_map=status_map,
metrics=list(_alerts_mod.METRICS.items()),
operators=list(_alerts_mod.OPERATORS.items()),
severities=_alerts_mod.SEVERITIES,
)
@app.route("/alerts/add", methods=["POST"])
@login_required
def alerts_add():
"""Append a single rule and persist."""
rule = _rule_from_form(
name=request.form.get("name", "").strip(),
metric=request.form.get("metric", ""),
operator=request.form.get("operator", "gt"),
threshold=request.form.get("threshold", ""),
window_minutes=request.form.get("window_minutes", ""),
cooldown_minutes=request.form.get("cooldown_minutes", ""),
severity=request.form.get("severity", "warning"),
enabled=request.form.get("enabled", "1"),
notify_webhook=request.form.get("notify_webhook", ""),
)
rules = _alerts_mod.load_rules(_alerts_path())
rules.append(rule)
try:
_alerts_mod.save_rules(rules, _alerts_path())
audit("alert_add", f"name={rule.name} metric={rule.metric} "
f"threshold={rule.threshold} op={rule.operator}")
db.session.commit()
flash(f"已添加告警规则: {rule.name}", "ok")
except Exception as exc:
audit("alert_add_fail", f"msg={str(exc)[:200]}")
db.session.commit()
flash(f"添加失败: {exc}", "error")
return redirect(url_for("alerts_view"))
@app.route("/alerts/save", methods=["POST"])
@login_required
def alerts_save():
"""Bulk-save all rules from the in-table editor.
Expects parallel arrays: name[], metric[], operator[], threshold[],
window_minutes[], cooldown_minutes[], severity[], enabled[], and
optional notify_webhook[]. Missing notify_webhook is treated as "".
Rows whose name is blank are skipped (operator removed that row in UI).
"""
form = request.form
names = form.getlist("name[]")
metrics = form.getlist("metric[]")
operators = form.getlist("operator[]")
thresholds = form.getlist("threshold[]")
windows = form.getlist("window_minutes[]")
cooldowns = form.getlist("cooldown_minutes[]")
severities = form.getlist("severity[]")
enableds = form.getlist("enabled[]") # only checked rows appear
webhooks = form.getlist("notify_webhook[]")
# Maps to allow zip() even if one field is shorter.
def at(lst, i, default=""):
return lst[i] if i < len(lst) else default
rules = []
for i, name in enumerate(names):
n = (name or "").strip()
if not n:
continue
rules.append(_rule_from_form(
name=n,
metric=at(metrics, i, "5xx_rate"),
operator=at(operators, i, "gt"),
threshold=at(thresholds, i, "0"),
window_minutes=at(windows, i, "5"),
cooldown_minutes=at(cooldowns, i, "30"),
severity=at(severities, i, "warning"),
# If the row exists in the list, it's checked; missing = off.
enabled=("1" if n in enableds else "0"),
notify_webhook=at(webhooks, i, ""),
))
try:
_alerts_mod.save_rules(rules, _alerts_path())
audit("alert_save", f"count={len(rules)}")
db.session.commit()
flash(f"已保存 {len(rules)} 条告警规则", "ok")
except Exception as exc:
audit("alert_save_fail", f"msg={str(exc)[:200]}")
db.session.commit()
flash(f"保存失败: {exc}", "error")
return redirect(url_for("alerts_view"))
@app.route("/alerts/delete", methods=["POST"])
@login_required
def alerts_delete():
"""Delete a rule by name. If multiple rules share a name, only the first
match is removed - the UI normally shows unique names."""
name = (request.form.get("name") or "").strip()
if not name:
flash("缺少规则名称", "error")
return redirect(url_for("alerts_view"))
rules = _alerts_mod.load_rules(_alerts_path())
before = len(rules)
rules = [r for r in rules if r.name != name]
if len(rules) == before:
flash(f"未找到规则: {name}", "error")
return redirect(url_for("alerts_view"))
try:
_alerts_mod.save_rules(rules, _alerts_path())
audit("alert_delete", f"name={name}")
db.session.commit()
flash(f"已删除告警规则: {name}", "ok")
except Exception as exc:
audit("alert_delete_fail", f"msg={str(exc)[:200]}")
db.session.commit()
flash(f"删除失败: {exc}", "error")
return redirect(url_for("alerts_view"))
@app.route("/alerts/evaluate", methods=["POST"])
@login_required
def alerts_evaluate():
"""Run all rules once against the current access.log snapshot.
Returns JSON so the operator can plug this into a cron job or
integrate with an external monitor. When called from the UI we
still flash a summary and bounce back to /alerts so the user
sees what happened.
"""
rules = _alerts_mod.load_rules(_alerts_path())
try:
status = _alerts_mod.evaluate_rules(
get_parsed_logs(), rules,
cache_dir=get_config("cache_dir", "/var/spool/squid"),
)
except Exception as exc:
if request.headers.get("Accept", "").startswith("application/json") \
or request.args.get("format") == "json":
return jsonify({"ok": False, "error": str(exc),
"triggered": [], "evaluated": 0}), 500
flash(f"评估失败: {exc}", "error")
return redirect(url_for("alerts_view"))
triggered = [
{
"name": s["rule"].name,
"metric": s["rule"].metric,
"value": s["value"],
"threshold": s["threshold"],
"operator": s["operator"],
"severity": s["severity"],
"message": s["message"],
"ts": s["ts"],
}
for s in status if s["triggered"]
]
audit("alert_evaluate", f"rules={len(rules)} triggered={len(triggered)}")
db.session.commit()
payload = {
"ok": True,
"evaluated": len(status),
"triggered_count": len(triggered),
"triggered": triggered,
}
if request.headers.get("Accept", "").startswith("application/json") \
or request.args.get("format") == "json":
return jsonify(payload)
if triggered:
flash(f"评估完成: {len(triggered)} 条规则触发,请查看告警列表", "warning")
else:
flash(f"评估完成: {len(rules)} 条规则均未触发", "ok")
return redirect(url_for("alerts_view"))
@app.route("/alerts/history")
@login_required
def alerts_history():
"""Show recent alert trigger events from the audit_log table."""
triggered = (AuditLog.query
.filter(AuditLog.action == "alert_triggered")
.order_by(AuditLog.timestamp.desc())
.limit(200)
.all())
# Also pull a few support events (add / save / delete / evaluate) so the
# operator can correlate "rule was saved" → "rule then fired".
extras = (AuditLog.query
.filter(AuditLog.action.in_([
"alert_add", "alert_save", "alert_delete",
"alert_evaluate", "alert_add_fail", "alert_save_fail",
"alert_delete_fail",
]))
.order_by(AuditLog.timestamp.desc())
.limit(100)
.all())
return render_template(
"alerts_history.html",
triggered=triggered,
extras=extras,
)
# ---------------------------------------------------------------------------
# P1-3: Multi-instance management
# ---------------------------------------------------------------------------
#
# Endpoints for managing SquidInstance rows. The active instance (one per
# user session) overrides AppConfig for the six _INSTANCE_KEYS in get_config
# / get_all_config above, so the existing config/* / logs / service routes
# transparently target whichever instance the user has selected.
#
# No new imports, no new dependencies.
def _coerce_bool(val) -> bool:
if isinstance(val, bool):
return val
if val is None:
return False
s = str(val).strip().lower()
return s in ("1", "true", "yes", "on", "y", "t")
def _instance_form_payload() -> dict:
"""Extract a SquidInstance-shaped dict from request.form.
Missing fields fall back to model defaults so partial forms (e.g. quick
add with just a name) still produce a usable row.
"""
def _s(k, default=""):
v = request.form.get(k, None)
if v is None:
return default
return str(v).strip()
port_raw = request.form.get("ssh_port", "")
try:
port = int(port_raw) if str(port_raw).strip() else 22
except (TypeError, ValueError):
port = 22
return {
"name": _s("name", ""),
"description": _s("description", ""),
"squid_conf_path": _s("squid_conf_path", "/etc/squid/squid.conf"),
"access_log_path": _s("access_log_path", "/var/log/squid/access.log"),
"cache_log_path": _s("cache_log_path", "/var/log/squid/cache.log"),
"squid_binary": _s("squid_binary", "squid"),
"systemctl": _s("systemctl", "systemctl"),
"squid_service": _s("squid_service", "squid"),
"ssh_host": _s("ssh_host", ""),
"ssh_user": _s("ssh_user", ""),
"ssh_port": port,
"ssh_key_path": _s("ssh_key_path", ""),
"is_local": _coerce_bool(request.form.get("is_local", "1")),
"is_active": _coerce_bool(request.form.get("is_active", "")),
}
@app.route("/instances")
@login_required
def instances_view():
"""List all managed Squid instances."""
instances = SquidInstance.query.order_by(SquidInstance.id.asc()).all()
active = _resolve_active_instance()
return render_template(
"instances.html",
instances=instances,
active=active,
)
@app.route("/instances/add", methods=["POST"])
@login_required
def instances_add():
"""Create a new SquidInstance from the add form."""
data = _instance_form_payload()
if not data["name"]:
flash("实例名称不能为空", "error")
return redirect(url_for("instances_view"))
if len(data["name"]) > 80:
flash("实例名称过长 (最多 80 字符)", "error")
return redirect(url_for("instances_view"))
existing = SquidInstance.query.filter_by(name=data["name"]).first()
if existing is not None:
flash(f"实例名称已存在: {data['name']}", "error")
return redirect(url_for("instances_view"))
try:
inst = SquidInstance(**data)
if data["is_active"]:
# only one row may carry is_active=True
SquidInstance.query.update({SquidInstance.is_active: False})
inst.is_active = True
session["active_instance_id"] = inst.id
db.session.add(inst)
audit("instance_add", f"name={inst.name}")
db.session.commit()
flash(f"已添加实例: {inst.name}", "ok")
except Exception as exc:
db.session.rollback()
audit("instance_add_fail", f"name={data['name']} msg={str(exc)[:200]}")
db.session.commit()
flash(f"添加实例失败: {exc}", "error")
return redirect(url_for("instances_view"))
@app.route("/instances/save", methods=["POST"])
@login_required
def instances_save():
"""Bulk-save edits to all instances.
Each instance submits a row with name_<id>=<new name>, description_<id>,
squid_conf_path_<id>, etc. (single underscore between id and field).
We iterate the existing rows and apply the matching form fields.
Rows missing from the form are left untouched (use /instances/delete
to remove).
"""
instances = SquidInstance.query.order_by(SquidInstance.id.asc()).all()
saved = 0
try:
# Pre-compute new active flag: at most one row may end up active.
active_changed_to = None
for inst in instances:
flag_key = f"is_active_{inst.id}"
if flag_key in request.form:
active_changed_to = inst.id
for inst in instances:
# Field naming convention used by templates/instances.html:
# name_<id>, description_<id>, squid_conf_path_<id>, ...
sid = str(inst.id)
name = request.form.get(f"name_{sid}", "").strip()
if not name:
# empty name => skip this row's edits but keep it as-is
continue
if name != inst.name:
clash = SquidInstance.query.filter(
SquidInstance.name == name,
SquidInstance.id != inst.id,
).first()
if clash is not None:
flash(f"实例名称冲突 (已被 {clash.name} 占用): {name}", "error")
continue
inst.name = name
inst.description = (request.form.get(f"description_{sid}", "") or "").strip()
inst.squid_conf_path = (request.form.get(f"squid_conf_path_{sid}", "")
or inst.squid_conf_path)
inst.access_log_path = (request.form.get(f"access_log_path_{sid}", "")
or inst.access_log_path)
inst.cache_log_path = (request.form.get(f"cache_log_path_{sid}", "")
or inst.cache_log_path)
inst.squid_binary = (request.form.get(f"squid_binary_{sid}", "")
or inst.squid_binary)
inst.systemctl = (request.form.get(f"systemctl_{sid}", "")
or inst.systemctl)
inst.squid_service = (request.form.get(f"squid_service_{sid}", "")
or inst.squid_service)
inst.ssh_host = (request.form.get(f"ssh_host_{sid}", "") or "").strip()
inst.ssh_user = (request.form.get(f"ssh_user_{sid}", "") or "").strip()
port_raw = request.form.get(f"ssh_port_{sid}", "")
try:
inst.ssh_port = int(port_raw) if str(port_raw).strip() else inst.ssh_port
except (TypeError, ValueError):
pass
inst.ssh_key_path = (request.form.get(f"ssh_key_path_{sid}", "")
or inst.ssh_key_path)
inst.is_local = _coerce_bool(request.form.get(f"is_local_{sid}", "1"))
saved += 1
# Reconcile the is_active flag - only one row at a time
for inst in instances:
inst.is_active = (inst.id == active_changed_to)
if active_changed_to is not None:
session["active_instance_id"] = active_changed_to
audit("instance_save", f"updated={saved} active={active_changed_to}")
db.session.commit()
flash(f"已保存 {saved} 个实例", "ok")
except Exception as exc:
db.session.rollback()
audit("instance_save_fail", f"msg={str(exc)[:200]}")
db.session.commit()
flash(f"保存失败: {exc}", "error")
return redirect(url_for("instances_view"))
@app.route("/instances/delete", methods=["POST"])
@login_required
def instances_delete():
"""Delete one instance by id."""
raw_id = request.form.get("id", "")
try:
inst_id = int(raw_id)
except (TypeError, ValueError):
flash("无效的实例 ID", "error")
return redirect(url_for("instances_view"))
inst = db.session.get(SquidInstance, inst_id)
if inst is None:
flash(f"实例不存在: #{inst_id}", "error")
return redirect(url_for("instances_view"))
name = inst.name
try:
# If we're deleting the active one, clear the session pointer.
if session.get("active_instance_id") == inst.id:
session.pop("active_instance_id", None)
db.session.delete(inst)
# If a different row is still flagged is_active=True that's fine;
# otherwise leave it False and let the next request pick one (or none).
audit("instance_delete", f"name={name}")
db.session.commit()
flash(f"已删除实例: {name}", "ok")
except Exception as exc:
db.session.rollback()
audit("instance_delete_fail", f"name={name} msg={str(exc)[:200]}")
db.session.commit()
flash(f"删除失败: {exc}", "error")
return redirect(url_for("instances_view"))
@app.route("/instances/activate/<int:inst_id>", methods=["POST"])
@login_required
def instances_activate(inst_id: int):
"""Switch the current session's active instance."""
inst = db.session.get(SquidInstance, inst_id)
if inst is None:
flash(f"实例不存在: #{inst_id}", "error")
return redirect(url_for("instances_view"))
try:
# Mark this row active and clear others - keeps DB state consistent
# with the session pointer.
SquidInstance.query.update({SquidInstance.is_active: False})
inst.is_active = True
session["active_instance_id"] = inst.id
# also bust the per-request cache so the redirect sees the new value
g.pop("active_instance", None)
audit("instance_activate", f"name={inst.name}")
db.session.commit()
flash(f"已切换到实例: {inst.name}", "ok")
except Exception as exc:
db.session.rollback()
audit("instance_activate_fail", f"id={inst_id} msg={str(exc)[:200]}")
db.session.commit()
flash(f"切换失败: {exc}", "error")
# Redirect to wherever the user came from when possible, default to /
nxt = request.form.get("next") or request.args.get("next") or url_for("dashboard")
# only allow same-app paths
if not nxt.startswith("/"):
nxt = url_for("dashboard")
return redirect(nxt)
@app.route("/api/instances/test/<int:inst_id>")
@login_required
def instances_test(inst_id: int):
"""Test an instance: check conf_path readable + log paths exist.
For local instances this uses os.access / os.path.isfile directly.
For remote (is_local=False) instances we report the same checks as
'not checked' - SSH-based probing would need paramiko which we don't
want to add as a hard dependency for this MVP endpoint.
"""
inst = db.session.get(SquidInstance, inst_id)
if inst is None:
return jsonify({"ok": False, "error": "instance not found"}), 404
checks = []
if not inst.is_local:
checks.append({"key": "remote", "ok": None,
"detail": "远程实例需要 SSH 凭据 (本接口未探测)"})
else:
conf = inst.squid_conf_path or ""
if not conf:
checks.append({"key": "squid_conf_path", "ok": False,
"detail": "未配置"})
else:
ok = os.path.isfile(conf) and os.access(conf, os.R_OK)
checks.append({"key": "squid_conf_path", "ok": ok,
"detail": conf + (" (可读)" if ok else " (不可读或不存在)")})
for key in ("access_log_path", "cache_log_path"):
p = getattr(inst, key, "") or ""
if not p:
checks.append({"key": key, "ok": False, "detail": "未配置"})
else:
ok = os.path.isfile(p)
checks.append({"key": key, "ok": ok,
"detail": p + (" (存在)" if ok else " (不存在)")})
overall = all(c["ok"] for c in checks if c["ok"] is not None)
return jsonify({
"ok": overall,
"instance": {"id": inst.id, "name": inst.name},
"is_local": inst.is_local,
"checks": checks,
})
# ---------------------------------------------------------------------------
# P1-4: squidclient mgr: real-time performance metrics
# ---------------------------------------------------------------------------
def _resolve_perf_connection() -> dict:
"""Pick squidclient binary, host, port and optional mgr password.
All values are user-configurable via get_config() but ship with sensible
defaults so a fresh install works out of the box.
"""
return {
"squidclient": get_config("squidclient_binary", "squidclient") or "squidclient",
"host": get_config("squid_mgr_host", "127.0.0.1") or "127.0.0.1",
"port": int(get_config("squid_mgr_port", "3128") or "3128"),
"password": get_config("squid_mgr_password", "") or "",
"timeout": int(get_config("squid_mgr_timeout", "5") or "5"),
}
@app.route("/performance")
@login_required
def performance_view():
"""Real-time performance dashboard, fed by squidclient mgr:*."""
conn = _resolve_perf_connection()
summary = squid_mgr.get_performance_summary(**conn)
# Lightweight breadcrumb for the sidebar active state
g.page_title = "实时性能"
audit("performance_view", f"ok={summary.get('fetched_ok')}")
db.session.commit()
return render_template(
"performance.html",
summary=summary,
conn=conn,
format_bytes=log_parser.format_bytes,
format_duration=log_parser.format_duration,
)
@app.route("/performance/refresh", methods=["POST"])
@login_required
def performance_refresh():
"""AJAX endpoint: re-run squidclient mgr:* and return JSON for the UI."""
conn = _resolve_perf_connection()
summary = squid_mgr.get_performance_summary(**conn)
audit("performance_refresh", f"ok={summary.get('fetched_ok')}")
db.session.commit()
# Strip the raw_info blob from the JSON payload to keep the response
# small; the page only needs it for the <details> fold-out which is
# fetched lazily below via /performance/raw.
payload = {k: v for k, v in summary.items() if k != "raw_info"}
return jsonify(payload)
@app.route("/performance/raw/<section>")
@login_required
def performance_raw(section: str):
"""Return the raw mgr:<section> output for the fold-out <details> block.
Whitelisted to a small set of sections to avoid letting arbitrary callers
tunnel commands through squidclient — squidclient doesn't take arbitrary
file args so this is mostly defense-in-depth.
"""
allowed = {"info", "5min", "counters", "storedir", "io", "menu", "config"}
if section not in allowed:
return jsonify({"ok": False, "error": f"section not allowed: {section}"}), 400
conn = _resolve_perf_connection()
ok, raw, err = squid_mgr.fetch_mgr_info(mgr=section, **conn)
audit("performance_raw", f"section={section} ok={ok}")
db.session.commit()
return jsonify({"ok": ok, "section": section, "raw": raw, "error": err})
# ---------------------------------------------------------------------------
# P1-5: traffic anomaly detection
# ---------------------------------------------------------------------------
# Local import - keeps the top-of-file import block stable and means an
# anomaly.py typo can never break module import of app.py.
try:
import anomaly as _anomaly
except Exception: # pragma: no cover - defensive only
_anomaly = None
def _parse_threshold_float(name: str, default: float, lo: float = 0.0) -> float:
"""Read a numeric threshold from request.args with safe fallback."""
try:
val = request.args.get(name, type=str)
if val is None or val == "":
return default
f = float(val)
return max(lo, f)
except Exception:
return default
def _parse_threshold_int(name: str, default: int, lo: int = 1) -> int:
try:
val = request.args.get(name, type=str)
if val is None or val == "":
return default
return max(lo, int(float(val)))
except Exception:
return default
def _compute_anomaly_summary(
entries,
spike_ratio: float = 3.0,
large_mb: float = 100.0,
small_kb: float = 1.0,
min_small_req: int = 100,
) -> dict:
"""Build a summary honoring caller-supplied thresholds; never raises."""
if _anomaly is None:
return {
"anomalous_clients": [], "large_requests": [],
"high_freq_small": [], "new_clients": [],
"total_anomalies": 0, "thresholds": {},
"header_stats": {},
}
try:
# Re-use the module-level analyzer but override thresholds
large = _anomaly.detect_large_requests(
entries, size_threshold_mb=large_mb, limit=100)
small = _anomaly.detect_high_freq_small(
entries, size_threshold_kb=small_kb,
min_requests=min_small_req, limit=50)
new_clients = _anomaly.detect_new_clients(entries, limit=50)
# For spikes we need to filter by the requested ratio; run the
# module default then post-filter to avoid touching anom.py
spikes_all = _anomaly.detect_client_anomalies(entries)
spikes = [
a for a in spikes_all
if (
a.get("ratio") == float("inf")
or (
isinstance(a.get("ratio"), (int, float))
and a["ratio"] > spike_ratio
)
)
]
# re-label severities against caller threshold
for a in spikes:
r = a.get("ratio", 0)
if r == float("inf") or (isinstance(r, (int, float)) and r > spike_ratio * 3):
# treat anything > 3x the warn threshold as critical
# when warn threshold was raised by the user
a["severity"] = "warning" if isinstance(r, (int, float)) and r <= spike_ratio * 3 else "critical"
else:
a["severity"] = "warning"
return {
"anomalous_clients": spikes,
"large_requests": large,
"high_freq_small": small,
"new_clients": new_clients,
"total_anomalies": len(spikes) + len(large) + len(small) + len(new_clients),
"thresholds": {
"spike_warn_ratio": spike_ratio,
"spike_crit_ratio": max(spike_ratio * 3, 10.0),
"large_size_mb": large_mb,
"small_size_kb": small_kb,
"min_small_requests": min_small_req,
},
"header_stats": {},
}
except Exception:
return {
"anomalous_clients": [], "large_requests": [],
"high_freq_small": [], "new_clients": [],
"total_anomalies": 0, "thresholds": {},
"header_stats": {},
}
def _build_anomaly_summary(entries) -> dict:
"""Build the full summary using request-derived thresholds."""
spike_ratio = _parse_threshold_float("spike_ratio", 3.0, lo=1.0)
large_mb = _parse_threshold_float("large_mb", 100.0, lo=1.0)
small_kb = _parse_threshold_float("small_kb", 1.0, lo=0.1)
min_small_req = _parse_threshold_int("min_small_req", 100, lo=1)
summary = _compute_anomaly_summary(
entries,
spike_ratio=spike_ratio,
large_mb=large_mb,
small_kb=small_kb,
min_small_req=min_small_req,
)
# header stats
try:
if entries:
n = len(entries)
cut = max(1, int(n * 0.2))
current = entries[cut:]
summary["header_stats"] = {
"current_window_count": len(current),
"current_window_clients": len({(e.get("client") if isinstance(e, dict) else "") for e in current}),
}
except Exception:
summary.setdefault("header_stats", {})
return summary
@app.route("/anomalies")
@login_required
def anomalies_view():
"""Render the anomaly dashboard page."""
try:
entries = get_parsed_logs()
except Exception:
entries = []
summary = _build_anomaly_summary(entries)
g.page_title = "流量异常"
try:
audit("anomalies_view", f"anomalies={summary.get('total_anomalies', 0)}")
db.session.commit()
except Exception:
pass
return render_template(
"anomalies.html",
summary=summary,
thresholds=summary.get("thresholds", {}),
entries_count=len(entries),
)
@app.route("/anomalies/refresh", methods=["POST"])
@login_required
def anomalies_refresh():
"""AJAX endpoint: force re-read of access.log and return anomaly counts."""
try:
entries = get_parsed_logs(force=True)
except Exception:
entries = []
summary = _build_anomaly_summary(entries)
try:
audit("anomalies_refresh", f"anomalies={summary.get('total_anomalies', 0)}")
db.session.commit()
except Exception:
pass
return jsonify({
"ok": True,
"total_anomalies": summary.get("total_anomalies", 0),
"anomalous_clients": len(summary.get("anomalous_clients", [])),
"large_requests": len(summary.get("large_requests", [])),
"high_freq_small": len(summary.get("high_freq_small", [])),
"new_clients": len(summary.get("new_clients", [])),
})
@app.route("/api/anomalies")
@login_required
def api_anomalies():
"""JSON endpoint: full anomaly summary, suitable for external integrations."""
try:
entries = get_parsed_logs()
except Exception:
entries = []
summary = _build_anomaly_summary(entries)
try:
audit("api_anomalies", f"anomalies={summary.get('total_anomalies', 0)}")
db.session.commit()
except Exception:
pass
return jsonify(summary)
# ---------------------------------------------------------------------------
# P2-2: Export endpoints (CSV / JSON)
# ---------------------------------------------------------------------------
# These routes share filter parameters with the corresponding view pages
# (logs, anomalies, audit) so the UI can hand off "the same query, but
# download the result" by passing the existing GET query-string through.
#
# All routes are GET-only, login-protected, and stream the file as an
# attachment so browsers save instead of rendering inline.
# ---------------------------------------------------------------------------
# Local import - keeps the top of this module unchanged.
import exporters # noqa: E402
def _export_logs_payload():
"""Build (entries, filters) for export, reusing ``logs_view`` logic.
Mirrors the persistent / legacy branching in :func:`logs_view` so
exports stay consistent with what the user sees on the page.
"""
cfg = get_all_config()
use_persistent = cfg.get("log_use_persistent", "1") not in ("0", "false", "False")
f_client = request.args.get("client", "").strip()
f_method = request.args.get("method", "").strip()
f_result = request.args.get("result_code", "").strip()
f_host = request.args.get("host", "").strip()
f_url = request.args.get("url", "").strip()
f_min_size = request.args.get("min_size", type=int)
f_max_size = request.args.get("max_size", type=int)
f_min_elapsed = request.args.get("min_elapsed", type=int)
f_max_elapsed = request.args.get("max_elapsed", type=int)
f_start = request.args.get("start_time", "").strip()
f_end = request.args.get("end_time", "").strip()
instance_id = 0
try:
inst = _resolve_active_instance()
if inst is not None:
instance_id = inst.id
except Exception:
instance_id = 0
base_filters = {
"instance_id": instance_id,
"client": f_client or None,
"method": f_method or None,
"result_code": f_result or None,
"host": f_host or None,
"url": f_url or None,
"min_size": f_min_size,
"max_size": f_max_size,
"min_elapsed": f_min_elapsed,
"max_elapsed": f_max_elapsed,
"start_time": f_start or None,
"end_time": f_end or None,
}
if use_persistent:
import log_storage
try:
if _should_auto_sync(instance_id):
path = _resolve_access_log_path()
if os.path.isfile(path):
log_storage.sync_log_to_db(path=path, instance_id=instance_id)
except Exception:
pass
rows = log_storage.query_logs({**base_filters, "limit": 100000})
return rows, base_filters
# legacy: parse file tail then filter in memory
entries = get_parsed_logs()
try:
filtered = log_parser.filter_entries(
entries,
client=f_client or None, method=f_method or None,
result_code=f_result or None, host=f_host or None,
url=f_url or None,
min_size=f_min_size, max_size=f_max_size,
min_elapsed=f_min_elapsed, max_elapsed=f_max_elapsed,
limit=100000,
)
except Exception:
filtered = []
return filtered, base_filters
def _audit_query():
"""Build a SQLAlchemy query for the audit log honoring ``?username`` / ``?action``."""
q = AuditLog.query.order_by(AuditLog.timestamp.desc())
f_user = request.args.get("username", "").strip()
f_action = request.args.get("action", "").strip()
if f_user:
q = q.filter(AuditLog.username == f_user)
if f_action:
# case-insensitive substring match - users often pass partial actions
# like "login" expecting to also catch "login_fail"
q = q.filter(AuditLog.action.ilike(f"%{f_action}%"))
return q
@app.route("/export/logs.csv")
@login_required
def export_logs_csv():
"""Export the current filtered log view to a CSV file."""
try:
entries, _filters = _export_logs_payload()
except Exception:
entries = []
csv_text = exporters.entries_to_csv(entries)
try:
audit("export_logs_csv", f"count={len(entries) if isinstance(entries, list) else 0}")
db.session.commit()
except Exception:
pass
return exporters.csv_response(
csv_text, exporters.timestamped_filename("squid_logs", "csv")
)
@app.route("/export/logs.json")
@login_required
def export_logs_json():
"""Export the current filtered log view to a JSON file."""
try:
entries, _filters = _export_logs_payload()
except Exception:
entries = []
json_text = exporters.entries_to_json(entries)
try:
audit("export_logs_json", f"count={len(entries) if isinstance(entries, list) else 0}")
db.session.commit()
except Exception:
pass
return exporters.json_response(
json_text, exporters.timestamped_filename("squid_logs", "json")
)
@app.route("/export/stats.json")
@login_required
def export_stats_json():
"""Export the current KPI summary to a JSON file."""
try:
stats = get_stats()
except Exception:
stats = {}
# Add a top-level generated_at for downstream consumers.
try:
from datetime import datetime as _dt
if isinstance(stats, dict):
stats = dict(stats)
stats["generated_at"] = _dt.now().isoformat(timespec="seconds")
stats["active_instance"] = active_instance_name()
except Exception:
pass
json_text = exporters.stats_to_json(stats)
try:
audit("export_stats_json")
db.session.commit()
except Exception:
pass
return exporters.json_response(
json_text, exporters.timestamped_filename("squid_stats", "json")
)
@app.route("/export/anomalies.csv")
@login_required
def export_anomalies_csv():
"""Export the current anomaly summary (long-form CSV) to a file."""
try:
entries = get_parsed_logs()
except Exception:
entries = []
try:
summary = _build_anomaly_summary(entries)
except Exception:
summary = {}
csv_text = exporters.anomalies_to_csv(summary)
try:
audit("export_anomalies_csv",
f"total={summary.get('total_anomalies', 0) if isinstance(summary, dict) else 0}")
db.session.commit()
except Exception:
pass
return exporters.csv_response(
csv_text, exporters.timestamped_filename("squid_anomalies", "csv")
)
@app.route("/export/anomalies.json")
@login_required
def export_anomalies_json():
"""Export the current anomaly summary to a JSON file."""
try:
entries = get_parsed_logs()
except Exception:
entries = []
try:
summary = _build_anomaly_summary(entries)
except Exception:
summary = {}
json_text = exporters.anomalies_to_json(summary)
try:
audit("export_anomalies_json",
f"total={summary.get('total_anomalies', 0) if isinstance(summary, dict) else 0}")
db.session.commit()
except Exception:
pass
return exporters.json_response(
json_text, exporters.timestamped_filename("squid_anomalies", "json")
)
@app.route("/export/audit.csv")
@login_required
def export_audit_csv():
"""Export audit log rows to a CSV file.
Accepts ``?username=...`` (exact match) and ``?action=...`` (case-
insensitive substring) so the export matches the on-screen filter.
A hard cap of 100k rows keeps the response from blowing up if the
audit table grows huge.
"""
try:
rows = _audit_query().limit(100000).all()
except Exception:
rows = []
csv_text = exporters.audit_to_csv(rows)
try:
audit("export_audit_csv", f"count={len(rows)}")
db.session.commit()
except Exception:
pass
return exporters.csv_response(
csv_text, exporters.timestamped_filename("squid_audit", "csv")
)
@app.route("/export/audit.json")
@login_required
def export_audit_json():
"""Export audit log rows to a JSON file."""
try:
rows = _audit_query().limit(100000).all()
except Exception:
rows = []
json_text = exporters.audit_to_json(rows)
try:
audit("export_audit_json", f"count={len(rows)}")
db.session.commit()
except Exception:
pass
return exporters.json_response(
json_text, exporters.timestamped_filename("squid_audit", "json")
)
# ---------------------------------------------------------------------------
# P2-3: GeoIP map visualisation
# ---------------------------------------------------------------------------
#
# Provides three routes:
# * GET /geoip - page rendering Top 20 clients on a Leaflet map
# * GET /api/geoip/clients - JSON for client IPs (top 50)
# * GET /api/geoip/hosts - JSON for destination hosts (top 50, after DNS)
#
# Backends are chosen in geoip_lookup.lookup():
# * online -> ip-api.com (default; no key required)
# * offline -> MaxMind GeoLite2-City .mmdb if ``maxminddb`` is installed
# AND a db_path is configured via AppConfig(geoip_mmdb)
# ---------------------------------------------------------------------------
def _resolve_geoip_backend() -> tuple[bool, str]:
"""Pick the right backend for this request.
Returns ``(use_online, db_path)``:
* ``(True, '')`` -> use ip-api.com (default),
* ``(False, db)`` -> use offline mmdb at ``db``,
* ``(False, '')`` -> no backend, calls will fail with an error.
"""
db_path = ""
try:
db_path = (get_config("geoip_mmdb") or "").strip()
except Exception:
db_path = ""
use_offline = bool(db_path)
# If an offline DB is configured *and* maxminddb is importable, use it.
if use_offline:
try:
import maxminddb # type: ignore # noqa: F401
except Exception:
# fall back to online so the page still works
return True, ""
return False, db_path
return True, ""
def _resolve_host_ip(host: str) -> str:
"""Resolve a hostname to an IP. Returns '' on failure."""
if not host:
return ""
try:
return socket.gethostbyname(host)
except Exception:
return ""
def _client_rows(use_online: bool, db_path: str, top_n: int, source: str) -> list[dict]:
"""Build geo-enriched rows for ``source`` in {"clients", "hosts"}.
Each row is ``{ip, country, city, country_name, region, lat, lon,
count, bytes, private}`` (some keys may be empty when lookup fails).
"""
try:
entries = get_parsed_logs()
except Exception:
entries = []
if not entries:
return []
stats = log_parser.aggregate_stats(entries)
if source == "clients":
raw = list(stats.get("top_clients") or [])[:top_n]
for row in raw:
row["_key"] = row.get("client") or ""
else:
raw = list(stats.get("top_hosts") or [])[:top_n]
for row in raw:
row["_key"] = row.get("host") or ""
out: list[dict] = []
for row in raw:
key = row["_key"]
ip = key
if source == "hosts":
ip = _resolve_host_ip(key)
lookup_ip = ip or key # fall back to the hostname so we still get a row
try:
geo = geoip_lookup.lookup(
lookup_ip, db_path=db_path, use_online=use_online
)
except Exception as e: # safety net
geo = geoip_lookup.GeoIPResult(ip=lookup_ip, error=str(e))
out.append({
"ip": ip or key,
"host": key if source == "hosts" else "",
"country": geo.country,
"country_name": geo.country_name,
"city": geo.city,
"region": geo.region,
"latitude": geo.latitude,
"longitude": geo.longitude,
"asn": geo.asn,
"isp": geo.isp,
"count": int(row.get("count") or 0),
"bytes": int(row.get("bytes") or 0),
"error": geo.error,
"source": source,
})
return out
@app.route("/geoip")
@login_required
def geoip_view():
"""Render the GeoIP map page (Top 20 clients)."""
try:
entries = get_parsed_logs()
except Exception:
entries = []
entries_count = len(entries)
use_online, db_path = _resolve_geoip_backend()
data_source = (
"ip-api.com (online)" if use_online
else (f"GeoLite2-City ({db_path})" if db_path else "none")
)
# Page only resolves the top 20 to keep the map load light.
rows = _client_rows(use_online, db_path, top_n=20, source="clients")
geo_rows = [r for r in rows if not r["error"] and r["latitude"]]
try:
audit("geoip_view", f"total={len(rows)} geo={len(geo_rows)} backend={data_source}")
db.session.commit()
except Exception:
pass
return render_template(
"geoip.html",
rows=rows,
geo_rows=geo_rows,
total_clients=len(rows),
resolved_clients=len(geo_rows),
countries=len({r["country"] for r in geo_rows if r["country"]}),
cities=len({(r["country"], r["city"]) for r in geo_rows if r["city"]}),
data_source=data_source,
use_online=use_online,
db_path=db_path,
entries_count=entries_count,
format_bytes=log_parser.format_bytes,
csrf_token_value=security.generate_csrf_token(),
)
@app.route("/api/geoip/clients")
@login_required
def api_geoip_clients():
"""Top 50 client IPs enriched with GeoIP data."""
use_online, db_path = _resolve_geoip_backend()
rows = _client_rows(use_online, db_path, top_n=50, source="clients")
try:
audit("api_geoip_clients", f"rows={len(rows)}")
db.session.commit()
except Exception:
pass
return jsonify({
"ok": True,
"source": "clients",
"backend": "online" if use_online else "offline",
"rows": rows,
})
@app.route("/api/geoip/hosts")
@login_required
def api_geoip_hosts():
"""Top 50 destination hosts. Hostnames are resolved to IPs first;
rows whose hostname cannot be resolved are skipped from the map
but still reported so the user sees what was attempted."""
use_online, db_path = _resolve_geoip_backend()
rows = _client_rows(use_online, db_path, top_n=50, source="hosts")
placed = [r for r in rows if not r["error"] and r["latitude"]]
skipped = [r for r in rows if r["error"] and not r.get("host")]
try:
audit(
"api_geoip_hosts",
f"rows={len(rows)} placed={len(placed)} skipped={len(skipped)}",
)
db.session.commit()
except Exception:
pass
return jsonify({
"ok": True,
"source": "hosts",
"backend": "online" if use_online else "offline",
"rows": rows,
"placed": len(placed),
"skipped": len(skipped),
})
# ---------------------------------------------------------------------------
# Routes - per-client 24h trend detail page (P2-4)
# ---------------------------------------------------------------------------
#
# Squid's "突增" anomaly page (anomalies.html) can flag a client but offers
# no historical context: an operator clicks through and only sees a flat
# log list. /clients/<ip> fixes that by giving each client its own page:
# 24h hourly trend line, top hosts, top URLs, recent request samples, and
# KPIs (total requests / bytes / first-seen / last-seen / avg size).
#
# Security: the IP path parameter is validated against a strict whitelist
# (letters/digits/dot/dash/colon/percent, length 1-64, no "..", "/", "\").
# Anything else returns 404 - we never let an untrusted string reach the
# filesystem, SQL filter, or template without a sanity check first.
# Whitelist for the <ip> path parameter: letters, digits, dot, dash,
# colon (IPv6), percent (IPv6 zone id, e.g. fe80::1%eth0). 64-char ceiling.
# `re` is imported at module scope (line 14).
_CLIENT_RE = re.compile(r"^[A-Za-z0-9.\-:%]{1,64}$")
def _is_safe_client_id(s: str) -> bool:
"""Strict validator for the /clients/<ip> path parameter.
Returns True only for values that look like an IP (v4 / v6 / hostname).
Rejects: empty, "..", "/", "\\", whitespace, anything URL-encoded that
would re-introduce a separator after percent-decoding.
"""
if not s:
return False
if not _CLIENT_RE.match(s):
return False
if ".." in s or "/" in s or "\\" in s:
return False
return True
def _build_client_trend(entries: list[log_parser.LogEntry]) -> dict:
"""Aggregate a list of LogEntry rows that all share a single client.
Returns KPIs, hourly buckets (24, requests + bytes), top hosts,
top URLs, and the 50 most-recent request samples. Designed to feed
/clients/<ip> directly.
"""
from collections import Counter, defaultdict
if not entries:
return {
"total_requests": 0,
"total_bytes": 0,
"avg_size_bytes": 0.0,
"time_start": None,
"time_end": None,
"hourly": [
{"hour": h, "requests": 0, "bytes": 0} for h in range(24)
],
"top_hosts": [],
"top_urls": [],
"samples": [],
}
total = len(entries)
total_bytes = sum(int(e["size"] or 0) for e in entries)
avg_size = total_bytes / total if total > 0 else 0.0
timestamps = [e["timestamp"] for e in entries if e["timestamp"]]
time_start = min(timestamps) if timestamps else None
time_end = max(timestamps) if timestamps else None
# 24h hourly buckets keyed on the UTC hour-of-day of each entry's
# timestamp. Matches the structure of dashboard.html's `stats.hourly`
# so the Chart.js code can be shared.
by_hour = defaultdict(int)
by_hour_bytes = defaultdict(int)
for e in entries:
if e["timestamp"]:
h = e["timestamp"].hour
by_hour[h] += 1
by_hour_bytes[h] += int(e["size"] or 0)
hourly = [
{"hour": h, "requests": by_hour.get(h, 0), "bytes": by_hour_bytes.get(h, 0)}
for h in range(24)
]
# Top hosts (count + bytes)
host_count = Counter()
host_bytes = defaultdict(int)
for e in entries:
if e["host"]:
host_count[e["host"]] += 1
host_bytes[e["host"]] += int(e["size"] or 0)
top_hosts = [
{"host": h, "count": n, "bytes": host_bytes[h]}
for h, n in host_count.most_common(20)
]
# Top URLs
url_count = Counter(e["url"] for e in entries if e["url"])
top_urls = [{"url": u, "count": n} for u, n in url_count.most_common(20)]
# Last 50 samples (newest first). Sort in-place on a copy so we don't
# mutate the caller's list ordering.
samples = sorted(
entries,
key=lambda e: float(e.get("time") or 0.0),
reverse=True,
)[:50]
return {
"total_requests": total,
"total_bytes": total_bytes,
"avg_size_bytes": avg_size,
"time_start": time_start,
"time_end": time_end,
"hourly": hourly,
"top_hosts": top_hosts,
"top_urls": top_urls,
"samples": samples,
}
@app.route("/clients/<ip>")
@login_required
def client_detail(ip: str):
"""Per-client 24h trend + top hosts + sample requests.
Validates that ``ip`` looks like an IP/hostname (whitelist only) so the
value cannot escape into a filesystem path, SQL filter, or template
context as anything other than an inert string. Untrusted input ->
404 (not 400) so probing doesn't leak whether the URL space is "real".
"""
if not _is_safe_client_id(ip):
abort(404)
all_entries = get_parsed_logs()
# filter_entries does substring match; we use equality to avoid a client
# like "10.0.0.1" accidentally matching "10.0.0.10".
client_entries = [e for e in all_entries if e["client"] == ip]
trend = _build_client_trend(client_entries)
cfg = get_all_config()
# Audit the lookup so "who looked at this client" is recorded.
try:
audit("client_detail", f"ip={ip} requests={trend['total_requests']}")
db.session.commit()
except Exception:
pass
return render_template(
"client_detail.html",
client=ip,
trend=trend,
cfg=cfg,
format_bytes=log_parser.format_bytes,
format_duration=log_parser.format_duration,
has_data=bool(client_entries),
)
# ---------------------------------------------------------------------------
# P3-1: Dark / light theme toggle (cookie-backed, server-side endpoint)
# ---------------------------------------------------------------------------
# Why this lives here:
# - The toggle button (templates/_theme_toggle.html) writes the cookie
# client-side via JS for instant feedback. This server endpoint exists so
# non-JS clients (curl, scripts, browser w/o JS) can still flip the theme
# and so any server-rendered redirect can carry the user's preference.
# - Theme is purely a UI preference, not security-sensitive. We monkey-patch
# security.csrf_protect (the function reference) so /theme/set is exempt
# without touching security.py. The patch is narrow: only this endpoint,
# only on POST, and only when a valid theme value is supplied.
# ---------------------------------------------------------------------------
_VALID_THEMES = ("dark", "light")
_THEME_COOKIE_AGE = 365 * 24 * 3600 # 1 year, per spec
def _read_theme_cookie():
"""Return 'light' or 'dark' from the theme cookie. Defaults to 'dark'."""
val = request.cookies.get("theme", "dark")
if val not in _VALID_THEMES:
return "dark"
return val
@app.context_processor
def _theme_globals():
"""Expose the active theme to every template.
Templates reference this as `{{ theme }}`. We read the cookie on every
render so the very first response after a toggle already reflects the
new theme (no stale cache, no extra round-trip).
"""
return {"theme": _read_theme_cookie()}
# Patch security.csrf_protect (a runtime function reference) so /theme/set
# is exempt from CSRF. Theme is not a security-sensitive operation; the
# cookie value is also writable from any browser via document.cookie.
_orig_csrf_protect = security.csrf_protect
def _csrf_protect_themed():
"""Wrapped csrf_protect that exempts the theme_set endpoint only."""
if request.endpoint == "theme_set":
return None
return _orig_csrf_protect()
# Replace the global reference; _security_gate() looks it up by name on
# every request, so the wrapper takes effect immediately.
security.csrf_protect = _csrf_protect_themed
@app.route("/theme/set", methods=["POST"])
def theme_set():
"""Set the theme cookie and redirect back to the referring page.
Body: theme=dark|light (form-encoded). Any value outside this set is
silently coerced to 'dark' so a malformed call can't poison the cookie.
Referer is used for the redirect; if missing, we fall back to the
dashboard (or login page if not authenticated).
"""
new_theme = (request.form.get("theme") or "dark").strip().lower()
if new_theme not in _VALID_THEMES:
new_theme = "dark"
# Pick a safe redirect target. Referer is attacker-influenced but here
# it's only used as a same-origin UX nicety; we constrain it to a
# same-host path to avoid open-redirect abuse.
target = request.referrer or ""
if not target:
target = url_for("dashboard") if session.get("user_id") else url_for("login")
else:
from urllib.parse import urlparse
ref = urlparse(target)
# Only follow same-host referers; drop query/fragment from scheme+netloc
req_host = request.host
if ref.netloc and ref.netloc != req_host:
target = url_for("dashboard") if session.get("user_id") else url_for("login")
elif ref.path:
target = ref.path + (("?" + ref.query) if ref.query else "")
resp = redirect(target)
resp.set_cookie(
"theme",
new_theme,
max_age=_THEME_COOKIE_AGE,
path="/",
samesite="Lax",
httponly=False, # readable by client JS (the toggle reads it on load)
)
return resp
# ===========================================================================
# P3-3: WebSSH terminal (browser → WebSocket → paramiko → remote SSH)
# ===========================================================================
#
# Architecture:
# - GET /terminal renders templates/terminal.html (xterm.js UI)
# - WS /ws/terminal bi-directional bridge between xterm.js and a
# paramiko SSH channel. One TCP stream per WS.
#
# Hard requirements (declared in requirements.txt):
# - paramiko>=2.10.0 SSH2 client (Transport / Channel)
# - flask-sock>=0.7.0 WebSocket route decorator for Flask
#
# Both are imported lazily here so a missing paramiko doesn't crash the
# whole app on startup; in that case the WebSocket route degrades to
# {"type": "error", ...} and the GET /terminal page shows a banner.
try:
import paramiko as _paramiko # type: ignore
except Exception: # ImportError, syntax errors on exotic platforms, etc.
_paramiko = None # type: ignore
try:
from flask_sock import Sock as _Sock # type: ignore
except Exception:
_Sock = None # type: ignore
if _Sock is not None:
_sock = _Sock(app)
else:
_sock = None # degraded mode
def _terminal_audit(action: str, detail: str):
"""Write a terminal-related audit entry using the standard audit() helper.
Falls back to a no-op if called outside of a request context (the WS
handler may not have one for some flows).
"""
try:
audit(f"terminal_{action}", detail[:2000])
db.session.commit()
except Exception:
try:
db.session.rollback()
except Exception:
pass
def _terminal_user_role() -> str:
"""Return the current user's role, or 'guest' if not logged in.
We reuse the session cookie because flask_sock decorators don't
preserve the request stack in a way Flask-Login would.
"""
try:
uid = session.get("user_id")
if not uid:
return "guest"
u = db.session.get(User, uid)
return (u.role or "") if u else "guest"
except Exception:
return "guest"
def _terminal_login_ok() -> bool:
"""True iff the WS connection is from a logged-in admin."""
return bool(session.get("user_id")) and _terminal_user_role() == "admin"
def _terminal_get_instance():
"""Resolve the active SquidInstance for the current WS request, or None."""
try:
return _resolve_active_instance()
except Exception:
return None
@app.route("/terminal", methods=["GET"])
@login_required
def terminal_view():
"""Browser page for the WebSSH terminal.
Admin-only: any other role gets a 403. The sidebar link is only shown
to admins via base.html, but we double-check here.
"""
cu = current_user()
if not cu or (cu.role or "") != "admin":
abort(403)
inst = _terminal_get_instance()
ssh_target = ""
ssh_user = ""
ssh_port = 22
ssh_key_path = ""
ssh_key_short = ""
instance_name = "(未配置)"
if inst is not None:
instance_name = inst.name or f"#{inst.id}"
host = (inst.ssh_host or "").strip()
user = (inst.ssh_user or "").strip()
if host and user:
ssh_target = f"{user}@{host}:{inst.ssh_port or 22}"
ssh_user = user
ssh_port = inst.ssh_port or 22
ssh_key_path = (inst.ssh_key_path or "").strip()
if ssh_key_path:
# Show only the basename to keep the header compact
ssh_key_short = os.path.basename(ssh_key_path)
return render_template(
"terminal.html",
ssh_target=ssh_target,
ssh_user=ssh_user,
ssh_port=ssh_port,
ssh_key_path=ssh_key_path,
ssh_key_short=ssh_key_short,
instance_name=instance_name,
paramiko_missing=(_paramiko is None) or (_sock is None),
)
# ---------- WebSocket route (paramiko-driven SSH bridge) -----------------------
if _sock is not None:
@_sock.route("/ws/terminal")
def ws_terminal(ws):
"""WebSSH WebSocket endpoint.
Protocol (client → server):
{"type": "input", "data": "..."}
{"type": "resize", "cols": 80, "rows": 24}
Protocol (server → client):
{"type": "output", "data": "..."} raw bytes for xterm.js
{"type": "status", "msg": "..."} human-readable info
{"type": "error", "msg": "..."} terminal-close triggering
"""
# -- auth gate --
if not _terminal_login_ok():
try:
ws.send(json.dumps({"type": "error",
"msg": "auth required (admin only)"}))
except Exception:
pass
try: ws.close()
except Exception: pass
return
# -- dependency gate --
if _paramiko is None:
try:
ws.send(json.dumps({"type": "error",
"msg": "paramiko not installed"}))
except Exception:
pass
try: ws.close()
except Exception: pass
return
# -- resolve target from active instance --
inst = _terminal_get_instance()
if inst is None:
try:
ws.send(json.dumps({"type": "error",
"msg": "no active squid instance"}))
except Exception:
pass
try: ws.close()
except Exception: pass
return
ssh_host = (inst.ssh_host or "").strip()
ssh_user = (inst.ssh_user or "").strip()
ssh_port = int(inst.ssh_port or 22)
ssh_key_path = (inst.ssh_key_path or "").strip()
if not ssh_host or not ssh_user:
try:
ws.send(json.dumps({
"type": "error",
"msg": "active instance missing ssh_host / ssh_user",
}))
except Exception:
pass
try: ws.close()
except Exception: pass
return
# -- open SSH channel --
client = None
channel = None
connected = False
session_start = time.time()
audit_action = "ssh_open"
try:
try:
ws.send(json.dumps({"type": "status",
"msg": f"connecting to {ssh_user}@{ssh_host}:{ssh_port}"}))
except Exception:
pass
client = _paramiko.SSHClient()
# Auto-trust host keys for the manager-internal use case.
# In a stricter setup, replace with a persistent host_keys file.
client.set_missing_host_key_policy(_paramiko.AutoAddPolicy())
connect_kwargs = dict(
hostname=ssh_host,
port=ssh_port,
username=ssh_user,
timeout=10,
allow_agent=False,
look_for_keys=False,
)
if ssh_key_path and os.path.isfile(ssh_key_path):
connect_kwargs["key_filename"] = ssh_key_path
client.connect(**connect_kwargs)
channel = client.get_transport().open_session(timeout=10)
channel.get_pty(term="xterm-256color", width=80, height=24)
channel.invoke_shell()
connected = True
try:
_terminal_audit(audit_action,
f"instance={inst.name or inst.id} "
f"target={ssh_user}@{ssh_host}:{ssh_port}")
except Exception:
pass
try:
ws.send(json.dumps({"type": "status",
"msg": f"connected to {ssh_user}@{ssh_host}"}))
except Exception:
pass
except Exception as e:
try:
ws.send(json.dumps({"type": "error",
"msg": f"SSH connect failed: {e}"}))
except Exception:
pass
try:
_terminal_audit("ssh_open_fail",
f"target={ssh_user}@{ssh_host}:{ssh_port} err={e}")
except Exception:
pass
try:
if channel is not None:
channel.close()
if client is not None:
client.close()
except Exception:
pass
try: ws.close()
except Exception: pass
return
# -- bidirectional pump --
#
# flask-sock's ws.receive() is blocking, and paramiko's channel
# is also blocking. We solve this by running the SSH→WS pump on a
# background thread that polls channel.recv_ready() with a tiny
# sleep; the main handler thread pulls WS frames and forwards
# them to channel.send() / channel.resize_pty().
import threading as _threading
stop_event = _threading.Event()
ssh_errors: list[str] = []
def _send(payload):
"""Safe JSON send to the WebSocket (swallows errors)."""
try:
ws.send(json.dumps(payload))
except Exception:
pass
def _ssh_reader():
"""Pump SSH channel output → WS until the channel closes."""
try:
while not stop_event.is_set():
if channel.closed or channel.exit_status_ready():
_send({"type": "status", "msg": "SSH channel closed"})
break
try:
# Tiny blocking recv with timeout so we can observe stop_event.
# paramiko's Channel has no real non-blocking mode without
# a transport, but recv_ready()/recv(timeout=...) works.
if channel.recv_ready():
data = channel.recv(4096)
if not data:
break
try:
text = data.decode("utf-8", errors="replace")
except Exception:
text = data.decode("latin-1", errors="replace")
_send({"type": "output", "data": text})
else:
time.sleep(0.03)
except Exception as e:
ssh_errors.append(str(e))
_send({"type": "error",
"msg": f"SSH read error: {e}"})
break
finally:
try:
_send({"type": "status", "msg": "reader exited"})
except Exception:
pass
reader_thread = _threading.Thread(target=_ssh_reader, daemon=True)
reader_thread.start()
try:
while not stop_event.is_set():
# blocking recv on the WS
try:
msg = ws.receive(timeout=1.0)
except Exception:
# timeout -> loop and check SSH side
if not reader_thread.is_alive():
break
continue
if msg is None:
# client closed
break
# msg may be str (text frame) or bytes (binary frame)
if isinstance(msg, (bytes, bytearray)):
try:
msg = msg.decode("utf-8", errors="replace")
except Exception:
continue
try:
payload = json.loads(msg)
except Exception:
# ignore malformed frames
continue
kind = payload.get("type")
if kind == "input":
data = payload.get("data", "")
if not isinstance(data, str):
continue
if not connected or channel is None:
continue
try:
channel.send(data)
except Exception as e:
_send({"type": "error",
"msg": f"SSH write error: {e}"})
break
# Audit high-volume keystrokes conservatively: log
# the presence of dangerous commands but never
# full paste dumps (privacy + log bloat).
if len(data) > 2 and any(c in data for c in ("\n", "\r")):
snippet = data.replace("\n", "\\n")[:200]
try:
_terminal_audit(
"ssh_input",
f"target={ssh_user}@{ssh_host} "
f"len={len(data)} snippet={snippet!r}",
)
except Exception:
pass
elif kind == "resize":
try:
cols = int(payload.get("cols", 80))
rows = int(payload.get("rows", 24))
except Exception:
cols, rows = 80, 24
if not connected or channel is None:
continue
try:
channel.resize_pty(width=cols, height=rows)
_terminal_audit(
"ssh_resize",
f"target={ssh_user}@{ssh_host} cols={cols} rows={rows}",
)
except Exception as e:
_send({"type": "error",
"msg": f"resize error: {e}"})
else:
# unknown frame type - ignore silently
continue
except Exception as e:
try:
_terminal_audit("ssh_ws_error",
f"target={ssh_user}@{ssh_host} err={e}")
except Exception:
pass
finally:
stop_event.set()
try:
if channel is not None:
channel.close()
except Exception:
pass
try:
if client is not None:
client.close()
except Exception:
pass
duration = int(time.time() - session_start)
try:
_terminal_audit("ssh_close",
f"target={ssh_user}@{ssh_host} duration={duration}s")
except Exception:
pass
try:
ws.close()
except Exception:
pass
# Safe-guard: if flask_sock couldn't be imported, expose a stub HTTP route
# so users see a useful message instead of a generic 404.
if _sock is None:
@app.route("/terminal", methods=["GET"])
@login_required
def terminal_view():
cu = current_user()
if not cu or (cu.role or "") != "admin":
abort(403)
return ("<h1>WebSSH not available</h1>"
"<p>Install flask-sock + paramiko on the server.</p>",
503, {"Content-Type": "text/html; charset=utf-8"})
# Make sure json is available for the WS route (it was already imported at
# module top via `from flask import ... jsonify`, but json.dumps needs the
# stdlib module).
# (json is already imported at module top — see lines 18-23)