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:
253
server/vpn/vpn.service.ts
Normal file
253
server/vpn/vpn.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user