Files
glchat/web/src/api/gateway.ts
T
grendervill bf0d130ee8 feat(cli): включение 2FA инстанс-админа командой totp-setup
2FA обязательна для инстанс-администраторов, но до её включения вход закрыт —
получался замкнутый круг. Добавлены команды обслуживания:

- `glchat totp-setup --email <admin>`: создаёт секрет, подтверждает его кодом,
  печатает секрет, otpauth-ссылку и 8 резервных кодов (каждый одноразовый);
- `glchat totp-reset --email <admin>`: удаляет секрет при потере устройства;
- код `auth.2fa_enrollment_required` с подсказкой, какую команду выполнить;
- установщик: флаг `--admin-2fa` для автоматического включения (по умолчанию
  печатает подсказку, чтобы секреты не оседали в логах установки).

Проверено сквозным прогоном локально: bootstrap → 403 на входе без 2FA →
totp-setup → вход с TOTP-кодом → профиль, серверы, комнаты, роли, участники,
аудит, создание сервера админом, 429 на шестой попытке входа, секретов в логах
нет.
2026-09-19 21:53:35 +03:00

645 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describeUnknown } from '@/lib/http';
import type { Channel, Role, UserStatus } from './types';
/**
* Клиент шлюза (WebSocket) Фазы 1.
*
* Контракт: сервер присылает HELLO (op 10) с интервалом heartbeat, клиент
* отвечает IDENTIFY (op 2, пустой токен — браузер аутентифицируется cookie
* на рукопожатии), затем получает READY/RESUMED. Разрыв соединения лечится
* экспоненциальной задержкой и RESUME (op 3) с последним полученным `s`.
*
* Модуль намеренно не зависит от React и Zustand: он принимает колбэки,
* поэтому его можно проверить юнит-тестом с подставным WebSocket.
*/
export const GatewayOp = {
DISPATCH: 0,
HEARTBEAT: 1,
IDENTIFY: 2,
RESUME: 3,
INVALID_SESSION: 4,
HELLO: 10,
HEARTBEAT_ACK: 11,
RECONNECT: 12,
} as const;
export const GATEWAY_CLOSE_INVALID_SESSION = 4000;
export interface GatewayHello {
heartbeat_interval_ms: number;
session_id: string;
}
/** Сервер в снапшоте READY: каналы/роли/права приходят сразу. */
export interface GatewayGuild {
id: string;
name: string;
owner_id: string;
is_main: boolean;
icon_file_id?: string;
channels: Channel[];
roles: Role[];
member_ids: string[];
my_role_ids: string[];
my_nickname?: string;
my_permissions?: string[];
}
export interface GatewayUser {
id: string;
username: string;
display_name: string;
avatar_file_id?: string;
is_instance_admin: boolean;
badges: string[];
status?: UserStatus;
custom_status?: string;
custom_status_emoji?: string;
}
export interface GatewaySnapshot {
user: GatewayUser;
guilds: GatewayGuild[];
dm_channels: Channel[];
read_states: unknown[];
session_id: string;
heartbeat_interval_ms: number;
}
export interface GatewayDispatch {
op: 0;
t: string;
s: number;
d: unknown;
}
export type GatewayStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'disconnected';
export interface GatewayHandlers {
/** Любое событие DISPATCH (READY, RESUMED и события гильдий/каналов). */
onDispatch?: (event: GatewayDispatch) => void;
onStatus?: (status: GatewayStatus) => void;
/** Сессия недействительна — нужно разлогинить пользователя. */
onInvalidSession?: () => void;
/** RESUMED без снапшота: данные нужно перечитать по REST. */
onResumeIncomplete?: () => void;
}
export interface GatewayLogger {
warn: (message: string) => void;
error: (message: string) => void;
}
export interface GatewayConnectionOptions {
url: string;
handlers?: GatewayHandlers;
/** Инъекция для тестов; по умолчанию берётся глобальный WebSocket. */
socketFactory?: (url: string) => WebSocket;
logger?: GatewayLogger;
/** Базовая задержка переподключения; в тестах её уменьшают. */
baseBackoffMs?: number;
/** Сколько ждать HELLO после открытия сокета. */
helloTimeoutMs?: number;
}
const DEFAULT_HEARTBEAT_MS = 45_000;
const MAX_BACKOFF_MS = 30_000;
const BASE_BACKOFF_MS = 1_000;
const HELLO_TIMEOUT_MS = 15_000;
interface GatewayPacket {
op: number;
t?: string;
s?: number;
d?: unknown;
}
function isGatewayPacket(value: unknown): value is GatewayPacket {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as { op?: unknown }).op === 'number'
);
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}
function asNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function asStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: [];
}
function parseChannel(value: unknown): Channel | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const id = asString(record['id']);
const name = asString(record['name']);
const type = asString(record['type']);
if (id === undefined || name === undefined) {
return null;
}
if (type !== 'text' && type !== 'voice' && type !== 'category') {
return null;
}
const channel: Channel = {
id,
name,
type,
position: asNumber(record['position']) ?? 0,
};
const guildId = asString(record['guild_id']);
const parentId = asString(record['parent_id']);
const userLimit = asNumber(record['user_limit']);
const slowmode = asNumber(record['slowmode_seconds']);
if (guildId !== undefined) {
channel.guild_id = guildId;
}
if (parentId !== undefined) {
channel.parent_id = parentId;
}
if (userLimit !== undefined) {
channel.user_limit = userLimit;
}
if (slowmode !== undefined) {
channel.slowmode_seconds = slowmode;
}
if (typeof record['can_send'] === 'boolean') {
channel.can_send = record['can_send'];
}
if (typeof record['can_connect'] === 'boolean') {
channel.can_connect = record['can_connect'];
}
if (typeof record['can_view'] === 'boolean') {
channel.can_view = record['can_view'];
}
return channel;
}
function parseRole(value: unknown): Role | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const id = asString(record['id']);
const name = asString(record['name']);
if (id === undefined || name === undefined) {
return null;
}
const permissions = record['permissions'];
return {
id,
name,
color: asNumber(record['color']) ?? 0,
position: asNumber(record['position']) ?? 0,
permissions:
typeof permissions === 'string'
? permissions
: typeof permissions === 'number'
? String(permissions)
: '0',
is_default: record['is_default'] === true,
hoist: record['hoist'] === true,
mentionable: record['mentionable'] === true,
};
}
function parseGatewayUser(value: unknown): GatewayUser | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const id = asString(record['id']);
const username = asString(record['username']);
if (id === undefined || username === undefined) {
return null;
}
const user: GatewayUser = {
id,
username,
display_name: asString(record['display_name']) ?? username,
is_instance_admin: record['is_instance_admin'] === true,
badges: asStringArray(record['badges']),
};
const avatar = asString(record['avatar_file_id']);
const status = asString(record['status']);
const customStatus = asString(record['custom_status']);
const customEmoji = asString(record['custom_status_emoji']);
if (avatar !== undefined) {
user.avatar_file_id = avatar;
}
if (status !== undefined) {
user.status = status as UserStatus;
}
if (customStatus !== undefined) {
user.custom_status = customStatus;
}
if (customEmoji !== undefined) {
user.custom_status_emoji = customEmoji;
}
return user;
}
function parseGuild(value: unknown): GatewayGuild | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const id = asString(record['id']);
const name = asString(record['name']);
if (id === undefined || name === undefined) {
return null;
}
const channels = Array.isArray(record['channels'])
? record['channels'].map(parseChannel).filter((item): item is Channel => item !== null)
: [];
const roles = Array.isArray(record['roles'])
? record['roles'].map(parseRole).filter((item): item is Role => item !== null)
: [];
const guild: GatewayGuild = {
id,
name,
owner_id: asString(record['owner_id']) ?? '',
is_main: record['is_main'] === true,
channels,
roles,
member_ids: asStringArray(record['member_ids']),
my_role_ids: asStringArray(record['my_role_ids']),
my_permissions: asStringArray(record['my_permissions']),
};
const icon = asString(record['icon_file_id']);
const nickname = asString(record['my_nickname']);
if (icon !== undefined) {
guild.icon_file_id = icon;
}
if (nickname !== undefined) {
guild.my_nickname = nickname;
}
return guild;
}
/** Разбирает снапшот READY, отбрасывая всё, что не совпало с контрактом. */
export function parseGatewaySnapshot(value: unknown): GatewaySnapshot | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const user = parseGatewayUser(record['user']);
if (user === null) {
return null;
}
const guilds = Array.isArray(record['guilds'])
? record['guilds'].map(parseGuild).filter((item): item is GatewayGuild => item !== null)
: [];
const dmChannels = Array.isArray(record['dm_channels'])
? record['dm_channels'].map(parseChannel).filter((item): item is Channel => item !== null)
: [];
return {
user,
guilds,
dm_channels: dmChannels,
read_states: Array.isArray(record['read_states']) ? record['read_states'] : [],
session_id: asString(record['session_id']) ?? '',
heartbeat_interval_ms: asNumber(record['heartbeat_interval_ms']) ?? DEFAULT_HEARTBEAT_MS,
};
}
export function gatewayUrl(): string {
if (typeof window === 'undefined') {
return '/gateway';
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/gateway`;
}
export type GatewaySend = (packet: { op: number; d: unknown }) => void;
export class GatewayConnection {
private readonly url: string;
private readonly handlers: GatewayHandlers;
private readonly socketFactory: (url: string) => WebSocket;
private readonly logger: GatewayLogger;
private readonly baseBackoffMs: number;
private readonly helloTimeoutMs: number;
private socket: WebSocket | null = null;
private status: GatewayStatus = 'idle';
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private helloTimeout: ReturnType<typeof setTimeout> | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private heartbeatIntervalMs = DEFAULT_HEARTBEAT_MS;
private awaitingAck = false;
private attempt = 0;
private sessionId: string | null = null;
private lastSeq = 0;
private closedByUser = false;
constructor(options: GatewayConnectionOptions) {
this.url = options.url;
this.handlers = options.handlers ?? {};
this.logger = options.logger ?? { warn: () => undefined, error: () => undefined };
this.baseBackoffMs = options.baseBackoffMs ?? BASE_BACKOFF_MS;
this.helloTimeoutMs = options.helloTimeoutMs ?? HELLO_TIMEOUT_MS;
this.socketFactory =
options.socketFactory ??
((url) => {
if (typeof WebSocket === 'undefined') {
throw new Error('WebSocket is not available in this environment');
}
return new WebSocket(url);
});
}
getStatus(): GatewayStatus {
return this.status;
}
getSessionId(): string | null {
return this.sessionId;
}
getLastSeq(): number {
return this.lastSeq;
}
connect(): void {
this.closedByUser = false;
if (this.socket !== null) {
return;
}
this.open(undefined);
}
/** Плановое закрытие: без переподключения. */
close(): void {
this.closedByUser = true;
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.stopHeartbeat();
const socket = this.socket;
this.socket = null;
if (socket !== null) {
socket.onopen = null;
socket.onmessage = null;
socket.onclose = null;
socket.onerror = null;
try {
socket.close();
} catch {
// Сокет мог уже закрыться — это не ошибка.
}
}
this.setStatus('disconnected');
}
private open(reason: unknown): void {
this.setStatus(this.attempt === 0 ? 'connecting' : 'reconnecting');
let socket: WebSocket;
try {
socket = this.socketFactory(this.url);
} catch (error) {
this.logger.warn(`gateway: cannot open socket (${describeUnknown(error)})`);
this.scheduleReconnect();
return;
}
this.socket = socket;
if (reason !== undefined) {
this.logger.warn(`gateway: reconnecting (${describeUnknown(reason)})`);
}
socket.onopen = () => {
// Ждём HELLO; если его нет — соединение считаем нерабочим.
this.helloTimeout = setTimeout(() => {
this.logger.warn('gateway: HELLO timeout');
this.dropSocket();
}, this.helloTimeoutMs);
};
socket.onmessage = (event: MessageEvent) => {
this.handleMessage(event.data);
};
socket.onerror = () => {
this.logger.warn('gateway: socket error');
};
socket.onclose = (event: CloseEvent) => {
const wasCurrent = this.socket === socket;
this.socket = null;
this.stopHeartbeat();
if (!wasCurrent || this.closedByUser) {
return;
}
if (event.code === GATEWAY_CLOSE_INVALID_SESSION) {
this.setStatus('disconnected');
this.handlers.onInvalidSession?.();
return;
}
this.scheduleReconnect();
};
}
private dropSocket(): void {
const socket = this.socket;
this.socket = null;
this.stopHeartbeat();
if (socket !== null) {
socket.onclose = null;
try {
socket.close();
} catch {
// Игнорируем: сокет уже не нужен.
}
}
this.scheduleReconnect();
}
private scheduleReconnect(): void {
if (this.closedByUser || this.reconnectTimer !== null) {
return;
}
this.attempt += 1;
const backoff = Math.min(this.baseBackoffMs * 2 ** (this.attempt - 1), MAX_BACKOFF_MS);
const jitter = Math.floor(Math.random() * 250);
this.setStatus('reconnecting');
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.open('backoff');
}, backoff + jitter);
}
private setStatus(status: GatewayStatus): void {
if (this.status === status) {
return;
}
this.status = status;
this.handlers.onStatus?.(status);
}
private send(op: number, d: unknown): void {
const socket = this.socket;
if (socket === null || socket.readyState !== 1) {
return;
}
socket.send(JSON.stringify({ op, d }));
}
private handleMessage(raw: unknown): void {
if (typeof raw !== 'string') {
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
this.logger.warn('gateway: cannot parse frame');
return;
}
if (!isGatewayPacket(parsed)) {
return;
}
switch (parsed.op) {
case GatewayOp.HELLO: {
const data = asRecord(parsed.d);
this.heartbeatIntervalMs =
asNumber(data?.['heartbeat_interval_ms']) ?? DEFAULT_HEARTBEAT_MS;
this.sessionId = asString(data?.['session_id']) ?? this.sessionId;
if (this.helloTimeout !== null) {
clearTimeout(this.helloTimeout);
this.helloTimeout = null;
}
this.identify();
this.startHeartbeat();
return;
}
case GatewayOp.HEARTBEAT_ACK: {
this.awaitingAck = false;
return;
}
case GatewayOp.DISPATCH: {
const seq = asNumber(parsed.s) ?? 0;
if (seq > this.lastSeq) {
this.lastSeq = seq;
}
const type = asString(parsed.t) ?? '';
if (type === 'READY') {
this.attempt = 0;
}
if (type === 'RESUMED' && parsed.d === null) {
this.handlers.onResumeIncomplete?.();
}
this.setStatus('connected');
this.handlers.onDispatch?.({
op: 0,
t: type,
s: seq,
d: parsed.d,
});
return;
}
case GatewayOp.INVALID_SESSION: {
const resumable = parsed.d === true;
if (resumable) {
this.logger.warn('gateway: invalid session, retrying resume');
this.dropSocket();
return;
}
this.sessionId = null;
this.lastSeq = 0;
this.closedByUser = true;
this.setStatus('disconnected');
this.handlers.onInvalidSession?.();
return;
}
case GatewayOp.RECONNECT: {
this.dropSocket();
return;
}
default:
// Неизвестные opcode игнорируем: контракт может расширяться.
return;
}
}
private identify(): void {
// Пустой токен допустим: браузер аутентифицируется cookie на рукопожатии.
const token = '';
if (this.sessionId !== null && this.lastSeq > 0) {
this.send(GatewayOp.RESUME, { token, resume_seq: this.lastSeq });
return;
}
this.send(GatewayOp.IDENTIFY, { token, resume_seq: 0 });
}
private startHeartbeat(): void {
this.stopHeartbeat();
this.awaitingAck = false;
this.heartbeatTimer = setInterval(() => {
if (this.awaitingAck) {
this.logger.warn('gateway: heartbeat was not acknowledged');
this.dropSocket();
return;
}
this.awaitingAck = true;
this.send(GatewayOp.HEARTBEAT, null);
}, this.heartbeatIntervalMs);
}
private stopHeartbeat(): void {
if (this.heartbeatTimer !== null) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
if (this.helloTimeout !== null) {
clearTimeout(this.helloTimeout);
this.helloTimeout = null;
}
}
}
let sharedConnection: GatewayConnection | null = null;
export function getGatewayConnection(): GatewayConnection | null {
return sharedConnection;
}
/** Создаёт (или переиспользует) единственное соединение с шлюзом. */
export function connectGateway(options: {
url?: string;
handlers: GatewayHandlers;
socketFactory?: (url: string) => WebSocket;
}): GatewayConnection {
if (sharedConnection !== null) {
sharedConnection.close();
}
const socketFactory = options.socketFactory;
sharedConnection = new GatewayConnection({
url: options.url ?? gatewayUrl(),
handlers: options.handlers,
...(socketFactory === undefined ? {} : { socketFactory }),
});
sharedConnection.connect();
return sharedConnection;
}
export function disconnectGateway(): void {
sharedConnection?.close();
sharedConnection = null;
}