vimp-engine 0.38.0 → 0.38.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +12 -6
- package/bin/vimp-sim.js +2 -2
- package/core/Cargo.toml +4 -3
- package/package.json +12 -5
- package/src/client/components/controller/ClientReports.js +2 -1
- package/src/client/components/controller/Games.js +1 -1
- package/src/client/components/controller/LobbyAuth.js +6 -2
- package/src/client/components/controller/Profile.js +13 -3
- package/src/client/components/model/LobbyAuth.js +56 -18
- package/src/client/components/model/Profile.js +6 -4
- package/src/client/components/view/Auth.js +4 -2
- package/src/client/components/view/LobbyAuth.js +7 -7
- package/src/client/components/view/Profile.js +0 -1
- package/src/client/lib/authApi.js +9 -0
- package/src/client/lib/formBuilder.js +9 -8
- package/src/client/lib/hostCaps.js +3 -2
- package/src/client/lib/sessionSync.js +45 -0
- package/src/client/main.js +55 -13
- package/src/client/network/TokenRenewer.js +16 -7
- package/src/client/network/WebSocketTransport.js +1 -1
- package/src/client/session/Membership.js +2 -1
- package/src/client/style.css +17 -8
- package/src/client/views/includes/lobby.pug +2 -1
- package/src/client/views/includes/lobbyAuth.pug +12 -12
- package/src/config/authClient.js +3 -3
- package/src/config/gameCodes.js +2 -2
- package/src/config/lobby.js +2 -3
- package/src/config/wsports.js +2 -2
- package/src/devtools/builtinScenario.js +3 -3
- package/src/devtools/contract/rules/a2-package-scripts.js +4 -3
- package/src/devtools/contract/rules/a4-vite-config.js +4 -3
- package/src/devtools/contract/rules/a7-package-repository.js +6 -5
- package/src/devtools/contract/rules/c4-component-dependencies.js +5 -5
- package/src/devtools/invariants.js +1 -1
- package/src/devtools/pluginLoader.js +3 -3
- package/src/host/PortMachine.js +6 -5
- package/src/lib/loadGamePackage.js +1 -1
- package/src/lib/packageLink.js +11 -10
- package/src/standalone/index.js +5 -5
- package/bin/vimp-surface.js +0 -88
- package/src/config/env.js +0 -104
- package/src/config/master.js +0 -288
- package/src/lib/clientIp.js +0 -96
- package/src/lib/rateLimiter.js +0 -40
- package/src/lib/security.js +0 -45
package/bin/vimp-surface.js
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import {
|
|
6
|
-
collectSurface,
|
|
7
|
-
formatSurface,
|
|
8
|
-
diffSurface,
|
|
9
|
-
} from '../src/devtools/surface/collect.js';
|
|
10
|
-
|
|
11
|
-
// CLI слепка плагинной поверхности (этап 1 плана plugin-forward-compat):
|
|
12
|
-
// без флага печатает расхождение с закоммиченным contract/surface.json и
|
|
13
|
-
// возвращает 1 при нарушении И1/И3, с --write перезаписывает слепок.
|
|
14
|
-
// Внутренний инструмент репозитория движка: в поле `bin` публикуемого
|
|
15
|
-
// пакета он не значится.
|
|
16
|
-
|
|
17
|
-
const SURFACE_PATH = fileURLToPath(
|
|
18
|
-
new URL('../contract/surface.json', import.meta.url),
|
|
19
|
-
);
|
|
20
|
-
|
|
21
|
-
const USAGE = `Usage: vimp-surface [options]
|
|
22
|
-
|
|
23
|
-
--write rewrite contract/surface.json with the collected surface
|
|
24
|
-
--help
|
|
25
|
-
`;
|
|
26
|
-
|
|
27
|
-
async function main(argv) {
|
|
28
|
-
if (argv.includes('--help')) {
|
|
29
|
-
process.stdout.write(USAGE);
|
|
30
|
-
return 0;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const surface = await collectSurface();
|
|
34
|
-
|
|
35
|
-
if (argv.includes('--write')) {
|
|
36
|
-
await mkdir(path.dirname(SURFACE_PATH), { recursive: true });
|
|
37
|
-
await writeFile(SURFACE_PATH, formatSurface(surface));
|
|
38
|
-
process.stdout.write(`surface: written ${SURFACE_PATH}\n`);
|
|
39
|
-
|
|
40
|
-
return 0;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
let committed;
|
|
44
|
-
|
|
45
|
-
try {
|
|
46
|
-
committed = JSON.parse(await readFile(SURFACE_PATH, 'utf8'));
|
|
47
|
-
} catch {
|
|
48
|
-
process.stderr.write(
|
|
49
|
-
`surface: no snapshot at ${SURFACE_PATH} — run ` +
|
|
50
|
-
'`npm run surface:update` to create it\n',
|
|
51
|
-
);
|
|
52
|
-
|
|
53
|
-
return 1;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const { violations, additions } = diffSurface(committed, surface);
|
|
57
|
-
|
|
58
|
-
for (const addition of additions) {
|
|
59
|
-
process.stdout.write(`surface: добавлено ${addition}\n`);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
if (additions.length > 0 && violations.length === 0) {
|
|
63
|
-
process.stdout.write(
|
|
64
|
-
'слепок устарел, запусти `npm run surface:update` (добавление ' +
|
|
65
|
-
'поверхности совместимость не ломает)\n',
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
for (const violation of violations) {
|
|
70
|
-
process.stderr.write(`${violation}\n\n`);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
if (violations.length === 0 && additions.length === 0) {
|
|
74
|
-
process.stdout.write('surface: слепок совпадает\n');
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return violations.length > 0 ? 1 : 0;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
main(process.argv.slice(2)).then(
|
|
81
|
-
code => {
|
|
82
|
-
process.exitCode = code;
|
|
83
|
-
},
|
|
84
|
-
err => {
|
|
85
|
-
process.stderr.write(`${err.message}\n`);
|
|
86
|
-
process.exitCode = 1;
|
|
87
|
-
},
|
|
88
|
-
);
|
package/src/config/env.js
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
// Env-переопределения серверного конфига (Этап 4 плана standalone-sdk).
|
|
2
|
-
// Раньше этот блок жил в master/main.js под условием NODE_ENV=production:
|
|
3
|
-
// лобби-мастер в dev работает на дефолтах конфига. Dedicated-серверу так
|
|
4
|
-
// нельзя — игру, порт и настройки комнаты он получает только из окружения,
|
|
5
|
-
// в том числе локально, — поэтому чтение вынесено сюда и применяется каждым
|
|
6
|
-
// входом по своим правилам.
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Применяет env-переопределения к конфигу мастера.
|
|
10
|
-
* @param {Object} config - Синглтон lib/config.js.
|
|
11
|
-
* @param {Object} [env] - Окружение (по умолчанию process.env).
|
|
12
|
-
*/
|
|
13
|
-
export function applyMasterEnv(config, env = process.env) {
|
|
14
|
-
if (env.VIMP_DOMAIN) {
|
|
15
|
-
config.set('master:domain', env.VIMP_DOMAIN);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// порт серверного процесса
|
|
19
|
-
if (env.VIMP_MASTER_PORT) {
|
|
20
|
-
config.set('master:port', Number(env.VIMP_MASTER_PORT));
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// домен central auth-сервиса — попадает в CSP connect-src, т.к. лобби
|
|
24
|
-
// делает туда прямые fetch (ручки профиля)
|
|
25
|
-
if (env.VIMP_AUTH_SERVICE_URL) {
|
|
26
|
-
config.set('master:security:authServiceUrl', env.VIMP_AUTH_SERVICE_URL);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// корень хранилища игровых пакетов (направление master-game-registry) —
|
|
30
|
-
// в проде это смонтированный том, переживающий пересоздание контейнера
|
|
31
|
-
if (env.VIMP_GAMES_DIR) {
|
|
32
|
-
config.set('master:gameStore:dir', env.VIMP_GAMES_DIR);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// общий секрет бокса и auth-сервиса для пересылки журнала клиентских
|
|
36
|
-
// ошибок (plan/client-reports)
|
|
37
|
-
if (env.VIMP_CLIENT_REPORTS_TOKEN) {
|
|
38
|
-
config.set('master:clientReports:token', env.VIMP_CLIENT_REPORTS_TOKEN);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// минимальная длина VIMP_ROOM_SECRET_KEY — ключ HMAC-SHA256
|
|
43
|
-
const ROOM_SECRET_KEY_MIN_BYTES = 32;
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Ключ секрета комнаты (host-migration, этап 2): roomSecret = HMAC(ключ,
|
|
47
|
-
* roomId:epoch:hostUserId) — им хост доказывает комнату после рестарта
|
|
48
|
-
* мастера. В production обязателен; в dev без него генерируется случайный на
|
|
49
|
-
* время процесса (комнаты после рестарта dev-мастера не восстанавливаются).
|
|
50
|
-
* @param {Object} [env] - Окружение (по умолчанию process.env).
|
|
51
|
-
* @param {Object} opts
|
|
52
|
-
* @param {boolean} opts.isProduction
|
|
53
|
-
* @param {Function} opts.randomBytes - crypto.randomBytes.
|
|
54
|
-
* @returns {{ key: string|Buffer, ephemeral: boolean }}
|
|
55
|
-
*/
|
|
56
|
-
export function readRoomSecretKey(
|
|
57
|
-
env = process.env,
|
|
58
|
-
{ isProduction, randomBytes },
|
|
59
|
-
) {
|
|
60
|
-
const key = env.VIMP_ROOM_SECRET_KEY;
|
|
61
|
-
|
|
62
|
-
if (key) {
|
|
63
|
-
if (Buffer.byteLength(key) < ROOM_SECRET_KEY_MIN_BYTES) {
|
|
64
|
-
throw new Error(
|
|
65
|
-
`VIMP_ROOM_SECRET_KEY: at least ${ROOM_SECRET_KEY_MIN_BYTES} bytes required`,
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return { key, ephemeral: false };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
if (isProduction) {
|
|
73
|
-
throw new Error('VIMP_ROOM_SECRET_KEY must be set in production');
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return { key: randomBytes(ROOM_SECRET_KEY_MIN_BYTES), ephemeral: true };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Настройки комнаты dedicated-сервера: VIMP_DEDICATED_ROOM — JSON-объект
|
|
81
|
-
* (map, maxPlayers, roundTime, mapTime, friendlyFire, seed). Мусор в
|
|
82
|
-
* переменной не должен ронять процесс молча — отказ именованный.
|
|
83
|
-
* @param {Object} [env] - Окружение (по умолчанию process.env).
|
|
84
|
-
* @returns {Object} Переопределения комнаты.
|
|
85
|
-
*/
|
|
86
|
-
export function readDedicatedRoom(env = process.env) {
|
|
87
|
-
if (!env.VIMP_DEDICATED_ROOM) {
|
|
88
|
-
return {};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
let room;
|
|
92
|
-
|
|
93
|
-
try {
|
|
94
|
-
room = JSON.parse(env.VIMP_DEDICATED_ROOM);
|
|
95
|
-
} catch (e) {
|
|
96
|
-
throw new Error(`VIMP_DEDICATED_ROOM: invalid JSON — ${e.message}`);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
if (!room || typeof room !== 'object' || Array.isArray(room)) {
|
|
100
|
-
throw new Error('VIMP_DEDICATED_ROOM: expected a JSON object');
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
return room;
|
|
104
|
-
}
|
package/src/config/master.js
DELETED
|
@@ -1,288 +0,0 @@
|
|
|
1
|
-
import path from 'path';
|
|
2
|
-
import { fileURLToPath } from 'url';
|
|
3
|
-
|
|
4
|
-
// корень репозитория — якорь от расположения файла, не от cwd
|
|
5
|
-
const rootDir = path.resolve(
|
|
6
|
-
fileURLToPath(import.meta.url),
|
|
7
|
-
'..',
|
|
8
|
-
'..',
|
|
9
|
-
'..',
|
|
10
|
-
'..',
|
|
11
|
-
'..',
|
|
12
|
-
);
|
|
13
|
-
|
|
14
|
-
export default {
|
|
15
|
-
name: 'VIMP Master Server',
|
|
16
|
-
protocol: 'https:',
|
|
17
|
-
domain: 'localhost',
|
|
18
|
-
// 3000 — игровой сервер, 3001 — Vite HMR (vite.config.js)
|
|
19
|
-
port: 3002,
|
|
20
|
-
|
|
21
|
-
// сертификаты для локальной разработки — в .certs корня репозитория
|
|
22
|
-
// (в продакшене обычный HTTP за Nginx)
|
|
23
|
-
httpsOptions: {
|
|
24
|
-
key: path.join(rootDir, '.certs', 'key.pem'),
|
|
25
|
-
cert: path.join(rootDir, '.certs', 'cert.pem'),
|
|
26
|
-
},
|
|
27
|
-
|
|
28
|
-
// Статический каталог игр-плагинов. ПУСТ по умолчанию
|
|
29
|
-
// (master-game-registry, этап 3): штатный источник каталога — реестр игр
|
|
30
|
-
// auth-сервиса, откуда мастер скачивает одобренные пакеты сам
|
|
31
|
-
// (master/GameSync.js). Движок игр не знает и зависимостями их не тянет.
|
|
32
|
-
//
|
|
33
|
-
// Массив остаётся двум потребителям, и env-переопределения у него нет:
|
|
34
|
-
// локальной разработке (каталог наполняют собранные пакеты @vimp-games/*
|
|
35
|
-
// из node_modules, master/localGames.js — прилинкованная игра важнее
|
|
36
|
-
// реестра) и dedicated-серверу, который берёт отсюда игру, названную
|
|
37
|
-
// VIMP_DEDICATED_GAME. `package` — имя npm-пакета игры, резолвится через
|
|
38
|
-
// node_modules; версию задаёт сама установленная зависимость.
|
|
39
|
-
// maxGameScore (snakes-v3 этап 3.3, необязательное поле рядом с
|
|
40
|
-
// id/package) — потолок результата ОДНОЙ игры для этой игры; мастер
|
|
41
|
-
// клампит им `best`/`points` PUT /auth/rank. Для игр реестра его задаёт
|
|
42
|
-
// админ при модерации, здесь — только запасной путь. Не задан — дефолт
|
|
43
|
-
// master:playerData:maxGameScore
|
|
44
|
-
games: [],
|
|
45
|
-
|
|
46
|
-
// Хранилище игровых пакетов (направление master-game-registry): мастер
|
|
47
|
-
// качает одобренные игры из npm registry и раздаёт их с диска, вместо того
|
|
48
|
-
// чтобы получать их npm-зависимостью на этапе сборки образа.
|
|
49
|
-
gameStore: {
|
|
50
|
-
// корень хранилища; null → <repoRoot>/.games (локальная разработка).
|
|
51
|
-
// В проде задаётся VIMP_GAMES_DIR и монтируется томом
|
|
52
|
-
dir: null,
|
|
53
|
-
registryUrl: 'https://registry.npmjs.org',
|
|
54
|
-
// период опроса реестра auth за изменениями каталога
|
|
55
|
-
refreshInterval: 60000,
|
|
56
|
-
// потолки распаковки недоверенного архива
|
|
57
|
-
maxTarballBytes: 64 * 1024 * 1024,
|
|
58
|
-
maxFiles: 5000,
|
|
59
|
-
// сколько версий одной игры держать на диске: активная + стейджевая
|
|
60
|
-
keepVersions: 2,
|
|
61
|
-
// потолок ожидания ответа реестра
|
|
62
|
-
timeout: 30000,
|
|
63
|
-
},
|
|
64
|
-
|
|
65
|
-
// список серверов (GET /servers)
|
|
66
|
-
servers: {
|
|
67
|
-
// если всего комнат <= порога — региональный фильтр
|
|
68
|
-
// и пагинация отключаются, отдаётся весь список
|
|
69
|
-
regionThreshold: 15,
|
|
70
|
-
defaultLimit: 10, // размер страницы по умолчанию
|
|
71
|
-
maxLimit: 50, // максимальный размер страницы
|
|
72
|
-
},
|
|
73
|
-
|
|
74
|
-
// ограничения регистрируемых комнат
|
|
75
|
-
host: {
|
|
76
|
-
// санитарная рамка вместимости комнаты для случая, когда игра комнаты
|
|
77
|
-
// неизвестна мастеру (gameId: null у старых хостов или id не из
|
|
78
|
-
// каталога). Потолок известной игры задаёт её манифест
|
|
79
|
-
// (roomDefaults.maxPlayers) — движок его не ограничивает
|
|
80
|
-
maxPlayersLimit: 8,
|
|
81
|
-
heartbeatTimeout: 30000, // нет heartbeat дольше — хост потерян
|
|
82
|
-
sweepInterval: 10000, // период уборки реестра комнат
|
|
83
|
-
},
|
|
84
|
-
|
|
85
|
-
// жизнь комнаты (host-migration, этап 2): комната живёт, пока в ней есть
|
|
86
|
-
// участник
|
|
87
|
-
room: {
|
|
88
|
-
// отсоединённый участник ещё считается в комнате (реконнект сигналинга)
|
|
89
|
-
memberGraceMs: 15000,
|
|
90
|
-
// обрыв WS хоста запускает миграцию не сразу, а через столько, если хост
|
|
91
|
-
// не вернулся reclaim_host'ом и ни один гость не сообщил host_unreachable
|
|
92
|
-
// (host_leaving мигрирует сразу); reclaim_host после начала миграции, до
|
|
93
|
-
// регистрации преемника, её отменяет (этап 7.0)
|
|
94
|
-
hostDisconnectGraceMs: 2000,
|
|
95
|
-
// повышать некого — комната ждёт reclaim_host столько, потом уборка
|
|
96
|
-
// мигрирует или закрывает её
|
|
97
|
-
hostReclaimGraceMs: 10000,
|
|
98
|
-
// потолок строки карточки комнаты в лобби (gameConfig.lobbyInfo игры)
|
|
99
|
-
maxInfoLength: 48,
|
|
100
|
-
// RTT участников (host-migration этап 6): ws.ping() каждой сессии с
|
|
101
|
-
// этим периодом; EMA RTT и джиттера — единая линейка связности для
|
|
102
|
-
// хоста и кандидатов
|
|
103
|
-
rttProbeIntervalMs: 5000,
|
|
104
|
-
// сессия без pong дольше — terminate(): «тихо умерший» хост находится
|
|
105
|
-
// быстрее, чем по heartbeatTimeout
|
|
106
|
-
wsDeadAfterMs: 12000,
|
|
107
|
-
// преемник (бета): кандидат должен пробыть в комнате не меньше этого
|
|
108
|
-
minMemberAgeMs: 10000,
|
|
109
|
-
// плановый пересчёт преемника (помимо join/leave/member_update)
|
|
110
|
-
successorReviewMs: 15000,
|
|
111
|
-
// гистерезис смены беты: лучший кандидат держит score не хуже
|
|
112
|
-
// successorSwitchRatio × score текущего дольше successorSwitchSustainMs
|
|
113
|
-
successorSwitchSustainMs: 30000,
|
|
114
|
-
successorSwitchRatio: 0.65,
|
|
115
|
-
// аварийная миграция (этап 7): бета — преемник с точкой, если она
|
|
116
|
-
// получила её не раньше checkpointMaxAgeMs назад (по standby_status.ageMs;
|
|
117
|
-
// статус идёт раз в 5 с — окно больше двух периодов), иначе cold-промоушен любого
|
|
118
|
-
// способного; преемник не занял комнату за promotionTimeoutMs
|
|
119
|
-
// (coldPromotionTimeoutMs — он перезагружает страницу) — следующий
|
|
120
|
-
checkpointMaxAgeMs: 12000,
|
|
121
|
-
promotionTimeoutMs: 10000,
|
|
122
|
-
coldPromotionTimeoutMs: 25000,
|
|
123
|
-
// отчёты host_unreachable: хост не ответил на probe за probeTimeoutMs —
|
|
124
|
-
// потерян; ответил, но за reportWindowMs отчиталась половина гостей —
|
|
125
|
-
// принудительная миграция, не чаще forcedMigrationCooldownMs на комнату.
|
|
126
|
-
// Гости и отчёты считаются по аккаунтам; кворум — не меньше
|
|
127
|
-
// minUnreachableReporters разных пользователей (в паре с плохим P2P один
|
|
128
|
-
// гость не снимает хоста — иначе роль «пинг-понгом» ходит по кругу)
|
|
129
|
-
probeTimeoutMs: 2000,
|
|
130
|
-
reportWindowMs: 5000,
|
|
131
|
-
forcedMigrationCooldownMs: 30000,
|
|
132
|
-
minUnreachableReporters: 2,
|
|
133
|
-
// плановая передача (этап 8): преемник не занял комнату за
|
|
134
|
-
// handoffTimeoutMs — передача отменяется, хост размораживает матч
|
|
135
|
-
handoffTimeoutMs: 8000,
|
|
136
|
-
// host_migrating.waitMs — дедлайн текущей попытки промоушена (передачи)
|
|
137
|
-
// плюс migrationNoticeMarginMs: столько гость ждёт host_changed
|
|
138
|
-
migrationNoticeMarginMs: 5000,
|
|
139
|
-
// автотриггеры (этап 9c). Сетевой лаг хоста: медиана его RTT до гостей
|
|
140
|
-
// (host_health) выше lagRttThresholdMs непрерывно lagSustainMs, а score
|
|
141
|
-
// беты лучше score хоста хотя бы на lagImprovementRatio — хосту
|
|
142
|
-
// request_handoff. Не раньше autoMigrationCooldownMs с прошлой
|
|
143
|
-
// авто-смены хоста в комнате (overload/hidden/network — кулдаун общий)
|
|
144
|
-
// и с получения роли текущим хостом
|
|
145
|
-
lagRttThresholdMs: 250,
|
|
146
|
-
lagSustainMs: 10000,
|
|
147
|
-
lagImprovementRatio: 0.35,
|
|
148
|
-
autoMigrationCooldownMs: 90000,
|
|
149
|
-
// бета — не ниже этого FPS рендера (caps.fps гостя; неизвестный FPS не
|
|
150
|
-
// отсеивает). Аварийный промоушен порог не применяет
|
|
151
|
-
minSuccessorFps: 30,
|
|
152
|
-
// голосование «Change host» (этап 10, /changehost; только лобби-режим):
|
|
153
|
-
// голоса считает мастер. Длительность голосования; повторный старт в
|
|
154
|
-
// комнате и тем же пользователем — не раньше кулдаунов; прошедшее
|
|
155
|
-
// голосование просит хоста отдать роль, не начал за voteForceAfterMs —
|
|
156
|
-
// принудительная миграция; снятый хост не бета и не хост комнаты
|
|
157
|
-
// demotedCooldownMs (кроме случая, когда больше принять некому).
|
|
158
|
-
// Один голос на аккаунт; голосует и начинает участник, пробывший в
|
|
159
|
-
// комнате не меньше minVoterAgeMs (свежие вкладки не накручивают голоса).
|
|
160
|
-
// После смены хоста голосование ждёт первого room_peers нового хоста —
|
|
161
|
-
// до него подтверждёнными считаются все, и фантомы попали бы в число
|
|
162
|
-
// голосующих, — но не дольше peersReportGraceMs (больше
|
|
163
|
-
// lobby.migration.peersReportIntervalMs: потерянный первый отчёт
|
|
164
|
-
// повторится внутри окна; хост старше room_peers отчёта не пришлёт)
|
|
165
|
-
vote: {
|
|
166
|
-
hostVoteDurationMs: 15000,
|
|
167
|
-
roomVoteCooldownMs: 120000,
|
|
168
|
-
userStartCooldownMs: 60000,
|
|
169
|
-
voteForceAfterMs: 5000,
|
|
170
|
-
demotedCooldownMs: 600000,
|
|
171
|
-
minVoterAgeMs: 30000,
|
|
172
|
-
peersReportGraceMs: 20000,
|
|
173
|
-
},
|
|
174
|
-
// GET /rooms/:roomId (прямая ссылка, этап 3) на IP: открытие ссылки —
|
|
175
|
-
// один запрос, лимит только против перебора roomId
|
|
176
|
-
lookupRateLimit: {
|
|
177
|
-
limit: 20,
|
|
178
|
-
windowMs: 1000,
|
|
179
|
-
},
|
|
180
|
-
},
|
|
181
|
-
|
|
182
|
-
// GET /auth/leaderboard (code review L2): TTL кэша на мастере (мс) и
|
|
183
|
-
// верхняя граница ?limit= — публичный анонимный эндпоинт, самый частый
|
|
184
|
-
// запрос лобби, выборка меняется медленно
|
|
185
|
-
leaderboard: {
|
|
186
|
-
cacheTtl: 15000,
|
|
187
|
-
maxLimit: 100,
|
|
188
|
-
},
|
|
189
|
-
|
|
190
|
-
// GET /auth/placement + агрегирующий GET /auth/placements (snakes-v3
|
|
191
|
-
// этап 3.3): место меняется медленно, а каждый вход участника стоит трёх
|
|
192
|
-
// срезов. Кэш здесь про round-trip до auth; стоимость самого запроса
|
|
193
|
-
// снята на стороне auth (RankDistribution)
|
|
194
|
-
placement: {
|
|
195
|
-
cacheTtl: 30000,
|
|
196
|
-
},
|
|
197
|
-
|
|
198
|
-
// пределы записи профилей в БД (snakes-v3 этап 3, решение пользователя 9):
|
|
199
|
-
// «игр сотни, серверов сотни» — минимальный интервал держит движок на
|
|
200
|
-
// стороне хоста, а мастер держит потолок для сломанного или злонамеренного
|
|
201
|
-
// сервера, который этот интервал обошёл
|
|
202
|
-
playerData: {
|
|
203
|
-
// PUT /auth/rank + /auth/state на комнату (проверенный roomId) в минуту.
|
|
204
|
-
// Честная комната на 32 при lobbyConfig.playerData.minFlushInterval в
|
|
205
|
-
// 5 минут пишет 64 запроса за эти 5 минут, то есть ~13/мин; остальное —
|
|
206
|
-
// запас на срочные границы (уход участника обходит интервал), и его
|
|
207
|
-
// хватает даже комнате, полностью сменившей состав дважды за минуту.
|
|
208
|
-
// Потолок держит не честную комнату, а сломанную или злонамеренную,
|
|
209
|
-
// поэтому запас считается от честной, а не «пусть будет побольше»
|
|
210
|
-
writesPerMinute: 120,
|
|
211
|
-
// потолок результата ОДНОЙ игры, если игра не объявила свой
|
|
212
|
-
// (master:games[].maxGameScore): обоснование — plan/snakes-v3/stage_2.md
|
|
213
|
-
maxGameScore: 10000,
|
|
214
|
-
},
|
|
215
|
-
|
|
216
|
-
// заголовки безопасности (гигиена среды, Этап 5.4). CSP на статику/.wasm в
|
|
217
|
-
// проде ставит Nginx (см. docs/deployment.md) — здесь single source of truth
|
|
218
|
-
// политики; мастер применяет её к своим ответам только в проде (в dev CSP
|
|
219
|
-
// сломала бы Vite HMR). WASM требует 'wasm-unsafe-eval', Worker — 'blob:';
|
|
220
|
-
// connect-src data: — PixiJS фетчит тестовый data:-URL для проверки ImageBitmap.
|
|
221
|
-
// authServiceUrl — домен central auth-сервиса (packages/auth): лобби
|
|
222
|
-
// делает туда прямой fetch (браузерные ручки auth-сервиса), поэтому
|
|
223
|
-
// connect-src должен его разрешать; сам OAuth-редирект (location.href на
|
|
224
|
-
// auth-сервис/провайдера) CSP не ограничивает — это навигация верхнего
|
|
225
|
-
// уровня, не fetch/XHR. challenges.cloudflare.com в script-src и frame-src
|
|
226
|
-
// — скрипт и iframe виджета Turnstile на экране регистрации.
|
|
227
|
-
// script-src несёт sha256-хэш инлайнового importmap из index.html (для
|
|
228
|
-
// pixi.js — src="..." на <script type="importmap"> браузеры не
|
|
229
|
-
// поддерживают, инлайн обязателен). Хэш посчитан по факту собранного
|
|
230
|
-
// packages/engine/dist/index.html скриптом
|
|
231
|
-
// scripts/check-importmap-csp-hash.mjs (запускается postbuild) — vite
|
|
232
|
-
// build минифицирует HTML и может изменить байты скрипта, поэтому хэш
|
|
233
|
-
// нельзя брать из исходника/консоли браузера; при правке importmap или
|
|
234
|
-
// апгрейде Vite смотреть на вывод postbuild-проверки.
|
|
235
|
-
security: {
|
|
236
|
-
authServiceUrl: 'http://localhost:3010',
|
|
237
|
-
csp: authServiceUrl =>
|
|
238
|
-
[
|
|
239
|
-
"default-src 'self'",
|
|
240
|
-
"script-src 'self' 'wasm-unsafe-eval' 'sha256-XJmzkFBLHYpcM8KgGRFztTJTwfMb5xIFKAmqlgTpobo=' https://challenges.cloudflare.com",
|
|
241
|
-
"worker-src 'self' blob:",
|
|
242
|
-
'frame-src https://challenges.cloudflare.com',
|
|
243
|
-
`connect-src 'self' wss: data:${authServiceUrl ? ` ${authServiceUrl}` : ''}`,
|
|
244
|
-
"img-src 'self' data: blob:",
|
|
245
|
-
"style-src 'self' 'unsafe-inline'",
|
|
246
|
-
"object-src 'none'",
|
|
247
|
-
"base-uri 'self'",
|
|
248
|
-
"frame-ancestors 'none'",
|
|
249
|
-
].join('; '),
|
|
250
|
-
referrerPolicy: 'no-referrer',
|
|
251
|
-
},
|
|
252
|
-
|
|
253
|
-
// журнал клиентских ошибок (plan/client-reports): приём POST
|
|
254
|
-
// /client-reports на боксе и пересылка пачками в auth-сервис. Пустой
|
|
255
|
-
// token — пересылка выключена, остаётся строка в журнале процесса
|
|
256
|
-
clientReports: {
|
|
257
|
-
token: '',
|
|
258
|
-
flushIntervalMs: 30000,
|
|
259
|
-
forwardBatch: 50,
|
|
260
|
-
forwardTimeoutMs: 5000,
|
|
261
|
-
maxPending: 500, // потолок разных отпечатков в буфере
|
|
262
|
-
logSeenMax: 5000, // сколько отпечатков процесс помнит «уже печатал»
|
|
263
|
-
// бюджет НОВЫХ отпечатков на бокс (решение 9 плана): распределённый спам
|
|
264
|
-
// обходит лимит по IP, а новая строка в auth — главный ресурс. Повторы
|
|
265
|
-
// известных отпечатков бюджет не тратят
|
|
266
|
-
newFingerprintsPerMinute: 60,
|
|
267
|
-
// ключ — адрес, для IPv6 — подсеть /64 (lib/clientIp.js → rateLimitKey)
|
|
268
|
-
// клиент шлёт не чаще раза в 10 с — запас на несколько вкладок за одним
|
|
269
|
-
// адресом (NAT); главная защита — бюджет новых отпечатков
|
|
270
|
-
rateLimit: { limit: 30, windowMs: 60000 },
|
|
271
|
-
bodyLimit: '16kb',
|
|
272
|
-
maxItemsPerRequest: 10,
|
|
273
|
-
},
|
|
274
|
-
|
|
275
|
-
// заголовок с регионом хоста от Nginx/CDN (например, CF-IPCountry);
|
|
276
|
-
// выбран вместо geoip-lite — бесплатнее по памяти
|
|
277
|
-
regionHeader: 'x-region',
|
|
278
|
-
|
|
279
|
-
// лимит сигнальных ping-запросов с одного IP (защита от DDOS)
|
|
280
|
-
pingRateLimit: {
|
|
281
|
-
limit: 10,
|
|
282
|
-
windowMs: 1000,
|
|
283
|
-
},
|
|
284
|
-
|
|
285
|
-
// ICE-конфигурация для установки P2P-соединений:
|
|
286
|
-
// STUN обязателен; TURN — опциональный релей по итогам Этапа 0
|
|
287
|
-
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
|
288
|
-
};
|
package/src/lib/clientIp.js
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
// Адрес клиента для rate-limit'ов серверных контуров.
|
|
2
|
-
//
|
|
3
|
-
// `X-Forwarded-For` для этого не годится: Nginx деплоя ставит его через
|
|
4
|
-
// `$proxy_add_x_forwarded_for` (.github/deployment/install-system.sh), то есть
|
|
5
|
-
// ДОПИСЫВАЕТ реальный адрес к тому, что прислал клиент. Первый элемент списка
|
|
6
|
-
// задаёт сам клиент — на нём лимит и обходится (свой заголовок на каждое
|
|
7
|
-
// соединение), и выбирается чужой бакет (заголовок с адресом жертвы закрывает
|
|
8
|
-
// ей вход). Тот же Nginx перезаписывает `X-Real-IP` значением `$remote_addr`,
|
|
9
|
-
// поэтому за прокси доверяем только ему, а без прокси — адресу сокета.
|
|
10
|
-
|
|
11
|
-
// один раз на процесс: предупреждение о прокси без X-Real-IP на каждое
|
|
12
|
-
// соединение превратилось бы в лог-флуд
|
|
13
|
-
let proxyHeaderWarned = false;
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* @param {Object} req - Запрос (http.IncomingMessage или express Request).
|
|
17
|
-
* @param {Object} [options]
|
|
18
|
-
* @param {boolean} [options.trustProxy] - Стоит ли перед процессом обратный
|
|
19
|
-
* прокси, перезаписывающий `X-Real-IP` (в этом репозитории — прод-Nginx).
|
|
20
|
-
* @returns {string} Адрес клиента или '' (сокет уже разорван).
|
|
21
|
-
*/
|
|
22
|
-
export function clientIp(req, { trustProxy = false } = {}) {
|
|
23
|
-
const header = trustProxy ? req.headers['x-real-ip'] : undefined;
|
|
24
|
-
|
|
25
|
-
// за прокси без X-Real-IP ключом станет адрес самого прокси — один общий
|
|
26
|
-
// бакет на всех клиентов сразу: правило «одна комната на IP» пустило бы
|
|
27
|
-
// одну комнату на весь мастер, а лимит пингов стал бы общим. Падать на
|
|
28
|
-
// первом запросе нельзя (сервер должен подняться), молчать — тоже
|
|
29
|
-
if (trustProxy && !header && !proxyHeaderWarned) {
|
|
30
|
-
proxyHeaderWarned = true;
|
|
31
|
-
console.warn(
|
|
32
|
-
'[clientIp] trustProxy is on but no X-Real-IP header arrived: every ' +
|
|
33
|
-
"client now keys on the proxy's own address, one shared rate-limit " +
|
|
34
|
-
'bucket. Check `proxy_set_header X-Real-IP $remote_addr`.',
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
return String(header || req.socket?.remoteAddress || '').trim();
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Ключ rate-limit'а по адресу: IPv4 — сам адрес, IPv6 — подсеть /64.
|
|
42
|
-
// Провайдер выдаёт абоненту /64 целиком, то есть у одного человека
|
|
43
|
-
// 2^64 адресов, и лимит «на адрес» для IPv6 не лимит вовсе
|
|
44
|
-
export function rateLimitKey(ip) {
|
|
45
|
-
const addr = String(ip ?? '').trim();
|
|
46
|
-
|
|
47
|
-
if (!addr) {
|
|
48
|
-
return '';
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(addr);
|
|
52
|
-
|
|
53
|
-
if (mapped) {
|
|
54
|
-
return mapped[1];
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
if (!addr.includes(':')) {
|
|
58
|
-
return addr;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const groups = expandIpv6(addr.split('%')[0]);
|
|
62
|
-
|
|
63
|
-
if (!groups) {
|
|
64
|
-
return addr;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return `v6:${groups
|
|
68
|
-
.slice(0, 4)
|
|
69
|
-
.map(g => parseInt(g, 16).toString(16))
|
|
70
|
-
.join(':')}::/64`;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// '2001:db8::1' → 8 hex-групп или null, если запись непарсима
|
|
74
|
-
function expandIpv6(addr) {
|
|
75
|
-
const halves = addr.split('::');
|
|
76
|
-
|
|
77
|
-
if (halves.length > 2) {
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const head = halves[0] ? halves[0].split(':') : [];
|
|
82
|
-
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
|
83
|
-
const missing = 8 - head.length - tail.length;
|
|
84
|
-
|
|
85
|
-
if (halves.length === 1 ? missing !== 0 : missing < 1) {
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const groups = [
|
|
90
|
-
...head,
|
|
91
|
-
...Array(halves.length === 2 ? missing : 0).fill('0'),
|
|
92
|
-
...tail,
|
|
93
|
-
];
|
|
94
|
-
|
|
95
|
-
return groups.every(g => /^[0-9a-f]{1,4}$/i.test(g)) ? groups : null;
|
|
96
|
-
}
|
package/src/lib/rateLimiter.js
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
// Rate limiter с фиксированным окном: не более `limit` событий
|
|
2
|
-
// на ключ (например, IP) за `windowMs` миллисекунд.
|
|
3
|
-
export default class RateLimiter {
|
|
4
|
-
constructor({ limit, windowMs }) {
|
|
5
|
-
this._limit = limit;
|
|
6
|
-
this._windowMs = windowMs;
|
|
7
|
-
this._buckets = new Map(); // key -> { count, windowStart }
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
// регистрирует событие; false — лимит для ключа исчерпан
|
|
11
|
-
consume(key, now = Date.now()) {
|
|
12
|
-
const bucket = this._buckets.get(key);
|
|
13
|
-
|
|
14
|
-
if (!bucket || now - bucket.windowStart >= this._windowMs) {
|
|
15
|
-
this._buckets.set(key, { count: 1, windowStart: now });
|
|
16
|
-
return true;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
if (bucket.count >= this._limit) {
|
|
20
|
-
return false;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
bucket.count += 1;
|
|
24
|
-
|
|
25
|
-
return true;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// удаляет истёкшие окна (периодическая уборка памяти)
|
|
29
|
-
sweep(now = Date.now()) {
|
|
30
|
-
for (const [key, bucket] of this._buckets) {
|
|
31
|
-
if (now - bucket.windowStart >= this._windowMs) {
|
|
32
|
-
this._buckets.delete(key);
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
clear() {
|
|
38
|
-
this._buckets.clear();
|
|
39
|
-
}
|
|
40
|
-
}
|
package/src/lib/security.js
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import config from './config.js';
|
|
2
|
-
|
|
3
|
-
// создаёт проверку origin для заданных protocol/domain/port
|
|
4
|
-
// (используется и игровым сервером, и мастер-сервером)
|
|
5
|
-
const createOriginValidator = function ({ protocol, domain, port }) {
|
|
6
|
-
return function (requestOrigin, cb) {
|
|
7
|
-
const isProduction = process.env.NODE_ENV === 'production';
|
|
8
|
-
|
|
9
|
-
const allowedOrigins = [
|
|
10
|
-
// origin'ы для локальной разработки
|
|
11
|
-
`${protocol}//localhost:${port}`,
|
|
12
|
-
`${protocol}//127.0.0.1:${port}`,
|
|
13
|
-
];
|
|
14
|
-
|
|
15
|
-
if (isProduction) {
|
|
16
|
-
// в продакшене разрешен только настоящий домен по стандартному HTTPS
|
|
17
|
-
// порт не требуется, так как браузер не добавляет его
|
|
18
|
-
// для стандартного порта 443
|
|
19
|
-
allowedOrigins.push(`https://${domain}`);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
let err = null;
|
|
23
|
-
|
|
24
|
-
if (!allowedOrigins.includes(requestOrigin)) {
|
|
25
|
-
err = `Blocked connection from invalid origin: ${requestOrigin}`;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
process.nextTick(() => cb(err));
|
|
29
|
-
};
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
const origin = function (requestOrigin, cb) {
|
|
33
|
-
const validator = createOriginValidator({
|
|
34
|
-
protocol: config.get('server:protocol'),
|
|
35
|
-
domain: config.get('server:domain'),
|
|
36
|
-
port: config.get('server:port'),
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
validator(requestOrigin, cb);
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
export default {
|
|
43
|
-
origin,
|
|
44
|
-
createOriginValidator,
|
|
45
|
-
};
|