diff --git a/Dockerfile b/Dockerfile index 7211241..3f30007 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,9 @@ COPY --from=builder /app/server/scripts ./server/scripts RUN chmod +x docker-entrypoint.sh RUN mkdir -p /app/data +# VPN client apps (olcbox APK/IPA) +COPY --from=builder /app/apps /app/apps + # Python + python-docx для обработки DOCX-шаблонов RUN apk add --no-cache python3 py3-pip py3-lxml \ && pip3 install --break-system-packages python-docx \ diff --git a/apps/.gitkeep b/apps/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/README.md b/apps/README.md new file mode 100644 index 0000000..e75c3c8 --- /dev/null +++ b/apps/README.md @@ -0,0 +1,10 @@ +# VPN client apps + +Эта папка должна содержать файлы приложений olcbox, которые раздаёт VPN-бот: + +- `Olcbox-1.0.112-android-release.apk` +- `Olcbox-1.0.112-ios-unsigned.ipa` + +Файлы копируются в образ через `COPY --from=builder /app/apps /app/apps` в `Dockerfile`. + +Без этих файлов сборка пройдёт, но бот не сможет прикрепить APK/IPA к сообщениям. diff --git a/server/index.ts b/server/index.ts index fdb1e22..675f3f1 100644 --- a/server/index.ts +++ b/server/index.ts @@ -986,6 +986,9 @@ async function runStartupDataPatches() { const server = await registerRoutes(app); + // Static files for VPN client apps (olcbox APK/IPA) + app.use('/apps', express.static('/app/apps')); + // Seed application roles and permissions try { await storage.seedAppRolesAndPermissions(); diff --git a/server/routes/index.ts b/server/routes/index.ts index 8fe9d85..537ea5f 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -40,6 +40,7 @@ import organizationSkinsRouter from "./organization-skins.routes"; import { registerSyncRoutes } from "./sync.routes"; import { registerMedScheduleRoutes } from "../medschedule/routes"; import { registerMedScheduleBotRoutes } from "../medschedule/bot.routes"; +import { registerVpnRoutes } from "../vpn"; import { FIELD_TYPE_DEFINITIONS } from "../../shared/field-types"; export { publishNotificationSSE }; @@ -117,6 +118,7 @@ export async function registerRoutes(app: Express): Promise { registerSyncRoutes(app); registerMedScheduleRoutes(app); registerMedScheduleBotRoutes(app); + registerVpnRoutes(app); const httpServer = createServer(app); return httpServer; diff --git a/server/routes/messenger.messages.routes.ts b/server/routes/messenger.messages.routes.ts index a8350b8..978d7e3 100644 --- a/server/routes/messenger.messages.routes.ts +++ b/server/routes/messenger.messages.routes.ts @@ -11,6 +11,7 @@ import { formatUserName } from "../utils/formatUserName"; import { notificationService } from "../services/notification.service"; import { handleAiBotDirectMessage } from "../services/ai-bot.service"; import { checkBotAccess } from "../services/ai-bot.service"; +import { handleVpnBotDirectMessage } from "../vpn/vpn-bot.service"; export function registerMessengerMessageRoutes(router: Router): void { // GET /api/messenger/conversations/:id/messages @@ -249,71 +250,111 @@ export function registerMessengerMessageRoutes(router: Router): void { .where(eq(bots.id, botId)); if (!bot || !bot.isActive) return; - if (bot.type !== 'ai_assistant') return; - const typedBot = bot as Bot; - if (!checkBotAccess(typedBot, req.user!)) { - const [denyMsg] = await db - .insert(conversationMessages) - .values({ + + if (bot.type === 'ai_assistant') { + const typedBot = bot as Bot; + if (!checkBotAccess(typedBot, req.user!)) { + const [denyMsg] = await db + .insert(conversationMessages) + .values({ + conversationId: convId, + authorId: null, + botId: bot.id, + message: `⛔ У вас нет доступа к боту ${bot.name}`, + replyToId: null, + mentionedUserIds: null, + attachments: null, + }) + .returning(); + const enrichedDeny = await enrichMessage(denyMsg.id); + for (const { userId: memberId, orgId: memberOrgId } of members) { + eventBus.publishEvent({ type: "conv_message_created", data: { conversationId: convId, message: enrichedDeny }, organizationId: memberOrgId, userId: memberId }); + } + notificationService.notifyMessengerMessage({ conversationId: convId, - authorId: null, - botId: bot.id, - message: `⛔ У вас нет доступа к боту ${bot.name}`, - replyToId: null, - mentionedUserIds: null, - attachments: null, - }) - .returning(); - const enrichedDeny = await enrichMessage(denyMsg.id); - for (const { userId: memberId, orgId: memberOrgId } of members) { - eventBus.publishEvent({ type: "conv_message_created", data: { conversationId: convId, message: enrichedDeny }, organizationId: memberOrgId, userId: memberId }); + authorId: -1, + authorName: bot.name, + chatName: bot.name, + chatType: 'bot_direct', + messageText: `⛔ У вас нет доступа к боту ${bot.name}`, + mentionedUserIds: [], + memberOrgMap, + }).catch(err => console.error('[Bot Deny Notify] Error:', err)); + return; } - notificationService.notifyMessengerMessage({ + + await handleAiBotDirectMessage({ + bot: typedBot, conversationId: convId, - authorId: -1, - authorName: bot.name, - chatName: bot.name, - chatType: 'bot_direct', - messageText: `⛔ У вас нет доступа к боту ${bot.name}`, - mentionedUserIds: [], - memberOrgMap, - }).catch(err => console.error('[Bot Deny Notify] Error:', err)); - return; - } + message: message.trim(), + user: req.user!, + organizationId: orgId, + }); - await handleAiBotDirectMessage({ - bot: typedBot, - conversationId: convId, - message: message.trim(), - user: req.user!, - organizationId: orgId, - }); + const latestBotMsg = await db + .select({ id: conversationMessages.id }) + .from(conversationMessages) + .where(and( + eq(conversationMessages.conversationId, convId), + eq(conversationMessages.botId, botId), + )) + .orderBy(desc(conversationMessages.id)) + .limit(1); - const latestBotMsg = await db - .select({ id: conversationMessages.id }) - .from(conversationMessages) - .where(and( - eq(conversationMessages.conversationId, convId), - eq(conversationMessages.botId, botId), - )) - .orderBy(desc(conversationMessages.id)) - .limit(1); - - if (latestBotMsg[0]) { - const enrichedBot = await enrichMessage(latestBotMsg[0].id); - for (const { userId: memberId, orgId: memberOrgId } of members) { - eventBus.publishEvent({ type: "conv_message_created", data: { conversationId: convId, message: enrichedBot }, organizationId: memberOrgId, userId: memberId }); + if (latestBotMsg[0]) { + const enrichedBot = await enrichMessage(latestBotMsg[0].id); + for (const { userId: memberId, orgId: memberOrgId } of members) { + eventBus.publishEvent({ type: "conv_message_created", data: { conversationId: convId, message: enrichedBot }, organizationId: memberOrgId, userId: memberId }); + } + notificationService.notifyMessengerMessage({ + conversationId: convId, + authorId: -1, + authorName: bot.name, + chatName: bot.name, + chatType: 'bot_direct', + messageText: (enrichedBot as any).message ?? '', + mentionedUserIds: [], + memberOrgMap, + }).catch(err => console.error('[Bot Reply Notify] Error:', err)); } - notificationService.notifyMessengerMessage({ + } else if (bot.type === 'vpn') { + const typedBot = bot as Bot; + if (!checkBotAccess(typedBot, req.user!)) { + const [denyMsg] = await db + .insert(conversationMessages) + .values({ + conversationId: convId, + authorId: null, + botId: bot.id, + message: `⛔ У вас нет доступа к боту ${bot.name}`, + replyToId: null, + mentionedUserIds: null, + attachments: null, + }) + .returning(); + const enrichedDeny = await enrichMessage(denyMsg.id); + for (const { userId: memberId, orgId: memberOrgId } of members) { + eventBus.publishEvent({ type: "conv_message_created", data: { conversationId: convId, message: enrichedDeny }, organizationId: memberOrgId, userId: memberId }); + } + notificationService.notifyMessengerMessage({ + conversationId: convId, + authorId: -1, + authorName: bot.name, + chatName: bot.name, + chatType: 'bot_direct', + messageText: `⛔ У вас нет доступа к боту ${bot.name}`, + mentionedUserIds: [], + memberOrgMap, + }).catch(err => console.error('[VPN Bot Deny Notify] Error:', err)); + return; + } + await handleVpnBotDirectMessage({ + bot: typedBot, conversationId: convId, - authorId: -1, - authorName: bot.name, - chatName: bot.name, - chatType: 'bot_direct', - messageText: (enrichedBot as any).message ?? '', - mentionedUserIds: [], - memberOrgMap, - }).catch(err => console.error('[Bot Reply Notify] Error:', err)); + message: message.trim(), + user: req.user!, + organizationId: orgId, + }); } }); } finally { diff --git a/server/vpn/index.ts b/server/vpn/index.ts new file mode 100644 index 0000000..97bcb37 --- /dev/null +++ b/server/vpn/index.ts @@ -0,0 +1 @@ +export { registerVpnRoutes } from './vpn.routes'; diff --git a/server/vpn/vpn-bot.service.ts b/server/vpn/vpn-bot.service.ts new file mode 100644 index 0000000..0df6fae --- /dev/null +++ b/server/vpn/vpn-bot.service.ts @@ -0,0 +1,222 @@ +import fs from "fs"; +import path from "path"; +import { db } from "../db"; +import { conversationMessages, conversationMembers, users, bots, type Bot, type User } from "@shared/schema"; +import { eq, and } from "drizzle-orm"; +import { eventBus } from "../routes/shared"; +import { enrichMessage, getMembersForSSE } from "../routes/messenger.helpers"; +import { notificationService } from "../services/notification.service"; +import { getOrCreateVpnSubscription, extractRoomUrl } from "./vpn.service"; +import { VPN_BOT_LOGIN } from "./vpn.config"; + +const APPS_BASE_URL = process.env.APP_URL + ? `${process.env.APP_URL.replace(/\/$/, "")}/apps` + : "https://iistwin.ru/apps"; + +const ANDROID_FILE = "Olcbox-1.0.112-android-release.apk"; +const IOS_FILE = "Olcbox-1.0.112-ios-unsigned.ipa"; + +const INSTRUCTIONS_TEXT = `Привет! Для получения корпоративного VPN: + +1. Открой Яндекс Телемост (приложение или https://telemost.yandex.ru). +2. Создай новую видеовстречу. +3. Пришли сюда ссылку на встречу. Примеры того, что подходит: + +• https://telemost.yandex.ru/j/abc123def456 +• telemost.yandex.ru/j/abc123def456 +• abc123def456 + +Можно прислать и сопроводительный текст — я сам найду ссылку.`; + +const PLATFORM_PROMPT = `Ссылка принята. Теперь скажи, какой у тебя телефон:\n\n• Android\n• iPhone (iOS)\n\nЯ пришлю подходящий файл приложения olcbox.`; + +const IOS_WARNING = `⚠️ Файл для iOS не подписан. Как его установить на iPhone — честно, не знаю, придётся разбираться самому (AltStore, enterprise-сертификат и т.п.).`; + +function formatUserName(user: User): string { + const parts = [user.firstName, user.lastName].filter(Boolean); + return parts.join(" ") || user.email || "Сотрудник"; +} + +type Attachment = { url: string; name: string; size: number; mimeType?: string }; + +export async function sendVpnBotMessage( + conversationId: number, + text: string, + organizationId: number, + attachments?: Attachment[], +): Promise { + const [msg] = await db + .insert(conversationMessages) + .values({ + conversationId, + authorId: null, + botId: (await getVpnBotId(organizationId)) ?? null, + message: text.trim(), + replyToId: null, + mentionedUserIds: null, + attachments: attachments?.length ? attachments : null, + }) + .returning(); + + const enriched = await enrichMessage(msg.id); + const members = await getMembersForSSE(conversationId, organizationId); + + for (const { userId: memberId, orgId: memberOrgId } of members) { + eventBus.publishEvent({ + type: "conv_message_created", + data: { conversationId, message: enriched }, + organizationId: memberOrgId, + userId: memberId, + }); + } + + const memberMuteRows = await db + .select({ userId: conversationMembers.userId, mutedAt: conversationMembers.mutedAt, externalOrgId: conversationMembers.externalOrgId }) + .from(conversationMembers) + .where(eq(conversationMembers.conversationId, conversationId)); + + const memberOrgMap = new Map( + memberMuteRows.map(r => [r.userId, { orgId: r.externalOrgId ?? organizationId, mutedAt: r.mutedAt ?? null }]), + ); + + notificationService.notifyMessengerMessage({ + conversationId, + authorId: -1, + authorName: "ВПН бот", + chatName: "ВПН бот", + chatType: "bot_direct", + messageText: text.trim(), + mentionedUserIds: [], + memberOrgMap, + }).catch(err => console.error("[VPN Bot] notify error:", err)); +} + +async function getVpnBotId(organizationId: number): Promise { + const [bot] = await db + .select({ id: bots.id }) + .from(bots) + .where(and(eq(bots.organizationId, organizationId), eq(bots.login, VPN_BOT_LOGIN))) + .limit(1); + return bot?.id ?? null; +} + +// Simple in-memory state for the platform-selection step. +// Key = conversationId. TTL cleanup runs every 5 minutes. +const pendingPlatform = new Map(); + +function cleanPending() { + const cutoff = Date.now() - 10 * 60 * 1000; + for (const [key, value] of pendingPlatform.entries()) { + if (value.createdAt < cutoff) pendingPlatform.delete(key); + } +} +setInterval(cleanPending, 5 * 60 * 1000); + +function detectPlatform(text: string): "android" | "ios" | null { + const lower = text.toLowerCase(); + if (/\b(android|андроид|samsung|xiaomi|pixel|huawei|honор|poco|redmi|galaxy)\b/i.test(lower)) { + return "android"; + } + if (/\b(iphone|ios|айфон|ipad|apple)\b/i.test(lower)) { + return "ios"; + } + return null; +} + +function getFileAttachment(filename: string): Attachment { + const filePath = path.join("/app/apps", filename); + let size = 0; + try { + size = fs.statSync(filePath).size; + } catch (e) { + console.error(`[VPN Bot] could not stat ${filePath}:`, e); + } + const ext = path.extname(filename).toLowerCase(); + const mimeType = ext === ".apk" ? "application/vnd.android.package-archive" : "application/octet-stream"; + return { + url: `${APPS_BASE_URL}/${filename}`, + name: filename, + size, + mimeType, + }; +} + +export async function handleVpnBotDirectMessage({ + bot, + conversationId, + message, + user, + organizationId, +}: { + bot: Bot; + conversationId: number; + message: string; + user: User; + organizationId: number; +}): Promise { + const text = message.trim(); + const lower = text.toLowerCase(); + + if (lower === "статус" || lower === "status") { + await sendVpnBotMessage( + conversationId, + "Статус подписки пока не реализован. Пришлите ссылку на встречу для получения подписки.", + organizationId, + ); + return; + } + + // Continue platform-selection dialog + const pending = pendingPlatform.get(conversationId); + if (pending) { + const platform = detectPlatform(text); + if (!platform) { + await sendVpnBotMessage(conversationId, PLATFORM_PROMPT, organizationId); + return; + } + + pendingPlatform.delete(conversationId); + + try { + const userName = formatUserName(user); + const result = await getOrCreateVpnSubscription(organizationId, user.id, userName, pending.roomUrl); + + if (platform === "android") { + await sendVpnBotMessage( + conversationId, + "Установи приложение olcbox из прикреплённого APK, затем добавь подписку по ссылке ниже.", + organizationId, + [getFileAttachment(ANDROID_FILE)], + ); + } else { + await sendVpnBotMessage( + conversationId, + `${IOS_WARNING}\n\nПосле установки добавь подписку по ссылке ниже.`, + organizationId, + [getFileAttachment(IOS_FILE)], + ); + } + + const subscriptionText = result.isNew + ? `Подписка готова:\n${result.subscriptionUrl}\n\nTelegram и WhatsApp пойдут через латвийский сервер, российские сайты — напрямую.\nСсылка привязана к первому устройству, на котором её активируют.` + : `У вас уже есть подписка для этой комнаты:\n${result.subscriptionUrl}`; + + await sendVpnBotMessage(conversationId, subscriptionText, organizationId); + } catch (err: any) { + console.error("[VPN Bot] handle message error:", err); + const userMessage = err instanceof Error ? err.message : "Произошла ошибка. Попробуйте позже."; + await sendVpnBotMessage(conversationId, userMessage, organizationId); + } + return; + } + + const roomUrl = extractRoomUrl(text); + if (!roomUrl) { + await sendVpnBotMessage(conversationId, INSTRUCTIONS_TEXT, organizationId); + return; + } + + // Start platform-selection dialog + pendingPlatform.set(conversationId, { roomUrl, createdAt: Date.now() }); + await sendVpnBotMessage(conversationId, PLATFORM_PROMPT, organizationId); +} diff --git a/server/vpn/vpn.config.ts b/server/vpn/vpn.config.ts new file mode 100644 index 0000000..59d003b --- /dev/null +++ b/server/vpn/vpn.config.ts @@ -0,0 +1,19 @@ +export const VPN_FORM_ID = 19; +export const VPN_BOT_LOGIN = 'vpn_bot'; +export const VPN_BOT_NAME = 'ВПН бот'; + +export const SUBSCRIPTION_BASE_URL = + process.env.VPN_SUBSCRIPTION_BASE_URL || + (process.env.APP_URL ? `${process.env.APP_URL}/api/vpn/sub` : 'https://iistwin.ru/api/vpn/sub'); + +export const VPN_TELEMOST_API_BASE = + 'https://cloud-api.yandex.ru/telemost_front/v2/telemost'; + +export const DEFAULT_TRAFFIC_LIMIT_GB = 100; +export const DEFAULT_SUBSCRIPTION_DAYS = 30; +export const SUBSCRIPTION_UPDATE_INTERVAL_HOURS = 1; + +export const OLC_RTC_PROVIDER = 'telemost'; +export const OLC_RTC_TRANSPORT = 'vp8channel'; +export const OLC_RTC_VP8_FPS = 30; +export const OLC_RTC_VP8_BATCH = 64; diff --git a/server/vpn/vpn.db.ts b/server/vpn/vpn.db.ts new file mode 100644 index 0000000..57809be --- /dev/null +++ b/server/vpn/vpn.db.ts @@ -0,0 +1,286 @@ +import { db } from '../db'; +import { + formFields, + formStatuses, + tasks, + taskFieldValues, + type Task, + type FormField, + type FormStatus, +} from '@shared/schema'; +import { eq, and, sql } from 'drizzle-orm'; +import { storage } from '../storage'; +import { VPN_FORM_ID } from './vpn.config'; + +export interface VpnFormCache { + fields: FormField[]; + statuses: FormStatus[]; + fieldByCode: Map; + statusByName: Map; +} + +const cacheByOrg = new Map(); + +export async function getVpnFormCache(organizationId: number): Promise { + if (cacheByOrg.has(organizationId)) { + return cacheByOrg.get(organizationId)!; + } + + const [fields, statuses] = await Promise.all([ + db + .select() + .from(formFields) + .where(eq(formFields.formId, VPN_FORM_ID)) + .then(rows => rows as FormField[]), + db + .select() + .from(formStatuses) + .where(eq(formStatuses.formId, VPN_FORM_ID)) + .then(rows => rows as FormStatus[]), + ]); + + const cache: VpnFormCache = { + fields, + statuses, + fieldByCode: new Map(fields.map(f => [f.code, f])), + statusByName: new Map(statuses.map(s => [s.name, s])), + }; + cacheByOrg.set(organizationId, cache); + return cache; +} + +export function getFieldId(cache: VpnFormCache, code: string): number { + const f = cache.fieldByCode.get(code); + if (!f) throw new Error(`VPN field not found: ${code}`); + return f.id; +} + +export function getStatusId(cache: VpnFormCache, name: string): number { + const s = cache.statusByName.get(name); + if (!s) throw new Error(`VPN status not found: ${name}`); + return s.id; +} + +export function clearVpnFormCache(organizationId: number): void { + cacheByOrg.delete(organizationId); +} + +export interface VpnTaskData { + task: Task; + values: Record; +} + +export async function findVpnTaskByToken( + token: string, + organizationId: number, +): Promise { + const cache = await getVpnFormCache(organizationId); + const fieldId = getFieldId(cache, 'subscription_token'); + + const rows = await db + .select({ task: tasks, value: taskFieldValues.value }) + .from(taskFieldValues) + .innerJoin(tasks, eq(taskFieldValues.taskId, tasks.id)) + .where( + and( + eq(taskFieldValues.fieldId, fieldId), + eq(taskFieldValues.formId, VPN_FORM_ID), + sql`${taskFieldValues.value} = to_jsonb(${token}::text)`, + eq(tasks.organizationId, organizationId), + ), + ) + .limit(1); + + if (!rows.length) return null; + return loadVpnTaskValues(rows[0].task.id, organizationId); +} + +export async function findVpnTaskByRoomUrl( + roomUrl: string, + organizationId: number, +): Promise { + const cache = await getVpnFormCache(organizationId); + const fieldId = getFieldId(cache, 'room_url'); + + const rows = await db + .select({ task: tasks }) + .from(taskFieldValues) + .innerJoin(tasks, eq(taskFieldValues.taskId, tasks.id)) + .where( + and( + eq(taskFieldValues.fieldId, fieldId), + eq(taskFieldValues.formId, VPN_FORM_ID), + sql`${taskFieldValues.value} = to_jsonb(${roomUrl}::text)`, + eq(tasks.organizationId, organizationId), + ), + ) + .limit(1); + + if (!rows.length) return null; + return loadVpnTaskValues(rows[0].task.id, organizationId); +} + +export async function findVpnTaskByRoomAndDevice( + roomUrl: string, + deviceId: string, + organizationId: number, +): Promise { + const cache = await getVpnFormCache(organizationId); + const roomFieldId = getFieldId(cache, 'room_url'); + + const rows = await db + .select({ task: tasks }) + .from(taskFieldValues) + .innerJoin(tasks, eq(taskFieldValues.taskId, tasks.id)) + .where( + and( + eq(taskFieldValues.fieldId, roomFieldId), + eq(taskFieldValues.formId, VPN_FORM_ID), + sql`${taskFieldValues.value} = to_jsonb(${roomUrl}::text)`, + eq(tasks.organizationId, organizationId), + ), + ) + .limit(1); + + if (!rows.length) return null; + const data = await loadVpnTaskValues(rows[0].task.id, organizationId); + if (data.values.hwid !== deviceId) return null; + return data; +} + +export async function findVpnTaskByRoomAndSession( + roomUrl: string, + sessionId: string, + organizationId: number, +): Promise { + const cache = await getVpnFormCache(organizationId); + const roomFieldId = getFieldId(cache, 'room_url'); + + const rows = await db + .select({ task: tasks }) + .from(taskFieldValues) + .innerJoin(tasks, eq(taskFieldValues.taskId, tasks.id)) + .where( + and( + eq(taskFieldValues.fieldId, roomFieldId), + eq(taskFieldValues.formId, VPN_FORM_ID), + sql`${taskFieldValues.value} = to_jsonb(${roomUrl}::text)`, + eq(tasks.organizationId, organizationId), + ), + ) + .limit(1); + + if (!rows.length) return null; + const data = await loadVpnTaskValues(rows[0].task.id, organizationId); + if (data.values.active_session_id !== sessionId) return null; + return data; +} + +export async function loadVpnTaskValues( + taskId: number, + organizationId: number, +): Promise { + const cache = await getVpnFormCache(organizationId); + const [task, fvs] = await Promise.all([ + storage.getTask(taskId, organizationId), + storage.getTaskFieldValues(taskId, organizationId), + ]); + if (!task) throw new Error(`VPN task not found: ${taskId}`); + + const values: Record = {}; + for (const fv of fvs) { + const field = cache.fields.find(f => f.id === fv.fieldId); + if (field) { + values[field.code] = fv.value; + } + } + return { task, values }; +} + +export async function setVpnTaskField( + taskId: number, + fieldCode: string, + value: unknown, + organizationId: number, +): Promise { + const cache = await getVpnFormCache(organizationId); + const fieldId = getFieldId(cache, fieldCode); + + const existing = await db + .select({ id: taskFieldValues.id }) + .from(taskFieldValues) + .where(and(eq(taskFieldValues.taskId, taskId), eq(taskFieldValues.fieldId, fieldId))) + .limit(1); + + if (existing.length) { + await storage.updateTaskFieldValue(taskId, fieldId, organizationId, { value: value as any }); + } else { + await storage.createTaskFieldValue({ + taskId, + fieldId, + formId: VPN_FORM_ID, + value: value as any, + }); + } +} + +export async function setVpnTaskStatus( + taskId: number, + statusName: string, + organizationId: number, +): Promise { + const cache = await getVpnFormCache(organizationId); + const statusId = getStatusId(cache, statusName); + const status = cache.statusByName.get(statusName)!; + await storage.updateTask(taskId, organizationId, { + currentStatusId: statusId, + isCompleted: status.isFinal, + }); +} + +export async function createVpnTask( + input: { + organizationId: number; + createdBy: number; + title: string; + statusName: string; + fields: Record; + }, +): Promise { + const cache = await getVpnFormCache(input.organizationId); + const statusId = getStatusId(cache, input.statusName); + + const task = await storage.createTask({ + organizationId: input.organizationId, + formId: VPN_FORM_ID, + title: input.title, + currentStatusId: statusId, + createdBy: input.createdBy, + completedAt: null, + dueDate: null, + }); + + for (const [code, value] of Object.entries(input.fields)) { + if (value === undefined || value === null) continue; + const fieldId = getFieldId(cache, code); + await storage.createTaskFieldValue({ + taskId: task.id, + fieldId, + formId: VPN_FORM_ID, + value, + }); + } + + return task; +} + +export async function updateVpnTaskFieldMap( + taskId: number, + organizationId: number, + fields: Record, +): Promise { + for (const [code, value] of Object.entries(fields)) { + if (value === undefined) continue; + await setVpnTaskField(taskId, code, value, organizationId); + } +} diff --git a/server/vpn/vpn.routes.ts b/server/vpn/vpn.routes.ts new file mode 100644 index 0000000..dcbe4f3 --- /dev/null +++ b/server/vpn/vpn.routes.ts @@ -0,0 +1,87 @@ +import { Router } from "express"; +import type { Request, Response } from "express"; +import { + fetchSubscription, + authorizeSession, + closeSession, + recordTraffic, + VpnError, +} from "./vpn.service"; +import { SUBSCRIPTION_UPDATE_INTERVAL_HOURS } from "./vpn.config"; + +function handleVpnError(res: Response, err: unknown): void { + if (err instanceof VpnError) { + res.status(403).type("text/plain").send(err.message); + return; + } + console.error("[VPN API] error:", err); + res.status(500).type("text/plain").send("Internal error"); +} + +export function registerVpnRoutes(router: Router): void { + // Subscription endpoint used by olcbox clients + router.get("/api/vpn/sub/:token", async (req: Request, res: Response) => { + try { + const token = req.params.token; + const hwid = req.headers["x-hwid"] as string | undefined; + const uri = await fetchSubscription(token, hwid); + res.setHeader("profile-update-interval", String(SUBSCRIPTION_UPDATE_INTERVAL_HOURS)); + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.send(uri); + } catch (err) { + handleVpnError(res, err); + } + }); + + // olrtc srv AuthHook + router.post("/api/vpn/olrtc/auth", async (req: Request, res: Response) => { + try { + const { device_id, room_url } = req.body || {}; + if (!device_id || !room_url) { + res.status(400).json({ success: false, error: "missing device_id or room_url" }); + return; + } + const result = await authorizeSession(room_url, device_id); + res.json({ success: true, session_id: result.sessionId }); + } catch (err) { + if (err instanceof VpnError) { + res.status(403).json({ success: false, error: err.message }); + return; + } + console.error("[VPN Auth] error:", err); + res.status(500).json({ success: false, error: "internal error" }); + } + }); + + // olrtc srv OnSessionClose + router.post("/api/vpn/olrtc/close", async (req: Request, res: Response) => { + try { + const { session_id, room_url } = req.body || {}; + if (!session_id || !room_url) { + res.status(400).json({ success: false, error: "missing fields" }); + return; + } + await closeSession(room_url, session_id); + res.json({ success: true }); + } catch (err) { + console.error("[VPN Close] error:", err); + res.status(500).json({ success: false, error: "internal error" }); + } + }); + + // olrtc srv OnTraffic + router.post("/api/vpn/olrtc/traffic", async (req: Request, res: Response) => { + try { + const { session_id, room_url, bytes_in, bytes_out } = req.body || {}; + if (!session_id || !room_url || typeof bytes_in !== "number" || typeof bytes_out !== "number") { + res.status(400).json({ success: false, error: "missing fields" }); + return; + } + await recordTraffic(room_url, session_id, bytes_in, bytes_out); + res.json({ success: true }); + } catch (err) { + console.error("[VPN Traffic] error:", err); + res.status(500).json({ success: false, error: "internal error" }); + } + }); +} diff --git a/server/vpn/vpn.service.ts b/server/vpn/vpn.service.ts new file mode 100644 index 0000000..6a9cceb --- /dev/null +++ b/server/vpn/vpn.service.ts @@ -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}@${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 { + 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 { + // 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; + } +}