Files
Hermes Agent 4a1d949e41 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
2026-07-15 10:19:21 +08:00

304 lines
10 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 %}