import crypto from 'crypto'; import { findVpnTaskByToken, findVpnTaskByRoomUrl, findVpnTaskByRoomAndDevice, findVpnTaskByRoomAndSession, loadVpnTaskValues, setVpnTaskField, setVpnTaskStatus, updateVpnTaskFieldMap, createVpnTask, getVpnFormCache, getFieldId, findAllActiveVpnTasks, type VpnFormCache, } from './vpn.db'; import { SUBSCRIPTION_BASE_URL, DEFAULT_TRAFFIC_LIMIT_GB, DEFAULT_SUBSCRIPTION_DAYS, SUBSCRIPTION_UPDATE_INTERVAL_HOURS, OLC_RTC_PROVIDER, OLC_RTC_TRANSPORT, OLC_RTC_VP8_FPS, OLC_RTC_VP8_BATCH, VPN_TELEMOST_API_BASE, } from './vpn.config'; export class VpnError extends Error { constructor(message: string, public readonly code: string) { super(message); this.name = 'VpnError'; } } function generateToken(): string { return crypto.randomBytes(24).toString('base64url'); } function generateCryptoKey(): string { return crypto.randomBytes(32).toString('hex'); } export function buildSubscriptionUrl(token: string): string { return `${SUBSCRIPTION_BASE_URL}/${token}`; } export function buildOlcRtcUri(roomId: string, cryptoKey: string, employeeName: string): string { const name = (employeeName || 'Corp VPN').replace(/\s+/g, ' ').trim(); return `olcrtc://${OLC_RTC_PROVIDER}?${OLC_RTC_TRANSPORT}@${roomId}#${cryptoKey}$${name}`; } export function extractRoomUrl(text: string): string | null { const trimmed = text.trim(); // Full or partial link: https://telemost.yandex.ru/j/XXXXXX (with optional www, query, hash) const linkMatch = trimmed.match(/(?:https?:\/\/)?(?:www\.)?telemost\.yandex\.ru\/j\/([a-zA-Z0-9_-]+)/i); if (linkMatch) { return `https://telemost.yandex.ru/j/${linkMatch[1]}`; } // Just the room ID if (/^[a-zA-Z0-9_-]+$/.test(trimmed)) { return `https://telemost.yandex.ru/j/${trimmed}`; } return null; } export function extractRoomId(roomUrl: string): string { const idx = roomUrl.lastIndexOf('/j/'); if (idx === -1) return roomUrl; return roomUrl.slice(idx + 3); } const telemostValidationCache = new Map(); export async function validateTelemostRoom(roomUrl: string): Promise { const cached = telemostValidationCache.get(roomUrl); if (cached && cached.expiresAt > Date.now()) { return cached.valid; } try { const encoded = encodeURIComponent(roomUrl); const url = `${VPN_TELEMOST_API_BASE}/conferences/${encoded}/connection?next_gen_media_platform_allowed=true&display_name=VPN-Bot&waiting_room_supported=true`; const resp = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:149.0) Gecko/20100101 Firefox/149.0', Accept: '*/*', 'Content-Type': 'application/json', 'Client-Instance-Id': crypto.randomUUID(), 'X-Telemost-Client-Version': '187.1.0', 'Idempotency-Key': crypto.randomUUID(), Origin: 'https://telemost.yandex.ru', Referer: 'https://telemost.yandex.ru/', }, signal: AbortSignal.timeout(3000), }); const valid = resp.status === 200; telemostValidationCache.set(roomUrl, { valid, expiresAt: Date.now() + 10 * 60 * 1000 }); return valid; } catch (err) { console.error('[VPN] Telemost validation error:', err); telemostValidationCache.set(roomUrl, { valid: false, expiresAt: Date.now() + 60 * 1000 }); return false; } } export interface PreparedSubscription { subscriptionUrl: string; isNew: boolean; taskId?: number; roomId: string; cryptoKey?: string; } export async function getExistingVpnSubscription( organizationId: number, roomUrl: string, ): Promise { const existing = await findVpnTaskByRoomUrl(roomUrl, organizationId); if (!existing) return null; const token = existing.values.subscription_token as string | undefined; if (!token) return null; return { subscriptionUrl: buildSubscriptionUrl(token), isNew: false, taskId: existing.task.id, roomId: extractRoomId(roomUrl), cryptoKey: existing.values.crypto_key as string | undefined, }; } export async function prepareVpnSubscription( organizationId: number, userId: number, userName: string, roomUrl: string, ): Promise { const existing = await findVpnTaskByRoomUrl(roomUrl, organizationId); if (existing && existing.task.createdBy !== userId) { throw new VpnError('Эта комната уже используется другим сотрудником', 'ROOM_IN_USE'); } if (existing) { const token = existing.values.subscription_token as string | undefined; if (!token) throw new VpnError('Подписка повреждена, обратитесь к администратору', 'BROKEN'); return { subscriptionUrl: buildSubscriptionUrl(token), isNew: false, taskId: existing.task.id, roomId: extractRoomId(roomUrl), cryptoKey: existing.values.crypto_key as string | undefined, }; } const roomId = extractRoomId(roomUrl); const isValid = await validateTelemostRoom(roomUrl); if (!isValid) { throw new VpnError( 'Не удалось найти встречу. Создайте новую в Яндекс Телемост и пришлите ссылку.', 'ROOM_NOT_FOUND', ); } const token = generateToken(); const cryptoKey = generateCryptoKey(); const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + DEFAULT_SUBSCRIPTION_DAYS); const task = await createVpnTask({ organizationId, createdBy: userId, title: `VPN ${userName || `#${userId}`} / ${roomId}`, statusName: 'Активна', fields: { employee_name: userName || '', room_id: roomId, room_url: roomUrl, crypto_key: cryptoKey, subscription_token: token, status: 'Активна', expires_at: expiresAt.toISOString().slice(0, 10), traffic_limit_gb: DEFAULT_TRAFFIC_LIMIT_GB, traffic_used_gb: 0, is_active: true, }, }); return { subscriptionUrl: buildSubscriptionUrl(token), isNew: true, taskId: task.id, roomId, cryptoKey, }; } /** @deprecated Use prepareVpnSubscription instead */ export async function getOrCreateVpnSubscription( organizationId: number, userId: number, userName: string, roomUrl: string, ): Promise<{ subscriptionUrl: string; isNew: boolean }> { const result = await prepareVpnSubscription(organizationId, userId, userName, roomUrl); return { subscriptionUrl: result.subscriptionUrl, isNew: result.isNew }; } export async function fetchSubscription(token: string, hwid: string | undefined): Promise { // We don't know organizationId here; token is globally unique. // Search across all orgs by token. const orgIds = [1]; // TODO: support multi-org for (const orgId of orgIds) { const data = await findVpnTaskByToken(token, orgId); if (!data) continue; const { task, values } = data; if (!values.is_active || values.status !== 'Активна') { throw new VpnError('Подписка неактивна', 'INACTIVE'); } if (values.expires_at && new Date(values.expires_at as string) < new Date()) { await setVpnTaskStatus(task.id, 'Истекла', orgId); throw new VpnError('Срок действия подписки истёк', 'EXPIRED'); } const limit = Number(values.traffic_limit_gb || 0); const used = Number(values.traffic_used_gb || 0); if (limit > 0 && used >= limit) { throw new VpnError('Превышен лимит трафика', 'TRAFFIC_LIMIT'); } if (hwid) { const boundHwid = values.hwid as string | undefined; if (!boundHwid) { await setVpnTaskField(task.id, 'hwid', hwid, orgId); } else if (boundHwid !== hwid) { throw new VpnError('Подписка привязана к другому устройству', 'HWID_MISMATCH'); } } const roomId = values.room_id as string; const cryptoKey = values.crypto_key as string; const employeeName = (values.employee_name as string) || 'Corp VPN'; const uri = buildOlcRtcUri(roomId, cryptoKey, employeeName); return uri; } throw new VpnError('Подписка не найдена', 'NOT_FOUND'); } export async function authorizeSession( roomUrl: string, deviceId: string, ): Promise<{ sessionId: string; organizationId: number; taskId: number }> { const orgIds = [1]; // TODO: support multi-org for (const orgId of orgIds) { const data = await findVpnTaskByRoomAndDevice(roomUrl, deviceId, orgId); if (!data) continue; const { task, values } = data; if (!values.is_active || values.status !== 'Активна') { throw new VpnError('Подписка неактивна', 'INACTIVE'); } if (values.expires_at && new Date(values.expires_at as string) < new Date()) { throw new VpnError('Срок действия подписки истёк', 'EXPIRED'); } const sessionId = crypto.randomUUID(); await updateVpnTaskFieldMap(task.id, orgId, { active_session_id: sessionId, last_seen_at: new Date().toISOString(), last_error: '', }); return { sessionId, organizationId: orgId, taskId: task.id }; } throw new VpnError('Устройство не авторизовано', 'UNAUTHORIZED'); } export async function closeSession( roomUrl: string, sessionId: string, ): Promise { const orgIds = [1]; for (const orgId of orgIds) { const data = await findVpnTaskByRoomAndSession(roomUrl, sessionId, orgId); if (!data) continue; const activeSession = data.values.active_session_id as string | undefined; if (activeSession === sessionId) { await setVpnTaskField(data.task.id, 'active_session_id', '', orgId); await setVpnTaskField(data.task.id, 'last_seen_at', new Date().toISOString(), orgId); } return; } } export async function recordTraffic( roomUrl: string, sessionId: string, bytesIn: number, bytesOut: number, ): Promise { const orgIds = [1]; for (const orgId of orgIds) { const data = await findVpnTaskByRoomAndSession(roomUrl, sessionId, orgId); if (!data) continue; const used = Number(data.values.traffic_used_gb || 0); const added = (bytesIn + bytesOut) / (1024 * 1024 * 1024); await updateVpnTaskFieldMap(data.task.id, orgId, { traffic_used_gb: Math.round((used + added) * 1000) / 1000, last_seen_at: new Date().toISOString(), }); return; } } export interface ExpiredVpnRoom { taskId: number; organizationId: number; createdBy: number; roomUrl: string; } export async function checkVpnRoomsValidity(): Promise { const activeTasks = await findAllActiveVpnTasks(); const expired: ExpiredVpnRoom[] = []; // Limit concurrent validations to avoid overwhelming Yandex API const concurrency = 5; for (let i = 0; i < activeTasks.length; i += concurrency) { const batch = activeTasks.slice(i, i + concurrency); const results = await Promise.all( batch.map(async (task) => { try { const isValid = await validateTelemostRoom(task.roomUrl); return { task, isValid }; } catch (err) { console.error(`[VPN] validation error for task ${task.taskId}:`, err); return { task, isValid: false }; } }), ); for (const { task, isValid } of results) { if (!isValid) { expired.push(task); try { await setVpnTaskStatus(task.taskId, 'Истекла', task.organizationId); await setVpnTaskField(task.taskId, 'last_error', 'Комната Яндекс Телемост недоступна', task.organizationId); } catch (err) { console.error(`[VPN] failed to mark task ${task.taskId} as expired:`, err); } } } } return expired; }