用户诉求:管理端与前端完全分离,本地探针只跑 /admin,前端部署到 GitHub Pages 并硬编码管理地址,探测仍在本地常驻。用户自己搭隧道给本地套 HTTPS。 硬限制(务必先看):GitHub Pages 强制 HTTPS,而浏览器「混合内容」策略禁止 HTTPS 页面 fetch HTTP 资源 —— 这与 CORS 无关,改设置也没用。 因此 settings.dataUrl 必须与面板页面同为 HTTPS。 服务端 - 只读接口(/api/status、/api/monitors、/api/events、/api/v2/getMonitors、 /api/refresh)按 settings.corsOrigins 白名单回显 Access-Control-Allow-Origin - /api/admin/* 一律不放行跨域,预检直接 403 —— 外部页面既读不到也改不了 - 绝不使用 '*':白名单里写 '*' 不放行任何来源 前端 - settings.dataUrl 存在时,所有 api/ 相对路径自动改写到该地址 - 数据源标记为「远程探针(跨域)」,页脚可区分 - 管理界面新增两项配置:跨域数据源地址、允许跨域的页面地址(改完立即生效) 关键设计:origin 匹配必须考虑端口 origin 是「协议 + 主机 + 端口」的完整组合。最初白名单存 http://localhost(无端口), 导致本地前端跑在 localhost:5173 时被拒 —— 浏览器实测 Failed to fetch 而 curl 能过。 修法:回环主机(localhost / 127.0.0.1)忽略端口放行,其余要求完整 origin 精确匹配。 测试:verify-security 25→40 项,新增 [D3] CORS 白名单 14 条 + [D4] '*' 写法 1 条。 全项目 8 套 334 项通过。 附记一个测试上的教训:跨域 fetch 时 JS 读不到 Access-Control-Allow-Origin (CORS 规范规定只有同源能读),所以断言「是否放行」要看 fetch 有没有拿到数据, 不能看响应头 —— 我一开始用 headers.get() 判断,拿到 null 误以为失败。
221 lines
11 KiB
HTML
221 lines
11 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta name="robots" content="noindex, nofollow">
|
||
<title>管理后台 · Shiroko Monitor</title>
|
||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
||
<link rel="stylesheet" href="/assets/style.css">
|
||
<link rel="stylesheet" href="/assets/theme-shiroko.css">
|
||
<link rel="stylesheet" href="/assets/admin.css">
|
||
</head>
|
||
<body class="admin-body">
|
||
<header class="admin-topbar">
|
||
<div class="brand">
|
||
<span class="logo" aria-hidden="true"></span>
|
||
<div class="brand-text">
|
||
<h1>管理后台</h1>
|
||
<p>需绑定并通过 2FA 验证后才能进入</p>
|
||
</div>
|
||
</div>
|
||
<div class="admin-topbar-right">
|
||
<span class="admin-ver" id="admin-ver">–</span>
|
||
<a class="admin-link" href="/">← 返回面板</a>
|
||
<button type="button" class="admin-btn admin-btn-ghost" id="logout-btn" hidden>退出登录</button>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="admin-main">
|
||
<!-- 状态探测中 -->
|
||
<section class="admin-card" id="loading"><p class="admin-muted">正在检查 2FA 状态…</p></section>
|
||
|
||
<!-- ① 未绑定:先绑定 2FA -->
|
||
<section class="admin-card" id="bind-panel" hidden>
|
||
<h2>第一步:绑定 2FA</h2>
|
||
<p class="admin-muted">管理后台必须绑定两步验证后才能进入。请先在启动探针的环境里设置 <code>SHIROKO_ADMIN_SETUP_KEY</code>,然后在这里输入它以生成密钥。</p>
|
||
|
||
<div class="admin-step" id="step-key">
|
||
<label for="setup-key">绑定口令(SHIROKO_ADMIN_SETUP_KEY)</label>
|
||
<div class="admin-row">
|
||
<input type="password" id="setup-key" autocomplete="off" placeholder="环境变量里设置的那个值" inputmode="text">
|
||
<button type="button" class="admin-btn" id="setup-start">生成密钥</button>
|
||
</div>
|
||
<p class="admin-msg" id="setup-key-msg"></p>
|
||
</div>
|
||
|
||
<div class="admin-step" id="step-secret" hidden>
|
||
<h3>第二步:把密钥加入你的验证器 App</h3>
|
||
<ol class="admin-ol">
|
||
<li>用 Google Authenticator / Microsoft Authenticator / 1Password / Aegis 扫描二维码,或</li>
|
||
<li>手动输入下面的密钥(Base32):</li>
|
||
</ol>
|
||
<div class="qr-box"><canvas id="qr" width="220" height="220" aria-label="2FA 绑定二维码"></canvas></div>
|
||
<div class="secret-box">
|
||
<code id="secret-text">–</code>
|
||
<button type="button" class="admin-btn admin-btn-sm" id="copy-secret">复制密钥</button>
|
||
</div>
|
||
<details class="admin-details">
|
||
<summary>扫码不方便?复制 otpauth 链接</summary>
|
||
<div class="secret-box"><code id="uri-text">–</code><button type="button" class="admin-btn admin-btn-sm" id="copy-uri">复制链接</button></div>
|
||
</details>
|
||
<h3>第三步:输入 App 显示的 6 位验证码完成绑定</h3>
|
||
<div class="admin-row">
|
||
<input type="text" id="confirm-code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="6 位数字">
|
||
<button type="button" class="admin-btn" id="confirm-btn">完成绑定</button>
|
||
</div>
|
||
<p class="admin-msg" id="confirm-msg"></p>
|
||
</div>
|
||
|
||
<div class="admin-step" id="step-recovery" hidden>
|
||
<h3>第四步:保存恢复码(只显示这一次)</h3>
|
||
<p class="admin-muted">每个恢复码只能用一次,用于手机丢失、验证器清库时登录。请离线保存,不要只存在这台机器上。</p>
|
||
<div class="recovery-box" id="recovery-list"></div>
|
||
<div class="admin-row">
|
||
<button type="button" class="admin-btn admin-btn-sm" id="copy-recovery">复制全部恢复码</button>
|
||
<button type="button" class="admin-btn admin-btn-primary" id="recovery-done">我已保存</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ② 已绑定未登录:输 2FA 码登录 -->
|
||
<section class="admin-card admin-card-narrow" id="login-panel" hidden>
|
||
<h2>两步验证</h2>
|
||
<p class="admin-muted">输入验证器 App 的 6 位动态码,或使用一枚未用过的恢复码。</p>
|
||
<div class="admin-row">
|
||
<input type="text" id="login-code" inputmode="numeric" autocomplete="one-time-code" placeholder="验证码 / 恢复码" autofocus>
|
||
<button type="button" class="admin-btn admin-btn-primary" id="login-btn">进入</button>
|
||
</div>
|
||
<p class="admin-msg" id="login-msg"></p>
|
||
<details class="admin-details">
|
||
<summary>验证码设备丢了?</summary>
|
||
<p class="admin-muted">用一枚未使用的恢复码登录;或用 <code>SHIROKO_ADMIN_SETUP_KEY</code> 重置 2FA(重置后需重新绑定)。</p>
|
||
<div class="admin-row">
|
||
<input type="password" id="reset-key" placeholder="SHIROKO_ADMIN_SETUP_KEY" autocomplete="off">
|
||
<button type="button" class="admin-btn admin-btn-danger" id="reset-btn">重置 2FA</button>
|
||
</div>
|
||
</details>
|
||
</section>
|
||
|
||
<!-- ③ 已登录:管理面板 -->
|
||
<section id="console-panel" hidden>
|
||
<div class="admin-card" id="stat-card">
|
||
<h2>运行状态</h2>
|
||
<div class="admin-stats" id="admin-stats"></div>
|
||
<p class="admin-muted" id="updated-at">–</p>
|
||
</div>
|
||
|
||
<div class="admin-card">
|
||
<h2>口径与范围</h2>
|
||
<div class="admin-field">
|
||
<label for="probe-scope">探测范围</label>
|
||
<select id="probe-scope">
|
||
<option value="all">全部(自建 + UptimeRobot)</option>
|
||
<option value="config">仅自建配置</option>
|
||
<option value="uptimerobot">仅 UptimeRobot</option>
|
||
</select>
|
||
<p class="admin-hint">决定探针真正发请求的范围;改动立即生效并热重载配置。</p>
|
||
</div>
|
||
<div class="admin-field">
|
||
<label for="data-filter">展示口径</label>
|
||
<select id="data-filter">
|
||
<option value="both">全部</option>
|
||
<option value="config">仅自建配置</option>
|
||
<option value="uptimerobot">仅 UptimeRobot</option>
|
||
</select>
|
||
<p class="admin-hint">只影响面板展示哪些目标;与探测范围相互独立。</p>
|
||
</div>
|
||
<div class="admin-field">
|
||
<label for="admin-url">面板上的管理入口链接</label>
|
||
<input type="url" id="admin-url" placeholder="https://admin.example.com/admin" autocomplete="off">
|
||
<p class="admin-hint">
|
||
填了之后,公开面板页脚会出现一个「管理」按钮指向这里;<b>留空则不显示</b>。
|
||
地址由你自行决定怎么暴露(内网 IP、Tailscale、VPS 域名、隧道……都可以),
|
||
面板不做任何假设。
|
||
</p>
|
||
</div>
|
||
<div class="admin-field">
|
||
<label for="data-url">跨域数据源地址</label>
|
||
<input type="url" id="data-url" placeholder="https://monitor.example.com" autocomplete="off">
|
||
<p class="admin-hint">
|
||
仅当<b>面板部署在别处</b>(GitHub Pages / Cloudflare)而探针在另一台机器时才需要填。
|
||
填了之后面板会去这个地址取数据,因此<b>该地址必须与页面同为 HTTPS</b> ——
|
||
HTTPS 页面读 HTTP 会被浏览器以「混合内容」为由直接拦截,与跨域设置无关。
|
||
</p>
|
||
</div>
|
||
<div class="admin-field">
|
||
<label for="cors-origins">允许跨域读取的页面地址</label>
|
||
<input type="text" id="cors-origins" placeholder="https://you.github.io, https://monitor.example.com" autocomplete="off">
|
||
<p class="admin-hint">
|
||
逗号分隔,<b>必须是完整来源(含协议与端口)</b>。只有这里列出的页面才能跨域读走监控数据;
|
||
默认只放行 <code>http://127.0.0.1</code> 与 <code>http://localhost</code>。管理端本身永远不同源开放。
|
||
</p>
|
||
</div>
|
||
<div class="admin-row">
|
||
<button type="button" class="admin-btn" id="probe-now">立即重新探测</button>
|
||
<button type="button" class="admin-btn" id="notify-test">发送通知自检</button>
|
||
</div>
|
||
<p class="admin-msg" id="action-msg"></p>
|
||
</div>
|
||
|
||
<div class="admin-card">
|
||
<h2>维护窗口</h2>
|
||
<p class="admin-muted">窗口内的目标不计入可用率、也不发故障告警。</p>
|
||
<div class="admin-field-row">
|
||
<div class="admin-field">
|
||
<label for="mw-from">开始</label>
|
||
<input type="datetime-local" id="mw-from">
|
||
</div>
|
||
<div class="admin-field">
|
||
<label for="mw-to">结束</label>
|
||
<input type="datetime-local" id="mw-to">
|
||
</div>
|
||
</div>
|
||
<div class="admin-field-row">
|
||
<div class="admin-field">
|
||
<label for="mw-id">仅限目标(留空 = 全部)</label>
|
||
<select id="mw-id"><option value="">全部目标</option></select>
|
||
</div>
|
||
<div class="admin-field">
|
||
<label for="mw-note">备注</label>
|
||
<input type="text" id="mw-note" maxlength="200" placeholder="例如:升级数据库">
|
||
</div>
|
||
</div>
|
||
<div class="admin-row">
|
||
<button type="button" class="admin-btn admin-btn-primary" id="mw-add">新增窗口</button>
|
||
</div>
|
||
<div class="mw-list" id="mw-list"></div>
|
||
</div>
|
||
|
||
<div class="admin-card">
|
||
<h2>监控目标</h2>
|
||
<p class="admin-muted">当前配置中的目标(探测范围为 uptimerobot 时,本地目标不会被真正探测)。</p>
|
||
<div class="table-scroll">
|
||
<table class="admin-table">
|
||
<thead><tr><th>名称</th><th>分组</th><th>类型</th><th>目标</th><th>间隔</th></tr></thead>
|
||
<tbody id="monitor-list"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="admin-card">
|
||
<h2>最近事件</h2>
|
||
<div class="table-scroll">
|
||
<table class="admin-table ev-table">
|
||
<thead><tr><th>时间</th><th>类型</th><th>目标</th><th>详情</th></tr></thead>
|
||
<tbody id="event-list"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="admin-card">
|
||
<h2>安全</h2>
|
||
<p class="admin-muted" id="twofa-summary">–</p>
|
||
<p class="admin-muted">会话最长 2 小时,30 分钟无操作自动失效;进程重启会清空所有会话。</p>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
<script type="module" src="/assets/admin.js"></script>
|
||
</body>
|
||
</html> |