Files
shiroko-monitor/public/admin.html
T
tamakyi 6b0a23c38e feat: v0.12.2 —— 跨域只读支持(面板可部署在别处)
用户诉求:管理端与前端完全分离,本地探针只跑 /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 误以为失败。
2026-10-14 16:19:31 +08:00

221 lines
11 KiB
HTML
Raw 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.
<!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>