feat: 公网访问加固(登录限速/会话滑动续期)+ 管理页三 Tab 信息架构重构

公网/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;子账号(非管理员)视图不变。
This commit is contained in:
Gloridust
2026-07-08 02:57:10 +08:00
parent c54d3ff18e
commit f3b3219cdb
5 changed files with 215 additions and 20 deletions

View File

@@ -99,3 +99,40 @@ WechatOnCloud/
```
数据:面板账号(含密码哈希)在 `./data-panel`,各微信实例在 docker 命名卷 `woc-data-<id>``./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://<NAS 内网 IP>: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/内网穿透而非直接开放。

View File

@@ -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 分钟)防单账号爆破;来源 IP20 次/15 分钟)防换用户名轮询。
// 用 socket 直连地址而非 X-Forwarded-For——trustProxy 开着XFF 可伪造,直连地址不可伪造
// (反代场景下直连地址是反代 IP限速粒度变粗但依然有效兜底。成功登录清零。纯内存重启即清。
const loginFails = new Map<string, { n: number; resetAt: number }>();
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,

View File

@@ -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;
}

View File

@@ -278,6 +278,20 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
const [volumeInst, setVolumeInst] = useState<InstanceWithStatus | null>(null); // 数据卷管理弹窗
const [iconInst, setIconInst] = useState<InstanceWithStatus | null>(null); // 图标编辑弹窗
const [acting, setActing] = useState<Record<string, string>>({}); // 实例 id → 进行中的动作文案(启动中/升级中…)
// 管理页信息架构:实例 / 用户 / 系统 三个 Tab此前 7 个区块一条长滚动,找东西全靠翻)。
// 记住上次停留的 TabsessionStorage升级轮询等跨 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: ()
<main className="content">
{err && <div className="error">{err}</div>}
{/* 三 Tab 信息架构:实例(日常) / 用户(账号权限) / 系统(维护+诊断+关于)。
牛奶布艺分段选择器:凹槽 + 浮起的选中胶囊;角标点提示"该 Tab 里有事要处理"。 */}
{isAdmin && (
<div className="seg-tabs" role="tablist">
{(
[
{ 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) => (
<button
key={t.key}
role="tab"
aria-selected={tab === t.key}
className={'seg-tab' + (tab === t.key ? ' active' : '')}
onClick={() => setTab(t.key)}
>
{t.label}
{t.dot && <span className="seg-dot" />}
</button>
))}
</div>
)}
{isAdmin && tab === 'inst' && (
<>
<div className="section-row">
<span className="section-title"></span>
@@ -602,7 +641,12 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
</div>
)}
<div className="section-row" style={{ marginTop: 22 }}>
</>
)}
{isAdmin && tab === 'users' && (
<>
<div className="section-row">
<span className="section-title"></span>
<button className="btn-text" onClick={() => setCreatingUser(true)}>
+
@@ -658,9 +702,14 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
))}
</div>
)}
</>
)}
{isAdmin && tab === 'system' && (
<>
{orphanConts.length > 0 && (
<>
<div className="section-row" style={{ marginTop: 22 }}>
<div className="section-row">
<span className="section-title"></span>
<span className="muted small"></span>
</div>
@@ -718,27 +767,31 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
</>
)}
{/* 账号:所有人(含子账号)都能在此改密 */}
<div className="section-row" style={{ marginTop: isAdmin ? 22 : 0 }}>
<span className="section-title"></span>
</div>
<div className="inst-grid">
<div className="inst-card">
<div className="inst-head">
<span className="inst-name">{user?.username}</span>
{isAdmin ? <span className="tag"></span> : <span className="tag tag-on"></span>}
{/* 账号:所有人(含子账号)都能在此改密。管理员放「用户」Tab子账号无 Tab 直接显示 */}
{(!isAdmin || tab === 'users') && (
<>
<div className="section-row" style={{ marginTop: isAdmin ? 22 : 0 }}>
<span className="section-title"></span>
</div>
<div className="inst-sub">{isAdmin ? '可访问全部实例' : `可访问 ${user?.allowedInstances.length ?? 0} 个实例`}</div>
<div className="inst-actions">
<button className="btn btn-primary inst-act-wide" onClick={onChangePassword}>
</button>
<div className="inst-grid">
<div className="inst-card">
<div className="inst-head">
<span className="inst-name">{user?.username}</span>
{isAdmin ? <span className="tag"></span> : <span className="tag tag-on"></span>}
</div>
<div className="inst-sub">{isAdmin ? '可访问全部实例' : `可访问 ${user?.allowedInstances.length ?? 0} 个实例`}</div>
<div className="inst-actions">
<button className="btn btn-primary inst-act-wide" onClick={onChangePassword}>
</button>
</div>
</div>
</div>
</div>
</div>
</>
)}
{isAdmin && <DiagnosticsSection />}
<AboutSection isAdmin={isAdmin} />
{isAdmin && tab === 'system' && <DiagnosticsSection />}
{(!isAdmin || tab === 'system') && <AboutSection isAdmin={isAdmin} />}
</main>
{creatingUser && (

View File

@@ -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;