Backend stability and security improvements: * internal/util/ — common RandomHex helper, removed 3 duplicates * ESL: deduplicated readMessage (locked/unlocked), net.JoinHostPort for IPv6 * AMI: synchronous reconnect() in readEventsLoop, net.JoinHostPort for IPv6 * Auth: /api/auth/refresh accepts Authorization header only (no ?token=) * decodeJSON: http.MaxBytesReader(1<<20) body limit * Trunks: gatewayParams() uses configured ESL.GatewayPrefix * Config: jwt_secret_env env-var fallback * FSCollector: time.After → time.NewTimer with defer Stop * Monitoring: Prometheus counters (route_requests, nodes_total/healthy, uptime) * go fmt pass across all internal/ packages
Pulse Lets Go
pulse-lets-go — балансировщик телефонной нагрузки на базе FreeSWITCH + NATS.
Агенты PBX отправляют метрики в NATS каждые 5 секунд. Балансировщик вычисляет weighted score готовности каждой ноды, кэширует лучшую и отдаёт её FreeSWITCH через /api/route для следующего звонка.
Features
- pulse-lets-go-agent — агент сбора метрик для FreeSWITCH (ESL) и Asterisk (AMI). Работает на каждой PBX-ноде.
- Scoring engine — lethal checks (disabled, stale, overload, CPU, failure rate) + weighted score (call, load, idle, fail) с конфигурируемыми весами.
- NATS шина — асинхронный сбор метрик через
pulse.metrics.<node_id>. - Auto-создание транков — при получении первой метрики от новой ноды с
sip_gatewayсоздаётся balance-транк автоматически. - Gateway health WS — статус FS-gateway (registered/unregistered) транслируется на фронт через WebSocket.
- REST API — JWT (access + refresh), роли admin/viewer, rate limiting, CORS, health endpoints.
- WebSocket — real-time трансляция метрик на SvelteKit дашборд.
- FreeSWITCH ESL — управление Sofia-шлюзами через raw TCP ESL.
- Monitoring — Prometheus и Zabbix эндпоинты с отдельным API-key.
- SvelteKit UI — дашборд, таблицы нод, транков, пользователей.
- Защита пользователей — первичный администратор (user-01) не может быть удалён или понижен до viewer.
- Graceful shutdown — корректное завершение с таймаутом 10 с.
- systemd ready — unit с изоляцией (NoNewPrivileges, ProtectSystem, PrivateTmp).
Архитектура
┌─────────────────────┐ ESL/AMI ┌──────────────────────┐
│ PBX-01 │──────────▶│ pulse-lets-go-agent │
│ (FS or Asterisk) │ collect │ (on each PBX node) │
├─────────────────────┤ ├──────────────────────┤
│ PBX-02 │──────────▶│ pulse-lets-go-agent │
│ (FS or Asterisk) │ ├──────────────────────┤
├─────────────────────┤ ├──────────────────────┤
│ PBX-N │──────────▶│ pulse-lets-go-agent │
│ (FS or Asterisk) │ └─────────┬────────────┘
└─────────────────────┘ │ pulse.metrics.<id>
│ every 5s
▼
┌──────────────┐
│ NATS Server │
│ nats://:4222 │
└──────┬───────┘
│
▼
┌──────────────────────┐
│ 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. Сборка
git clone git@git.totmin.ru:en2zmax/pulse-lets-go.git
cd pulse-lets-go
make build
3. Запуск
# Терминал 1: NATS
make nats
# Терминал 2: балансировщик
./bin/pulse-lets-go
# Терминал 3: эмулятор метрик (3 здоровые ноды)
make emulate-normal
4. Проверка
# 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
{
"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=) |
Примеры запросов
# 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.
-- contrib/route.lua (сокращённо)
api = freeswitch.API()
caller_id = session:getVariable("caller_id_number") or ""
dest = session:getVariable("destination_number") or ""
url = "http://localhost:8080/api/route?caller_id=" .. caller_id .. "&dest=" .. dest
raw = api:execute("curl", url)
body = raw:match("\r?\n\r?\n(.+)") or raw
local ok, route = pcall(cjson.decode, body)
if not ok then session:hangup("NORMAL_TEMPORARY_FAILURE") return end
if route.fallback then
session:bridge(route.sip_gateway) -- оператор
elseif route.sip_gateway then
session:bridge(route.sip_gateway) -- лучшая нода
else
session:hangup("NORMAL_TEMPORARY_FAILURE")
end
Конфигурация в dialplan:
<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 с)
{
"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
make install # копирует бинарник + web-статику
make install-systemd # устанавливает systemd unit
# deploy/pulse-lets-go.service
[Unit]
Description=Pulse Lets Go — Telephone Load Balancer
After=network.target
[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— изоляция homePrivateTmp=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:
fs_cli -x "load mod_curl"
# Добавить в modules.conf.xml для персистентности:
# <load module="mod_curl"/>
Порядок деплоя (7 шагов)
Шаг 1 — Скопировать бинарник и конфиги
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 сервер (если не установлен)
ssh admin@balancer.lan
sudo mkdir -p /opt/nats
# Установить nats-server из репозитория или скопировать бинарник
# Запуск: nats-server -p 4222 -D &
Шаг 3 — Деплой agent на PBX-ноды (повторить для каждой UC/Asterisk ноды)
# Скопировать бинарник и конфиг на каждую PBX:
scp bin/pulse-lets-go-agent admin@PBX_IP:/usr/local/bin/
scp contrib/agent-uc.json admin@PBX_IP:/etc/pulse-lets-go-agent/agent.json
# Запустить:
ssh admin@PBX_IP "systemctl start pulse-lets-go-agent"
# Проверить лог:
# journalctl -u pulse-lets-go-agent -f
# → [ami] подключён к 127.0.0.1:6154
# → [agent] опубликована метрика: calls=0/150 load=0.28 cpu=99% status=ok
Шаг 4 — route.lua в FS scripts
sudo mkdir -p /etc/freeswitch/scripts
sudo cp /tmp/route.lua /etc/freeswitch/scripts/
Шаг 5 — Dialplan (добавить pulse_route extension)
<!-- В /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>
sudo fs_cli -x "reloadxml"
Шаг 6 — Создание balance-транков для каждой PBX-ноды
# Получить список gateway из FS (или список нод из конфига):
echo "api sofia profile external gwlist" | nc 127.0.0.1 8021
# Создать balance-транк для каждой UC-ноды:
curl -X POST .../api/trunks
-d '{"name":"uc06","type":"balance","node_id":"uc06","gateway":"sip:uc-pbx.lan:5060","enabled":true}'
Транки также создаются автоматически при получении первой метрики от агента (если в метрике передан sip_gateway).
Шаг 7 — Запуск pulse-lets-go
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
Шаг 8 — Проверка
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 сломал звонки)
# 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
make build-agent
# → bin/pulse-lets-go-agent (static binary, ~9 MB)
Configuration (agent.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 (расширяемость)
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 (ses-pbx.lan) | freeswitch |
Deployment
# 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
# /etc/systemd/system/pulse-lets-go-agent.service
[Unit]
Description=Pulse Lets Go Agent — PBX metrics collector
After=network.target
Wants=network-online.target
[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
# На 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 build-agent |
Сборка pulse-lets-go-agent |
make build-siptest |
Сборка SIP-тестера |
make emulate-* |
Эмуляция метрик (normal, overload, chaos, stale, ...) |
make siptest-* |
SIP-тестирование (uas, uac, full) |
Стек
| Компонент | Технология |
|---|---|
| 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)