4a1d949e41
完整 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
304 lines
10 KiB
HTML
304 lines
10 KiB
HTML
{% 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 %}
|