From fd949ddaed085a9524a8c79bf62fb8927f1a7439 Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Thu, 25 Jun 2026 16:59:17 +0700 Subject: [PATCH] chore: emulator sip_gateway, Makefile targets, full README documentation - Emulator: add sip_gateway field to all scenarios (auto-trunk creation) - Makefile: build-agent, build-siptest, emulate-*, siptest-* targets - README: full documentation with agent section, production deployment - Project structure update with all components --- Makefile | 37 +- README.md | 790 +++++++++++++++++++++++++++++++++++++++++++ cmd/emulator/main.go | 24 +- 3 files changed, 841 insertions(+), 10 deletions(-) create mode 100644 README.md diff --git a/Makefile b/Makefile index 49763a7..29b633d 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,8 @@ DATA_DIR := data .PHONY: all build build-go build-web clean run test install \ emulate emulate-normal emulate-overload emulate-cpu-low emulate-fail-high \ - emulate-node-down emulate-stale emulate-chaos build-emulator + emulate-node-down emulate-stale emulate-chaos build-emulator \ + build-siptest siptest-uas siptest-uac siptest-full all: build @@ -100,6 +101,40 @@ emulate-stale: nats build-emulator emulate-chaos: nats build-emulator ./$(BINDIR)/emulator --scenario chaos +# ============================================================ +# Агент сбора метрик (pulse-lets-go-agent) +# ============================================================ + +build-agent: + @mkdir -p $(BINDIR) + go build -o $(BINDIR)/pulse-lets-go-agent ./cmd/pulse-lets-go-agent + +# ============================================================ +# SIP-тестер (e2e проверка с FreeSWITCH) +# ============================================================ + +build-siptest: + @mkdir -p $(BINDIR) + go build -o $(BINDIR)/siptest ./cmd/siptest + +# Запуск UAS (PBX-нода, отвечает 200 OK на INVITE) +siptest-uas: build-siptest + ./$(BINDIR)/siptest -mode uas + +# Отправка тестового звонка (10 CPS, 100 вызовов) +siptest-uac: build-siptest + ./$(BINDIR)/siptest -mode uac -r 10 -l 100 -m 100 + +# Полный e2e тест: UAS + UAC +siptest-full: build-siptest + @echo "→ Запуск UAS (PBX-нода) на порту 5090..." + ./$(BINDIR)/siptest -mode uas & + @sleep 1 + @echo "→ Запуск UAC (оператор)..." + ./$(BINDIR)/siptest -mode uac -r 10 -l 100 -m 200 + @echo "→ Остановка UAS..." + pkill siptest 2>/dev/null || true + # ============================================================ # NATS и запуск # ============================================================ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f1ee959 --- /dev/null +++ b/README.md @@ -0,0 +1,790 @@ +# Pulse Lets Go + +[![Go](https://img.shields.io/badge/Go-1.26-blue?logo=go)](https://go.dev) +[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) +[![NATS](https://img.shields.io/badge/NATS-latest-27ae60?logo=nats)](https://nats.io) +[![SvelteKit](https://img.shields.io/badge/SvelteKit-2-fb923c?logo=svelte)](https://kit.svelte.dev) + +**pulse-lets-go** — балансировщик телефонной нагрузки на базе FreeSWITCH + NATS. + +Агенты PBX отправляют метрики в NATS каждые 5 секунд. Балансировщик вычисляет weighted score готовности каждой ноды, кэширует лучшую и отдаёт её FreeSWITCH через `/api/route` для следующего звонка. + +--- + +## Features + +- **Scoring engine** — lethal checks (disabled, stale, overload, CPU, failure rate) + weighted score (call, load, idle, fail) с конфигурируемыми весами. +- **NATS шина** — асинхронный сбор метрик через `pulse.metrics.`. +- **REST API** — JWT (access + refresh), роли admin/viewer, rate limiting, CORS. +- **WebSocket** — real-time трансляция метрик на SvelteKit дашборд. +- **FreeSWITCH ESL** — управление Sofia-шлюзами через raw TCP ESL. +- **Monitoring** — Prometheus и Zabbix эндпоинты с отдельным API-key. +- **SvelteKit UI** — дашборд, таблицы нод, транков, пользователей. +- **Graceful shutdown** — корректное завершение с таймаутом 10 с. +- **systemd ready** — unit с изоляцией (NoNewPrivileges, ProtectSystem, PrivateTmp). + +--- + +## Архитектура + +``` +┌────────────────┐ pulse.metrics. ┌──────────────────┐ +│ PBX-01 │────── every 5s ────────────▶ │ │ +│ (FS node) │ │ NATS Server │ +├────────────────┤ │ nats://:4222 │ +│ PBX-02 │────── every 5s ────────────▶ │ │ +│ (FS node) │ └────────┬─────────┘ +├────────────────┤ │ +│ PBX-03 │────── every 5s ────────────▶ │ +│ (FS node) │ │ +└────────────────┘ ▼ + ┌──────────────────────┐ + │ pulse-lets-go │ + │ │ + │ ┌────────────────┐ │ + ┌───────▶│ NATS Sub │ │ + │ │ (pulse.metrics)│ │ + │ └────────┬───────┘ │ + │ ▼ │ + │ ┌────────────────┐ │ + │ │ Engine │ │ + │ │ (scoring) │──│──▶ /api/route + │ └────────┬───────┘ │ ↓ + │ ▼ │ FreeSWITCH bridge + │ ┌────────────────┐ │ sip:pbx-03:5060 + │ │ REST API │──│──▶ /api/nodes + │ │ (JWT auth) │ │ /api/trunks + │ └────────┬───────┘ │ /api/users + │ ▼ │ + │ ┌────────────────┐ │ + │ │ WebSocket │──│──▶ SvelteKit UI + │ └────────────────┘ │ (ws://:8080/ws/metrics) + │ ┌────────────────┐ │ + │ │ ESL Client │──│──▶ FreeSWITCH ESL + │ │ (gateways) │ │ :8021 + │ └────────────────┘ │ + │ ┌────────────────┐ │ + │ │ Monitoring │──│──▶ /api/monitoring/prometheus + │ │ (API-key) │ │ /api/monitoring/zabbix + │ └────────────────┘ │ + └──────────────────────┘ +``` + +--- + +## Quick Start + +### 1. Требования + +- Go 1.26+ +- Node.js 22+ +- NATS Server (автоматически качается `make nats`) + +### 2. Сборка + +```bash +git clone git@git.totmin.ru:en2zmax/pulse-lets-go.git +cd pulse-lets-go +make build +``` + +### 3. Запуск + +```bash +# Терминал 1: NATS +make nats + +# Терминал 2: балансировщик +./bin/pulse-lets-go + +# Терминал 3: эмулятор метрик (3 здоровые ноды) +make emulate-normal +``` + +### 4. Проверка + +```bash +# Route (без авторизации) +curl 'http://localhost:8080/api/route?caller_id=123&dest=456&ingress_trunk=trk-001' + +# Login +curl -X POST http://localhost:8080/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"admin"}' +``` + +--- + +## Конфигурация + +При первом запуске `config.json` создаётся автоматически в директории `data/` (или `$PULSE_DATA_DIR`). JWT-секрет и API-key генерируются случайно. + +### config.json + +```json +{ + "nats_url": "nats://localhost:4222", + "nats_user": "", + "nats_password": "", + "listen_addr": ":8080", + "jwt_secret": "aabbccdd...32-байта-hex", + "monitoring_api_key": "11223344...32-байта-hex", + "stale_threshold_sec": 20, + "scoring": { + "idle_cpu_min": 5, + "call_failure_rate_lethal": 15.0, + "weights": { + "call_score": 0.40, + "load_score": 0.30, + "idle_score": 0.20, + "fail_score": 0.10 + } + }, + "tls": { + "enabled": false, + "cert_file": "", + "key_file": "" + }, + "rate_limit": { + "enabled": true, + "route_per_sec": 5000, + "api_per_sec": 100 + }, + "log": { + "level": "info", + "format": "text" + }, + "esl": { + "host": "", + "port": 8021, + "password": "", + "password_env": "", + "sofia_profile": "external", + "gateway_prefix": "pulse" + } +} +``` + +| Поле | Дефолт | Описание | +|------|--------|----------| +| `nats_url` | `nats://localhost:4222` | URL NATS сервера | +| `nats_user` | `""` | Имя пользователя NATS (если нужна аутентификация) | +| `nats_password` | `""` | Пароль NATS | +| `listen_addr` | `:8080` | Адрес HTTP-сервера | +| `jwt_secret` | auto | Секрет для подписи JWT (32 байта hex) | +| `monitoring_api_key` | auto | API-ключ для `/api/monitoring/*` | +| `stale_threshold_sec` | `20` | Через сколько секунд без метрик нода считается stale | +| `scoring.idle_cpu_min` | `5` | Минимальный idle CPU. Ниже — lethal | +| `scoring.call_failure_rate_lethal` | `15.0` | Максимальный failure rate. Выше — lethal | +| `scoring.weights.*` | 0.40/0.30/0.20/0.10 | Веса компонентов скора (сумма = 1.0) | +| `tls.enabled` | `false` | Включить HTTPS | +| `rate_limit.enabled` | `true` | Включить rate limiting | +| `rate_limit.route_per_sec` | `5000` | Лимит `/api/route` на IP | +| `rate_limit.api_per_sec` | `100` | Лимит остальных `/api/*` на IP | +| `log.level` | `info` | Уровень лога: `debug`, `info`, `warn`, `error` | +| `log.format` | `text` | Формат: `text` или `json` | +| `esl.host` | `""` | Хост FreeSWITCH ESL. Пусто — ESL отключён | +| `esl.port` | `8021` | Порт ESL | +| `esl.password` | `""` | Пароль ESL (ClueCon) | +| `esl.password_env` | `""` | Или имя переменной окружения с паролем | + +--- + +## Scoring Engine + +### Lethal-условия (score = -100) + +Если хотя бы одно истинно — нода исключается из роутинга: + +| Условие | Порог | +|---------|-------| +| `status != "ok"` | Любой статус кроме ok | +| Stale | > `stale_threshold_sec` (20 с) | +| `active_calls >= max_calls` | 100% заполнение | +| `idle_cpu < idle_cpu_min` | < 5% | +| `call_failure_rate > call_failure_rate_lethal` | > 15% | +| `disabled == true` | Ручное отключение админом | + +### Weighted score (0..100) + +``` +call_score = clamp(100 - (active_calls / max_calls * 100), 0, 100) +load_score = clamp(100 - (load_avg * 50), 0, 100) +idle_score = clamp(idle_cpu, 0, 100) +fail_score = clamp(100 - call_failure_rate, 0, 100) + +score = call_score × 0.40 + + load_score × 0.30 + + idle_score × 0.20 + + fail_score × 0.10 +``` + +### Пример + +Нода `pbx-01`: `active_calls=42`, `max_calls=250`, `load_avg=0.85`, `idle_cpu=35`, `fail_rate=0.5` + +``` +call_score = clamp(100 - (42/250)*100, 0, 100) = 83.2 +load_score = clamp(100 - 0.85*50, 0, 100) = 57.5 +idle_score = clamp(35, 0, 100) = 35.0 +fail_score = clamp(100 - 0.5, 0, 100) = 99.5 + +score = 83.2×0.40 + 57.5×0.30 + 35.0×0.20 + 99.5×0.10 = 70.1 +``` + +### Кэширование + +`best_node_id` + `best_score` + `fallback_active` пересчитываются при каждой метрике. +`/api/route` читает их за RLock — O(1). + +--- + +## API Reference + +### Auth + +| Метод | Путь | Роль | Описание | +|-------|------|------|----------| +| `POST` | `/api/auth/login` | – | Вход: `{"username":"admin","password":"admin"}` → access_token + refresh_token | +| `POST` | `/api/auth/refresh` | – | Обновление access-токена через refresh_token | + +Все остальные эндпоинты (кроме `/api/route`, `/api/health/*`, `/api/monitoring/*`) требуют `Authorization: Bearer `. + +### Core + +| Метод | Путь | Роль | Описание | +|-------|------|------|----------| +| `GET` | `/api/route` | – | Выбор ноды для звонка. Query: `caller_id`, `dest`, `ingress_trunk` | +| `GET` | `/api/health` | – | Статистика: всего нод, здоровых, запросов, uptime | +| `GET` | `/api/health/live` | – | Liveness probe (всегда 200) | +| `GET` | `/api/health/ready` | – | Readiness probe (NATS + метрики) | + +### Nodes + +| Метод | Путь | Роль | Описание | +|-------|------|------|----------| +| `GET` | `/api/nodes` | admin/viewer | Список всех нод со score, состоянием | +| `GET` | `/api/nodes/{id}/metrics` | admin/viewer | История метрик (ring buffer, ~30 мин) | +| `PUT` | `/api/nodes/{id}/toggle` | admin | `{"disabled":true, "reason":"..."}` | + +### Trunks + +| Метод | Путь | Роль | Описание | +|-------|------|------|----------| +| `GET` | `/api/trunks` | admin | Все транки. Query: `type=ingress\|balance\|fallback` | +| `POST` | `/api/trunks` | admin | Создать транк | +| `PUT` | `/api/trunks/{id}` | admin | Обновить транк | +| `DELETE` | `/api/trunks/{id}` | admin | Удалить транк | + +### Users + +| Метод | Путь | Роль | Описание | +|-------|------|------|----------| +| `GET` | `/api/users` | admin | Список пользователей | +| `POST` | `/api/users` | admin | Создать пользователя | +| `PUT` | `/api/users/{id}` | admin | Обновить пользователя | +| `DELETE` | `/api/users/{id}` | admin | Удалить пользователя | + +### Monitoring (API-key, не JWT) + +| Метод | Путь | Описание | +|-------|------|----------| +| `GET` | `/api/monitoring/zabbix` | JSON для Zabbix LLD | +| `GET` | `/api/monitoring/prometheus` | `text/plain` метрики для Prometheus | + +### WebSocket + +| Метод | Путь | Описание | +|-------|------|----------| +| `WS` | `/ws/metrics` | Real-time метрики на фронт (JWT в query `?token=`) | + +### Примеры запросов + +```bash +# Route (no auth) +curl 'http://localhost:8080/api/route?caller_id=74951234567&dest=123&ingress_trunk=trk-001' +# → {"node_id":"pbx-03","score":88,"sip_gateway":"sip:pbx03.lan:5060"} + +# Fallback (все ноды unhealthy) +# → {"fallback":true,"sip_gateway":"sip:operator.lan:5060","reason":"all_nodes_unhealthy","nodes":[...]} + +# Login +TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"admin"}' | jq -r '.access_token') + +# Nodes with JWT +curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/nodes + +# Toggle node (admin only) +curl -X PUT http://localhost:8080/api/nodes/pbx-01/toggle \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"disabled":true,"reason":"maintenance"}' + +# Prometheus metrics (API-key) +curl -H "X-API-Key: $(jq -r '.monitoring_api_key' data/config.json)" \ + http://localhost:8080/api/monitoring/prometheus +``` + +--- + +## FreeSWITCH Интеграция + +### Dialplan (route.lua) + +FreeSWITCH использует Lua-скрипт в dialplan. Скрипт дёргает `/api/route` через `mod_curl` и бриджится на полученный gateway. + +```lua +-- contrib/route.lua (упрощённо) +local url = string.format("%s/api/route?caller_id=%s&dest=%s&ingress_trunk=%s", + session:getVariable("balancer_url"), + session:getVariable("caller_id_number"), + session:getVariable("destination_number"), + session:getVariable("ingress_trunk")) + +local response = curl.fetch({url = url, timeout = tonumber(route_timeout)}) +local ok, result = cjson.decode(response.body) + +if result.fallback then + session:bridge(result.sip_gateway) +else + session:bridge(result.sip_gateway) +end +``` + +Конфигурация в dialplan: + +```xml + + + + + + + +``` + +### ESL (автоматическое управление шлюзами) + +При включении `esl.host` в конфиге балансировщик: +- Подключается к FreeSWITCH ESL (`:8021`) +- Синхронизирует ingress-транки как Sofia-шлюзы +- Слушает события регистрации шлюзов +- Передаёт статус gateway на WebSocket фронта + +--- + +## Модели данных + +### NATS-сообщение (PBX → Balancer, каждые 5 с) + +```json +{ + "node_id": "pbx-01", + "ts": 1719000000, + "status": "ok", + "active_calls": 42, + "max_calls": 250, + "idle_cpu": 35, + "load_avg": 0.85, + "call_failure_rate": 0.5 +} +``` + +### Транки + +| Тип | Назначение | +|-----|-----------| +| `ingress` | Входящий транк (откуда звонки приходят) | +| `balance` | Транк назначения (привязан к Node) | +| `fallback` | Резервный транк (когда все balance score < 0), ровно 1 | + +--- + +## Деплой + +### systemd + +```bash +make install # копирует бинарник + web-статику +make install-systemd # устанавливает systemd unit +``` + +```ini +# deploy/pulse-lets-go.service +[Unit] +Description=Pulse Lets Go — Telephone Load Balancer +Wants=nats.service + +[Service] +User=pulse +Group=pulse +WorkingDirectory=/usr/local/share/pulse-lets-go +Environment=PULSE_DATA_DIR=/etc/pulse-lets-go +ExecStart=/usr/local/bin/pulse-lets-go +Restart=always +RestartSec=5 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=yes +ReadWritePaths=/etc/pulse-lets-go /var/log/pulse-lets-go + +[Install] +WantedBy=multi-user.target +``` + +### Структура директорий + +``` +/usr/local/share/pulse-lets-go/ +├── web/build/ # SvelteKit статика +/etc/pulse-lets-go/ +├── config.json # Конфигурация +├── trunks.json # Транки +├── users.json # Пользователи +/var/log/pulse-lets-go/ +└── metrics.YYYY-MM-DD.log # ASCII-лог метрик +``` + +### Security + +- `NoNewPrivileges=true` — запрет повышения привилегий +- `ProtectSystem=strict` — read-only системные директории +- `ProtectHome=yes` — изоляция home +- `PrivateTmp=true` — изолированный /tmp +- JWT-секрет и API-key генерируются случайно при первом запуске + +--- + +## Production Deployment + +### Предусловия (перед деплоем pulse-lets-go) + +| № | Проверка | Команда | Ожидание | +|:-:|----------|---------|----------| +| 1 | **ESL запущен** | `nc -z 127.0.0.1 8021 && echo OK` | `OK` | +| 2 | **ESL пароль** | `echo "auth ClueCon" \| nc 127.0.0.1 8021` | `+OK accepted` | +| 3 | **mod_curl загружен** | `fs_cli -x "show modules" \| grep mod_curl` | `` | +| 4 | **mod_lua загружен** | `fs_cli -x "show modules" \| grep mod_lua` | `` | +| 5 | **NATS установлен** | `which nats-server` | путь к бинарнику | +| 6 | **Права на ESL** | ACL в `event_socket.conf.xml` разрешает 127.0.0.1 | `localhost` или `127.0.0.1` в ACL | + +### Загрузка mod_curl без рестарта FreeSWITCH + +Если `mod_curl` не загружен — звонки работать не будут. Загружается **без остановки FS**: + +```bash +fs_cli -x "load mod_curl" +# Добавить в modules.conf.xml для персистентности: +# +``` + +### Порядок деплоя (7 шагов) + +**Шаг 1 — Скопировать бинарник и конфиги** +```bash +make build-prod +scp bin/pulse-lets-go admin@balancer.lan:/usr/local/bin/ +scp -r contrib/route.lua admin@balancer.lan:/tmp/ +``` + +**Шаг 2 — NATS сервер** (если не установлен) +```bash +ssh admin@balancer.lan +sudo mkdir -p /opt/nats +# Установить nats-server из репозитория или скопировать бинарник +# Запуск: nats-server -p 4222 -D & +``` + +**Шаг 3 — route.lua в FS scripts** +```bash +sudo mkdir -p /etc/freeswitch/scripts +sudo cp /tmp/route.lua /etc/freeswitch/scripts/ +``` + +**Шаг 4 — Dialplan** (добавить `pulse_route` extension) +```xml + + + + + + + +``` +```bash +sudo fs_cli -x "reloadxml" +``` + +**Шаг 5 — Маппинг существующих FS gateway в pulse-lets-go транки** +```bash +# Получить список gateway из FS: +echo "api sofia profile external gwlist" | nc 127.0.0.1 8021 | grep "pulse-" +# → список имён gateway + +# Создать balance-транки для каждого gateway через API: +# POST /api/trunks {"name":"uc-69 баланс","type":"balance","node_id":"uc-69","gateway":"sip:10.x.x.x:5060"} +``` + +**Шаг 6 — Запуск pulse-lets-go** +```bash +sudo mkdir -p /etc/pulse-lets-go /var/log/pulse-lets-go +# config.json создастся автоматически при первом запуске +# Добавить esl блок в config.json: +# "esl": {"host": "127.0.0.1", "port": 8021, "password": "ClueCon"} +PULSE_DATA_DIR=/etc/pulse-lets-go ./pulse-lets-go +``` + +**Шаг 7 — Проверка** +```bash +curl http://127.0.0.1:8080/api/health | jq '.connections' +# → {"nats":"connected","esl":"connected"} +curl 'http://127.0.0.1:8080/api/route?caller_id=123&dest=456' +# → {"node_id":"pbx-03","score":88,"sip_gateway":...} +``` + +### Rollback (если route.lua сломал звонки) + +```bash +# 1. Убрать pulse_route из dialplan +sudo sed -i '/pulse_route/,/<\/extension>/d' /etc/freeswitch/dialplan/default.xml +sudo fs_cli -x "reloadxml" + +# 2. Остановить pulse-lets-go +sudo systemctl stop pulse-lets-go + +# Звонки продолжают идти по старому dialplan без балансировщика. +``` + +### Права доступа (production) + +| Операция | Требуемые права | +|----------|----------------| +| Чтение FS конфигов | root или freeswitch | +| `fs_cli -x` команды | root или freeswitch | +| ESL (порт 8021) | ACL в `event_socket.conf.xml` | +| Установка ПО | root | +| Запись в `/etc/freeswitch/scripts/` | root | +| `reloadxml` | root или freeswitch | + +### Версии (протестировано) + +| Компонент | Версия | +|-----------|--------| +| FreeSWITCH | 1.10.12+ | +| NATS Server | 2.10+ | +| Go | 1.26 | +| ОС | Linux (CentOS 7+, AlmaLinux, Arch) | + +--- + +## Metrics Agent (pulse-lets-go-agent) + +### Architecture + +``` +┌─────────────────────┐ pulse.metrics. ┌─────────────────────┐ +│ pulse-lets-go-agent│──────────────────────────▶ │ NATS Server │ +│ (on each PBX node) │ every 5 seconds │ (central) │ +├─────────────────────┤ └────────┬────────────┘ +│ FreeSWITCH (ESL) │ │ +│ Asterisk (AMI) │ ▼ +│ System (/proc) │ ┌─────────────────┐ +└─────────────────────┘ │ pulse-lets-go │ + │ (scoring engine) │ + └─────────────────┘ +``` + +The agent runs on each PBX node (FreeSWITCH or Asterisk), collects metrics, and publishes them to NATS at a regular interval (default 5 seconds). The pulse-lets-go balancer subscribes to all metrics and uses them for scoring and routing decisions. + +### Installation + +```bash +make build-agent +# → bin/pulse-lets-go-agent (static binary, ~9 MB) +``` + +### Configuration (agent.json) + +```json +{ + "node_id": "uc06", + "type": "asterisk", + "nats_url": "nats://balancer.host:4222", + "nats_user": "", + "nats_password": "", + "interval_sec": 5, + "max_calls": 150, + "sip_gateway": "sip:uc-pbx.lan:5060", + "sip_gateway_auto": false, + "failure_window": 1000, + "esl": { "host": "127.0.0.1", "port": 8021, "password": "ClueCon" }, + "ami": { "host": "127.0.0.1", "port": 6154, "username": "admin", "password": "changeme" } +} +``` + +| Поле | Тип | Default | Описание | +|------|-----|---------|----------| +| `node_id` | string | — | Уникальный ID ноды (uc06, ses-sip) | +| `type` | string | — | `freeswitch` или `asterisk` | +| `nats_url` | string | — | URL NATS-сервера | +| `nats_user` | string | `""` | Пользователь NATS | +| `nats_password` | string | `""` | Пароль NATS | +| `interval_sec` | int | `5` | Интервал публикации метрик | +| `max_calls` | int | `250` | Ёмкость ноды (fallback) | +| `sip_gateway` | string | `""` | SIP-адрес для auto-создания транка | +| `sip_gateway_auto` | bool | `false` | Авто-определение SIP из PBX | +| `failure_window` | int | `1000` | Размер окна для call_failure_rate | +| `esl.*` | object | — | FreeSWITCH ESL настройки (`host`, `port`, `password`) | +| `ami.*` | object | — | Asterisk AMI настройки (`host`, `port`, `username`, `password`) | + +### Collectors + +| Collector | Источник | Метрики | PBX | +|-----------|----------|---------|-----| +| `freeswitch` | ESL: `show channels count`, `json status`, `eval $${idle_cpu}` | active_calls, max_calls, idle_cpu | ✅ | +| `asterisk` | AMI: `CoreShowChannels`, `CoreSettings`, Hangup events | active_calls, max_calls, call_failure_rate | ✅ | +| `system` | `/proc/loadavg`, `/proc/stat` | load_avg, idle_cpu (fallback) | ✅ | + +**Call Failure Rate:** скользящее окно последних N (1000) завершённых звонков. + +| Исход | FS (ESL) | Asterisk (AMI) | +|-------|----------|---------------| +| Успех | `CHANNEL_HANGUP` + `Hangup-Cause: NORMAL_CLEARING` | `Hangup` + `Cause: 16` | +| Отказ | Любой другой `Hangup-Cause` | Любой другой `Cause` | + +### Collector Interface (расширяемость) + +```go +type Collector interface { + Type() string // "freeswitch" | "asterisk" + Connect() error // ESL auth / AMI login + Collect() (*PBXResult, error) // Сбор метрик + ListenHangup(onHangup func(bool)) // Подписка на hangup события + Close() // Закрытие соединения +} +``` + +Новый тип ноды (omnichannel, другой PBX) = новый файл `collector_.go`, реализующий 5 методов интерфейса. Ничего в существующем коде менять не нужно. + +### Template Configs + +| Файл | Назначение | Тип | +|------|-----------|-----| +| `contrib/agent-uc.json` | Asterisk UC-ноды (05, 06, 66-69) | asterisk | +| `contrib/agent-sessip.json` | FreeSWITCH ses-sip (ses-pbx.lan) | freeswitch | + +### Deployment + +```bash +# 1. Собрать +make build-agent + +# 2. Скопировать на ноду +scp bin/pulse-lets-go-agent admin@NODE_IP:/usr/local/bin/ + +# 3. Создать конфиг +scp contrib/agent-uc.json admin@NODE_IP:/etc/pulse-lets-go-agent/agent.json + +# 4. Запустить +ssh admin@NODE_IP "pulse-lets-go-agent -config /etc/pulse-lets-go-agent/agent.json" +``` + +### Systemd Unit + +```ini +# /etc/systemd/system/pulse-lets-go-agent.service +[Unit] +Description=Pulse Lets Go Agent — PBX metrics collector +After=network.target nats.service +Wants=nats.service + +[Service] +Type=simple +User=root +ExecStart=/usr/local/bin/pulse-lets-go-agent -config /etc/pulse-lets-go-agent/agent.json +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target +``` + +### Verification + +```bash +# На PBX-ноде — проверить лог агента: +journalctl -u pulse-lets-go-agent -f +# Ожидаем: +# [publisher] NATS подключён к nats://host:4222 +# [ami] подключён к 127.0.0.1:6154 (для Asterisk) +# [agent] опубликована метрика: calls=0/150 load=0.28 cpu=99% status=ok + +# На балансировщике — проверить что нода появилась: +curl http://localhost:8080/api/nodes | jq '.[] | {node_id, score, active_calls}' +``` + +### Troubleshooting + +| Проблема | Причина | Решение | +|----------|---------|---------| +| `nats: no servers available` | NATS не запущен или недоступен | `systemctl start nats-server` | +| `ami auth rejected` | Неверный пароль или доступ | Проверить `/etc/asterisk/manager.conf` | +| `esl connect timeout` | ESL не слушает на порту | `fs_cli -x "load mod_event_socket"` | +| `calls=0/0` | max_calls не удалось получить | Указать `max_calls` в `agent.json` | + +--- + +## Разработка + +### Команды + +| Команда | Описание | +|---------|----------| +| `make build` | Полная сборка (Go + SvelteKit) | +| `make dev` | Go backend + SvelteKit dev server (hot-reload) | +| `make test` | Go тесты с coverage | +| `make test-race` | Тесты с race detector | +| `make lint` | `go vet ./...` | +| `make fmt` | `go fmt ./...` | +| `make emulate-normal` | Эмуляция метрик (3 здоровые ноды) | +| `make emulate-chaos` | Эмуляция случайных сбоев | + +### Стек + +| Компонент | Технология | +|-----------|-----------| +| Backend | Go 1.26, stdlib `net/http` + `http.ServeMux` | +| Frontend | SvelteKit 2 + Tailwind CSS 4 + Lucide | +| Auth | JWT (golang-jwt/v5, HS256) + bcrypt | +| Message bus | NATS (nats.go) | +| Storage | JSON-файлы (data/) | +| ESL | Raw TCP (без внешних библиотек) | + +### Структура проекта + +``` +pulse-lets-go/ +├── cmd/ +│ ├── pulse-lets-go/ # Точка входа (main.go) +│ ├── pulse-lets-go-agent/ # Агент сбора метрик (FS / Asterisk) +│ ├── emulator/ # Эмулятор метрик для тестирования +│ └── siptest/ # SIP-тестер для e2e проверок +├── internal/ +│ ├── api/ # HTTP-хендлеры (auth, nodes, trunks, users, monitoring, ws, route) +│ ├── config/ # JSON-конфиги (atomic save, thread-safe) +│ ├── engine/ # Scoring engine + router (sync.RWMutex, best-node cache) +│ ├── esl/ # FreeSWITCH ESL client (raw TCP) +│ ├── ami/ # Asterisk AMI client (raw TCP) +│ ├── log/ # ASCII-логгер метрик с ротацией +│ ├── models/ # Все типы данных (NodeMetric, NodeState, Trunk, User, ...) +│ └── nats/ # NATS subscriber +├── contrib/ # route.lua, agent-*.json (FreeSWITCH dialplan + агент) +├── deploy/ # systemd unit +├── web/ # SvelteKit фронтенд +└── data/ # JSON-файлы времени выполнения (gitignored) +``` diff --git a/cmd/emulator/main.go b/cmd/emulator/main.go index df0f3ad..cabf50c 100644 --- a/cmd/emulator/main.go +++ b/cmd/emulator/main.go @@ -37,6 +37,7 @@ type nodeMetric struct { IdleCPU float64 `json:"idle_cpu"` LoadAvg float64 `json:"load_avg"` CallFailureRate float64 `json:"call_failure_rate"` + SIPGateway string `json:"sip_gateway,omitempty"` } // --- Спецификация значения (фиксированное или случайный диапазон) --- @@ -63,13 +64,14 @@ func (v valueSpec) get() float64 { // --- Конфигурация ноды в эмуляторе --- type nodeSpec struct { - nodeID string - status valueSpec // строка: ok / down - calls valueSpec - maxCalls float64 // фиксированное - idleCPU valueSpec - loadAvg valueSpec - failRate valueSpec + nodeID string + status valueSpec // строка: ok / down + calls valueSpec + maxCalls float64 // фиксированное + idleCPU valueSpec + loadAvg valueSpec + failRate valueSpec + sipGateway string // SIP-адрес для авто-создания транка } func (ns *nodeSpec) generate() nodeMetric { @@ -82,6 +84,7 @@ func (ns *nodeSpec) generate() nodeMetric { IdleCPU: ns.idleCPU.get(), LoadAvg: ns.loadAvg.get(), CallFailureRate: ns.failRate.get(), + SIPGateway: ns.sipGateway, } } @@ -100,16 +103,19 @@ func scenarioNormal() []nodeSpec { nodeID: "pbx-01", status: fixed(1), calls: rrange(10, 100), maxCalls: 250, idleCPU: rrange(40, 95), loadAvg: rrange(0.1, 1.0), failRate: rrange(0, 2), + sipGateway: "sip:pbx-01.lan:5060", }, { nodeID: "pbx-02", status: fixed(1), calls: rrange(50, 200), maxCalls: 250, idleCPU: rrange(15, 45), loadAvg: rrange(0.5, 2.0), failRate: rrange(1, 8), + sipGateway: "sip:pbx-02.lan:5060", }, { nodeID: "pbx-03", status: fixed(1), calls: rrange(5, 60), maxCalls: 250, idleCPU: rrange(60, 90), loadAvg: rrange(0.05, 0.5), failRate: rrange(0, 1), + sipGateway: "sip:pbx-03.lan:5060", }, } } @@ -327,6 +333,6 @@ func publish(nc *natsgo.Conn, m nodeMetric) { log.Printf("[emulator] ошибка публикации %s: %v", m.NodeID, err) return } - log.Printf("[emulator] → %s: calls=%d/%d idle=%.0f%% load=%.2f fail=%.1f%% status=%s", - m.NodeID, m.ActiveCalls, m.MaxCalls, m.IdleCPU, m.LoadAvg, m.CallFailureRate, m.Status) + log.Printf("[emulator] → %s: calls=%d/%d idle=%.0f%% load=%.2f fail=%.1f%% status=%s gw=%s", + m.NodeID, m.ActiveCalls, m.MaxCalls, m.IdleCPU, m.LoadAvg, m.CallFailureRate, m.Status, m.SIPGateway) }