feat: 完整 Squid Web Manager 平台 - P0-P3 全功能

完整 Web 管理平台,涵盖:

P0 生产加固:
- 会话超时(空闲+绝对) + CSRF 防护 + 登录失败限流
- HTTPS/TLS 证书管理(自签生成/上传/删除/过期提醒)
- SSL Bump (HTTPS 透明代理) 可视化配置
- 配置文件 diff 预览 + 二次确认才写入
- 服务控制二次确认 + 操作期间按钮锁定

P1 运维能力:
- 日志轮转管理(logrotate) + 日志状态监控
- 告警规则(5xx/命中率/磁盘/客户端流量)
- 多 Squid 实例管理 + 一键切换
- squidclient mgr 实时性能指标
- 流量异常检测(突增/大文件/高频小包/新客户端)

P2 日志分析增强:
- 日志持久化到 SQLite + 历史时间范围查询
- 导出 CSV/JSON(日志/KPI/异常/审计)
- GeoIP 地图(Leaflet + ip-api.com 离线可选 GeoLite2)
- 每客户端 24h 趋势图
- 自定义 Squid logformat 解析器

P3 体验锦上添花:
- 暗/亮主题切换(cookie 持久化)
- WebSSH 终端(xterm.js + paramiko)
- 完整审计日志 + 用户管理

技术栈: Flask 3.0 + SQLAlchemy 2.0 + SQLite + Chart.js + Leaflet
总代码量: ~16500 行 (15 Python 模块 + 34 模板 + CSS)
路由数: 73
This commit is contained in:
Hermes Agent
2026-07-15 10:19:21 +08:00
commit 4a1d949e41
54 changed files with 16895 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg
*.egg-info/
dist/
build/
.eggs/
.installed.cfg
*.egg
# Virtual envs
venv/
env/
.venv/
ENV/
# pytest / coverage
.pytest_cache/
.coverage
htmlcov/
.tox/
# IDE
.idea/
.vscode/
*.swp
*.swo
.DS_Store
# Logs
*.log
# Local instance (sqlite db, uploaded files, generated certs)
instance/
uploads/
backups/
ssl_certs/
alerts/
instances/
log_storage/
security/
# Sample artifacts
*.pid
*.sock
*.pid.lock
# Local config overrides
.env
.env.local
# OS
Thumbs.db
+284
View File
@@ -0,0 +1,284 @@
# Squid Web Manager
一个基于 Flask 的 Squid 代理服务器 Web 管理平台。
## 功能
### 1. 全 Web 化配置管理
- **主配置**: HTTP/HTTPS/ICP/SNMP 端口、缓存内存、对象大小、超时、DNS、日志路径、管理员邮箱等
- **ACL 规则表**: 完整 ACL CRUD,支持 28+ 类型 (src, dst, dstdomain, port, method, time, url_regex, proxy_auth, ...)
- **访问控制**: http_access / https_access / icp_access / snmp_access 等所有访问指令的可视化管理
- **缓存目录**: cache_dir 多目录管理 (ufs / aufs / diskd / rock)
- **刷新规则**: refresh_pattern 正则/百分比/最大时间
- **认证配置**: basic / digest / ntlm / negotiate 全套 auth_param 管理
- **缓存对等**: cache_peer 父代理/兄弟节点配置
- **原始编辑**: 直接编辑 squid.conf,支持语法校验
每次保存自动备份当前配置,可选启用 `squid -k parse` 语法校验。
### 2. 日志分析
解析 Squid 原生 access.log,提供:
- **KPI 卡片**: 总请求数、缓存命中率、总流量、平均响应、拒绝率、中断率、4xx/5xx 错误率
- **每小时流量图**: 请求数 + 流量双 Y 轴折线图
- **结果码分布**: TCP_HIT / TCP_MISS / TCP_TUNNEL / TCP_DENIED 等 (含中文释义)
- **HTTP 状态码分布**: 2xx/3xx/4xx/5xx 分类
- **方法分布**: GET / POST / CONNECT / ...
- **请求分类**: Hit / Miss / Tunnel / Denied / Aborted / Error
- **Top 客户端**: 按 IP 排序,显示请求数和流量
- **Top 目标主机**: 按域名排序
- **Top URL**: 访问最多的资源
- **层级代码**: HIER_DIRECT / HIER_PARENT_HIT / ...
- **Content-Type 分布**
- **日志查询**: 多维度过滤 (IP / 方法 / 结果码 / 主机 / URL / 大小范围 / 时间范围)
- **实时日志**: 自动刷新的 Live Tail 视图
- **日志导入**: 离线分析一次性粘贴的日志
### 3. 服务控制
- 启动 / 停止 / 重启 / 平滑重载 (reload,失败回退 restart)
- 服务状态查看 (PID, 版本, 启动时间, unit 文件)
- 关键路径一览
### 4. 运维功能
- **配置备份**: 自动备份历史,可下载/恢复
- **审计日志**: 所有配置变更、服务操作、登录登出全程留痕
- **修改密码**: admin/admin 默认账号,强制可改
## 技术栈
| 组件 | 选型 |
|------|------|
| 后端 | Python 3 + Flask + SQLAlchemy |
| 数据库 | SQLite (instance/squid_mgr.db) |
| 前端 | 原生 HTML + CSS + 原生 JS |
| 图表 | Chart.js 4.4 (CDN) |
| WSGI | gunicorn |
| 进程管理 | systemd (可选) |
## 快速开始
### 安装依赖
```bash
cd /root/squid-manager
pip install -r requirements.txt --break-system-packages
```
### 直接运行 (开发)
```bash
python3 app.py
# 默认监听 0.0.0.0:5200,调试模式
```
### 生产部署 (gunicorn + systemd)
```bash
# 初始化数据库
python3 -c "from app import app, db, seed_admin; \
app.app_context().push(); \
db.create_all(); seed_admin()"
# 启动 gunicorn
gunicorn --workers 2 --bind 0.0.0.0:5200 wsgi:app
```
### systemd 单元 (推荐)
`/etc/systemd/system/squid-manager.service`:
```ini
[Unit]
Description=Squid Web Manager
After=network.target
[Service]
Type=exec
User=root
WorkingDirectory=/root/squid-manager
ExecStart=/usr/bin/gunicorn --workers 2 --bind 0.0.0.0:5200 wsgi:app
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```
```bash
systemctl daemon-reload
systemctl enable --now squid-manager
```
## 默认账号
- 用户名: `admin`
- 密码: `admin`
**首次登录后请立即修改密码** (左侧导航 → 修改密码)。
## 配置路径
默认假设 Squid 安装在本机:
- 配置文件: `/etc/squid/squid.conf`
- 访问日志: `/var/log/squid/access.log`
- 缓存日志: `/var/log/squid/cache.log`
- 二进制: `squid`
- 服务名: `squid`
如需管理远程 Squid 或路径不同,在
**路径设置** 页面修改 (路径需在本平台可访问的位置)。
## 项目结构
```
squid-manager/
├── app.py # Flask 应用 (模型、路由、业务逻辑)
├── squid_config.py # squid.conf 解析 / 生成 / 校验
├── log_parser.py # access.log 解析与统计
├── wsgi.py # gunicorn 入口
├── requirements.txt
├── README.md
├── instance/ # SQLite 数据库 (自动创建)
│ └── squid_mgr.db
├── backups/ # squid.conf 自动备份
├── templates/ # Jinja2 模板
│ ├── base.html
│ ├── login.html
│ ├── dashboard.html
│ ├── logs.html
│ ├── logs_import.html
│ ├── logs_live.html
│ ├── service.html
│ ├── config_main.html
│ ├── config_acls.html
│ ├── config_rules.html
│ ├── config_cache.html
│ ├── config_refresh.html
│ ├── config_auth.html
│ ├── config_peers.html
│ ├── config_raw.html
│ ├── config_paths.html
│ ├── audit.html
│ ├── backups.html
│ ├── change_password.html
│ └── error.html
└── static/
└── style.css
```
## 日志格式支持
标准 Squid access.log 格式 (默认):
```
time elapsed client action/code size method URL ident hierarchy content-type
```
示例:
```
1783841223.438 2488 172.16.12.232 TCP_MISS_ABORTED/000 0 GET http://169.254.169.254/metadata/instance/compute - HIER_DIRECT/169.254.169.254 -
1783841225.683 4564 172.16.237.9 TCP_TUNNEL/200 10197 CONNECT default.exp-tas.com:443 - HIER_DIRECT/13.107.5.93 -
```
支持的结果码:
- TCP_HIT, TCP_MEM_HIT, TCP_NEGATIVE_HIT, TCP_IMS_HIT, TCP_REFRESH_HIT (命中)
- TCP_MISS, TCP_REFRESH_MISS, TCP_CLIENT_REFRESH_MISS (未命中)
- TCP_TUNNEL (CONNECT 隧道)
- TCP_DENIED, TCP_DENIED_REPLY (拒绝)
- TCP_MISS_ABORTED (中断)
- TCP_REDIRECT (重定向)
- UDP_* (ICP 协议)
- NONE (错误)
## 安全建议
1. **修改默认密码** - admin/admin 仅为示例
2. **绑定内网 IP** - 不要暴露到公网,或前置 nginx + Basic Auth
3. **限制文件权限** - 配置文件含敏感信息,确保 600
4. **审计日志** - 定期查看 `/audit` 页面
5. **TLS** - 生产环境建议 nginx 反代 + HTTPS
## 已知限制
- 服务控制 (start/stop/reload) 仅在 Squid 与本平台同机时有效
- `squid -k parse` 校验需要本机有 squid 二进制
- 多 gunicorn worker 下 SQLite 写入有竞态,已用 try/except 兜底
- access.log 解析按需读取 (默认末尾 10000 行),不持久化
## TLS / HTTPS 部署指南
本 Web 平台本身默认监听 HTTP。在公网或需要 HTTPS 的内网场景下,**强烈不要把本平台直接暴露**
请用 `nginx` 做反向代理 + TLS 终结。
Web UI 提供 **🔐 TLS 证书管理** 页面 (`/config/tls`):
### 1. 自签名证书 (仅用于测试 / 内网)
> 进入 **配置管理 → TLS 证书**,填入 Common Name (如 `proxy.example.local`),点击"生成自签名证书"。
> 生成的文件保存在 `ssl_certs/` 目录下,名为 `CN_YYYYMMDD_xxxxxxxx.pem`,
> 内含 cert + key,可直接用于 nginx 自测。
>
> 浏览器会提示"不受信任",仅用于内网 / 自测 / `curl -k`。
### 2. 生产部署: nginx 反代 + Let's Encrypt + 强制 HTTPS
最小化的 `nginx` 配置示例 (假设本平台监听 `127.0.0.1:5200`):
```nginx
# HTTP -> HTTPS 强制重定向
server {
listen 80;
server_name proxy.example.com;
return 301 https://$host$request_uri;
}
# HTTPS 终结
server {
listen 443 ssl http2;
server_name proxy.example.com;
# Let's Encrypt 签发的证书
ssl_certificate /etc/letsencrypt/live/proxy.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/proxy.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
# 转发到本平台
location / {
proxy_pass http://127.0.0.1:5200;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
}
```
申请 Let's Encrypt 证书:
```bash
# 一次性安装 certbot (Debian/Ubuntu)
apt install -y certbot
# 仅申请证书,不需要 nginx 插件 (webroot 模式)
certbot certonly --webroot -w /var/www/html \
-d proxy.example.com \
--email admin@example.com --agree-tos -n
# 证书自动存放在 /etc/letsencrypt/live/proxy.example.com/
# 推荐加上 cron 自动续期
echo "0 3 * * * /usr/bin/certbot renew --quiet --post-hook 'systemctl reload nginx'" \
| sudo crontab -
```
也可以从本平台 **上传** 页面手动上传 `.pem` / `.crt` / `.key` (上限 1 MB):
| 字段 | 用法 |
|------|------|
| 主题 (CN) | 证书绑定的域名,需与 nginx `server_name` 一致 |
| 状态 | 绿色=有效 ≥30 天 / 黄色=<30 天 / 红色=已过期 |
| SHA-256 指纹 | 审计追踪用,只记录指纹,**绝不写私钥内容** |
### 3. 常见误区
- 不要把 `ssl_certs/*.pem` 加入 git 或公网备份,文件含**私钥**
- 不要把 80 / 443 直接绑到 `app.py` 运行端口,务必过 nginx (WAF / 限速 / 防护)
- Let's Encrypt 单证书有效期 90 天,务必配置自动续期
- 反代场景下,`app.py``SECRET_KEY` 必须固定 (环境变量),否则每次重启会话失效
+435
View File
@@ -0,0 +1,435 @@
"""alerts.py - Alert rule engine for Squid Web Manager (P1-2).
Provides:
- AlertRule dataclass - configurable metric threshold rule
- evaluate_rules(entries, rules, now=None) - run all enabled rules against
the current access.log stats and return a list of triggered alerts.
- save_rules / load_rules - persist rule list as JSON
- default_rules() - opinionated starting set that covers the common SRE cases
All metric computation uses only Python stdlib + existing log_parser statistics
- no new dependencies. Audit writes use a lazy import of `app` so this module
can be imported without forcing a circular dependency on app.py at startup.
"""
from __future__ import annotations
import json
import os
import shutil
import time
from dataclasses import asdict, dataclass, field
from typing import Any, Iterable
# --- Supported metrics ----------------------------------------------------
# Each entry documents the operator semantics. Values are floats unless noted.
METRICS: dict[str, str] = {
"5xx_rate": "HTTP 5xx 错误占比 (0.0 - 1.0)",
"4xx_rate": "HTTP 4xx 错误占比 (0.0 - 1.0)",
"hit_ratio": "缓存命中率 (0.0 - 1.0)",
"denied_rate": "ACL 拒绝请求占比 (0.0 - 1.0)",
"disk_usage_pct": "缓存目录磁盘使用率 (0.0 - 100.0)",
"client_bytes_per_min": "Top 客户端每分钟流量 (字节/分钟)",
}
OPERATORS: dict[str, str] = {
"gt": ">",
"lt": "<",
"gte": ">=",
"lte": "<=",
}
SEVERITIES = ("info", "warning", "critical")
# --- Rule dataclass -------------------------------------------------------
@dataclass
class AlertRule:
"""One alert rule. Persisted as JSON via save_rules / load_rules."""
name: str
metric: str
operator: str
threshold: float
window_minutes: int = 5
enabled: bool = True
cooldown_minutes: int = 30
last_triggered: float = 0.0
severity: str = "warning"
notify_webhook: str = ""
def to_dict(self) -> dict:
d = asdict(self)
# JSON doesn't love bare floats like 0.0 - keep it simple.
return d
@classmethod
def from_dict(cls, d: dict) -> "AlertRule":
"""Build from a dict (e.g. one loaded from JSON). Missing keys fall
back to dataclass defaults so old config files keep working."""
if not isinstance(d, dict):
raise ValueError("AlertRule.from_dict expects a dict")
# Only forward the keys we know about; ignore extras.
allowed = {f for f in cls.__dataclass_fields__.keys()} # type: ignore[attr-defined]
kwargs = {k: d[k] for k in d.keys() & allowed}
# Coerce numerics - if the file is hand-edited we don't want to crash.
for key in ("threshold", "last_triggered"):
if key in kwargs:
try:
kwargs[key] = float(kwargs[key])
except (TypeError, ValueError):
kwargs[key] = 0.0
for key in ("window_minutes", "cooldown_minutes"):
if key in kwargs:
try:
kwargs[key] = int(kwargs[key])
except (TypeError, ValueError):
kwargs[key] = 5 if key == "window_minutes" else 30
kwargs["enabled"] = bool(kwargs.get("enabled", True))
# Severity / operator / metric - fall back if hand-edited to junk.
sev = kwargs.get("severity", "warning")
if sev not in SEVERITIES:
sev = "warning"
kwargs["severity"] = sev
op = kwargs.get("operator", "gt")
if op not in OPERATORS:
op = "gt"
kwargs["operator"] = op
return cls(**kwargs)
# --- Metric evaluation ---------------------------------------------------
def _apply_operator(value: float, op: str, threshold: float) -> bool:
if op == "gt":
return value > threshold
if op == "lt":
return value < threshold
if op == "gte":
return value >= threshold
if op == "lte":
return value <= threshold
return False
def _compute_metric(
metric: str,
stats: dict,
cache_dir: str | None,
window_minutes: int,
) -> float:
"""Compute the current value of a metric from pre-aggregated stats.
For most metrics this is just `stats[metric]`. For client_bytes_per_min we
fold in the time window (which is configurable on the rule, so we can't
pre-bake it into log_parser). disk_usage_pct needs shutil.disk_usage.
"""
if metric == "5xx_rate":
return float(stats.get("errors_5xx_rate") or 0.0)
if metric == "4xx_rate":
return float(stats.get("errors_4xx_rate") or 0.0)
if metric == "hit_ratio":
return float(stats.get("hit_ratio") or 0.0)
if metric == "denied_rate":
return float(stats.get("denied_rate") or 0.0)
if metric == "disk_usage_pct":
target = cache_dir or "/var/spool/squid"
try:
total, used, free = shutil.disk_usage(target)
except (FileNotFoundError, PermissionError, OSError):
return 0.0
if not total:
return 0.0
return round(used * 100.0 / total, 2)
if metric == "client_bytes_per_min":
tops = stats.get("top_clients") or []
if not tops:
return 0.0
max_bytes = max((c.get("bytes") or 0) for c in tops)
# Approximate bytes-per-minute for the top client over the rule window.
# If the time span is < window_minutes we still divide by window so
# the operator gets a comparable value (long window = low rate).
w = max(1, int(window_minutes))
return float(max_bytes) * 60.0 / w
return 0.0
def _humanise_metric(metric: str, value: float) -> str:
if metric.endswith("_rate"):
return f"{value * 100:.2f}%"
if metric == "hit_ratio":
return f"{value * 100:.1f}%"
if metric == "disk_usage_pct":
return f"{value:.1f}%"
if metric == "client_bytes_per_min":
# Display in KB/MB/GB - keep parity with log_parser.format_bytes shape.
n = float(value)
for unit in ("B/s", "KB/s", "MB/s", "GB/s"):
if abs(n) < 1024.0:
if unit == "B/s":
return f"{n:.0f} {unit}"
return f"{n:.1f} {unit}"
n /= 1024.0
return f"{n:.2f} TB/s"
return f"{value:.4f}"
def _format_message(rule: AlertRule, value: float, window_minutes: int) -> str:
op = OPERATORS.get(rule.operator, rule.operator)
human = _humanise_metric(rule.metric, value)
metric_desc = METRICS.get(rule.metric, rule.metric)
return (
f"{rule.name}: {metric_desc} 当前值 {human} {op} 阈值 "
f"{_humanise_metric(rule.metric, rule.threshold)} "
f"(窗口 {window_minutes}m, 严重度 {rule.severity})"
)
# --- Audit (lazy import to avoid circular reference) ---------------------
def _audit_trigger(rule: AlertRule, value: float, message: str):
"""Try to write a row into the audit_log. Silently swallow on failure
so a missing app context never breaks evaluation."""
try:
from app import audit, db # type: ignore
audit(
"alert_triggered",
f"name={rule.name} metric={rule.metric} "
f"value={value:.6f} threshold={rule.threshold} "
f"severity={rule.severity} msg={message[:300]}",
)
db.session.commit()
except Exception:
# Never let an audit failure mask a real alert.
try:
from app import db # type: ignore
db.session.rollback()
except Exception:
pass
def _post_webhook(rule: AlertRule, payload: dict):
"""Fire-and-forget webhook POST. We import urllib lazily; if the URL is
bad or the import fails we just log silently - alerts mustn't crash
the calling request."""
url = (rule.notify_webhook or "").strip()
if not url:
return
try:
import urllib.request
import urllib.error
body = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
url,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
urllib.request.urlopen(req, timeout=5).read()
except Exception:
# We deliberately don't surface webhook errors - delivery is
# best-effort and the audit row is the source of truth.
pass
# --- Main entry point ----------------------------------------------------
def evaluate_rules(
entries: list | None,
rules: Iterable[AlertRule],
*,
cache_dir: str | None = None,
now: float | None = None,
) -> list[dict]:
"""Evaluate all enabled rules against the current access.log parsing.
Parameters
----------
entries : list of log_parser.LogEntry (may be empty / None).
rules : iterable of AlertRule to check.
cache_dir : path to the Squid cache dir. Used for disk_usage_pct. If
None, defaults to /var/spool/squid.
now : epoch seconds to treat as "now". Defaults to time.time().
Returns
-------
list of dict:
{rule, value, severity, message, ts, triggered}
Each dict contains:
- rule : the AlertRule instance that matched
- value : the float value that triggered the rule
- severity : copy of rule.severity
- message : human-readable summary
- ts : epoch seconds when this was evaluated
- triggered : bool - True if outside cooldown, False if suppressed
The list always contains one entry per *evaluated* rule; rows with
triggered=False are useful for the UI to show "currently OK / cooldown".
"""
ts = float(now if now is not None else time.time())
stats = _safe_stats(entries)
out: list[dict] = []
rules_list = list(rules)
for rule in rules_list:
if not getattr(rule, "enabled", True):
continue
metric = getattr(rule, "metric", "")
op = getattr(rule, "operator", "gt")
threshold = float(getattr(rule, "threshold", 0.0) or 0.0)
window = max(1, int(getattr(rule, "window_minutes", 5) or 5))
try:
value = _compute_metric(metric, stats, cache_dir, window)
except Exception:
value = 0.0
triggered = _apply_operator(value, op, threshold)
in_cooldown = False
if triggered:
last = float(getattr(rule, "last_triggered", 0.0) or 0.0)
cooldown = max(0, int(getattr(rule, "cooldown_minutes", 30) or 30))
if last and (ts - last) < cooldown * 60:
# Suppress but still report so the UI can show "cooling down".
triggered = False
in_cooldown = True
else:
rule.last_triggered = ts
msg = _format_message(rule, value, window)
if triggered:
_audit_trigger(rule, value, msg)
_post_webhook(
rule,
{
"name": rule.name,
"metric": rule.metric,
"value": value,
"threshold": threshold,
"operator": op,
"severity": rule.severity,
"message": msg,
"ts": ts,
},
)
out.append({
"rule": rule,
"value": value,
"threshold": threshold,
"operator": op,
"severity": rule.severity,
"message": msg,
"ts": ts,
"triggered": triggered,
"in_cooldown": in_cooldown,
})
return out
def _safe_stats(entries: list | None) -> dict:
"""Run log_parser.aggregate_stats if available, otherwise return {}.
Import log_parser lazily so unit tests / minimal contexts don't crash."""
if not entries:
return {}
try:
import log_parser # type: ignore
return log_parser.aggregate_stats(entries) or {}
except Exception:
return {}
# --- Persistence --------------------------------------------------------
def save_rules(rules: list[AlertRule], path: str) -> None:
"""Write the rule list to `path` as pretty-printed JSON."""
payload = [r.to_dict() for r in (rules or [])]
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
def load_rules(path: str) -> list[AlertRule]:
"""Load rules from a JSON file. Missing / corrupt files yield the
`default_rules()` set; that way the UI always has something to show."""
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
except FileNotFoundError:
return default_rules()
except (json.JSONDecodeError, OSError, ValueError):
return default_rules()
if not isinstance(data, list):
return default_rules()
out: list[AlertRule] = []
for item in data:
try:
out.append(AlertRule.from_dict(item))
except Exception:
# Skip individual bad rows instead of nuking the whole file.
continue
return out or default_rules()
def default_rules() -> list[AlertRule]:
"""A balanced starter set: catches the common SRE cases without being
noisy. Operators can tune thresholds or disable rules from the UI."""
return [
AlertRule(
name="5xx 错误率突增",
metric="5xx_rate",
operator="gt",
threshold=0.05, # > 5% 5xx
window_minutes=5,
cooldown_minutes=30,
severity="critical",
),
AlertRule(
name="缓存命中率下跌",
metric="hit_ratio",
operator="lt",
threshold=0.20, # < 20%
window_minutes=5,
cooldown_minutes=60,
severity="warning",
),
AlertRule(
name="磁盘空间不足",
metric="disk_usage_pct",
operator="gte",
threshold=90.0, # >= 90%
window_minutes=5,
cooldown_minutes=60,
severity="critical",
),
AlertRule(
name="客户端流量异常",
metric="client_bytes_per_min",
operator="gt",
threshold=200 * 1024 * 1024, # > 200 MB/min
window_minutes=5,
cooldown_minutes=15,
severity="warning",
),
AlertRule(
name="拒绝率过高",
metric="denied_rate",
operator="gt",
threshold=0.50, # > 50%
window_minutes=5,
cooldown_minutes=30,
severity="warning",
),
]
# --- CLI smoke test ------------------------------------------------------
if __name__ == "__main__": # pragma: no cover - manual sanity check
import log_parser as _lp
sample = os.path.join(os.path.dirname(__file__), "sample_access.log")
text = open(sample, "r", encoding="utf-8").read() if os.path.exists(sample) else ""
entries = _lp.parse_lines(text) if text else []
triggered = evaluate_rules(entries, default_rules())
print(f"triggered: {len(triggered)}")
for t in triggered:
marker = "*" if t["triggered"] else " "
print(f" {marker} {t['rule'].name}: value={t['value']:.4f} threshold={t['threshold']} ({t['message']})")
+552
View File
@@ -0,0 +1,552 @@
"""P1-5: 流量异常检测 (traffic anomaly detection).
Reads ``list[LogEntry]`` from :mod:`log_parser` and flags:
1. **Sudden traffic spikes** per client
Compare a baseline window (first ``BASELINE_RATIO`` of entries) to the
current window (last ``CURRENT_RATIO`` of entries) and flag clients whose
request-per-minute rate grew more than ``SPIKE_WARN_RATIO`` (default 3x).
2. **Abnormally large responses**
Single responses larger than ``size_threshold_mb`` (default 100 MB).
3. **High-frequency, small payload clients**
Clients that emit lots of tiny responses (< ``size_threshold_kb``).
4. **First-time-seen clients**
Clients whose first request happens in the current window.
All helpers defensively swallow exceptions and return safe empty values so a
bad log line never breaks the dashboard.
"""
from __future__ import annotations
import statistics
from collections import Counter, defaultdict
from datetime import datetime
from typing import Iterable
# --- Default thresholds (overridable via kwargs) ----------------------------
#: Fraction of entries used to compute the baseline (the "past")
BASELINE_RATIO = 0.8
#: Fraction of entries used to compute the current state (the "now")
CURRENT_RATIO = 0.2
#: Spike ratio above which a client is flagged as anomaly (warning)
SPIKE_WARN_RATIO = 3.0
#: Spike ratio above which a client is flagged as critical
SPIKE_CRIT_RATIO = 10.0
#: Minimum samples required for baseline; below this the result is unreliable
MIN_SAMPLES = 5
# Worst case fallback so we don't return monstrous lists for giant logs
DEFAULT_LARGE_LIMIT = 100
DEFAULT_SMALL_LIMIT = 50
DEFAULT_NEW_LIMIT = 50
# --- Internal helpers --------------------------------------------------------
def _safe_float(value, default: float = 0.0) -> float:
"""Return ``value`` as float, or ``default`` on conversion failure."""
try:
return float(value)
except (TypeError, ValueError):
return default
def _entry_ts(entry) -> float | None:
"""Pull a numeric timestamp from a LogEntry, falling back to float(time)."""
try:
ts = entry.get("timestamp") if isinstance(entry, dict) else None
if isinstance(ts, datetime):
return ts.timestamp()
if ts is not None:
return _safe_float(ts)
return _safe_float(entry.get("time"), 0.0) if isinstance(entry, dict) else None
except Exception:
return None
def _entry_field(entry, key: str, default=""):
"""Dict-or-attribute getter that never raises."""
try:
if isinstance(entry, dict):
return entry.get(key, default)
return getattr(entry, key, default)
except Exception:
return default
def _window_split(entries: list) -> tuple[list, list]:
"""Split ``entries`` into (baseline, current).
First BASELINE_RATIO of entries go to the baseline, the remainder into the
current window. The current window is dropped entirely for tiny logs.
"""
if not entries:
return [], []
try:
n = len(entries)
cut = max(1, int(n * BASELINE_RATIO))
baseline = entries[:cut]
current = entries[cut:]
# ensure both windows have a minimum sample size to be meaningful
if len(current) < max(2, MIN_SAMPLES // 2):
return baseline, []
return baseline, current
except Exception:
return [], []
def _window_duration(window: list) -> float:
"""Compute the spanned seconds in ``window`` using timestamp field.
Returns 0.0 when timestamps are unavailable so callers can avoid div-by-zero.
"""
if not window:
return 0.0
try:
timestamps: list[float] = []
for e in window:
ts = _entry_ts(e)
if ts is not None and ts > 0:
timestamps.append(ts)
if len(timestamps) < 2:
return 0.0
span = max(timestamps) - min(timestamps)
return span if span > 0 else 0.0
except Exception:
return 0.0
def _rate_per_minute(count: int, duration_sec: float) -> float:
"""Convert ``count over duration_sec`` to requests-per-minute."""
try:
if duration_sec <= 0:
return float(count)
return count / (duration_sec / 60.0)
except Exception:
return 0.0
def _client_counts(window: Iterable) -> Counter:
"""Build a Counter {client: count} for ``window``."""
out: Counter = Counter()
if not window:
return out
try:
for e in window:
client = _entry_field(e, "client", "")
if not client:
continue
out[client] += 1
except Exception:
return out
return out
# --- Public API --------------------------------------------------------------
def detect_client_anomalies(
entries: list,
baseline_window: list | None = None,
current_window: list | None = None,
) -> list[dict]:
"""Detect clients whose recent request rate spikes vs. their own past.
If ``baseline_window`` / ``current_window`` are omitted we split the given
``entries`` using :data:`BASELINE_RATIO` / :data:`CURRENT_RATIO`.
Returns a list of dicts sorted by severity (critical first), then by ratio::
{
"client": "172.16.x.y",
"baseline_rpm": 12.3, # requests/min in baseline window
"current_rpm": 180.0, # requests/min in current window
"baseline_count": 50,
"current_count": 200,
"ratio": 14.6, # current / baseline
"is_anomaly": True,
"severity": "critical" | "warning" | None,
"baseline_window_sec": 244.0,
"current_window_sec": 66.5,
}
"""
try:
if baseline_window is None or current_window is None:
baseline_window, current_window = _window_split(entries)
if not baseline_window or not current_window:
return []
base_counts = _client_counts(baseline_window)
cur_counts = _client_counts(current_window)
if not base_counts or not cur_counts:
return []
base_dur = _window_duration(baseline_window)
cur_dur = _window_duration(current_window)
anomalies: list[dict] = []
# only consider clients that appear in BOTH windows for ratio math
for client, cur_n in cur_counts.items():
try:
base_n = base_counts.get(client, 0)
if base_n < MIN_SAMPLES:
# New client; show as anomaly but skip ratio math
anomalies.append({
"client": client,
"baseline_rpm": 0.0,
"current_rpm": _rate_per_minute(cur_n, cur_dur),
"baseline_count": 0,
"current_count": cur_n,
"ratio": float("inf"),
"is_anomaly": True,
"severity": "warning",
"baseline_window_sec": round(base_dur, 2),
"current_window_sec": round(cur_dur, 2),
})
continue
base_rpm = _rate_per_minute(base_n, base_dur)
cur_rpm = _rate_per_minute(cur_n, cur_dur)
# avoid div-by-zero: if base_rpm is zero, treat as infinite ratio
if base_rpm <= 0:
ratio = float("inf") if cur_rpm > 0 else 0.0
else:
ratio = cur_rpm / base_rpm
if ratio > SPIKE_WARN_RATIO:
severity = "critical" if ratio > SPIKE_CRIT_RATIO else "warning"
anomalies.append({
"client": client,
"baseline_rpm": round(base_rpm, 2),
"current_rpm": round(cur_rpm, 2),
"baseline_count": base_n,
"current_count": cur_n,
"ratio": round(ratio, 2) if ratio != float("inf") else ratio,
"is_anomaly": True,
"severity": severity,
"baseline_window_sec": round(base_dur, 2),
"current_window_sec": round(cur_dur, 2),
})
except Exception:
# single client failure doesn't kill the whole result
continue
# sort: critical first, then highest ratio
def _sort_key(a: dict):
sev_rank = 0 if a.get("severity") == "critical" else 1
ratio = a.get("ratio", 0)
ratio_key = -ratio if isinstance(ratio, (int, float)) and ratio != float("inf") else -1e18
return (sev_rank, ratio_key)
try:
anomalies.sort(key=_sort_key)
except Exception:
pass
return anomalies
except Exception:
return []
def detect_large_requests(
entries: list,
size_threshold_mb: float = 100,
limit: int = DEFAULT_LARGE_LIMIT,
) -> list[dict]:
"""Find individual responses larger than ``size_threshold_mb`` MB.
Returned dicts are sorted by size descending and capped at ``limit``::
{
"time": "2024-01-01 12:34:56",
"timestamp": datetime(...),
"client": "172.16.x.y",
"url": "https://...",
"host": "example.com",
"method": "GET",
"result": "TCP_MISS/200",
"size_bytes": 157286400,
"size_mb": 150.0,
}
"""
try:
if not entries:
return []
threshold_bytes = float(size_threshold_mb) * 1024.0 * 1024.0
out: list[dict] = []
for e in entries:
try:
size = _safe_float(_entry_field(e, "size", 0), 0.0)
if size <= threshold_bytes:
continue
ts = _entry_ts(e)
dt = None
try:
dt = _entry_field(e, "timestamp", None)
if not isinstance(dt, datetime):
dt = datetime.fromtimestamp(ts) if ts else None
except Exception:
dt = None
out.append({
"time": dt.strftime("%Y-%m-%d %H:%M:%S") if dt else "",
"timestamp": dt.isoformat() if dt else None,
"client": _entry_field(e, "client", ""),
"url": _entry_field(e, "url", ""),
"host": _entry_field(e, "host", ""),
"method": _entry_field(e, "method", ""),
"result": _entry_field(e, "result", ""),
"result_code": _entry_field(e, "result_code", ""),
"http_code": _entry_field(e, "http_code", ""),
"size_bytes": int(size),
"size_mb": round(size / (1024.0 * 1024.0), 2),
})
except Exception:
continue
try:
out.sort(key=lambda x: x.get("size_bytes", 0), reverse=True)
except Exception:
pass
return out[: max(1, int(limit))]
except Exception:
return []
def detect_high_freq_small(
entries: list,
size_threshold_kb: float = 1,
min_requests: int = 100,
limit: int = DEFAULT_SMALL_LIMIT,
) -> list[dict]:
"""Find clients that emit lots of tiny responses (< ``size_threshold_kb``).
Tiny-response spam is often a fingerprint of polling bots, beaconing
implants, or chatty telemetry agents.
Returns dicts sorted by request count desc::
{
"client": "172.16.x.y",
"request_count": 540,
"total_bytes": 420000,
"avg_size": 778,
"sample_url": "https://...",
"sample_host": "example.com",
}
"""
try:
if not entries:
return []
threshold_bytes = float(size_threshold_kb) * 1024.0
counts: Counter = Counter()
total_bytes: dict[str, int] = defaultdict(int)
sample_url: dict[str, str] = {}
sample_host: dict[str, str] = {}
for e in entries:
try:
size = _safe_float(_entry_field(e, "size", 0), 0.0)
if size >= threshold_bytes:
continue
client = _entry_field(e, "client", "")
if not client:
continue
counts[client] += 1
total_bytes[client] += int(size)
if client not in sample_url:
sample_url[client] = _entry_field(e, "url", "")
sample_host[client] = _entry_field(e, "host", "")
except Exception:
continue
out: list[dict] = []
for client, n in counts.most_common():
if n < max(1, int(min_requests)):
continue
tb = total_bytes.get(client, 0)
avg = tb / n if n else 0
out.append({
"client": client,
"request_count": n,
"total_bytes": tb,
"avg_size": round(avg, 1),
"sample_url": sample_url.get(client, ""),
"sample_host": sample_host.get(client, ""),
})
try:
out.sort(key=lambda x: x.get("request_count", 0), reverse=True)
except Exception:
pass
return out[: max(1, int(limit))]
except Exception:
return []
def detect_new_clients(
entries: list,
known_clients: set[str] | list[str] | None = None,
limit: int = DEFAULT_NEW_LIMIT,
) -> list[dict]:
"""Find clients that appear for the first time within ``entries``.
"First time" means: their first LogEntry timestamp is among the entries
we are inspecting AND they aren't in ``known_clients`` (when supplied).
Returns::
{
"client": "172.16.x.y",
"first_seen": "2024-...",
"first_ts": float,
"request_count": int,
"sample_url": "...",
}
"""
try:
if not entries:
return []
if known_clients is None:
known = set()
elif isinstance(known_clients, set):
known = known_clients
else:
known = set(known_clients or [])
# find earliest timestamp per client
first_ts: dict[str, float] = {}
first_dt: dict[str, datetime] = {}
counts: Counter = Counter()
sample_url: dict[str, str] = {}
for e in entries:
try:
client = _entry_field(e, "client", "")
if not client:
continue
ts = _entry_ts(e) or 0.0
dt = _entry_field(e, "timestamp", None)
if ts and (client not in first_ts or ts < first_ts[client]):
first_ts[client] = ts
if isinstance(dt, datetime) and (client not in first_dt or dt < first_dt[client]):
first_dt[client] = dt
counts[client] += 1
if client not in sample_url:
sample_url[client] = _entry_field(e, "url", "")
except Exception:
continue
# min timestamp across all entries = baseline cutoff
all_ts = [t for t in first_ts.values() if t > 0]
if not all_ts:
return []
global_min_ts = min(all_ts)
out: list[dict] = []
for client, ts in first_ts.items():
try:
# first-seen in entries exactly equal to global min means
# they've never been seen before — anything <= min qualifies
# as a "new" arrival within this log slice
if ts > global_min_ts:
continue
if client in known:
continue
dt = first_dt.get(client)
out.append({
"client": client,
"first_seen": dt.strftime("%Y-%m-%d %H:%M:%S") if dt else "",
"first_ts": ts,
"request_count": counts.get(client, 1),
"sample_url": sample_url.get(client, ""),
})
except Exception:
continue
try:
out.sort(key=lambda x: x.get("first_ts", 0))
except Exception:
pass
return out[: max(1, int(limit))]
except Exception:
return []
def get_anomaly_summary(entries: list) -> dict:
"""Run all four detectors and bundle the results.
Layout::
{
"anomalous_clients": [...], # detect_client_anomalies
"large_requests": [...], # detect_large_requests
"high_freq_small": [...], # detect_high_freq_small
"new_clients": [...], # detect_new_clients
"total_anomalies": int,
"thresholds": {...},
}
"""
try:
anomalous = detect_client_anomalies(entries)
except Exception:
anomalous = []
try:
large = detect_large_requests(entries)
except Exception:
large = []
try:
small = detect_high_freq_small(entries)
except Exception:
small = []
try:
new = detect_new_clients(entries)
except Exception:
new = []
total = len(anomalous) + len(large) + len(small) + len(new)
# quick stats over the (current) window for the page header
header_stats: dict = {}
try:
if entries:
n = len(entries)
cut = max(1, int(n * CURRENT_RATIO))
current = entries[cut:]
header_stats = {
"current_window_count": len(current),
"current_window_clients": len({_entry_field(e, "client", "") for e in current}),
}
except Exception:
pass
return {
"anomalous_clients": anomalous,
"large_requests": large,
"high_freq_small": small,
"new_clients": new,
"total_anomalies": total,
"thresholds": {
"spike_warn_ratio": SPIKE_WARN_RATIO,
"spike_crit_ratio": SPIKE_CRIT_RATIO,
"large_size_mb": 100,
"small_size_kb": 1,
"min_small_requests": 100,
},
"header_stats": header_stats,
}
# --- Tiny self-test when run directly --------------------------------------
if __name__ == "__main__": # pragma: no cover
import json
import log_parser
sample_path = "/root/squid-manager/sample_access.log"
try:
with open(sample_path, encoding="utf-8") as f:
text = f.read()
entries = log_parser.parse_lines(text)
except OSError:
entries = []
summary = get_anomaly_summary(entries)
print(json.dumps(summary, default=str, ensure_ascii=False, indent=2)[:2000])
+4185
View File
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
"""Unified diff generator for squid.conf changes.
Provides a small, pure-Python unified-diff renderer. We avoid Python's
``difflib.unified_diff`` because we want tight control over hunk sizing
and color classes used by the UI.
"""
from __future__ import annotations
import difflib
from typing import Iterable
def unified_diff(old: str, new: str, context: int = 3, lineterm: str = "\n") -> list[dict]:
"""Return a list of diff lines as dicts: {kind, text, old_lineno, new_lineno}.
kind: 'equal' | 'add' | 'del' | 'hunk' | 'meta'
"""
old_lines = old.splitlines(keepends=False)
new_lines = new.splitlines(keepends=False)
sm = difflib.SequenceMatcher(a=old_lines, b=new_lines, autojunk=False)
out: list[dict] = []
old_no = 0
new_no = 0
def push_meta(text: str):
out.append({"kind": "meta", "text": text, "old_lineno": None, "new_lineno": None})
push_meta("--- 当前配置 (existing)")
push_meta("+++ 新配置 (proposed)")
for tag, i1, i2, j1, j2 in sm.get_opcodes():
if tag == "equal":
# show context lines (limit to first/last of long equal runs)
block = old_lines[i1:i2]
if len(block) <= context * 2 + 1:
for k, ln in enumerate(block):
old_no += 1
new_no += 1
out.append({
"kind": "equal", "text": ln,
"old_lineno": old_no, "new_lineno": new_no,
})
else:
# head
for k in range(context):
old_no += 1; new_no += 1
out.append({
"kind": "equal", "text": old_lines[i1 + k],
"old_lineno": old_no, "new_lineno": new_no,
})
out.append({
"kind": "meta", "text": f"... ({i2 - i1 - 2 * context} equal lines skipped) ...",
"old_lineno": None, "new_lineno": None,
})
old_no += (i2 - i1 - 2 * context)
new_no += (i2 - i1 - 2 * context)
# tail
for k in range(i2 - context, i2):
old_no += 1; new_no += 1
out.append({
"kind": "equal", "text": old_lines[k],
"old_lineno": old_no, "new_lineno": new_no,
})
elif tag == "replace":
for ln in old_lines[i1:i2]:
old_no += 1
out.append({
"kind": "del", "text": ln,
"old_lineno": old_no, "new_lineno": None,
})
for ln in new_lines[j1:j2]:
new_no += 1
out.append({
"kind": "add", "text": ln,
"old_lineno": None, "new_lineno": new_no,
})
elif tag == "delete":
for ln in old_lines[i1:i2]:
old_no += 1
out.append({
"kind": "del", "text": ln,
"old_lineno": old_no, "new_lineno": None,
})
elif tag == "insert":
for ln in new_lines[j1:j2]:
new_no += 1
out.append({
"kind": "add", "text": ln,
"old_lineno": None, "new_lineno": new_no,
})
return out
def summarize(diff: list[dict]) -> dict:
"""Return {adds, dels, total_changed}."""
adds = sum(1 for d in diff if d["kind"] == "add")
dels = sum(1 for d in diff if d["kind"] == "del")
return {"adds": adds, "dels": dels, "total_changed": adds + dels}
def render_html(diff: list[dict]) -> str:
"""Render diff to HTML with classes used by static/style.css."""
parts = ['<div class="diff-container">']
for d in diff:
kind = d["kind"]
text = d["text"]
# escape HTML
esc = (text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))
old_n = d.get("old_lineno") or ""
new_n = d.get("new_lineno") or ""
if kind == "add":
parts.append(f'<div class="diff-line diff-add"><span class="diff-ln">{old_n:>4}</span><span class="diff-ln">{new_n:>4}</span> + {esc}</div>')
elif kind == "del":
parts.append(f'<div class="diff-line diff-del"><span class="diff-ln">{old_n:>4}</span><span class="diff-ln">{new_n:>4}</span> - {esc}</div>')
elif kind == "equal":
parts.append(f'<div class="diff-line diff-ctx"><span class="diff-ln">{old_n:>4}</span><span class="diff-ln">{new_n:>4}</span> {esc}</div>')
else: # meta
parts.append(f'<div class="diff-line diff-hunk">{esc}</div>')
parts.append("</div>")
return "\n".join(parts)
+577
View File
@@ -0,0 +1,577 @@
"""P2-2: Export utilities for Squid Web Manager.
Provides CSV/JSON serialisation helpers and Flask response builders for
downloading log entries, KPI summaries, anomaly lists and audit log entries.
Conventions
-----------
- All CSV output starts with a UTF-8 BOM (``\ufeff``) so Microsoft Excel
opens it as UTF-8 rather than falling back to the local code page.
- Timestamps are serialised as ISO-8601 strings (UTC when the source
value carries tzinfo). ``None`` values become empty cells; ``inf``
becomes the literal string ``Infinity`` so downstream tools don't
silently choke.
- Every public function swallows exceptions and returns a safe empty
value (``""`` / ``{}``). This makes it safe to call from request
handlers - a broken export must never 500 the dashboard.
- No third-party dependencies. The module only uses ``csv``, ``json``,
``io`` and Flask's ``Response``.
"""
from __future__ import annotations
import csv
import io
import json
from datetime import date, datetime
from typing import Any, Iterable
from flask import Response
# --- Constants -------------------------------------------------------------
#: Default columns exported for a LogEntry. Order matters - this is the
#: column order Excel/LibreOffice will display.
DEFAULT_ENTRY_FIELDS: list[str] = [
"time",
"client",
"result_code",
"http_code",
"method",
"url",
"host",
"size_bytes",
"elapsed_ms",
"hier_code",
"content_type",
]
#: UTF-8 BOM. Excel needs this prefix to recognise the file as UTF-8.
_UTF8_BOM = "\ufeff"
# --- Internal helpers ------------------------------------------------------
def _get_field(entry: Any, key: str) -> Any:
"""Dict-or-attribute getter that never raises.
LogEntry subclasses dict, so ``entry.get(key)`` works, but we also
support plain attribute access (e.g. SQLAlchemy model instances).
"""
try:
if isinstance(entry, dict):
return entry.get(key)
return getattr(entry, key, None)
except Exception:
return None
def _serialise_value(value: Any) -> str:
"""Convert a single cell value into a CSV-friendly string.
- ``None`` -> empty cell (not the literal "None").
- ``datetime`` / ``date`` -> ISO-8601.
- ``float('inf')`` / ``float('-inf')`` -> literal "Infinity" / "-Infinity".
- everything else -> ``str(value)``.
"""
if value is None:
return ""
# datetime must come before date because datetime is a subclass of date
if isinstance(value, datetime):
try:
return value.isoformat()
except Exception:
return str(value)
if isinstance(value, date):
try:
return value.isoformat()
except Exception:
return str(value)
if isinstance(value, float):
# JSON-emitted inf / -inf / nan don't round-trip cleanly through
# CSV consumers; spell them out instead.
if value != value: # NaN
return "NaN"
if value == float("inf"):
return "Infinity"
if value == float("-inf"):
return "-Infinity"
# drop noisy trailing zeros for whole numbers
if value.is_integer():
return str(int(value))
return repr(value)
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (list, tuple)):
try:
return ",".join(str(v) for v in value)
except Exception:
return str(value)
try:
return str(value)
except Exception:
return ""
def _timestamp_iso(entry: Any) -> str:
"""Best-effort ISO timestamp for ``entry`` - falls back to ``time`` field."""
try:
ts = _get_field(entry, "timestamp")
if isinstance(ts, datetime):
return ts.isoformat()
if isinstance(ts, (int, float)):
return datetime.utcfromtimestamp(float(ts)).isoformat() + "Z"
except Exception:
pass
try:
t = _get_field(entry, "time")
if isinstance(t, (int, float)):
return datetime.utcfromtimestamp(float(t)).isoformat() + "Z"
except Exception:
pass
return ""
def _write_csv(headers: list[str], rows: Iterable[list[Any]]) -> str:
"""Render ``headers`` + ``rows`` into a CSV string with UTF-8 BOM.
Uses ``csv.writer`` with QUOTE_MINIMAL so embedded newlines / quotes
inside URLs are escaped correctly. Output is a plain string (no
BytesIO shenanigans) so the caller can decide whether to wrap it in
a Flask ``Response`` or write to disk.
"""
buf = io.StringIO()
# Write the BOM first; CSV writer will then prepend the header row.
buf.write(_UTF8_BOM)
try:
writer = csv.writer(buf, quoting=csv.QUOTE_MINIMAL, lineterminator="\n")
writer.writerow(headers)
for row in rows:
try:
writer.writerow([_serialise_value(v) for v in row])
except Exception:
# one bad row shouldn't nuke the whole export
continue
except Exception:
# return at least the BOM + header line so callers get *something*
try:
buf.write(",".join(headers))
except Exception:
pass
return buf.getvalue()
# --- Entry export ----------------------------------------------------------
def entries_to_csv(entries: Any, fields: list[str] | None = None) -> str:
"""Serialise a list of log entries to a CSV string.
``entries`` may be any iterable of dict-like objects (LogEntry,
SQLAlchemy model, plain dict). ``fields`` defaults to
:data:`DEFAULT_ENTRY_FIELDS`. Returns an empty string on error.
"""
try:
if not entries:
# still emit the header so an empty download is a valid CSV
cols = list(fields) if fields else list(DEFAULT_ENTRY_FIELDS)
return _write_csv(cols, [])
cols = list(fields) if fields else list(DEFAULT_ENTRY_FIELDS)
def _row(entry: Any) -> list[Any]:
out: list[Any] = []
for col in cols:
if col == "time":
# Prefer ISO timestamp; fall back to raw time field
iso = _timestamp_iso(entry)
if iso:
out.append(iso)
else:
out.append(_get_field(entry, "time"))
elif col == "size_bytes":
# The DB column is ``size`` (LogEntry) or ``size`` (dict);
# allow ``size_bytes`` as a fallback for either shape.
val = _get_field(entry, "size_bytes")
if val is None:
val = _get_field(entry, "size")
out.append(val)
elif col == "elapsed_ms":
val = _get_field(entry, "elapsed_ms")
if val is None:
val = _get_field(entry, "elapsed")
out.append(val)
elif col == "hier_code":
val = _get_field(entry, "hier_code")
if val is None:
val = _get_field(entry, "hierarchy")
out.append(val)
else:
out.append(_get_field(entry, col))
return out
return _write_csv(cols, (_row(e) for e in entries))
except Exception:
return ""
def entries_to_json(entries: Any, fields: list[str] | None = None) -> str:
"""Serialise a list of log entries to a pretty-printed JSON string.
Datetimes are converted to ISO-8601. ``fields`` constrains the
exported keys (per-entry filtering); ``None`` exports every key the
entry exposes. Returns an empty string on error.
"""
try:
out: list[dict[str, Any]] = []
for entry in entries or []:
try:
if fields is not None:
rec: dict[str, Any] = {}
for col in fields:
if col == "time":
iso = _timestamp_iso(entry)
rec["time"] = iso or _get_field(entry, "time")
elif col == "size_bytes":
v = _get_field(entry, "size_bytes")
rec["size_bytes"] = v if v is not None else _get_field(entry, "size")
elif col == "elapsed_ms":
v = _get_field(entry, "elapsed_ms")
rec["elapsed_ms"] = v if v is not None else _get_field(entry, "elapsed")
elif col == "hier_code":
v = _get_field(entry, "hier_code")
rec["hier_code"] = v if v is not None else _get_field(entry, "hierarchy")
else:
rec[col] = _get_field(entry, col)
else:
rec = _entry_to_dict(entry)
out.append(rec)
except Exception:
continue
return json.dumps(out, indent=2, ensure_ascii=False, default=_json_default)
except Exception:
return ""
def _entry_to_dict(entry: Any) -> dict[str, Any]:
"""Flatten a LogEntry / SQLAlchemy row into a JSON-friendly dict."""
try:
if isinstance(entry, dict):
return {str(k): _json_safe(v) for k, v in entry.items()}
except Exception:
pass
# Fall back to per-attribute access
result: dict[str, Any] = {}
for col in DEFAULT_ENTRY_FIELDS:
try:
result[col] = _get_field(entry, col)
except Exception:
result[col] = None
return result
def _json_safe(value: Any) -> Any:
"""Make ``value`` JSON-serialisable. Datetimes -> ISO strings."""
if value is None or isinstance(value, (str, int, bool)):
return value
if isinstance(value, float):
if value != value:
return None # NaN -> null
if value == float("inf"):
return "Infinity"
if value == float("-inf"):
return "-Infinity"
return value
if isinstance(value, (datetime, date)):
try:
return value.isoformat()
except Exception:
return str(value)
if isinstance(value, (list, tuple)):
return [_json_safe(v) for v in value]
if isinstance(value, dict):
return {str(k): _json_safe(v) for k, v in value.items()}
return str(value)
def _json_default(value: Any) -> Any:
"""Fallback encoder for json.dumps - keeps the export robust."""
return _json_safe(value)
# --- Stats export ----------------------------------------------------------
def stats_to_json(stats: Any) -> str:
"""Serialise a KPI summary dict to a pretty-printed JSON string.
The dashboard's ``get_stats()`` returns a dict that may contain
nested structures (top_clients list, etc.). Datetimes nested inside
are normalised via :func:`_json_safe`. Returns ``""`` on error.
"""
try:
if stats is None:
stats = {}
return json.dumps(_json_safe(stats), indent=2,
ensure_ascii=False, default=_json_default)
except Exception:
return ""
# --- Anomaly export --------------------------------------------------------
#: Field order for the long-form anomaly CSV. ``type`` distinguishes the
#: detector; the rest of the columns are the union of useful fields.
ANOMALY_CSV_FIELDS: list[str] = [
"type",
"client",
"severity",
"time",
"url",
"host",
"method",
"result",
"result_code",
"http_code",
"size_bytes",
"size_mb",
"request_count",
"total_bytes",
"avg_size",
"baseline_rpm",
"current_rpm",
"baseline_count",
"current_count",
"ratio",
"first_seen",
"sample_url",
"sample_host",
]
def _anomaly_rows(summary: dict[str, Any]) -> list[dict[str, Any]]:
"""Flatten the four anomaly sub-lists into a single long table.
Each row gets a ``type`` column so consumers can pivot / filter.
Rows from different detectors carry different fields; missing
fields are simply left empty.
"""
rows: list[dict[str, Any]] = []
if not isinstance(summary, dict):
return rows
# 1. anomalous_clients (spikes)
for a in summary.get("anomalous_clients") or []:
if not isinstance(a, dict):
continue
rec = {k: a.get(k) for k in ANOMALY_CSV_FIELDS if k in a}
rec["type"] = "client_spike"
rec.setdefault("client", a.get("client"))
rec.setdefault("severity", a.get("severity"))
rec.setdefault("baseline_rpm", a.get("baseline_rpm"))
rec.setdefault("current_rpm", a.get("current_rpm"))
rec.setdefault("baseline_count", a.get("baseline_count"))
rec.setdefault("current_count", a.get("current_count"))
rec.setdefault("ratio", a.get("ratio"))
rows.append(rec)
# 2. large_requests
for a in summary.get("large_requests") or []:
if not isinstance(a, dict):
continue
rec = {k: a.get(k) for k in ANOMALY_CSV_FIELDS if k in a}
rec["type"] = "large_request"
rec.setdefault("client", a.get("client"))
rec.setdefault("severity", "warning")
rec.setdefault("size_bytes", a.get("size_bytes"))
rec.setdefault("size_mb", a.get("size_mb"))
rows.append(rec)
# 3. high_freq_small
for a in summary.get("high_freq_small") or []:
if not isinstance(a, dict):
continue
rec = {k: a.get(k) for k in ANOMALY_CSV_FIELDS if k in a}
rec["type"] = "high_freq_small"
rec.setdefault("client", a.get("client"))
rec.setdefault("severity", "warning")
rec.setdefault("sample_url", a.get("sample_url"))
rec.setdefault("sample_host", a.get("sample_host"))
rows.append(rec)
# 4. new_clients
for a in summary.get("new_clients") or []:
if not isinstance(a, dict):
continue
rec = {k: a.get(k) for k in ANOMALY_CSV_FIELDS if k in a}
rec["type"] = "new_client"
rec.setdefault("client", a.get("client"))
rec.setdefault("severity", "info")
rec.setdefault("first_seen", a.get("first_seen"))
rec.setdefault("request_count", a.get("request_count"))
rec.setdefault("sample_url", a.get("sample_url"))
rows.append(rec)
return rows
def anomalies_to_csv(summary: Any) -> str:
"""Serialise the anomaly summary to a long-form CSV.
``summary`` is the dict returned by ``_build_anomaly_summary`` (or
anything with the same four sub-list keys). All four detector
outputs are flattened into one table with a ``type`` column.
Returns ``""`` on error.
"""
try:
rows = _anomaly_rows(summary if isinstance(summary, dict) else {})
return _write_csv(ANOMALY_CSV_FIELDS, [
[r.get(col) for col in ANOMALY_CSV_FIELDS] for r in rows
])
except Exception:
return ""
def anomalies_to_json(summary: Any) -> str:
"""Serialise the anomaly summary to pretty JSON (raw structure)."""
try:
if summary is None:
summary = {}
return json.dumps(_json_safe(summary), indent=2,
ensure_ascii=False, default=_json_default)
except Exception:
return ""
# --- Audit export ----------------------------------------------------------
#: Field order for the audit log CSV.
AUDIT_CSV_FIELDS: list[str] = [
"timestamp",
"username",
"action",
"detail",
]
def _audit_row(audit_obj: Any) -> list[Any]:
"""Convert an AuditLog row to a list aligned with AUDIT_CSV_FIELDS."""
try:
ts = _get_field(audit_obj, "timestamp")
if isinstance(ts, datetime):
ts_str = ts.isoformat()
elif isinstance(ts, (int, float)):
try:
ts_str = datetime.utcfromtimestamp(float(ts)).isoformat() + "Z"
except Exception:
ts_str = str(ts)
else:
ts_str = _serialise_value(ts)
except Exception:
ts_str = ""
return [
ts_str,
_get_field(audit_obj, "username"),
_get_field(audit_obj, "action"),
_get_field(audit_obj, "detail"),
]
def audit_to_csv(entries: Any) -> str:
"""Serialise a list of AuditLog rows to a CSV string."""
try:
items = list(entries) if entries else []
return _write_csv(AUDIT_CSV_FIELDS, (_audit_row(a) for a in items))
except Exception:
return ""
def audit_to_json(entries: Any) -> str:
"""Serialise a list of AuditLog rows to pretty JSON."""
try:
out: list[dict[str, Any]] = []
for a in entries or []:
try:
out.append({
"timestamp": _json_safe(_get_field(a, "timestamp")),
"username": _get_field(a, "username"),
"action": _get_field(a, "action"),
"detail": _get_field(a, "detail"),
})
except Exception:
continue
return json.dumps(out, indent=2, ensure_ascii=False,
default=_json_default)
except Exception:
return ""
# --- Response builders -----------------------------------------------------
def csv_response(csv_text: str, filename: str) -> Response:
"""Wrap a CSV string in a Flask ``Response`` with download headers.
Adds ``Content-Disposition: attachment; filename=...`` so browsers
save the file rather than rendering it inline. ``Content-Type`` is
``text/csv; charset=utf-8`` (Flask appends the charset once).
"""
if not csv_text:
csv_text = _UTF8_BOM # at least the BOM, so the file isn't empty
# The CSV already has the UTF-8 BOM baked in; we hand Flask a plain
# str response and let it encode via utf-8.
safe_name = _safe_filename(filename, default="export.csv")
resp = Response(
csv_text,
status=200,
)
resp.headers["Content-Type"] = "text/csv; charset=utf-8"
resp.headers["Content-Disposition"] = f'attachment; filename="{safe_name}"'
resp.headers["Cache-Control"] = "no-store"
return resp
def json_response(json_text: str, filename: str) -> Response:
"""Wrap a JSON string in a Flask ``Response`` with download headers."""
if not json_text:
json_text = "{}"
safe_name = _safe_filename(filename, default="export.json")
resp = Response(
json_text,
status=200,
)
resp.headers["Content-Type"] = "application/json; charset=utf-8"
resp.headers["Content-Disposition"] = f'attachment; filename="{safe_name}"'
resp.headers["Cache-Control"] = "no-store"
return resp
def _safe_filename(name: str, default: str = "export") -> str:
"""Strip path separators / control chars from a download filename."""
try:
if not name:
return default
# Drop any directory components - browsers only care about the basename.
name = name.replace("\\", "/").split("/")[-1]
# Reject anything that isn't a basic filename char.
cleaned = "".join(c for c in name if c.isprintable() and c not in '"<>|:*?\n\r\t')
return cleaned or default
except Exception:
return default
# --- Filename helpers ------------------------------------------------------
def timestamped_filename(prefix: str, ext: str) -> str:
"""Build ``prefix_YYYYMMDD_HHMMSS.ext`` (local time)."""
try:
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
except Exception:
ts = "unknown"
# ext should be a plain suffix like "csv" or "json" (no leading dot)
ext = (ext or "").lstrip(".").lower() or "bin"
return f"{prefix}_{ts}.{ext}"
+378
View File
@@ -0,0 +1,378 @@
"""GeoIP lookup helpers for Squid Web Manager.
Two backends are supported:
1. **Online** — public free `ip-api.com` JSON endpoint (no API key,
45 requests/min from the same source IP). This is the default
because it requires no extra files and no extra dependencies.
2. **Offline** — a local MaxMind GeoLite2-City ``.mmdb`` file. We
only use the optional ``maxminddb`` package if it's installed;
otherwise we report an error rather than failing the page render.
Both backends write to a small in-process dict cache so the same IP
is never looked up twice within ``CACHE_TTL`` seconds (default 24 h).
Private IPs (RFC 1918 / loopback / link-local) are short-circuited and
return a GeoIPResult with ``error="private IP"`` so they never hit the
network or DB.
"""
from __future__ import annotations
import ipaddress
import json
import socket
import time
import urllib.error
import urllib.request
from typing import Any
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
#: In-process cache. Maps ``ip`` -> ``(GeoIPResult, ts)``.
CACHE: dict[str, tuple["GeoIPResult", float]] = {}
#: Cache entry lifetime, in seconds. Geo data is stable for ~weeks.
CACHE_TTL = 86400 # 24h
#: ip-api.com endpoint. Field list pulled to a minimum to stay polite.
_API_FIELDS = (
"status,message,country,countryCode,region,regionName,city,"
"lat,lon,isp,org,as,query"
)
_API_URL = "http://ip-api.com/json/{ip}?fields={fields}"
#: Per-request timeout for the online endpoint.
DEFAULT_TIMEOUT = 5
#: User agent — ip-api.com prefers something identifying.
_USER_AGENT = "squid-web-manager/1.0 (geoip-lookup)"
# ---------------------------------------------------------------------------
# Result container
# ---------------------------------------------------------------------------
class GeoIPResult:
"""Lightweight GeoIP result. Plain attributes; safe to JSON-serialize."""
__slots__ = (
"ip",
"country",
"country_name",
"city",
"region",
"latitude",
"longitude",
"asn",
"isp",
"error",
)
def __init__(
self,
ip: str = "",
country: str = "",
country_name: str = "",
city: str = "",
region: str = "",
latitude: float = 0.0,
longitude: float = 0.0,
asn: str = "",
isp: str = "",
error: str = "",
) -> None:
self.ip = ip
self.country = country
self.country_name = country_name
self.city = city
self.region = region
self.latitude = latitude
self.longitude = longitude
self.asn = asn
self.isp = isp
self.error = error
# -- helpers -----------------------------------------------------------
def ok(self) -> bool:
return not self.error and self.latitude != 0.0 and self.longitude != 0.0
def to_dict(self) -> dict[str, Any]:
return {
"ip": self.ip,
"country": self.country,
"country_name": self.country_name,
"city": self.city,
"region": self.region,
"latitude": self.latitude,
"longitude": self.longitude,
"asn": self.asn,
"isp": self.isp,
"error": self.error,
}
def __repr__(self) -> str:
if self.error:
return f"GeoIPResult({self.ip!r}, error={self.error!r})"
return (
f"GeoIPResult({self.ip!r}, {self.country_name!r}, "
f"{self.city!r}, ({self.latitude}, {self.longitude}))"
)
# ---------------------------------------------------------------------------
# Private-IP classification (RFC 1918 + loopback + link-local)
# ---------------------------------------------------------------------------
def _is_private_ip(ip: str) -> bool:
"""Return True for RFC 1918 / loopback / link-local addresses."""
if not ip:
return True
# cheap pre-filter so we don't pay ipaddress.parse for garbage
head = ip.split(".", 1)[0] if "." in ip else ""
if head in ("127", "10"):
return True
try:
addr = ipaddress.ip_address(ip)
except ValueError:
# not an IP at all (could be a hostname) — treat as "not private"
return False
return bool(
addr.is_private
or addr.is_loopback
or addr.is_link_local
or addr.is_multicast
or addr.is_reserved
or addr.is_unspecified
)
# ---------------------------------------------------------------------------
# Online backend — ip-api.com
# ---------------------------------------------------------------------------
def lookup_online(ip: str, timeout: int = DEFAULT_TIMEOUT) -> GeoIPResult:
"""Look up ``ip`` against the public ip-api.com endpoint.
Returns a :class:`GeoIPResult` whose ``error`` attribute is set on
any failure (network error, bad JSON, private IP, rate-limit…). Never
raises.
"""
result = GeoIPResult(ip=ip)
if not ip:
result.error = "empty ip"
return result
if _is_private_ip(ip):
result.error = "private IP"
return result
url = _API_URL.format(ip=ip, fields=_API_FIELDS)
req = urllib.request.Request(
url,
headers={
"User-Agent": _USER_AGENT,
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
payload = json.loads(raw.decode("utf-8", errors="replace"))
except urllib.error.HTTPError as e:
# 429 = rate limited
result.error = f"http {e.code} {e.reason}"
return result
except urllib.error.URLError as e:
result.error = f"url error: {e.reason}"
return result
except (socket.timeout, TimeoutError):
result.error = "timeout"
return result
except json.JSONDecodeError as e:
result.error = f"json decode: {e.msg}"
return result
except Exception as e: # noqa: BLE001
result.error = f"unexpected: {type(e).__name__}: {e}"
return result
if not isinstance(payload, dict):
result.error = "unexpected payload shape"
return result
if payload.get("status") == "fail":
result.error = payload.get("message") or "lookup failed"
return result
result.country = (payload.get("countryCode") or "").strip()
result.country_name = (payload.get("country") or "").strip()
result.city = (payload.get("city") or "").strip()
result.region = (payload.get("regionName") or "").strip()
try:
result.latitude = float(payload.get("lat") or 0.0)
except (TypeError, ValueError):
result.latitude = 0.0
try:
result.longitude = float(payload.get("lon") or 0.0)
except (TypeError, ValueError):
result.longitude = 0.0
result.asn = (payload.get("as") or "").split()[0] if payload.get("as") else ""
result.isp = (payload.get("isp") or payload.get("org") or "").strip()
return result
# ---------------------------------------------------------------------------
# Offline backend — MaxMind GeoLite2-City .mmdb
# ---------------------------------------------------------------------------
def lookup_offline(ip: str, db_path: str = "") -> GeoIPResult:
"""Look up ``ip`` against a local MaxMind GeoLite2-City ``.mmdb``.
Requires the optional ``maxminddb`` package; if it's not installed,
the function returns a result with ``error="maxminddb not installed"``
rather than raising.
"""
result = GeoIPResult(ip=ip)
if not ip:
result.error = "empty ip"
return result
if _is_private_ip(ip):
result.error = "private IP"
return result
if not db_path:
result.error = "no db_path provided"
return result
try:
import maxminddb # type: ignore
except ImportError:
result.error = "maxminddb not installed"
return result
try:
reader = maxminddb.open_database(db_path)
except Exception as e: # noqa: BLE001
result.error = f"cannot open db: {e}"
return result
try:
record = reader.get(ip)
except Exception as e: # noqa: BLE001
reader.close()
result.error = f"db error: {e}"
return result
reader.close()
if not record:
result.error = "no record"
return result
country = record.get("country") or {}
iso = country.get("iso_code") or ""
name = (country.get("names") or {}).get("en") or ""
city = ((record.get("city") or {}).get("names") or {}).get("en") or ""
sub = ((record.get("subdivisions") or [{}])[0]) if record.get("subdivisions") else {}
region = (sub.get("names") or {}).get("en") or ""
loc = record.get("location") or {}
try:
lat = float(loc.get("latitude") or 0.0)
lon = float(loc.get("longitude") or 0.0)
except (TypeError, ValueError):
lat = lon = 0.0
result.country = iso
result.country_name = name
result.city = city
result.region = region
result.latitude = lat
result.longitude = lon
return result
# ---------------------------------------------------------------------------
# Cached unified entry point
# ---------------------------------------------------------------------------
def _cache_get(ip: str) -> GeoIPResult | None:
entry = CACHE.get(ip)
if not entry:
return None
result, ts = entry
if (time.time() - ts) > CACHE_TTL:
CACHE.pop(ip, None)
return None
return result
def _cache_put(result: GeoIPResult) -> None:
if not result.ip:
return
CACHE[result.ip] = (result, time.time())
def lookup(
ip: str,
*,
db_path: str = "",
use_online: bool = True,
) -> GeoIPResult:
"""Unified GeoIP entry point.
Returns a cached :class:`GeoIPResult` if present; otherwise performs
an online or offline lookup depending on ``use_online``.
- Private IPs short-circuit and are cached too (cheap; also lets
us avoid repeatedly network-attempting them).
- Failures are cached as well — but only briefly. Negative caching
for 60 s is plenty.
"""
cached = _cache_get(ip)
if cached is not None:
return cached
if _is_private_ip(ip):
result = GeoIPResult(ip=ip, error="private IP")
_cache_put(result)
return result
if use_online:
result = lookup_online(ip)
elif db_path:
result = lookup_offline(ip, db_path)
else:
result = GeoIPResult(ip=ip, error="no backend")
_cache_put(result)
return result
def cache_clear() -> int:
"""Clear the in-process cache. Returns the number of entries purged."""
n = len(CACHE)
CACHE.clear()
return n
def cache_stats() -> dict[str, int]:
"""Return a small snapshot of the cache for diagnostics pages."""
now = time.time()
total = len(CACHE)
fresh = 0
stale = 0
for _ip, (_, ts) in CACHE.items():
if (now - ts) <= CACHE_TTL:
fresh += 1
else:
stale += 1
return {"total": total, "fresh": fresh, "stale": stale}
__all__ = [
"GeoIPResult",
"CACHE",
"CACHE_TTL",
"lookup",
"lookup_online",
"lookup_offline",
"cache_clear",
"cache_stats",
]
+442
View File
@@ -0,0 +1,442 @@
"""Squid access.log parser and analyzer.
Squid native access.log line format (default):
time elapsed client action/code size method URL ident hierarchy content-type
Example:
1783841223.438 2488 172.16.12.232 TCP_MISS_ABORTED/000 0 GET \
http://169.254.169.254/metadata/instance/compute - HIER_DIRECT/169.254.169.254 -
"""
from __future__ import annotations
import re
from collections import Counter, defaultdict
from datetime import datetime, timezone
from urllib.parse import urlsplit
# --- Result code taxonomy ---------------------------------------------------
# Squid result codes split into categories for analysis.
RESULT_CATEGORIES = {
# cache hits
"TCP_HIT": "Hit",
"TCP_MEM_HIT": "Hit",
"TCP_NEGATIVE_HIT": "Hit",
"TCP_IMS_HIT": "Hit",
"TCP_REFRESH_HIT": "Hit",
"TCP_REFRESH_FAIL_HIT": "Hit",
"TCP_REF_FAIL_HIT": "Hit",
"TCP_IMS_HIT": "Hit",
"TCP_DENIED_REPLY": "Denied",
# cache misses
"TCP_MISS": "Miss",
"TCP_MISS_ABORTED": "Aborted",
"TCP_REFRESH_MISS": "Miss",
"TCP_CLIENT_REFRESH_MISS": "Miss",
"TCP_SWAPFAIL_MISS": "Miss",
# tunnels / connect
"TCP_TUNNEL": "Tunnel",
"CONNECT": "Tunnel",
# denied
"TCP_DENIED": "Denied",
"TCP_REDIRECT": "Redirect",
"NONE": "Error",
# udp
"UDP_HIT": "Hit",
"UDP_MISS": "Miss",
"UDP_DENIED": "Denied",
"UDP_INVALID": "Error",
"UDP_MISS_NO_FETCH": "Miss",
}
RESULT_CODE_DESCRIPTION = {
"TCP_HIT": "Cache hit - served from cache",
"TCP_MEM_HIT": "Memory cache hit",
"TCP_NEGATIVE_HIT": "Negative cached (e.g. 404)",
"TCP_IMS_HIT": "IMS hit (If-Modified-Since, 304)",
"TCP_MISS": "Cache miss - fetched from origin",
"TCP_MISS_ABORTED": "Miss - client aborted the request",
"TCP_TUNNEL": "CONNECT tunnel established (HTTPS proxy)",
"TCP_DENIED": "Request denied by ACL",
"TCP_DENIED_REPLY": "Reply denied by ACL",
"TCP_REFRESH_HIT": "Revalidation: still fresh",
"TCP_REFRESH_MISS": "Revalidation: stale, re-fetched",
"TCP_CLIENT_REFRESH_MISS": "Client forced refresh (Ctrl+F5)",
"TCP_REDIRECT": "Redirected by redirector",
"TCP_SWAPFAIL_MISS": "Swapfile miss (cache corruption?)",
"NONE": "Error before request was processed",
"UDP_HIT": "ICP cache hit",
"UDP_MISS": "ICP cache miss",
"UDP_DENIED": "ICP request denied",
"UDP_INVALID": "Invalid ICP request",
}
# hierarchy code -> short label
HIERARCHY_LABELS = {
"HIER_DIRECT": "Direct (no peer)",
"HIER_NONE": "None",
"HIER_SIBLING_HIT": "Sibling hit",
"HIER_PARENT_HIT": "Parent hit",
"HIER_DEFAULT_PARENT": "Default parent",
"HIER_SINGLE_PARENT": "Single parent",
"HIER_FIRST_UP_PARENT": "First up parent",
"HIER_NO_DIRECT_FAIL": "No direct fail",
"HIER_SRC_RTT_FASTEST": "Fastest RTT",
"HIER_NEAREST_NEIGHBOR_HIT": "Nearest neighbor hit",
"HIER_CD_SIBLING_HIT": "CARP sibling hit",
"HIER_CD_PARENT_HIT": "CARP parent hit",
"HIER_CARPLIST": "CARP list",
"HIER_MULTICAST_SIBLING": "Multicast sibling",
"HIER_ANY_PARENT": "Any parent",
"HIER_INVALID_CODE": "Invalid",
}
_LOG_LINE_RE = re.compile(
r"""^
(?P<time>\d+(?:\.\d+)?)\s+
(?P<elapsed>\d+)\s+
(?P<client>\S+)\s+
(?P<result>\S+)\s+
(?P<size>\d+)\s+
(?P<method>\S+)\s+
(?P<url>\S+)\s+
(?P<ident>\S+)\s+
(?P<hierarchy>\S+)\s+
(?P<content_type>.*)
$""",
re.VERBOSE,
)
class LogEntry(dict):
"""Parsed log entry as a dict with attribute access."""
@property
def category(self) -> str:
return RESULT_CATEGORIES.get(self.result_code, "Other")
@property
def result_description(self) -> str:
return RESULT_CODE_DESCRIPTION.get(self.result_code, self.result_code)
def parse_line(line: str) -> LogEntry | None:
"""Parse a single access.log line. Returns None on unparseable lines."""
if not line or not line.strip():
return None
m = _LOG_LINE_RE.match(line.strip())
if not m:
return None
g = m.groupdict()
# split result code into squid_code / http_code
raw_result = g["result"]
if "/" in raw_result:
squid_code, _, http_code = raw_result.partition("/")
else:
squid_code, http_code = raw_result, ""
# split hierarchy into hier_code / peer
raw_hier = g["hierarchy"]
if "/" in raw_hier:
hier_code, _, peer = raw_hier.partition("/")
else:
hier_code, peer = raw_hier, ""
# parse timestamp
try:
ts = float(g["time"])
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
except (ValueError, OSError):
dt = None
# parse URL into domain
url = g["url"]
host = _extract_host(url)
return LogEntry(
time=g["time"],
timestamp=dt,
elapsed_ms=int(g["elapsed"]),
client=g["client"],
result=raw_result,
result_code=squid_code,
http_code=http_code,
category=RESULT_CATEGORIES.get(squid_code, "Other"),
size=int(g["size"]),
method=g["method"],
url=url,
host=host,
ident=g["ident"] if g["ident"] != "-" else "",
hierarchy=raw_hier,
hier_code=hier_code,
peer=peer,
content_type=g["content_type"].strip(),
)
def parse_lines(text: str) -> list[LogEntry]:
"""Parse a multi-line string. Returns list of LogEntry."""
out = []
for line in text.splitlines():
e = parse_line(line)
if e:
out.append(e)
return out
def _extract_host(url: str) -> str:
"""Extract hostname from a Squid log URL.
Squid logs URLs in two forms:
- http(s)://host/path?query (normal GET/POST)
- host:port (CONNECT tunnel, no scheme)
urlsplit() fails on the second form (treats 'host' as scheme).
"""
if not url or url == "-":
return ""
# CONNECT method URLs look like 'host:port' with no scheme
# Detect by absence of '://' and presence of ':'
if "://" not in url:
# could be 'host:port' or just 'host'
if ":" in url:
host, _, _port = url.rpartition(":")
return host.strip()
return url.strip()
try:
h = urlsplit(url).hostname
return h or ""
except Exception:
return ""
def _top_n(counter: Counter, n: int = 20) -> list[tuple[str, int]]:
"""Return top-N (value, count) tuples."""
return counter.most_common(n)
def aggregate_stats(entries: list[LogEntry]) -> dict:
"""Build aggregate statistics from a list of LogEntry."""
if not entries:
return _empty_stats()
total = len(entries)
total_bytes = sum(e["size"] for e in entries)
total_elapsed = sum(e["elapsed_ms"] for e in entries)
# result code distribution
by_result = Counter(e["result_code"] for e in entries)
# http code distribution
by_http = Counter(e["http_code"] for e in entries if e["http_code"])
# method distribution
by_method = Counter(e["method"] for e in entries)
# category distribution (hit/miss/tunnel/denied/...)
by_category = Counter(e["category"] for e in entries)
# top clients (count + bytes)
client_count = Counter()
client_bytes = defaultdict(int)
for e in entries:
client_count[e["client"]] += 1
client_bytes[e["client"]] += e["size"]
# top destinations (host)
host_count = Counter()
host_bytes = defaultdict(int)
for e in entries:
if e["host"]:
host_count[e["host"]] += 1
host_bytes[e["host"]] += e["size"]
# top URLs
url_count = Counter(e["url"] for e in entries)
# hierarchy codes
by_hierarchy = Counter(e["hier_code"] for e in entries)
# content-type
by_ctype = Counter()
for e in entries:
ct = e["content_type"] or "-"
# collapse common types
ct_main = ct.split(";")[0].strip()
by_ctype[ct_main] += 1
# hourly distribution (UTC hour of day)
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] += e["size"]
# cache hit ratio: hits / (hits + misses), excluding tunnels/denied
hits = sum(c for code, c in by_result.items() if RESULT_CATEGORIES.get(code) == "Hit")
misses = sum(c for code, c in by_result.items() if RESULT_CATEGORIES.get(code) == "Miss")
hit_ratio = hits / (hits + misses) if (hits + misses) > 0 else 0.0
# denied rate
denied = sum(c for code, c in by_result.items() if RESULT_CATEGORIES.get(code) == "Denied")
denied_rate = denied / total if total > 0 else 0.0
# aborted rate
aborted = by_result.get("TCP_MISS_ABORTED", 0)
aborted_rate = aborted / total if total > 0 else 0.0
# unique clients
unique_clients = len(client_count)
# average response time
avg_elapsed = total_elapsed / total if total > 0 else 0.0
# average object size
avg_size = total_bytes / total if total > 0 else 0.0
# error analysis - HTTP 4xx / 5xx
errors_4xx = sum(c for code, c in by_http.items() if code.startswith("4"))
errors_5xx = sum(c for code, c in by_http.items() if code.startswith("5"))
errors_4xx_rate = errors_4xx / total if total > 0 else 0.0
errors_5xx_rate = errors_5xx / total if total > 0 else 0.0
# time range
timestamps = [e["timestamp"] for e in entries if e["timestamp"]]
if timestamps:
time_start = min(timestamps)
time_end = max(timestamps)
else:
time_start = time_end = None
return {
"total_requests": total,
"total_bytes": total_bytes,
"total_elapsed_ms": total_elapsed,
"unique_clients": unique_clients,
"unique_hosts": len(host_count),
"hit_ratio": hit_ratio,
"denied_rate": denied_rate,
"aborted_rate": aborted_rate,
"errors_4xx_rate": errors_4xx_rate,
"errors_5xx_rate": errors_5xx_rate,
"errors_4xx_count": errors_4xx,
"errors_5xx_count": errors_5xx,
"avg_elapsed_ms": round(avg_elapsed, 2),
"avg_size_bytes": round(avg_size, 2),
"hits": hits,
"misses": misses,
"denied": denied,
"aborted": aborted,
"time_start": time_start,
"time_end": time_end,
"by_result": _top_n(by_result, 50),
"by_http": _top_n(by_http, 50),
"by_method": _top_n(by_method, 20),
"by_category": _top_n(by_category, 20),
"by_hierarchy": _top_n(by_hierarchy, 20),
"by_ctype": _top_n(by_ctype, 20),
"top_clients": [
{"client": c, "count": n, "bytes": client_bytes[c]}
for c, n in _top_n(client_count, 50)
],
"top_hosts": [
{"host": h, "count": n, "bytes": host_bytes[h]}
for h, n in _top_n(host_count, 50)
],
"top_urls": [{"url": u, "count": n} for u, n in _top_n(url_count, 50)],
"hourly": [
{"hour": h, "requests": by_hour.get(h, 0), "bytes": by_hour_bytes.get(h, 0)}
for h in range(24)
],
"result_code_legend": RESULT_CODE_DESCRIPTION,
"hierarchy_legend": HIERARCHY_LABELS,
}
def _empty_stats() -> dict:
return {
"total_requests": 0,
"total_bytes": 0,
"total_elapsed_ms": 0,
"unique_clients": 0,
"unique_hosts": 0,
"hit_ratio": 0.0,
"denied_rate": 0.0,
"aborted_rate": 0.0,
"errors_4xx_rate": 0.0,
"errors_5xx_rate": 0.0,
"errors_4xx_count": 0,
"errors_5xx_count": 0,
"avg_elapsed_ms": 0.0,
"avg_size_bytes": 0.0,
"hits": 0,
"misses": 0,
"denied": 0,
"aborted": 0,
"time_start": None,
"time_end": None,
"by_result": [],
"by_http": [],
"by_method": [],
"by_category": [],
"by_hierarchy": [],
"by_ctype": [],
"top_clients": [],
"top_hosts": [],
"top_urls": [],
"hourly": [
{"hour": h, "requests": 0, "bytes": 0} for h in range(24)
],
"result_code_legend": RESULT_CODE_DESCRIPTION,
"hierarchy_legend": HIERARCHY_LABELS,
}
# --- Filters for log search -------------------------------------------------
def filter_entries(
entries: list[LogEntry],
client: str | None = None,
method: str | None = None,
result_code: str | None = None,
host: str | None = None,
url: str | None = None,
min_size: int | None = None,
max_size: int | None = None,
min_elapsed: int | None = None,
max_elapsed: int | None = None,
limit: int = 500,
) -> list[LogEntry]:
out = []
for e in entries:
if client and client not in e["client"]:
continue
if method and e["method"].upper() != method.upper():
continue
if result_code and result_code.upper() not in e["result_code"].upper():
continue
if host and host.lower() not in (e["host"] or "").lower():
continue
if url and url.lower() not in e["url"].lower():
continue
if min_size is not None and e["size"] < min_size:
continue
if max_size is not None and e["size"] > max_size:
continue
if min_elapsed is not None and e["elapsed_ms"] < min_elapsed:
continue
if max_elapsed is not None and e["elapsed_ms"] > max_elapsed:
continue
out.append(e)
if len(out) >= limit:
break
return out
def format_bytes(n: float) -> str:
"""Human-readable byte size."""
if n < 1024:
return f"{n:.0f} B"
if n < 1024 * 1024:
return f"{n / 1024:.1f} KB"
if n < 1024 * 1024 * 1024:
return f"{n / (1024 * 1024):.1f} MB"
if n < 1024 * 1024 * 1024 * 1024:
return f"{n / (1024 * 1024 * 1024):.2f} GB"
return f"{n / (1024 * 1024 * 1024 * 1024):.2f} TB"
def format_duration(ms: float) -> str:
"""Format millisecond duration."""
if ms < 1000:
return f"{ms:.0f} ms"
s = ms / 1000.0
if s < 60:
return f"{s:.2f} s"
m = int(s // 60)
s = s % 60
return f"{m}m {s:.1f}s"
+365
View File
@@ -0,0 +1,365 @@
"""log_rotate.py - Squid log rotation manager.
Provides:
- get_log_file_info(path) -> dict with size/mtime/age/writability
- generate_logrotate_config(log_paths, ...) -> str (full /etc/logrotate.d/squid content)
- install_logrotate(config_content) -> (ok, msg)
- uninstall_logrotate() -> (ok, msg)
- trigger_squid_rotate(binary='squid') -> (ok, msg)
- is_logrotate_available() -> bool
All functions use only Python stdlib (os, shutil, subprocess, datetime, time).
"""
from __future__ import annotations
import os
import shutil
import subprocess
import time
from datetime import datetime, timezone
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
LOGROTATE_DIR = "/etc/logrotate.d"
LOGROTATE_PATH = os.path.join(LOGROTATE_DIR, "squid")
LOGROTATE_BIN_CANDIDATES = ("/usr/sbin/logrotate", "/usr/bin/logrotate", "logrotate")
# Header that is recognised by `logrotate -d` as managed-by-squid-manager.
MANAGED_HEADER = "# Managed by Squid Web Manager - do not edit by hand."
# ---------------------------------------------------------------------------
# File info
# ---------------------------------------------------------------------------
def _human_size(num_bytes: int) -> str:
"""Format a byte count as a human-readable string (KB/MB/GB)."""
try:
n = float(num_bytes)
except (TypeError, ValueError):
return "0 B"
if n < 0:
return "0 B"
units = ("B", "KB", "MB", "GB", "TB", "PB")
idx = 0
while n >= 1024.0 and idx < len(units) - 1:
n /= 1024.0
idx += 1
if idx == 0:
return f"{int(n)} {units[idx]}"
return f"{n:.2f} {units[idx]}"
def get_log_file_info(path: str) -> dict:
"""Return metadata for a single log file.
Keys: exists, size_bytes, size_human, mtime, mtime_human,
age_days, is_writable, path, error.
Never raises - missing files / permission errors return a stub.
"""
info = {
"path": path,
"exists": False,
"size_bytes": 0,
"size_human": "0 B",
"mtime": None,
"mtime_human": "",
"age_days": None,
"is_writable": False,
"error": "",
}
if not path:
info["error"] = "empty path"
return info
try:
st = os.stat(path)
except FileNotFoundError:
info["error"] = "not found"
return info
except OSError as e:
info["error"] = f"{type(e).__name__}: {e}"
return info
info["exists"] = True
info["size_bytes"] = int(st.st_size)
info["size_human"] = _human_size(st.st_size)
info["mtime"] = st.st_mtime
info["mtime_human"] = datetime.fromtimestamp(
st.st_mtime, tz=timezone.utc
).strftime("%Y-%m-%d %H:%M:%S UTC")
try:
age_sec = max(0.0, time.time() - st.st_mtime)
info["age_days"] = round(age_sec / 86400.0, 2)
except Exception:
info["age_days"] = None
info["is_writable"] = os.access(path, os.W_OK)
return info
# ---------------------------------------------------------------------------
# logrotate config generation
# ---------------------------------------------------------------------------
def _is_log_path(path: str) -> bool:
"""True if path looks like an actual log file path (not blank, not None)."""
return bool(path) and isinstance(path, str) and path.strip() != ""
def generate_logrotate_config(
log_paths: list[str],
compress: bool = True,
rotate_count: int = 7,
rotate_size: str = "100M",
postrotate_cmd: str = "squid -k rotate",
prerotate_cmd: str = "",
daily: bool = True,
missingok: bool = True,
notifempty: bool = True,
create_mode: str = "0640",
create_owner: str = "proxy",
create_group: str = "proxy",
sharedscripts: bool = True,
delaycompress: bool = False,
) -> str:
"""Render a full /etc/logrotate.d/squid config block.
Only non-empty paths from ``log_paths`` are included.
``rotate_count`` is the number of old logs to keep, ``rotate_size`` is the
size threshold that triggers rotation (e.g. ``"100M"``, ``"500K"``).
"""
paths = [p.strip() for p in (log_paths or []) if _is_log_path(p)]
# de-dup, keep order
seen = set()
uniq_paths = []
for p in paths:
if p not in seen:
seen.add(p)
uniq_paths.append(p)
lines = [MANAGED_HEADER, "{", ""]
lines.append(" # rotate thresholds")
lines.append(f" size {rotate_size}")
if daily:
lines.append(" daily")
if missingok:
lines.append(" missingok")
if notifempty:
lines.append(" notifempty")
lines.append(f" rotate {int(rotate_count)}")
if create_mode or create_owner or create_group:
parts = []
if create_mode:
parts.append(create_mode)
if create_owner:
parts.append(create_owner)
if create_group:
parts.append(create_group)
lines.append(f" create {' '.join(parts)}".rstrip())
if delaycompress:
lines.append(" delaycompress")
if compress:
lines.append(" compress")
if sharedscripts:
lines.append(" sharedscripts")
lines.append("")
if uniq_paths:
lines.append(" # log files managed by this block")
for p in uniq_paths:
lines.append(f" {p}")
lines.append("")
if prerotate_cmd:
lines.append(f" prerotate")
lines.append(f" {prerotate_cmd}")
lines.append(f" endscript")
if postrotate_cmd:
lines.append(f" postrotate")
lines.append(f" {postrotate_cmd}")
lines.append(f" endscript")
lines.append("}")
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# logrotate install / uninstall
# ---------------------------------------------------------------------------
def _try_write(path: str, content: str) -> tuple[bool, str]:
"""Attempt a direct write to ``path``. Returns (ok, msg)."""
parent = os.path.dirname(path)
try:
if parent and not os.path.isdir(parent):
os.makedirs(parent, exist_ok=True)
# Write atomically: temp file then rename.
tmp_path = f"{path}.squidmgr.tmp"
with open(tmp_path, "w", encoding="utf-8") as fh:
fh.write(content)
os.replace(tmp_path, path)
try:
os.chmod(path, 0o644)
except OSError:
pass
return True, f"wrote {path} ({len(content)} bytes)"
except PermissionError as e:
return False, f"permission denied: {e}"
except OSError as e:
return False, f"{type(e).__name__}: {e}"
def install_logrotate(config_content: str, path: str = LOGROTATE_PATH) -> tuple[bool, str]:
"""Write a logrotate config to /etc/logrotate.d/squid.
Tries direct write first, then falls back to ``sudo tee`` if available.
"""
ok, msg = _try_write(path, config_content)
if ok:
return True, msg
# Fall back to sudo tee
if shutil.which("sudo") is None:
return False, msg
try:
proc = subprocess.run(
["sudo", "-n", "tee", path],
input=config_content,
text=True,
capture_output=True,
timeout=10,
)
if proc.returncode == 0:
# sudo tee creates the file as root; chmod for readability
try:
subprocess.run(["sudo", "-n", "chmod", "644", path],
capture_output=True, timeout=5)
except Exception:
pass
return True, f"wrote {path} via sudo ({len(config_content)} bytes)"
return False, msg + f" | sudo failed: rc={proc.returncode} {proc.stderr.strip()}"
except subprocess.TimeoutExpired:
return False, msg + " | sudo timed out"
except FileNotFoundError as e:
return False, msg + f" | sudo not usable: {e}"
def uninstall_logrotate(path: str = LOGROTATE_PATH) -> tuple[bool, str]:
"""Remove the logrotate config. Returns (ok, msg)."""
if not os.path.exists(path):
return True, f"{path} does not exist (nothing to do)"
try:
os.remove(path)
return True, f"removed {path}"
except PermissionError:
pass
except OSError as e:
return False, f"{type(e).__name__}: {e}"
# Fallback: sudo rm
if shutil.which("sudo") is None:
return False, f"permission denied removing {path} (sudo unavailable)"
try:
proc = subprocess.run(
["sudo", "-n", "rm", "-f", path],
capture_output=True, text=True, timeout=10,
)
if proc.returncode == 0:
return True, f"removed {path} via sudo"
return False, f"sudo rm failed: rc={proc.returncode} {proc.stderr.strip()}"
except subprocess.TimeoutExpired:
return False, "sudo rm timed out"
def read_logrotate_config(path: str = LOGROTATE_PATH) -> tuple[bool, str, str]:
"""Read the current logrotate config. Returns (ok, content, msg)."""
if not os.path.exists(path):
return False, "", f"{path} not found"
try:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
data = fh.read()
return True, data, f"read {len(data)} bytes from {path}"
except PermissionError as e:
return False, "", f"permission denied: {e}"
except OSError as e:
return False, "", f"{type(e).__name__}: {e}"
# ---------------------------------------------------------------------------
# logrotate binary detection
# ---------------------------------------------------------------------------
def is_logrotate_available() -> bool:
"""Return True if the logrotate binary is on PATH."""
for cand in LOGROTATE_BIN_CANDIDATES:
if shutil.which(cand):
return True
return False
def get_logrotate_binary() -> str | None:
"""Return the absolute path of logrotate, or None."""
for cand in LOGROTATE_BIN_CANDIDATES:
p = shutil.which(cand)
if p:
return p
return None
# ---------------------------------------------------------------------------
# Squid rotate trigger
# ---------------------------------------------------------------------------
def trigger_squid_rotate(binary: str = "squid", timeout: int = 15) -> tuple[bool, str]:
"""Ask Squid to rotate its logs via ``squid -k rotate``.
This sends a signal to the running squid process asking it to close and
reopen its log files. Combined with logrotate's "create" directive this
lets logrotate manage file rotation without disrupting Squid.
Returns (ok, msg). msg is a one-line summary suitable for flash / audit.
"""
binary_path = shutil.which(binary) if os.sep not in binary else binary
if not binary_path or not os.path.exists(binary_path):
return False, f"squid binary not found: {binary}"
try:
proc = subprocess.run(
[binary_path, "-k", "rotate"],
capture_output=True,
text=True,
timeout=timeout,
)
out = (proc.stdout or "").strip()
err = (proc.stderr or "").strip()
if proc.returncode == 0:
msg = f"squid -k rotate ok"
if out:
msg += f" | stdout={out[:200]}"
return True, msg
msg = f"squid -k rotate failed rc={proc.returncode}"
if err:
msg += f" | stderr={err[:200]}"
elif out:
msg += f" | stdout={out[:200]}"
return False, msg
except subprocess.TimeoutExpired:
return False, f"squid -k rotate timed out after {timeout}s"
except FileNotFoundError as e:
return False, f"squid not executable: {e}"
except Exception as e:
return False, f"{type(e).__name__}: {e}"
# ---------------------------------------------------------------------------
# Threshold helpers for UI badges
# ---------------------------------------------------------------------------
# Bytes; UI uses these to colour-size badges.
SIZE_WARN_BYTES = 500 * 1024 * 1024 # 500 MB
SIZE_CRIT_BYTES = 2 * 1024 * 1024 * 1024 # 2 GB
def size_severity(size_bytes: int) -> str:
"""Return 'normal' / 'warn' / 'crit' for badge styling."""
if size_bytes >= SIZE_CRIT_BYTES:
return "crit"
if size_bytes >= SIZE_WARN_BYTES:
return "warn"
return "normal"
+800
View File
@@ -0,0 +1,800 @@
"""Persistent log storage for Squid access.log.
This module owns the bridge between raw access.log files on disk and the
SQLite-backed :class:`~app.LogEntry` table. It exposes:
- :func:`sync_log_to_db` - incrementally import new lines from a log file
- :func:`query_logs` - SQL-backed filter/search with time range support
- :func:`get_log_stats` - aggregated statistics (hit/miss, top clients...)
- :func:`get_distinct_hosts` / :func:`get_distinct_clients` /
:func:`get_distinct_methods` - faceted browsing
Implementation notes:
- We rely on the existing :mod:`log_parser` for line format handling so we do
not duplicate the access.log grammar.
- Bulk inserts are batched (default 500 rows) using
:func:`sqlalchemy.orm.Session.execute` with a parameterised INSERT so we
don't pay the round-trip cost of per-row commits.
- De-duplication uses the tuple ``(instance_id, log_time, client, raw_line)``;
on conflict (we surface those as ``skipped_count`` rather than re-inserting).
"""
from __future__ import annotations
import os
import time
from collections import Counter, defaultdict
from datetime import datetime, timezone
from typing import Iterable
from sqlalchemy import and_, func, or_, select
from sqlalchemy.exc import SQLAlchemyError
import log_parser
from app import db, LogEntry # imported lazily-friendly via app
# ---------------------------------------------------------------------------
# Sync
# ---------------------------------------------------------------------------
def _iter_log_lines(path: str, max_lines: int | None = None):
"""Yield raw lines from ``path`` as bytes-decoded strings.
When ``max_lines`` is set we walk backwards through the file with a
bounded per-iteration buffer (Deque-style window) and yield lines in
chronological order. For an unbounded scan (full-history import) we
just stream forwards. Designed to be tolerant of mid-stream truncation
or binary garbage.
"""
if not os.path.isfile(path):
return
if not max_lines or max_lines <= 0:
try:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
for raw in fh:
line = raw.rstrip("\n").rstrip("\r")
if line.strip():
yield line
except OSError:
return
return
# Bounded tail: read last ``max_lines`` lines efficiently
try:
with open(path, "rb") as fh:
try:
fh.seek(0, os.SEEK_END)
size = fh.tell()
chunks: list[bytes] = []
lines_found = 0
block = 65536
pos = size
while pos > 0 and lines_found <= max_lines:
read_size = min(block, pos)
pos -= read_size
fh.seek(pos)
chunks.append(fh.read(read_size))
lines_found += chunks[-1].count(b"\n")
data = b"".join(reversed(chunks))
except OSError:
fh.seek(0)
data = fh.read()
text = data.decode("utf-8", errors="replace")
lines = text.splitlines()
if len(lines) > max_lines:
lines = lines[-max_lines:]
for line in lines:
stripped = line.strip()
if stripped:
yield stripped
except OSError:
return
def sync_log_to_db(
path: str,
instance_id: int = 0,
batch_size: int = 500,
max_lines: int = 100000,
) -> tuple[int, int, str | None]:
"""Incrementally import new lines from ``path`` into :class:`LogEntry`.
Steps:
1. Compute the high-water mark ``max(existing.log_time)`` for the
instance so we can skip already-imported lines quickly.
2. Stream the last ``max_lines`` lines of the file
(or the entire file when ``max_lines`` is None/0) and parse them
through :func:`log_parser.parse_line`.
3. Insert new rows in batches of ``batch_size`` using
:func:`sqlalchemy.dialects.sqlite.insert` with
``OR (primary key uniqueness handled by auot-increment)`` semantics.
Duplicate suppression uses an in-memory ``seen`` set keyed by
``(client, log_time, raw_line)`` since the file itself can repeat
a row at rotation boundaries.
4. Commit once per batch and return a tuple ``(inserted, skipped, err)``.
All exceptions are caught and reported as the third tuple element so the
caller can decide whether to surface them (e.g. on the dashboard).
"""
if batch_size <= 0:
batch_size = 500
try:
# high-water mark: only lines with log_time > wm are new
wm = (
db.session.query(func.max(LogEntry.log_time))
.filter(LogEntry.instance_id == instance_id)
.scalar()
)
if wm is None:
wm = 0.0
seen_keys: set[tuple[str, float, str]] = set()
inserted = 0
skipped = 0
batch: list[dict] = []
for raw_line in _iter_log_lines(path, max_lines=max_lines):
e = log_parser.parse_line(raw_line)
if not e:
skipped += 1
continue
try:
ts = float(e.get("time") or 0)
except (TypeError, ValueError):
skipped += 1
continue
if ts <= wm:
skipped += 1
continue
key = (e.get("client") or "", ts, raw_line)
if key in seen_keys:
skipped += 1
continue
seen_keys.add(key)
batch.append({
"instance_id": instance_id,
"log_time": ts,
"elapsed_ms": int(e.get("elapsed_ms") or 0),
"client": (e.get("client") or "")[:64],
"result_code": (e.get("result_code") or "")[:32],
"http_code": (e.get("http_code") or "")[:8],
"size_bytes": int(e.get("size") or 0),
"method": (e.get("method") or "")[:16],
"url": (e.get("url") or "")[:65535],
"host": (e.get("host") or "")[:256],
"hier_code": (e.get("hier_code") or "")[:32],
"content_type": (e.get("content_type") or "")[:128],
"raw_line": raw_line[:65535],
"created_at": datetime.now(timezone.utc),
})
if len(batch) >= batch_size:
_flush_batch(batch)
inserted += len(batch)
batch = []
if batch:
_flush_batch(batch)
inserted += len(batch)
# record last sync time so the UI can display "上次同步"
_record_last_sync(instance_id, path)
return inserted, skipped, None
except SQLAlchemyError as e:
try:
db.session.rollback()
except Exception:
pass
return 0, 0, f"DB error: {type(e).__name__}: {e}"
except Exception as e:
try:
db.session.rollback()
except Exception:
pass
return 0, 0, f"{type(e).__name__}: {e}"
def _flush_batch(batch: list[dict]) -> None:
"""Insert a batch of rows in a single SQL statement.
We rely on the caller's in-memory ``(instance_id, log_time, client,
raw_line)`` set (built in :func:`sync_log_to_db`) for dedup since we
don't have a SQL-level UNIQUE constraint. This means inserts are
guaranteed to be new rows at insert time, so we use a plain
``insert().values()`` rather than ``on_conflict_do_nothing``.
"""
if not batch:
return
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
stmt = sqlite_insert(LogEntry).values(batch)
db.session.execute(stmt)
db.session.commit()
# ---------------------------------------------------------------------------
# Query
# ---------------------------------------------------------------------------
def _parse_time(value) -> float | None:
"""Parse an ISO-ish datetime / unix timestamp into a unix float (UTC)."""
if value is None or value == "":
return None
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, datetime):
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.timestamp()
if isinstance(value, str):
s = value.strip()
if not s:
return None
# datetime-local HTML input sends strings like "2024-01-31T10:00"
# or "2024-01-31 10:00:00". Python's fromisoformat handles both.
try:
# Support trailing Z
if s.endswith("Z"):
s = s[:-1] + "+00:00"
dt = datetime.fromisoformat(s)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.timestamp()
except ValueError:
# fall through to float attempt
pass
try:
return float(s)
except ValueError:
return None
return None
def _entry_to_dict(row: LogEntry) -> dict:
"""Convert a :class:`LogEntry` row to the dict shape used downstream.
Mirrors :class:`log_parser.LogEntry` (which is a dict subclass) so
existing consumers - templates, alert rules, anomaly detection - keep
working without modification.
"""
ts = float(row.log_time or 0)
host = row.host or ""
url = row.url or ""
raw = row.raw_line or ""
# split result_code (already stored without slash) - reconstruct full
# Squid "result" string (e.g. "TCP_HIT/200") for backward compat
result_full = row.result_code or ""
if row.http_code:
result_full = f"{result_full}/{row.http_code}"
return {
"id": row.id,
"time": row.log_time, # float unix ts
"timestamp": datetime.fromtimestamp(ts, tz=timezone.utc) if ts > 0 else None,
"elapsed_ms": int(row.elapsed_ms or 0),
"client": row.client or "",
"result": result_full,
"result_code": row.result_code or "",
"http_code": row.http_code or "",
"category": log_parser.RESULT_CATEGORIES.get(row.result_code or "", "Other"),
"result_description": log_parser.RESULT_CODE_DESCRIPTION.get(row.result_code or "", row.result_code or ""),
"size": int(row.size_bytes or 0),
"method": row.method or "",
"url": url,
"host": host,
"ident": "", # not persisted separately
"hierarchy": row.hier_code or "",
"hier_code": row.hier_code or "",
"peer": "", # not persisted
"content_type": row.content_type or "",
"raw_line": raw,
"formatted_size": log_parser.format_bytes(int(row.size_bytes or 0)),
"formatted_duration": log_parser.format_duration(int(row.elapsed_ms or 0)),
}
def query_logs(filters: dict) -> list[dict]:
"""Run a filtered SQL query against the persisted log rows.
Supported keys (all optional):
- ``instance_id`` (int)
- ``client``, ``method``, ``result_code``, ``host`` (exact or substring)
- ``host_filter_mode`` "exact" | "contains" (default "contains")
- ``url`` substring LIKE
- ``min_size``, ``max_size`` (int bytes)
- ``min_elapsed``, ``max_elapsed`` (int milliseconds)
- ``start_time``, ``end_time`` (datetime or unix timestamp, inclusive lo,
inclusive hi for end so users don't accidentally exclude the minute
they typed)
- ``limit`` (default 1000), ``offset`` (default 0)
Returns a list of dicts with the same shape as
:func:`log_parser.parse_lines`, plus ``formatted_size`` /
``formatted_duration`` keys.
"""
try:
q = db.session.query(LogEntry)
# instance scoping
iid = filters.get("instance_id")
if iid is not None:
q = q.filter(LogEntry.instance_id == iid)
c = (filters.get("client") or "").strip()
if c:
q = q.filter(LogEntry.client.like(f"%{c}%"))
m = (filters.get("method") or "").strip()
if m:
q = q.filter(LogEntry.method == m)
rc = (filters.get("result_code") or "").strip()
if rc:
q = q.filter(LogEntry.result_code.like(f"%{rc}%"))
h = (filters.get("host") or "").strip()
if h:
mode = (filters.get("host_filter_mode") or "contains").lower()
if mode == "exact":
q = q.filter(LogEntry.host == h)
else:
q = q.filter(LogEntry.host.like(f"%{h}%"))
u = (filters.get("url") or "").strip()
if u:
q = q.filter(LogEntry.url.like(f"%{u}%"))
mn = filters.get("min_size")
if mn is not None:
q = q.filter(LogEntry.size_bytes >= int(mn))
mx = filters.get("max_size")
if mx is not None:
q = q.filter(LogEntry.size_bytes <= int(mx))
mne = filters.get("min_elapsed")
if mne is not None:
q = q.filter(LogEntry.elapsed_ms >= int(mne))
mxe = filters.get("max_elapsed")
if mxe is not None:
q = q.filter(LogEntry.elapsed_ms <= int(mxe))
st = _parse_time(filters.get("start_time"))
if st is not None:
q = q.filter(LogEntry.log_time >= st)
et = _parse_time(filters.get("end_time"))
if et is not None:
q = q.filter(LogEntry.log_time <= et)
# default ordering: newest first
q = q.order_by(LogEntry.log_time.desc(), LogEntry.id.desc())
limit = int(filters.get("limit") or 1000)
offset = int(filters.get("offset") or 0)
if limit > 0:
q = q.limit(limit)
if offset > 0:
q = q.offset(offset)
rows = q.all()
return [_entry_to_dict(r) for r in rows]
except SQLAlchemyError as e:
try:
db.session.rollback()
except Exception:
pass
return []
def count_logs(filters: dict | None = None) -> int:
"""Return the number of rows matching ``filters`` (same shape as ``query_logs``).
Used by ``logs_view`` to render total / pagination accurately without
transferring every row over the wire.
"""
f = dict(filters or {})
f["limit"] = 0
f["offset"] = 0
try:
# rebuild only the WHERE portion - run the same filter logic but
# count instead of fetch
q = _build_base_query(f)
return q.with_entities(func.count(LogEntry.id)).scalar() or 0
except SQLAlchemyError:
try:
db.session.rollback()
except Exception:
pass
return 0
def _build_base_query(filters: dict):
"""Internal: same WHERE-clause as :func:`query_logs` but returns the
SQLAlchemy query for further composition (e.g. count)."""
q = db.session.query(LogEntry)
iid = filters.get("instance_id")
if iid is not None:
q = q.filter(LogEntry.instance_id == iid)
c = (filters.get("client") or "").strip()
if c:
q = q.filter(LogEntry.client.like(f"%{c}%"))
m = (filters.get("method") or "").strip()
if m:
q = q.filter(LogEntry.method == m)
rc = (filters.get("result_code") or "").strip()
if rc:
q = q.filter(LogEntry.result_code.like(f"%{rc}%"))
h = (filters.get("host") or "").strip()
if h:
q = q.filter(LogEntry.host.like(f"%{h}%"))
u = (filters.get("url") or "").strip()
if u:
q = q.filter(LogEntry.url.like(f"%{u}%"))
mn = filters.get("min_size")
if mn is not None:
q = q.filter(LogEntry.size_bytes >= int(mn))
mx = filters.get("max_size")
if mx is not None:
q = q.filter(LogEntry.size_bytes <= int(mx))
mne = filters.get("min_elapsed")
if mne is not None:
q = q.filter(LogEntry.elapsed_ms >= int(mne))
mxe = filters.get("max_elapsed")
if mxe is not None:
q = q.filter(LogEntry.elapsed_ms <= int(mxe))
st = _parse_time(filters.get("start_time"))
if st is not None:
q = q.filter(LogEntry.log_time >= st)
et = _parse_time(filters.get("end_time"))
if et is not None:
q = q.filter(LogEntry.log_time <= et)
return q
# ---------------------------------------------------------------------------
# Aggregate stats
# ---------------------------------------------------------------------------
def get_log_stats(
instance_id: int = 0,
start_time: float | None = None,
end_time: float | None = None,
) -> dict:
"""Compute aggregate statistics in SQL.
Compatible with :func:`log_parser.aggregate_stats`. When there are no
rows in scope we return the same ``_empty_stats()`` structure so the
caller doesn't need a special case.
"""
try:
q = _build_base_query({
"instance_id": instance_id,
"start_time": start_time,
"end_time": end_time,
})
total = q.with_entities(func.count(LogEntry.id)).scalar() or 0
if total == 0:
stats = _copy_empty_stats()
else:
agg = q.with_entities(
func.coalesce(func.sum(LogEntry.size_bytes), 0),
func.coalesce(func.sum(LogEntry.elapsed_ms), 0),
func.min(LogEntry.log_time),
func.max(LogEntry.log_time),
).one()
total_bytes, total_elapsed, ts_min, ts_max = agg
total = int(total)
total_bytes = int(total_bytes)
total_elapsed = int(total_elapsed)
avg_elapsed = round(total_elapsed / total, 2) if total else 0.0
avg_size = round(total_bytes / total, 2) if total else 0.0
# result_code distribution
by_result = dict(
q.with_entities(
LogEntry.result_code,
func.count(LogEntry.id),
).group_by(LogEntry.result_code).all()
)
by_result = {k or "": v for k, v in by_result.items()}
# http_code distribution (only rows with a value)
by_http = dict(
q.filter(LogEntry.http_code != "").with_entities(
LogEntry.http_code,
func.count(LogEntry.id),
).group_by(LogEntry.http_code).all()
)
# method distribution
by_method = dict(
q.with_entities(
LogEntry.method,
func.count(LogEntry.id),
).group_by(LogEntry.method).all()
)
# hierarchy distribution
by_hier = dict(
q.with_entities(
LogEntry.hier_code,
func.count(LogEntry.id),
).group_by(LogEntry.hier_code).all()
)
# top clients: most active + bytes
top_clients = q.with_entities(
LogEntry.client,
func.count(LogEntry.id).label("c"),
func.coalesce(func.sum(LogEntry.size_bytes), 0).label("b"),
).group_by(LogEntry.client).order_by(func.count(LogEntry.id).desc()).limit(50).all()
top_clients = [{"client": c or "", "count": int(c_), "bytes": int(b_)} for c, c_, b_ in top_clients]
# top hosts
top_hosts_rows = q.filter(LogEntry.host != "").with_entities(
LogEntry.host,
func.count(LogEntry.id).label("c"),
func.coalesce(func.sum(LogEntry.size_bytes), 0).label("b"),
).group_by(LogEntry.host).order_by(func.count(LogEntry.id).desc()).limit(50).all()
top_hosts = [{"host": h, "count": int(c_), "bytes": int(b_)} for h, c_, b_ in top_hosts_rows]
# top urls
top_urls_rows = q.with_entities(
LogEntry.url,
func.count(LogEntry.id).label("c"),
).group_by(LogEntry.url).order_by(func.count(LogEntry.id).desc()).limit(50).all()
top_urls = [{"url": u, "count": int(c_)} for u, c_ in top_urls_rows]
# content-type: simpler approach - just count distinct top values
by_ctype_rows = q.with_entities(
LogEntry.content_type,
func.count(LogEntry.id).label("c"),
).group_by(LogEntry.content_type).order_by(func.count(LogEntry.id).desc()).limit(20).all()
by_ctype = []
for ct, c_ in by_ctype_rows:
ct_main = (ct or "-").split(";")[0].strip() or "-"
by_ctype.append((ct_main, int(c_)))
# collapse same main types
merged: dict[str, int] = defaultdict(int)
for ct_main, c_ in by_ctype:
merged[ct_main] += c_
by_ctype = list(merged.items())[:20]
# hit/miss ratio using result_code categories
hits = sum(c for code, c in by_result.items()
if log_parser.RESULT_CATEGORIES.get(code or "") == "Hit")
misses = sum(c for code, c in by_result.items()
if log_parser.RESULT_CATEGORIES.get(code or "") == "Miss")
denied = sum(c for code, c in by_result.items()
if log_parser.RESULT_CATEGORIES.get(code or "") == "Denied")
aborted = by_result.get("TCP_MISS_ABORTED", 0)
denied_rate = denied / total if total else 0.0
aborted_rate = aborted / total if total else 0.0
hit_ratio = hits / (hits + misses) if (hits + misses) > 0 else 0.0
errors_4xx = sum(c for code, c in by_http.items() if code.startswith("4"))
errors_5xx = sum(c for code, c in by_http.items() if code.startswith("5"))
errors_4xx_rate = errors_4xx / total if total else 0.0
errors_5xx_rate = errors_5xx / total if total else 0.0
# unique clients / hosts
unique_clients = q.with_entities(func.count(func.distinct(LogEntry.client))).scalar() or 0
unique_hosts = q.filter(LogEntry.host != "").with_entities(
func.count(func.distinct(LogEntry.host))
).scalar() or 0
# hourly distribution (UTC hour of day)
by_hour_counts: dict[int, int] = defaultdict(int)
by_hour_bytes: dict[int, int] = defaultdict(int)
# we already have min/max so size of an in-Python bucket is small
# but to avoid pulling all rows we can do an UNION-ish query per
# hour; the simpler approach: fetch (time, size) projection.
rows = q.with_entities(LogEntry.log_time, LogEntry.size_bytes).all()
for ts, size in rows:
if not ts:
continue
hr = datetime.fromtimestamp(float(ts), tz=timezone.utc).hour
by_hour_counts[hr] += 1
by_hour_bytes[hr] += int(size or 0)
time_start = datetime.fromtimestamp(float(ts_min), tz=timezone.utc) if ts_min else None
time_end = datetime.fromtimestamp(float(ts_max), tz=timezone.utc) if ts_max else None
stats = {
"total_requests": total,
"total_bytes": total_bytes,
"total_elapsed_ms": total_elapsed,
"unique_clients": int(unique_clients),
"unique_hosts": int(unique_hosts),
"hit_ratio": hit_ratio,
"denied_rate": denied_rate,
"aborted_rate": aborted_rate,
"errors_4xx_rate": errors_4xx_rate,
"errors_5xx_rate": errors_5xx_rate,
"errors_4xx_count": errors_4xx,
"errors_5xx_count": errors_5xx,
"avg_elapsed_ms": avg_elapsed,
"avg_size_bytes": avg_size,
"hits": hits,
"misses": misses,
"denied": denied,
"aborted": aborted,
"time_start": time_start,
"time_end": time_end,
"by_result": log_parser._top_n(Counter(by_result), 50),
"by_http": log_parser._top_n(Counter(by_http), 50),
"by_method": log_parser._top_n(Counter(by_method), 20),
"by_category": [], # we don't store category; compute inline if needed
"by_hierarchy": log_parser._top_n(Counter(by_hier), 20),
"by_ctype": by_ctype,
"top_clients": top_clients,
"top_hosts": top_hosts,
"top_urls": top_urls,
"hourly": [
{"hour": h, "requests": int(by_hour_counts.get(h, 0)),
"bytes": int(by_hour_bytes.get(h, 0))}
for h in range(24)
],
"result_code_legend": log_parser.RESULT_CODE_DESCRIPTION,
"hierarchy_legend": log_parser.HIERARCHY_LABELS,
}
# augment with sync timestamp for the dashboard UI
try:
from app import AppConfig
ts_str = AppConfig.query.filter_by(key="_log_last_sync_at").first()
stats["last_sync_at"] = ts_str.value if ts_str else None
except Exception:
stats["last_sync_at"] = None
return stats
except SQLAlchemyError as e:
try:
db.session.rollback()
except Exception:
pass
s = _copy_empty_stats()
s["last_sync_at"] = None
s["error"] = f"{type(e).__name__}: {e}"
return s
def _copy_empty_stats() -> dict:
"""Return a fresh empty-stats dict (avoid mutating log_parser._empty_stats)."""
return log_parser._empty_stats()
# ---------------------------------------------------------------------------
# Distinct facets
# ---------------------------------------------------------------------------
def get_distinct_hosts(instance_id: int = 0, limit: int = 200) -> list[str]:
try:
rows = (
db.session.query(LogEntry.host)
.filter(LogEntry.host != "", LogEntry.instance_id == instance_id)
.distinct()
.order_by(LogEntry.host.asc())
.limit(int(limit))
.all()
)
return [r[0] for r in rows if r[0]]
except SQLAlchemyError:
try:
db.session.rollback()
except Exception:
pass
return []
def get_distinct_clients(instance_id: int = 0, limit: int = 200) -> list[str]:
try:
rows = (
db.session.query(LogEntry.client)
.filter(LogEntry.client != "", LogEntry.instance_id == instance_id)
.distinct()
.order_by(LogEntry.client.asc())
.limit(int(limit))
.all()
)
return [r[0] for r in rows if r[0]]
except SQLAlchemyError:
try:
db.session.rollback()
except Exception:
pass
return []
def get_distinct_methods(instance_id: int = 0) -> list[str]:
try:
rows = (
db.session.query(LogEntry.method)
.filter(LogEntry.method != "", LogEntry.instance_id == instance_id)
.distinct()
.order_by(LogEntry.method.asc())
.all()
)
return [r[0] for r in rows if r[0]]
except SQLAlchemyError:
try:
db.session.rollback()
except Exception:
pass
return []
# ---------------------------------------------------------------------------
# Sync bookkeeping
# ---------------------------------------------------------------------------
_LAST_SYNC_KEY = "_log_last_sync_at"
def _record_last_sync(instance_id: int, path: str) -> None:
"""Stamp ``AppConfig`` rows with the last successful sync timestamp.
Two values are stored: ``_log_last_sync_at`` (ISO timestamp) and
``_log_last_sync_path`` (the file we synced from). We use ``AppConfig``
rather than ``LogEntry`` so the timestamp survives even if log rows
are later rotated out.
"""
try:
from app import AppConfig
ts_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
for key, value in (
(_LAST_SYNC_KEY, ts_iso),
("_log_last_sync_path", path or ""),
("_log_last_sync_instance", str(instance_id)),
):
row = AppConfig.query.filter_by(key=key).first()
if row is None:
row = AppConfig(key=key, value=value)
db.session.add(row)
else:
row.value = value
db.session.commit()
except Exception:
try:
db.session.rollback()
except Exception:
pass
def get_last_sync_at() -> str | None:
"""Return the ISO timestamp of the last sync, or ``None``."""
try:
from app import AppConfig
row = AppConfig.query.filter_by(key=_LAST_SYNC_KEY).first()
return row.value if row else None
except Exception:
return None
def get_last_sync_path() -> str | None:
try:
from app import AppConfig
row = AppConfig.query.filter_by(key="_log_last_sync_path").first()
return row.value if row else None
except Exception:
return None
def import_full_history(
path: str,
instance_id: int = 0,
batch_size: int = 500,
progress_cb=None,
) -> tuple[int, int, str | None]:
"""Import the entire ``access.log`` from byte 0 (not just the tail).
Used by the "Import history" button on the logs page. Calls
:func:`sync_log_to_db` with a very high ``max_lines`` (50M) so we
stream every line. Progress is reported via ``progress_cb(processed)``
so the caller can render a status indicator.
"""
try:
# 50M lines ceiling - enough for multi-year logs; streaming means
# we never actually load them all into memory.
return sync_log_to_db(
path=path,
instance_id=instance_id,
batch_size=batch_size,
max_lines=50_000_000,
)
except Exception as e:
return 0, 0, f"{type(e).__name__}: {e}"
+527
View File
@@ -0,0 +1,527 @@
"""Squid custom logformat parser.
Squid's ``access.log`` is normally written in *native* format, but operators
can override it with a ``logformat`` directive, e.g.::
logformat squid %ts.%03tu %6tr %>a %Ss/%03>Hs %<st %rm %ru %un %Sh/%<a %mt
This module turns such a format string into a regular expression and applies
it to individual log lines, returning a ``LogEntry``-compatible dict whose
field names line up with :mod:`log_parser` so the rest of the application
(dashboard, alerts, anomaly detection) does not need to change.
The two important design choices:
1. **Field name parity.** ``parse_with_format`` returns the same keys as
``log_parser.parse_line`` so ``get_parsed_logs`` can return a single
uniform ``LogEntry`` regardless of which path produced it.
2. **Compose-then-split.** The combined tokens such as ``%Ss/%03>Hs`` or
``%Sh/%<a`` are matched as a single field (one whitespace-delimited
token) and then split on ``/`` internally. This lets us handle the
native-style "slash-joined" log line shape no matter which ``logformat``
the operator uses.
"""
from __future__ import annotations
import re
from datetime import datetime, timezone
from urllib.parse import urlsplit
# ---------------------------------------------------------------------------
# Token map
# ---------------------------------------------------------------------------
#
# Each entry is ``(token_text, canonical_group_name, inner_pattern)``.
# ``canonical_group_name`` is a valid Python regex identifier that maps onto
# the field name used by :mod:`log_parser`. The order of ``_TOKEN_DEFS``
# matters: longer / more specific tokens (with width specifiers like
# ``%03tu``) MUST be tried before shorter variants of the same prefix so
# we don't accidentally swallow them as ``%tu``.
#
# Tokens covered:
# %ts - Unix timestamp seconds (with optional fractional part)
# %03tu / %tu - Sub-second fraction (milliseconds)
# %6tr / %tr - Response time (microseconds with %6, otherwise raw)
# %>a - Client IP / source address
# %<a - Server IP / peer address
# %Ss - Squid result code (TCP_HIT etc)
# %03>Hs / %>Hs - HTTP status code (with / without width spec)
# %<st / %st - Reply size in bytes
# %rm - Request method (GET/POST/CONNECT ...)
# %ru - Request URL
# %un - Auth user / ident ("-" when anonymous)
# %Sh - Hierarchy code (HIER_DIRECT etc)
# %mt - MIME content type (greedy at the end)
# (token_text, canonical_group_name, regex_inner)
# ``regex_inner`` is the regex WITHOUT the ``(?P<name>...)`` wrapper so we
# can reuse it for compound tokens (where multiple tokens share the same
# captured string).
_TOKEN_DEFS: list[tuple[str, str, str]] = [
# Timestamp: integer seconds. The optional fractional part is
# handled by %03tu / %tu if the operator's format splits it out
# (e.g. "%ts.%03tu"); otherwise the native parser captures both
# halves via its \d+(?:\.\d+)? pattern.
("%ts", "time", r"\d+"),
("%03tu", "msec", r"\d{1,3}"),
("%tu", "msec", r"\d+"),
# Response time (microseconds with %6, otherwise raw)
("%6tr", "elapsed", r"\d+"),
("%tr", "elapsed", r"\d+"),
# Client / peer address
("%>a", "client", r"\S+"),
("%<a", "peer", r"\S+"),
# Result code (squid code / http status combined)
("%Ss", "result", r"\S+"),
# HTTP status code field (becomes http_code_field; combined with result
# in parse_with_format to yield http_code)
("%03>Hs", "http_code_field", r"\d+"),
("%>Hs", "http_code_field", r"\d+"),
# Sizes
("%<st", "size", r"\d+"),
("%st", "size", r"\d+"),
# Request
("%rm", "method", r"\S+"),
("%ru", "url", r"\S+"),
("%un", "ident", r"\S+"),
# Hierarchy
("%Sh", "hier_code", r"\S+"),
# Content type (greedy because it's the last column)
("%mt", "content_type", r".*"),
]
# Lookup helpers - built once at import time
_TOKEN_TO_GROUP: dict[str, str] = {tok: grp for tok, grp, _ in _TOKEN_DEFS}
_GROUP_TO_INNER: dict[str, str] = {grp: inner for _, grp, inner in _TOKEN_DEFS}
# Order to try matching tokens at any given '%' position: longest first.
_TOKEN_ORDER: list[str] = sorted(_TOKEN_TO_GROUP.keys(), key=lambda x: -len(x))
# Built-in logformat examples presented in the UI dropdown. "native" is
# the default Squid format and is handled by log_parser directly - it is
# listed here so the dropdown has a consistent shape and the user can
# explicitly choose "go back to native" from the picker.
PRESET_FORMATS: dict[str, str] = {
"native": "", # empty = fall back to log_parser (Squid native)
"squid": "%ts.%03tu %6tr %>a %Ss/%03>Hs %<st %rm %ru %un %Sh/%<a %mt",
"common": '%>a %[ui] [%{%d/%b/%Y:%H:%M:%S +0000}] "%rm %ru HTTP/%rv" %>Hs %<st "%{Referer}>h" "%{User-Agent}>h"',
"combined": '%>a %[ui] [%{%d/%b/%Y:%H:%M:%S +0000}] "%rm %ru HTTP/%rv" %>Hs %<st "%{Referer}>h" "%{User-Agent}>h" %Sh/%<a %mt',
"referer": '%ts.%03tu %>a "%{Referer}>h" "%rm %ru" %>Hs %<st',
"useragent": '%ts.%03tu %>a "%rm %ru" %>Hs %<st "%{User-Agent}>h"',
}
# ---------------------------------------------------------------------------
# Compilation
# ---------------------------------------------------------------------------
class CompiledFormat:
"""Bundles a compiled regex with the list of named groups it produces.
The caller can introspect ``fields`` to decide what to display in the
UI without re-running the parser.
"""
__slots__ = ("pattern", "fields", "source")
def __init__(self, pattern: re.Pattern, fields: list[str], source: str):
self.pattern = pattern
self.fields = fields
self.source = source
def __repr__(self) -> str: # pragma: no cover - debug only
return f"CompiledFormat(fields={self.fields!r})"
def _sanitize_group_name(name: str) -> str:
"""Turn an arbitrary string into a valid Python regex group identifier."""
# Group names in Python's re module must match [A-Za-z_][A-Za-z0-9_]*
# and must not be a pure integer. Strip invalid chars and prefix
# if the result would otherwise start with a digit.
out = re.sub(r"[^A-Za-z0-9_]", "_", name)
if not out or out[0].isdigit():
out = "g_" + out
return out
def _inner_without_slash(inner: str) -> str:
"""Return a variant of ``inner`` that does not match ``/``.
Used for the first half of a compound token so the regex engine
knows where to place the literal ``/`` separator. We rewrite the
common character classes to exclude ``/``:
- ``\\S+`` -> ``[^/\\s]+``
- ``\\S*`` -> ``[^/\\s]*``
- ``.*`` -> ``[^/]*`` (only used for trailing content_type)
"""
out = inner
out = out.replace(r"\S+", r"[^/\s]+")
out = out.replace(r"\S*", r"[^/\s]*")
out = out.replace(r".*", r"[^/]*")
return out
def compile_logformat(fmt: str) -> CompiledFormat:
"""Compile a Squid ``logformat`` string into a regex.
The algorithm:
1. Walk the format string, looking for ``%`` and trying to match the
longest known token at each position.
2. Replace each matched token with its regex fragment (which carries a
named capture group).
3. Escape any literal text between tokens, but treat **whitespace as a
separator** rather than a literal character - we expect log lines
to be whitespace-separated and want the regex to remain robust
even if the operator accidentally uses tabs vs. spaces.
4. Return a :class:`CompiledFormat` carrying the compiled pattern, the
ordered list of named fields, and the original source string.
Notes / limitations:
- Squid has hundreds of format codes; we only know the subset listed in
:data:`_TOKEN_DEFS`. Unknown ``%xxx`` sequences are passed through
as ``\\S+`` so the line still parses.
- Compound tokens like ``%Ss/%03>Hs`` are detected at compile time
and rendered as a single compound field (``hierarchy_blob``) that
is split on ``/`` in :func:`parse_with_format`. The compound
shares the first half's group name so existing field-name lookups
still work.
"""
fmt = (fmt or "").strip()
if not fmt:
raise ValueError("empty logformat string")
# Detect whether the operator splits the timestamp via ``%ts.%03tu``
# - if so, we constrain ``%ts`` to integer seconds so the literal
# dot between the two halves is unambiguous. Otherwise we accept
# the bare ``seconds.msec`` shape from the native format too.
has_split_ts = bool(re.search(r"%ts\s*\.\s*%(03)?tu", fmt))
# Pass 1: tokenise into a stream of (kind, value) pairs.
# kind in {"tok","lit","unk","slash"} - "slash" is a marker we emit
# when a literal begins with "/" so we can later detect compound
# sequences like "%A/%B".
tokens: list[tuple[str, str]] = []
i = 0
n = len(fmt)
while i < n:
if fmt[i] == "%":
# Try longest-known-token first
matched_tok: str | None = None
for tok in _TOKEN_ORDER:
if fmt.startswith(tok, i):
matched_tok = tok
break
if matched_tok is not None:
grp = _TOKEN_TO_GROUP[matched_tok]
# ``%ts`` gets a context-sensitive inner pattern: if the
# format splits it (``%ts.%03tu``), only integer seconds
# are valid; otherwise we accept either integer or
# ``seconds.msec`` to also cover the native format.
if matched_tok == "%ts" and not has_split_ts:
grp = "time_loose"
tokens.append(("tok", grp))
i += len(matched_tok)
continue
# Try special %>xx / %<xx forms that aren't in the literal
# table but we still want to capture (e.g. %>ru, %>ha).
if i + 1 < n and fmt[i + 1] in (">", "<"):
end = i + 2
while end < n and (fmt[end].isalpha() or fmt[end] == "_"):
end += 1
candidate = fmt[i + 1:end] # e.g. "a", "ru", "Hs"
name = _sanitize_group_name("ext_" + candidate.lower())
# Emit as a known field if we happen to map it
mapped = _GROUP_TO_INNER.get(name)
# Generic capture; cannot recover semantics so just \S+
tokens.append(("tok", name))
i = end
continue
# Unknown %-code: consume the % and the following
# identifier characters; emit a generic "unknown" field.
end = i + 1
while end < n and (fmt[end].isalpha() or fmt[end].isdigit()
or fmt[end] in "<>_{}[]"):
end += 1
if end == i + 1:
# bare '%' with nothing after it: emit literally
tokens.append(("lit", "%"))
i += 1
else:
name = _sanitize_group_name("u_" + fmt[i + 1:end].lower())
tokens.append(("unk", name))
i = end
else:
# Literal run - collect everything until the next % or end
j = i
while j < n and fmt[j] != "%":
j += 1
lit = fmt[i:j]
# If a token was just emitted and the next literal begins
# with "/", mark this as a compound separator.
if lit.startswith("/") and tokens and tokens[-1][0] == "tok":
tokens.append(("slash", ""))
lit = lit[1:]
tokens.append(("lit", lit))
i = j
# Pass 2: detect compound tokens (tok - slash - [lit] - tok) and
# merge them into a single field that captures the slash-joined
# string. The result is rendered as one capture group whose name is
# the first half's name; the post-processor in parse_with_format
# splits it.
merged: list[tuple[str, str]] = []
j = 0
while j < len(tokens):
kind, value = tokens[j]
# Look ahead for: tok - slash - [optional empty literal] - tok
is_compound = False
if kind == "tok" and j + 1 < len(tokens) and tokens[j + 1][0] == "slash":
# Skip any empty literal between slash and second half.
k = j + 2
while k < len(tokens) and tokens[k][0] == "lit" and tokens[k][1] == "":
k += 1
if k < len(tokens) and tokens[k][0] in ("tok", "unk"):
v1 = tokens[j][1]
v2 = tokens[k][1]
inner1 = _GROUP_TO_INNER.get(v1, r"\S+")
inner2 = _GROUP_TO_INNER.get(v2, r"\S+")
merged.append(("compound", f"{v1}|{inner1}|{inner2}"))
j = k + 1
is_compound = True
if is_compound:
continue
if kind == "slash":
# Stray slash: shouldn't happen given the tokeniser but
# be defensive.
j += 1
continue
merged.append((kind, value))
j += 1
# Pass 3: translate into regex
parts: list[str] = []
fields: list[str] = []
seen_groups: set[str] = set()
for kind, value in merged:
if kind == "lit":
# Whitespace runs -> \s+, anything else -> re.escape
k = 0
while k < len(value):
ch = value[k]
if ch.isspace():
m = k
while m < len(value) and value[m].isspace():
m += 1
parts.append(r"\s+")
k = m
else:
parts.append(re.escape(ch))
k += 1
elif kind == "tok":
inner = _GROUP_TO_INNER.get(value, r"\S+")
grp_name = value
if grp_name in seen_groups:
# Duplicate group name (shouldn't happen for our token
# table but be defensive). Use a suffixed alias.
grp_name = grp_name + "_dup"
seen_groups.add(grp_name)
parts.append(rf"(?P<{grp_name}>{inner})")
if grp_name not in fields:
fields.append(grp_name)
elif kind == "unk":
parts.append(rf"(?P<{value}>\S+)")
if value not in fields:
fields.append(value)
elif kind == "compound":
# value is "v1|inner1|inner2" - encode the slash-joined
# capture under v1's group name.
v1, inner1, inner2 = value.split("|", 2)
grp_name = v1
if grp_name in seen_groups:
grp_name = grp_name + "_c"
seen_groups.add(grp_name)
# The combined pattern captures the full slash-joined value.
# The *first* half must be non-greedy and constrained to
# *not* contain "/" so the "/" separator falls in the right
# place. E.g. ``\S+/`` would otherwise over-eat the
# ``HIER_DIRECT/13.107.5.93`` style string and return
# ``HIER_DIRECT/13.107.5.9`` (3 captured).
#
# We rewrite inner1 to disallow "/" and make it non-greedy
# so the engine backtracks to the first valid separator.
non_greedy_inner1 = _inner_without_slash(inner1)
parts.append(rf"(?P<{grp_name}>{non_greedy_inner1}/{inner2})")
if grp_name not in fields:
fields.append(grp_name)
pattern = re.compile("^" + "".join(parts) + r"\s*$")
return CompiledFormat(pattern=pattern, fields=fields, source=fmt)
# ---------------------------------------------------------------------------
# Parsing
# ---------------------------------------------------------------------------
def parse_with_format(line: str, compiled) -> dict | None:
"""Parse a single log line using a compiled logformat.
``compiled`` may be either a :class:`CompiledFormat` or the bare
``(pattern, fields)`` tuple returned by older call sites. Returns a
dict shaped exactly like :func:`log_parser.parse_line` so the result
can flow through the same downstream code.
"""
if not line or not line.strip():
return None
if isinstance(compiled, tuple):
pattern = compiled[0]
else:
pattern = compiled.pattern
m = pattern.match(line.strip())
if not m:
return None
g = m.groupdict()
# --- timestamp ------------------------------------------------------
# If the format splits the timestamp into integer seconds and a
# separate msec field (%ts.%03tu), recombine them so consumers
# always see the canonical ``time`` string in ``seconds.msec`` form
# - matching log_parser's output and avoiding surprising downstream
# code that expects ``float(time)`` to give a sub-second timestamp.
time_str = g.get("time") or "0"
msec = g.get("msec")
if msec and "." not in time_str:
# pad msec to 3 digits to match Squid's native formatting
time_str = f"{time_str}.{int(msec):03d}"
try:
ts = float(time_str)
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
except (ValueError, OSError):
dt = None
# --- elapsed --------------------------------------------------------
# log_parser treats this field as milliseconds directly. Squid's
# ``%6tr`` width specifier is just left-padding - the value itself
# is reported in milliseconds. Mirror that behaviour so downstream
# code can compare LogEntry dicts interchangeably.
elapsed_raw = g.get("elapsed") or "0"
try:
elapsed_ms = int(elapsed_raw)
except ValueError:
elapsed_ms = 0
# --- result / http code --------------------------------------------
# Compound tokens like %Ss/%03>Hs end up captured under the *first*
# half's group name (e.g. "result"). Split on "/" to recover the
# two halves. The http_code_field group is used when the result is
# not slash-joined but the http code is a standalone token.
raw_result = g.get("result", "") or ""
http_code_field = g.get("http_code_field", "") or ""
if "/" in raw_result:
squid_code, _, http_code = raw_result.partition("/")
if not http_code:
http_code = http_code_field
else:
squid_code = raw_result
http_code = http_code_field
# --- hierarchy / peer ----------------------------------------------
# The compound %Sh/%<a lives in the "hier_code" group as
# "HIER_DIRECT/13.107.5.93". Split on "/" to recover both halves.
# If the user only specified %Sh (no compound), the value is the
# bare code and peer stays empty.
raw_hier = g.get("hier_code", "") or ""
if "/" in raw_hier:
hier_code, _, peer = raw_hier.partition("/")
else:
hier_code, peer = raw_hier, ""
# If the user explicitly captured a peer separately (e.g. via %>a
# followed by %<a) prefer that over the compound value.
if g.get("peer") and not peer:
peer = g["peer"]
# --- URL / host -----------------------------------------------------
url = g.get("url", "") or ""
host = _extract_host(url)
# --- ident ----------------------------------------------------------
ident = g.get("ident", "") or ""
if ident == "-":
ident = ""
# --- category -------------------------------------------------------
# Imported lazily to avoid a circular import at module load time.
from log_parser import RESULT_CATEGORIES
category = RESULT_CATEGORIES.get(squid_code, "Other")
# --- size / content_type -------------------------------------------
try:
size = int(g.get("size") or 0)
except ValueError:
size = 0
content_type = (g.get("content_type") or "").strip()
return {
# Mirror log_parser.LogEntry keys exactly
"time": time_str,
"timestamp": dt,
"elapsed_ms": elapsed_ms,
"client": g.get("client", "") or "",
"result": raw_result,
"result_code": squid_code,
"http_code": http_code,
"category": category,
"size": size,
"method": g.get("method", "") or "",
"url": url,
"host": host,
"ident": ident,
"hierarchy": f"{hier_code}/{peer}" if (hier_code or peer) else "",
"hier_code": hier_code,
"peer": peer,
"content_type": content_type,
}
# ---------------------------------------------------------------------------
# Host extraction (mirrors log_parser._extract_host so we behave the same)
# ---------------------------------------------------------------------------
def _extract_host(url: str) -> str:
"""Extract hostname from a Squid log URL.
Mirrors :func:`log_parser._extract_host`. Squid logs URLs in two
forms: ``http(s)://host/path?query`` for normal GET/POST, and
``host:port`` for CONNECT tunnels. ``urlsplit`` chokes on the second
form so we detect it explicitly.
"""
if not url or url == "-":
return ""
if "://" not in url:
if ":" in url:
host, _, _port = url.rpartition(":")
return host.strip()
return url.strip()
try:
h = urlsplit(url).hostname
return h or ""
except Exception:
return ""
# ---------------------------------------------------------------------------
# Convenience: parse multi-line text
# ---------------------------------------------------------------------------
def parse_lines_with_format(text: str, compiled) -> list[dict]:
"""Parse a multi-line log blob. Unparseable lines are silently skipped."""
out = []
for line in text.splitlines():
e = parse_with_format(line, compiled)
if e:
out.append(e)
return out
+8
View File
@@ -0,0 +1,8 @@
Flask==3.0.3
Flask-SQLAlchemy==3.1.1
SQLAlchemy==2.0.30
Werkzeug==3.0.3
gunicorn==22.0.0
# P3-3: WebSSH terminal
paramiko>=2.10.0
flask-sock>=0.7.0
+47
View File
@@ -0,0 +1,47 @@
# ============================================================
# squid.conf - managed by Squid Web Manager
# WARNING: this file is regenerated by the web UI.
# Manual edits may be lost - use the Raw Editor tab
# if you need to add directives not covered by the UI.
# ============================================================
# ---- Network ----
http_port 3128
visible_hostname proxy.example.com
# ---- Cache ----
cache_mem 256 MB
cache_dir ufs /var/spool/squid 100 16 256
# ---- ACLs ----
acl localhost src 127.0.0.1/32
acl to_localhost dst 127.0.0.0/8
acl safeports port 80 443 21 70 80-1024
acl sslports port 443 563
acl CONNECT method CONNECT
acl all src 0.0.0.0/0
acl manager proto cache_object
acl local_lan src 172.16.0.0/12 #局域网测试
# ---- Refresh patterns ----
refresh_pattern ^ftp: 1440 20% 10080
refresh_pattern ^gopher: 1440 0% 1440
refresh_pattern -i (/cgi-bin/|\?) 0 0% 0
refresh_pattern . 0 20% 4320
# ---- Access rules ----
http_access deny manager
http_access deny !safeports
http_access deny !sslports CONNECT
http_access allow localhost
http_access deny all
# ---- Logging ----
# ---- DNS ----
# ---- Timeouts ----
# ---- Admin ----
cache_mgr admin@example.com
+211
View File
@@ -0,0 +1,211 @@
"""Security helpers: session timeout, CSRF protection, login throttling.
This module is intentionally self-contained so it can be wired into
the existing app without breaking the current auth flow.
Features:
- Session lifetime: configurable idle + absolute timeout
- CSRF token: per-session token, validated on all state-changing requests
- Login throttle: rate-limit failed login attempts by client IP+username
- Audit helpers
"""
from __future__ import annotations
import hashlib
import hmac
import os
import secrets
import time
from collections import defaultdict
from functools import wraps
from threading import Lock
from flask import abort, current_app, flash, redirect, request, session, url_for
# ---- Session lifetime config -------------------------------------------------
DEFAULTS = {
"session_idle_timeout": 1800, # 30 min - logout after this much inactivity
"session_absolute_timeout": 28800, # 8 hours - hard logout no matter what
"login_throttle_window": 300, # 5 min - window for counting failed attempts
"login_throttle_max": 5, # 5 failures within window triggers throttle
"login_throttle_lockout": 900, # 15 min - lockout duration after threshold
"csrf_enabled": True,
}
# Module-level state for throttle (per-process; for multi-worker use Redis or DB)
_throttle_lock = Lock()
_failed_logins: dict[tuple[str, str], list[float]] = defaultdict(list)
_locked_until: dict[tuple[str, str], float] = {}
def get_security_config(key: str):
"""Read security config from Flask app config with fallback to DEFAULTS."""
try:
return current_app.config.get(f"SECURITY_{key.upper()}", DEFAULTS[key])
except Exception:
return DEFAULTS.get(key, "")
# ---- CSRF --------------------------------------------------------------------
def generate_csrf_token() -> str:
"""Return the current session's CSRF token, creating one if missing."""
token = session.get("_csrf")
if not token:
token = secrets.token_urlsafe(32)
session["_csrf"] = token
return token
def csrf_token_input() -> str:
"""Jinja helper: render <input type=hidden name=_csrf value=...>."""
return f'<input type="hidden" name="_csrf" value="{generate_csrf_token()}">'
def csrf_protect():
"""Flask before_request handler that validates CSRF on unsafe methods."""
if not get_security_config("csrf_enabled"):
return None
# skip safe methods
if request.method in ("GET", "HEAD", "OPTIONS"):
return None
# skip login (no session yet)
if request.endpoint in ("login", "static"):
return None
# require logged in
if not session.get("user_id"):
return None
sent = (request.form.get("_csrf")
or (request.headers.get("X-CSRF-Token", ""))
or (request.headers.get("X-CSRFToken", "")))
expected = session.get("_csrf", "")
if not expected or not sent or not hmac.compare_digest(sent, expected):
abort(400, "CSRF token missing or invalid")
# ---- Session lifetime --------------------------------------------------------
def enforce_session_lifetime():
"""Flask before_request handler that enforces idle + absolute timeout."""
if not session.get("user_id"):
return None
now = time.time()
last_seen = session.get("_last_seen", now)
logged_in_at = session.get("_logged_in_at", now)
idle_to = get_security_config("session_idle_timeout")
abs_to = get_security_config("session_absolute_timeout")
if (now - last_seen) > idle_to:
# idle timeout
session.clear()
flash("会话因长时间无操作已过期,请重新登录", "error")
return redirect(url_for("login", next=request.path))
if (now - logged_in_at) > abs_to:
session.clear()
flash("会话已达最大时长,请重新登录", "error")
return redirect(url_for("login", next=request.path))
session["_last_seen"] = now
return None
# ---- Login throttle ----------------------------------------------------------
def _throttle_key() -> tuple[str, str]:
ip = request.headers.get("X-Forwarded-For", request.remote_addr or "0.0.0.0")
if "," in ip:
ip = ip.split(",")[0].strip()
username = (request.form.get("username") or "").strip().lower()
return (ip, username)
def is_throttled() -> tuple[bool, int]:
"""Return (is_locked, seconds_remaining)."""
key = _throttle_key()
with _throttle_lock:
locked = _locked_until.get(key, 0)
now = time.time()
if locked > now:
return True, int(locked - now)
# expired lockout - clean up
if locked and locked <= now:
_locked_until.pop(key, None)
_failed_logins.pop(key, None)
return False, 0
def record_failed_login():
key = _throttle_key()
now = time.time()
with _throttle_lock:
window = get_security_config("login_throttle_window")
max_fail = get_security_config("login_throttle_max")
lockout = get_security_config("login_throttle_lockout")
# trim old
_failed_logins[key] = [t for t in _failed_logins[key] if now - t < window]
_failed_logins[key].append(now)
if len(_failed_logins[key]) >= max_fail:
_locked_until[key] = now + lockout
return True, lockout
return False, 0
def record_successful_login():
"""Clear any throttle state on success."""
key = _throttle_key()
with _throttle_lock:
_failed_logins.pop(key, None)
_locked_until.pop(key, None)
# ---- Decorator that requires login + CSRF (already done globally) ------------
def login_required_v2(func):
"""Same as login_required but also ensures CSRF token is generated for forms."""
@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
# ---- Password strength scoring (used by P3-2 user mgmt) ----------------------
def password_strength(pw: str) -> dict:
"""Score password 0-4 (very weak -> very strong)."""
if not pw:
return {"score": 0, "label": "", "hint": "密码不能为空"}
score = 0
if len(pw) >= 8: score += 1
if len(pw) >= 12: score += 1
if any(c.isupper() for c in pw) and any(c.islower() for c in pw): score += 1
if any(c.isdigit() for c in pw): score += 1
if any(not c.isalnum() for c in pw): score += 1
# special-case: simple/common penalties
if pw.lower() in ("admin", "password", "123456", "squid", "changeme"):
score = max(0, score - 2)
score = min(4, max(0, score))
labels = {0: "极弱", 1: "", 2: "", 3: "", 4: "很强"}
hints = {
0: "请使用 8+ 字符,含大小写+数字+符号",
1: "建议加长到 12+ 字符",
2: "可加入特殊字符进一步提升",
3: "密码强度良好",
4: "密码强度优秀",
}
return {"score": score, "label": labels[score], "hint": hints[score]}
# ---- Audit log helpers --------------------------------------------------------
def client_ip() -> str:
"""Best-effort client IP, honoring X-Forwarded-For."""
ip = request.headers.get("X-Forwarded-For", request.remote_addr or "0.0.0.0")
if "," in ip:
ip = ip.split(",")[0].strip()
return ip
def hash_password_for_audit(pw: str) -> str:
"""Stable short hash to record password changes without leaking the password."""
return hashlib.sha256(pw.encode("utf-8")).hexdigest()[:12]
+706
View File
@@ -0,0 +1,706 @@
"""Squid configuration (squid.conf) parser and generator.
Strategy:
- Parse the existing squid.conf into a list of directives with comments
- Expose structured getters for sections (network, cache, ACLs, rules, auth, ...)
- Allow web UI to update specific directives
- Generate a complete, well-formed squid.conf from the structured config
- Validate before applying (run `squid -k parse` if available)
Directive model:
- Each directive is a tuple (key, [args...], comment)
- Directives are grouped into named sections for the UI
Note: Squid supports many directives. We cover the most common ones used in
production deployments. A "raw editor" tab is always available for anything we
don't model explicitly.
"""
from __future__ import annotations
import os
import re
import subprocess
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class Directive:
"""A single squid.conf directive line, possibly with a comment."""
key: str
args: list[str]
comment: str = ""
# raw original line (for round-trip preservation)
raw: str = ""
def render(self) -> str:
if self.raw and not self.args and not self.comment:
return self.raw
line = self.key
if self.args:
line += " " + " ".join(self.args)
if self.comment:
line += " #" + self.comment
return line
@dataclass
class Acl:
"""An ACL entry: acl <name> <type> <value>..."""
name: str
type: str
values: list[str]
comment: str = ""
def render(self) -> str:
parts = ["acl", self.name, self.type] + self.values
line = " ".join(parts)
if self.comment:
line += " #" + self.comment
return line
@dataclass
class Rule:
"""An access rule: http_access <allow|deny> [!]acl..."""
action: str # allow / deny
acls: list[str] # e.g. ["!localhost", "allowed_net"]
comment: str = ""
directive: str = "http_access" # http_access, https_access, icp_access, ...
def render(self) -> str:
parts = [self.directive, self.action] + self.acls
line = " ".join(parts)
if self.comment:
line += " #" + self.comment
return line
@dataclass
class CacheDir:
"""A cache_dir directive: cache_dir <type> <path> <size> <L1> <L2> [options]"""
type: str = "ufs"
path: str = "/var/spool/squid"
size_mb: int = 100
l1: int = 16
l2: int = 256
options: list[str] = field(default_factory=list)
def render(self) -> str:
parts = ["cache_dir", self.type, self.path, str(self.size_mb), str(self.l1), str(self.l2)]
parts.extend(self.options)
return " ".join(parts)
@dataclass
class RefreshPattern:
"""refresh_pattern [-i] regex min percent max [{options}]"""
case_insensitive: bool = False
regex: str = "."
min_minutes: int = 0
percent: int = 20
max_minutes: int = 4320
options: list[str] = field(default_factory=list)
def render(self) -> str:
parts = ["refresh_pattern"]
if self.case_insensitive:
parts.append("-i")
parts.extend([self.regex, str(self.min_minutes), f"{self.percent}%", str(self.max_minutes)])
if self.options:
parts.append("{" + " ".join(self.options) + "}")
return " ".join(parts)
@dataclass
class AuthParam:
"""auth_param <scheme> <param> <value>..."""
scheme: str = "basic"
program: str = "/usr/lib/squid/basic_ncsa_auth /etc/squid/passwd"
children: int = 5
realm: str = "Squid Proxy"
credentialsttl: int = 2
# also stored as raw param dict for other schemes
extra: dict = field(default_factory=dict)
def render_all(self) -> list[str]:
lines = []
lines.append(f"auth_param {self.scheme} program {self.program}")
lines.append(f"auth_param {self.scheme} children {self.children}")
lines.append(f"auth_param {self.scheme} realm {self.realm}")
lines.append(f"auth_param {self.scheme} credentialsttl {self.credentialsttl} hours")
for k, v in self.extra.items():
lines.append(f"auth_param {self.scheme} {k} {v}")
return lines
@dataclass
class CachePeer:
"""cache_peer <host> <type> <http_port> <icp_port> [options]"""
host: str = ""
type: str = "parent"
http_port: int = 3128
icp_port: int = 0
options: list[str] = field(default_factory=list)
def render(self) -> str:
parts = ["cache_peer", self.host, self.type, str(self.http_port), str(self.icp_port)]
parts.extend(self.options)
return " ".join(parts)
@dataclass
class SslBumpRule:
"""ssl_bump directive: ssl_bump <action> [!]acl ...
action: peek, splice, bump, terminate, client-first, server-first, none
"""
action: str = "peek"
acls: list[str] = field(default_factory=list) # ACL names + optional ! negation
step: int = 1 # rule order
comment: str = ""
def render(self) -> str:
parts = ["ssl_bump", self.action] + self.acls
line = " ".join(parts)
if self.comment:
line += " #" + self.comment
return line
# ---------------------------------------------------------------------------
# Parsing
# ---------------------------------------------------------------------------
def parse_conf(text: str):
"""Parse a squid.conf string into structured groups.
Returns dict with keys: directives (raw), acls, rules, cache_dirs,
refresh_patterns, auth_params, cache_peers, ssl_bump_rules.
"""
acls = []
rules = []
cache_dirs = []
refresh_patterns = []
cache_peers = []
auth_lines = [] # raw auth_param lines, parsed into AuthParam later
ssl_bump_rules = [] # ssl_bump directives
# map of directive -> list of (args, comment) for simple single-value directives
simple: dict[str, list[tuple[list[str], str]]] = {}
for raw in text.splitlines():
line = raw.strip()
if not line:
continue
if line.startswith("#"):
continue
# split off trailing comment
comment = ""
if " #" in line:
line, _, comment = line.partition(" #")
line = line.rstrip()
comment = comment.strip()
tokens = line.split()
if not tokens:
continue
key = tokens[0]
args = tokens[1:]
if key == "acl":
if len(args) >= 3:
acls.append(Acl(
name=args[0], type=args[1], values=args[2:],
comment=comment,
))
continue
if key in ("http_access", "http_access2", "https_access",
"icp_access", "htcp_access", "miss_access",
"snmp_access", "ident_lookup_access",
"always_direct", "never_direct",
"reply_body_max_size", "reply_access"):
if args:
rules.append(Rule(
action=args[0], acls=args[1:], comment=comment,
directive=key,
))
continue
if key == "cache_dir":
if len(args) >= 5:
cache_dirs.append(CacheDir(
type=args[0], path=args[1],
size_mb=int(args[2]) if args[2].isdigit() else 100,
l1=int(args[3]) if args[3].isdigit() else 16,
l2=int(args[4]) if args[4].isdigit() else 256,
options=args[5:],
))
continue
if key == "refresh_pattern":
rp = RefreshPattern()
idx = 0
if idx < len(args) and args[idx] == "-i":
rp.case_insensitive = True
idx += 1
if idx < len(args):
rp.regex = args[idx]; idx += 1
if idx < len(args) and args[idx].lstrip("-").isdigit():
rp.min_minutes = int(args[idx]); idx += 1
if idx < len(args) and args[idx].endswith("%"):
try:
rp.percent = int(args[idx].rstrip("%"))
except ValueError:
pass
idx += 1
if idx < len(args) and args[idx].lstrip("-").isdigit():
rp.max_minutes = int(args[idx]); idx += 1
rp.options = args[idx:]
refresh_patterns.append(rp)
continue
if key == "cache_peer":
if len(args) >= 4:
cache_peers.append(CachePeer(
host=args[0], type=args[1],
http_port=int(args[2]) if args[2].isdigit() else 3128,
icp_port=int(args[3]) if args[3].isdigit() else 0,
options=args[4:],
))
continue
if key == "ssl_bump":
# ssl_bump <action> [!]acl...
if args:
action = args[0]
acls_for_rule = args[1:]
step = len(ssl_bump_rules) + 1
ssl_bump_rules.append(SslBumpRule(
action=action,
acls=acls_for_rule,
step=step,
comment=comment,
))
continue
if key == "auth_param":
auth_lines.append((args, comment))
continue
simple.setdefault(key, []).append((args, comment))
# parse auth_params into structured form (one per scheme)
auth_params = parse_auth_lines(auth_lines)
return {
"acls": acls,
"rules": rules,
"cache_dirs": cache_dirs,
"refresh_patterns": refresh_patterns,
"cache_peers": cache_peers,
"auth_params": auth_params,
"ssl_bump_rules": ssl_bump_rules,
"simple": simple, # raw simple directives, list of (args, comment)
}
def parse_auth_lines(lines: list[tuple[list[str], str]]) -> list[AuthParam]:
"""Group auth_param lines by scheme."""
by_scheme: dict[str, AuthParam] = {}
for args, comment in lines:
if len(args) < 2:
continue
scheme = args[0]
param = args[1]
value = " ".join(args[2:])
if scheme not in by_scheme:
ap = AuthParam(scheme=scheme)
by_scheme[scheme] = ap
ap = by_scheme[scheme]
if param == "program":
ap.program = value
elif param == "children":
try:
ap.children = int(args[2])
except (ValueError, IndexError):
pass
elif param == "realm":
ap.realm = value
elif param == "credentialsttl":
try:
ap.credentialsttl = int(args[2])
except (ValueError, IndexError):
pass
else:
ap.extra[param] = value
return list(by_scheme.values())
# ---------------------------------------------------------------------------
# Generation
# ---------------------------------------------------------------------------
# default simple directives the UI manages explicitly.
DEFAULT_SIMPLE = {
# network
"http_port": [["3128"], ""],
"https_port": [],
"icp_port": [["3130"], ""],
"snmp_port": [],
"visible_hostname": [["localhost"], ""],
"unique_hostname": [],
"hostname_aliases": [],
# cache
"cache_mem": [["256", "MB"], ""],
"maximum_object_size": [["4096", "KB"], ""],
"minimum_object_size": [["0", "KB"], ""],
"maximum_object_size_in_memory": [["512", "KB"], ""],
"cache_replacement_policy": [["lru"], ""],
"memory_replacement_policy": [["lru"], ""],
"cache_swap_low": [["90"], ""],
"cache_swap_high": [["95"], ""],
# logging
"access_log": [["/var/log/squid/access.log"], ""],
"cache_log": [["/var/log/squid/cache.log"], ""],
"cache_store_log": [["/var/log/squid/store.log"], ""],
"pid_filename": [["/var/run/squid.pid"], ""],
"logformat": [],
"debug_options": [],
"log_ip_on_direct": [["on"], ""],
"buffered_logs": [["on"], ""],
# dns
"dns_nameservers": [],
"dns_timeout": [["30", "seconds"], ""],
"hosts_file": [["/etc/hosts"], ""],
"dns_defnames": [["off"], ""],
# SSL / TLS (for ssl-bump)
"sslcrtd_program": [["security_file_certgen", "-s", "/var/lib/squid/ssl_db", "-M", "4", "MB"], ""],
"sslcrtd_children": [["5", "startup=1", "idle=1"], ""],
"sslproxy_ca_certfile": [],
"sslproxy_ca_keyfile": [],
"tls_outgoing_version": [],
# timeouts
"connect_timeout": [["60", "seconds"], ""],
"read_timeout": [["15", "minutes"], ""],
"client_lifetime": [["1", "day"], ""],
"shutdown_lifetime": [["30", "seconds"], ""],
# admin
"cache_mgr": [["root"], ""],
"err_html_language": [["en"], ""],
"ftp_user": [],
}
def generate_conf(struct: dict, raw_extra: str = "") -> str:
"""Generate a complete squid.conf string from the structured config."""
lines: list[str] = []
lines.append("# ============================================================")
lines.append("# squid.conf - managed by Squid Web Manager")
lines.append("# WARNING: this file is regenerated by the web UI.")
lines.append("# Manual edits may be lost - use the Raw Editor tab")
lines.append("# if you need to add directives not covered by the UI.")
lines.append("# ============================================================")
lines.append("")
# --- Network ---
lines.append("# ---- Network ----")
for key in ("http_port", "https_port", "icp_port", "snmp_port",
"visible_hostname", "unique_hostname", "hostname_aliases"):
for args, comment in struct["simple"].get(key, []):
lines.append(_render_simple(key, args, comment))
lines.append("")
# --- Cache ---
lines.append("# ---- Cache ----")
for key in ("cache_mem", "maximum_object_size", "minimum_object_size",
"maximum_object_size_in_memory",
"cache_replacement_policy", "memory_replacement_policy",
"cache_swap_low", "cache_swap_high"):
for args, comment in struct["simple"].get(key, []):
lines.append(_render_simple(key, args, comment))
for cd in struct["cache_dirs"]:
lines.append(cd.render())
lines.append("")
# --- Auth ---
if struct["auth_params"]:
lines.append("# ---- Authentication ----")
for ap in struct["auth_params"]:
for ln in ap.render_all():
lines.append(ln)
lines.append("")
# --- ACLs ---
lines.append("# ---- ACLs ----")
for acl in struct["acls"]:
lines.append(acl.render())
lines.append("")
# --- Refresh patterns ---
if struct["refresh_patterns"]:
lines.append("# ---- Refresh patterns ----")
for rp in struct["refresh_patterns"]:
lines.append(rp.render())
lines.append("")
# --- Cache peers ---
if struct["cache_peers"]:
lines.append("# ---- Cache peers ----")
for cp in struct["cache_peers"]:
lines.append(cp.render())
lines.append("")
# --- Access rules ---
lines.append("# ---- Access rules ----")
for r in struct["rules"]:
lines.append(r.render())
lines.append("")
# --- Logging ---
lines.append("# ---- Logging ----")
for key in ("access_log", "cache_log", "cache_store_log",
"pid_filename", "logformat", "debug_options",
"log_ip_on_direct", "buffered_logs"):
for args, comment in struct["simple"].get(key, []):
lines.append(_render_simple(key, args, comment))
lines.append("")
# --- DNS ---
lines.append("# ---- DNS ----")
for key in ("dns_nameservers", "dns_timeout", "hosts_file", "dns_defnames"):
for args, comment in struct["simple"].get(key, []):
lines.append(_render_simple(key, args, comment))
lines.append("")
# --- SSL/TLS ---
ssl_keys = ("sslcrtd_program", "sslcrtd_children", "sslproxy_ca_certfile",
"sslproxy_ca_keyfile", "tls_outgoing_version")
if any(struct["simple"].get(k) for k in ssl_keys) or struct.get("ssl_bump_rules"):
lines.append("# ---- SSL/TLS ----")
for key in ssl_keys:
for args, comment in struct["simple"].get(key, []):
lines.append(_render_simple(key, args, comment))
for rule in struct.get("ssl_bump_rules", []):
lines.append(rule.render())
lines.append("")
# --- Timeouts ---
lines.append("# ---- Timeouts ----")
for key in ("connect_timeout", "read_timeout", "client_lifetime",
"shutdown_lifetime"):
for args, comment in struct["simple"].get(key, []):
lines.append(_render_simple(key, args, comment))
lines.append("")
# --- Admin ---
lines.append("# ---- Admin ----")
for key in ("cache_mgr", "err_html_language", "ftp_user"):
for args, comment in struct["simple"].get(key, []):
lines.append(_render_simple(key, args, comment))
lines.append("")
# --- raw extra directives ---
if raw_extra.strip():
lines.append("# ---- Raw directives (manual) ----")
for ln in raw_extra.splitlines():
if ln.strip():
lines.append(ln.strip())
lines.append("")
return "\n".join(lines) + "\n"
def _render_simple(key: str, args: list[str], comment: str = "") -> str:
line = key + (" " + " ".join(args) if args else "")
if comment:
line += " #" + comment
return line
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
def validate_conf(text: str, squid_binary: str = "squid") -> tuple[bool, str]:
"""Run `squid -k parse` on the given config text.
Returns (ok, message). Falls back to syntax-only check if squid isn't
installed.
"""
if not squid_binary or not _which(squid_binary):
# no squid binary available - just check that we can parse it
try:
parse_conf(text)
return True, "Squid binary not found - parsed structure successfully " \
"(skipped runtime syntax check)."
except Exception as e:
return False, f"Parse error: {e}"
tmp_path = "/tmp/squid_conf_check.conf"
try:
with open(tmp_path, "w") as f:
f.write(text)
except OSError as e:
return False, f"Cannot write temp file: {e}"
try:
r = subprocess.run(
[squid_binary, "-k", "parse", "-f", tmp_path],
capture_output=True, text=True, timeout=10,
)
except subprocess.TimeoutExpired:
return False, "squid -k parse timed out"
except FileNotFoundError:
return False, "squid binary not found"
# squid -k parse exits 0 on success. Errors are written to stdout.
out = (r.stdout or "").strip()
err = (r.stderr or "").strip()
msg = out + ("\n" + err if err else "")
# squid typically prints "WARNING:" lines for non-fatal issues.
if r.returncode == 0:
return True, msg or "Configuration OK"
return False, msg or f"squid -k parse failed (exit {r.returncode})"
def _which(cmd: str) -> Optional[str]:
"""Lightweight which() that doesn't depend on shutil.which."""
for path in os.environ.get("PATH", "").split(":"):
if not path:
continue
candidate = os.path.join(path, cmd)
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
return None
# ---------------------------------------------------------------------------
# Defaults (used to bootstrap a brand new install)
# ---------------------------------------------------------------------------
def default_struct() -> dict:
"""Return a default structured config for a fresh install."""
simple = {k: [v] if isinstance(v, list) and v and isinstance(v[0], list)
else (v if isinstance(v, list) else [])
for k, v in DEFAULT_SIMPLE.items()}
# DEFAULT_SIMPLE values are already lists of [args, comment]
# but they are stored as [args_list, comment] tuples - normalize.
simple = {}
for k, v in DEFAULT_SIMPLE.items():
# v is [[args...], comment] or [] (empty)
if isinstance(v, list) and len(v) == 2 and isinstance(v[0], list):
simple[k] = [(v[0], v[1])]
else:
simple[k] = []
# Provide a sensible default sslcrtd_program bootstrap so freshly-deployed
# SSL Bump setups have a working cert generator out of the box.
# (Empty list would render "sslcrtd_program" with no args - leave it.)
# Default is intentionally commented out in DEFAULT_SIMPLE above
# (sslcrtd_program: []). Users override via the SSL Bump UI.
return {
"acls": [
Acl(name="localhost", type="src", values=["127.0.0.1/32"]),
Acl(name="to_localhost", type="dst", values=["127.0.0.0/8"]),
Acl(name="safeports", type="port", values=["80", "443", "21", "70", "80-1024"]),
Acl(name="sslports", type="port", values=["443", "563"]),
Acl(name="CONNECT", type="method", values=["CONNECT"]),
Acl(name="all", type="src", values=["0.0.0.0/0"]),
Acl(name="manager", type="proto", values=["cache_object"]),
],
"rules": [
Rule(action="deny", acls=["manager"], directive="http_access"),
Rule(action="deny", acls=["!Safe_ports"], directive="http_access"),
Rule(action="deny", acls=["!SSL_ports"], directive="http_access"),
Rule(action="allow", acls=["localhost"], directive="http_access"),
Rule(action="deny", acls=["all"], directive="http_access"),
],
"cache_dirs": [
CacheDir(type="ufs", path="/var/spool/squid",
size_mb=100, l1=16, l2=256)
],
"refresh_patterns": [
RefreshPattern(regex="^ftp:", min_minutes=1440, percent=20, max_minutes=10080),
RefreshPattern(regex="^gopher:", min_minutes=1440, percent=0, max_minutes=1440),
RefreshPattern(case_insensitive=True, regex=" cgi-bin",
min_minutes=0, percent=0, max_minutes=0),
RefreshPattern(regex=".", min_minutes=0, percent=20, max_minutes=4320),
],
"cache_peers": [],
"auth_params": [],
"ssl_bump_rules": [],
"simple": simple,
}
ACL_TYPES = [
("src", "Source IP/CIDR (src 192.168.1.0/24)"),
("dst", "Destination IP/CIDR"),
("dstdomain", "Destination domain (matches *.example.com)"),
("dstdom_regex", "Destination domain regex"),
("srcdomain", "Source domain"),
("srcdom_regex", "Source domain regex"),
("port", "Destination port"),
("method", "HTTP method (GET, POST, CONNECT, ...)"),
("proto", "Protocol (http, https, ftp, ...)"),
("time", "Time of day/week (SMTWHFA/HH:MM-HH:MM)"),
("url_regex", "URL regex"),
("urlpath_regex", "URL path regex"),
("url_login", "URL login part regex"),
("browser", "User-Agent regex"),
("referer_regex", "Referer regex"),
("ident", "User ident string"),
("ident_regex", "User ident regex"),
("proxy_auth", "Authenticated user / REQUIRED"),
("proxy_auth_regex", "Authenticated user regex"),
("maxconn", "Max connections per client"),
("max_user_ip", "Max IPs per user"),
("rep_mime_type", "Reply MIME type regex"),
("req_header", "Request header"),
("rep_header", "Reply header"),
("localip", "Local IP"),
("localnet", "Local network"),
("peername", "Peer name"),
("hierarchy_type", "Hierarchy type"),
]
RULE_DIRECTIVES = [
"http_access",
"http_access2",
"https_access",
"icp_access",
"htcp_access",
"miss_access",
"snmp_access",
"ident_lookup_access",
"reply_access",
"adaptation_access",
]
# Valid ssl_bump actions (Squid docs - https://www.squid-cache.org/Doc/config/ssl_bump/)
SSL_BUMP_ACTIONS = [
"peek",
"splice",
"bump",
"terminate",
"client-first",
"server-first",
"none",
]
SSL_BUMP_DEFAULT_EXAMPLE = (
"# Suggested starter set (rule order matters - top to bottom):\n"
"# ssl_bump peek step1 # peek at TLS client hello (step 1)\n"
"# ssl_bump splice all # otherwise pass-through tunnel\n"
"#\n"
"# Required prereqs before SSL Bump will work:\n"
"# 1) sslcrtd_program/children pointing at security_file_certgen\n"
"# and an initialised ssl_db directory\n"
"# 2) sslproxy_ca_certfile / sslproxy_ca_keyfile pointing at a CA\n"
"# that your client browsers trust (or you distribute the cert\n"
"# and instruct users to install it)\n"
"# 3) https_port configured with ssl-bump option"
)
+515
View File
@@ -0,0 +1,515 @@
"""squid_mgr.py — interface to Squid's built-in `squidclient mgr:` admin endpoints.
The Squid HTTP proxy exposes a management interface on the cache_object port
(default 3128). `squidclient mgr:<section>` returns plain-text key/value
information for sections like `info`, `5min`, `counters`, `io`, `storedir`, etc.
This module is intentionally dependency-free (stdlib only) so it can be reused
in the Flask app, the alert evaluator, and unit tests without installing extra
packages.
Public surface
--------------
fetch_mgr_info(...) -> (ok, raw_text, error_msg)
parse_mgr_info(text) -> dict[str, str]
fetch_and_parse(...) -> (ok, parsed_dict, error_msg)
parse_counters(text) -> list[(label, value)]
get_performance_summary(...) -> dict (the high-level payload the UI consumes)
All parsers are forgiving: malformed lines are skipped silently, never raised.
This matters because Squid output varies between versions and locales.
"""
from __future__ import annotations
import re
import subprocess
from datetime import datetime, timezone
# ---------------------------------------------------------------------------
# Fetching
# ---------------------------------------------------------------------------
def fetch_mgr_info(squidclient: str = "squidclient",
host: str = "127.0.0.1",
port: int = 3128,
mgr: str = "info",
password: str = "",
timeout: int = 5) -> tuple[bool, str, str]:
"""Run `squidclient -h host -p port mgr:<section>` and capture stdout.
Returns (ok, raw_text, error_msg).
- ok is True iff the process exited with status 0 AND produced output.
- error_msg is the empty string on success; otherwise a short diagnostic.
- On timeout we surface a clear message so the caller can degrade gracefully.
"""
cmd = [squidclient, "-h", str(host), "-p", str(port), f"mgr:{mgr}"]
if password:
cmd[2:2] = ["-w", password] # insert -w <pw> right after the binary
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
)
except FileNotFoundError as exc:
return False, "", f"squidclient not found: {exc}"
except subprocess.TimeoutExpired:
return False, "", f"squidclient mgr:{mgr} timeout after {timeout}s"
except Exception as exc: # pragma: no cover - defensive
return False, "", f"squidclient mgr:{mgr} failed: {exc}"
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "").strip()
return False, proc.stdout or "", f"exit={proc.returncode} {err[:300]}"
if not proc.stdout.strip():
return False, "", f"squidclient mgr:{mgr} returned empty output"
return True, proc.stdout, ""
# ---------------------------------------------------------------------------
# Parsing helpers
# ---------------------------------------------------------------------------
# Section headers look like "Connection information:" (a key ending with a
# trailing colon). Within a section, indented lines hold `Key: value` pairs.
# `mgr:5min`/`mgr:counters` outputs are flat `key = value` (no indentation),
# so we allow either: 2+ spaces of indent with ANY separator, OR no indent
# but an '=' separator (so we don't accidentally grab plain English text).
_SECTION_RE = re.compile(r"^\s*([A-Z][A-Za-z0-9 /%()\-]+):\s*$")
_INDENTED_KV_RE = re.compile(r"^\s{2,}([A-Za-z0-9 _/%.()\-]+?)\s*[:=]\s*(.+?)\s*$")
_FLAT_KV_RE = re.compile(r"^([A-Za-z0-9_.]+)\s*=\s*(.+?)\s*$")
def _strip_inline_section(line: str) -> tuple[str | None, str | None]:
"""First-line special case: "Squid Object Cache (Version 5.7): John Doe"
which mixes a header and a value on one line. Returns ("Squid Object Cache",
"(Version 5.7): John Doe") so the normal KV regex can pick it up.
"""
return None, None # handled by caller via simpler regex
def parse_mgr_info(text: str) -> dict:
"""Parse a `squidclient mgr:info` text blob into a flat dict.
The shape of mgr:info output (see task example):
Squid Object Cache (Version 5.7): John Doe
Start Time: Mon, 14 Jul 2026 12:00:00 GMT
Current Time: Mon, 14 Jul 2026 13:00:00 GMT
Connection information:
Number of clients accessing cache: 5
Cache information:
Hits as % of bytes sent: 35.2%
...
We collect every `Key: value` line as `Key -> value`. Section headers are
preserved as `Section:` entries so callers can correlate them if needed.
Values are kept as strings; numeric coercion is the job of the summary
builder.
"""
out: dict[str, str] = {}
if not text:
return out
for raw in text.splitlines():
line = raw.rstrip()
if not line.strip():
continue
# Section header: ends with ":", no value on the same line
m_sec = _SECTION_RE.match(line)
if m_sec:
out[m_sec.group(1).strip()] = "" # marker, no value
continue
# Indented key/value line (2+ spaces of indent, `:` or `=` separator).
# Used by mgr:info.
m_kv = _INDENTED_KV_RE.match(line)
if m_kv:
key = m_kv.group(1).strip()
val = m_kv.group(2).strip()
out[key] = val
continue
# Flat key/value line (no indent, '=' separator, snake_case key).
# Used by mgr:5min / mgr:counters.
m_flat = _FLAT_KV_RE.match(line)
if m_flat:
key = m_flat.group(1).strip()
val = m_flat.group(2).strip()
out[key] = val
continue
# Fallback: "Squid Object Cache (Version 5.7): John Doe" — header +
# value on the same line. Try a relaxed split on ":".
if ":" in line:
k, v = line.split(":", 1)
k = k.strip()
if k and v.strip():
out.setdefault(k, v.strip())
return out
# ---------------------------------------------------------------------------
# Field extractors (named accessors used by get_performance_summary)
# ---------------------------------------------------------------------------
_TIME_FIELD_RE = re.compile(
r"(?:(?P<days>\d+)\s+days?,\s*)?(?P<hours>\d+):(?P<minutes>\d+):(?P<seconds>\d+(?:\.\d+)?)"
)
def parse_uptime_to_seconds(text: str) -> int | None:
"""Convert Squid uptime strings like "3 days, 12:34:56" or "0:05:30" to seconds."""
if not text:
return None
m = _TIME_FIELD_RE.search(text)
if not m:
return None
days = int(m.group("days") or 0)
hours = int(m.group("hours") or 0)
minutes = int(m.group("minutes") or 0)
seconds = float(m.group("seconds") or 0)
return int(days * 86400 + hours * 3600 + minutes * 60 + seconds)
_BYTE_UNITS = {
"B": 1,
"KB": 1024,
"MB": 1024 ** 2,
"GB": 1024 ** 3,
"TB": 1024 ** 4,
"K": 1024,
"M": 1024 ** 2,
"G": 1024 ** 3,
}
_BYTES_RE = re.compile(
r"^\s*(?P<num>[0-9]+(?:\.[0-9]+)?)\s*(?P<unit>[A-Za-z]+)?\s*$"
)
def parse_bytes_to_int(text: str) -> int | None:
"""Convert strings like "1.2 GB" or "256 MB" or "12345" to an int byte count."""
if text is None:
return None
s = text.strip()
if not s:
return None
m = _BYTES_RE.match(s)
if not m:
# Try to pull a number out of mixed strings ("1,234,567")
digits = re.sub(r"[^0-9.]", "", s)
try:
return int(float(digits)) if digits else None
except ValueError:
return None
num = float(m.group("num"))
unit = (m.group("unit") or "B").upper()
factor = _BYTE_UNITS.get(unit)
if factor is None:
# Unknown unit — treat as raw count
return int(num)
return int(num * factor)
def parse_percent(text: str) -> float | None:
"""Convert strings like "35.2%" or "35.2" to a float percentage value."""
if not text:
return None
s = text.strip().rstrip("%").strip()
try:
return float(s)
except ValueError:
return None
_VERSION_RE = re.compile(r"Version\s+(\d+(?:\.\d+)*)")
def parse_version(text: str) -> str | None:
"""Pull '5.7' out of a header like 'Squid Object Cache (Version 5.7)'."""
if not text:
return None
m = _VERSION_RE.search(text)
return m.group(1) if m else None
# ---------------------------------------------------------------------------
# Counters parsing (mgr:counters — for chart rendering)
# ---------------------------------------------------------------------------
def parse_counters(text: str) -> list[tuple[str, str]]:
"""Parse `mgr:counters` output, returning [(label, value), ...].
mgr:counters emits blank-line-separated blocks, each with a counter name
on the first line and `sample_time = NN, client_http.requests = 12345`
style key=value pairs beneath. We flatten everything into (label, value)
pairs skipping the 'sample_time' bookkeeping rows so the UI can show a
simple table.
"""
out: list[tuple[str, str]] = []
if not text:
return out
current_block = ""
for raw in text.splitlines():
line = raw.rstrip()
if not line.strip():
current_block = ""
continue
if not line.startswith((" ", "\t")):
# New block header (e.g. "client_http.requests")
current_block = line.strip()
out.append((current_block, ""))
continue
# Inside a block: "key = value"
if "=" in line:
k, v = line.split("=", 1)
k = k.strip()
v = v.strip()
if k == "sample_time":
continue
label = f"{current_block}.{k}" if current_block else k
out.append((label, v))
return out
# ---------------------------------------------------------------------------
# One-step convenience wrappers
# ---------------------------------------------------------------------------
def fetch_and_parse(mgr: str = "info", **kwargs) -> tuple[bool, dict, str]:
"""fetch_mgr_info + parse_mgr_info in one call.
Returns (ok, parsed_dict, error_msg).
"""
ok, raw, err = fetch_mgr_info(mgr=mgr, **kwargs)
if not ok:
return False, {}, err
return True, parse_mgr_info(raw), ""
# ---------------------------------------------------------------------------
# High-level aggregator
# ---------------------------------------------------------------------------
def _fmt_now() -> str:
return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S")
def _safe_first(d: dict, *keys: str) -> str:
"""Return the first non-empty value among keys, or ''."""
for k in keys:
v = d.get(k)
if v not in (None, ""):
return str(v)
return ""
def get_performance_summary(squidclient: str = "squidclient", **kwargs) -> dict:
"""Aggregate the most important mgr:info metrics into a single dict.
Pulls mgr:info, then optionally mgr:5min for request/byte rates. Other
mgr:* sections (counters, storedir, io) can be fetched on demand by the
UI; this function only does the must-have numbers so the page loads fast.
The returned dict is the contract for both the rendered template and the
/performance/refresh JSON endpoint.
"""
fetched_at = _fmt_now()
summary = {
"squid_uptime_seconds": None,
"squid_uptime_human": "",
"squid_version": None,
"squid_author": "",
"cpu_time_seconds": None,
"cpu_time_human": "",
"max_rss_mb": None,
"max_rss_human": "",
"current_clients": None,
"cache_hits_pct": None,
"storage_swap_mb": None,
"storage_swap_human": "",
"storage_mem_mb": None,
"storage_mem_human": "",
"request_rate_5min": None, # requests / minute
"byte_rate_5min": None, # bytes / second
"start_time": "",
"current_time": "",
"fetched_ok": False,
"fetched_at": fetched_at,
"error": None,
"raw_info": "", # included so the UI can show it in a <details> block
}
# ---- mgr:info ----
ok, raw, err = fetch_mgr_info(squidclient=squidclient, mgr="info", **kwargs)
summary["raw_info"] = raw or ""
if not ok:
summary["error"] = err
return summary
info = parse_mgr_info(raw)
# Squid Object Cache (Version 5.7): John Doe
# parse_mgr_info's fallback split puts the (Version X.Y) literal in the
# KEY (with the author trailing) — or in the value, depending on the
# line. We find whichever side contains "Version" and parse from there.
header_full = ""
header_author = ""
for k, v in info.items():
if k.startswith("Squid Object Cache"):
if "Version" in k:
header_full = k
header_author = (v or "").strip()
break
if "Version" in v:
header_full = f"{k}: {v}" if v else k
break
if header_full:
summary["squid_version"] = parse_version(header_full)
if header_author:
summary["squid_author"] = header_author
elif "):" in header_full:
summary["squid_author"] = header_full.split("):", 1)[1].strip()
elif ":" in header_full:
summary["squid_author"] = header_full.split(":", 1)[1].strip()
# Uptime: from Start Time + Current Time if Squid didn't print it directly.
start_time_s = _safe_first(info, "Start Time")
current_time_s = _safe_first(info, "Current Time")
summary["start_time"] = start_time_s
summary["current_time"] = current_time_s
uptime_s = _parse_duration_diff_seconds(start_time_s, current_time_s)
if uptime_s is not None:
summary["squid_uptime_seconds"] = uptime_s
summary["squid_uptime_human"] = _seconds_to_human(uptime_s)
# Connection information: Number of clients accessing cache
clients = _safe_first(info, "Number of clients accessing cache")
if clients:
try:
summary["current_clients"] = int(re.sub(r"[^0-9]", "", clients) or 0)
except ValueError:
summary["current_clients"] = None
# Cache information: Hits as % of bytes sent
hits_pct = _safe_first(info, "Hits as % of bytes sent")
summary["cache_hits_pct"] = parse_percent(hits_pct)
# Storage info
swap = _safe_first(info, "Storage Swap size")
summary["storage_swap_human"] = swap
summary["storage_swap_mb"] = _bytes_to_mb(parse_bytes_to_int(swap))
mem = _safe_first(info, "Storage Mem size")
summary["storage_mem_human"] = mem
summary["storage_mem_mb"] = _bytes_to_mb(parse_bytes_to_int(mem))
# Resource usage
cpu = _safe_first(info, "CPU Time")
summary["cpu_time_human"] = cpu
summary["cpu_time_seconds"] = parse_uptime_to_seconds(cpu)
rss = _safe_first(info, "Maximum Resident Size")
summary["max_rss_human"] = rss
summary["max_rss_mb"] = _bytes_to_mb(parse_bytes_to_int(rss))
# ---- mgr:5min (best-effort, don't fail the whole page if missing) ----
ok5, raw5, _err5 = fetch_mgr_info(
squidclient=squidclient, mgr="5min", **kwargs
)
if ok5:
five = parse_mgr_info(raw5)
sample = _safe_first(five, "sample_time")
# Squid prints per-5min averages with names like:
# "client_http.requests = 1234" and "client_http.kbytes_in/out = ..."
# Compute deltas ourselves if the same metric appears with a previous
# sample; for an MVP we just read the absolute number — labelled
# "5min 平均" by the UI.
req = _safe_first(five, "client_http.requests")
if req:
try:
summary["request_rate_5min"] = float(req)
except ValueError:
pass
kb_in = _safe_first(five, "client_http.kbytes_in")
kb_out = _safe_first(five, "client_http.kbytes_out")
total_kb = 0.0
for s in (kb_in, kb_out):
try:
total_kb += float(s)
except (ValueError, TypeError):
pass
if total_kb > 0:
# Squid's 5min averages are reported per-second (kbytes/sec).
summary["byte_rate_5min"] = int(total_kb * 1024)
_ = sample # currently unused but kept for future deltas
summary["fetched_ok"] = True
return summary
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _bytes_to_mb(n: int | None) -> int | None:
if n is None:
return None
return int(n / (1024 * 1024))
def _seconds_to_human(sec: int) -> str:
"""Format seconds into 'Xd HH:MM:SS' or 'HH:MM:SS'."""
days, rem = divmod(sec, 86400)
hours, rem = divmod(rem, 3600)
minutes, seconds = divmod(rem, 60)
if days:
return f"{days}d {hours:02d}:{minutes:02d}:{seconds:02d}"
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
# Accept RFC 822 / RFC 1123 / "YYYY-MM-DD HH:MM:SS" — best-effort.
_DATE_FORMATS = (
"%a, %d %b %Y %H:%M:%S %Z", # RFC 822 with named TZ (Squid mgr:info)
"%a, %d %b %Y %H:%M:%S %z",
"%a, %d %b %Y %H:%M:%S GMT",
"%Y-%m-%d %H:%M:%S",
)
def _parse_duration_diff_seconds(start: str, current: str) -> int | None:
if not start or not current:
return None
# Strip trailing timezone names that %Z may not recognize on all systems
# (Squid's mgr:info uses "GMT"; Python's %Z accepts it on Linux).
s_dt = _try_parse(start)
c_dt = _try_parse(current)
if s_dt is None or c_dt is None:
return None
delta = (c_dt - s_dt).total_seconds()
return int(delta) if delta >= 0 else None
def _try_parse(s: str):
s = (s or "").strip()
if not s:
return None
for fmt in _DATE_FORMATS:
try:
return datetime.strptime(s, fmt)
except ValueError:
continue
# last resort: split off a trailing TZ like "GMT" / "UTC"
parts = s.rsplit(" ", 1)
if len(parts) == 2 and parts[1].upper() in {"GMT", "UTC"}:
for fmt in _DATE_FORMATS:
try:
return datetime.strptime(parts[0], fmt)
except ValueError:
continue
return None
+495
View File
@@ -0,0 +1,495 @@
"""TLS / HTTPS certificate management for Squid Web Manager.
Self-contained helper around the system ``openssl`` binary. We deliberately
avoid Python ``cryptography`` / ``pyOpenSSL`` to keep the dependency surface
small; everything goes through ``subprocess``.
Public API (all return a tuple ``(ok: bool, message: str, data: dict)``
on failure / success paths so callers can ``flash()`` directly):
- ``is_openssl_available()`` -> ``(ok, msg, data)`` with ``data["openssl"]`` path
- ``generate_self_signed(common_name, days, cert_dir)``
- ``parse_pem_info(cert_path)``
- ``list_certificates(cert_dir)``
- ``upload_certificate(uploaded_file, cert_dir)``
- ``delete_certificate(name, cert_dir)``
Security notes:
- File names are sanitised; ``..`` and path separators are rejected.
- Uploads are limited to 1 MiB and only ``.pem`` / ``.crt`` / ``.key`` accepted.
- Self-signed filenames embed CN + date + short hash, no spaces.
- Audit callers MUST NOT pass private-key material; only metadata + hash.
"""
from __future__ import annotations
import hashlib
import os
import re
import shutil
import subprocess
from datetime import datetime, timezone
# --- Limits / allowed extensions --------------------------------------------
MAX_UPLOAD_BYTES = 1 * 1024 * 1024 # 1 MiB
ALLOWED_EXTENSIONS = (".pem", ".crt", ".key")
SAFE_NAME_RE = re.compile(r"^[A-Za-z0-9_.\-]+$")
# Allow CN to contain dots/underscores but normalise whitespace to underscores.
CN_SAFE_RE = re.compile(r"[^A-Za-z0-9._-]")
# --- helpers ----------------------------------------------------------------
def _err(message: str, **extra) -> tuple[bool, str, dict]:
data = {"error": message}
data.update(extra)
return False, message, data
def _ok(message: str = "OK", **extra) -> tuple[bool, str, dict]:
data = {"ok": True}
data.update(extra)
return True, message, data
def _short_hash(data: bytes) -> str:
"""Return first 8 hex chars of SHA-256 for filename uniqueness."""
return hashlib.sha256(data).hexdigest()[:8]
def _safe_filename(name: str) -> str:
"""Strip directory components and reject anything non-ASCII-clean."""
if not name:
return ""
base = os.path.basename(name)
if base != name:
return ""
if not SAFE_NAME_RE.match(base):
return ""
return base
def _ensure_dir(cert_dir: str) -> tuple[bool, str, dict]:
"""Make sure ``cert_dir`` exists and is writable."""
if not cert_dir:
return _err("cert_dir 不能为空")
try:
os.makedirs(cert_dir, exist_ok=True)
except OSError as e:
return _err(f"无法创建目录 {cert_dir}: {e}")
if not os.path.isdir(cert_dir):
return _err(f"{cert_dir} 不是目录")
return _ok("dir-ok", dir=cert_dir)
def _safe_join(cert_dir: str, name: str) -> tuple[str | None, str | None]:
"""Join ``cert_dir`` + ``name`` and reject path traversal."""
clean = _safe_filename(name)
if not clean:
return None, "文件名包含非法字符 (只允许字母/数字/_-.)"
full = os.path.abspath(os.path.join(cert_dir, clean))
base = os.path.abspath(cert_dir)
# Use os.path.commonpath for traversal rejection.
try:
if os.path.commonpath([full, base]) != base:
return None, "不允许的路径"
except ValueError:
return None, "不允许的路径"
return full, None
# --- public API --------------------------------------------------------------
def is_openssl_available() -> tuple[bool, str, dict]:
"""Return (ok, message, data) where data["openssl"] is the binary path."""
path = shutil.which("openssl")
if not path:
return _err("系统未安装 openssl 或不在 PATH 中")
try:
r = subprocess.run(
[path, "version"], capture_output=True, text=True, timeout=5
)
if r.returncode != 0:
return _err(f"openssl 调用失败: {(r.stderr or '').strip()}")
version = (r.stdout or "").strip()
except (subprocess.TimeoutExpired, OSError) as e:
return _err(f"无法执行 openssl: {e}")
return _ok("openssl 可用", openssl=path, version=version)
def generate_self_signed(
common_name: str, days: int = 365, cert_dir: str = ""
) -> tuple[bool, str, dict]:
"""Generate a self-signed PEM cert+key with combined contents.
Output filename: ``{CN}_{YYYYMMDD}_{short_hash8}.pem`` containing both
certificate and private key sections, parsable by OpenSSL directly.
"""
available_ok, _, avail_data = is_openssl_available()
if not available_ok:
return _err(avail_data.get("error", "openssl 不可用"))
openssl = avail_data["openssl"]
ensured_ok, _, ensured_data = _ensure_dir(cert_dir)
if not ensured_ok:
return False, ensured_data["error"], ensured_data
cn = (common_name or "").strip()
if not cn:
return _err("Common Name (CN) 不能为空")
# File-system safe CN for filename use only; the certificate itself
# preserves the original CN via ``-subj``.
safe_cn = CN_SAFE_RE.sub("_", cn).strip("._-")
if not safe_cn:
return _err("CN 经清洗后为空,仅含非法字符")
if len(safe_cn) > 64:
safe_cn = safe_cn[:64].rstrip("._-")
try:
days_i = int(days)
except (TypeError, ValueError):
return _err("days 必须是整数")
if days_i < 1 or days_i > 3650:
return _err("days 必须在 1 ~ 3650 之间")
date_part = datetime.now().strftime("%Y%m%d")
# 8-char random hash ensures repeated runs with same CN don't collide.
rand_hash = hashlib.sha256(
f"{cn}|{datetime.now().timestamp()}|{os.urandom(8).hex()}".encode()
).hexdigest()[:8]
out_name = f"{safe_cn}_{date_part}_{rand_hash}.pem"
out_path = os.path.join(cert_dir, out_name)
# Escape the CN for the -subj argument: /CN=something
# Allow only printable ASCII in subject to avoid openssl quoting issues.
subj_cn = re.sub(r"[^\x20-\x7e]", "_", cn)
if "/" in subj_cn:
# '/' starts a new RDN component in -subj; replace with '_'
subj_cn = subj_cn.replace("/", "_")
subject = f"/CN={subj_cn}"
cmd = [
openssl, "req", "-x509", "-newkey", "rsa:2048",
"-keyout", out_path,
"-out", out_path,
"-days", str(days_i),
"-nodes",
"-subj", subject,
]
try:
r = subprocess.run(
cmd, capture_output=True, text=True, timeout=30
)
except subprocess.TimeoutExpired:
# best-effort cleanup of half-written file
if os.path.exists(out_path):
try:
os.remove(out_path)
except OSError:
pass
return _err("openssl 生成超时 (>30s)")
except OSError as e:
return _err(f"执行 openssl 失败: {e}")
if r.returncode != 0:
# clean any half-written file
if os.path.exists(out_path):
try:
os.remove(out_path)
except OSError:
pass
err = (r.stderr or r.stdout or "").strip()
return _err(f"openssl 失败: {err[:400]}")
if not os.path.isfile(out_path):
return _err(f"openssl 未生成文件: {out_path}")
# Validate the cert parses back.
parse_ok, parse_msg, parse_data = parse_pem_info(out_path)
if not parse_ok:
return False, f"生成的证书无法解析: {parse_msg}", parse_data
# Audit-friendly fingerprint of the cert portion only (no private key).
fingerprint = parse_data.get("sha256", "")
return _ok(
f"已生成自签名证书: {out_name}",
name=out_name,
path=out_path,
cn=parse_data.get("cn", cn),
days=days_i,
expires=parse_data.get("not_after", ""),
fingerprint=fingerprint,
size=os.path.getsize(out_path),
)
def parse_pem_info(cert_path: str) -> tuple[bool, str, dict]:
"""Parse a PEM file and return subject CN, validity, sha256 fingerprint.
If the file is not a recognised PEM certificate (e.g. a private key)
``ok`` is False and the data dict explains why.
"""
if not cert_path or not os.path.isfile(cert_path):
return _err(f"找不到证书: {cert_path}")
# Subject (CN)
cn = ""
try:
r = subprocess.run(
["openssl", "x509", "-noout", "-subject", "-nameopt", "RFC2253",
"-in", cert_path],
capture_output=True, text=True, timeout=10,
)
if r.returncode != 0:
return _err(
"openssl 无法解析 (文件可能不是证书, 或包含私钥): "
+ (r.stderr or "").strip()[:200]
)
# subject= /CN=foo
subj_line = (r.stdout or "").strip()
m = re.search(r"CN\s*=\s*([^,/\n]+)", subj_line)
if m:
cn = m.group(1).strip()
except (subprocess.TimeoutExpired, OSError) as e:
return _err(f"openssl x509 调用失败: {e}")
# Validity: notBefore / notAfter
not_before = ""
not_after = ""
try:
r = subprocess.run(
["openssl", "x509", "-noout", "-dates", "-in", cert_path],
capture_output=True, text=True, timeout=10,
)
if r.returncode == 0:
for line in (r.stdout or "").splitlines():
if line.startswith("notBefore="):
not_before = line.split("=", 1)[1].strip()
elif line.startswith("notAfter="):
not_after = line.split("=", 1)[1].strip()
except (subprocess.TimeoutExpired, OSError):
pass
# Serial
serial = ""
try:
r = subprocess.run(
["openssl", "x509", "-noout", "-serial", "-in", cert_path],
capture_output=True, text=True, timeout=10,
)
if r.returncode == 0:
m = re.search(r"=([0-9A-Fa-f]+)", r.stdout or "")
if m:
serial = m.group(1)
except (subprocess.TimeoutExpired, OSError):
pass
# SHA-256 fingerprint
sha256 = ""
try:
r = subprocess.run(
["openssl", "x509", "-noout", "-fingerprint", "-sha256",
"-in", cert_path],
capture_output=True, text=True, timeout=10,
)
if r.returncode == 0:
# output e.g. "sha256 Fingerprint=AA:BB:..."
line = (r.stdout or "").strip()
if "=" in line:
sha256 = line.split("=", 1)[1].replace(":", "").lower()
except (subprocess.TimeoutExpired, OSError):
pass
days_remaining = None
expires_iso = ""
status = "unknown"
if not_after:
try:
# notAfter format: e.g. "Jun 14 12:00:00 2026 GMT"
dt = datetime.strptime(not_after, "%b %d %H:%M:%S %Y %Z")
dt = dt.replace(tzinfo=timezone.utc)
expires_iso = dt.strftime("%Y-%m-%dT%H:%M:%SZ")
now = datetime.now(timezone.utc)
days_remaining = int((dt - now).total_seconds() // 86400)
if days_remaining < 0:
status = "expired"
elif days_remaining < 30:
status = "expiring"
else:
status = "valid"
except ValueError:
status = "unknown"
return _ok(
"parsed",
cn=cn,
not_before=not_before,
not_after=not_after,
expires_iso=expires_iso,
days_remaining=days_remaining,
status=status,
serial=serial,
sha256=sha256,
path=cert_path,
)
def list_certificates(cert_dir: str) -> tuple[bool, str, list]:
"""List certificates in ``cert_dir``. Returns plain list on success,
not the (ok, msg, data) tuple — for easier template iteration.
Returns a list of dicts with all fields; non-certificate files are skipped.
"""
ensured_ok, _, _ = _ensure_dir(cert_dir)
if not ensured_ok:
return False, "目录不可访问", []
items: list[dict] = []
try:
names = sorted(os.listdir(cert_dir))
except OSError as e:
return False, f"无法列出目录: {e}", []
for n in names:
full = os.path.join(cert_dir, n)
if not os.path.isfile(full):
continue
# only show files with allowed extensions
ext = os.path.splitext(n)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
continue
st = os.stat(full)
try:
ok, msg, data = parse_pem_info(full)
except Exception:
ok, msg, data = False, "解析异常", {}
items.append({
"name": n,
"path": full,
"size": st.st_size,
"mtime": st.st_mtime,
"ext": ext,
"is_key": (ext == ".key"),
"parsed_ok": ok,
"parse_error": "" if ok else msg,
"cn": data.get("cn", ""),
"not_after": data.get("not_after", ""),
"expires_iso": data.get("expires_iso", ""),
"days_remaining": data.get("days_remaining"),
"status": data.get("status", "unknown"),
"serial": data.get("serial", ""),
"sha256": data.get("sha256", ""),
})
return True, "OK", items
def upload_certificate(
uploaded_file, cert_dir: str
) -> tuple[bool, str, dict]:
"""Accept a Werkzeug FileStorage (or any object with .filename + .read)
and save it into ``cert_dir``.
Limits: 1 MiB, allowed extensions: .pem / .crt / .key.
"""
if not uploaded_file:
return _err("未提供上传文件")
# Many test/CLI paths pass a path string or None.
filename = getattr(uploaded_file, "filename", "") or ""
name = _safe_filename(filename)
if not name:
return _err("文件名不合法 (不允许 / 或 .. 或特殊字符)")
ext = os.path.splitext(name)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
return _err(
f"不支持的扩展名 '{ext}', 仅接受 {', '.join(ALLOWED_EXTENSIONS)}"
)
full, err = _safe_join(cert_dir, name)
if err or full is None:
return _err(err or "路径不安全")
# Read bytes (size cap).
try:
if hasattr(uploaded_file, "read"):
data = uploaded_file.read()
elif isinstance(uploaded_file, (bytes, bytearray)):
data = bytes(uploaded_file)
elif isinstance(uploaded_file, str) and os.path.isfile(uploaded_file):
with open(uploaded_file, "rb") as fh:
data = fh.read()
else:
return _err("无法读取上传文件内容")
except OSError as e:
return _err(f"读取失败: {e}")
if data is None:
return _err("上传内容为空")
if not isinstance(data, (bytes, bytearray)):
return _err("上传内容必须是二进制")
if len(data) == 0:
return _err("上传内容为空")
if len(data) > MAX_UPLOAD_BYTES:
return _err(
f"文件过大 ({len(data)} 字节), 上限 {MAX_UPLOAD_BYTES} 字节 (1MB)"
)
# Sanity check: PEM must contain BEGIN ... header for certs/keys.
head = data[:120].decode("ascii", errors="replace")
looks_like_pem = "-----BEGIN" in head
if ext in (".pem", ".crt") and not looks_like_pem:
return _err(
"文件内容不像有效 PEM (缺少 '-----BEGIN' 头), 上传被拒绝"
)
# Avoid overwriting existing file silently.
if os.path.exists(full):
return _err(f"目标已存在: {name}, 请先删除或重命名后再上传")
try:
with open(full, "wb") as fh:
fh.write(data)
except OSError as e:
return _err(f"保存失败: {e}")
fingerprint = hashlib.sha256(data).hexdigest().lower()
kind = "key" if ext == ".key" else (
"cert+key" if ext == ".pem" else "cert"
)
return _ok(
f"已上传 {name} ({len(data)} 字节)",
name=name,
path=full,
size=len(data),
sha256=fingerprint,
kind=kind,
ext=ext,
)
def delete_certificate(name: str, cert_dir: str) -> tuple[bool, str, dict]:
"""Delete a single file (cert or key) from ``cert_dir``."""
full, err = _safe_join(cert_dir, name)
if err or full is None:
return _err(err or "路径不安全")
if not os.path.isfile(full):
return _err(f"文件不存在: {name}")
try:
# Compute SHA-256 of content before deletion for audit trail.
with open(full, "rb") as fh:
blob = fh.read()
fingerprint = hashlib.sha256(blob).hexdigest().lower()
os.remove(full)
except OSError as e:
return _err(f"删除失败: {e}")
return _ok(
f"已删除 {name}",
name=name,
sha256=fingerprint,
size=len(blob),
)
+1034
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{% set _theme = theme|default('dark') %}
{% set _icon = '☀️' if _theme == 'light' else '🌙' %}
<button class="theme-toggle" type="button" onclick="toggleTheme()" title="切换主题 (暗/亮)" aria-label="切换主题">
<span id="theme-icon">{{ _icon }}</span>
</button>
<script>
function toggleTheme() {
const current = document.body.classList.contains('theme-light') ? 'light' : 'dark';
const next = current === 'light' ? 'dark' : 'light';
document.cookie = `theme=${next}; path=/; max-age=${365*24*3600}; samesite=Lax`;
document.body.classList.toggle('theme-light', next === 'light');
const icon = document.getElementById('theme-icon');
if (icon) icon.textContent = next === 'light' ? '☀️' : '🌙';
}
// initial state — keep icon in sync if cookie says light but the body class
// hasn't been set yet (e.g. partial rendered before server emitted the class)
(function() {
const match = document.cookie.match(/theme=(\w+)/);
if (match && match[1] === 'light') {
document.body.classList.add('theme-light');
const icon = document.getElementById('theme-icon');
if (icon) icon.textContent = '☀️';
} else if (match && match[1] === 'dark') {
document.body.classList.remove('theme-light');
const icon = document.getElementById('theme-icon');
if (icon) icon.textContent = '🌙';
}
})();
</script>
+223
View File
@@ -0,0 +1,223 @@
{% extends "base.html" %}
{% block title %}告警规则 - Squid Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1>🚨 告警规则</h1>
<div class="page-actions">
<form method="post" action="{{ url_for('alerts_evaluate') }}" style="display:inline;">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-primary btn-small">⚡ 立即评估</button>
</form>
<a href="{{ url_for('alerts_history') }}" class="btn btn-secondary btn-small">📜 触发历史</a>
<button onclick="document.getElementById('add-form').style.display='block'" class="btn btn-primary btn-small"> 添加规则</button>
</div>
</div>
<p class="meta-text">
基于 access.log 解析指标与磁盘使用情况,按阈值触发告警。
同一规则在 cooldown 时间窗内只记录一次,避免告警风暴。
严重度: <span class="badge badge-blue">info</span>
<span class="badge badge-yellow">warning</span>
<span class="badge badge-red">critical</span>
</p>
{# active alerts banner #}
{% set active = [] %}
{% for status in current_status %}
{% if status.triggered %}{% set _ = active.append(status) %}{% endif %}
{% endfor %}
{% if active %}
<div class="card" style="border-left: 4px solid #ef4444;">
<h3 class="card-title">⚠️ 当前活跃告警 ({{ active | length }})</h3>
<ul class="rule-messages">
{% for s in active %}
<li>
<span class="badge badge-{% if s.severity == 'critical' %}red{% elif s.severity == 'warning' %}yellow{% else %}blue{% endif %}">{{ s.severity }}</span>
{{ s.message }}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{# add rule form #}
<div class="card" id="add-form" style="display:none;">
<h3 class="card-title">添加新规则</h3>
<form method="post" action="{{ url_for('alerts_add') }}" class="inline-form">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<div class="filter-grid">
<div class="form-group" style="grid-column: span 2;">
<label>规则名称 <span class="hint">(简短描述,便于审计追溯)</span></label>
<input type="text" name="name" class="form-input" required placeholder="例如: 高 5xx 错误率">
</div>
<div class="form-group">
<label>指标</label>
<select name="metric" class="form-input" required>
{% for m, desc in metrics %}
<option value="{{ m }}">{{ m }} — {{ desc }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>条件</label>
<select name="operator" class="form-input" required>
{% for op, sym in operators %}
<option value="{{ op }}">{{ sym }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>阈值</label>
<input type="number" name="threshold" class="form-input" step="0.0001" required value="0.05">
</div>
<div class="form-group">
<label>窗口 (分钟)</label>
<input type="number" name="window_minutes" class="form-input" min="1" max="1440" value="5">
</div>
<div class="form-group">
<label>冷却 (分钟)</label>
<input type="number" name="cooldown_minutes" class="form-input" min="1" max="10080" value="30">
</div>
<div class="form-group">
<label>严重度</label>
<select name="severity" class="form-input">
<option value="info">info</option>
<option value="warning" selected>warning</option>
<option value="critical">critical</option>
</select>
</div>
<div class="form-group">
<label>启用</label>
<select name="enabled" class="form-input">
<option value="1" selected></option>
<option value="0"></option>
</select>
</div>
<div class="form-group" style="grid-column: span 2;">
<label>Webhook URL <span class="hint">(可选。留空仅记审计,非空则 POST JSON)</span></label>
<input type="text" name="notify_webhook" class="form-input" placeholder="https://hooks.example.com/squid">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">添加</button>
<button type="button" onclick="document.getElementById('add-form').style.display='none'" class="btn btn-secondary">取消</button>
</div>
</form>
</div>
<div class="card">
<h3 class="card-title">规则列表</h3>
<p class="card-desc">共 {{ rules | length }} 条规则。当前值是最近一次评估的快照。
勾选/取消勾选"启用"列后点击"💾 保存所有规则"即可生效。</p>
<form method="post" action="{{ url_for('alerts_save') }}">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th style="width:40px;">#</th>
<th>名称</th>
<th style="width:130px;">指标</th>
<th style="width:80px;">条件</th>
<th style="width:110px;">阈值</th>
<th style="width:70px;">窗口</th>
<th style="width:80px;">冷却</th>
<th style="width:90px;">严重度</th>
<th style="width:160px;">当前值</th>
<th style="width:140px;">状态</th>
<th style="width:80px;">启用</th>
<th style="width:80px;">删除</th>
</tr>
</thead>
<tbody>
{% for r in rules %}
{% set s = status_map.get(loop.index0) %}
<tr>
<td>{{ loop.index }}<input type="hidden" name="name[]" value="{{ r.name }}"></td>
<td>
{{ r.name }}
{% if r.notify_webhook %}<br><span class="meta-text">🔗 webhook</span>{% endif %}
</td>
<td>
<select name="metric[]" class="form-input">
{% for m, desc in metrics %}
<option value="{{ m }}" {% if r.metric == m %}selected{% endif %}>{{ m }}</option>
{% endfor %}
</select>
</td>
<td>
<select name="operator[]" class="form-input">
{% for op, sym in operators %}
<option value="{{ op }}" {% if r.operator == op %}selected{% endif %}>{{ sym }}</option>
{% endfor %}
</select>
</td>
<td><input type="number" step="0.0001" name="threshold[]" value="{{ r.threshold }}" class="form-input"></td>
<td><input type="number" name="window_minutes[]" value="{{ r.window_minutes }}" min="1" class="form-input"></td>
<td><input type="number" name="cooldown_minutes[]" value="{{ r.cooldown_minutes }}" min="1" class="form-input"></td>
<td>
<select name="severity[]" class="form-input">
{% for sv in severities %}
<option value="{{ sv }}" {% if r.severity == sv %}selected{% endif %}>{{ sv }}</option>
{% endfor %}
</select>
</td>
<td>
{% if s %}
<code>{{ '%.4f' % s.value }}</code>
{% else %}
<span class="meta-text"></span>
{% endif %}
</td>
<td>
{% if not r.enabled %}
<span class="badge badge-gray">已禁用</span>
{% elif s %}
{% if s.triggered %}
<span class="badge badge-red">触发</span>
{% elif s.in_cooldown %}
<span class="badge badge-yellow">冷却</span>
{% else %}
<span class="badge badge-green">正常</span>
{% endif %}
{% else %}
<span class="badge badge-gray">未评估</span>
{% endif %}
</td>
<td>
<input type="checkbox" name="enabled[]" value="1" {% if r.enabled %}checked{% endif %}>
</td>
<td>
<button type="button" class="btn btn-danger btn-tiny" onclick="this.closest('tr').remove()">删除</button>
</td>
</tr>
{% endfor %}
{% if not rules %}
<tr><td colspan="12" class="empty-row">暂无规则。点击上方"添加规则"按钮开始,或点击 "立即评估" 先运行默认规则。</td></tr>
{% endif %}
</tbody>
</table>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">💾 保存所有规则</button>
</div>
</form>
</div>
<div class="card">
<h3 class="card-title">📖 规则说明</h3>
<ul>
<li><strong>触发逻辑</strong>: 计算当前指标值,与阈值按指定运算符 ( &gt; &lt; &ge; &le; ) 比较,满足则记录并 (可选) POST webhook。</li>
<li><strong>冷却 (cooldown)</strong>: 同一规则两次触发之间的最小间隔,避免告警风暴。</li>
<li><strong>窗口 (window_minutes)</strong>: 用于按窗口归一化的指标 (如 <code>client_bytes_per_min</code>);其他指标仅用于备注。</li>
<li><strong>评估方式</strong>: 点击"立即评估"使用当前 access.log 快照立即跑一遍;要持续监控,配合系统的 cron 调用 <code>/alerts/evaluate</code> 接口即可。</li>
<li><strong>触发历史</strong>: 持久化记录会写入 <code>audit_log</code>,可在"<a href="{{ url_for('alerts_history') }}">触发历史</a>"查看 (取最近 200 条)。</li>
</ul>
</div>
<style>
.rule-messages { list-style: none; padding: 0; margin: 0; }
.rule-messages li { padding: 6px 0; border-bottom: 1px dashed rgba(148, 163, 184, 0.18); }
.rule-messages li:last-child { border-bottom: none; }
</style>
{% endblock %}
+71
View File
@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}告警历史 - Squid Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1>📜 告警触发历史</h1>
<div class="page-actions">
<a href="{{ url_for('alerts_view') }}" class="btn btn-secondary btn-small">← 返回告警规则</a>
</div>
</div>
<div class="card">
<p class="card-desc">最近 {{ triggered | length }} 次实际触发 (来源 audit_log, action=<code>alert_triggered</code>)
以及最近 {{ extras | length }} 条规则管理事件 (添加/保存/删除/评估)。</p>
<h3 class="card-title">🚨 触发记录 ({{ triggered | length }})</h3>
{% if triggered %}
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th style="width:180px;">时间</th>
<th style="width:120px;">用户</th>
<th>详情</th>
</tr>
</thead>
<tbody>
{% for a in triggered %}
<tr>
<td class="time-cell">{{ a.timestamp_local }}</td>
<td>{{ a.username }}</td>
<td class="detail-cell">{{ a.detail }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="meta-text">还没有触发记录。在 <a href="{{ url_for('alerts_view') }}">告警规则</a> 页面点击 “立即评估” 即可触发评估。</p>
{% endif %}
</div>
<div class="card">
<h3 class="card-title">⚙️ 规则管理事件 ({{ extras | length }})</h3>
{% if extras %}
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th style="width:180px;">时间</th>
<th style="width:120px;">用户</th>
<th style="width:140px;">操作</th>
<th>详情</th>
</tr>
</thead>
<tbody>
{% for a in extras %}
<tr>
<td class="time-cell">{{ a.timestamp_local }}</td>
<td>{{ a.username }}</td>
<td><code>{{ a.action }}</code></td>
<td class="detail-cell">{{ a.detail }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="meta-text">暂无管理事件。</p>
{% endif %}
</div>
{% endblock %}
+276
View File
@@ -0,0 +1,276 @@
{% extends "base.html" %}
{% block title %}流量异常 - Squid Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1>⚠️ 流量异常检测</h1>
<div class="page-actions">
<span class="meta-text">
{% if summary.header_stats %}
当前窗口: {{ summary.header_stats.current_window_count }} 条 ·
{{ summary.header_stats.current_window_clients }} 个客户端
{% else %}
暂无日志数据
{% endif %}
</span>
<span class="btn-group" style="display:inline-flex; gap:4px;">
<a href="{{ url_for('export_anomalies_csv') }}"
class="btn btn-secondary btn-small" title="导出异常列表为 CSV">⬇️ CSV</a>
<a href="{{ url_for('export_anomalies_json') }}"
class="btn btn-secondary btn-small" title="导出异常列表为 JSON">⬇️ JSON</a>
</span>
<button onclick="refreshAnomalies()" class="btn btn-small btn-secondary">🔄 刷新</button>
</div>
</div>
{% if entries_count == 0 %}
<div class="card">
<div class="card-empty">
<div class="empty-icon">🕵️</div>
<h2>暂无日志数据</h2>
<p>请先到 <a href="{{ url_for('logs_import') }}">日志导入</a>
<a href="{{ url_for('config_paths') }}">路径设置</a> 配置 access.log。</p>
</div>
</div>
{% else %}
<!-- Thresholds (collapsible) -->
<div class="card">
<details>
<summary class="card-title" style="cursor:pointer; margin:0;">
⚙️ 阈值设置 (显示用, 修改后请回到此页面点击刷新)
</summary>
<form method="get" action="{{ url_for('anomalies_view') }}" class="filter-form"
style="margin-top:12px; display:flex; gap:12px; flex-wrap:wrap; align-items:flex-end;">
<div>
<label class="form-label">突增比例 (ratio > X 标记异常)</label>
<input type="number" name="spike_ratio" min="1" step="0.5"
value="{{ thresholds.spike_warn_ratio }}" class="form-input" style="width:100px;">
</div>
<div>
<label class="form-label">大文件阈值 (MB)</label>
<input type="number" name="large_mb" min="1" step="1"
value="{{ thresholds.large_size_mb }}" class="form-input" style="width:100px;">
</div>
<div>
<label class="form-label">小包阈值 (KB)</label>
<input type="number" name="small_kb" min="0.1" step="0.1"
value="{{ thresholds.small_size_kb }}" class="form-input" style="width:100px;">
</div>
<div>
<label class="form-label">高频小包最小请求数</label>
<input type="number" name="min_small_req" min="1" step="1"
value="{{ thresholds.min_small_requests }}" class="form-input" style="width:120px;">
</div>
<div>
<button type="submit" class="btn btn-primary btn-small">应用</button>
</div>
<div class="meta-text" style="width:100%;">
当前 critical 阈值 = {{ thresholds.spike_crit_ratio }}x (固定) ·
基线窗口 = 前 80%, 当前窗口 = 后 20%
</div>
</form>
</details>
</div>
<!-- Summary KPI -->
<div class="kpi-grid">
<div class="kpi-card kpi-red">
<div class="kpi-label">突增客户端</div>
<div class="kpi-value">{{ summary.anomalous_clients | length }}</div>
<div class="kpi-meta">请求速率突增 > {{ thresholds.spike_warn_ratio }}x</div>
</div>
<div class="kpi-card kpi-purple">
<div class="kpi-label">大文件请求</div>
<div class="kpi-value">{{ summary.large_requests | length }}</div>
<div class="kpi-meta">单次响应 > {{ thresholds.large_size_mb }} MB</div>
</div>
<div class="kpi-card kpi-orange">
<div class="kpi-label">高频小包客户端</div>
<div class="kpi-value">{{ summary.high_freq_small | length }}</div>
<div class="kpi-meta">响应 &lt; {{ thresholds.small_size_kb }} KB 且请求数 &ge; {{ thresholds.min_small_requests }}</div>
</div>
<div class="kpi-card kpi-blue">
<div class="kpi-label">首次出现客户端</div>
<div class="kpi-value">{{ summary.new_clients | length }}</div>
<div class="kpi-meta">本日志切片内首次见到的客户端</div>
</div>
</div>
<!-- 1. Spikes -->
<div class="card">
<h3 class="card-title">🔥 突增客户端
<span class="meta-text">({{ summary.anomalous_clients | length }} 条)</span>
</h3>
{% if summary.anomalous_clients %}
<table class="data-table">
<thead>
<tr>
<th>客户端</th>
<th>基线请求/分</th>
<th>当前请求/分</th>
<th>基线总数</th>
<th>当前总数</th>
<th>倍数</th>
<th>严重程度</th>
</tr>
</thead>
<tbody>
{% for a in summary.anomalous_clients %}
<tr>
<td><a href="{{ url_for('logs_view', client=a.client) }}">{{ a.client }}</a></td>
<td>{{ '%.2f' % a.baseline_rpm }}</td>
<td>{{ '%.2f' % a.current_rpm }}</td>
<td>{{ a.baseline_count }}</td>
<td>{{ a.current_count }}</td>
<td>
{% if a.ratio == 'inf' or a.ratio == 'Infinity' or (a.ratio is number and a.ratio > 1e15) %}
∞ (基线为 0)
{% else %}
{{ '%.2f' % a.ratio }}x
{% endif %}
</td>
<td>
<span class="badge badge-{% if a.severity == 'critical' %}red{% else %}yellow{% endif %}">
{{ a.severity }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="meta-text">未检测到异常突增客户端。</p>
{% endif %}
</div>
<!-- 2. Large requests -->
<div class="card">
<h3 class="card-title">💾 异常大文件请求
<span class="meta-text">({{ summary.large_requests | length }} 条)</span>
</h3>
{% if summary.large_requests %}
<table class="data-table">
<thead>
<tr>
<th>时间</th>
<th>客户端</th>
<th>大小</th>
<th>方法</th>
<th>状态</th>
<th>URL</th>
</tr>
</thead>
<tbody>
{% for r in summary.large_requests %}
<tr>
<td><code>{{ r.time }}</code></td>
<td><a href="{{ url_for('logs_view', client=r.client) }}">{{ r.client }}</a></td>
<td>
<strong>{{ '%.2f' % r.size_mb }} MB</strong>
<span class="meta-text">({{ r.size_bytes | thousands }} B)</span>
</td>
<td><code>{{ r.method }}</code></td>
<td><code>{{ r.result }}</code></td>
<td class="url-cell" title="{{ r.url }}">
<a href="{{ url_for('logs_view', url=r.url) }}">{{ r.url }}</a>
{% if r.host %}<span class="meta-text">[{{ r.host }}]</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="meta-text">未检测到 &gt; {{ thresholds.large_size_mb }} MB 的请求。</p>
{% endif %}
</div>
<!-- 3. High-frequency small packets -->
<div class="card">
<h3 class="card-title">📊 高频小包客户端
<span class="meta-text">({{ summary.high_freq_small | length }} 个)</span>
</h3>
{% if summary.high_freq_small %}
<table class="data-table">
<thead>
<tr>
<th>客户端</th>
<th>请求数</th>
<th>总流量</th>
<th>平均大小</th>
<th>示例主机</th>
<th>示例 URL</th>
</tr>
</thead>
<tbody>
{% for c in summary.high_freq_small %}
<tr>
<td><a href="{{ url_for('logs_view', client=c.client) }}">{{ c.client }}</a></td>
<td><strong>{{ c.request_count | thousands }}</strong></td>
<td>{{ c.total_bytes | thousands }} B</td>
<td>{{ '%.0f' % c.avg_size }} B</td>
<td>{{ c.sample_host or '-' }}</td>
<td class="url-cell" title="{{ c.sample_url }}">
<a href="{{ url_for('logs_view', url=c.sample_url) }}">{{ c.sample_url }}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="meta-text">
未检测到符合条件 (&lt; {{ thresholds.small_size_kb }} KB × &ge; {{ thresholds.min_small_requests }} 次) 的客户端。
</p>
{% endif %}
</div>
<!-- 4. New clients -->
<div class="card">
<h3 class="card-title">👋 首次出现客户端
<span class="meta-text">({{ summary.new_clients | length }} 个)</span>
</h3>
{% if summary.new_clients %}
<table class="data-table">
<thead>
<tr>
<th>客户端</th>
<th>首次出现时间</th>
<th>本日志段请求数</th>
<th>示例 URL</th>
</tr>
</thead>
<tbody>
{% for c in summary.new_clients %}
<tr>
<td><a href="{{ url_for('logs_view', client=c.client) }}">{{ c.client }}</a></td>
<td><code>{{ c.first_seen }}</code></td>
<td>{{ c.request_count | thousands }}</td>
<td class="url-cell" title="{{ c.sample_url }}">
<a href="{{ url_for('logs_view', url=c.sample_url) }}">{{ c.sample_url }}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="meta-text">未检测到新出现的客户端。</p>
{% endif %}
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
function refreshAnomalies() {
fetch('{{ url_for("anomalies_refresh") }}', { method: 'POST' })
.then(r => r.json())
.then(d => {
if (d.ok) location.reload();
else alert('刷新失败: ' + (d.message || ''));
})
.catch(e => alert('网络错误: ' + e));
}
</script>
{% endblock %}
+49
View File
@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}审计日志 - Squid Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1>📜 审计日志</h1>
<div class="page-actions">
<span class="meta-text">共 {{ total | thousands }} 条</span>
</div>
</div>
<div class="card">
{% if items %}
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th style="width:180px;">时间</th>
<th style="width:120px;">用户</th>
<th style="width:180px;">操作</th>
<th>详情</th>
</tr>
</thead>
<tbody>
{% for a in items %}
<tr>
<td class="time-cell">{{ a.timestamp_local }}</td>
<td>{{ a.username }}</td>
<td><code>{{ a.action }}</code></td>
<td class="detail-cell">{{ a.detail }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<div class="pagination">
{% if page > 1 %}<a href="?page={{ page - 1 }}" class="btn btn-small">« 上一页</a>{% endif %}
<span class="page-info">第 {{ page }} / {{ total_pages }} 页</span>
{% if page < total_pages %}<a href="?page={{ page + 1 }}" class="btn btn-small">下一页 »</a>{% endif %}
</div>
{% endif %}
{% else %}
<div class="card-empty">
<div class="empty-icon">📜</div>
<p>暂无审计记录</p>
</div>
{% endif %}
</div>
{% endblock %}
+57
View File
@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block title %}配置备份 - Squid Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1>📦 配置备份</h1>
<div class="page-actions">
<span class="meta-text">共 {{ files | length }} 个备份</span>
</div>
</div>
<div class="card">
{% if files %}
<table class="data-table">
<thead>
<tr>
<th>文件名</th>
<th>大小</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for f in files %}
<tr>
<td><code>{{ f.name }}</code></td>
<td>{{ f.size }} 字节</td>
<td class="time-cell">{{ f.mtime }}</td>
<td>
<a href="{{ url_for('backup_download', name=f.name) }}" class="btn btn-small btn-secondary">⬇ 下载</a>
<form method="post" action="{{ url_for('backup_restore', name=f.name) }}" style="display:inline;"
onsubmit="return confirm('确认从备份 {{ f.name }} 恢复? 当前配置将被覆盖。');">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-small btn-warning">♻ 恢复</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card-empty">
<div class="empty-icon">📦</div>
<p>暂无备份。在保存配置时会自动创建备份。</p>
</div>
{% endif %}
</div>
<div class="card">
<h3 class="card-title">️ 备份说明</h3>
<ul>
<li>每次通过 Web UI 保存配置时,会自动备份当前配置到 <code>backups/</code> 目录</li>
<li>备份命名格式: <code>squid.conf.YYYYMMDD_HHMMSS.&lt;reason&gt;</code></li>
<li>恢复时会先将当前配置备份为 <code>before-restore</code></li>
<li>恢复后需要 <a href="{{ url_for('service_view') }}">重载服务</a> 才能生效</li>
</ul>
</div>
{% endblock %}
+220
View File
@@ -0,0 +1,220 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{ csrf_meta()|safe }}
<title>{% block title %}Squid Web Manager{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{% block head %}{% endblock %}
</head>
<body class="{% if theme == 'light' %}theme-light{% endif %}">
{% if current_user %}
<aside class="sidebar">
<div class="brand">
<div class="brand-logo">🦑</div>
<div class="brand-name">Squid Manager</div>
<div class="brand-actions">
{% include '_theme_toggle.html' %}
</div>
</div>
{# P1-3: instance switcher - shows the currently active Squid instance
and lets the user swap to a different one without leaving the page. #}
{% if instances %}
<div class="instance-switcher">
<label for="instance-select" class="instance-switcher-label">当前实例</label>
<select id="instance-select" class="instance-switcher-select"
onchange="if(this.value){window.location='{{ url_for('instances_activate', inst_id=0) }}'.replace('/0','/'+this.value);}">
{% for inst in instances %}
<option value="{{ inst.id }}" {% if active_instance_id == inst.id %}selected{% endif %}>
{% if active_instance_id == inst.id %}★ {% endif %}{{ inst.name }}{% if not inst.is_local %} (远程){% endif %}
</option>
{% endfor %}
</select>
{# Quick-switch buttons: each is its own POST form (HTML doesn't allow
nested forms, so we don't wrap them in another <form>). #}
<div class="instance-switcher-quick">
{% for inst in instances %}
{% if active_instance_id != inst.id %}
<form method="post" action="{{ url_for('instances_activate', inst_id=inst.id) }}" style="display:inline; margin:0;">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ request.full_path }}">
<button type="submit" class="btn btn-secondary btn-tiny" title="切换到 {{ inst.name }}">→ {{ inst.name }}</button>
</form>
{% endif %}
{% endfor %}
<a href="{{ url_for('instances_view') }}" class="btn btn-secondary btn-tiny">⚙ 管理</a>
</div>
</div>
{% else %}
<div class="instance-switcher">
<label class="instance-switcher-label">当前实例</label>
<div class="instance-switcher-empty">
<span class="meta-text">未配置实例 (使用全局设置)</span>
<a href="{{ url_for('instances_view') }}" class="btn btn-secondary btn-tiny" style="margin-top:6px;">⚙ 添加实例</a>
</div>
</div>
{% endif %}
<nav>
<a href="{{ url_for('dashboard') }}" class="{% if request.endpoint == 'dashboard' %}active{% endif %}">
📊 <span>仪表盘</span>
</a>
<a href="{{ url_for('anomalies_view') }}" class="{% if request.endpoint == 'anomalies_view' %}active{% endif %}">
⚠️ <span>流量异常</span>
</a>
<a href="{{ url_for('logs_view') }}" class="{% if request.endpoint in ('logs_view',) %}active{% endif %}">
📋 <span>日志查询</span>
</a>
<a href="{{ url_for('logs_live') }}" class="{% if request.endpoint == 'logs_live' %}active{% endif %}">
<span>实时日志</span>
</a>
<a href="{{ url_for('logs_import') }}" class="{% if request.endpoint == 'logs_import' %}active{% endif %}">
📥 <span>日志导入</span>
</a>
<a href="{{ url_for('service_view') }}" class="{% if request.endpoint == 'service_view' %}active{% endif %}">
🛠️ <span>服务控制</span>
</a>
<div class="nav-section">配置管理</div>
<a href="{{ url_for('config_main') }}" class="{% if request.endpoint == 'config_main' %}active{% endif %}">
⚙️ <span>主配置</span>
</a>
<a href="{{ url_for('config_acls') }}" class="{% if request.endpoint == 'config_acls' %}active{% endif %}">
🚦 <span>ACL 规则表</span>
</a>
<a href="{{ url_for('config_rules') }}" class="{% if request.endpoint == 'config_rules' %}active{% endif %}">
🔐 <span>访问控制</span>
</a>
<a href="{{ url_for('config_cache') }}" class="{% if request.endpoint == 'config_cache' %}active{% endif %}">
💾 <span>缓存目录</span>
</a>
<a href="{{ url_for('config_refresh') }}" class="{% if request.endpoint == 'config_refresh' %}active{% endif %}">
♻️ <span>刷新规则</span>
</a>
<a href="{{ url_for('config_auth') }}" class="{% if request.endpoint == 'config_auth' %}active{% endif %}">
🔑 <span>认证配置</span>
</a>
<a href="{{ url_for('config_ssl_bump') }}" class="{% if request.endpoint == 'config_ssl_bump' %}active{% endif %}">
🔐 <span>SSL Bump</span>
</a>
<a href="{{ url_for('config_peers') }}" class="{% if request.endpoint == 'config_peers' %}active{% endif %}">
🌐 <span>缓存对等</span>
</a>
<a href="{{ url_for('config_raw') }}" class="{% if request.endpoint == 'config_raw' %}active{% endif %}">
📝 <span>原始编辑</span>
</a>
<a href="{{ url_for('config_tls') }}" class="{% if request.endpoint == 'config_tls' %}active{% endif %}">
🔐 <span>TLS 证书</span>
</a>
<a href="{{ url_for('config_paths') }}" class="{% if request.endpoint == 'config_paths' %}active{% endif %}">
🗂️ <span>路径设置</span>
</a>
<div class="nav-section">运维</div>
<a href="{{ url_for('backups_view') }}" class="{% if request.endpoint == 'backups_view' %}active{% endif %}">
📦 <span>配置备份</span>
</a>
<a href="{{ url_for('audit_view') }}" class="{% if request.endpoint == 'audit_view' %}active{% endif %}">
📜 <span>审计日志</span>
</a>
<a href="{{ url_for('ops_logs') }}" class="{% if request.endpoint == 'ops_logs' %}active{% endif %}">
📂 <span>日志状态</span>
</a>
<a href="{{ url_for('ops_logrotate') }}" class="{% if request.endpoint == 'ops_logrotate' %}active{% endif %}">
🔁 <span>日志轮转</span>
</a>
<a href="{{ url_for('alerts_view') }}" class="{% if request.endpoint == 'alerts_view' %}active{% endif %}">
🚨 <span>告警规则</span>
</a>
<a href="{{ url_for('instances_view') }}" class="{% if request.endpoint == 'instances_view' %}active{% endif %}">
🖥️ <span>多实例管理</span>
</a>
<a href="{{ url_for('performance_view') }}" class="{% if request.endpoint == 'performance_view' %}active{% endif %}">
📊 <span>实时性能</span>
</a>
{% if current_user and current_user.role == 'admin' %}
<a href="{{ url_for('terminal_view') }}" class="{% if request.endpoint == 'terminal_view' %}active{% endif %}">
💻 <span>Web 终端</span>
</a>
{% endif %}
<a href="{{ url_for('change_password') }}" class="{% if request.endpoint == 'change_password' %}active{% endif %}">
🔒 <span>修改密码</span>
</a>
<a href="{{ url_for('logout') }}" class="nav-danger">
🚪 <span>退出登录</span>
</a>
</nav>
</aside>
{% endif %}
<main class="{% if not current_user %}content content-full{% else %}content{% endif %}">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-container">
{% for category, msg in messages %}
<div class="flash flash-{{ category }}">{{ msg }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
{% if current_user %}
<!-- Session idle warning toast -->
<div id="session-warn" class="session-warn" style="display:none;">
<div class="session-warn-inner">
<span>⏰ 会话即将过期 (<span id="session-warn-sec">60</span>s),点击任意位置续期</span>
<button onclick="event.stopPropagation(); document.body.click();" class="btn btn-small btn-primary">续期</button>
</div>
</div>
{% endif %}
{% block scripts %}{% endblock %}
<script>
// CSRF helper: auto-attach token to every fetch() with unsafe method
(function() {
const meta = document.querySelector('meta[name="csrf-token"]');
if (!meta) return;
const token = meta.getAttribute('content');
const origFetch = window.fetch;
window.fetch = function(url, opts) {
opts = opts || {};
const method = (opts.method || 'GET').toUpperCase();
if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') {
opts.headers = opts.headers || {};
// Don't overwrite if user already set it
if (!opts.headers['X-CSRF-Token'] && !opts.headers['x-csrf-token']) {
opts.headers['X-CSRF-Token'] = token;
}
}
return origFetch(url, opts);
};
})();
// Session idle warning: warn user 60s before session expires
(function() {
const idleSec = {{ session_idle_to|int }};
if (!idleSec || idleSec < 120) return;
let lastActivity = Date.now();
['click','keydown','mousemove','scroll','touchstart'].forEach(ev =>
document.addEventListener(ev, () => { lastActivity = Date.now(); }, { passive: true })
);
const warn = document.getElementById('session-warn');
const secEl = document.getElementById('session-warn-sec');
if (!warn) return;
setInterval(() => {
const idle = (Date.now() - lastActivity) / 1000;
const remaining = idleSec - idle;
if (remaining <= 60 && remaining > 0) {
warn.style.display = 'block';
if (secEl) secEl.textContent = Math.ceil(remaining);
} else if (remaining <= 0) {
// hard reload to trigger session timeout redirect
location.reload();
} else {
warn.style.display = 'none';
}
}, 5000);
})();
</script>
</body>
</html>
+97
View File
@@ -0,0 +1,97 @@
{% extends "base.html" %}
{% block title %}修改密码 - Squid Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1>🔒 修改密码</h1>
</div>
<div class="card">
<form method="post" class="form-narrow" id="change-pw-form">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<div class="form-group">
<label>旧密码</label>
<input type="password" name="old_password" required class="form-input"
autocomplete="current-password">
</div>
<div class="form-group">
<label>新密码 <span class="hint">至少 8 位,含大小写+数字</span></label>
<input type="password" name="new_password" id="new_password" required
class="form-input" autocomplete="new-password" minlength="8">
<div class="pw-strength" id="pw-strength">
<div class="pw-strength-bar"><div class="pw-strength-fill" id="pw-fill"></div></div>
<span class="pw-strength-label" id="pw-label"></span>
<span class="pw-strength-hint" id="pw-hint"></span>
</div>
</div>
<div class="form-group">
<label>确认新密码</label>
<input type="password" name="confirm_password" id="confirm_password" required
class="form-input" autocomplete="new-password">
<div class="pw-match" id="pw-match"></div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">💾 修改</button>
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">取消</a>
</div>
</form>
</div>
{% endblock %}
{% block scripts %}
<script>
const newPw = document.getElementById('new_password');
const confirmPw = document.getElementById('confirm_password');
const fill = document.getElementById('pw-fill');
const label = document.getElementById('pw-label');
const hint = document.getElementById('pw-hint');
const matchEl = document.getElementById('pw-match');
const COLORS = ['#ef4444', '#f59e0b', '#eab308', '#10b981', '#059669'];
const LABELS = ['极弱', '弱', '中', '强', '很强'];
function score(pw) {
if (!pw) return 0;
let s = 0;
if (pw.length >= 8) s++;
if (pw.length >= 12) s++;
if (/[A-Z]/.test(pw) && /[a-z]/.test(pw)) s++;
if (/\d/.test(pw)) s++;
if (/[^A-Za-z0-9]/.test(pw)) s++;
if (pw.toLowerCase() === 'admin' || pw.toLowerCase() === 'password' ||
pw.toLowerCase() === '123456' || pw.toLowerCase() === 'squid' ||
pw.toLowerCase() === 'changeme') s = Math.max(0, s - 2);
return Math.min(4, Math.max(0, s));
}
newPw.addEventListener('input', () => {
const s = score(newPw.value);
fill.style.width = ((s + 1) * 20) + '%';
fill.style.background = COLORS[s];
label.textContent = '强度: ' + LABELS[s];
label.style.color = COLORS[s];
if (s < 2) hint.textContent = '建议 8+ 字符,含大小写+数字+特殊字符';
else if (s < 3) hint.textContent = '可加入特殊字符进一步提升';
else hint.textContent = '密码强度良好';
if (newPw.value && confirmPw.value) checkMatch();
});
confirmPw.addEventListener('input', checkMatch);
function checkMatch() {
if (!confirmPw.value) { matchEl.textContent = ''; return; }
if (newPw.value === confirmPw.value) {
matchEl.textContent = '✓ 两次输入一致';
matchEl.style.color = '#10b981';
} else {
matchEl.textContent = '✗ 两次输入不一致';
matchEl.style.color = '#ef4444';
}
}
</script>
<style>
.pw-strength { margin-top: 8px; font-size: 12px; }
.pw-strength-bar { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; margin-bottom: 4px; }
.pw-strength-fill { height: 100%; width: 0; background: #ef4444; transition: width 0.2s, background 0.2s; }
.pw-strength-label { font-weight: 600; }
.pw-strength-hint { color: var(--text-muted); margin-left: 8px; }
.pw-match { font-size: 12px; margin-top: 4px; min-height: 16px; }
</style>
{% endblock %}
+281
View File
@@ -0,0 +1,281 @@
{% extends "base.html" %}
{% block title %}客户端 {{ client }} - Squid Manager{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
{% endblock %}
{% block content %}
<div class="page-header">
<h1>👤 客户端详情: <code>{{ client }}</code></h1>
<div class="page-actions">
<a href="{{ url_for('logs_view', client=client) }}" class="btn btn-small btn-secondary">📋 查看该客户端的原始日志</a>
<a href="{{ url_for('dashboard') }}" class="btn btn-small btn-secondary">← 返回仪表盘</a>
</div>
</div>
{# P2-4: per-client 24h trend. When the client has no rows in the parsed
log, render an empty-state card instead of empty charts so the page
is informative (and so we don't ship a 0-axis Chart.js graph). #}
{% if not has_data %}
<div class="card">
<div class="card-empty">
<div class="empty-icon">🔍</div>
<h2>未找到该客户端的日志</h2>
<p>已加载的日志范围内没有来自 <code>{{ client }}</code> 的请求记录。</p>
<p class="meta-text">可能原因: 客户端拼写错误、尚未产生流量、或日志已被归档。</p>
<p>
<a href="{{ url_for('logs_view') }}" class="btn btn-secondary">去日志查询</a>
<a href="{{ url_for('logs_sync') }}" class="btn btn-primary"
onclick="event.preventDefault(); fetch('{{ url_for('logs_sync') }}', {method:'POST'}).then(r=>location.reload());">
🔄 立即同步日志
</a>
</p>
</div>
</div>
{% else %}
<!-- KPI cards: 总请求 / 总流量 / 首次 / 最后 / 平均请求大小 -->
<div class="kpi-grid">
<div class="kpi-card kpi-blue">
<div class="kpi-label">总请求数</div>
<div class="kpi-value">{{ trend.total_requests | thousands }}</div>
<div class="kpi-meta">该客户端的累计请求</div>
</div>
<div class="kpi-card kpi-purple">
<div class="kpi-label">总流量</div>
<div class="kpi-value">{{ format_bytes(trend.total_bytes) }}</div>
<div class="kpi-meta">原始字节数 {{ trend.total_bytes | thousands }}</div>
</div>
<div class="kpi-card kpi-green">
<div class="kpi-label">平均请求大小</div>
<div class="kpi-value">{{ format_bytes(trend.avg_size_bytes) }}</div>
<div class="kpi-meta">{{ '%.0f' % trend.avg_size_bytes }} 字节/请求</div>
</div>
<div class="kpi-card kpi-orange">
<div class="kpi-label">首次出现</div>
<div class="kpi-value" style="font-size:1.2em;">
{% if trend.time_start %}
{{ trend.time_start.strftime('%m-%d %H:%M') }}
{% else %}
-
{% endif %}
</div>
<div class="kpi-meta">{% if trend.time_start %}{{ trend.time_start.strftime('%Y-%m-%d %H:%M:%S UTC') }}{% else %}-{% endif %}</div>
</div>
<div class="kpi-card kpi-red">
<div class="kpi-label">最后出现</div>
<div class="kpi-value" style="font-size:1.2em;">
{% if trend.time_end %}
{{ trend.time_end.strftime('%m-%d %H:%M') }}
{% else %}
-
{% endif %}
</div>
<div class="kpi-meta">{% if trend.time_end %}{{ trend.time_end.strftime('%Y-%m-%d %H:%M:%S UTC') }}{% else %}-{% endif %}</div>
</div>
<div class="kpi-card kpi-cyan">
<div class="kpi-label">Top 主机数</div>
<div class="kpi-value">{{ trend.top_hosts | length }}</div>
<div class="kpi-meta">访问过的目标主机</div>
</div>
</div>
<!-- 24h hourly trend - dual axis: requests + bytes -->
<div class="card chart-card">
<h3 class="card-title">📈 24h 趋势 (按 UTC 小时聚合)</h3>
<div class="chart-container">
<canvas id="clientHourlyChart" height="100"></canvas>
</div>
<p class="meta-text">
横轴 = UTC 小时 (00-23) · 左轴 = 请求数 · 右轴 = 流量 (MB)。
与仪表盘的 24h 折线结构一致,方便横向对比整体流量。
</p>
</div>
<div class="grid-2">
<!-- Top hosts (click to drill into logs) -->
<div class="card">
<h3 class="card-title">🌍 Top 目标主机 (按请求数)</h3>
{% if trend.top_hosts %}
<table class="data-table">
<thead><tr><th>#</th><th>目标主机</th><th>请求数</th><th>流量</th></tr></thead>
<tbody>
{% for h in trend.top_hosts %}
<tr>
<td>{{ loop.index }}</td>
<td>
<a href="{{ url_for('logs_view', host=h.host, client=client) }}"
title="查看该客户端对该主机的全部日志">{{ h.host }}</a>
</td>
<td>{{ h.count | thousands }}</td>
<td>{{ format_bytes(h.bytes) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="meta-text">无目标主机数据。</p>
{% endif %}
</div>
<!-- Top URLs -->
<div class="card">
<h3 class="card-title">🔗 Top URL (访问最多的资源)</h3>
{% if trend.top_urls %}
<table class="data-table">
<thead><tr><th>#</th><th>URL</th><th>访问次数</th></tr></thead>
<tbody>
{% for u in trend.top_urls %}
<tr>
<td>{{ loop.index }}</td>
<td class="url-cell">
<a href="{{ url_for('logs_view', url=u.url, client=client) }}"
title="{{ u.url }}">{{ u.url }}</a>
</td>
<td>{{ u.count | thousands }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="meta-text">无 URL 数据。</p>
{% endif %}
</div>
</div>
<!-- 最近 50 条请求样本 -->
<div class="card">
<h3 class="card-title">📜 最近 50 条请求样本</h3>
{% if trend.samples %}
<div class="table-scroll">
<table class="data-table log-table">
<thead>
<tr>
<th>时间</th>
<th>结果码</th>
<th>HTTP</th>
<th>方法</th>
<th>大小</th>
<th>耗时</th>
<th>URL</th>
<th>层级</th>
</tr>
</thead>
<tbody>
{% for e in trend.samples %}
<tr>
<td class="time-cell">
{% if e.timestamp %}
{{ e.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}
{% else %}
{{ e.time }}
{% endif %}
</td>
<td>
<span class="badge badge-{% if e.category == 'Hit' %}green{% elif e.category == 'Miss' %}blue{% elif e.category == 'Tunnel' %}cyan{% elif e.category == 'Denied' %}red{% elif e.category == 'Aborted' %}orange{% elif e.category == 'Error' %}red{% else %}gray{% endif %}"
title="{{ e.result_description }}">{{ e.result_code }}</span>
</td>
<td>
<span class="badge badge-{% if e.http_code and e.http_code|first == '2' %}green{% elif e.http_code and e.http_code|first == '3' %}blue{% elif e.http_code and e.http_code|first == '4' %}orange{% elif e.http_code and e.http_code|first == '5' %}red{% else %}gray{% endif %}">{{ e.http_code or '-' }}</span>
</td>
<td>{{ e.method }}</td>
<td>{{ format_bytes(e.size) }}</td>
<td>{{ format_duration(e.elapsed_ms) }}</td>
<td class="url-cell" title="{{ e.url }}">
<a href="{{ url_for('logs_view', url=e.url, client=client) }}">{{ e.url }}</a>
</td>
<td><code>{{ e.hier_code }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="meta-text">无样本数据。</p>
{% endif %}
</div>
<!-- Action bar: 封禁该 IP (WIP - 待 P3-2 实现) -->
<div class="card">
<h3 class="card-title">🚫 操作</h3>
<button type="button" class="btn btn-secondary" disabled
title="WIP: 待实现 P3-2"
style="opacity:0.55; cursor:not-allowed;">
🚫 封禁该 IP (WIP: 待实现 P3-2)
</button>
<span class="meta-text" style="margin-left:12px;">
封禁功能将由 P3-2 提供:写入 squid ACL + 重载配置 + 持久化黑名单。
</span>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
{# Drive the 24h hourly chart. Same dual-axis pattern as the dashboard
so an operator can mentally diff a single client's curve against the
global 24h envelope without learning a new visualisation. #}
<script>
const HOURLY = {{ trend.hourly | tojson }};
window.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById('clientHourlyChart');
if (!el || !HOURLY || !HOURLY.length) return;
const ctx = el.getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: HOURLY.map(h => String(h.hour).padStart(2,'0') + ':00'),
datasets: [
{
label: '请求数',
data: HOURLY.map(h => h.requests),
borderColor: '#3b82f6',
backgroundColor: 'rgba(59,130,246,0.1)',
fill: true,
yAxisID: 'y',
tension: 0.3
},
{
label: '流量 (MB)',
data: HOURLY.map(h => h.bytes / (1024*1024)),
borderColor: '#10b981',
backgroundColor: 'rgba(16,185,129,0.1)',
fill: true,
yAxisID: 'y1',
tension: 0.3
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { labels: { color: '#cbd5e1' } },
title: {
display: true,
text: '客户端 {{ client }} · 24h 请求/流量分布',
color: '#cbd5e1'
}
},
scales: {
x: { ticks: { color: '#94a3b8' }, grid: { color: '#1e293b' } },
y: {
type: 'linear', position: 'left',
ticks: { color: '#3b82f6' },
grid: { color: '#1e293b' },
title: { display: true, text: '请求数', color: '#3b82f6' }
},
y1: {
type: 'linear', position: 'right',
ticks: { color: '#10b981' },
grid: { display: false },
title: { display: true, text: '流量 MB', color: '#10b981' }
}
}
}
});
});
</script>
{% endblock %}
+119
View File
@@ -0,0 +1,119 @@
{% extends "base.html" %}
{% block title %}ACL 规则表 - Squid Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1>🚦 ACL 规则表</h1>
<div class="page-actions">
<button onclick="document.getElementById('add-form').style.display='block'; this.scrollIntoView()" class="btn btn-primary btn-small"> 添加 ACL</button>
</div>
</div>
<div class="card" id="add-form" style="display:none;">
<h3 class="card-title">添加新 ACL</h3>
<form method="post" class="inline-form">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="add">
<div class="filter-grid">
<div class="form-group">
<label>名称 <span class="hint">*</span></label>
<input type="text" name="name" class="form-input" required placeholder="my_network">
</div>
<div class="form-group">
<label>类型 <span class="hint">*</span></label>
<select name="type" class="form-input" required>
{% for code, desc in acl_types %}
<option value="{{ code }}">{{ code }} - {{ desc }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="grid-column: span 2;">
<label><span class="hint">(空格分隔多个值)</span></label>
<input type="text" name="values" class="form-input" required placeholder="192.168.1.0/24">
</div>
<div class="form-group" style="grid-column: span 2;">
<label>备注</label>
<input type="text" name="comment" class="form-input" placeholder="可选">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">添加</button>
<button type="button" onclick="document.getElementById('add-form').style.display='none'" class="btn btn-secondary">取消</button>
</div>
</form>
</div>
<div class="card">
<form method="post">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="save">
<p class="card-desc">共 {{ acls | length }} 条 ACL。修改后点击底部"保存"按钮。</p>
<div class="table-scroll">
<table class="data-table acl-table">
<thead>
<tr>
<th style="width:60px;">#</th>
<th>名称</th>
<th>类型</th>
<th></th>
<th>备注</th>
<th style="width:80px;">删除</th>
</tr>
</thead>
<tbody>
{% for acl in acls %}
<tr>
<td>{{ loop.index }}
<input type="hidden" name="id[]" value="{{ loop.index }}">
</td>
<td><input type="text" name="name[]" value="{{ acl.name }}" class="form-input"></td>
<td>
<select name="type[]" class="form-input">
{% for code, desc in acl_types %}
<option value="{{ code }}" {% if acl.type == code %}selected{% endif %}>{{ code }}</option>
{% endfor %}
</select>
</td>
<td><input type="text" name="values[]" value="{{ acl.values | join(' ') }}" class="form-input"></td>
<td><input type="text" name="comment[]" value="{{ acl.comment }}" class="form-input"></td>
<td>
<button type="button" class="btn btn-danger btn-tiny" onclick="this.closest('tr').remove()">删除</button>
</td>
</tr>
{% endfor %}
{% if not acls %}
<tr><td colspan="6" class="empty-row">暂无 ACL。点击上方"添加 ACL"按钮开始。</td></tr>
{% endif %}
</tbody>
</table>
</div>
{% if acls %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">💾 保存所有 ACL</button>
</div>
{% endif %}
</form>
</div>
<div class="card">
<h3 class="card-title">📖 ACL 类型参考</h3>
<div class="table-scroll">
<table class="data-table">
<thead><tr><th>类型</th><th>说明</th><th>示例</th></tr></thead>
<tbody>
<tr><td><code>src</code></td><td>源 IP / CIDR</td><td>192.168.1.0/24</td></tr>
<tr><td><code>dst</code></td><td>目的 IP / CIDR</td><td>10.0.0.0/8</td></tr>
<tr><td><code>dstdomain</code></td><td>目的域名 (含子域)</td><td>.example.com</td></tr>
<tr><td><code>dstdom_regex</code></td><td>目的域名正则</td><td>^www\.</td></tr>
<tr><td><code>port</code></td><td>目的端口</td><td>80 443</td></tr>
<tr><td><code>method</code></td><td>HTTP 方法</td><td>GET POST</td></tr>
<tr><td><code>proto</code></td><td>协议</td><td>http https ftp</td></tr>
<tr><td><code>time</code></td><td>时间段 (周/时:分)</td><td>MTWHF 09:00-18:00</td></tr>
<tr><td><code>url_regex</code></td><td>URL 正则</td><td>\.mp3$</td></tr>
<tr><td><code>proxy_auth</code></td><td>认证用户</td><td>REQUIRED 或 user1 user2</td></tr>
<tr><td><code>maxconn</code></td><td>每客户端最大连接</td><td>16</td></tr>
<tr><td><code>browser</code></td><td>User-Agent 正则</td><td>Mozilla</td></tr>
</tbody>
</table>
</div>
</div>
{% endblock %}
+121
View File
@@ -0,0 +1,121 @@
{% extends "base.html" %}
{% block title %}认证配置 - Squid Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1>🔑 认证配置 (auth_param)</h1>
<div class="page-actions">
<button onclick="document.getElementById('add-form').style.display='block'" class="btn btn-primary btn-small"> 添加认证方案</button>
</div>
</div>
<div class="card" id="add-form" style="display:none;">
<h3 class="card-title">添加认证方案</h3>
<form method="post">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="add">
<div class="filter-grid">
<div class="form-group">
<label>方案 (scheme)</label>
<select name="scheme" class="form-input">
{% for s in ['basic','digest','ntlm','negotiate'] %}
<option value="{{ s }}">{{ s }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="grid-column: span 2;">
<label>认证程序路径</label>
<input type="text" name="program" class="form-input" required
value="/usr/lib/squid/basic_ncsa_auth /etc/squid/passwd">
</div>
<div class="form-group">
<label>realm 提示</label>
<input type="text" name="realm" class="form-input" value="Squid Proxy">
</div>
<div class="form-group">
<label>children 进程数</label>
<input type="number" name="children" class="form-input" value="5">
</div>
<div class="form-group">
<label>credentialsttl (小时)</label>
<input type="number" name="credentialsttl" class="form-input" value="2">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">添加</button>
<button type="button" onclick="document.getElementById('add-form').style.display='none'" class="btn btn-secondary">取消</button>
</div>
</form>
</div>
<div class="card">
<form method="post">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="save">
<p class="card-desc">共 {{ auth_params | length }} 个认证方案。</p>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>方案</th>
<th>程序</th>
<th>realm</th>
<th>children</th>
<th>ttl</th>
<th>删除</th>
</tr>
</thead>
<tbody>
{% for ap in auth_params %}
<tr>
<td>{{ loop.index }}
<input type="hidden" name="id[]" value="{{ loop.index }}">
</td>
<td>
<select name="scheme[]" class="form-input">
{% for s in ['basic','digest','ntlm','negotiate'] %}
<option value="{{ s }}" {% if ap.scheme == s %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</td>
<td><input type="text" name="program[]" value="{{ ap.program }}" class="form-input"></td>
<td><input type="text" name="realm[]" value="{{ ap.realm }}" class="form-input"></td>
<td><input type="number" name="children[]" value="{{ ap.children }}" class="form-input"></td>
<td><input type="number" name="credentialsttl[]" value="{{ ap.credentialsttl }}" class="form-input"></td>
<td><button type="button" class="btn btn-danger btn-tiny" onclick="this.closest('tr').remove()">删除</button></td>
</tr>
{% endfor %}
{% if not auth_params %}
<tr><td colspan="7" class="empty-row">暂无认证方案。点击上方"添加"按钮。</td></tr>
{% endif %}
</tbody>
</table>
</div>
{% if auth_params %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">💾 保存</button>
</div>
{% endif %}
</form>
</div>
<div class="card">
<h3 class="card-title">📖 常用认证程序</h3>
<table class="data-table">
<thead><tr><th>方案</th><th>用途</th><th>示例程序</th></tr></thead>
<tbody>
<tr><td><code>basic</code></td><td>基础认证 (用户名/密码明文)</td><td><code>/usr/lib/squid/basic_ncsa_auth /etc/squid/passwd</code></td></tr>
<tr><td><code>basic</code></td><td>LDAP 认证</td><td><code>/usr/lib/squid/basic_ldap_auth -b "ou=users,dc=example,dc=com" ldap://ldap.example.com</code></td></tr>
<tr><td><code>basic</code></td><td>PAM 认证</td><td><code>/usr/lib/squid/basic_pam_auth</code></td></tr>
<tr><td><code>basic</code></td><td>Radius</td><td><code>/usr/lib/squid/basic_radius_auth -f /etc/squid/radius_config</code></td></tr>
<tr><td><code>digest</code></td><td>摘要认证 (更安全)</td><td><code>/usr/lib/squid/digest_file_auth -c /etc/squid/digest_passwd</code></td></tr>
<tr><td><code>negotiate</code></td><td>Kerberos / SPNEGO (AD)</td><td><code>/usr/lib/squid/negotiate_kerberos_auth</code></td></tr>
<tr><td><code>ntlm</code></td><td>NTLM (旧版 Windows)</td><td><code>/usr/lib/squid/ntlm_smb_lm_auth</code></td></tr>
</tbody>
</table>
<p class="meta-text">⚠️ 添加认证后,还需在 <a href="{{ url_for('config_acls') }}">ACL 规则表</a> 中添加
<code>acl auth_users proxy_auth REQUIRED</code>,并在
<a href="{{ url_for('config_rules') }}">访问控制</a> 中添加
<code>http_access allow auth_users</code></p>
</div>
{% endblock %}
+111
View File
@@ -0,0 +1,111 @@
{% extends "base.html" %}
{% block title %}缓存目录 - Squid Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1>💾 缓存目录配置</h1>
<div class="page-actions">
<button onclick="document.getElementById('add-form').style.display='block'" class="btn btn-primary btn-small"> 添加 cache_dir</button>
</div>
</div>
<div class="card" id="add-form" style="display:none;">
<h3 class="card-title">添加 cache_dir</h3>
<form method="post">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="add_dir">
<div class="filter-grid">
<div class="form-group">
<label>类型</label>
<select name="type" class="form-input">
{% for t in ['ufs','aufs','diskd','rock'] %}
<option value="{{ t }}">{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>路径 <span class="hint">*</span></label>
<input type="text" name="path" class="form-input" required value="/var/spool/squid">
</div>
<div class="form-group">
<label>大小 (MB)</label>
<input type="number" name="size_mb" class="form-input" value="100">
</div>
<div class="form-group">
<label>L1 (一级目录数)</label>
<input type="number" name="l1" class="form-input" value="16">
</div>
<div class="form-group">
<label>L2 (二级目录数)</label>
<input type="number" name="l2" class="form-input" value="256">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">添加</button>
<button type="button" onclick="document.getElementById('add-form').style.display='none'" class="btn btn-secondary">取消</button>
</div>
</form>
</div>
<div class="card">
<form method="post">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="save">
<p class="card-desc">共 {{ cache_dirs | length }} 个缓存目录。修改路径后需手动创建目录并执行 <code>squid -z</code> 初始化。</p>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>类型</th>
<th>路径</th>
<th>大小 (MB)</th>
<th>L1</th>
<th>L2</th>
<th>删除</th>
</tr>
</thead>
<tbody>
{% for cd in cache_dirs %}
<tr>
<td>{{ loop.index }}
<input type="hidden" name="id[]" value="{{ loop.index }}">
</td>
<td>
<select name="type[]" class="form-input">
{% for t in ['ufs','aufs','diskd','rock'] %}
<option value="{{ t }}" {% if cd.type == t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</td>
<td><input type="text" name="path[]" value="{{ cd.path }}" class="form-input"></td>
<td><input type="number" name="size_mb[]" value="{{ cd.size_mb }}" class="form-input"></td>
<td><input type="number" name="l1[]" value="{{ cd.l1 }}" class="form-input"></td>
<td><input type="number" name="l2[]" value="{{ cd.l2 }}" class="form-input"></td>
<td><button type="button" class="btn btn-danger btn-tiny" onclick="this.closest('tr').remove()">删除</button></td>
</tr>
{% endfor %}
{% if not cache_dirs %}
<tr><td colspan="7" class="empty-row">暂无 cache_dir。点击上方添加。</td></tr>
{% endif %}
</tbody>
</table>
</div>
{% if cache_dirs %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">💾 保存</button>
</div>
{% endif %}
</form>
</div>
<div class="card">
<h3 class="card-title">📖 cache_dir 类型说明</h3>
<ul>
<li><code>ufs</code>: 默认同步写入</li>
<li><code>aufs</code>: 异步线程写入 (推荐生产环境)</li>
<li><code>diskd</code>: 独立进程写入</li>
<li><code>rock</code>: 共享内存缓存 (适合多 worker)</li>
</ul>
<p class="meta-text">缓存大小、内存策略等参数在 <a href="{{ url_for('config_main') }}">主配置</a> 页面设置。</p>
</div>
{% endblock %}
+215
View File
@@ -0,0 +1,215 @@
{% extends "base.html" %}
{% block title %}主配置 - Squid Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1>⚙️ 主配置</h1>
<div class="page-actions">
<span class="meta-text">配置文件: <code>{{ squid_conf_path }}</code></span>
</div>
</div>
<div class="card">
<form method="post">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<div class="config-section">
<h3 class="config-section-title">🌐 网络 (Network)</h3>
<div class="filter-grid">
<div class="form-group">
<label>http_port <span class="hint">HTTP 代理监听端口</span></label>
<input type="text" name="http_port" value="{{ simple.http_port }}" class="form-input" placeholder="3128">
</div>
<div class="form-group">
<label>https_port <span class="hint">HTTPS 监听 (用于 SSL Bump)</span></label>
<input type="text" name="https_port" value="{{ simple.https_port }}" class="form-input" placeholder="留空则不启用">
</div>
<div class="form-group">
<label>icp_port <span class="hint">ICP 协议端口 (邻居间)</span></label>
<input type="text" name="icp_port" value="{{ simple.icp_port }}" class="form-input" placeholder="3130">
</div>
<div class="form-group">
<label>snmp_port <span class="hint">SNMP 监控端口</span></label>
<input type="text" name="snmp_port" value="{{ simple.snmp_port }}" class="form-input" placeholder="留空则不启用">
</div>
<div class="form-group">
<label>visible_hostname <span class="hint">对外显示的主机名</span></label>
<input type="text" name="visible_hostname" value="{{ simple.visible_hostname }}" class="form-input" placeholder="proxy.example.com">
</div>
<div class="form-group">
<label>unique_hostname <span class="hint">多实例区分 (可选)</span></label>
<input type="text" name="unique_hostname" value="{{ simple.unique_hostname }}" class="form-input">
</div>
</div>
</div>
<div class="config-section">
<h3 class="config-section-title">💾 缓存 (Cache)</h3>
<div class="filter-grid">
<div class="form-group">
<label>cache_mem <span class="hint">内存缓存大小</span></label>
<input type="text" name="cache_mem" value="{{ simple.cache_mem }}" class="form-input" placeholder="256 MB">
</div>
<div class="form-group">
<label>maximum_object_size <span class="hint">最大对象大小</span></label>
<input type="text" name="maximum_object_size" value="{{ simple.maximum_object_size }}" class="form-input" placeholder="4096 KB">
</div>
<div class="form-group">
<label>minimum_object_size</label>
<input type="text" name="minimum_object_size" value="{{ simple.minimum_object_size }}" class="form-input" placeholder="0 KB">
</div>
<div class="form-group">
<label>maximum_object_size_in_memory</label>
<input type="text" name="maximum_object_size_in_memory" value="{{ simple.maximum_object_size_in_memory }}" class="form-input" placeholder="512 KB">
</div>
<div class="form-group">
<label>cache_replacement_policy</label>
<select name="cache_replacement_policy" class="form-input">
{% for p in ['lru','heap GDSF','heap LFUDA','heap LRU'] %}
<option value="{{ p }}" {% if simple.cache_replacement_policy == p %}selected{% endif %}>{{ p }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>memory_replacement_policy</label>
<select name="memory_replacement_policy" class="form-input">
{% for p in ['lru','heap GDSF','heap LFUDA','heap LRU'] %}
<option value="{{ p }}" {% if simple.memory_replacement_policy == p %}selected{% endif %}>{{ p }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>cache_swap_low <span class="hint">%</span></label>
<input type="number" name="cache_swap_low" value="{{ simple.cache_swap_low }}" class="form-input" placeholder="90">
</div>
<div class="form-group">
<label>cache_swap_high <span class="hint">%</span></label>
<input type="number" name="cache_swap_high" value="{{ simple.cache_swap_high }}" class="form-input" placeholder="95">
</div>
</div>
<p class="meta-text">磁盘缓存目录 (cache_dir) 在 <a href="{{ url_for('config_cache') }}">缓存目录</a> 页面配置</p>
</div>
<div class="config-section">
<h3 class="config-section-title">📝 日志 (Logging)</h3>
<div class="filter-grid">
<div class="form-group">
<label>access_log <span class="hint">访问日志路径</span></label>
<input type="text" name="access_log" value="{{ simple.access_log }}" class="form-input" placeholder="/var/log/squid/access.log">
</div>
<div class="form-group">
<label>cache_log <span class="hint">缓存日志</span></label>
<input type="text" name="cache_log" value="{{ simple.cache_log }}" class="form-input" placeholder="/var/log/squid/cache.log">
</div>
<div class="form-group">
<label>cache_store_log</label>
<input type="text" name="cache_store_log" value="{{ simple.cache_store_log }}" class="form-input" placeholder="/var/log/squid/store.log 或留空">
</div>
<div class="form-group">
<label>pid_filename</label>
<input type="text" name="pid_filename" value="{{ simple.pid_filename }}" class="form-input" placeholder="/var/run/squid.pid">
</div>
<div class="form-group">
<label>logformat <span class="hint">自定义格式</span></label>
<input type="text" name="logformat" value="{{ simple.logformat }}" class="form-input" placeholder="留空使用默认">
</div>
<div class="form-group">
<label>debug_options <span class="hint">ALL,1 / section,level</span></label>
<input type="text" name="debug_options" value="{{ simple.debug_options }}" class="form-input" placeholder="ALL,1">
</div>
<div class="form-group">
<label>log_ip_on_direct</label>
<select name="log_ip_on_direct" class="form-input">
<option value="on" {% if simple.log_ip_on_direct == 'on' %}selected{% endif %}>on</option>
<option value="off" {% if simple.log_ip_on_direct == 'off' %}selected{% endif %}>off</option>
</select>
</div>
<div class="form-group">
<label>buffered_logs</label>
<select name="buffered_logs" class="form-input">
<option value="on" {% if simple.buffered_logs == 'on' %}selected{% endif %}>on</option>
<option value="off" {% if simple.buffered_logs == 'off' %}selected{% endif %}>off</option>
</select>
</div>
</div>
</div>
<div class="config-section">
<h3 class="config-section-title">🌍 DNS</h3>
<div class="filter-grid">
<div class="form-group">
<label>dns_nameservers <span class="hint">DNS 服务器 (空格分隔)</span></label>
<input type="text" name="dns_nameservers" value="{{ simple.dns_nameservers }}" class="form-input" placeholder="8.8.8.8 1.1.1.1">
</div>
<div class="form-group">
<label>dns_timeout</label>
<input type="text" name="dns_timeout" value="{{ simple.dns_timeout }}" class="form-input" placeholder="30 seconds">
</div>
<div class="form-group">
<label>hosts_file</label>
<input type="text" name="hosts_file" value="{{ simple.hosts_file }}" class="form-input" placeholder="/etc/hosts">
</div>
<div class="form-group">
<label>dns_defnames</label>
<select name="dns_defnames" class="form-input">
<option value="off" {% if simple.dns_defnames == 'off' %}selected{% endif %}>off</option>
<option value="on" {% if simple.dns_defnames == 'on' %}selected{% endif %}>on</option>
</select>
</div>
</div>
</div>
<div class="config-section">
<h3 class="config-section-title">⏱️ 超时 (Timeouts)</h3>
<div class="filter-grid">
<div class="form-group">
<label>connect_timeout</label>
<input type="text" name="connect_timeout" value="{{ simple.connect_timeout }}" class="form-input" placeholder="60 seconds">
</div>
<div class="form-group">
<label>read_timeout</label>
<input type="text" name="read_timeout" value="{{ simple.read_timeout }}" class="form-input" placeholder="15 minutes">
</div>
<div class="form-group">
<label>client_lifetime</label>
<input type="text" name="client_lifetime" value="{{ simple.client_lifetime }}" class="form-input" placeholder="1 day">
</div>
<div class="form-group">
<label>shutdown_lifetime</label>
<input type="text" name="shutdown_lifetime" value="{{ simple.shutdown_lifetime }}" class="form-input" placeholder="30 seconds">
</div>
</div>
</div>
<div class="config-section">
<h3 class="config-section-title">🛠️ 管理 (Admin)</h3>
<div class="filter-grid">
<div class="form-group">
<label>cache_mgr <span class="hint">管理员邮箱</span></label>
<input type="text" name="cache_mgr" value="{{ simple.cache_mgr }}" class="form-input" placeholder="root">
</div>
<div class="form-group">
<label>err_html_language</label>
<input type="text" name="err_html_language" value="{{ simple.err_html_language }}" class="form-input" placeholder="en">
</div>
<div class="form-group">
<label>ftp_user</label>
<input type="text" name="ftp_user" value="{{ simple.ftp_user }}" class="form-input" placeholder="Squid@">
</div>
</div>
</div>
<div class="config-section">
<h3 class="config-section-title">📝 原始附加指令</h3>
<p class="meta-text">此处填写本页未涵盖的额外指令 (每行一条,会原样写入 squid.conf 末尾)。</p>
<div class="form-group">
<textarea name="raw_extra" rows="5" class="form-input monospace" placeholder="# 例如 forward_timeout 4 minutes
# maximum_single_addr_connections 16"></textarea>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">💾 保存并校验</button>
<a href="{{ url_for('config_raw') }}" class="btn btn-secondary">📝 原始编辑</a>
</div>
</form>
</div>
{% endblock %}
+111
View File
@@ -0,0 +1,111 @@
{% extends "base.html" %}
{% block title %}路径设置 - Squid Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1>🗂️ 应用路径设置</h1>
</div>
<div class="card">
<p class="card-desc">配置本平台如何与 Squid 交互。这些设置仅保存在数据库,不影响 squid.conf。</p>
<form method="post">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<div class="config-section">
<h3 class="config-section-title">🦑 Squid 路径</h3>
<div class="filter-grid">
<div class="form-group">
<label>Squid 配置文件路径</label>
<input type="text" name="squid_conf_path" value="{{ cfg.squid_conf_path }}" class="form-input">
</div>
<div class="form-group">
<label>Squid 二进制</label>
<input type="text" name="squid_binary" value="{{ cfg.squid_binary }}" class="form-input">
</div>
<div class="form-group">
<label>systemctl 路径</label>
<input type="text" name="systemctl" value="{{ cfg.systemctl }}" class="form-input">
</div>
<div class="form-group">
<label>Systemd 服务名</label>
<input type="text" name="squid_service" value="{{ cfg.squid_service }}" class="form-input">
</div>
</div>
</div>
<div class="config-section">
<h3 class="config-section-title">📄 日志路径</h3>
<div class="filter-grid">
<div class="form-group">
<label>access.log 路径</label>
<input type="text" name="access_log_path" value="{{ cfg.access_log_path }}" class="form-input">
</div>
<div class="form-group">
<label>cache.log 路径</label>
<input type="text" name="cache_log_path" value="{{ cfg.cache_log_path }}" class="form-input">
</div>
</div>
</div>
<div class="config-section">
<h3 class="config-section-title">⚙️ 日志分析设置</h3>
<div class="filter-grid">
<div class="form-group">
<label>解析行数上限 <span class="hint">从 access.log 末尾读取的最大行数</span></label>
<input type="number" name="log_parse_limit" value="{{ cfg.log_parse_limit }}" class="form-input">
</div>
<div class="form-group">
<label>缓存秒数 <span class="hint">解析结果缓存 TTL</span></label>
<input type="number" name="log_cache_seconds" value="{{ cfg.log_cache_seconds }}" class="form-input">
</div>
<div class="form-group">
<label>实时日志显示行数</label>
<input type="number" name="live_tail_lines" value="{{ cfg.live_tail_lines }}" class="form-input">
</div>
<div class="form-group">
<label>自动刷新间隔 (秒)</label>
<input type="number" name="auto_refresh_seconds" value="{{ cfg.auto_refresh_seconds }}" class="form-input">
</div>
</div>
</div>
<div class="config-section">
<h3 class="config-section-title">🗄️ 日志存储方式</h3>
<p class="card-desc">P2-1 持久化模式会把 access.log 写入 SQLite,保留历史并支持时间范围查询;文件直读模式保持旧行为。</p>
<div class="radio-group">
<label class="radio-option">
<input type="radio" name="log_use_persistent" value="1"
{% if cfg.log_use_persistent != '0' %}checked{% endif %}>
<span class="radio-title">✅ SQLite 持久化</span>
<span class="radio-desc">日志行写入数据库,可分页/时间范围查询。同步通过 /logs/sync 触发,或设置下面自动同步间隔。</span>
</label>
<label class="radio-option">
<input type="radio" name="log_use_persistent" value="0"
{% if cfg.log_use_persistent == '0' %}checked{% endif %}>
<span class="radio-title">📂 文件直读(旧模式)</span>
<span class="radio-desc">仅读 access.log 末尾的指定行数。不保留历史,刷新即丢。</span>
</label>
</div>
<div class="filter-grid">
<div class="form-group">
<label>自动同步间隔 (秒) <span class="hint">仅在持久化模式下生效;0 表示仅手动同步</span></label>
<input type="number" name="log_sync_interval" value="{{ cfg.log_sync_interval }}" class="form-input" min="0">
</div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">💾 保存</button>
</div>
</form>
</div>
<div class="card">
<h3 class="card-title">️ 说明</h3>
<ul>
<li>所有路径必须在本平台进程可访问的位置 (同一台主机或挂载的卷)</li>
<li>如果 squid 安装在远程主机,可考虑通过 SSHFS 挂载配置和日志目录</li>
<li>服务控制 (start/stop/reload) 通过本地 systemctl 执行 - 仅当 squid 与本平台同机时有效</li>
<li>降低"解析行数上限"可加快仪表盘加载速度,但会丢失早期数据</li>
</ul>
</div>
{% endblock %}
+115
View File
@@ -0,0 +1,115 @@
{% extends "base.html" %}
{% block title %}缓存对等 - Squid Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1>🌐 缓存对等 (cache_peer)</h1>
<div class="page-actions">
<button onclick="document.getElementById('add-form').style.display='block'" class="btn btn-primary btn-small"> 添加 Peer</button>
</div>
</div>
<div class="card" id="add-form" style="display:none;">
<h3 class="card-title">添加 cache_peer</h3>
<form method="post">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="add">
<div class="filter-grid">
<div class="form-group">
<label>主机 <span class="hint">*</span></label>
<input type="text" name="host" class="form-input" required placeholder="proxy2.example.com">
</div>
<div class="form-group">
<label>类型</label>
<select name="type" class="form-input">
{% for t in ['parent','sibling','multicast'] %}
<option value="{{ t }}">{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>HTTP 端口</label>
<input type="number" name="http_port" class="form-input" value="3128">
</div>
<div class="form-group">
<label>ICP 端口 <span class="hint">0=禁用</span></label>
<input type="number" name="icp_port" class="form-input" value="0">
</div>
<div class="form-group" style="grid-column: span 2;">
<label>额外选项</label>
<input type="text" name="options" class="form-input" placeholder="proxy-only no-query weight=10">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">添加</button>
<button type="button" onclick="document.getElementById('add-form').style.display='none'" class="btn btn-secondary">取消</button>
</div>
</form>
</div>
<div class="card">
<form method="post">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="save">
<p class="card-desc">共 {{ peers | length }} 个对等节点。</p>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>主机</th>
<th>类型</th>
<th>HTTP 端口</th>
<th>ICP 端口</th>
<th>选项</th>
<th>删除</th>
</tr>
</thead>
<tbody>
{% for p in peers %}
<tr>
<td>{{ loop.index }}
<input type="hidden" name="id[]" value="{{ loop.index }}">
</td>
<td><input type="text" name="host[]" value="{{ p.host }}" class="form-input"></td>
<td>
<select name="type[]" class="form-input">
{% for t in ['parent','sibling','multicast'] %}
<option value="{{ t }}" {% if p.type == t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</td>
<td><input type="number" name="http_port[]" value="{{ p.http_port }}" class="form-input"></td>
<td><input type="number" name="icp_port[]" value="{{ p.icp_port }}" class="form-input"></td>
<td><input type="text" name="options[]" value="{{ p.options | join(' ') }}" class="form-input"></td>
<td><button type="button" class="btn btn-danger btn-tiny" onclick="this.closest('tr').remove()">删除</button></td>
</tr>
{% endfor %}
{% if not peers %}
<tr><td colspan="7" class="empty-row">暂无对等节点</td></tr>
{% endif %}
</tbody>
</table>
</div>
{% if peers %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">💾 保存</button>
</div>
{% endif %}
</form>
</div>
<div class="card">
<h3 class="card-title">📖 cache_peer 类型与选项</h3>
<ul>
<li><strong>parent</strong>: 父代理 - 找不到缓存时转发到此节点</li>
<li><strong>sibling</strong>: 兄弟节点 - 仅查询缓存,不转发请求</li>
<li><strong>multicast</strong>: 多播组</li>
</ul>
<p class="meta-text">常用选项: <code>proxy-only</code> (不缓存到本地)、
<code>no-query</code> (不发送 ICP 查询)、
<code>weight=N</code> (权重)、
<code>login=user:pass</code> (代理认证)、
<code>ssl</code> (SSL 连接)、
<code>round-robin</code> (轮询)</p>
</div>
{% endblock %}
+55
View File
@@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block title %}配置预览 - Squid Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1>🔍 配置变更预览</h1>
<div class="page-actions">
<a href="{{ return_to }}" class="btn btn-secondary">← 取消</a>
</div>
</div>
<div class="card">
<h3 class="card-title">📊 变更摘要</h3>
<div class="diff-summary">
<span class="diff-stat-add">+ 新增 {{ summary.adds }} 行</span>
<span class="diff-stat-del">- 删除 {{ summary.dels }} 行</span>
<span>共 {{ summary.total_changed }} 行变更</span>
{% if summary.total_changed == 0 %}
<span class="badge badge-blue">无变更</span>
{% endif %}
</div>
{% if summary.total_changed > 0 %}
<div class="diff-warning">
⚠️ 仔细检查以下变更,确认无误后再点击 <strong>应用配置</strong>
配置写入后会自动备份当前文件到 <code>backups/</code>
</div>
{% endif %}
{{ diff_html | safe }}
{% if summary.total_changed > 0 %}
<form method="post" action="{{ url_for('config_confirm') }}" style="margin-top:20px;">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="pending" value="{{ pending_b64 }}">
<input type="hidden" name="return_to" value="{{ return_to }}">
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="validate" value="1" checked>
写入前运行 <code>squid -k parse</code> 校验
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary"
onclick="return confirm('确认应用此配置?\\n\\n会自动备份当前配置。')">
✅ 应用配置
</button>
<a href="{{ return_to }}" class="btn btn-secondary">取消</a>
</div>
</form>
{% else %}
<p class="meta-text" style="margin-top:16px;">配置无变化,无需保存。</p>
<a href="{{ return_to }}" class="btn btn-secondary">返回</a>
{% endif %}
</div>
{% endblock %}
+72
View File
@@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block title %}原始编辑 - Squid Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1>📝 原始配置编辑</h1>
<div class="page-actions">
<span class="meta-text">配置文件: <code>{{ squid_conf_path }}</code></span>
</div>
</div>
{% if error %}
<div class="flash flash-error">⚠️ {{ error }}</div>
{% endif %}
<div class="card">
<p class="card-desc">直接编辑 squid.conf 原始内容。保存时可选启用语法校验。
所有结构化编辑 (主配置、ACL、规则等) 在保存后会重新生成完整配置文件。</p>
<form method="post">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<div class="form-group">
<textarea name="content" rows="35" class="form-input monospace raw-editor">{{ content }}</textarea>
</div>
<div class="form-actions">
<label class="checkbox-label">
<input type="checkbox" name="validate" checked> 保存前校验 (<code>squid -k parse</code>)
</label>
<button type="submit" class="btn btn-primary">💾 保存</button>
<button type="button" onclick="validateOnly()" class="btn btn-secondary">🔍 仅校验 (不保存)</button>
<a href="{{ url_for('backups_view') }}" class="btn btn-secondary">📦 查看备份</a>
</div>
</form>
<div id="validate-result" class="svc-result" style="display:none;"></div>
</div>
<div class="card">
<h3 class="card-title">⚠️ 注意事项</h3>
<ul>
<li>保存前会自动备份当前配置到 <code>backups/</code> 目录</li>
<li>校验需要本机安装 squid 二进制 (否则仅做结构解析)</li>
<li>保存成功后,需要 <a href="{{ url_for('service_view') }}">重载服务</a> 才能生效</li>
<li>如果使用结构化编辑页面,原始编辑的修改可能被覆盖</li>
</ul>
</div>
{% endblock %}
{% block scripts %}
<script>
function validateOnly() {
const content = document.querySelector('textarea[name="content"]').value;
const box = document.getElementById('validate-result');
box.style.display = 'block';
box.className = 'svc-result svc-loading';
box.textContent = '校验中...';
fetch('/api/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'content=' + encodeURIComponent(content)
})
.then(r => r.json())
.then(d => {
box.className = 'svc-result ' + (d.ok ? 'svc-ok' : 'svc-err');
box.innerHTML = '<strong>' + (d.ok ? '✅ 校验通过' : '❌ 校验失败') + '</strong><pre>' + (d.message || '') + '</pre>';
})
.catch(e => {
box.className = 'svc-result svc-err';
box.textContent = '请求失败: ' + e;
});
}
</script>
{% endblock %}
+107
View File
@@ -0,0 +1,107 @@
{% extends "base.html" %}
{% block title %}刷新规则 - Squid Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1>♻️ Refresh Pattern 刷新规则</h1>
<div class="page-actions">
<button onclick="document.getElementById('add-form').style.display='block'" class="btn btn-primary btn-small"> 添加规则</button>
</div>
</div>
<div class="card" id="add-form" style="display:none;">
<h3 class="card-title">添加 refresh_pattern</h3>
<form method="post">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="add">
<div class="filter-grid">
<div class="form-group">
<label>正则</label>
<input type="text" name="regex" class="form-input" required value="." >
</div>
<div class="form-group">
<label>忽略大小写 (-i)</label>
<select name="case_insensitive" class="form-input">
<option value="off">off</option>
<option value="on">on</option>
</select>
</div>
<div class="form-group">
<label>min (分钟)</label>
<input type="number" name="min_minutes" class="form-input" value="0">
</div>
<div class="form-group">
<label>percent (%)</label>
<input type="number" name="percent" class="form-input" value="20">
</div>
<div class="form-group">
<label>max (分钟)</label>
<input type="number" name="max_minutes" class="form-input" value="4320">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">添加</button>
<button type="button" onclick="document.getElementById('add-form').style.display='none'" class="btn btn-secondary">取消</button>
</div>
</form>
</div>
<div class="card">
<form method="post">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="save">
<p class="card-desc">refresh_pattern regex min percent max - 控制对象过期判断</p>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>-i</th>
<th>正则</th>
<th>min (分钟)</th>
<th>percent</th>
<th>max (分钟)</th>
<th>删除</th>
</tr>
</thead>
<tbody>
{% for rp in patterns %}
<tr>
<td>{{ loop.index }}
<input type="hidden" name="id[]" value="{{ loop.index }}">
<input type="hidden" name="case_insensitive_marker[]" value="{{ loop.index0 }}">
</td>
<td>
<input type="checkbox" name="case_insensitive[]" value="{{ loop.index0 }}" {% if rp.case_insensitive %}checked{% endif %}>
</td>
<td><input type="text" name="regex[]" value="{{ rp.regex }}" class="form-input"></td>
<td><input type="number" name="min_minutes[]" value="{{ rp.min_minutes }}" class="form-input"></td>
<td><input type="number" name="percent[]" value="{{ rp.percent }}" class="form-input"></td>
<td><input type="number" name="max_minutes[]" value="{{ rp.max_minutes }}" class="form-input"></td>
<td><button type="button" class="btn btn-danger btn-tiny" onclick="this.closest('tr').remove()">删除</button></td>
</tr>
{% endfor %}
{% if not patterns %}
<tr><td colspan="7" class="empty-row">暂无刷新规则</td></tr>
{% endif %}
</tbody>
</table>
</div>
{% if patterns %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">💾 保存</button>
</div>
{% endif %}
</form>
</div>
<div class="card">
<h3 class="card-title">📖 参数说明</h3>
<ul>
<li><strong>regex</strong>: 匹配 URL 的正则表达式</li>
<li><strong>min</strong>: 最少缓存时间 (分钟)。即使 origin 返回更新也至少缓存这么久</li>
<li><strong>percent</strong>: 对象年龄百分比。如果 (当前时间 - 修改时间) × percent% &gt; 已缓存时间,则视为过期</li>
<li><strong>max</strong>: 最大缓存时间 (分钟)。无论 origin 怎么说,超过此时间视为过期</li>
<li><strong>-i</strong>: 忽略大小写</li>
</ul>
</div>
{% endblock %}
+122
View File
@@ -0,0 +1,122 @@
{% extends "base.html" %}
{% block title %}访问控制 - Squid Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1>🔐 访问控制规则</h1>
<div class="page-actions">
<button onclick="document.getElementById('add-form').style.display='block'" class="btn btn-primary btn-small"> 添加规则</button>
</div>
</div>
<div class="card" id="add-form" style="display:none;">
<h3 class="card-title">添加新规则</h3>
<form method="post" class="inline-form">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="add">
<div class="filter-grid">
<div class="form-group">
<label>指令</label>
<select name="directive" class="form-input">
{% for d in rule_directives %}
<option value="{{ d }}">{{ d }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>动作</label>
<select name="action_type" class="form-input">
<option value="allow">allow</option>
<option value="deny">deny</option>
</select>
</div>
<div class="form-group" style="grid-column: span 2;">
<label>ACL 列表 <span class="hint">(空格分隔,! 表示取反,如 !localhost my_net)</span></label>
<input type="text" name="acls" class="form-input" placeholder="!localhost allowed_net">
</div>
<div class="form-group" style="grid-column: span 2;">
<label>备注</label>
<input type="text" name="comment" class="form-input">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">添加</button>
<button type="button" onclick="document.getElementById('add-form').style.display='none'" class="btn btn-secondary">取消</button>
</div>
</form>
</div>
<div class="card">
<p class="card-desc">访问规则按顺序从上到下匹配,命中即停止。共 {{ rules | length }} 条规则。</p>
<p class="meta-text">可用的 ACL:
{% for name in acls_by_name.keys() %}
<code>{{ name }}</code>{% if not loop.last %} · {% endif %}
{% endfor %}
</p>
<form method="post">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="save">
<div class="table-scroll">
<table class="data-table rules-table">
<thead>
<tr>
<th style="width:60px;">顺序</th>
<th style="width:160px;">指令</th>
<th style="width:100px;">动作</th>
<th>ACL</th>
<th>备注</th>
<th style="width:80px;">删除</th>
</tr>
</thead>
<tbody>
{% for r in rules %}
<tr>
<td>{{ loop.index }}
<input type="hidden" name="id[]" value="{{ loop.index }}">
</td>
<td>
<select name="directive[]" class="form-input">
{% for d in rule_directives %}
<option value="{{ d }}" {% if r.directive == d %}selected{% endif %}>{{ d }}</option>
{% endfor %}
</select>
</td>
<td>
<select name="action_type[]" class="form-input">
<option value="allow" {% if r.action == 'allow' %}selected{% endif %}>allow</option>
<option value="deny" {% if r.action == 'deny' %}selected{% endif %}>deny</option>
</select>
</td>
<td><input type="text" name="acls[]" value="{{ r.acls | join(' ') }}" class="form-input"></td>
<td><input type="text" name="comment[]" value="{{ r.comment }}" class="form-input"></td>
<td>
<button type="button" class="btn btn-danger btn-tiny" onclick="this.closest('tr').remove()">删除</button>
</td>
</tr>
{% endfor %}
{% if not rules %}
<tr><td colspan="6" class="empty-row">暂无规则。点击上方"添加规则"按钮开始。</td></tr>
{% endif %}
</tbody>
</table>
</div>
{% if rules %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">💾 保存所有规则</button>
</div>
{% endif %}
</form>
</div>
<div class="card">
<h3 class="card-title">📖 规则说明</h3>
<ul>
<li><code>http_access</code>: 控制 HTTP 请求是否允许通过代理</li>
<li><code>https_access</code>: 控制 CONNECT 隧道 (HTTPS) 请求</li>
<li><code>icp_access</code>: 控制 ICP 查询 (邻居间)</li>
<li><code>snmp_access</code>: 控制 SNMP 监控访问</li>
<li><code>always_direct</code>: 强制直接访问 (不走 peer)</li>
<li><code>never_direct</code>: 强制走父代理</li>
<li>ACL 列表支持 <code>!</code> 取反,多个 ACL 之间为 AND 关系 (全部满足才匹配)</li>
</ul>
</div>
{% endblock %}
+206
View File
@@ -0,0 +1,206 @@
{% extends "base.html" %}
{% block title %}SSL Bump - Squid Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1>🔐 SSL Bump (HTTPS 透明代理)</h1>
<div class="page-actions">
<button onclick="document.getElementById('add-form').style.display='block'" class="btn btn-primary btn-small"> 添加规则</button>
<button onclick="document.getElementById('cert-form').style.display='block'" class="btn btn-secondary btn-small">🪪 生成自签 CA</button>
</div>
</div>
<div class="card" style="border-left: 4px solid #d33; background: #fff8f8;">
<h3 class="card-title" style="color:#c0392b;">⚠️ 风险与合规提示</h3>
<ul>
<li><b>中间人攻击:</b> 启用 <code>bump</code> 后代理会解密所有 HTTPS 流量,浏览器看到的是代理签名的证书而非真实证书。</li>
<li><b>私钥泄露风险:</b> 必须在受信环境运行;一旦 CA 私钥泄露,所有用它签名的中间证书都可以伪造。</li>
<li><b>用户信任:</b> 客户端必须安装并信任你生成的 CA;否则会看到证书错误。</li>
<li><b>审计/合规:</b> 许多司法管辖区要求告知用户并获取同意;请提前与法务/合规团队沟通。</li>
<li><b>替代方案:</b> 如果只是需要域名过滤,优先用 <code>splice</code> (不解密) + SNI 嗅探;只有需要看 payload 时再用 <code>bump</code></li>
</ul>
</div>
<div class="card">
<h3 class="card-title">📋 默认规则示例</h3>
<pre class="code-block"><code>{{ default_example|e }}</code></pre>
<p class="meta-text">规则按顺序匹配,首条命中即停止。建议先用 <code>splice</code> 放通全部,排查没问题后再加 <code>peek</code>/<code>bump</code></p>
</div>
<div class="card" id="add-form" style="display:none;">
<h3 class="card-title">添加新 SSL Bump 规则</h3>
<form method="post" class="inline-form">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="add">
<div class="filter-grid">
<div class="form-group">
<label>动作 (action)</label>
<select name="action_type" class="form-input">
{% for a in actions %}
<option value="{{ a }}" {% if a == 'peek' %}selected{% endif %}>{{ a }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="grid-column: span 2;">
<label>ACL 列表 <span class="hint">(空格分隔,支持 <code>!aclname</code> 取反,如 <code>!localhost trusted_net</code>)</span></label>
<input type="text" name="acls" class="form-input" placeholder="step1 all">
</div>
<div class="form-group" style="grid-column: span 3;">
<label>备注</label>
<input type="text" name="comment" class="form-input" placeholder="示例: 缺省放行">
</div>
</div>
<details class="hint" style="margin-top: 0.5em;">
<summary>📚 动作含义</summary>
<ul style="margin-top: 0.5em;">
<li><code>peek</code> - 在 SslBump1 解密 client hello,只读 SNI/ALPN,不解后续</li>
<li><code>splice</code> - TCP 直通隧道,不解密 (性能最好,几乎无开销)</li>
<li><code>bump</code> - 完整解密,代理签发证书,可看明文 (最慢/最重)</li>
<li><code>terminate</code> - 主动关闭连接 (用于阻断)</li>
<li><code>client-first</code> / <code>server-first</code> - 控制 TLS 握手方向</li>
<li><code>none</code> - 显式禁用 (用于在内层 step 关闭默认行为)</li>
</ul>
</details>
<div class="form-actions">
<button type="submit" class="btn btn-primary">添加</button>
<button type="button" onclick="document.getElementById('add-form').style.display='none'" class="btn btn-secondary">取消</button>
</div>
</form>
</div>
<div class="card">
<p class="card-desc">当前共 <b>{{ rules | length }}</b> 条 SSL Bump 规则。按表从上到下顺序匹配,首条命中即终止。</p>
<p class="meta-text">可用 ACL:
{% for name in acls_by_name.keys() %}
<code>{{ name }}</code>{% if not loop.last %} · {% endif %}
{% else %}
<em>暂无 ACL,请先在「ACL 规则表」页面创建。</em>
{% endfor %}
</p>
<form method="post">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="save">
<div class="table-scroll">
<table class="data-table rules-table">
<thead>
<tr>
<th style="width:60px;">#</th>
<th style="width:160px;">动作</th>
<th>ACL</th>
<th>备注</th>
<th style="width:80px;">操作</th>
</tr>
</thead>
<tbody>
{% for r in rules %}
<tr>
<td>{{ loop.index }}
<input type="hidden" name="id[]" value="{{ loop.index }}">
</td>
<td>
<select name="action_type[]" class="form-input">
{% for a in actions %}
<option value="{{ a }}" {% if r.action == a %}selected{% endif %}>{{ a }}</option>
{% endfor %}
</select>
</td>
<td><input type="text" name="acls[]" value="{{ r.acls | join(' ') }}" class="form-input" placeholder="!localhost trusted_net"></td>
<td><input type="text" name="comment[]" value="{{ r.comment }}" class="form-input"></td>
<td>
<button type="button" class="btn btn-danger btn-tiny" onclick="this.closest('tr').remove()">删除</button>
</td>
</tr>
{% endfor %}
{% if not rules %}
<tr><td colspan="5" class="empty-row">暂无规则。点击"添加规则"开始,或先在下方配置 sslcrtd / sslproxy_ca_* 再添加 peek/splice 规则。</td></tr>
{% endif %}
</tbody>
</table>
</div>
{% if rules %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">💾 保存所有规则与 SSL 设置</button>
</div>
{% endif %}
</form>
</div>
<div class="card">
<h3 class="card-title">🗄️ SSL 数据库与 CA 证书设置</h3>
<p class="meta-text">
这些是让 SSL Bump 真正起作用的底层配置。
<b>sslcrtd_program</b> + <b>sslcrtd_children</b> 控制 on-the-fly 自签证书生成器;
<b>sslproxy_ca_certfile</b> + <b>sslproxy_ca_keyfile</b> 指向你用作 CA 的证书与私钥。
</p>
<form method="post">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="save">
<div class="filter-grid">
<div class="form-group" style="grid-column: span 3;">
<label>sslcrtd_program <span class="hint">(完整命令行: 可执行文件 + 参数,空格分隔)</span></label>
<input type="text" name="sslcrtd_program" class="form-input"
value="{{ simple.sslcrtd_program }}"
placeholder="security_file_certgen -s /var/lib/squid/ssl_db -M 4 MB">
</div>
<div class="form-group" style="grid-column: span 3;">
<label>sslcrtd_children <span class="hint">(空格分隔,如 <code>5 startup=1 idle=1</code>)</span></label>
<input type="text" name="sslcrtd_children" class="form-input"
value="{{ simple.sslcrtd_children }}"
placeholder="5 startup=1 idle=1">
</div>
<div class="form-group" style="grid-column: span 3;">
<label>sslproxy_ca_certfile <span class="hint">(CA 证书路径,绝对路径)</span></label>
<input type="text" name="sslproxy_ca_certfile" class="form-input"
value="{{ simple.sslproxy_ca_certfile }}"
placeholder="/etc/squid/bump_ca.crt">
</div>
<div class="form-group" style="grid-column: span 3;">
<label>sslproxy_ca_keyfile <span class="hint">(CA 私钥路径,绝对路径)</span></label>
<input type="text" name="sslproxy_ca_keyfile" class="form-input"
value="{{ simple.sslproxy_ca_keyfile }}"
placeholder="/etc/squid/bump_ca.key">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">💾 保存 SSL 数据库与 CA 设置</button>
</div>
</form>
</div>
<div class="card" id="cert-form" style="display:none;">
<h3 class="card-title">🪪 生成 SSL Bump 自签 CA (便利工具)</h3>
<p class="meta-text">
一键生成自签 CA 证书到 <code>ssl_certs/</code>。生成后请将证书安装到所有需要被 SSL Bump 的客户端的"受信任的根证书颁发机构"。
<b>注意:</b> 不要复用生产 CA 私钥。
</p>
<form method="post">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="generate-cert">
<div class="filter-grid">
<div class="form-group">
<label>Common Name (CN)</label>
<input type="text" name="common_name" class="form-input" value="Squid Bump CA" placeholder="Squid Bump CA">
</div>
<div class="form-group">
<label>有效期 (天)</label>
<input type="number" name="days" class="form-input" value="3650" min="1" max="36500">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">🪪 生成</button>
<button type="button" onclick="document.getElementById('cert-form').style.display='none'" class="btn btn-secondary">取消</button>
</div>
</form>
</div>
<div class="card">
<h3 class="card-title">📖 参考</h3>
<ul>
<li><code>peek</code>: 解密 client hello 拿到 SNI/ALPN,然后视后续 step 再决定;不让代理完整解密流量。</li>
<li><code>splice</code>: 直接把 TCP 流透传给 origin,不解密;性能最佳,适合大批流量。</li>
<li><code>bump</code>: 完整解密 TLS,代理用 sslproxy_ca_certfile 签发 leaf cert;</li>
<li><code>terminate</code>: 客户端 TCP 被代理关闭 (用于拒绝)。</li>
<li>ACL 列表里用 <code>!</code> 取反,多个 ACL 之间为 AND (全部满足)。</li>
<li>常见配套: <code>acl step1 at_step SslBump1</code> + <code>ssl_bump peek step1</code> 然后 <code>ssl_bump splice all</code></li>
</ul>
</div>
{% endblock %}
+161
View File
@@ -0,0 +1,161 @@
{% extends "base.html" %}
{% block title %}TLS 证书 - Squid Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1>🔐 TLS 证书管理</h1>
<div class="page-actions">
<span class="meta-text">共 {{ certs | length }} 个证书 · 目录 <code>{{ cert_dir }}</code></span>
</div>
</div>
<div class="card">
<p class="card-desc">
HTTPS 反代需要 TLS 证书。这里可以一键生成<strong>自签名证书</strong>用于快速测试,
也可以上传由 Let's Encrypt / 商业 CA 签发的正式证书(<code>.pem</code> / <code>.crt</code> + <code>.key</code>)。
<br>
<strong>⚠️ 生产环境强烈建议</strong>: 使用 <code>nginx</code> + <code>Let's Encrypt</code> 反向代理并强制 HTTPS,
而不是把本 Web 平台直接暴露在公网。
</p>
</div>
<div class="card">
<h3 class="card-title">🪄 生成自签名证书</h3>
<p class="card-desc">立即可用的测试证书,有效期可选 (1 ~ 3650 天)。建议仅用于内网 / 临时调试。</p>
<form method="post" action="{{ url_for('config_tls_generate') }}">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<div class="filter-grid">
<div class="form-group">
<label>Common Name (CN) <span class="hint">主机名 / 域名, 例如 proxy.example.com</span></label>
<input type="text" name="common_name" required minlength="1" maxlength="253"
placeholder="proxy.example.com" class="form-input">
</div>
<div class="form-group">
<label>有效期 (天) <span class="hint">默认 365, 最大 3650</span></label>
<input type="number" name="days" value="365" min="1" max="3650" class="form-input">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">🔧 生成自签名证书</button>
</div>
</form>
</div>
<div class="card">
<h3 class="card-title">⬆ 上传证书</h3>
<p class="card-desc">
支持 <code>.pem</code> / <code>.crt</code> / <code>.key</code>。单个文件上限 <strong>1 MB</strong>
上传后请在表中核对<strong>主题 (CN)</strong><strong>到期时间</strong>
</p>
<form method="post" action="{{ url_for('config_tls_upload') }}"
enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<div class="form-group">
<label>证书 / 私钥文件 (.pem / .crt / .key)</label>
<input type="file" name="certfile" required accept=".pem,.crt,.key" class="form-input">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">📤 上传</button>
</div>
</form>
</div>
<div class="card">
<h3 class="card-title">📜 已有证书</h3>
{% if certs %}
<table class="data-table">
<thead>
<tr>
<th>文件名</th>
<th>类型</th>
<th>主题 (CN)</th>
<th>到期</th>
<th>状态</th>
<th>SHA-256 指纹</th>
<th>大小</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for c in certs %}
<tr>
<td><code>{{ c.name }}</code></td>
<td>
{% if c.is_key %}
<span class="badge badge-blue">KEY</span>
{% elif c.ext == '.pem' %}
<span class="badge badge-purple">PEM</span>
{% else %}
<span class="badge badge-cyan">CRT</span>
{% endif %}
</td>
<td>
{% if c.parsed_ok and c.cn %}
{{ c.cn }}
{% elif c.parsed_ok %}
<span class="meta-text"></span>
{% else %}
<span class="badge badge-gray">{{ c.parse_error[:30] }}</span>
{% endif %}
</td>
<td>
{% if c.parsed_ok and c.not_after %}
<span title="{{ c.expires_iso }}">{{ c.not_after }}</span>
{% else %}
<span class="meta-text"></span>
{% endif %}
</td>
<td>
{% if c.parsed_ok %}
{% if c.status == 'valid' %}
<span class="badge badge-green">有效 ({{ c.days_remaining }} 天)</span>
{% elif c.status == 'expiring' %}
<span class="badge badge-yellow">即将过期 ({{ c.days_remaining }} 天)</span>
{% elif c.status == 'expired' %}
<span class="badge badge-red">已过期</span>
{% else %}
<span class="badge badge-gray">未知</span>
{% endif %}
{% else %}
<span class="badge badge-gray">未识别</span>
{% endif %}
</td>
<td>
{% if c.sha256 %}
<code class="meta-text" title="{{ c.sha256 }}">{{ c.sha256[:16] }}…</code>
{% else %}
<span class="meta-text"></span>
{% endif %}
</td>
<td>{{ c.size }} B</td>
<td>
<form method="post" action="{{ url_for('config_tls_delete') }}"
style="display:inline;"
onsubmit="return confirm('确认删除证书 {{ c.name }}? 此操作不可恢复。');">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="name" value="{{ c.name }}">
<button type="submit" class="btn btn-small btn-warning">🗑 删除</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card-empty">
<div class="empty-icon">🔐</div>
<p>暂无证书。先点击上方「生成自签名证书」或上传一个 .pem / .crt 文件。</p>
</div>
{% endif %}
</div>
<div class="card">
<h3 class="card-title">️ 使用提示</h3>
<ul>
<li>所有证书都存储在 <code>{{ cert_dir }}/</code>,文件权限建议 <code>600</code></li>
<li>自签名证书浏览器会提示<strong>不受信任</strong>,仅适合内网 / 临时测试</li>
<li>推荐的正式方案见 README 中的 <strong>TLS / HTTPS 部署指南</strong></li>
<li>删除私钥 (<code>.key</code>) 后,对应的证书将无法继续用于 HTTPS</li>
<li>上传的 .pem 同时包含证书和私钥时,<strong>请确保只有 root 才能访问本目录</strong></li>
</ul>
</div>
{% endblock %}
+387
View File
@@ -0,0 +1,387 @@
{% extends "base.html" %}
{% block title %}仪表盘 - Squid Manager{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
{% endblock %}
{% block content %}
<div class="page-header">
<h1>📊 日志分析仪表盘</h1>
<div class="page-actions">
<span class="meta-text">
{% if stats.time_start and stats.time_end %}
范围: {{ stats.time_start.strftime('%Y-%m-%d %H:%M:%S UTC') }} ~
{{ stats.time_end.strftime('%Y-%m-%d %H:%M:%S UTC') }}
{% else %}
暂无日志数据
{% endif %}
</span>
<span class="meta-text">共解析 {{ entries_count }} 条</span>
<button onclick="refreshLogs()" class="btn btn-small btn-secondary">🔄 刷新</button>
</div>
</div>
{% if active_alerts %}
<div class="card" style="border-left: 4px solid #ef4444;">
<h3 class="card-title">🚨 当前活跃告警 ({{ active_alerts | length }})</h3>
<ul class="active-alerts">
{% for a in active_alerts %}
<li>
<span class="badge badge-{% if a.severity == 'critical' %}red{% elif a.severity == 'warning' %}yellow{% else %}blue{% endif %}">{{ a.severity }}</span>
{{ a.message }}
<a href="{{ url_for('alerts_view') }}">管理规则</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if entries_count == 0 %}
<div class="card">
<div class="card-empty">
<div class="empty-icon">📭</div>
<h2>暂无日志数据</h2>
<p>请检查访问日志路径是否正确,或
<a href="{{ url_for('logs_import') }}">手动导入日志</a>进行分析。</p>
<p class="meta-text">当前路径: <code>{{ cfg.access_log_path }}</code></p>
<p class="meta-text">如需修改,请前往 <a href="{{ url_for('config_paths') }}">路径设置</a></p>
</div>
</div>
{% else %}
<!-- KPI cards -->
<div class="kpi-grid">
<div class="kpi-card kpi-blue">
<div class="kpi-label">总请求数</div>
<div class="kpi-value">{{ stats.total_requests | thousands }}</div>
<div class="kpi-meta">{{ stats.unique_clients }} 个客户端 · {{ stats.unique_hosts }} 个目标主机</div>
</div>
<div class="kpi-card kpi-green">
<div class="kpi-label">缓存命中率</div>
<div class="kpi-value">{{ '%.1f' % (stats.hit_ratio * 100) }}%</div>
<div class="kpi-meta">命中 {{ stats.hits }} · 未命中 {{ stats.misses }}</div>
</div>
<div class="kpi-card kpi-purple">
<div class="kpi-label">总流量</div>
<div class="kpi-value">{{ format_bytes(stats.total_bytes) }}</div>
<div class="kpi-meta">平均 {{ format_bytes(stats.avg_size_bytes) }}/请求</div>
</div>
<div class="kpi-card kpi-orange">
<div class="kpi-label">平均响应时间</div>
<div class="kpi-value">{{ format_duration(stats.avg_elapsed_ms) }}</div>
<div class="kpi-meta">总耗时 {{ format_duration(stats.total_elapsed_ms) }}</div>
</div>
<div class="kpi-card kpi-red">
<div class="kpi-label">拒绝率</div>
<div class="kpi-value">{{ '%.2f' % (stats.denied_rate * 100) }}%</div>
<div class="kpi-meta">{{ stats.denied }} 条被 ACL 拒绝</div>
</div>
<div class="kpi-card kpi-yellow">
<div class="kpi-label">中断率</div>
<div class="kpi-value">{{ '%.2f' % (stats.aborted_rate * 100) }}%</div>
<div class="kpi-meta">{{ stats.aborted }} 条客户端中断</div>
</div>
<div class="kpi-card kpi-cyan">
<div class="kpi-label">4xx 错误率</div>
<div class="kpi-value">{{ '%.2f' % (stats.errors_4xx_rate * 100) }}%</div>
<div class="kpi-meta">{{ stats.errors_4xx_count }} 条</div>
</div>
<div class="kpi-card kpi-pink">
<div class="kpi-label">5xx 错误率</div>
<div class="kpi-value">{{ '%.2f' % (stats.errors_5xx_rate * 100) }}%</div>
<div class="kpi-meta">{{ stats.errors_5xx_count }} 条</div>
</div>
</div>
<!-- Hourly traffic chart -->
<div class="card chart-card">
<h3 class="card-title">📈 每小时请求量与流量</h3>
<div class="chart-container">
<canvas id="hourlyChart" height="100"></canvas>
</div>
</div>
<div class="grid-2">
<!-- Result code distribution -->
<div class="card chart-card">
<h3 class="card-title">🏷️ Squid 结果码分布</h3>
<div class="chart-container">
<canvas id="resultChart" height="200"></canvas>
</div>
<details class="legend-details">
<summary>结果码含义</summary>
<table class="legend-table">
<thead><tr><th>代码</th><th>含义</th></tr></thead>
<tbody>
{% for code, desc in stats.result_code_legend.items() %}
<tr><td><code>{{ code }}</code></td><td>{{ desc }}</td></tr>
{% endfor %}
</tbody>
</table>
</details>
</div>
<!-- HTTP status distribution -->
<div class="card chart-card">
<h3 class="card-title">🌐 HTTP 状态码分布</h3>
<div class="chart-container">
<canvas id="httpChart" height="200"></canvas>
</div>
{% if stats.by_http %}
<table class="data-table small">
<thead><tr><th>状态码</th><th>次数</th><th>占比</th></tr></thead>
<tbody>
{% for code, n in stats.by_http[:10] %}
<tr>
<td><span class="badge badge-{% if code|first == '2' %}green{% elif code|first == '3' %}blue{% elif code|first == '4' %}orange{% elif code|first == '5' %}red{% else %}gray{% endif %}">{{ code }}</span></td>
<td>{{ n | thousands }}</td>
<td>{{ '%.1f' % (n / stats.total_requests * 100) }}%</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
<div class="grid-2">
<!-- Method distribution -->
<div class="card chart-card">
<h3 class="card-title">🚀 HTTP 方法分布</h3>
<div class="chart-container">
<canvas id="methodChart" height="200"></canvas>
</div>
</div>
<!-- Category distribution -->
<div class="card chart-card">
<h3 class="card-title">📂 请求分类</h3>
<div class="chart-container">
<canvas id="categoryChart" height="200"></canvas>
</div>
<p class="meta-text">Hit=缓存命中, Miss=未命中, Tunnel=CONNECT隧道,
Denied=被拒, Aborted=中断, Error=错误</p>
</div>
</div>
<div class="grid-2">
<!-- Top clients -->
<div class="card">
<h3 class="card-title">👥 Top 客户端 (按请求数)</h3>
<table class="data-table">
<thead><tr><th>#</th><th>客户端 IP</th><th>请求数</th><th>流量</th><th>占比</th></tr></thead>
<tbody>
{% for c in stats.top_clients[:20] %}
<tr>
<td>{{ loop.index }}</td>
<td><a href="{{ url_for('client_detail', ip=c.client) }}">{{ c.client }}</a></td>
<td>{{ c.count | thousands }}</td>
<td>{{ format_bytes(c.bytes) }}</td>
<td>{{ '%.1f' % (c.count / stats.total_requests * 100) }}%</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Top hosts -->
<div class="card">
<h3 class="card-title">🌍 Top 目标主机 (按请求数)</h3>
<table class="data-table">
<thead><tr><th>#</th><th>目标主机</th><th>请求数</th><th>流量</th><th>占比</th></tr></thead>
<tbody>
{% for h in stats.top_hosts[:20] %}
<tr>
<td>{{ loop.index }}</td>
<td><a href="{{ url_for('logs_view', host=h.host) }}">{{ h.host }}</a></td>
<td>{{ h.count | thousands }}</td>
<td>{{ format_bytes(h.bytes) }}</td>
<td>{{ '%.1f' % (h.count / stats.total_requests * 100) }}%</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="grid-2">
<!-- Hierarchy distribution -->
<div class="card">
<h3 class="card-title">🔗 层级代码分布 (Hierarchy)</h3>
<table class="data-table">
<thead><tr><th>层级代码</th><th>次数</th><th>含义</th></tr></thead>
<tbody>
{% for hier, n in stats.by_hierarchy[:15] %}
<tr>
<td><code>{{ hier }}</code></td>
<td>{{ n | thousands }}</td>
<td>{{ stats.hierarchy_legend.get(hier, '-') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Content type -->
<div class="card">
<h3 class="card-title">📦 Content-Type 分布</h3>
<table class="data-table">
<thead><tr><th>Content-Type</th><th>次数</th><th>占比</th></tr></thead>
<tbody>
{% for ct, n in stats.by_ctype[:15] %}
<tr>
<td><code>{{ ct }}</code></td>
<td>{{ n | thousands }}</td>
<td>{{ '%.1f' % (n / stats.total_requests * 100) }}%</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Top URLs -->
<div class="card">
<h3 class="card-title">🔗 Top URL (访问最多的资源)</h3>
<table class="data-table">
<thead><tr><th>#</th><th>URL</th><th>访问次数</th></tr></thead>
<tbody>
{% for u in stats.top_urls[:20] %}
<tr>
<td>{{ loop.index }}</td>
<td class="url-cell"><a href="{{ url_for('logs_view', url=u.url) }}" title="{{ u.url }}">{{ u.url }}</a></td>
<td>{{ u.count | thousands }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
const HOURLY = {{ stats.hourly | tojson }};
const BY_RESULT = {{ stats.by_result | tojson }};
const BY_HTTP = {{ stats.by_http | tojson }};
const BY_METHOD = {{ stats.by_method | tojson }};
const BY_CATEGORY = {{ stats.by_category | tojson }};
const COLORS = ['#3b82f6','#ef4444','#10b981','#f59e0b','#8b5cf6','#ec4899','#06b6d4','#84cc16','#f97316','#6366f1','#14b8a6','#a855f7','#eab308','#22c55e','#64748b','#0ea5e9','#d946ef','#f43f5e'];
function makeDoughnut(ctx, data, label) {
const labels = data.map(d => d[0]);
const values = data.map(d => d[1]);
return new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: values,
backgroundColor: COLORS.slice(0, labels.length),
borderWidth: 1,
borderColor: '#1e293b'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'right', labels: { color: '#cbd5e1', font: { size: 11 } } },
tooltip: {
callbacks: {
label: (ctx) => {
const total = ctx.dataset.data.reduce((a,b)=>a+b, 0);
const pct = total ? (ctx.parsed/total*100).toFixed(1) : 0;
return ` ${ctx.label}: ${ctx.parsed} (${pct}%)`;
}
}
}
}
}
});
}
function makeBar(ctx, data, label) {
const labels = data.map(d => d[0]);
const values = data.map(d => d[1]);
return new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: label,
data: values,
backgroundColor: '#3b82f6',
borderColor: '#1e40af',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
plugins: { legend: { display: false }, tooltip: { enabled: true } },
scales: {
x: { ticks: { color: '#94a3b8' }, grid: { color: '#1e293b' } },
y: { ticks: { color: '#cbd5e1', font: { size: 11 } }, grid: { display: false } }
}
}
});
}
window.addEventListener('DOMContentLoaded', () => {
// hourly chart - dual axis (requests + bytes)
const hCtx = document.getElementById('hourlyChart').getContext('2d');
new Chart(hCtx, {
type: 'line',
data: {
labels: HOURLY.map(h => String(h.hour).padStart(2,'0') + ':00'),
datasets: [
{
label: '请求数',
data: HOURLY.map(h => h.requests),
borderColor: '#3b82f6',
backgroundColor: 'rgba(59,130,246,0.1)',
fill: true,
yAxisID: 'y',
tension: 0.3
},
{
label: '流量 (MB)',
data: HOURLY.map(h => h.bytes / (1024*1024)),
borderColor: '#10b981',
backgroundColor: 'rgba(16,185,129,0.1)',
fill: true,
yAxisID: 'y1',
tension: 0.3
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { labels: { color: '#cbd5e1' } } },
scales: {
x: { ticks: { color: '#94a3b8' }, grid: { color: '#1e293b' } },
y: { type: 'linear', position: 'left', ticks: { color: '#3b82f6' }, grid: { color: '#1e293b' }, title: { display: true, text: '请求数', color: '#3b82f6' } },
y1: { type: 'linear', position: 'right', ticks: { color: '#10b981' }, grid: { display: false }, title: { display: true, text: '流量 MB', color: '#10b981' } }
}
}
});
if (BY_RESULT.length) makeDoughnut(document.getElementById('resultChart').getContext('2d'), BY_RESULT, '结果码');
if (BY_HTTP.length) makeBar(document.getElementById('httpChart').getContext('2d'), BY_HTTP, 'HTTP状态');
if (BY_METHOD.length) makeDoughnut(document.getElementById('methodChart').getContext('2d'), BY_METHOD, '方法');
if (BY_CATEGORY.length) makeDoughnut(document.getElementById('categoryChart').getContext('2d'), BY_CATEGORY, '分类');
});
function refreshLogs() {
fetch('{{ url_for("api_logs_refresh") }}', { method: 'POST' })
.then(r => r.json())
.then(d => {
if (d.ok) location.reload();
else alert('刷新失败: ' + (d.message || ''));
});
}
</script>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}错误 {{ code }} - Squid Manager{% endblock %}
{% block content %}
<div class="card">
<div class="card-empty">
<div class="empty-icon" style="font-size:64px;">{{ code }}</div>
<h2>{{ message }}</h2>
<p><a href="{{ url_for('dashboard') }}" class="btn btn-primary">返回仪表盘</a></p>
</div>
</div>
{% endblock %}
+306
View File
@@ -0,0 +1,306 @@
{% extends "base.html" %}
{% block title %}GeoIP 地图 - Squid Manager{% endblock %}
{% block head %}
<!-- Leaflet (open-source map library, CDN, no key) -->
<link rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<style>
/* map must have a fixed height for Leaflet */
#geoip-map { height: 460px; width: 100%; border-radius: 6px; }
.leaflet-popup-content { font-size: 12px; line-height: 1.5; }
.leaflet-popup-content code { font-size: 11px; }
.marker-flag {
display:inline-block;
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
border-radius: 50%;
background: #1e293b;
color: #f1f5f9;
font-weight: 700;
font-size: 11px;
border: 2px solid #f1f5f9;
box-shadow: 0 1px 3px rgba(0,0,0,.5);
}
.geoip-source-inline {
font-family: monospace;
font-size: 12px;
padding: 2px 6px;
background: rgba(255,255,255,.05);
border-radius: 4px;
}
</style>
{% endblock %}
{% block content %}
<div class="page-header">
<h1>🌍 GeoIP 客户端地图</h1>
<div class="page-actions">
<span class="meta-text">
{% if entries_count %}已解析 {{ entries_count }} 条日志{% else %}暂无日志{% endif %}
</span>
<button id="geoip-refresh" class="btn btn-small btn-secondary" type="button">🔄 刷新</button>
</div>
</div>
<!-- Source note -->
<div class="card" style="border-left: 4px solid #3b82f6;">
<h3 class="card-title">📡 数据来源</h3>
<p class="card-desc">
本页地理位置查询使用
<span class="geoip-source-inline">{{ data_source }}</span>
{% if use_online %}
默认走 <a href="https://ip-api.com" target="_blank" rel="noopener">ip-api.com</a> 免费公共 API (45 req/min,无需 key)。
如果你需要更稳定/更高速的查询,可以下载 MaxMind
<a href="https://www.maxmind.com/en/geolite2/signup" target="_blank" rel="noopener">GeoLite2-City</a>
<code>.mmdb</code> 文件并通过 AppConfig(<code>geoip_mmdb</code>) 配置离线路径。
{% else %}
已配置离线数据库 <code>{{ db_path }}</code>,优先使用本地查询,不再发送外网请求。
{% endif %}
</p>
<p class="meta-text">
私有 IP (10.x / 192.168.x / 172.16-31.x / 127.x) 不会发出查询请求,直接在表里标记为「私有 IP」,
不会出现在地图上。本页默认仅展示 Top 20 客户端;需要更广范围的数据请用
<code>/api/geoip/clients</code> (Top 50) 与 <code>/api/geoip/hosts</code> (目标主机) 接口。
</p>
</div>
<!-- KPI -->
<div class="kpi-grid">
<div class="kpi-card kpi-blue">
<div class="kpi-label">客户端总数</div>
<div class="kpi-value">{{ total_clients }}</div>
<div class="kpi-meta">Top {{ total_clients }} 客户端</div>
</div>
<div class="kpi-card kpi-green">
<div class="kpi-label">已解析位置</div>
<div class="kpi-value">{{ resolved_clients }}</div>
<div class="kpi-meta">
{{ '%.0f' % (resolved_clients / total_clients * 100) if total_clients else 0 }}% 命中率
</div>
</div>
<div class="kpi-card kpi-purple">
<div class="kpi-label">国家数</div>
<div class="kpi-value">{{ countries }}</div>
<div class="kpi-meta">按 ISO 国家码统计</div>
</div>
<div class="kpi-card kpi-orange">
<div class="kpi-label">城市数</div>
<div class="kpi-value">{{ cities }}</div>
<div class="kpi-meta">(国家, 城市) 去重</div>
</div>
</div>
<!-- Map -->
<div class="card">
<h3 class="card-title">🗺️ Top 客户端位置</h3>
{% if geo_rows %}
<div id="geoip-map"></div>
<p class="meta-text" style="margin-top:8px;">
地图标签基于 OpenStreetMap 瓦片;点位大小近似与该客户端的请求数成正比。
私有地址不会出现在地图上,可参考下方表格。
</p>
{% else %}
<div class="card-empty">
<div class="empty-icon">🗺️</div>
<h2>没有可显示的客户端位置</h2>
{% if total_clients == 0 %}
<p>当前解析日志中没有客户端 IP。请确认 access.log 路径与权限。</p>
{% else %}
<p>Top {{ total_clients }} 个客户端均为私有地址,或 ip-api.com 暂时不可达。
详见下方的状态表。</p>
{% endif %}
</div>
{% endif %}
</div>
<!-- Detail table -->
<div class="card">
<h3 class="card-title">📋 客户端明细</h3>
{% if rows %}
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>客户端 IP</th>
<th>国家</th>
<th>城市</th>
<th>ISP/ASN</th>
<th>请求数</th>
<th>流量</th>
<th>状态</th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td>{{ loop.index }}</td>
<td><code>{{ r.ip }}</code></td>
<td>
{% if r.country_name %}
{{ r.country_name }}
{% if r.country %}<span class="badge badge-blue">{{ r.country }}</span>{% endif %}
{% else %}
<span class="meta-text">-</span>
{% endif %}
</td>
<td>
{{ r.city or '-' }}
{% if r.region %}<span class="meta-text">{{ r.region }}</span>{% endif %}
</td>
<td class="meta-text">
{{ r.isp or '-' }}
{% if r.asn %}<br><code>{{ r.asn }}</code>{% endif %}
</td>
<td>{{ '{:,}'.format(r.count) }}</td>
<td>{{ format_bytes(r.bytes) }}</td>
<td>
{% if not r.error %}
<span class="badge badge-green">已解析</span>
{% elif r.error == 'private IP' %}
<span class="badge badge-gray">私有 IP</span>
{% else %}
<span class="badge badge-yellow" title="{{ r.error }}">⚠ 查询失败</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card-empty">
<p>暂无客户端数据。</p>
</div>
{% endif %}
</div>
<!-- API endpoints table for reference -->
<div class="card">
<h3 class="card-title">🔌 JSON 接口</h3>
<p class="card-desc">同样数据以 JSON 形式提供,便于脚本与外部仪表盘接入:</p>
<table class="data-table small">
<thead><tr><th>路径</th><th>说明</th><th>返回</th></tr></thead>
<tbody>
<tr>
<td><code>GET /api/geoip/clients</code></td>
<td>Top 50 客户端 IP + GeoIP</td>
<td><code>{ok, source:"clients", backend, rows:[…]}</code></td>
</tr>
<tr>
<td><code>GET /api/geoip/hosts</code></td>
<td>Top 50 目标主机 (先解析为 IP 再查询)</td>
<td><code>{ok, source:"hosts", backend, rows, placed, skipped}</code></td>
</tr>
<tr>
<td><code>GET /geoip</code></td>
<td>本页 (Top 20)</td>
<td>HTML</td>
</tr>
</tbody>
</table>
</div>
{% endblock %}
{% block scripts %}
{% if geo_rows %}
<script>
(function() {
// GeoIP rows are safe-serialized in Jinja via tojson so quotes are escaped.
const ROWS = {{ geo_rows | tojson }};
const ALL = {{ rows | tojson }};
// start at a neutral world view
const map = L.map('geoip-map', {
worldCopyJump: true,
minZoom: 2,
maxZoom: 14,
}).setView([20, 0], 2);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
// pick a marker radius that scales sub-linearly with request count
const counts = ROWS.map(r => r.count || 0);
const minR = 6, maxR = 22;
const cmin = Math.min.apply(null, counts);
const cmax = Math.max.apply(null, counts);
function radiusFor(n) {
if (cmax <= cmin) return (minR + maxR) / 2;
const t = (Math.log10(n + 1) - Math.log10(cmin + 1)) /
(Math.log10(cmax + 1) - Math.log10(cmin + 1));
return minR + t * (maxR - minR);
}
const bounds = [];
ROWS.forEach((r, idx) => {
if (!r.latitude || !r.longitude) return;
const lat = r.latitude, lon = r.longitude;
const rad = radiusFor(r.count);
const flag = (r.country || '??').slice(0, 2);
const icon = L.divIcon({
className: 'geoip-marker',
html: '<div class="marker-flag" title="' + r.ip + '">' + flag + '</div>',
iconSize: [24, 24],
iconAnchor: [12, 12],
});
const m = L.marker([lat, lon], { icon: icon }).addTo(map);
m.bindPopup(
'<strong>' + escapeHtml(r.ip) + '</strong><br>' +
'<span style="color:#94a3b8">#' + (idx + 1) + ' · ' +
escapeHtml(r.country_name || r.country || '-') + ' · ' +
escapeHtml(r.city || '-') + '</span><br>' +
'<table style="margin-top:6px; font-size:11px; color:#cbd5e1;">' +
'<tr><td>请求数</td><td style="text-align:right;"><strong>' +
(r.count || 0).toLocaleString() + '</strong></td></tr>' +
'<tr><td>流量</td><td style="text-align:right;">' +
escapeHtml(formatBytes(r.bytes || 0)) + '</td></tr>' +
(r.isp ? '<tr><td>ISP</td><td style="text-align:right;">' +
escapeHtml(r.isp) + '</td></tr>' : '') +
(r.asn ? '<tr><td>ASN</td><td style="text-align:right;"><code>' +
escapeHtml(r.asn) + '</code></td></tr>' : '') +
'</table>'
);
bounds.push([lat, lon]);
});
if (bounds.length === 1) {
map.setView(bounds[0], 6);
} else if (bounds.length > 1) {
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 6 });
}
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function formatBytes(n) {
if (n < 1024) return n + ' B';
if (n < 1048576) return (n / 1024).toFixed(1) + ' KB';
if (n < 1073741824) return (n / 1048576).toFixed(1) + ' MB';
if (n < 1099511627776) return (n / 1073741824).toFixed(2) + ' GB';
return (n / 1099511627776).toFixed(2) + ' TB';
}
// refresh button — just reload the page (cheap + obvious)
const btn = document.getElementById('geoip-refresh');
if (btn) btn.addEventListener('click', () => location.reload());
})();
</script>
{% else %}
<script>
// No map to render — still wire the refresh button.
document.getElementById('geoip-refresh').addEventListener('click',
() => location.reload());
</script>
{% endif %}
{% endblock %}
+195
View File
@@ -0,0 +1,195 @@
{% extends "base.html" %}
{% block title %}多实例管理 - Squid Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1>🖥️ 多实例管理</h1>
<div class="page-actions">
<button onclick="document.getElementById('add-form').style.display='block'" class="btn btn-primary btn-small"> 添加实例</button>
</div>
</div>
<p class="meta-text">
在数据库中保存多个 Squid 实例。切换"当前活动实例"后,所有配置、日志、服务控制页面都会基于该实例的路径与服务名。<br>
当前活动实例: <span class="badge badge-blue">{{ active_instance_name }}</span>
{% if active %}<span class="meta-text">(ID #{{ active.id }})</span>{% endif %}
</p>
{# -------- add new instance form (hidden by default) -------- #}
<div class="card" id="add-form" style="display:none;">
<h3 class="card-title">添加新实例</h3>
<form method="post" action="{{ url_for('instances_add') }}" class="inline-form">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<div class="filter-grid">
<div class="form-group">
<label>名称 <span class="hint">(唯一)</span></label>
<input type="text" name="name" class="form-input" required maxlength="80" placeholder="例如: prod-edge-01">
</div>
<div class="form-group">
<label>描述</label>
<input type="text" name="description" class="form-input" maxlength="200" placeholder="可选">
</div>
<div class="form-group">
<label>本机实例</label>
<select name="is_local" class="form-input">
<option value="1" selected>是 (本地)</option>
<option value="0">否 (远程 SSH)</option>
</select>
</div>
</div>
<div class="config-section">
<h3 class="config-section-title">🦑 Squid 路径</h3>
<div class="filter-grid">
<div class="form-group">
<label>squid.conf 路径</label>
<input type="text" name="squid_conf_path" class="form-input" value="/etc/squid/squid.conf">
</div>
<div class="form-group">
<label>access.log 路径</label>
<input type="text" name="access_log_path" class="form-input" value="/var/log/squid/access.log">
</div>
<div class="form-group">
<label>cache.log 路径</label>
<input type="text" name="cache_log_path" class="form-input" value="/var/log/squid/cache.log">
</div>
<div class="form-group">
<label>squid 二进制</label>
<input type="text" name="squid_binary" class="form-input" value="squid">
</div>
<div class="form-group">
<label>systemctl</label>
<input type="text" name="systemctl" class="form-input" value="systemctl">
</div>
<div class="form-group">
<label>Systemd 服务名</label>
<input type="text" name="squid_service" class="form-input" value="squid">
</div>
</div>
</div>
<div class="config-section">
<h3 class="config-section-title">🔐 SSH (仅远程实例)</h3>
<div class="filter-grid">
<div class="form-group">
<label>SSH 主机</label>
<input type="text" name="ssh_host" class="form-input" placeholder="例如: 10.0.0.5">
</div>
<div class="form-group">
<label>SSH 用户</label>
<input type="text" name="ssh_user" class="form-input" placeholder="root">
</div>
<div class="form-group">
<label>SSH 端口</label>
<input type="number" name="ssh_port" class="form-input" value="22" min="1" max="65535">
</div>
<div class="form-group">
<label>SSH 私钥路径</label>
<input type="text" name="ssh_key_path" class="form-input" placeholder="/root/.ssh/id_rsa">
</div>
</div>
</div>
<div class="form-actions">
<label style="display:inline-flex; align-items:center; gap:6px; margin-right:auto;">
<input type="checkbox" name="is_active" value="1">
添加后立即激活
</label>
<button type="button" onclick="document.getElementById('add-form').style.display='none'" class="btn btn-secondary btn-small">取消</button>
<button type="submit" class="btn btn-primary btn-small">💾 添加</button>
</div>
</form>
</div>
{# -------- existing instances table -------- #}
<div class="card">
<h3 class="card-title">📋 已配置实例 ({{ instances|length }})</h3>
{% if not instances %}
<p class="card-empty">尚未配置任何实例。点击右上角"添加实例"开始。</p>
{% else %}
<div class="table-scroll">
<form method="post" action="{{ url_for('instances_save') }}">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<table class="data-table">
<thead>
<tr>
<th>名称</th>
<th>描述</th>
<th>主机</th>
<th>配置文件</th>
<th>access.log</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for inst in instances %}
{% set is_current = (active and active.id == inst.id) %}
<tr {% if is_current %}style="background: rgba(59,130,246,0.08);"{% endif %}>
<td>
<input type="text" name="name_{{ inst.id }}" value="{{ inst.name }}" class="form-input" required maxlength="80">
</td>
<td>
<input type="text" name="description_{{ inst.id }}" value="{{ inst.description or '' }}" class="form-input" maxlength="200" placeholder="(无)">
</td>
<td>
{% if inst.is_local %}
<span class="badge badge-blue">本地</span>
{% else %}
<code>{{ inst.ssh_host or '?' }}:{{ inst.ssh_port }}</code>
<span class="meta-text">({{ inst.ssh_user or '?' }})</span>
{% endif %}
</td>
<td><code class="meta-text">{{ inst.squid_conf_path }}</code></td>
<td><code class="meta-text">{{ inst.access_log_path }}</code></td>
<td>
{% if is_current %}
<span class="badge badge-green">★ 当前活动</span>
{% elif inst.is_active %}
<span class="badge badge-yellow">默认</span>
{% else %}
<span class="meta-text"></span>
{% endif %}
</td>
<td style="white-space:nowrap;">
{% if not is_current %}
<form method="post" action="{{ url_for('instances_activate', inst_id=inst.id) }}" style="display:inline;">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ request.path }}">
<button type="submit" class="btn btn-primary btn-small">▶ 激活</button>
</form>
{% endif %}
<a href="{{ url_for('instances_test', inst_id=inst.id) }}" class="btn btn-secondary btn-small" target="_blank">🔍 测试</a>
<form method="post" action="{{ url_for('instances_delete') }}" style="display:inline;" onsubmit="return confirm('确定要删除实例 {{ inst.name }} 吗?');">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="id" value="{{ inst.id }}">
<button type="submit" class="btn btn-danger btn-small">🗑</button>
</form>
{# hidden checkbox used by /instances/save to set the active row #}
{% if not is_current %}
<input type="checkbox" name="is_active_{{ inst.id }}" value="1" style="display:none;">
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="form-actions" style="margin-top: 12px;">
<span class="meta-text">提示: 表格中可编辑名称/描述/路径。修改后点击"保存所有修改"。</span>
<button type="submit" class="btn btn-primary">💾 保存所有修改</button>
</div>
</form>
</div>
{% endif %}
</div>
<div class="card">
<h3 class="card-title">️ 说明</h3>
<ul>
<li>当前活动实例的路径/服务名会覆盖 <code>/config/paths</code> 中的全局设置</li>
<li>切换实例后,所有现有页面 (config/*、logs、service) 自动基于新实例的路径</li>
<li>应用级参数 (日志解析上限、缓存秒数等) 仍然是全局的,不会因实例切换而变化</li>
<li>远程实例的 SSH 凭据当前仅记录,配置读写仍需通过本地文件系统或 SSHFS 挂载</li>
<li>"测试"按钮检查 conf_path 是否可读、access/cache log 是否存在</li>
</ul>
</div>
{% endblock %}
+51
View File
@@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}登录 - Squid Manager{% endblock %}
{% block content %}
<div class="login-wrap">
<div class="login-card">
<div class="login-logo">🦑</div>
<h1>Squid Web Manager</h1>
<p class="login-sub">Squid 代理服务器管理平台</p>
{% if throttle_remaining and throttle_remaining > 0 %}
<div class="login-throttle">
⏳ 登录已锁定,请等待 <strong id="throttle-countdown">{{ throttle_remaining }}</strong>
</div>
{% endif %}
<form method="post" action="{{ url_for('login') }}">
<input type="hidden" name="next" value="{{ next }}">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<div class="form-group">
<label>用户名</label>
<input type="text" name="username" required autofocus
autocomplete="username" placeholder="admin"
class="form-input" {% if throttle_remaining and throttle_remaining > 0 %}disabled{% endif %}>
</div>
<div class="form-group">
<label>密码</label>
<input type="password" name="password" required
autocomplete="current-password" class="form-input"
{% if throttle_remaining and throttle_remaining > 0 %}disabled{% endif %}>
</div>
<button type="submit" class="btn btn-primary btn-block"
{% if throttle_remaining and throttle_remaining > 0 %}disabled{% endif %}>
登录
</button>
</form>
<p class="login-hint">默认账号 admin / admin &middot; 首次登录后请修改密码</p>
</div>
</div>
{% endblock %}
{% block scripts %}
{% if throttle_remaining and throttle_remaining > 0 %}
<script>
let s = {{ throttle_remaining }};
const el = document.getElementById('throttle-countdown');
const t = setInterval(() => {
s--;
if (el) el.textContent = s;
if (s <= 0) { clearInterval(t); location.reload(); }
}, 1000);
</script>
{% endif %}
{% endblock %}
+165
View File
@@ -0,0 +1,165 @@
{% extends "base.html" %}
{% block title %}日志查询 - Squid Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1>📋 日志查询</h1>
<div class="page-actions">
<span class="meta-text">共 {{ total | thousands }} 条匹配</span>
</div>
</div>
<div class="card">
<div class="sync-bar">
<div class="sync-info">
{% if use_persistent %}
{% if last_sync %}
<span class="badge badge-green">📥 已启用持久化</span>
<span class="meta-text">上次同步: {{ last_sync }}{% if last_sync_path %} · 源: <code>{{ last_sync_path }}</code>{% endif %}</span>
{% else %}
<span class="badge badge-orange">⚠️ 尚未同步</span>
<span class="meta-text">点击"立即同步"把当前 access.log 导入到数据库</span>
{% endif %}
{% else %}
<span class="badge badge-gray">📂 文件直读模式</span>
<span class="meta-text">持久化已关闭 (在 <a href="{{ url_for('config_paths') }}">路径设置</a> 打开后可保留历史)</span>
{% endif %}
</div>
<div class="sync-actions">
<form method="post" action="{{ url_for('logs_sync') }}" style="display:inline">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-primary btn-small">🔄 立即同步</button>
</form>
<form method="post" action="{{ url_for('logs_sync') }}?full=1" style="display:inline"
onsubmit="return confirm('确定要从头扫描整个 access.log?耗时取决于文件大小.');">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="full" value="1">
<button type="submit" class="btn btn-secondary btn-small">📥 全量导入历史</button>
</form>
<span class="btn-group" style="display:inline-flex; gap:4px; margin-left:8px;">
{% set export_args = request.args.to_dict() %}
{% set _ = export_args.pop('page', None) %}
<a href="{{ url_for('export_logs_csv', **export_args) }}"
class="btn btn-secondary btn-small" title="导出当前过滤结果为 CSV">⬇️ CSV</a>
<a href="{{ url_for('export_logs_json', **export_args) }}"
class="btn btn-secondary btn-small" title="导出当前过滤结果为 JSON">⬇️ JSON</a>
</span>
</div>
</div>
<form method="get" class="filter-form">
<div class="filter-grid">
<div class="form-group">
<label>客户端 IP</label>
<input type="text" name="client" value="{{ filters.client }}" class="form-input" placeholder="172.16.12.232">
</div>
<div class="form-group">
<label>HTTP 方法</label>
<select name="method" class="form-input">
<option value="">全部</option>
{% for m in ['GET','POST','PUT','DELETE','CONNECT','HEAD','OPTIONS','TRACE'] %}
<option value="{{ m }}" {% if filters.method == m %}selected{% endif %}>{{ m }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>结果码</label>
<input type="text" name="result_code" value="{{ filters.result_code }}" class="form-input" placeholder="TCP_MISS / TCP_TUNNEL / 200 / 404 ...">
</div>
<div class="form-group">
<label>目标主机</label>
<input type="text" name="host" value="{{ filters.host }}" class="form-input" placeholder="chatgpt.com">
</div>
<div class="form-group">
<label>URL 包含</label>
<input type="text" name="url" value="{{ filters.url }}" class="form-input" placeholder="metadata">
</div>
<div class="form-group">
<label>响应大小 (字节)</label>
<div class="range-input">
<input type="number" name="min_size" value="{{ filters.min_size }}" class="form-input" placeholder="最小">
<span>~</span>
<input type="number" name="max_size" value="{{ filters.max_size }}" class="form-input" placeholder="最大">
</div>
</div>
<div class="form-group">
<label>响应时间 (ms)</label>
<div class="range-input">
<input type="number" name="min_elapsed" value="{{ filters.min_elapsed }}" class="form-input" placeholder="最小">
<span>~</span>
<input type="number" name="max_elapsed" value="{{ filters.max_elapsed }}" class="form-input" placeholder="最大">
</div>
</div>
<div class="form-group">
<label>起始时间 <span class="hint">本地时区</span></label>
<input type="datetime-local" name="start_time" value="{{ filters.start_time }}" class="form-input">
</div>
<div class="form-group">
<label>结束时间 <span class="hint">本地时区</span></label>
<input type="datetime-local" name="end_time" value="{{ filters.end_time }}" class="form-input">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">🔍 查询</button>
<a href="{{ url_for('logs_view') }}" class="btn btn-secondary">清空</a>
{% if filters.start_time or filters.end_time %}
<span class="meta-text">⏱ 时间范围: {{ filters.start_time or '最早' }} → {{ filters.end_time or '最新' }}</span>
{% endif %}
</div>
</form>
</div>
<div class="card">
{% if entries %}
<div class="table-scroll">
<table class="data-table log-table">
<thead>
<tr>
<th>时间</th>
<th>客户端</th>
<th>结果码</th>
<th>HTTP</th>
<th>方法</th>
<th>大小</th>
<th>耗时</th>
<th>URL</th>
<th>层级</th>
<th>Content-Type</th>
</tr>
</thead>
<tbody>
{% for e in entries %}
<tr>
<td class="time-cell">{{ e.timestamp.strftime('%Y-%m-%d %H:%M:%S') if e.timestamp else e.time }}</td>
<td><a href="{{ url_for('client_detail', ip=e.client) }}">{{ e.client }}</a></td>
<td>
<span class="badge badge-{% if e.category == 'Hit' %}green{% elif e.category == 'Miss' %}blue{% elif e.category == 'Tunnel' %}cyan{% elif e.category == 'Denied' %}red{% elif e.category == 'Aborted' %}orange{% elif e.category == 'Error' %}red{% else %}gray{% endif %}" title="{{ e.result_description }}">{{ e.result_code }}</span>
</td>
<td><span class="badge badge-{% if e.http_code and e.http_code|first == '2' %}green{% elif e.http_code and e.http_code|first == '3' %}blue{% elif e.http_code and e.http_code|first == '4' %}orange{% elif e.http_code and e.http_code|first == '5' %}red{% else %}gray{% endif %}">{{ e.http_code or '-' }}</span></td>
<td>{{ e.method }}</td>
<td>{{ format_bytes(e.size) }}</td>
<td>{{ format_duration(e.elapsed_ms) }}</td>
<td class="url-cell" title="{{ e.url }}"><a href="{{ url_for('logs_view', url=e.url) }}">{{ e.url }}</a></td>
<td><code>{{ e.hier_code }}</code>{% if e.peer %}<div class="meta-text">{{ e.peer }}</div>{% endif %}</td>
<td><code>{{ e.content_type }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<div class="pagination">
{% set args = request.args.to_dict() %}
{% if page > 1 %}{% set _ = args.update({'page': page - 1}) %}<a href="?{{ args | urlencode }}" class="btn btn-small">« 上一页</a>{% endif %}
<span class="page-info">第 {{ page }} / {{ total_pages }} 页</span>
{% if page < total_pages %}{% set _ = args.update({'page': page + 1}) %}<a href="?{{ args | urlencode }}" class="btn btn-small">下一页 »</a>{% endif %}
</div>
{% endif %}
{% else %}
<div class="card-empty">
<div class="empty-icon">🔍</div>
<p>未找到匹配的日志</p>
</div>
{% endif %}
</div>
{% endblock %}
+148
View File
@@ -0,0 +1,148 @@
{% extends "base.html" %}
{% block title %}日志导入 - Squid Manager{% endblock %}
{% block head %}
{% if imported %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
{% endif %}
{% endblock %}
{% block content %}
<div class="page-header">
<h1>📥 日志导入分析</h1>
</div>
{% if not imported %}
<div class="card">
<p class="card-desc">将 Squid access.log 内容粘贴到下方文本框,系统将解析并展示完整分析结果。
适用于离线日志分析或一次性诊断。</p>
<form method="post">
<div class="form-group">
<label>日志内容</label>
<textarea name="log_text" rows="20" class="form-input monospace"
placeholder="1783841223.438 2488 172.16.12.232 TCP_MISS_ABORTED/000 0 GET http://169.254.169.254/metadata/instance/compute - HIER_DIRECT/169.254.169.254 -
1783841225.683 4564 172.16.237.9 TCP_TUNNEL/200 10197 CONNECT default.exp-tas.com:443 - HIER_DIRECT/13.107.5.93 -
..."></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">📊 开始分析</button>
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">取消</a>
</div>
</form>
</div>
{% else %}
<div class="page-header">
<h2>分析结果</h2>
<div class="page-actions">
<span class="meta-text">共解析 {{ entries | length }} 条 / {{ raw_lines | length }} 行</span>
<a href="{{ url_for('logs_import') }}" class="btn btn-small btn-secondary">📥 重新导入</a>
</div>
</div>
<div class="kpi-grid">
<div class="kpi-card kpi-blue">
<div class="kpi-label">总请求数</div>
<div class="kpi-value">{{ stats.total_requests | thousands }}</div>
</div>
<div class="kpi-card kpi-green">
<div class="kpi-label">缓存命中率</div>
<div class="kpi-value">{{ '%.1f' % (stats.hit_ratio * 100) }}%</div>
</div>
<div class="kpi-card kpi-purple">
<div class="kpi-label">总流量</div>
<div class="kpi-value">{{ format_bytes(stats.total_bytes) }}</div>
</div>
<div class="kpi-card kpi-orange">
<div class="kpi-label">平均响应</div>
<div class="kpi-value">{{ format_duration(stats.avg_elapsed_ms) }}</div>
</div>
<div class="kpi-card kpi-red">
<div class="kpi-label">拒绝率</div>
<div class="kpi-value">{{ '%.2f' % (stats.denied_rate * 100) }}%</div>
</div>
<div class="kpi-card kpi-yellow">
<div class="kpi-label">中断率</div>
<div class="kpi-value">{{ '%.2f' % (stats.aborted_rate * 100) }}%</div>
</div>
</div>
<div class="grid-2">
<div class="card chart-card">
<h3 class="card-title">🏷️ 结果码分布</h3>
<div class="chart-container"><canvas id="resultChart" height="200"></canvas></div>
</div>
<div class="card chart-card">
<h3 class="card-title">🚀 方法分布</h3>
<div class="chart-container"><canvas id="methodChart" height="200"></canvas></div>
</div>
</div>
<div class="grid-2">
<div class="card">
<h3 class="card-title">👥 Top 客户端</h3>
<table class="data-table">
<thead><tr><th>#</th><th>IP</th><th>请求数</th><th>流量</th></tr></thead>
<tbody>
{% for c in stats.top_clients[:15] %}
<tr><td>{{ loop.index }}</td><td>{{ c.client }}</td><td>{{ c.count }}</td><td>{{ format_bytes(c.bytes) }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card">
<h3 class="card-title">🌍 Top 目标主机</h3>
<table class="data-table">
<thead><tr><th>#</th><th>主机</th><th>请求数</th><th>流量</th></tr></thead>
<tbody>
{% for h in stats.top_hosts[:15] %}
<tr><td>{{ loop.index }}</td><td>{{ h.host }}</td><td>{{ h.count }}</td><td>{{ format_bytes(h.bytes) }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="card">
<h3 class="card-title">📋 原始日志 (前 200 条)</h3>
<div class="table-scroll">
<table class="data-table log-table">
<thead><tr><th>时间</th><th>客户端</th><th>结果</th><th>HTTP</th><th>方法</th><th>大小</th><th>URL</th><th>层级</th></tr></thead>
<tbody>
{% for e in entries[:200] %}
<tr>
<td class="time-cell">{{ e.timestamp.strftime('%H:%M:%S') if e.timestamp else e.time }}</td>
<td>{{ e.client }}</td>
<td><span class="badge badge-{% if e.category == 'Hit' %}green{% elif e.category == 'Miss' %}blue{% elif e.category == 'Tunnel' %}cyan{% elif e.category == 'Denied' %}red{% elif e.category == 'Aborted' %}orange{% else %}gray{% endif %}">{{ e.result_code }}</span></td>
<td>{{ e.http_code or '-' }}</td>
<td>{{ e.method }}</td>
<td>{{ format_bytes(e.size) }}</td>
<td class="url-cell" title="{{ e.url }}">{{ e.url }}</td>
<td><code>{{ e.hier_code }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
{% if imported %}
<script>
const COLORS = ['#3b82f6','#ef4444','#10b981','#f59e0b','#8b5cf6','#ec4899','#06b6d4','#84cc16','#f97316','#6366f1','#14b8a6','#a855f7','#eab308','#22c55e','#64748b','#0ea5e9'];
function makeDoughnut(id, data) {
const ctx = document.getElementById(id).getContext('2d');
new Chart(ctx, {
type: 'doughnut',
data: { labels: data.map(d=>d[0]), datasets: [{ data: data.map(d=>d[1]), backgroundColor: COLORS, borderWidth:1, borderColor:'#1e293b' }] },
options: { responsive:true, maintainAspectRatio:false, plugins: { legend: { position:'right', labels:{color:'#cbd5e1',font:{size:11}} } } }
});
}
window.addEventListener('DOMContentLoaded', () => {
makeDoughnut('resultChart', {{ stats.by_result | tojson }});
makeDoughnut('methodChart', {{ stats.by_method | tojson }});
});
</script>
{% endif %}
{% endblock %}
+60
View File
@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% block title %}实时日志 - Squid Manager{% endblock %}
{% block head %}
<meta http-equiv="refresh" content="{{ cfg.auto_refresh_seconds }}">
{% endblock %}
{% block content %}
<div class="page-header">
<h1>⚡ 实时日志 (Live Tail)</h1>
<div class="page-actions">
<span class="meta-text">显示最近 {{ n }} 条 · 每 {{ cfg.auto_refresh_seconds }} 秒自动刷新</span>
<a href="{{ url_for('config_paths') }}" class="btn btn-small btn-secondary">⚙️ 调整</a>
<button onclick="location.reload()" class="btn btn-small btn-primary">🔄 立即刷新</button>
</div>
</div>
<div class="card">
{% if entries %}
<div class="table-scroll">
<table class="data-table log-table">
<thead>
<tr>
<th>时间</th>
<th>客户端</th>
<th>结果码</th>
<th>HTTP</th>
<th>方法</th>
<th>大小</th>
<th>耗时</th>
<th>URL</th>
<th>层级</th>
</tr>
</thead>
<tbody>
{% for e in entries %}
<tr>
<td class="time-cell">{{ e.timestamp.strftime('%H:%M:%S') if e.timestamp else e.time }}</td>
<td>{{ e.client }}</td>
<td>
<span class="badge badge-{% if e.category == 'Hit' %}green{% elif e.category == 'Miss' %}blue{% elif e.category == 'Tunnel' %}cyan{% elif e.category == 'Denied' %}red{% elif e.category == 'Aborted' %}orange{% elif e.category == 'Error' %}red{% else %}gray{% endif %}">{{ e.result_code }}</span>
</td>
<td><span class="badge badge-gray">{{ e.http_code or '-' }}</span></td>
<td>{{ e.method }}</td>
<td>{{ format_bytes(e.size) }}</td>
<td>{{ format_duration(e.elapsed_ms) }}</td>
<td class="url-cell" title="{{ e.url }}">{{ e.url }}</td>
<td><code>{{ e.hier_code }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card-empty">
<div class="empty-icon">📭</div>
<p>暂无日志数据。请确认访问日志路径正确:
<code>{{ cfg.access_log_path }}</code></p>
</div>
{% endif %}
</div>
{% endblock %}
+148
View File
@@ -0,0 +1,148 @@
{% extends "base.html" %}
{% block title %}日志轮转 - Squid Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1>🔁 日志轮转 (logrotate)</h1>
<div class="page-actions">
<span class="meta-text">配置: <code>{{ logrotate_path }}</code></span>
</div>
</div>
<div class="card">
<h3 class="card-title">📘 logrotate 怎么配合 Squid</h3>
<p class="card-desc">本页面会生成 <code>/etc/logrotate.d/squid</code>,内容由 Squid Manager 维护。流程:</p>
<ol>
<li>logrotate 检测到当前日志达到 <code>size</code> 阈值(或到 <code>daily</code> 时间)时,把当前文件改名为 <code>.1</code>;</li>
<li>logrotate 用 <code>create 0640 proxy proxy</code> 创建新空文件(原属主为 Squid 用户);</li>
<li>logrotate 执行 <code>postrotate</code> 块里的 <code>squid -k rotate</code>,让 Squid 关闭旧 fd 并重开新文件 — 这是核心联动;</li>
<li>老日志按 <code>rotate N</code> 数量保留,多余的会被删除/压缩 (<code>compress</code>);</li>
<li>当本页面「卸载」配置时,只删除 <code>/etc/logrotate.d/squid</code>,不会动 Squid 本身。</li>
</ol>
<p class="meta-text">
{% if logrotate_available %}
<span class="badge badge-green">● logrotate 可用</span>
{% else %}
<span class="badge badge-red">● logrotate 未安装</span> 请先 <code>apt install logrotate</code>
{% endif %}
</p>
</div>
<div class="grid-2">
<div class="card">
<h3 class="card-title">⚙️ 安装 / 重新生成配置</h3>
<p class="card-desc">目标文件: <code>{{ logrotate_path }}</code></p>
{% if not paths %}
<div class="card-empty">
<div class="empty-icon">📂</div>
<p>未在「<a href="{{ url_for('config_paths') }}">路径设置</a>」里配置任何日志路径,无法生成。</p>
</div>
{% else %}
<form method="post" action="{{ url_for('ops_logrotate_install') }}">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<div class="filter-grid">
<div class="form-group">
<label>轮转阈值 (size) <span class="hint">单文件达到此大小立即轮转,如 <code>100M</code> / <code>500K</code></span></label>
<input type="text" name="rotate_size" value="{{ install_size }}" class="form-input"
placeholder="100M" pattern="^\d+[KMG]?$">
</div>
<div class="form-group">
<label>保留份数 (rotate) <span class="hint">最多保留 N 份旧日志,超出自动删除</span></label>
<input type="number" name="rotate_count" value="{{ install_count }}" class="form-input"
min="1" max="365" step="1">
</div>
<div class="form-group">
<label>压缩 (compress)</label>
<select name="compress" class="form-input">
<option value="1" {% if install_compress %}selected{% endif %}>启用 — 旧日志用 gzip 压缩</option>
<option value="0" {% if not install_compress %}selected{% endif %}>禁用 — 保留为明文</option>
</select>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">💾 写入配置</button>
{% if is_installed %}
<button type="button" class="btn btn-danger" id="btn-uninstall">🗑 卸载配置</button>
{% endif %}
</div>
</form>
{% if is_installed %}
<form id="uninstall-form" method="post" action="{{ url_for('ops_logrotate_uninstall') }}" style="display:none;">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
</form>
{% endif %}
{% endif %}
</div>
<div class="card">
<h3 class="card-title">📋 当前已安装</h3>
{% if is_installed %}
<p class="meta-text">{{ current_msg }}</p>
<table class="data-table">
<tbody>
<tr><th style="width: 160px;">状态</th>
<td><span class="badge badge-green">● 已安装</span></td>
</tr>
<tr><th>路径</th><td><code>{{ logrotate_path }}</code></td></tr>
<tr><th>管理日志</th>
<td>
{% for p in paths %}
<div><code>{{ p }}</code></div>
{% endfor %}
</td>
</tr>
</tbody>
</table>
{% else %}
<p class="meta-text">
{% if not current_msg %}{% endif %}
尚未安装 logrotate 配置 ({{ current_msg or '文件不存在' }}),点击左侧表单写入。
</p>
{% endif %}
</div>
</div>
<div class="card">
<h3 class="card-title">📄 当前 {{ logrotate_path }} 内容</h3>
{% if current_content %}
<pre style="background: var(--bg-elevated); padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 12px;"><code>{{ current_content }}</code></pre>
{% else %}
<p class="meta-text">暂无内容 (尚未安装)。</p>
{% endif %}
</div>
{% if preview %}
<div class="card">
<h3 class="card-title">🧪 预览 (尚未写入磁盘)</h3>
<p class="card-desc">下面是根据当前表单参数生成的预览,仅供检查。点「写入配置」才会落盘。</p>
<pre style="background: var(--bg-elevated); padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 12px;"><code>{{ preview }}</code></pre>
</div>
{% endif %}
<div class="card">
<h3 class="card-title">️ 提示</h3>
<ul>
<li>权限不足时,写入会自动尝试 <code>sudo tee</code> — 需要本平台进程具备无密码 sudo,否则会回显失败原因。</li>
<li><code>size</code> 接受 logrotate 单位后缀 <code>K</code> / <code>M</code> / <code>G</code>;可与 <code>daily</code> 共存(任一条件满足即触发)。</li>
<li>卸载配置不会触碰日志文件本身 — 已存在的旧日志文件 <code>access.log.1.gz</code> 等仍保留。</li>
<li>每次「写入配置」都会记录到 <a href="{{ url_for('audit_view') }}">审计日志</a></li>
</ul>
</div>
{% endblock %}
{% block scripts %}
{% if is_installed %}
<script>
(function() {
const btn = document.getElementById('btn-uninstall');
const form = document.getElementById('uninstall-form');
if (!btn || !form) return;
btn.addEventListener('click', function() {
if (!confirm('确认卸载 logrotate 配置?\\n文件 {{ logrotate_path }} 将被删除,日志本身不会被影响。')) {
return;
}
form.submit();
});
})();
</script>
{% endif %}
{% endblock %}
+118
View File
@@ -0,0 +1,118 @@
{% extends "base.html" %}
{% block title %}日志状态 - Squid Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1>📂 日志状态</h1>
<div class="page-actions">
<span class="meta-text">合计: <strong>{{ total_human }}</strong> · {{ entries | length }} 个文件</span>
</div>
</div>
<div class="card">
<h3 class="card-title">📘 日志轮转原理</h3>
<p class="card-desc">Squid 默认会把请求/缓存/存储事件持续写入 <code>access.log</code> / <code>cache.log</code> / <code>store.log</code>,不会自动截断或归档,长期运行会让磁盘塞满。
常见的做法是结合 <strong>logrotate</strong><code>squid -k rotate</code> 信号:</p>
<ol>
<li><strong>logrotate</strong> 周期性地把当前日志改名 (<code>access.log.1</code>, <code>access.log.2.gz</code> …),并创建新的空日志文件;</li>
<li>logrotate 在 <code>postrotate</code> 钩子里执行 <code>squid -k rotate</code>,让 Squid 关闭旧 fd 并重新打开日志,避免日志写到被改名的旧文件里;</li>
<li>老化的日志按 <code>rotate N</code> 自动清理/压缩,既能保留审计又不会撑爆磁盘。</li>
</ol>
<p class="meta-text">下表实时读取每个日志文件的元信息;颜色标识来自阈值:&lt;{{ (warn_bytes / 1024 / 1024) | int }} MB 正常,≥{{ (warn_bytes / 1024 / 1024) | int }} MB 黄色,≥{{ (crit_bytes / 1024 / 1024 / 1024) | round(1) }} GB 红色。</p>
</div>
<div class="card">
{% if entries %}
<table class="data-table">
<thead>
<tr>
<th>日志文件</th>
<th>大小</th>
<th>状态</th>
<th>最后修改</th>
<th>年龄</th>
<th>可写</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for e in entries %}
<tr>
<td>
<code>{{ e.path }}</code>
{% if e.error %}<div class="meta-text">⚠ {{ e.error }}</div>{% endif %}
</td>
<td>{{ e.size_human }} <span class="meta-text">({{ '{:,}'.format(e.size_bytes) }} B)</span></td>
<td>
{% if not e.exists %}
<span class="badge badge-gray">● 不存在</span>
{% elif e.size_bytes >= crit_bytes %}
<span class="badge badge-red">● 过大 (≥ {{ (crit_bytes / 1024 / 1024 / 1024) | round(1) }} GB)</span>
{% elif e.size_bytes >= warn_bytes %}
<span class="badge badge-yellow">● 偏大 (≥ {{ (warn_bytes / 1024 / 1024) | int }} MB)</span>
{% else %}
<span class="badge badge-green">● 正常</span>
{% endif %}
</td>
<td class="time-cell">{{ e.mtime_human or '-' }}</td>
<td>{% if e.age_days is not none %}{{ e.age_days }} 天{% else %}-{% endif %}</td>
<td>
{% if e.is_writable %}
<span class="badge badge-green"></span>
{% else %}
<span class="badge badge-red"></span>
{% endif %}
</td>
<td>
<form method="post" action="{{ url_for('ops_logs_rotate') }}" style="display:inline;"
onsubmit="return confirm('确认立即轮转日志?\\n将向 Squid 发送 rotate 信号并由 logrotate 接管新文件。');">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-small btn-primary"
{% if not logrotate_available %}disabled title="logrotate 不可用"{% endif %}>
🔁 立即轮转
</button>
</form>
{% set parent_dir = e.path.rsplit('/', 1)[0] if '/' in e.path else '.' %}
<span class="meta-text">📁 {{ parent_dir }}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card-empty">
<div class="empty-icon">📂</div>
<p>未在「<a href="{{ url_for('config_paths') }}">路径设置</a>」里配置任何日志路径。</p>
</div>
{% endif %}
</div>
<div class="card">
<h3 class="card-title">🔧 工具状态</h3>
<table class="data-table">
<tbody>
<tr><th style="width: 200px;">logrotate 可执行</th>
<td>{% if logrotate_available %}<span class="badge badge-green">可用</span>
{% else %}<span class="badge badge-red">未找到</span>
<span class="meta-text">请安装 logrotate 包 (apt: logrotate)</span>{% endif %}
</td>
</tr>
<tr><th>Squid 二进制</th><td><code>{{ binary }}</code></td></tr>
<tr><th>日志轮转配置</th>
<td>
<a href="{{ url_for('ops_logrotate') }}" class="btn btn-small btn-secondary">🔁 前往日志轮转</a>
<span class="meta-text">安装 logrotate.d/squid 后会按计划自动轮转</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h3 class="card-title">️ 提示</h3>
<ul>
<li><strong>立即轮转</strong>按钮只发送 <code>squid -k rotate</code> 信号,本身不重命名文件 — 需要 logrotate 配置协同工作。</li>
<li>如果日志路径是网络挂载 (NFS/SMB),「可写」列反映的是当前进程对文件的写权限,可能与 Squid 实际权限不同。</li>
<li>建议把 <code>access.log</code> 的轮转阈值设为单文件大约 100–500 MB,避免后续 <code>logrotate</code> 重命名瞬间触发磁盘峰值。</li>
</ul>
</div>
{% endblock %}
+248
View File
@@ -0,0 +1,248 @@
{% extends "base.html" %}
{% block title %}实时性能 - Squid Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1>📊 实时性能监控</h1>
<div class="page-actions">
<span class="meta-text">
采集于 {{ summary.fetched_at }} · 来源 {{ conn.host }}:{{ conn.port }}
</span>
<button id="refresh-btn" class="btn btn-small btn-secondary">🔄 刷新</button>
</div>
</div>
{% if not summary.fetched_ok %}
<div class="card" style="border-left: 4px solid #ef4444;">
<h3 class="card-title">⚠️ 无法获取 Squid 性能数据</h3>
<p class="meta-text">
<strong>错误信息:</strong> {{ summary.error or "未知错误" }}
</p>
<p class="meta-text">
请检查:
<ul style="margin: 6px 0 0 18px;">
<li><code>squidclient</code> 工具是否已安装且在 <code>PATH</code> 中 (运行 <code>which squidclient</code> 验证)</li>
<li>Squid <code>cache_mgr</code> 管理接口是否启用 (默认端口 {{ conn.port }})</li>
<li>主机 <code>{{ conn.host }}:{{ conn.port }}</code> 是否可以从 Web 服务器访问</li>
<li>如启用了 <code>cachemgr_passwd</code>, 请在 <a href="{{ url_for('config_paths') }}">路径设置</a> 配置 <code>squid_mgr_password</code></li>
</ul>
</p>
</div>
{% endif %}
<!-- Top metric grid: 8 dense cards (4 cols × 2 rows on desktop) -->
<div class="kpi-grid" id="metric-grid">
<div class="kpi-card kpi-blue">
<div class="kpi-label">运行时长</div>
<div class="kpi-value" data-field="squid_uptime_human">{{ summary.squid_uptime_human or "—" }}</div>
<div class="kpi-meta">Squid 版本 {{ summary.squid_version or "?" }}</div>
</div>
<div class="kpi-card kpi-purple">
<div class="kpi-label">CPU 时间</div>
<div class="kpi-value" data-field="cpu_time_human">{{ summary.cpu_time_human or "—" }}</div>
<div class="kpi-meta">累计用户态+内核态</div>
</div>
<div class="kpi-card kpi-cyan">
<div class="kpi-label">最大内存占用</div>
<div class="kpi-value" data-field="max_rss_human">{{ summary.max_rss_human or "—" }}</div>
<div class="kpi-meta">RSS 峰值</div>
</div>
<div class="kpi-card kpi-orange">
<div class="kpi-label">活跃客户端</div>
<div class="kpi-value" data-field="current_clients">{{ summary.current_clients if summary.current_clients is not none else "—" }}</div>
<div class="kpi-meta">正在访问缓存的 IP 数</div>
</div>
<div class="kpi-card kpi-green">
<div class="kpi-label">缓存命中率</div>
<div class="kpi-value" data-field="cache_hits_pct">
{% if summary.cache_hits_pct is not none %}{{ '%.1f' % summary.cache_hits_pct }}%{% else %}—{% endif %}
</div>
<div class="kpi-meta">Hits as % of bytes sent</div>
</div>
<div class="kpi-card kpi-yellow">
<div class="kpi-label">磁盘缓存</div>
<div class="kpi-value" data-field="storage_swap_human">{{ summary.storage_swap_human or "—" }}</div>
<div class="kpi-meta">Storage Swap size</div>
</div>
<div class="kpi-card kpi-pink">
<div class="kpi-label">内存缓存</div>
<div class="kpi-value" data-field="storage_mem_human">{{ summary.storage_mem_human or "—" }}</div>
<div class="kpi-meta">Storage Mem size</div>
</div>
<div class="kpi-card kpi-red">
<div class="kpi-label">5min 请求率</div>
<div class="kpi-value" data-field="request_rate_5min">
{% if summary.request_rate_5min is not none %}{{ '%.1f' % summary.request_rate_5min }} req/s{% else %}—{% endif %}
</div>
<div class="kpi-meta">
流量:
<span data-field="byte_rate_5min_human">
{% if summary.byte_rate_5min is not none %}{{ format_bytes(summary.byte_rate_5min) }}/s{% else %}—{% endif %}
</span>
</div>
</div>
</div>
<!-- Status bar -->
<div class="card" style="margin-top: 16px;">
<h3 class="card-title">🛰️ Squid 状态</h3>
<div class="grid-2">
<div>
<table class="data-table small">
<tbody>
<tr>
<th>启动时间</th>
<td data-field="start_time">{{ summary.start_time or "—" }}</td>
</tr>
<tr>
<th>当前时间</th>
<td data-field="current_time">{{ summary.current_time or "—" }}</td>
</tr>
<tr>
<th>作者信息</th>
<td>{{ summary.squid_author or "—" }}</td>
</tr>
</tbody>
</table>
</div>
<div>
<table class="data-table small">
<tbody>
<tr>
<th>采集状态</th>
<td>
{% if summary.fetched_ok %}
<span class="badge badge-green">✅ 成功</span>
{% else %}
<span class="badge badge-red">❌ 失败</span>
{% endif %}
</td>
</tr>
<tr>
<th>采集时间</th>
<td data-field="fetched_at">{{ summary.fetched_at }}</td>
</tr>
<tr>
<th>采集源</th>
<td><code>{{ conn.host }}:{{ conn.port }}</code></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Fold-out raw output -->
<div class="card" style="margin-top: 16px;">
<h3 class="card-title">🔍 原始输出 (可折叠)</h3>
<details>
<summary>查看 mgr:info 完整文本</summary>
<pre id="raw-info" style="max-height: 400px; overflow: auto; background: var(--bg); padding: 12px; border-radius: 6px; font-size: 12px;">{{ summary.raw_info or summary.error or "无数据" }}</pre>
</details>
<details style="margin-top: 12px;">
<summary>查看 mgr:5min 完整文本 (懒加载)</summary>
<div id="raw-5min" data-loaded="false" class="meta-text" style="padding: 8px 0;">点击展开后按需加载...</div>
</details>
<details style="margin-top: 12px;">
<summary>查看 mgr:counters 完整文本 (懒加载)</summary>
<div id="raw-counters" data-loaded="false" class="meta-text" style="padding: 8px 0;">点击展开后按需加载...</div>
</details>
</div>
{% endblock %}
{% block scripts %}
<script>
(function() {
const btn = document.getElementById('refresh-btn');
const grid = document.getElementById('metric-grid');
if (!btn || !grid) return;
// ---- Auto-refresh: 30s if user hasn't disabled it ----
let autoTimer = null;
function scheduleAuto() {
if (autoTimer) clearInterval(autoTimer);
autoTimer = setInterval(refreshNow, 30000);
}
function fmtBytes(b) {
if (b === null || b === undefined) return '—';
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; }
return b.toFixed(b >= 100 ? 0 : (b >= 10 ? 1 : 2)) + ' ' + u[i];
}
function applySummary(s) {
// Update each card by data-field
const fields = {
squid_uptime_human: s.squid_uptime_human || '—',
cpu_time_human: s.cpu_time_human || '—',
max_rss_human: s.max_rss_human || '—',
current_clients: (s.current_clients === null || s.current_clients === undefined) ? '—' : s.current_clients,
cache_hits_pct: (s.cache_hits_pct === null || s.cache_hits_pct === undefined) ? '—' : s.cache_hits_pct.toFixed(1) + '%',
storage_swap_human: s.storage_swap_human || '—',
storage_mem_human: s.storage_mem_human || '—',
request_rate_5min: (s.request_rate_5min === null || s.request_rate_5min === undefined) ? '—' : s.request_rate_5min.toFixed(1) + ' req/s',
byte_rate_5min_human: (s.byte_rate_5min === null || s.byte_rate_5min === undefined) ? '—' : fmtBytes(s.byte_rate_5min) + '/s',
start_time: s.start_time || '—',
current_time: s.current_time || '—',
fetched_at: s.fetched_at || '—',
};
for (const [k, v] of Object.entries(fields)) {
const el = document.querySelector('[data-field="' + k + '"]');
if (el) el.textContent = v;
}
// Visual feedback on success
btn.textContent = '✅ ' + (s.fetched_ok ? '已刷新' : '刷新失败');
btn.classList.toggle('btn-primary', !!s.fetched_ok);
setTimeout(() => { btn.textContent = '🔄 刷新'; btn.classList.remove('btn-primary'); }, 1500);
}
async function refreshNow() {
btn.disabled = true;
btn.textContent = '⏳ 刷新中…';
try {
const resp = await fetch('{{ url_for("performance_refresh") }}', {
method: 'POST',
headers: {'X-Requested-With': 'XMLHttpRequest'},
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const data = await resp.json();
applySummary(data);
} catch (err) {
btn.textContent = '❌ ' + (err.message || '失败');
setTimeout(() => { btn.textContent = '🔄 刷新'; }, 2000);
} finally {
btn.disabled = false;
}
}
btn.addEventListener('click', refreshNow);
scheduleAuto();
// ---- Lazy load raw sections when their <details> is opened ----
document.querySelectorAll('details').forEach(d => {
const target = d.querySelector('[data-loaded="false"]');
if (!target) return;
d.addEventListener('toggle', async () => {
if (!d.open || target.dataset.loaded === 'true') return;
target.dataset.loaded = 'loading';
target.textContent = '加载中...';
const section = target.id.replace('raw-', '');
try {
const resp = await fetch('{{ url_for("performance_raw", section="PLACEHOLDER") }}'.replace('PLACEHOLDER', section));
const data = await resp.json();
target.dataset.loaded = 'true';
target.textContent = data.raw || data.error || '无数据';
target.style.whiteSpace = 'pre-wrap';
target.style.fontFamily = 'monospace';
target.style.fontSize = '12px';
} catch (err) {
target.dataset.loaded = 'true';
target.textContent = '加载失败: ' + err.message;
}
});
});
})();
</script>
{% endblock %}
+174
View File
@@ -0,0 +1,174 @@
{% extends "base.html" %}
{% block title %}服务控制 - Squid Manager{% endblock %}
{% block content %}
{% set tokens = confirm_tokens() %}
<div class="page-header">
<h1>🛠️ 服务控制</h1>
</div>
<div class="grid-2">
<div class="card">
<h3 class="card-title">📋 服务状态</h3>
<table class="data-table">
<tbody>
<tr>
<th>状态</th>
<td>
{% if status.active %}
<span class="badge badge-green">● 运行中</span>
{% elif status.state == 'stopped' %}
<span class="badge badge-gray">● 已停止</span>
{% else %}
<span class="badge badge-orange">● {{ status.state }}</span>
{% endif %}
</td>
</tr>
<tr><th>服务名</th><td><code>{{ cfg.squid_service if cfg is defined else 'squid' }}</code></td></tr>
<tr><th>主进程 PID</th><td>{{ status.pid or '-' }}</td></tr>
<tr><th>启动时间</th><td>{{ status.since or '-' }}</td></tr>
<tr><th>Squid 版本</th><td>{{ status.version or '-' }}</td></tr>
<tr><th>Unit 文件</th><td><code>{{ status.unit_file or '-' }}</code></td></tr>
<tr><th>二进制可用</th><td>{% if status.binary_available %}<span class="badge badge-green"></span>{% else %}<span class="badge badge-red"></span> (无法执行 squid 命令){% endif %}</td></tr>
</tbody>
</table>
</div>
<div class="card">
<h3 class="card-title">🎛️ 操作</h3>
<p class="card-desc">以下操作会通过 systemctl 调用 squid 服务。reload 会优先尝试平滑重载,失败则回退到 restart。</p>
<div class="btn-group-vertical">
<button onclick="svc('start')" class="btn btn-primary">▶ 启动</button>
<button onclick="showConfirm('stop')" class="btn btn-danger">⏹ 停止</button>
<button onclick="showConfirm('restart')" class="btn btn-warning">🔄 重启</button>
<button onclick="svc('reload')" class="btn btn-secondary">♻️ 平滑重载 (reload)</button>
</div>
<div id="svc-result" class="svc-result" style="display:none;"></div>
</div>
</div>
<div class="card">
<h3 class="card-title">📁 关键路径</h3>
<table class="data-table">
<thead><tr><th>项目</th><th>路径</th><th>操作</th></tr></thead>
<tbody>
<tr>
<td>主配置文件</td>
<td><code>{{ conf }}</code></td>
<td><a href="{{ url_for('config_raw') }}" class="btn btn-small">编辑</a></td>
</tr>
<tr>
<td>访问日志</td>
<td><code>{{ access_log }}</code></td>
<td><a href="{{ url_for('logs_view') }}" class="btn btn-small">查看</a></td>
</tr>
<tr>
<td>缓存日志</td>
<td><code>{{ cache_log }}</code></td>
<td>-</td>
</tr>
</tbody>
</table>
</div>
<!-- P0-5: confirm-modal for dangerous service operations -->
<div id="confirm-modal" class="modal" style="display:none;"
data-token-start="{{ tokens.start }}"
data-token-stop="{{ tokens.stop }}"
data-token-restart="{{ tokens.restart }}"
data-token-reload="{{ tokens.reload }}">
<div class="modal-card">
<h3 id="confirm-title">⚠️ 确认操作</h3>
<p>这是一个危险操作。Squid 服务的所有连接会立即中断。</p>
<p>请输入: <code id="confirm-phrase"></code> 来确认。</p>
<input type="text" id="confirm-input" class="form-input" autocomplete="off">
<input type="hidden" id="confirm-action" value="">
<input type="hidden" id="confirm-token" value="">
<div class="form-actions">
<button class="btn btn-primary" onclick="submitConfirm()">确认执行</button>
<button class="btn btn-secondary" onclick="hideConfirm()">取消</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
/* P0-5: read tokens from data-* attributes to avoid Jinja set scope limits */
const TOKENS = {
start: document.getElementById('confirm-modal').dataset.tokenStart,
stop: document.getElementById('confirm-modal').dataset.tokenStop,
restart:document.getElementById('confirm-modal').dataset.tokenRestart,
reload: document.getElementById('confirm-modal').dataset.tokenReload,
};
let locked = false;
function showConfirm(action) {
if (locked) return;
/* Phrase format: STOP-SQUID / RESTART-SQUID — see tests */
const phrase = action.toUpperCase() + '-SQUID';
document.getElementById('confirm-phrase').textContent = phrase;
document.getElementById('confirm-action').value = action;
document.getElementById('confirm-token').value = TOKENS[action];
document.getElementById('confirm-input').value = '';
document.getElementById('confirm-modal').style.display = 'flex';
document.getElementById('confirm-input').focus();
}
function hideConfirm() {
document.getElementById('confirm-modal').style.display = 'none';
}
function submitConfirm() {
const action = document.getElementById('confirm-action').value;
const token = document.getElementById('confirm-token').value;
const phrase = action.toUpperCase() + '-SQUID';
const input = document.getElementById('confirm-input').value.trim();
if (input !== phrase) {
alert('输入的确认码不正确');
return;
}
hideConfirm();
svc(action, token);
}
function svc(action, token) {
if (locked) return;
if (!confirm(`最后确认: 执行 ${action}?`)) return;
locked = true;
/* lock all buttons */
document.querySelectorAll('.btn-group-vertical .btn').forEach(b => b.disabled = true);
// show countdown
const box = document.getElementById('svc-result');
box.style.display = 'block';
box.className = 'svc-result svc-loading';
let countdown = 30;
box.innerHTML = `执行中: ${action} ... 按钮锁定 ${countdown}s`;
const t = setInterval(() => {
countdown--;
if (countdown > 0) {
box.innerHTML = `执行中: ${action} ... 按钮锁定 ${countdown}s`;
} else {
clearInterval(t);
}
}, 1000);
const form = new FormData();
form.append('confirm_token', token || '');
fetch(`/api/service/${action}`, { method: 'POST', body: form })
.then(r => r.json())
.then(d => {
clearInterval(t);
box.className = 'svc-result ' + (d.ok ? 'svc-ok' : 'svc-err');
box.innerHTML = `<strong>${d.ok ? '✅ 成功' : '❌ 失败'}</strong> (耗时 ${d.elapsed || '?'}s)<br><pre>${d.message || ''}</pre>`;
setTimeout(() => {
locked = false;
document.querySelectorAll('.btn-group-vertical .btn').forEach(b => b.disabled = false);
location.reload();
}, 2000);
})
.catch(e => {
clearInterval(t);
box.className = 'svc-result svc-err';
box.textContent = '请求失败: ' + e;
locked = false;
document.querySelectorAll('.btn-group-vertical .btn').forEach(b => b.disabled = false);
});
}
</script>
{% endblock %}
+303
View File
@@ -0,0 +1,303 @@
{% extends "base.html" %}
{% block title %}Web 终端 - Squid Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1>💻 Web SSH 终端</h1>
<p class="page-desc">直接在浏览器中打开远程 Squid 主机的 SSH 会话,所有输入输出都会被审计记录。</p>
</div>
{% if not ssh_target %}
<div class="banner banner-warn">
⚠ 当前未配置 SSH 目标 (没有活跃实例或活跃实例未启用 SSH)。
请先在
<a href="{{ url_for('instances_view') }}">多实例管理</a>
中为实例填写 <code>ssh_host</code> / <code>ssh_user</code> / <code>ssh_key_path</code>
</div>
{% endif %}
<div class="banner banner-danger">
<strong>🔒 审计提示:</strong>
所有 SSH 会话的开启、关闭、resize 事件以及输入数据
(经过编辑保留关键字符) 都会写入 <code>audit_log</code> 表,
操作记录可在
<a href="{{ url_for('audit_view') }}">审计日志</a>
中查询。非法操作将被追责。
</div>
{% if paramiko_missing %}
<div class="banner banner-error" id="paramiko-error-banner">
<strong>❌ 后端依赖缺失:</strong>
检测到当前 Python 环境未安装 <code>paramiko</code>,WebSSH 功能不可用。
请在服务器上执行
<code>pip install paramiko flask-sock</code>
后重启服务。当前页面仅供布局预览。
</div>
{% endif %}
<div class="terminal-config">
<div class="terminal-config-row">
<span class="meta-text">SSH 目标:</span>
{% if ssh_target %}
<code class="terminal-ssh-host">{{ ssh_target }}</code>
{% else %}
<code class="meta-text">(未配置)</code>
{% endif %}
<span class="meta-text">| 用户:</span>
<code>{{ ssh_user if ssh_user else '-' }}</code>
<span class="meta-text">| 端口:</span>
<code>{{ ssh_port }}</code>
<span class="meta-text">| 密钥:</span>
<code title="{{ ssh_key_path or '' }}">{{ ssh_key_short or '-' }}</code>
<span class="meta-text">| 实例:</span>
<code>{{ instance_name }}</code>
</div>
</div>
<div class="card terminal-card">
<h3 class="card-title">🖥️ 终端</h3>
<div id="terminal-error" class="banner banner-error" style="display:none;"></div>
<div id="terminal" class="terminal-host" data-testid="terminal-host"></div>
<div class="terminal-statusbar">
<span>状态:</span>
<span id="terminal-status" class="badge badge-gray">未连接</span>
<span class="meta-text">|</span>
<span>目标:</span>
<code id="terminal-target">{% if ssh_target %}{{ ssh_target }}{% else %}(未配置){% endif %}</code>
<span class="terminal-statusbar-spacer"></span>
<span id="terminal-bytes" class="meta-text">0 B in / 0 B out</span>
<button id="terminal-exit" type="button" class="btn btn-danger btn-small">⏹ 断开/退出</button>
</div>
</div>
<div class="card">
<h3 class="card-title">📘 使用说明</h3>
<ul class="card-list">
<li>终端会在页面加载时自动建立 WebSocket → paramiko SSH 通道,默认 80×24。</li>
<li>支持 <kbd>Ctrl+C</kbd> / <kbd>Ctrl+D</kbd> / <kbd>Ctrl+L</kbd> 等组合键,窗口大小变化会自动 resize。</li>
<li>顶部"断开/退出"按钮会主动关闭 SSH 会话并写入审计记录。</li>
<li>为安全起见,本功能仅 <code>admin</code> 角色可见可用,其他用户不会显示此入口。</li>
{% if ssh_key_path %}
<li>实例配置的密钥文件:
<code>{{ ssh_key_path }}</code>
<span class="meta-text">(如服务端读不到,UI 会显示错误)</span>
</li>
{% endif %}
</ul>
</div>
{% endblock %}
{% block scripts %}
{# xterm.js + addon-fit from CDN. We pin to a known-good version; the
addon-fit script is shipped as part of xterm-addon-fit. #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js" crossorigin="anonymous"></script>
<style>
/* --- terminal layout --- */
.terminal-config {
margin: 12px 0;
padding: 8px 12px;
background: rgba(0,0,0,0.18);
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.06);
font-size: 0.85rem;
}
.terminal-config-row > * { margin-right: 6px; }
.terminal-ssh-host { color: #4ec9b0; font-weight: 600; }
.terminal-card { padding: 12px; }
.terminal-host {
width: 100%;
height: 480px;
background: #1e1e1e;
border-radius: 6px;
overflow: hidden;
padding: 4px;
}
.terminal-host .xterm,
.terminal-host .xterm-viewport,
.terminal-host .xterm-screen { height: 100% !important; }
.terminal-statusbar {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
padding: 8px 10px;
border: 1px solid rgba(255,255,255,0.08);
border-radius: 6px;
background: rgba(0,0,0,0.12);
font-size: 0.85rem;
flex-wrap: wrap;
}
.terminal-statusbar-spacer { flex: 1; }
.banner-danger {
background: rgba(220, 53, 69, 0.12);
border-left: 4px solid #dc3545;
padding: 10px 14px;
margin: 12px 0;
border-radius: 4px;
}
.banner-error {
background: rgba(255, 80, 80, 0.18);
border: 1px solid #ff5252;
color: #ffb3b3;
padding: 10px 14px;
margin: 8px 0;
border-radius: 4px;
}
.banner-warn {
background: rgba(255, 193, 7, 0.10);
border-left: 4px solid #ffc107;
padding: 10px 14px;
margin: 12px 0;
border-radius: 4px;
}
.card-list { margin-left: 20px; }
.card-list li { margin: 4px 0; }
</style>
<script>
(function() {
"use strict";
const ERR_BANNER = document.getElementById('terminal-error');
const ERR_BANNER_PARAMIKO = document.getElementById('paramiko-error-banner');
const STATUS_EL = document.getElementById('terminal-status');
const BYTES_EL = document.getElementById('terminal-bytes');
const TARGET_EL = document.getElementById('terminal-target');
const EXIT_BTN = document.getElementById('terminal-exit');
function setStatus(text, cls) {
STATUS_EL.textContent = text;
STATUS_EL.className = 'badge ' + (cls || 'badge-gray');
}
function showError(msg) {
ERR_BANNER.style.display = 'block';
ERR_BANNER.textContent = '❌ ' + msg;
}
function clearError() {
ERR_BANNER.style.display = 'none';
}
/* Initialise xterm.js */
let term;
try {
term = new Terminal({
cursorBlink: true,
fontSize: 13,
fontFamily: 'Menlo, Monaco, Consolas, "DejaVu Sans Mono", monospace',
scrollback: 5000,
cols: 80,
rows: 24,
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#ffffff',
},
});
} catch (e) {
showError('xterm.js 加载失败,请检查网络 (CDN 被拦截): ' + e);
setStatus('加载失败', 'badge-red');
return;
}
const fitAddon = (window.FitAddon && window.FitAddon.FitAddon)
? new window.FitAddon.FitAddon()
: null;
term.open(document.getElementById('terminal'));
if (fitAddon) { try { fitAddon.fit(); } catch (_) {} }
term.writeln('\x1b[33m正在连接 SSH 服务,请稍候...\x1b[0m');
/* WebSocket */
const wsScheme = (window.location.protocol === 'https:') ? 'wss' : 'ws';
const wsUrl = wsScheme + '://' + window.location.host + '/ws/terminal';
let ws = null;
let bytesIn = 0, bytesOut = 0;
let closing = false;
function updateBytes() {
BYTES_EL.textContent = `${bytesIn} B in / ${bytesOut} B out`;
}
function connect() {
if (closing) return;
try {
ws = new WebSocket(wsUrl);
} catch (e) {
showError('无法创建 WebSocket: ' + e);
setStatus('异常', 'badge-red');
return;
}
setStatus('连接中...', 'badge-orange');
ws.onopen = function() {
setStatus('已连接', 'badge-green');
clearError();
// send resize
const cols = term.cols || 80;
const rows = term.rows || 24;
ws.send(JSON.stringify({type: 'resize', cols: cols, rows: rows}));
};
ws.onmessage = function(ev) {
let msg;
try { msg = JSON.parse(ev.data); }
catch (_) { term.write(ev.data); return; }
if (msg.type === 'output') {
term.write(msg.data);
bytesIn += (msg.data && msg.data.length) || 0;
updateBytes();
} else if (msg.type === 'status') {
term.writeln('\r\n\x1b[36m[状态] ' + (msg.msg || '') + '\x1b[0m');
} else if (msg.type === 'error') {
showError(msg.msg || 'unknown error');
setStatus('错误', 'badge-red');
term.writeln('\r\n\x1b[31m[错误] ' + (msg.msg || '') + '\x1b[0m');
// server forced close; close ws
try { ws.close(); } catch (_) {}
}
};
ws.onerror = function() {
setStatus('连接失败', 'badge-red');
showError('WebSocket 出现错误,请检查服务器日志。');
};
ws.onclose = function() {
setStatus('已断开', 'badge-gray');
term.writeln('\r\n\x1b[33m[提示] WebSocket 已关闭\x1b[0m');
};
}
/* input -> server */
term.onData(function(data) {
if (!ws || ws.readyState !== 1) return;
try {
ws.send(JSON.stringify({type: 'input', data: data}));
bytesOut += data.length;
updateBytes();
} catch (_) {}
});
/* resize */
function sendResize() {
if (!ws || ws.readyState !== 1) return;
try { ws.send(JSON.stringify({type: 'resize', cols: term.cols, rows: term.rows})); }
catch (_) {}
}
if (fitAddon) {
window.addEventListener('resize', function() {
try { fitAddon.fit(); sendResize(); } catch (_) {}
});
}
/* exit button */
EXIT_BTN.addEventListener('click', function() {
if (ws && ws.readyState === 1) {
try { ws.close(1000, 'user-exit'); } catch (_) {}
}
closing = true;
setStatus('已主动断开', 'badge-gray');
term.writeln('\r\n\x1b[33m[用户操作] 已请求断开 SSH 会话\x1b[0m');
});
/* kick off */
connect();
})();
</script>
{% endblock %}
+23
View File
@@ -0,0 +1,23 @@
"""WSGI entry point for gunicorn.
Run with:
gunicorn --workers 2 --bind 0.0.0.0:5200 wsgi:app
Or via systemd unit.
"""
import os
# Ensure instance dir exists before app imports (it references the SQLite path)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
os.makedirs(os.path.join(BASE_DIR, "instance"), exist_ok=True)
os.makedirs(os.path.join(BASE_DIR, "backups"), exist_ok=True)
from app import app, db, seed_admin
with app.app_context():
try:
db.create_all()
seed_admin()
except Exception as e:
# multi-worker race - the first worker wins; subsequent ones log
print(f"[wsgi] DB init: {e} (likely race with another worker)")