bf0d130ee8
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 на шестой попытке входа, секретов в логах нет.
645 lines
19 KiB
TypeScript
645 lines
19 KiB
TypeScript
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;
|
||
}
|