pulse-lets-go/README.md
Maksim Totmin 95ac4164dd 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
2026-06-25 16:59:17 +07:00

791 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.<node_id>`.
- **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.<id> ┌──────────────────┐
│ 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 <jwt>`.
### 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
<extension name="route_call">
<condition field="destination_number" expression="^.*$">
<action application="set" data="balancer_url=http://balancer.lan:8080"/>
<action application="set" data="ingress_trunk=trk-001"/>
<action application="lua" data="route.lua"/>
</condition>
</extension>
```
### 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` | `<load module="mod_curl"/>` |
| 4 | **mod_lua загружен** | `fs_cli -x "show modules" \| grep mod_lua` | `<load module="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 для персистентности:
# <load module="mod_curl"/>
```
### Порядок деплоя (7 шагов)
**Шаг 1 — Скопировать бинарник и конфиги**
```bash
make build-prod
scp bin/pulse-lets-go devadmin@10.101.60.81:/usr/local/bin/
scp -r contrib/route.lua devadmin@10.101.60.81:/tmp/
```
**Шаг 2 — NATS сервер** (если не установлен)
```bash
ssh devadmin@10.101.60.81
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
<!-- В /etc/freeswitch/dialplan/default.xml перед остальными extension -->
<extension name="pulse_route">
<condition field="destination_number" expression="^(.*)$">
<action application="set" data="balancer_url=http://127.0.0.1:8080"/>
<action application="lua" data="route.lua"/>
</condition>
</extension>
```
```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.<id> ┌─────────────────────┐
│ 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:10.101.60.115: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": "ctt", "password": "cttpass" }
}
```
| Поле | Тип | 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_<type>.go`, реализующий 5 методов интерфейса. Ничего в существующем коде менять не нужно.
### Template Configs
| Файл | Назначение | Тип |
|------|-----------|-----|
| `contrib/agent-uc.json` | Asterisk UC-ноды (05, 06, 66-69) | asterisk |
| `contrib/agent-sessip.json` | FreeSWITCH ses-sip (10.3.0.44) | freeswitch |
### Deployment
```bash
# 1. Собрать
make build-agent
# 2. Скопировать на ноду
scp bin/pulse-lets-go-agent devadmin@NODE_IP:/usr/local/bin/
# 3. Создать конфиг
scp contrib/agent-uc.json devadmin@NODE_IP:/etc/pulse-lets-go-agent/agent.json
# 4. Запустить
ssh devadmin@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)
```