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

View File

@@ -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 \

0
apps/.gitkeep Normal file
View File

10
apps/README.md Normal file
View File

@@ -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 к сообщениям.

View File

@@ -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();

View File

@@ -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<Server> {
registerSyncRoutes(app);
registerMedScheduleRoutes(app);
registerMedScheduleBotRoutes(app);
registerVpnRoutes(app);
const httpServer = createServer(app);
return httpServer;

View File

@@ -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 {

1
server/vpn/index.ts Normal file
View File

@@ -0,0 +1 @@
export { registerVpnRoutes } from './vpn.routes';

View File

@@ -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<void> {
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<number | null> {
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<number, { roomUrl: string; createdAt: number }>();
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<void> {
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);
}

19
server/vpn/vpn.config.ts Normal file
View File

@@ -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;

286
server/vpn/vpn.db.ts Normal file
View File

@@ -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<string, FormField>;
statusByName: Map<string, FormStatus>;
}
const cacheByOrg = new Map<number, VpnFormCache>();
export async function getVpnFormCache(organizationId: number): Promise<VpnFormCache> {
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<string, unknown>;
}
export async function findVpnTaskByToken(
token: string,
organizationId: number,
): Promise<VpnTaskData | null> {
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<VpnTaskData | null> {
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<VpnTaskData | null> {
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<VpnTaskData | null> {
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<VpnTaskData> {
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<string, unknown> = {};
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<void> {
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<void> {
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<string, unknown>;
},
): Promise<Task> {
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<string, unknown>,
): Promise<void> {
for (const [code, value] of Object.entries(fields)) {
if (value === undefined) continue;
await setVpnTaskField(taskId, code, value, organizationId);
}
}

87
server/vpn/vpn.routes.ts Normal file
View File

@@ -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" });
}
});
}

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;
}
}