Files
iistwin/server/vpn/vpn.service.ts
Ильяс Султанов aea0e9c3fe feat(vpn): монитор актуальности Яндекс Телемост + инструкция про выход из встречи
- INSTRUCTIONS_TEXT: добавлено требование выйти из встречи, а не завершать её
- vpn.db.ts: findAllActiveVpnTasks для поиска активных VPN-задач
- vpn.service.ts: checkVpnRoomsValidity — периодическая проверка комнат, concurrency=5
- vpn-bot.service.ts: findVpnBotConversationId и notifyVpnRoomExpired
- server/index.ts: worker каждые 6 часов, уведомление пользователю о протухшей ссылке
2026-07-17 13:08:24 +03:00

357 lines
12 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 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}<vp8-fps=${OLC_RTC_VP8_FPS}&vp8-batch=${OLC_RTC_VP8_BATCH}>@${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<string, { valid: boolean; expiresAt: number }>();
export async function validateTelemostRoom(roomUrl: string): Promise<boolean> {
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<PreparedSubscription | null> {
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<PreparedSubscription> {
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<string> {
// 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<void> {
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<void> {
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<ExpiredVpnRoom[]> {
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;
}