mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-08-30 18:21:27 -07:00
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:
37
doc/部署与运维.md
37
doc/部署与运维.md
@@ -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/内网穿透而非直接开放。
|
||||
|
||||
@@ -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<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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 个区块一条长滚动,找东西全靠翻)。
|
||||
// 记住上次停留的 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: ()
|
||||
<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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user