crm-calls

Модуль «Дзвінки» для CRM Tvorymo і власний Jitsi Meet з авторизацією через JWT.

Що Адреса
Модуль (сайт в iframe CRM, вікно дзвінка, гостьова сторінка) https://crm-calls.volcanic.tes.gd
Jitsi Meet https://meet.volcanic.tes.gd
CRM (тестова) https://test.units.tvorymo.com
Supabase CRM https://yjxbkcqsquwbsyoonfkn.supabase.co

Розгортання: Coolify Вулканика (http://coolify.volcanic.tes.gd:8000), VPS 164.90.233.156 (DigitalOcean), проєкт crm-calls (fdzlvs0ox6tb58i8ezwpca00), два застосунки з одного репозиторію:

Застосунок Coolify uuid Compose Домен (docker_compose_domains)
crm-calls (Jitsi) ibugx0ujnub2pbvtfr3euk9f docker-compose.yml web → https://meet.volcanic.tes.gd:8000
crm-calls-module (модуль) g2mi10g1rcuukibu6yy3tf5u docker-compose.calls.yml calls → https://crm-calls.volcanic.tes.gd:8080

:8000 / :8080 у домені — порт контейнера, куди Traefik веде трафік; публічні адреси — звичайний HTTPS 443. Застосунки окремі, щоб деплой модуля не перезапускав Jitsi і не обривав дзвінки.

Як додати модуль у бічну панель CRM

  1. CRM → налаштування бічної панелі → додати посилання.
  2. URL: https://crm-calls.volcanic.tes.gd (саме так, без шляху й редіректів: CRM надсилає токен лише на origin цієї адреси).
  3. Назва: «Дзвінки».
  4. Увімкнути галочку «Передавати авторизацію» (без неї модуль відкриється, але чекатиме авторизацію).
  5. Відкрити модуль у CRM: має з'явитися ім'я користувача і назва воркспейсу, кнопка «Приєднатися» і гостьове посилання.

Як це працює

  1. Модуль у iframe CRM надсилає crm:ready, отримує crm:auth (токен Supabase і workspace: { id, kind, title }), сервер перевіряє токен (exp, aud = authenticated, GET /auth/v1/user з publishable-ключем). Ім'я та email — з відповіді Supabase. Зміна воркспейсу в CRM (crm:workspace) підхоплюється одразу. Якщо CRM не передала воркспейс (workspace: null або поля немає), модуль показує список: «Мій», «Загальний» і воркспейси на людей, де користувач власник або учасник (таблиця workspaces з його токеном, RLS). Вибір запам'ятовується.
  2. Доступ до воркспейсу сервер перевіряє сам: shared — усім користувачам CRM, personal:<uid> — лише власнику, інші id — запитом GET /rest/v1/workspaces?id=eq.<id> з токеном користувача (RLS). Інакше 403.
  3. Кімната: одна постійна на воркспейс, ім'я ws- + 20 випадкових символів [a-z0-9], створюється при першому зверненні, зберігається в SQLite. Id воркспейсу в імені немає.
  4. «Приєднатися»: модуль одразу відкриває нове вікно (window.open по кліку, у sandbox CRM є allow-popups), бере в сервера одноразовий код (60 секунд) і веде вікно на /call?code=…. У iframe CRM немає доступу до камери, тому дзвінок іде в окремому вікні.
  5. Вікно /call одразу прибирає код з адреси, міняє його на сесію вікна (12 годин, у sessionStorage) і по ній бере пропуск Jitsi. Звонок — через external_api.js (new JitsiMeetExternalAPI('meet.volcanic.tes.gd', { roomName, jwt, parentNode })). Токен CRM у вікно не передається взагалі.
  6. Пропуск Jitsi: HS256, iss = tvorymo-crm, aud = jitsi, sub = meet.volcanic.tes.gd, room — одна кімната, 15 хвилин, context.user = { id, name, email, moderator }. Учасник воркспейсу — moderator: true, гість — false.
  7. Після 15 хвилин розпочатий дзвінок не обривається (пропуск перевіряється лише при підключенні). Короткий обрив мережі Jitsi переживає сам (відновлення XMPP-сесії). Після довгого обриву (перевірено 90 с) пропуск уже застарів, prosody відмовляє (connection.passwordRequired) — вікно саме бере новий пропуск і входить знову без екрана підготовки (так само при фатальних помилках; до 6 спроб із паузою).
  8. Телефон: /call і гостьова сторінка показують «Відкрити в додатку» і «Продовжити в браузері». «Відкрити в додатку» бере пропуск лише по натисканню й відкриває org.jitsi.meet://meet.volcanic.tes.gd/<кімната>?jwt=<пропуск> (тут пропуск неминуче в адресі). Посилання на App Store і Google Play — поруч. Додаток сам новий пропуск не бере: якщо зв'язок зник надовго, людина натискає кнопку ще раз. Власну сторінку Jitsi «завантажте додаток» вимкнено (DISABLE_DEEP_LINKING).
  9. Гостьове посилання https://crm-calls.volcanic.tes.gd/g/<токен>: постійне, без лобі. Гість вводить ім'я і входить без прав модератора. Будь-який учасник воркспейсу може згенерувати нове (підтвердження своїм вікном, confirm() в iframe не працює): старе одразу перестає пускати нових гостей, ті, хто вже в дзвінку, лишаються. Токен посилання = HMAC(GUEST_LINK_SECRET, id посилання); у базі — id, SHA-256 токена, хто й коли згенерував.

Jitsi (docker-compose.yml)

ENABLE_AUTH=1, AUTH_TYPE=jwt, ENABLE_GUESTS=0, JWT_APP_ID=tvorymo-crm, JWT_ACCEPTED_ISSUERS=tvorymo-crm, JWT_ACCEPTED_AUDIENCES=jitsi, JWT_APP_SECRET (env Coolify), XMPP_MUC_MODULES=token_affiliation (модератор з пропуску), JICOFO_ENABLE_AUTH=0 і ENABLE_AUTO_OWNER=false (інакше jicofo робить модератором кожного з токеном, гостей теж), DISABLE_DEEP_LINKING=true, CSP_HEADER: frame-ancestors https://crm-calls.volcanic.tes.gd (вбудовувати Jitsi може лише модуль).

Заголовки модуля

Content-Security-Policy: frame-ancestors <CRM_ORIGINS>; script-src 'self' https://meet.volcanic.tes.gd; frame-src https://meet.volcanic.tes.gd; … (без X-Frame-Options), Referrer-Policy: no-referrer, Permissions-Policy дозволяє камеру й мікрофон собі й Jitsi.

Змінні оточення (Coolify)

Секрети — лише в env Coolify, у репозиторії їх немає.

Модуль (g2mi10g1rcuukibu6yy3tf5u):

Змінна Що це Значення / за замовчуванням
JWT_APP_SECRET секрет підпису пропусків Jitsi, такий самий, як у Jitsi секрет
GUEST_LINK_SECRET секрет токенів гостьових посилань; зміна робить усі гостьові посилання недійсними секрет
SUPABASE_PUBLISHABLE_KEY publishable-ключ Supabase CRM (публічний за дизайном) sb_publishable_…
CRM_ORIGINS origin CRM через пробіл: звідки приймати crm:auth, вони ж у frame-ancestors https://test.units.tvorymo.com
CRM_ASSETS_ORIGIN звідки стилі CRM https://test.units.tvorymo.com
SUPABASE_URL https://yjxbkcqsquwbsyoonfkn.supabase.co
JITSI_DOMAIN meet.volcanic.tes.gd
JITSI_TOKEN_TTL_MINUTES строк пропуску 15
CALLS_SESSION_HOURS скільки живе сесія вікна дзвінка / гостя 12
CALLS_PUBLIC_URL https://crm-calls.volcanic.tes.gd

Для бойової CRM: CRM_ORIGINS="https://test.units.tvorymo.com https://units.tvorymo.com" (і CRM_ASSETS_ORIGIN, якщо стилі звідти) → Redeploy.

Jitsi (ibugx0ujnub2pbvtfr3euk9f): JWT_APP_SECRET (секрет, той самий), JICOFO_AUTH_PASSWORD, JVB_AUTH_PASSWORD (секрети), PUBLIC_URL=https://meet.volcanic.tes.gd, JVB_PORT=10000, JVB_ADVERTISE_IPS=164.90.233.156, TZ, DEFAULT_LANGUAGE.

Заміна JWT_APP_SECRET: однакове нове значення в обох застосунках → Redeploy обох.

REST API модуля

Метод Шлях Авторизація Що робить
GET /health — { status, dbCreatedAt }
GET /api/config — { crmOrigins, meetDomain, publicUrl, appStoreUrl, playStoreUrl }
POST /api/verify — { access_token } → { ok, id, email, name, exp }
GET /api/workspaces токен CRM воркспейси користувача (для вибору)
GET /api/room токен CRM + X-Calls-Workspace { workspace, guestLink: { url, updatedAt, updatedByName } }
POST /api/room/guest-link токен CRM + X-Calls-Workspace нове гостьове посилання
POST /api/join токен CRM + X-Calls-Workspace { code, expiresIn: 60, url } — одноразовий код входу
POST /api/call/exchange — { code } → { session, kind, name }; код одноразовий (410, якщо використаний/застарів)
POST /api/call/pass — { session } → { jwt, exp, room, domain, name, kind }; 401 — сесія завершилась
GET /api/guest/{token} — 200 — посилання дійсне, 404 — ні
POST /api/guest/{token}/join — { name } → { session, kind: "guest", name }

Токен CRM: Authorization: Bearer <access_token>. Помилки: { "error": "…" }, 401 — токен не прийнято, 403 — немає доступу до воркспейсу. Дані: SQLite /data/calls.db на томі calls-data (таблиці rooms, guest_links, join_codes, call_sessions; секрети — лише хешами).

Відомі обмеження

  • JitsiMeetExternalAPI сам кладе пропуск у адресу вкладеного iframe Jitsi (?jwt=…). В адресному рядку й історії вікна його немає (там /call), але він потрапляє в адресу запиту до meet.volcanic.tes.gd (і в журнал nginx Jitsi). Пропуск — на одну кімнату й 15 хвилин.
  • Вікно дзвінка, відкрите з iframe CRM, успадковує його sandbox: alert/confirm там не працюють (у коді їх немає).
  • Сесія вікна дзвінка живе 12 годин і не перевіряє повторно членство у воркспейсі: людина, яку прибрали з воркспейсу, може перепідключитися до кінця сесії. Новий вхід з CRM уже не пройде.
  • Гості, які увійшли за старим посиланням, після перегенерації лишаються в дзвінку й можуть перепідключатися до кінця своєї сесії.

Мережа

VPS має публічну адресу 164.90.233.156 безпосередньо на інтерфейсі (без NAT), тому JVB_ADVERTISE_IPS=164.90.233.156.

Порт Протокол Куди Навіщо
443 (і 80) TCP Traefik → web:8000 веб, /xmpp-websocket
10000 UDP jvb (опублікований на хості) медіа і SCTP-канал до JVB

UDP 10000 має бути дозволений і в ufw/iptables на VPS, і в DigitalOcean Cloud Firewall (якщо він прив'язаний до droplet). Порт, опублікований Docker, зазвичай проходить повз ufw, але не повз Cloud Firewall.

Без UDP 10000 сторінка й кімната відкриваються, але аудіо/відео через JVB не буде.

Перевірка: під час дзвінка в chrome://webrtc-internals вибрана ICE-пара має вести на 164.90.233.156:10000 (udp). Або на VPS: sudo tcpdump -ni any udp port 10000 і ззовні echo test | nc -u -w1 164.90.233.156 10000.

TURN (не робимо — рішення власника 2026-10-14; довідка на майбутнє, turn.volcanic.tes.gd)

Потрібен клієнтам, у яких заблокований UDP (корпоративні мережі, частина мобільних операторів). DNS turn.volcanic.tes.gd уже веде на 164.90.233.156 (wildcard).

Варіант 1 — класичний coturn (найкраща якість). Відкрити у файрволі VPS / DO: 3478 UDP+TCP, 5349 TCP (TURNS), relay-діапазон напр. 49160-49200 UDP. coturn: use-auth-secret, static-auth-secret=<секрет у Coolify env>, realm=crm-calls.volcanic.tes.gd, external-ip=164.90.233.156, min-port/max-port = relay-діапазон, TLS-сертифікат для turn.volcanic.tes.gd (5349), denied-peer-ip для приватних мереж.

Варіант 2 — TURNS на 443 через Traefik (нових портів не треба). coturn слухає TCP 3478 лише у внутрішній мережі; TCP-роутер Traefik HostSNI(turn.volcanic.tes.gd) на entrypoint https з TLS (letsencrypt) → coturn:3478. coturn ретранслює медіа на JVB усередині хоста. Працює там, де дозволений лише TCP 443; якість нижча (медіа по TCP).

В обох варіантах Jitsi (env prosody, через Coolify): TURN_CREDENTIALS=<той самий секрет>, TURNS_HOST=turn.volcanic.tes.gd, TURNS_PORT=5349 або 443, для варіанту 1 також TURN_HOST=turn.volcanic.tes.gd, TURN_PORT=3478, TURN_TRANSPORT=udp,tcp. Prosody (mod_external_services) роздає клієнтам тимчасові TURN-облікові дані.

Розробка

cd calls/backend/Calls.Api && Jitsi__AppSecret=<32+ символи> Calls__LinkSecret=<32+ символи> \
  ASPNETCORE_URLS=http://127.0.0.1:5090 dotnet run          # база в ./data
cd calls/frontend && npm i && npm run dev                  # Vite, /api → 127.0.0.1:5090
dotnet test calls/backend/Calls.Api.Tests                  # кімнати, коди, пропуски, гостьові посилання, доступ до воркспейсу (фальшивий Supabase)
Description
No description provided
Readme 344 KiB
Languages
C# 72.6%
TypeScript 22.9%
Shell 1.8%
Dockerfile 1.2%
JavaScript 0.8%
Other 0.7%