Files
iistwin/server/vpn/vpn.service.ts
Ильяс Султанов 586a591773 fix(vpn): восстановление VPN-модуля после затирания chat/push fix'ом
- Добавлена ветка bot.type === 'vpn' в messenger.messages.routes.ts
- Добавлен полный модуль server/vpn/ (routes, service, db, bot, config)
- Подключены VPN-маршруты в server/routes/index.ts
- Добавлена раздача /apps статики и COPY в Dockerfile
- apps/ пока содержит .gitkeep и README; APK/IPA нужно добавить отдельно
2026-07-17 09:32:43 +03:00

254 lines
8.6 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,
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);
}
export async function validateTelemostRoom(roomUrl: string): Promise<boolean> {
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/',
},
});
return resp.status === 200;
} catch (err) {
console.error('[VPN] Telemost validation error:', err);
return false;
}
}
export async function getOrCreateVpnSubscription(
organizationId: number,
userId: number,
userName: string,
roomUrl: string,
): Promise<{ subscriptionUrl: string; isNew: boolean }> {
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 };
}
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);
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 };
}
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;
}
}