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 нужно добавить отдельно
This commit is contained in:
2026-07-17 09:32:43 +03:00
parent 0a5f4516ef
commit 586a591773
12 changed files with 985 additions and 58 deletions

253
server/vpn/vpn.service.ts Normal file
View File

@@ -0,0 +1,253 @@
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;
}
}