From f3b3219cdbbf23cbfd0a4753d1183c182ba136c2 Mon Sep 17 00:00:00 2001 From: Gloridust Date: Wed, 8 Jul 2026 02:57:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=AC=E7=BD=91=E8=AE=BF=E9=97=AE?= =?UTF-8?q?=E5=8A=A0=E5=9B=BA=EF=BC=88=E7=99=BB=E5=BD=95=E9=99=90=E9=80=9F?= =?UTF-8?q?/=E4=BC=9A=E8=AF=9D=E6=BB=91=E5=8A=A8=E7=BB=AD=E6=9C=9F?= =?UTF-8?q?=EF=BC=89+=20=E7=AE=A1=E7=90=86=E9=A1=B5=E4=B8=89=20Tab=20?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E6=9E=B6=E6=9E=84=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 公网/NAS 稳定性专项扫描的产出: ① 登录限速:面板常被直接暴露公网,/api/auth/login 原先无任何限速可被无限爆破。 双键计数(IP+用户名 5 次、IP 20 次 / 15 分钟窗口),触发回 429 + 面板日志告警; 用 socket 直连地址而非可伪造的 XFF;成功登录清零。已实测:5×401 → 第 6 次 429。 ② 会话滑动续期:固定 30 天硬过期在"常年挂着"的 NAS 场景表现为"隔三差五要重登"。 现剩余不足一半自动续满(服务端),鉴权层顺手刷新 cookie maxAge(浏览器侧同步)—— 活跃用户永不掉线,闲置超时才需重登。 ③ DNS-rebind 钩子补 return reply(async 钩子已 send 后的规范终止写法)。 ④ 运维文档新增「公网访问 / 反代部署」章节:nginx 完整示例(ws 升级头 + proxy_read_timeout 3600s——默认 60s 是"桌面挂着不动 1 分钟就断线"的元凶 + client_max_body_size)、Caddy/frp/Cloudflare 注意事项、PANEL_ALLOWED_HOSTS 提醒。 ⑤ 管理页重构:7 个区块一条长滚动 → 「实例 / 用户 / 系统」三 Tab (实例=日常操作;用户=子账号+本账号;系统=残留容器+数据卷+诊断日志+关于)。 牛奶布艺分段选择器(凹槽+浮起胶囊),Tab 角标点提示待处理事项(可升级/有残留), 记住上次停留 Tab;子账号(非管理员)视图不变。 --- doc/部署与运维.md | 37 ++++++++++++++ panel/server/src/index.ts | 43 ++++++++++++++++ panel/server/src/sessions.ts | 9 ++++ panel/web/src/pages/Admin.tsx | 93 +++++++++++++++++++++++++++-------- panel/web/src/styles.css | 53 ++++++++++++++++++++ 5 files changed, 215 insertions(+), 20 deletions(-) diff --git a/doc/部署与运维.md b/doc/部署与运维.md index ae3f3e4..d910f52 100644 --- a/doc/部署与运维.md +++ b/doc/部署与运维.md @@ -99,3 +99,40 @@ WechatOnCloud/ ``` 数据:面板账号(含密码哈希)在 `./data-panel`,各微信实例在 docker 命名卷 `woc-data-`;`./data-panel` 已在 `.gitignore` 中。 + +## 公网访问 / 反代部署(nginx 示例) + +把面板暴露到公网(域名 + HTTPS)时,有三件事必须做对,否则表现为「登录 400 / 桌面黑屏 / 挂一会儿就断线」: + +1. **把对外域名加进 `PANEL_ALLOWED_HOSTS`**(`.env`,逗号分隔,支持 `*.example.com`),然后 `docker compose up -d` 重建(不是 restart)。这是防 DNS-rebinding 的白名单,不加会被面板以 400 拒绝。 +2. **反代必须透传 WebSocket**(远程桌面全靠它)。 +3. **把空闲超时调大**。nginx 默认 `proxy_read_timeout 60s`——桌面静止不动 1 分钟没有数据流过,nginx 就掐断连接,表现为「挂着不动就断线重连」。 + +```nginx +server { + listen 443 ssl; + server_name woc.example.com; + # ssl_certificate / ssl_certificate_key ... + + location / { + proxy_pass http://:36080; + proxy_http_version 1.1; + # WebSocket 升级(远程桌面/音频都走 ws) + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + # 透传真实 Host(与 PANEL_ALLOWED_HOSTS 对得上) + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # 关键:空闲超时调大,否则桌面静止 60s 即断(nginx 默认值) + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + # 大文件上传(传文件到桌面 / 卷导入) + client_max_body_size 3g; + } +} +``` + +其它反代同理:Caddy 默认无空闲超时(开箱即用);frp 建议 `transport.heartbeatInterval` 保持默认并确认 `proxy_read_timeout` 类配置未收紧;Cloudflare 免费版对 WebSocket 空闲约 100s 断开,桌面场景建议用「灰云」(仅 DNS)或自建隧道。 + +安全提示:面板已内建登录限速(15 分钟窗口内同源失败 5 次即暂时锁定),但公网暴露仍建议:强密码、改默认端口、优先用 VPN/内网穿透而非直接开放。 diff --git a/panel/server/src/index.ts b/panel/server/src/index.ts index 400a475..12906fc 100644 --- a/panel/server/src/index.ts +++ b/panel/server/src/index.ts @@ -132,6 +132,7 @@ app.addHook('onRequest', async (req, reply) => { forwardedHost: req.headers['x-forwarded-host'] || null, hint: '反代部署请把对外域名加入 PANEL_ALLOWED_HOSTS(.env 逗号分隔,支持 *.example.com),改完用 docker compose up -d 重建容器(不是 restart)使其生效', }); + return reply; // 显式终止后续生命周期(async 钩子里已 send 时的规范写法,防继续进入路由) } }); @@ -158,6 +159,19 @@ function requireAuth(req: FastifyRequest, reply: FastifyReply): User | null { reply.code(401).send({ error: '未登录' }); return null; } + // 会话滑动续期(见 sessions.ts):服务端刚续过期时间的话,顺手把 cookie 的 maxAge 也刷新, + // 浏览器侧同步续期——否则服务端会话还活着、cookie 却先过期,效果等于没续。 + const token = req.cookies?.[COOKIE]; + const s = token ? getSession(token) : null; + if (s?.slid) { + s.slid = false; + reply.setCookie(COOKIE, token!, { + httpOnly: true, + sameSite: 'lax', + path: '/', + maxAge: Math.floor(SESSION_TTL_MS / 1000), + }); + } return u; } @@ -172,12 +186,41 @@ function requireAdmin(req: FastifyRequest, reply: FastifyReply): User | null { } // ---------- 登录 / 会话 ---------- +// 登录限速:NAS 面板常被直接暴露公网,无限速 = 可被无脑爆破。 +// 双键计数:来源 IP+用户名(5 次/15 分钟)防单账号爆破;来源 IP(20 次/15 分钟)防换用户名轮询。 +// 用 socket 直连地址而非 X-Forwarded-For——trustProxy 开着,XFF 可伪造,直连地址不可伪造 +// (反代场景下直连地址是反代 IP,限速粒度变粗但依然有效兜底)。成功登录清零。纯内存,重启即清。 +const loginFails = new Map(); +const LOGIN_WINDOW_MS = 15 * 60 * 1000; +function loginFailCheck(key: string, max: number): boolean { + const e = loginFails.get(key); + if (!e || e.resetAt < Date.now()) return true; + return e.n < max; +} +function loginFailBump(key: string): void { + const e = loginFails.get(key); + if (!e || e.resetAt < Date.now()) loginFails.set(key, { n: 1, resetAt: Date.now() + LOGIN_WINDOW_MS }); + else e.n++; + // 防内存膨胀:过千条时清一次过期项 + if (loginFails.size > 1000) for (const [k, v] of loginFails) if (v.resetAt < Date.now()) loginFails.delete(k); +} app.post('/api/auth/login', async (req, reply) => { const { username, password } = (req.body as any) ?? {}; + const ip = req.raw.socket?.remoteAddress || '?'; + const ipKey = `ip:${ip}`; + const userKey = `u:${ip}|${String(username || '')}`; + if (!loginFailCheck(ipKey, 20) || !loginFailCheck(userKey, 5)) { + appendPanelLog('WARN', `登录限速触发:来源 ${ip} 尝试登录「${String(username || '')}」被暂时拒绝(15 分钟窗口内失败过多)`); + return reply.code(429).send({ error: '登录失败次数过多,请 15 分钟后再试' }); + } const u = username ? findByUsername(username) : undefined; if (!u || u.disabled || !verifyPassword(u, password ?? '')) { + loginFailBump(ipKey); + loginFailBump(userKey); return reply.code(401).send({ error: '用户名或密码错误' }); } + loginFails.delete(ipKey); + loginFails.delete(userKey); const token = createSession(u.id); reply.setCookie(COOKIE, token, { httpOnly: true, diff --git a/panel/server/src/sessions.ts b/panel/server/src/sessions.ts index 89499b3..39ce55e 100644 --- a/panel/server/src/sessions.ts +++ b/panel/server/src/sessions.ts @@ -5,6 +5,7 @@ import { dirname } from 'node:path'; interface Session { userId: string; expires: number; + slid?: boolean; // 瞬态:本次请求刚做过滑动续期,提示鉴权层顺手刷新 cookie(持久化了也无害) } // 会话时长:可配置(天),默认 30 天。避免频繁重登(issue #95)。 @@ -66,6 +67,14 @@ export function getSession(token?: string) { save(); return null; } + // 滑动续期:剩余不足一半即续满。NAS 面板常年挂着,固定 30 天硬过期的体验就是 + // "隔三差五要重登";滑动后活跃用户永不掉线,闲置超过时长才需重登。 + // index.ts 的鉴权层看到 slid 标记会顺手刷新 cookie 的 maxAge,浏览器侧同步续期。 + if (s.expires - Date.now() < SESSION_TTL_MS / 2) { + s.expires = Date.now() + SESSION_TTL_MS; + s.slid = true; + save(); + } return s; } diff --git a/panel/web/src/pages/Admin.tsx b/panel/web/src/pages/Admin.tsx index 70f78af..74b1996 100644 --- a/panel/web/src/pages/Admin.tsx +++ b/panel/web/src/pages/Admin.tsx @@ -278,6 +278,20 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () const [volumeInst, setVolumeInst] = useState(null); // 数据卷管理弹窗 const [iconInst, setIconInst] = useState(null); // 图标编辑弹窗 const [acting, setActing] = useState>({}); // 实例 id → 进行中的动作文案(启动中/升级中…) + // 管理页信息架构:实例 / 用户 / 系统 三个 Tab(此前 7 个区块一条长滚动,找东西全靠翻)。 + // 记住上次停留的 Tab(sessionStorage),升级轮询等跨 Tab 状态不受影响——Tab 只控制渲染。 + const [tab, setTabRaw] = useState<'inst' | 'users' | 'system'>(() => { + const t = sessionStorage.getItem('woc_admin_tab'); + return t === 'users' || t === 'system' ? t : 'inst'; + }); + const setTab = (t: 'inst' | 'users' | 'system') => { + setTabRaw(t); + try { + sessionStorage.setItem('woc_admin_tab', t); + } catch { + /* ignore */ + } + }; const [upg, setUpg] = useState<{ outdatedCount: number; outdatedIds: string[]; remoteNewer: boolean } | null>(null); // 镜像落后的实例 + 远端有新版 const [upgradingAll, setUpgradingAll] = useState(false); const [upgProgress, setUpgProgress] = useState(''); // 一键升级进度文案("2/5 · 升级「xxx」…") @@ -540,7 +554,32 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
{err &&
{err}
} + {/* 三 Tab 信息架构:实例(日常) / 用户(账号权限) / 系统(维护+诊断+关于)。 + 牛奶布艺分段选择器:凹槽 + 浮起的选中胶囊;角标点提示"该 Tab 里有事要处理"。 */} {isAdmin && ( +
+ {( + [ + { key: 'inst', label: '实例', dot: !!(upg?.outdatedCount || upg?.remoteNewer) && instances.length > 0 }, + { key: 'users', label: '用户', dot: false }, + { key: 'system', label: '系统', dot: orphanConts.length + orphanVols.length > 0 }, + ] as const + ).map((t) => ( + + ))} +
+ )} + + {isAdmin && tab === 'inst' && ( <>
实例 @@ -602,7 +641,12 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
)} -
+ + )} + + {isAdmin && tab === 'users' && ( + <> +
子账号
)} + + )} + + {isAdmin && tab === 'system' && ( + <> {orphanConts.length > 0 && ( <> -
+
残留容器 不属于任何登记实例(多为创建失败遗留);它们占着数据卷名,需先清理它们才能删除同名数据卷。
@@ -718,27 +767,31 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () )} - {/* 账号:所有人(含子账号)都能在此改密 */} -
- 账号 -
-
-
-
- {user?.username} - {isAdmin ? 管理员 : 子账号} + {/* 账号:所有人(含子账号)都能在此改密。管理员放「用户」Tab,子账号无 Tab 直接显示 */} + {(!isAdmin || tab === 'users') && ( + <> +
+ 账号
-
{isAdmin ? '可访问全部实例' : `可访问 ${user?.allowedInstances.length ?? 0} 个实例`}
-
- +
+
+
+ {user?.username} + {isAdmin ? 管理员 : 子账号} +
+
{isAdmin ? '可访问全部实例' : `可访问 ${user?.allowedInstances.length ?? 0} 个实例`}
+
+ +
+
-
-
+ + )} - {isAdmin && } - + {isAdmin && tab === 'system' && } + {(!isAdmin || tab === 'system') && }
{creatingUser && ( diff --git a/panel/web/src/styles.css b/panel/web/src/styles.css index 76e0792..5edb1ca 100644 --- a/panel/web/src/styles.css +++ b/panel/web/src/styles.css @@ -1093,6 +1093,59 @@ button { font-variant-numeric: tabular-nums; } +/* ---------- 管理页三 Tab 分段选择器(牛奶布艺:凹槽 + 浮起的选中胶囊) ---------- */ +.seg-tabs { + display: flex; + gap: 4px; + background: var(--trough); + border-radius: 999px; + padding: 4px; + margin: 2px 0 18px; + width: fit-content; +} +.seg-tab { + position: relative; + border: 0; + background: transparent; + color: var(--muted); + font-size: 14px; + font-weight: 600; + padding: 8px 22px; + border-radius: 999px; + cursor: pointer; + transition: + color 0.18s, + background 0.18s, + box-shadow 0.18s; +} +.seg-tab:hover { + color: var(--text); +} +.seg-tab.active { + background: var(--surface); + color: var(--wx-green); + box-shadow: var(--crease); +} +/* 角标点:该 Tab 内有待处理事项(可升级 / 有残留资源) */ +.seg-dot { + position: absolute; + top: 7px; + right: 10px; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--danger); +} +@media (max-width: 480px) { + .seg-tabs { + width: 100%; + } + .seg-tab { + flex: 1; + padding: 8px 0; + } +} + /* ---------- chip 多选 / 展示 ---------- */ .field-label { font-size: 13px;