From 27fdc3615d13d1351e474a4e8d1524cce6611b59 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 26 Oct 2026 09:05:05 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=95=E7=94=A8=E5=89=8D=E6=8E=92=E6=9F=A5?= =?UTF-8?q?=EF=BC=9AWebSocket=20=E5=BF=83=E8=B7=B3=E4=B8=8E=E6=96=AD?= =?UTF-8?q?=E7=BA=BF=E9=87=8D=E8=BF=9E=E3=80=81=E7=99=BB=E5=BD=95=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=8C=89=E8=B4=A6=E5=8F=B7=E9=99=90=E5=88=B6=E3=80=81?= =?UTF-8?q?=E6=97=A7=E6=B5=8F=E8=A7=88=E5=99=A8=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 服务器 30 秒发一次 ping:Nginx Proxy Manager 默认 60 秒没数据就断开,窗体开着不操作程序会被结束 - 连接意外断开时提示、清掉失效的窗体、自动重连,连上后提示重新运行 - 登录失败:同一账号 10 次只锁这个账号,同一 IP 300 次,登录成功不再清零 IP 计数 - index.html:浏览器太旧时说明原因和解决办法,加载慢时提示稍等 Co-Authored-By: Claude Opus 5.5 --- docs/设计方案.md | 4 ++++ server/src/app.ts | 3 ++- server/src/auth.ts | 42 ++++++++++++++++++++++++++--------------- server/src/index.ts | 4 ++++ server/test/api.test.ts | 32 ++++++++++++++++++++++++++----- web/index.html | 30 +++++++++++++++++++++++++++++ web/src/run/RunArea.vue | 10 +++++++++- web/src/run/session.ts | 35 +++++++++++++++++++++++++++++++++- 8 files changed, 137 insertions(+), 23 deletions(-) diff --git a/docs/设计方案.md b/docs/设计方案.md index 22ee515..15b91e9 100644 --- a/docs/设计方案.md +++ b/docs/设计方案.md @@ -322,6 +322,10 @@ flowchart TB - **检查的默认等待 2 秒 → 5 秒**:机器忙时,点"数据分析"后查库、画图表会超过 2 秒,正确的程序被判不通过。条件满足就立即往下走,放宽只让不通过的检查多等一会儿;规则里原来写的 3 秒一并去掉。 - **删除学生时删除他的数据库**:用户编号在删掉最大的编号后会重新用给下一个新学生,原来新学生会看到前一个人的库。 - **手动备份与恢复**:`deploy/backup.sh` 把平台数据库和每个学生的库(SQLite 一致读取)导出到宿主机的 `backups/<日期时间>/`;`deploy/restore.sh` 先备份现在的数据,再停平台、替换(删掉旧库的 -wal 文件)、启动。本机 Docker 实测备份、改数据、恢复后数据回到备份时的样子。 + - **WebSocket 心跳与断线重连**:线上前面的 Nginx Proxy Manager 默认 60 秒没数据就断开 WebSocket(本机用默认配置的 nginx 实测),学生的窗体开着一分钟不操作,程序就被结束、点了没反应。服务器改为 30 秒没收到数据就发 ping(Bun 的 idleTimeout),经 nginx 空闲 150 秒仍连着。连接意外断开时(部署重启、网络抖动),运行区显示"正在重新连接…",清掉已经失效的窗体,按 1、2、4…最长 10 秒的间隔自动重连;连上后提示"刚才的程序已经结束,请重新运行",实训台恢复。 + - **登录失败的限制**:原来按 IP 计数、任何人登录成功就清零,学生用自己的账号登录一次就能接着猜同学的 6 位数字密码。改为同一个账号 10 分钟输错 10 次只锁这个账号(同班其他人不受影响),同一 IP 10 分钟 300 次(登录成功不清零)。 + - **旧浏览器提示**:网页按 Chrome / Edge 107 以上打包,机房的旧浏览器或 360 浏览器的兼容模式会一片空白。index.html 里用老写法的一段脚本:网页程序有语法错误时说明浏览器太旧、怎么切换极速模式,并显示浏览器版本字符串方便老师排查;15 秒还没显示出来时提示正在加载。 + - **首次打开的下载量**:实验页第一次打开约 1.5 MB(压缩后,代码编辑器 Monaco 占 1 MB),之后浏览器长期缓存。全班 45 人同时第一次打开约 70 MB,服务器公网带宽 5 Mbps 时要近 2 分钟。 ## 技术风险与待确认事项 diff --git a/server/src/app.ts b/server/src/app.ts index 889553e..a85a7a0 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -50,8 +50,9 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ app.post('/api/login', async (c) => { const ip = clientIp(c) - if (tooManyFailures(ip)) return c.json({ message: '输错次数太多,请 10 分钟后再试' }, 429) const { username, password } = await body<{ username: string; password: string }>(c) + const blocked = tooManyFailures(ip, String(username ?? '')) + if (blocked) return c.json({ message: blocked }, 429) const tokens = await login(db, String(username ?? ''), String(password ?? ''), ip) if (!tokens) return c.json({ message: '账号或密码不对' }, 401) return c.json(await withUser(tokens)) diff --git a/server/src/auth.ts b/server/src/auth.ts index cc4298c..fc2346c 100644 --- a/server/src/auth.ts +++ b/server/src/auth.ts @@ -12,8 +12,10 @@ import { config } from './config' const ACCESS_SECONDS = 15 * 60 const REFRESH_DAYS = 30 -// 全班通常共用学校的一个出口 IP,上限不能太小,否则几个同学输错就把全班挡在外面 -const MAX_FAILURES = 30 +// 防暴力猜密码:同一个账号输错太多次只锁这个账号(学生密码是 6 位数字,不能让人一直猜); +// 全班通常共用学校的一个出口 IP,按 IP 的上限要大,否则几个同学输错就把全班挡在外面 +const MAX_FAILURES_PER_ACCOUNT = 10 +const MAX_FAILURES_PER_IP = 300 const FAILURE_WINDOW_MS = 10 * 60_000 export interface User { @@ -55,17 +57,27 @@ function secret(db: Database): string { const randomString = () => Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url') const hash = (token: string) => new Bun.CryptoHasher('sha256').update(token).digest('hex') -/** 每个 IP 一段时间内的失败次数,防止暴力猜密码 */ +/** 一段时间内的失败次数,键是 "ip:地址" 或 "user:账号" */ const failures = new Map() -export function tooManyFailures(ip: string) { - const entry = failures.get(ip) - if (!entry) return false - if (Date.now() - entry.since > FAILURE_WINDOW_MS) { - failures.delete(ip) - return false - } - return entry.count >= MAX_FAILURES +function failureCount(key: string) { + const entry = failures.get(key) + if (entry && Date.now() - entry.since > FAILURE_WINDOW_MS) failures.delete(key) + return failures.get(key)?.count ?? 0 +} + +function addFailure(key: string) { + failureCount(key) // 先清掉过期的 + const entry = failures.get(key) ?? { count: 0, since: Date.now() } + entry.count++ + failures.set(key, entry) +} + +/** 输错太多次时返回提示,否则返回 null */ +export function tooManyFailures(ip: string, username: string): string | null { + if (failureCount(`user:${username.trim()}`) >= MAX_FAILURES_PER_ACCOUNT) return '这个账号的密码输错太多次,请 10 分钟后再试,或请老师查看密码' + if (failureCount(`ip:${ip}`) >= MAX_FAILURES_PER_IP) return '输错次数太多,请 10 分钟后再试' + return null } async function issueTokens(db: Database, userId: number, tokenVersion: number): Promise { @@ -86,12 +98,12 @@ export async function login(db: Database, username: string, password: string, ip .query<{ id: number; password: string; token_version: number }, [string]>('SELECT id, password, token_version FROM users WHERE username = ?') .get(username.trim()) if (!row || row.password !== password) { - const entry = failures.get(ip) ?? { count: 0, since: Date.now() } - entry.count++ - failures.set(ip, entry) + addFailure(`ip:${ip}`) + addFailure(`user:${username.trim()}`) return null } - failures.delete(ip) + // 只清这个账号的;按 IP 的不清,否则用自己的账号登录一次就能接着猜别人的密码 + failures.delete(`user:${username.trim()}`) return issueTokens(db, row.id, row.token_version) } diff --git a/server/src/index.ts b/server/src/index.ts index ee4475a..71091c8 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -52,6 +52,10 @@ const server = Bun.serve({ }, websocket: { data: {} as SocketData, + // 30 秒没收到数据就发一次 ping(浏览器自动回 pong)。线上前面有 Nginx Proxy Manager, + // nginx 默认 60 秒没数据就断开 WebSocket,学生的窗体开着不操作一分钟程序就会被结束 + idleTimeout: 30, + sendPings: true, open(ws) { const userId = ws.data.userId! ws.data.session = new RunSession((message) => ws.send(JSON.stringify(message)), () => userDatabaseDir(userId)) diff --git a/server/test/api.test.ts b/server/test/api.test.ts index 23a3fb5..d6f8ccb 100644 --- a/server/test/api.test.ts +++ b/server/test/api.test.ts @@ -90,11 +90,33 @@ test('账号或密码不对时登录失败,老师登录后能看到自己的 expect(me.body.user).toMatchObject({ username: config.teacherUsername, role: 'teacher' }) }) -test('同一个 IP 输错 30 次后暂停登录,别的 IP 不受影响', async () => { - const attacker = new Browser('10.0.0.66') - for (let i = 0; i < 30; i++) await attacker.login('admin', `guess${i}`) - expect((await attacker.login('admin', 'whatever')).status).toBe(429) - expect((await new Browser('10.0.0.67').login('admin', 'whatever')).status).toBe(401) +test('同一个账号输错 10 次后暂停这个账号的登录,同一 IP 的其他同学不受影响', async () => { + const t = await teacher() + await classWithStudents(t, [ + { username: 'guessed', name: '甲', password: '123456' }, + { username: 'other', name: '乙', password: '654321' }, + ]) + const classmate = new Browser('10.0.0.66') + for (let i = 0; i < 9; i++) await classmate.login('guessed', `00000${i}`) + // 用自己的账号登录成功,不会清掉别人账号的失败次数 + expect((await classmate.login('other', '654321')).status).toBe(200) + await classmate.login('guessed', '000009') + const blocked = await new Browser('10.0.0.67').login('guessed', '123456') + expect(blocked.status).toBe(429) + expect(blocked.body.message).toContain('这个账号') + expect((await new Browser('10.0.0.66').login('other', '654321')).status).toBe(200) +}) + +test('同一个 IP 输错 300 次后暂停登录,登录成功也不清零,别的 IP 不受影响', async () => { + const t = await teacher() + await classWithStudents(t, [{ username: 'mine', name: '甲', password: '111111' }]) + const attacker = new Browser('10.0.0.68') + for (let i = 0; i < 300; i++) { + await attacker.login(`nobody${i % 50}`, 'guess') + if (i === 150) expect((await attacker.login('mine', '111111')).status).toBe(200) + } + expect((await attacker.login('mine', '111111')).status).toBe(429) + expect((await new Browser('10.0.0.69').login('mine', '111111')).status).toBe(200) }) describe('班级和名单', () => { diff --git a/web/index.html b/web/index.html index e56436c..7691891 100644 --- a/web/index.html +++ b/web/index.html @@ -8,5 +8,35 @@
+ + diff --git a/web/src/run/RunArea.vue b/web/src/run/RunArea.vue index 2d88676..a832bfb 100644 --- a/web/src/run/RunArea.vue +++ b/web/src/run/RunArea.vue @@ -79,7 +79,11 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) :box="box" @reply="session.replyMessageBox(box.requestId, $event)" /> -
点击"运行"后,学生程序的窗体会显示在这里
+
与服务器的连接断开了,正在重新连接…
+
+ 连接已恢复。刚才的程序随连接断开已经结束,请重新运行 +
+
点击"运行"后,学生程序的窗体会显示在这里
缩放 {{ Math.round(scale * 100) }}% @@ -219,6 +223,10 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) justify-content: center; color: rgb(255 255 255 / 0.7); } +.desktop-hint.lost { + color: #ffd591; + font-weight: 600; +} .photos { font-size: 12px; diff --git a/web/src/run/session.ts b/web/src/run/session.ts index 479fb00..e8df510 100644 --- a/web/src/run/session.ts +++ b/web/src/run/session.ts @@ -45,6 +45,11 @@ export function createRunSession() { /** 正在播放的声音(媒体播放器控件,实训台上的广播喇叭随之亮起) */ playing: null as { name: string } | null, metrics: { compileMs: 0, startMs: 0, lastRoundTripMs: 0 }, + /** + * 和服务器的连接:lost 表示意外断开(网络不稳、服务器在更新),正在自动重连; + * restored 表示断开后又连上了(提示学生刚才的程序已经结束),下次运行时变回 open + */ + connection: 'connecting' as 'connecting' | 'open' | 'lost' | 'restored', }) /** id → 控件节点,patch 按 id 修改节点属性 */ const nodes = shallowReactive(new Map()) @@ -54,6 +59,12 @@ export function createRunSession() { /** 正在建立或已建立的连接;同时多处调用 connect() 时共用同一个 */ let connecting: Promise | undefined let actionSentAt = 0 + /** 离开页面时主动断开,不再重连 */ + let closed = false + let reconnectTimer: ReturnType | undefined + /** 连续重连失败的次数,等待时间随之加长(最长 RECONNECT_MAX_MS) */ + let reconnectFailures = 0 + const RECONNECT_MAX_MS = 10_000 /** 每个媒体播放器控件一个