057d72b264
AppConfig 存储 UTC 时间,模板直接输出导致用户在上海(UTC+8)看到错误时刻。 新增 get_last_sync_at_local() 转本地时区,logs_view 采用本地时间显示。
4240 lines
159 KiB
Python
4240 lines
159 KiB
Python
"""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)
|