Files
laude/src/main/games/gsi-server.ts
AnRil 34fb03b265 chore: sprint D — sandbox, self-hosted fonts, logger с ротацией
#6  sandbox: true на обоих BrowserWindow (раньше false). Preload
    использует только contextBridge + ipcRenderer (оба sandbox-safe),
    никаких Node-built-ins. OS-уровневый sandbox изолирует renderer
    от GPU/IPC процессов; даже RCE в зависимости renderer'а не
    получит Node-доступа через preload.

#17 self-host шрифтов через @fontsource/* пакеты. Раньше тянулись
    с fonts.googleapis.com — внешняя CSP-зависимость + отсутствие
    интернета = шрифты не загружались. Теперь .woff/.woff2 в bundle
    (22 файла × 15-30KB = ~500KB).
    Подкрутили CSP: убрали https://fonts.* origins, добавили
    connect-src 'self', base-uri 'self', frame-ancestors 'none'.

#22 src/main/logger.ts — структурный лог с уровнями
    (debug/info/warn/error) и ротацией. Пишет в
    %APPDATA%/Exercise Reminder/logs/latest.log (≤1MB) и
    дублирует в console. При 1MB latest.log → prev.log
    (предыдущий prev.log удаляется). LAUDE_DEBUG=1 включает
    debug-уровень.

    Подключён в hot paths: store (corrupt/atomic write fails),
    updater (silent check errors), gsi-server (bad requests,
    handler throws), games/registry (GSI start, reconcile, match_end
    summary), games/dota2 (rejected token, POST_GAME detection).

    Особенно полезно для диагностики «челленджи не срабатывают»:
    лог покажет (а) пришёл ли вообще GSI payload (token verify),
    (б) детектировался ли POST_GAME, (в) сколько challenges были
    enabled и которые из них дали 0 reps.

    Logger — единственный файл с `eslint-disable no-console` (он
    намеренно дублирует в stderr).
2026-05-22 01:24:30 +07:00

147 lines
3.9 KiB
TypeScript

import {
createServer,
type IncomingMessage,
type Server,
type ServerResponse
} from 'node:http'
import { log } from '../logger'
export type GsiHandler = (
payload: unknown,
headers: Record<string, string | string[] | undefined>
) => void
const PORT = 4701
/**
* Hard cap on incoming POST body. Real Dota GSI payloads are ~8 KB; anything
* larger is either a bug or a malicious local client trying to OOM us.
*/
const MAX_BODY_BYTES = 256 * 1024
let server: Server | null = null
const handlers: Map<string, GsiHandler> = new Map()
function readBody(req: IncomingMessage): Promise<Buffer> {
return new Promise((resolve, reject) => {
let received = 0
const chunks: Buffer[] = []
req.on('data', (c: Buffer) => {
received += c.length
if (received > MAX_BODY_BYTES) {
// Drop the connection so we don't keep buffering.
req.destroy(new Error('body too large'))
reject(new Error('body too large'))
return
}
chunks.push(c)
})
req.on('end', () => resolve(Buffer.concat(chunks)))
req.on('error', reject)
})
}
async function onRequest(
req: IncomingMessage,
res: ServerResponse
): Promise<void> {
// Reject browser-originated requests outright. Legitimate Dota GSI POSTs
// never include an Origin header; any value here means a webpage is poking
// our localhost endpoint via cross-origin fetch, which we never want.
if (req.headers.origin) {
res.statusCode = 403
res.end()
return
}
// Same intent for Sec-Fetch-Site: browsers always set it, Dota never does.
if (req.headers['sec-fetch-site']) {
res.statusCode = 403
res.end()
return
}
const route = (req.url ?? '/').split('?')[0]
const handler = handlers.get(route)
if (!handler) {
res.statusCode = 404
res.end()
return
}
if (req.method !== 'POST') {
res.statusCode = 405
res.end()
return
}
// Require JSON content-type. Browsers' "simple" requests in no-cors mode
// can only send text/plain or form-encoded — locking to application/json
// shrinks the cross-origin attack surface further.
const ct = String(req.headers['content-type'] ?? '').toLowerCase()
if (!ct.includes('application/json')) {
res.statusCode = 415
res.end()
return
}
let payload: unknown
try {
const body = await readBody(req)
const text = body.toString('utf-8')
payload = text.length > 0 ? JSON.parse(text) : {}
} catch (err) {
// Log the real reason locally; do not echo it to the client.
log.warn('[gsi] bad request', err instanceof Error ? err.message : err)
res.statusCode = 400
res.end()
return
}
try {
handler(payload, req.headers)
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.end('ok')
} catch (err) {
log.error('[gsi] handler threw', err)
res.statusCode = 500
res.end()
}
}
export async function startGsiServer(): Promise<void> {
if (server) return
await new Promise<void>((resolve, reject) => {
server = createServer(onRequest)
server.once('error', reject)
server.listen(PORT, '127.0.0.1', () => resolve())
})
}
export async function stopGsiServer(): Promise<void> {
if (!server) return
const s = server
server = null
// Free pending sockets so close() can resolve quickly even while Dota holds
// a long-poll connection open.
s.closeAllConnections?.()
await new Promise<void>((resolve) => s.close(() => resolve()))
}
export function registerGsiRoute(
route: string,
handler: GsiHandler
): () => void {
handlers.set(route, handler)
return () => {
// Only delete if we're still the registered handler — protects against
// double-register + unregister races where a newer handler took our slot.
if (handlers.get(route) === handler) handlers.delete(route)
}
}
export function getGsiBaseUrl(): string {
return `http://127.0.0.1:${PORT}`
}
export function getGsiPort(): number {
return PORT
}