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
307 lines
11 KiB
HTML
307 lines
11 KiB
HTML
{% 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 %}
|