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

完整 Web 管理平台,涵盖:

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

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

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

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

技术栈: Flask 3.0 + SQLAlchemy 2.0 + SQLite + Chart.js + Leaflet
总代码量: ~16500 行 (15 Python 模块 + 34 模板 + CSS)
路由数: 73
This commit is contained in:
Hermes Agent
2026-07-15 10:19:21 +08:00
commit 4a1d949e41
54 changed files with 16895 additions and 0 deletions
+303
View File
@@ -0,0 +1,303 @@
{% extends "base.html" %}
{% block title %}Web 终端 - Squid Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1>💻 Web SSH 终端</h1>
<p class="page-desc">直接在浏览器中打开远程 Squid 主机的 SSH 会话,所有输入输出都会被审计记录。</p>
</div>
{% if not ssh_target %}
<div class="banner banner-warn">
⚠ 当前未配置 SSH 目标 (没有活跃实例或活跃实例未启用 SSH)。
请先在
<a href="{{ url_for('instances_view') }}">多实例管理</a>
中为实例填写 <code>ssh_host</code> / <code>ssh_user</code> / <code>ssh_key_path</code>
</div>
{% endif %}
<div class="banner banner-danger">
<strong>🔒 审计提示:</strong>
所有 SSH 会话的开启、关闭、resize 事件以及输入数据
(经过编辑保留关键字符) 都会写入 <code>audit_log</code> 表,
操作记录可在
<a href="{{ url_for('audit_view') }}">审计日志</a>
中查询。非法操作将被追责。
</div>
{% if paramiko_missing %}
<div class="banner banner-error" id="paramiko-error-banner">
<strong>❌ 后端依赖缺失:</strong>
检测到当前 Python 环境未安装 <code>paramiko</code>,WebSSH 功能不可用。
请在服务器上执行
<code>pip install paramiko flask-sock</code>
后重启服务。当前页面仅供布局预览。
</div>
{% endif %}
<div class="terminal-config">
<div class="terminal-config-row">
<span class="meta-text">SSH 目标:</span>
{% if ssh_target %}
<code class="terminal-ssh-host">{{ ssh_target }}</code>
{% else %}
<code class="meta-text">(未配置)</code>
{% endif %}
<span class="meta-text">| 用户:</span>
<code>{{ ssh_user if ssh_user else '-' }}</code>
<span class="meta-text">| 端口:</span>
<code>{{ ssh_port }}</code>
<span class="meta-text">| 密钥:</span>
<code title="{{ ssh_key_path or '' }}">{{ ssh_key_short or '-' }}</code>
<span class="meta-text">| 实例:</span>
<code>{{ instance_name }}</code>
</div>
</div>
<div class="card terminal-card">
<h3 class="card-title">🖥️ 终端</h3>
<div id="terminal-error" class="banner banner-error" style="display:none;"></div>
<div id="terminal" class="terminal-host" data-testid="terminal-host"></div>
<div class="terminal-statusbar">
<span>状态:</span>
<span id="terminal-status" class="badge badge-gray">未连接</span>
<span class="meta-text">|</span>
<span>目标:</span>
<code id="terminal-target">{% if ssh_target %}{{ ssh_target }}{% else %}(未配置){% endif %}</code>
<span class="terminal-statusbar-spacer"></span>
<span id="terminal-bytes" class="meta-text">0 B in / 0 B out</span>
<button id="terminal-exit" type="button" class="btn btn-danger btn-small">⏹ 断开/退出</button>
</div>
</div>
<div class="card">
<h3 class="card-title">📘 使用说明</h3>
<ul class="card-list">
<li>终端会在页面加载时自动建立 WebSocket → paramiko SSH 通道,默认 80×24。</li>
<li>支持 <kbd>Ctrl+C</kbd> / <kbd>Ctrl+D</kbd> / <kbd>Ctrl+L</kbd> 等组合键,窗口大小变化会自动 resize。</li>
<li>顶部"断开/退出"按钮会主动关闭 SSH 会话并写入审计记录。</li>
<li>为安全起见,本功能仅 <code>admin</code> 角色可见可用,其他用户不会显示此入口。</li>
{% if ssh_key_path %}
<li>实例配置的密钥文件:
<code>{{ ssh_key_path }}</code>
<span class="meta-text">(如服务端读不到,UI 会显示错误)</span>
</li>
{% endif %}
</ul>
</div>
{% endblock %}
{% block scripts %}
{# xterm.js + addon-fit from CDN. We pin to a known-good version; the
addon-fit script is shipped as part of xterm-addon-fit. #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js" crossorigin="anonymous"></script>
<style>
/* --- terminal layout --- */
.terminal-config {
margin: 12px 0;
padding: 8px 12px;
background: rgba(0,0,0,0.18);
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.06);
font-size: 0.85rem;
}
.terminal-config-row > * { margin-right: 6px; }
.terminal-ssh-host { color: #4ec9b0; font-weight: 600; }
.terminal-card { padding: 12px; }
.terminal-host {
width: 100%;
height: 480px;
background: #1e1e1e;
border-radius: 6px;
overflow: hidden;
padding: 4px;
}
.terminal-host .xterm,
.terminal-host .xterm-viewport,
.terminal-host .xterm-screen { height: 100% !important; }
.terminal-statusbar {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
padding: 8px 10px;
border: 1px solid rgba(255,255,255,0.08);
border-radius: 6px;
background: rgba(0,0,0,0.12);
font-size: 0.85rem;
flex-wrap: wrap;
}
.terminal-statusbar-spacer { flex: 1; }
.banner-danger {
background: rgba(220, 53, 69, 0.12);
border-left: 4px solid #dc3545;
padding: 10px 14px;
margin: 12px 0;
border-radius: 4px;
}
.banner-error {
background: rgba(255, 80, 80, 0.18);
border: 1px solid #ff5252;
color: #ffb3b3;
padding: 10px 14px;
margin: 8px 0;
border-radius: 4px;
}
.banner-warn {
background: rgba(255, 193, 7, 0.10);
border-left: 4px solid #ffc107;
padding: 10px 14px;
margin: 12px 0;
border-radius: 4px;
}
.card-list { margin-left: 20px; }
.card-list li { margin: 4px 0; }
</style>
<script>
(function() {
"use strict";
const ERR_BANNER = document.getElementById('terminal-error');
const ERR_BANNER_PARAMIKO = document.getElementById('paramiko-error-banner');
const STATUS_EL = document.getElementById('terminal-status');
const BYTES_EL = document.getElementById('terminal-bytes');
const TARGET_EL = document.getElementById('terminal-target');
const EXIT_BTN = document.getElementById('terminal-exit');
function setStatus(text, cls) {
STATUS_EL.textContent = text;
STATUS_EL.className = 'badge ' + (cls || 'badge-gray');
}
function showError(msg) {
ERR_BANNER.style.display = 'block';
ERR_BANNER.textContent = '❌ ' + msg;
}
function clearError() {
ERR_BANNER.style.display = 'none';
}
/* Initialise xterm.js */
let term;
try {
term = new Terminal({
cursorBlink: true,
fontSize: 13,
fontFamily: 'Menlo, Monaco, Consolas, "DejaVu Sans Mono", monospace',
scrollback: 5000,
cols: 80,
rows: 24,
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#ffffff',
},
});
} catch (e) {
showError('xterm.js 加载失败,请检查网络 (CDN 被拦截): ' + e);
setStatus('加载失败', 'badge-red');
return;
}
const fitAddon = (window.FitAddon && window.FitAddon.FitAddon)
? new window.FitAddon.FitAddon()
: null;
term.open(document.getElementById('terminal'));
if (fitAddon) { try { fitAddon.fit(); } catch (_) {} }
term.writeln('\x1b[33m正在连接 SSH 服务,请稍候...\x1b[0m');
/* WebSocket */
const wsScheme = (window.location.protocol === 'https:') ? 'wss' : 'ws';
const wsUrl = wsScheme + '://' + window.location.host + '/ws/terminal';
let ws = null;
let bytesIn = 0, bytesOut = 0;
let closing = false;
function updateBytes() {
BYTES_EL.textContent = `${bytesIn} B in / ${bytesOut} B out`;
}
function connect() {
if (closing) return;
try {
ws = new WebSocket(wsUrl);
} catch (e) {
showError('无法创建 WebSocket: ' + e);
setStatus('异常', 'badge-red');
return;
}
setStatus('连接中...', 'badge-orange');
ws.onopen = function() {
setStatus('已连接', 'badge-green');
clearError();
// send resize
const cols = term.cols || 80;
const rows = term.rows || 24;
ws.send(JSON.stringify({type: 'resize', cols: cols, rows: rows}));
};
ws.onmessage = function(ev) {
let msg;
try { msg = JSON.parse(ev.data); }
catch (_) { term.write(ev.data); return; }
if (msg.type === 'output') {
term.write(msg.data);
bytesIn += (msg.data && msg.data.length) || 0;
updateBytes();
} else if (msg.type === 'status') {
term.writeln('\r\n\x1b[36m[状态] ' + (msg.msg || '') + '\x1b[0m');
} else if (msg.type === 'error') {
showError(msg.msg || 'unknown error');
setStatus('错误', 'badge-red');
term.writeln('\r\n\x1b[31m[错误] ' + (msg.msg || '') + '\x1b[0m');
// server forced close; close ws
try { ws.close(); } catch (_) {}
}
};
ws.onerror = function() {
setStatus('连接失败', 'badge-red');
showError('WebSocket 出现错误,请检查服务器日志。');
};
ws.onclose = function() {
setStatus('已断开', 'badge-gray');
term.writeln('\r\n\x1b[33m[提示] WebSocket 已关闭\x1b[0m');
};
}
/* input -> server */
term.onData(function(data) {
if (!ws || ws.readyState !== 1) return;
try {
ws.send(JSON.stringify({type: 'input', data: data}));
bytesOut += data.length;
updateBytes();
} catch (_) {}
});
/* resize */
function sendResize() {
if (!ws || ws.readyState !== 1) return;
try { ws.send(JSON.stringify({type: 'resize', cols: term.cols, rows: term.rows})); }
catch (_) {}
}
if (fitAddon) {
window.addEventListener('resize', function() {
try { fitAddon.fit(); sendResize(); } catch (_) {}
});
}
/* exit button */
EXIT_BTN.addEventListener('click', function() {
if (ws && ws.readyState === 1) {
try { ws.close(1000, 'user-exit'); } catch (_) {}
}
closing = true;
setStatus('已主动断开', 'badge-gray');
term.writeln('\r\n\x1b[33m[用户操作] 已请求断开 SSH 会话\x1b[0m');
});
/* kick off */
connect();
})();
</script>
{% endblock %}