Files
wangjia 6e99e32285 feat(admin): 管理端最小后台(独立监听 + 白名单 + 2FA)tsk_SCMtcGF4F434
实现「一个二进制三个监听」中的管理端:

- 独立监听 ADMIN_LISTEN(默认 127.0.0.1:9443,FromEnv 拒绝 0.0.0.0/:: 等公网绑定)
- 中间件链:mw_ipallow(CIDR 白名单,仅信任 RemoteAddr,默认内网段)→
  mw_session(HttpOnly+Secure+SameSite=Strict cookie,Redis 30min 滑动 TTL)
- 登录双因素:argon2id 密码 + RFC6238 TOTP;失败限流与临时锁定;
  TOTP 密钥 AES-GCM 加密入库;migration 000008 增补 admins 表;
  首个管理员由 cmd/adminctl create 创建(终端输出 otpauth URI/Secret)
- 功能三块(html/template + embed 静态资源,原生 JS 二次确认):
  1. 码批次:表单生成→明文仅在本次 CSV 下载出现(不落盘/不入日志);
     批次列表 + 整批作废(复用 #3 codes,新增 Store.ListBatches/VoidBatch)
  2. 节点操作:列表 + 近期 node_events;replace 经 #14 ProvisionService、
     draining/up 经 #5 Lifecycle(二者未就绪→注入 stub,UI 置灰);二次确认 + CSRF
  3. audit_log 查看:actor/action/target/时间范围过滤分页;同页查 node_events
- 所有写操作写 audit_log(actor=用户名,meta 不含明文);管理端仅记安全事件
  (登录失败/锁定/白名单拦截),不记常规访问日志
- internal/totp 复用包(与 doc/05 用户中心 2FA 同算法,纯标准库)

测试:totp RFC6238 向量、argon2/AES 往返、白名单放行/拦截、会话滑动过期、
登录成功/密码错/TOTP 错/未知用户/锁定、批次 CSV 含明文且 audit 不泄露、
作废/节点操作的二次确认与 CSRF 缺失被拒、mock 断言 service 调用参数、审计过滤。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:49:32 +08:00

53 lines
2.7 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.
{{define "title"}}节点{{end}}
{{define "content"}}
<h1>节点</h1>
{{if not .Data.ProvisionReady}}<div class="warn">节点替换服务(#14)尚未接入,相关按钮已置灰。</div>{{end}}
{{if not .Data.LifecycleReady}}<div class="warn">节点生命周期服务(#5)尚未接入,上/下线按钮已置灰。</div>{{end}}
<table>
<thead><tr><th>ID</th><th>名称</th><th>地区</th><th>tier</th><th>权重</th><th>状态</th><th>厂商</th><th>近期事件</th><th>操作</th></tr></thead>
<tbody>
{{range .Data.Nodes}}
<tr>
<td>{{.ID}}</td>
<td>{{.NameZH}}<br><span class="muted">{{.UUID}}</span></td>
<td>{{.Region}}</td>
<td>{{.Tier}}</td>
<td>{{.Weight}}</td>
<td><span class="status status-{{.Status}}">{{.Status}}</span></td>
<td>{{.Provider}}</td>
<td>
{{range .RecentEvents}}<div class="ev">{{.Event}} <span class="muted">{{fmtTime .At}}</span></div>{{else}}<span class="muted"></span>{{end}}
</td>
<td class="ops">
<form method="post" action="/nodes/op" class="inline confirm" data-confirm="确认替换节点 {{.ID}}{{.NameZH}})?将开通新节点并下线旧节点。">
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
<input type="hidden" name="node_id" value="{{.ID}}">
<input type="hidden" name="op" value="replace">
<input type="hidden" name="confirm" value="yes">
<button type="submit" {{if not $.Data.ProvisionReady}}disabled{{end}}>替换</button>
</form>
<form method="post" action="/nodes/op" class="inline confirm" data-confirm="确认将节点 {{.ID}} 置为 draining(停止接入新连接)?">
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
<input type="hidden" name="node_id" value="{{.ID}}">
<input type="hidden" name="op" value="draining">
<input type="hidden" name="confirm" value="yes">
<button type="submit" {{if not $.Data.LifecycleReady}}disabled{{end}}>draining</button>
</form>
<form method="post" action="/nodes/op" class="inline confirm" data-confirm="确认将节点 {{.ID}} 置为 up(恢复接入)?">
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
<input type="hidden" name="node_id" value="{{.ID}}">
<input type="hidden" name="op" value="up">
<input type="hidden" name="confirm" value="yes">
<button type="submit" {{if not $.Data.LifecycleReady}}disabled{{end}}>up</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="9" class="muted">暂无节点</td></tr>
{{end}}
</tbody>
</table>
<script src="/static/confirm.js"></script>
{{end}}