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:
+57
@@ -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
|
||||
@@ -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` 必须固定 (环境变量),否则每次重启会话失效
|
||||
|
||||
@@ -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
@@ -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])
|
||||
+122
@@ -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("&", "&").replace("<", "<").replace(">", ">"))
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>: 计算当前指标值,与阈值按指定运算符 ( > < ≥ ≤ ) 比较,满足则记录并 (可选) 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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">响应 < {{ thresholds.small_size_kb }} KB 且请求数 ≥ {{ 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">未检测到 > {{ 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">
|
||||
未检测到符合条件 (< {{ thresholds.small_size_kb }} KB × ≥ {{ 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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.<reason></code></li>
|
||||
<li>恢复时会先将当前配置备份为 <code>before-restore</code></li>
|
||||
<li>恢复后需要 <a href="{{ url_for('service_view') }}">重载服务</a> 才能生效</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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% > 已缓存时间,则视为过期</li>
|
||||
<li><strong>max</strong>: 最大缓存时间 (分钟)。无论 origin 怎么说,超过此时间视为过期</li>
|
||||
<li><strong>-i</strong>: 忽略大小写</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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: '© <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, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 · 首次登录后请修改密码</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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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">下表实时读取每个日志文件的元信息;颜色标识来自阈值:<{{ (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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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)")
|
||||
Reference in New Issue
Block a user