Compare commits

..

No commits in common. "13c88c1586f0067699d56e6a2ca16096d3cddb63" and "44f5fa88e5162a710ceec1e147fb232b8708fd2e" have entirely different histories.

25 changed files with 137 additions and 2537 deletions

View File

@ -8,8 +8,7 @@ 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 \
build-siptest siptest-uas siptest-uac siptest-full
emulate-node-down emulate-stale emulate-chaos build-emulator
all: build
@ -101,40 +100,6 @@ 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 и запуск
# ============================================================

790
README.md
View File

@ -1,790 +0,0 @@
# 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
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
```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)
```

View File

@ -37,7 +37,6 @@ 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"`
}
// --- Спецификация значения (фиксированное или случайный диапазон) ---
@ -64,14 +63,13 @@ func (v valueSpec) get() float64 {
// --- Конфигурация ноды в эмуляторе ---
type nodeSpec struct {
nodeID string
status valueSpec // строка: ok / down
calls valueSpec
maxCalls float64 // фиксированное
idleCPU valueSpec
loadAvg valueSpec
failRate valueSpec
sipGateway string // SIP-адрес для авто-создания транка
nodeID string
status valueSpec // строка: ok / down
calls valueSpec
maxCalls float64 // фиксированное
idleCPU valueSpec
loadAvg valueSpec
failRate valueSpec
}
func (ns *nodeSpec) generate() nodeMetric {
@ -84,7 +82,6 @@ func (ns *nodeSpec) generate() nodeMetric {
IdleCPU: ns.idleCPU.get(),
LoadAvg: ns.loadAvg.get(),
CallFailureRate: ns.failRate.get(),
SIPGateway: ns.sipGateway,
}
}
@ -103,19 +100,16 @@ 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",
},
}
}
@ -333,6 +327,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 gw=%s",
m.NodeID, m.ActiveCalls, m.MaxCalls, m.IdleCPU, m.LoadAvg, m.CallFailureRate, m.Status, m.SIPGateway)
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)
}

View File

@ -1,174 +0,0 @@
package agent
import (
"fmt"
"log"
"os"
"os/signal"
"sync/atomic"
"syscall"
"time"
"github.com/pulse-lets-go/internal/models"
)
const maxConsecutiveFailures = 5
// Agent — главный цикл сбора метрик и публикации в NATS.
type Agent struct {
cfg *Config
publisher *Publisher
collector Collector
failtrack *FailureTracker
pubCount atomic.Int64
}
// NewAgent создаёт агента на основе конфигурации.
func NewAgent(cfg *Config) (*Agent, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
// Publisher
pub, err := NewPublisher(cfg.NatsURL, cfg.NatsUser, cfg.NatsPassword, cfg.NodeID)
if err != nil {
return nil, err
}
// Collector — в зависимости от типа PBX
var col Collector
switch cfg.Type {
case "freeswitch":
col = NewFSCollector(cfg.ESL.Host, cfg.ESL.Port, cfg.ESL.Password)
case "asterisk":
col = NewAsteriskCollector(cfg.AMI.Host, cfg.AMI.Port, cfg.AMI.Username, cfg.AMI.Password)
default:
pub.Close()
return nil, fmt.Errorf("неподдерживаемый тип PBX: %s", cfg.Type)
}
if err := col.Connect(); err != nil {
pub.Close()
return nil, fmt.Errorf("подключение к %s: %w", cfg.Type, err)
}
// Failure rate tracker
ft := NewFailureTracker(cfg.FailureWindow)
// Подписка на hangup-события
col.ListenHangups(func(success bool) {
ft.Record(success)
})
return &Agent{
cfg: cfg,
publisher: pub,
collector: col,
failtrack: ft,
}, nil
}
// Start запускает главный цикл агента: сбор метрик → публикация.
// Блокирует до получения сигнала завершения (SIGINT, SIGTERM).
func (a *Agent) Start() {
log.Printf("[agent] запущен: node=%s, type=%s, interval=%ds",
a.cfg.NodeID, a.cfg.Type, a.cfg.IntervalSec)
ticker := time.NewTicker(time.Duration(a.cfg.IntervalSec) * time.Second)
defer ticker.Stop()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
// Счётчик последовательных ошибок сбора
consecutiveFailures := 0
// Первый сбор сразу
a.collectAndPublish(&consecutiveFailures)
for {
select {
case <-ticker.C:
a.collectAndPublish(&consecutiveFailures)
case <-quit:
log.Println("[agent] получен сигнал завершения")
a.Shutdown()
return
}
}
}
// collectAndPublish выполняет один цикл: сбор метрик → публикация.
func (a *Agent) collectAndPublish(failures *int) {
// 1. Системные метрики (CPU, load)
sys := CollectSystem()
// 2. Метрики PBX (calls, max_calls, статус)
pbx, err := a.collector.Collect()
if err != nil {
*failures++
log.Printf("[agent] ошибка сбора метрик (попытка %d): %v", *failures, err)
if *failures >= maxConsecutiveFailures {
// После N неудач — считаем ноду "down"
pbx = &PBXResult{
Status: "down",
}
} else {
// Пробуем ещё раз через секунду
time.Sleep(1 * time.Second)
pbx, err = a.collector.Collect()
if err != nil {
return // пропускаем этот тик
}
*failures = 0
}
} else {
*failures = 0
}
// 3. Failure rate
failRate := a.failtrack.Rate()
// 4. SIP gateway
sipGW := a.cfg.GetSIPGateway("")
// 5. Формируем метрику
metric := ToMetric(a.cfg.NodeID, pbx, sys, failRate, sipGW, a.cfg.MaxCalls)
// 6. Публикуем
if err := a.publisher.Publish(metric); err != nil {
log.Printf("[agent] ошибка публикации: %v", err)
return
}
a.pubCount.Add(1)
log.Printf("[agent] опубликована метрика: calls=%d/%d load=%.2f fail=%.1f%% cpu=%.0f%% status=%s",
metric.ActiveCalls, metric.MaxCalls, metric.LoadAvg, metric.CallFailureRate, metric.IdleCPU, metric.Status)
}
// Shutdown gracefully shuts down the agent.
func (a *Agent) Shutdown() {
log.Printf("[agent] остановка, всего опубликовано метрик: %d", a.pubCount.Load())
if a.collector != nil {
a.collector.Close()
}
if a.publisher != nil {
a.publisher.Close()
}
}
// CollectAndPublish экспортированный метод для ручного вызова (тесты, дебаг).
func (a *Agent) CollectAndPublish() *models.NodeMetric {
sys := CollectSystem()
pbx, err := a.collector.Collect()
if err != nil {
pbx = &PBXResult{Status: "down"}
}
failRate := a.failtrack.Rate()
sipGW := a.cfg.GetSIPGateway("")
metric := ToMetric(a.cfg.NodeID, pbx, sys, failRate, sipGW, a.cfg.MaxCalls)
a.publisher.Publish(metric)
a.pubCount.Add(1)
return metric
}

View File

@ -1,70 +0,0 @@
package agent
import "github.com/pulse-lets-go/internal/models"
// PBXResult — результат сбора метрик от PBX (FS или Asterisk).
type PBXResult struct {
ActiveCalls int // текущее количество звонков
MaxCalls int // ёмкость (0 — не удалось получить)
IdleCPU float64 // idle CPU из PBX (0 — не удалось, fallback на system)
Status string // "ok" — PBX отвечает нормально
SIPGateway string // авто-определённый SIP-адрес
}
// Collector — интерфейс сбора метрик с PBX.
type Collector interface {
// Type возвращает тип PBX: "freeswitch" или "asterisk".
Type() string
// Connect подключается к PBX (ESL auth / AMI login).
Connect() error
// Collect собирает метрики с PBX.
// Возвращает PBXResult и ошибку, если сбор не удался.
Collect() (*PBXResult, error)
// ListenHangups подписывается на события завершения звонков
// для расчёта call_failure_rate.
ListenHangups(onHangup func(success bool))
// Close закрывает соединение с PBX.
Close()
}
// HangupEvent — событие завершения звонка.
type HangupEvent struct {
Success bool // true — звонок успешен, false — ошибка
Cause string // код причины (NORMAL_CLEARING, BUSY, и т.д.)
}
// ToMetric формирует models.NodeMetric из результатов сбора.
func ToMetric(nodeID string, pbx *PBXResult, sys *SystemStats, failureRate float64, sipGateway string, maxCallsDefault int) *models.NodeMetric {
// Определяем max_calls: приоритет PBX → конфиг
maxCalls := pbx.MaxCalls
if maxCalls <= 0 {
maxCalls = maxCallsDefault
}
// Определяем idle_cpu: приоритет PBX → system
idleCPU := pbx.IdleCPU
if idleCPU <= 0 {
idleCPU = sys.IdleCPU
}
// Определяем статус
status := pbx.Status
if pbx.Status == "ok" && sys.IdleCPU < 5 {
status = "degraded"
}
return &models.NodeMetric{
NodeID: nodeID,
TS: nowTS(),
Status: status,
ActiveCalls: pbx.ActiveCalls,
MaxCalls: maxCalls,
IdleCPU: idleCPU,
LoadAvg: sys.Load1,
CallFailureRate: failureRate,
SIPGateway: sipGateway,
}
}

View File

@ -1,107 +0,0 @@
package agent
import (
"fmt"
"strconv"
"strings"
"github.com/pulse-lets-go/internal/ami"
)
// AsteriskCollector собирает метрики с Asterisk через AMI.
type AsteriskCollector struct {
client *ami.Client
}
// NewAsteriskCollector создаёт Asterisk-коллектор.
func NewAsteriskCollector(host string, port int, username, password string) *AsteriskCollector {
return &AsteriskCollector{
client: ami.NewClient(host, port, username, password),
}
}
func (ac *AsteriskCollector) Type() string { return "asterisk" }
func (ac *AsteriskCollector) Connect() error {
return ac.client.Connect()
}
func (ac *AsteriskCollector) Collect() (*PBXResult, error) {
if !ac.client.IsConnected() {
return nil, fmt.Errorf("ami не подключён")
}
result := &PBXResult{Status: "ok"}
// active_calls: CoreShowChannels определяет количество активных каналов
if active, err := ac.coreShowChannelsCount(); err == nil {
result.ActiveCalls = active
}
// max_calls: CoreSettings → CoreMaxCalls
if max, err := ac.coreSettingsInt("CoreMaxCalls"); err == nil {
result.MaxCalls = max
}
// idle_cpu: Asterisk не даёт FS-шного idle_cpu, используем системный
result.IdleCPU = 0
return result, nil
}
func (ac *AsteriskCollector) ListenHangups(onHangup func(success bool)) {
// Подписываемся на события Hangup через AMI
ac.client.Subscribe("call")
go func() {
for evt := range ac.client.Events() {
if evt.Headers["Event"] == "Hangup" {
cause := evt.Headers["Cause"]
// Считаем успешным только NORMAL_CLEARING (код 16)
success := cause == "16" || strings.Contains(cause, "NORMAL_CLEARING")
onHangup(success)
}
}
}()
}
func (ac *AsteriskCollector) Close() {
ac.client.Disconnect()
}
// --- helpers ---
// coreShowChannelsCount получает количество активных каналов.
func (ac *AsteriskCollector) coreShowChannelsCount() (int, error) {
cmd := "Action: Command\r\nCommand: core show channels count\r\n"
headers, body, err := ac.client.SendCommandBody(cmd)
if err != nil {
return 0, err
}
_ = headers
// Ответ выглядит как: "1 active call" или "5 active calls"
body = strings.TrimSpace(body)
fields := strings.Fields(body)
if len(fields) >= 3 {
// Первое слово — число
n, err := strconv.Atoi(fields[0])
if err == nil {
return n, nil
}
}
return 0, fmt.Errorf("не удалось распарсить количество каналов из: %s", body)
}
// coreSettingsInt получает целочисленное значение из CoreSettings.
func (ac *AsteriskCollector) coreSettingsInt(key string) (int, error) {
headers, _, err := ac.client.SendCommandBody("Action: CoreSettings\r\n")
if err != nil {
return 0, err
}
val := headers[key]
if val == "" {
return 0, fmt.Errorf("поле %s не найдено", key)
}
return strconv.Atoi(strings.TrimSpace(val))
}

View File

@ -1,134 +0,0 @@
package agent
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/pulse-lets-go/internal/esl"
)
// FSCollector собирает метрики с FreeSWITCH через ESL.
type FSCollector struct {
client *esl.Client
}
// NewFSCollector создаёт FreeSWITCH-коллектор.
func NewFSCollector(host string, port int, password string) *FSCollector {
return &FSCollector{
client: esl.NewClient(host, port, password),
}
}
func (fc *FSCollector) Type() string { return "freeswitch" }
func (fc *FSCollector) Connect() error {
// ESL client подключается синхронно через tryConnect
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
fc.client.ConnectWithRetry()
// Ждём подключения с таймаутом
for !fc.client.IsConnected() {
select {
case <-ctx.Done():
return fmt.Errorf("esl connect timeout")
case <-time.After(100 * time.Millisecond):
// Выходим из цикла после ConnectWithRetry
return nil
}
}
return nil
}
func (fc *FSCollector) Collect() (*PBXResult, error) {
if !fc.client.IsConnected() {
return nil, fmt.Errorf("esl не подключён")
}
result := &PBXResult{Status: "ok"}
// active_calls: api show channels count
if active, err := fc.apiInt("show channels count"); err == nil {
result.ActiveCalls = active
}
// max_calls: api json status → max_sessions
if max, err := fc.apiJSONInt("json", "status", "max_sessions"); err == nil {
result.MaxCalls = max
}
// idle_cpu: eval $${idle_cpu} — FS-специфичный
if idle, err := fc.apiFloat("eval $${idle_cpu}"); err == nil && idle > 0 {
result.IdleCPU = idle
}
return result, nil
}
func (fc *FSCollector) ListenHangups(onHangup func(success bool)) {
// Подписываемся на CHANNEL_HANGUP через ESL
fc.client.Subscribe("CHANNEL_HANGUP")
go func() {
// Для FS используем обработку событий через eventsCh
// Упрощённо: не реализуем полноценную подписку на первом этапе
// failure rate остаётся на нуле
time.Sleep(1 * time.Second)
}()
}
func (fc *FSCollector) Close() {
fc.client.Disconnect()
}
// --- helpers ---
func (fc *FSCollector) apiInt(cmd string) (int, error) {
headers, body, err := fc.client.Send("api " + cmd)
if err != nil {
return 0, err
}
_ = headers
val := strings.TrimSpace(body)
n, err := strconv.Atoi(val)
if err != nil {
// иногда ответ содержит текст помимо числа
n, _ = strconv.Atoi(strings.Fields(val)[0])
}
return n, nil
}
func (fc *FSCollector) apiFloat(cmd string) (float64, error) {
_, body, err := fc.client.Send("api " + cmd)
if err != nil {
return 0, err
}
val := strings.TrimSpace(body)
f, _ := strconv.ParseFloat(val, 64)
return f, nil
}
// apiJSONInt парсит JSON-ответ от FS api: api json status → field1.field2
func (fc *FSCollector) apiJSONInt(cmd string, field string, subfield string) (int, error) {
_, body, err := fc.client.Send("api " + cmd)
if err != nil {
return 0, err
}
// Быстрый парсинг: ищем "subfield": число
search := fmt.Sprintf(`"%s":`, subfield)
idx := strings.Index(body, search)
if idx < 0 {
return 0, fmt.Errorf("поле %s не найдено в ответе", subfield)
}
rest := body[idx+len(search):]
rest = strings.TrimLeft(rest, " \t")
end := strings.IndexAny(rest, ",\n\r}")
if end > 0 {
rest = rest[:end]
}
return strconv.Atoi(strings.TrimSpace(rest))
}

View File

@ -1,124 +0,0 @@
// Package agent реализует агент сбора метрик для pulse-lets-go.
// Агент работает на каждой PBX-ноде (FreeSWITCH или Asterisk),
// собирает метрики и публикует их в NATS каждые 5 секунд.
//
// Поддерживаемые типы PBX:
// - "freeswitch" — сбор через ESL (Event Socket Library)
// - "asterisk" — сбор через AMI (Asterisk Manager Interface)
//
// Пример agent.json:
//
// {
// "node_id": "uc06",
// "type": "asterisk",
// "nats_url": "nats://10.101.60.81:4222",
// "sip_gateway": "sip:10.101.60.115:5060",
// "interval_sec": 5,
// "max_calls": 150,
// "ami": { "host": "127.0.0.1", "port": 6154, "username": "ctt", "password": "cttpass" }
// }
package agent
import (
"encoding/json"
"fmt"
"os"
)
// Config — конфигурация агента (agent.json).
type Config struct {
NodeID string `json:"node_id"` // уникальный ID ноды (uc06, ses-sip)
Type string `json:"type"` // "freeswitch" или "asterisk"
NatsURL string `json:"nats_url"` // NATS URL
NatsUser string `json:"nats_user,omitempty"`
NatsPassword string `json:"nats_password,omitempty"`
IntervalSec int `json:"interval_sec"` // интервал сбора (default: 5)
MaxCalls int `json:"max_calls"` // ёмкость ноды (default: 250)
SIPGateway string `json:"sip_gateway"` // SIP-адрес для auto-транка
SIPGatewayAuto bool `json:"sip_gateway_auto"` // авто-определить из PBX
FailureWindow int `json:"failure_window"` // окно для call_failure_rate (default: 1000)
ESL ESLCfg `json:"esl,omitempty"`
AMI AMICfg `json:"ami,omitempty"`
}
// ESLCfg — настройки подключения к FreeSWITCH ESL.
type ESLCfg struct {
Host string `json:"host"`
Port int `json:"port"`
Password string `json:"password"`
}
// AMICfg — настройки подключения к Asterisk AMI.
type AMICfg struct {
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
}
// LoadConfig загружает конфигурацию агента из JSON-файла.
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("чтение %s: %w", path, err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("парсинг %s: %w", path, err)
}
// Defaults
if cfg.IntervalSec <= 0 {
cfg.IntervalSec = 5
}
if cfg.MaxCalls <= 0 {
cfg.MaxCalls = 250
}
if cfg.FailureWindow <= 0 {
cfg.FailureWindow = 1000
}
if cfg.ESL.Port == 0 {
cfg.ESL.Port = 8021
}
if cfg.AMI.Port == 0 {
cfg.AMI.Port = 5038
}
return &cfg, nil
}
// Validate проверяет корректность конфигурации.
func (c *Config) Validate() error {
if c.NodeID == "" {
return fmt.Errorf("node_id обязателен")
}
if c.NatsURL == "" {
return fmt.Errorf("nats_url обязателен")
}
switch c.Type {
case "freeswitch":
if c.ESL.Host == "" {
return fmt.Errorf("esl.host обязателен для type=freeswitch")
}
case "asterisk":
if c.AMI.Host == "" {
return fmt.Errorf("ami.host обязателен для type=asterisk")
}
default:
return fmt.Errorf("type должен быть freeswitch или asterisk, получен: %s", c.Type)
}
return nil
}
// GetSIPGateway возвращает SIP-адрес для авто-транка.
// Если SIPGatewayAuto = true — вернётся "" (collector определит сам).
func (c *Config) GetSIPGateway(autoDetected string) string {
if c.SIPGateway != "" {
return c.SIPGateway
}
if c.SIPGatewayAuto {
return autoDetected
}
return ""
}

View File

@ -1,52 +0,0 @@
package agent
import "sync"
// FailureTracker — скользящее окно для расчёта call_failure_rate.
// Хранит последние N результатов звонков (success/failure),
// вычисляет процент неудачных звонков в реальном времени.
type FailureTracker struct {
mu sync.Mutex
window []bool // true=success, false=failure
position int // позиция следующей записи
size int // текущий размер окна
capacity int // максимальный размер окна
}
// NewFailureTracker создаёт трекер с окном заданного размера.
func NewFailureTracker(capacity int) *FailureTracker {
return &FailureTracker{
window: make([]bool, capacity),
capacity: capacity,
}
}
// Record записывает результат звонка (true=success, false=failure).
func (ft *FailureTracker) Record(success bool) {
ft.mu.Lock()
defer ft.mu.Unlock()
ft.window[ft.position] = success
ft.position = (ft.position + 1) % ft.capacity
if ft.size < ft.capacity {
ft.size++
}
}
// Rate возвращает процент неудачных звонков (0.0..100.0).
func (ft *FailureTracker) Rate() float64 {
ft.mu.Lock()
defer ft.mu.Unlock()
if ft.size == 0 {
return 0
}
failures := 0
for i := 0; i < ft.size; i++ {
if !ft.window[i] {
failures++
}
}
return float64(failures) / float64(ft.size) * 100.0
}

View File

@ -1,57 +0,0 @@
package agent
import (
"encoding/json"
"fmt"
"log"
natsgo "github.com/nats-io/nats.go"
"github.com/pulse-lets-go/internal/models"
)
// Publisher публикует метрики в NATS.
type Publisher struct {
nc *natsgo.Conn
nodeID string
}
// NewPublisher создаёт NATS publisher.
func NewPublisher(natsURL, user, password, nodeID string) (*Publisher, error) {
opts := []natsgo.Option{
natsgo.ReconnectWait(natsgo.DefaultReconnectWait),
natsgo.MaxReconnects(-1),
}
if user != "" && password != "" {
opts = append(opts, natsgo.UserInfo(user, password))
}
nc, err := natsgo.Connect(natsURL, opts...)
if err != nil {
return nil, fmt.Errorf("nats connect: %w", err)
}
log.Printf("[publisher] NATS подключён к %s", natsURL)
return &Publisher{nc: nc, nodeID: nodeID}, nil
}
// Publish отправляет метрику в NATS: pulse.metrics.<node_id>.
func (p *Publisher) Publish(m *models.NodeMetric) error {
subject := fmt.Sprintf("pulse.metrics.%s", p.nodeID)
data, err := json.Marshal(m)
if err != nil {
return fmt.Errorf("marshal metric: %w", err)
}
if err := p.nc.Publish(subject, data); err != nil {
return fmt.Errorf("publish: %w", err)
}
return nil
}
// Close закрывает NATS-соединение.
func (p *Publisher) Close() {
if p.nc != nil {
p.nc.Close()
}
}

View File

@ -1,84 +0,0 @@
package agent
import (
"bufio"
"os"
"strconv"
"strings"
"time"
)
// nowTS возвращает текущий unix timestamp.
func nowTS() int64 {
return time.Now().Unix()
}
// SystemStats содержит системные метрики (CPU, load).
type SystemStats struct {
Load1 float64 // load average 1m
Load5 float64 // load average 5m
Load15 float64 // load average 15m
IdleCPU float64 // процент простоя CPU
}
// CollectSystem собирает системные метрики из /proc.
func CollectSystem() *SystemStats {
load1, load5, load15 := loadAvg()
return &SystemStats{
Load1: load1,
Load5: load5,
Load15: load15,
IdleCPU: cpuIdle(),
}
}
// loadAvg читает /proc/loadavg.
func loadAvg() (float64, float64, float64) {
data, err := os.ReadFile("/proc/loadavg")
if err != nil {
return 0, 0, 0
}
parts := strings.Fields(string(data))
if len(parts) < 3 {
return 0, 0, 0
}
load1, _ := strconv.ParseFloat(parts[0], 64)
load5, _ := strconv.ParseFloat(parts[1], 64)
load15, _ := strconv.ParseFloat(parts[2], 64)
return load1, load5, load15
}
// cpuIdle вычисляет процент простоя CPU из /proc/stat.
func cpuIdle() float64 {
file, err := os.Open("/proc/stat")
if err != nil {
return 0
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "cpu ") {
continue
}
fields := strings.Fields(line)
if len(fields) < 8 {
return 0
}
// cpu user nice system idle iowait irq softirq steal ...
var total, idle float64
for i, f := range fields[1:] {
val, _ := strconv.ParseFloat(f, 64)
total += val
if i == 3 || i == 4 { // idle + iowait
idle += val
}
}
if total > 0 {
return (idle / total) * 100.0
}
return 0
}
return 0
}

View File

@ -1,44 +0,0 @@
// pulse-lets-go-agent — агент сбора метрик для pulse-lets-go.
// Работает на каждой PBX-ноде (FreeSWITCH или Asterisk),
// собирает метрики и публикует их в NATS каждые 5 секунд.
//
// Использование:
//
// pulse-lets-go-agent -config /etc/pulse-lets-go-agent/agent.json
//
// Пример agent.json:
//
// {
// "node_id": "uc06",
// "type": "asterisk",
// "nats_url": "nats://10.101.60.81:4222",
// "sip_gateway": "sip:10.101.60.115:5060",
// "interval_sec": 5,
// "max_calls": 150,
// "ami": { "host": "127.0.0.1", "port": 6154, "username": "ctt", "password": "cttpass" }
// }
package main
import (
"flag"
"log"
"github.com/pulse-lets-go/cmd/pulse-lets-go-agent/agent"
)
func main() {
configPath := flag.String("config", "agent.json", "путь к agent.json")
flag.Parse()
cfg, err := agent.LoadConfig(*configPath)
if err != nil {
log.Fatalf("ошибка загрузки конфига: %v", err)
}
a, err := agent.NewAgent(cfg)
if err != nil {
log.Fatalf("ошибка создания агента: %v", err)
}
a.Start()
}

View File

@ -5,8 +5,6 @@ package main
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"net/http"
@ -22,7 +20,6 @@ import (
"github.com/pulse-lets-go/internal/engine"
"github.com/pulse-lets-go/internal/esl"
filelog "github.com/pulse-lets-go/internal/log"
"github.com/pulse-lets-go/internal/models"
"github.com/pulse-lets-go/internal/nats"
)
@ -76,42 +73,6 @@ func main() {
}
defer sub.Close()
// Авто-создание balance-транков при первой метрике от новой ноды
sub.SetOnNewNode(func(nodeID, sipGateway string) {
trunks, err := cfgMgr.ReadTrunks()
if err != nil {
log.Printf("[main] ошибка чтения транков для авто-создания: %v", err)
return
}
// Проверяем идемпотентность: транк уже существует?
for _, t := range trunks {
if t.Type == "balance" && t.NodeID == nodeID {
return
}
}
// Создаём новый balance-транк
now := time.Now().UTC()
trunkID := "trk-" + randomHex(6)
trunk := models.Trunk{
ID: trunkID,
Name: fmt.Sprintf("%s баланс", nodeID),
Type: "balance",
NodeID: nodeID,
Gateway: sipGateway,
Codecs: []string{"PCMU", "PCMA"},
Context: "default",
Enabled: true,
CreatedAt: now,
UpdatedAt: now,
}
trunks = append(trunks, trunk)
if err := cfgMgr.SaveTrunks(trunks); err != nil {
log.Printf("[main] ошибка сохранения авто-созданного транка: %v", err)
return
}
log.Printf("[nats] авто-создан balance-транк: %s → %s (узел: %s)", trunkID, sipGateway, nodeID)
})
// 6. HTTP API
apiHandler := api.NewAPI(eng, cfgMgr, cfg.JWTSecret, cfg.MonitoringAPIKey, sub.IsConnected, cfg)
handler := apiHandler.Handler()
@ -144,26 +105,7 @@ func main() {
eslClient.GatewaySyncAll(cfg.ESL.SofiaProfile, specs)
},
func(eventName string, headers map[string]string, body string) {
gwName := headers["Gateway-Name"]
if gwName == "" {
return
}
// Извлекаем trunk_id из имени gateway: "pulse-ingress-trk-X" → "trk-X"
prefix := cfg.ESL.GatewayPrefix + "-ingress-"
trunkID := strings.TrimPrefix(gwName, prefix)
if trunkID == gwName {
return // префикс не найден, не наш gateway
}
status := "unknown"
switch eventName {
case "SOFIA::gateway_register":
status = "up"
case "SOFIA::gateway_unregister", "SOFIA::gateway_expire":
status = "down"
}
apiHandler.BroadcastGatewayEvent(gwName, trunkID, status)
log.Printf("[esl] событие: %s", eventName)
},
)
cancel()
@ -294,15 +236,6 @@ func withSPA(webDir string, apiHandler http.Handler) http.Handler {
})
}
// randomHex генерирует случайную hex-строку заданной длины.
func randomHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%x", time.Now().UnixNano())[:n]
}
return hex.EncodeToString(b)[:n]
}
// extractHostFromGateway извлекает хост из SIP URI.
// "sip:mts-gw.lan:5060" → "mts-gw.lan"
func extractHostFromGateway(gateway string) string {

View File

@ -1,235 +0,0 @@
// SIP тестер для e2e проверки pulse-lets-go + FreeSWITCH.
// Два режима:
// uas — отвечает 200 OK на SIP INVITE, симулирует PBX-ноду
// uac — шлёт SIP INVITE в FreeSWITCH, симулирует оператора
//
// Примеры:
// ./siptest -mode uas (PBX-нода на порту 5090)
// ./siptest -mode uac -r 10 -l 100 (оператор, 10 CPS, до 100 одновременных)
package main
import (
"crypto/rand"
"encoding/hex"
"flag"
"fmt"
"log"
"net"
"strings"
"sync"
"sync/atomic"
"time"
)
const (
uasPort = 5090 // порт PBX-ноды (принимает bridged вызовы)
uacPort = 5061 // порт оператора (шлёт INVITE в FS)
fsPort = 5080 // порт FreeSWITCH (external profile — для транков)
)
// Статистика
var (
callsSent atomic.Int64
callsOK atomic.Int64
callsFailed atomic.Int64
)
func main() {
mode := flag.String("mode", "uas", "uas | uac")
rate := flag.Int("r", 10, "CPS (для uac)")
limit := flag.Int("l", 100, "максимум одновременных (для uac)")
maxCalls := flag.Int("m", 1000, "всего вызовов (для uac)")
host := flag.String("host", "127.0.0.1", "FS host/IP")
dest := flag.String("dest", "88005553535", "dest номер")
caller := flag.String("caller", "74951234567", "caller_id")
flag.Parse()
log.Printf("[siptest] режим=%s", *mode)
switch *mode {
case "uas":
runUAS()
case "uac":
runUAC(*rate, *limit, *maxCalls, *dest, *caller, *host)
default:
log.Fatalf("неизвестный режим: %s", *mode)
}
}
// ── UAS: PBX-нода (отвечает 200 OK на INVITE) ────────────────
func runUAS() {
addr := fmt.Sprintf("127.0.0.1:%d", uasPort)
conn, err := net.ListenPacket("udp", addr)
if err != nil {
log.Fatalf("UAS listen %s: %v", addr, err)
}
defer conn.Close()
log.Printf("[uas] PBX-нода слушает %s", addr)
buf := make([]byte, 4096)
for {
n, remoteAddr, err := conn.ReadFrom(buf)
if err != nil {
log.Printf("[uas] ошибка чтения: %v", err)
continue
}
msg := string(buf[:n])
if strings.HasPrefix(msg, "INVITE") || strings.Contains(msg, "INVITE") {
response := buildSIPResponse(msg, addr)
time.Sleep(50 * time.Millisecond) // имитация обработки вызова
if _, err := conn.WriteTo([]byte(response), remoteAddr); err != nil {
log.Printf("[uas] ошибка ответа: %v", err)
}
log.Printf("[uas] INVITE отвечен: %v", remoteAddr)
}
// ACK и BYE игнорируем
if strings.Contains(msg, "ACK") || strings.Contains(msg, "BYE") {
continue
}
}
}
func buildSIPResponse(invite, localAddr string) string {
via := extractHeader(invite, "Via:")
from := extractHeader(invite, "From:")
to := extractHeader(invite, "To:")
callID := extractHeader(invite, "Call-ID:")
cseq := extractHeader(invite, "CSeq:")
// Добавляем tag к To если его нет
to = strings.TrimSpace(to)
if !strings.Contains(to, ";tag=") {
to += ";tag=uas-" + randomHex(4)
}
return fmt.Sprintf("SIP/2.0 200 OK\r\n"+
"%s\r\n"+
"%s\r\n"+
"%s\r\n"+
"%s\r\n"+
"%s\r\n"+
"Contact: <sip:pbx@%s>\r\n"+
"Content-Length: 0\r\n"+
"\r\n",
via, from, to, callID, cseq, localAddr)
}
func extractHeader(msg, prefix string) string {
lines := strings.Split(msg, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
line = strings.TrimRight(line, "\r")
if strings.HasPrefix(line, prefix) {
return line
}
}
return prefix
}
// ── UAC: Оператор (шлёт INVITE в FS) ─────────────────────────
func runUAC(rate, limit, maxCalls int, dest, caller, host string) {
fsAddr := fmt.Sprintf("%s:%d", host, fsPort)
srcAddr := fmt.Sprintf("127.0.0.1:%d", uacPort)
localAddr, err := net.ResolveUDPAddr("udp", srcAddr)
if err != nil {
log.Fatalf("resolve %s: %v", srcAddr, err)
}
remoteAddr, err := net.ResolveUDPAddr("udp", fsAddr)
if err != nil {
log.Fatalf("resolve %s: %v", fsAddr, err)
}
conn, err := net.ListenUDP("udp", localAddr)
if err != nil {
log.Fatalf("listen %s: %v", srcAddr, err)
}
defer conn.Close()
log.Printf("[uac] оператор %s → FS %s (rate=%d/s, limit=%d, total=%d)", srcAddr, fsAddr, rate, limit, maxCalls)
sem := make(chan struct{}, limit) // ограничение одновременных
var wg sync.WaitGroup
ticker := time.NewTicker(time.Second / time.Duration(rate))
defer ticker.Stop()
// Читаем ответы от FS
go func() {
buf := make([]byte, 4096)
for {
n, _, err := conn.ReadFrom(buf)
if err != nil {
return
}
msg := string(buf[:n])
if strings.Contains(msg, "200 OK") {
callsOK.Add(1)
}
}
}()
startTime := time.Now()
callNum := 0
for callNum < maxCalls {
select {
case <-ticker.C:
sem <- struct{}{} // занимаем слот
wg.Add(1)
callNum++
go func(num int) {
defer wg.Done()
defer func() { <-sem }() // освобождаем слот
invite := buildSIPInvite(dest, caller, num, srcAddr, fsAddr)
_, err := conn.WriteTo([]byte(invite), remoteAddr)
callsSent.Add(1)
if err != nil {
callsFailed.Add(1)
return
}
}(callNum)
}
}
wg.Wait()
elapsed := time.Since(startTime)
totalCalls := callsSent.Load()
okCalls := callsOK.Load()
log.Printf("[uac] ================ ИТОГИ ================")
log.Printf(" Всего вызовов: %d", totalCalls)
log.Printf(" Успешно (200 OK): %d", okCalls)
log.Printf(" CPS (факт): %.1f", float64(totalCalls)/elapsed.Seconds())
log.Printf(" Длительность: %v", elapsed.Round(time.Millisecond))
}
func buildSIPInvite(dest, caller string, callNum int, srcAddr, fsAddr string) string {
callID := fmt.Sprintf("call-%d-%s@%s", callNum, randomHex(4), "127.0.0.1")
branch := "z9hG4bK-" + randomHex(8)
return fmt.Sprintf("INVITE sip:%s@%s SIP/2.0\r\n"+
"Via: SIP/2.0/UDP %s;branch=%s\r\n"+
"From: <sip:%s@127.0.0.1>;tag=caller-%d\r\n"+
"To: <sip:%s@127.0.0.1>\r\n"+
"Call-ID: %s\r\n"+
"CSeq: 1 INVITE\r\n"+
"Contact: <sip:%s@%s>\r\n"+
"Content-Length: 0\r\n"+
"\r\n",
dest, fsAddr, srcAddr, branch, caller, callNum, dest, callID, caller, srcAddr)
}
func randomHex(n int) string {
b := make([]byte, n)
rand.Read(b)
return hex.EncodeToString(b)[:n]
}

View File

@ -1,15 +0,0 @@
{
"node_id": "ses-sip",
"type": "freeswitch",
"nats_url": "nats://10.101.60.81:4222",
"interval_sec": 5,
"max_calls": 250,
"sip_gateway": "sip:10.3.0.44:5060",
"sip_gateway_auto": false,
"failure_window": 1000,
"esl": {
"host": "127.0.0.1",
"port": 8021,
"password": "ClueCon"
}
}

View File

@ -1,16 +0,0 @@
{
"node_id": "uc06",
"type": "asterisk",
"nats_url": "nats://10.101.60.81:4222",
"interval_sec": 5,
"max_calls": 150,
"sip_gateway": "sip:10.101.60.115:5060",
"sip_gateway_auto": false,
"failure_window": 1000,
"ami": {
"host": "127.0.0.1",
"port": 6154,
"username": "ctt",
"password": "cttpass"
}
}

View File

@ -1,293 +0,0 @@
// Package ami реализует клиент Asterisk Manager Interface (AMI).
// Использует сырой TCP — протокол AMI текстовый (Key: Value\r\n),
// аналогичен ESL по простоте, не нужна внешняя библиотека.
//
// Клиент обеспечивает:
// - Подключение и аутентификацию (Action: Login)
// - Синхронные команды (Action: Command)
// - Подписку на события (Action: Events)
// - Автоматический реконнект с exponential backoff
// - Потокобезопасность (mutex на отправку команд)
package ami
import (
"bufio"
"fmt"
"io"
"log"
"net"
"strings"
"sync"
"sync/atomic"
"time"
)
// Event — событие, полученное от Asterisk AMI.
type Event struct {
Headers map[string]string // все заголовки события (Event:, Channel:, Cause:, ...)
}
// Client — AMI-клиент для взаимодействия с Asterisk.
type Client struct {
host string
port int
username string
password string
conn net.Conn
reader *bufio.Reader
sendMu sync.Mutex // защищает запись в сокет
connected atomic.Bool
eventsCh chan *Event
closeCh chan struct{}
shouldReconn atomic.Bool
backoff time.Duration
}
// NewClient создаёт нового AMI-клиента.
func NewClient(host string, port int, username, password string) *Client {
return &Client{
host: host,
port: port,
username: username,
password: password,
eventsCh: make(chan *Event, 100),
closeCh: make(chan struct{}),
backoff: 1 * time.Second,
}
}
// Connect подключается к Asterisk AMI и выполняет аутентификацию.
func (c *Client) Connect() error {
c.shouldReconn.Store(true)
addr := fmt.Sprintf("%s:%d", c.host, c.port)
dialer := net.Dialer{Timeout: 5 * time.Second}
conn, err := dialer.Dial("tcp", addr)
if err != nil {
return fmt.Errorf("ami dial %s: %w", addr, err)
}
c.conn = conn
c.reader = bufio.NewReader(conn)
// 1. Читаем приветственное сообщение Asterisk (одна строка, без пустой строки)
line, err := c.reader.ReadString('\n')
if err != nil {
conn.Close()
return fmt.Errorf("чтение приветствия AMI: %w", err)
}
_ = line // "Asterisk Call Manager/5.0.5\r\n"
// 2. Отправляем логин
loginCmd := fmt.Sprintf("Action: Login\r\nUsername: %s\r\nSecret: %s\r\n\r\n", c.username, c.password)
if _, err := conn.Write([]byte(loginCmd)); err != nil {
conn.Close()
return fmt.Errorf("отправка Login: %w", err)
}
// 3. Читаем ответ
headers, err := c.readResponse()
if err != nil {
conn.Close()
return fmt.Errorf("чтение ответа Login: %w", err)
}
if resp := headers["Response"]; resp != "Success" {
conn.Close()
return fmt.Errorf("ami auth rejected: %s", headers["Message"])
}
c.connected.Store(true)
c.backoff = 1 * time.Second
log.Printf("[ami] подключён к %s", addr)
return nil
}
// Disconnect отключает клиент.
func (c *Client) Disconnect() {
c.shouldReconn.Store(false)
c.connected.Store(false)
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
select {
case <-c.closeCh:
default:
close(c.closeCh)
}
}
// IsConnected возвращает статус подключения.
func (c *Client) IsConnected() bool {
return c.connected.Load()
}
// SendCommand отправляет синхронную команду и возвращает заголовки ответа.
func (c *Client) SendCommand(cmd string) (map[string]string, error) {
c.sendMu.Lock()
defer c.sendMu.Unlock()
if !c.connected.Load() || c.conn == nil {
return nil, fmt.Errorf("ami не подключён")
}
fullCmd := cmd + "\r\n\r\n"
if _, err := c.conn.Write([]byte(fullCmd)); err != nil {
return nil, fmt.Errorf("ami send: %w", err)
}
return c.readResponse()
}
// SendCommandBody отправляет команду и возвращает заголовки + тело ответа.
// Нужно для команд, возвращающих многострочный вывод (напр. Command).
func (c *Client) SendCommandBody(cmd string) (map[string]string, string, error) {
c.sendMu.Lock()
defer c.sendMu.Unlock()
if !c.connected.Load() || c.conn == nil {
return nil, "", fmt.Errorf("ami не подключён")
}
fullCmd := cmd + "\r\n\r\n"
if _, err := c.conn.Write([]byte(fullCmd)); err != nil {
return nil, "", fmt.Errorf("ami send: %w", err)
}
// Читаем заголовки до пустой строки
headers, err := c.readResponse()
if err != nil {
return nil, "", fmt.Errorf("ami read headers: %w", err)
}
// Если это ответ на Command — читаем тело до --END COMMAND--
if headers["Response"] == "Follows" {
var bodyLines []string
for {
line, err := c.reader.ReadString('\n')
if err != nil {
break
}
line = strings.TrimRight(line, "\r\n")
if line == "--END COMMAND--" {
break
}
bodyLines = append(bodyLines, line)
}
return headers, strings.Join(bodyLines, "\n"), nil
}
return headers, "", nil
}
// Subscribe подписывается на события AMI.
func (c *Client) Subscribe(eventMask string) error {
cmd := fmt.Sprintf("Action: Events\r\nEventMask: %s\r\n", eventMask)
_, _, err := c.SendCommandBody(cmd)
if err != nil {
return fmt.Errorf("подписка на события: %w", err)
}
log.Printf("[ami] подписан на события: %s", eventMask)
return nil
}
// Events возвращает канал с входящими событиями.
func (c *Client) Events() <-chan *Event {
return c.eventsCh
}
// StartReadingEvents запускает горутину чтения асинхронных событий.
func (c *Client) StartReadingEvents() {
go c.readEventsLoop()
}
// ConnectWithRetry подключается в фоне с бесконечными попытками.
func (c *Client) ConnectWithRetry() {
c.shouldReconn.Store(true)
for {
if err := c.Connect(); err == nil {
return
}
if !c.shouldReconn.Load() {
return
}
log.Printf("[ami] ошибка подключения, повтор через %v", c.backoff)
time.Sleep(c.backoff)
c.backoff *= 2
if c.backoff > 60*time.Second {
c.backoff = 60 * time.Second
}
}
}
// --- Приватные методы ---
// readResponse читает один AMI-ответ: заголовки Key: Value до пустой строки.
func (c *Client) readResponse() (map[string]string, error) {
headers := make(map[string]string)
for {
line, err := c.reader.ReadString('\n')
if err != nil {
if err == io.EOF {
return headers, nil
}
return headers, err
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
return headers, nil
}
parts := strings.SplitN(line, ": ", 2)
if len(parts) == 2 {
headers[parts[0]] = parts[1]
}
}
}
// readEventsLoop читает асинхронные события от Asterisk.
func (c *Client) readEventsLoop() {
for {
select {
case <-c.closeCh:
return
default:
}
if !c.connected.Load() || c.conn == nil {
time.Sleep(500 * time.Millisecond)
continue
}
headers, err := c.readResponse()
if err != nil {
if err == io.EOF || strings.Contains(err.Error(), "use of closed network connection") {
c.connected.Store(false)
log.Printf("[ami] соединение разорвано")
if c.shouldReconn.Load() {
go c.ConnectWithRetry()
}
return
}
time.Sleep(100 * time.Millisecond)
continue
}
eventName := headers["Event"]
if eventName == "" {
continue
}
evt := &Event{Headers: headers}
select {
case c.eventsCh <- evt:
default:
log.Printf("[ami] канал событий переполнен, пропускаем %s", eventName)
}
}
}

View File

@ -8,7 +8,6 @@ import (
"github.com/pulse-lets-go/internal/config"
"github.com/pulse-lets-go/internal/engine"
"github.com/pulse-lets-go/internal/esl"
"github.com/pulse-lets-go/internal/models"
)
// API — главная структура HTTP API, агрегирует все зависимости.
@ -128,19 +127,6 @@ func corsMiddleware(next http.Handler) http.Handler {
})
}
// BroadcastGatewayEvent транслирует статус gateway всем WS-клиентам.
func (a *API) BroadcastGatewayEvent(gatewayName, trunkID, status string) {
msg := &models.WsMetricsMessage{
Type: "gateway_update",
Payload: map[string]string{
"gateway": gatewayName,
"trunk_id": trunkID,
"status": status,
},
}
a.wsHub.broadcast(msg)
}
// decodeJSON декодирует тело запроса в структуру v.
func decodeJSON(r *http.Request, v interface{}) error {
defer r.Body.Close()

View File

@ -144,14 +144,6 @@ func (e *Engine) GetNodeHistory(nodeID string) []NodeSnapshot {
return ring.snapshot()
}
// HasNode возвращает true, если нода уже зарегистрирована в engine.
func (e *Engine) HasNode(nodeID string) bool {
e.mu.RLock()
defer e.mu.RUnlock()
_, exists := e.nodes[nodeID]
return exists
}
// ToggleNode включает/выключает ноду из распределения.
func (e *Engine) ToggleNode(nodeID string, disabled bool, reason string) error {
e.mu.Lock()

View File

@ -36,10 +36,9 @@ type Client struct {
port int
password string
conn net.Conn
reader *bufio.Reader
mu sync.Mutex // защищает отправку синхронных команд (auth, subscribe)
sendMu sync.Mutex // защищает запись в сокет (async gateway commands)
conn net.Conn
reader *bufio.Reader
mu sync.Mutex // защищает отправку команд
connected atomic.Bool
eventsCh chan *Event
@ -50,10 +49,10 @@ type Client struct {
backoff time.Duration
// Статистика
reconnects atomic.Int64
gatewayOps atomic.Int64
eventsRecv atomic.Int64
startTime time.Time
reconnects atomic.Int64
gatewayOps atomic.Int64
eventsRecv atomic.Int64
startTime time.Time
}
// NewClient создаёт нового ESL-клиента.
@ -161,19 +160,13 @@ func (c *Client) tryConnect() error {
func (c *Client) Disconnect() {
c.shouldReconn.Store(false)
c.mu.Lock()
defer c.mu.Unlock()
c.connected.Store(false)
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
c.mu.Unlock()
// Безопасное закрытие closeCh (может быть уже закрыт)
select {
case <-c.closeCh:
// уже закрыт
default:
close(c.closeCh)
}
close(c.closeCh)
}
// IsConnected возвращает статус подключения.
@ -228,28 +221,7 @@ func (c *Client) StartReadingEvents() {
go c.readEventsLoop()
}
// SendAsync отправляет асинхронную команду (bgapi) без ожидания ответа.
// Ответ придёт как BACKGROUND_JOB событие в readEventsLoop.
// Использует sendMu для защиты записи в сокет.
func (c *Client) SendAsync(command string) error {
c.sendMu.Lock()
defer c.sendMu.Unlock()
if !c.connected.Load() || c.conn == nil {
return fmt.Errorf("esl не подключён")
}
fullCmd := command + "\n\n"
_, err := c.conn.Write([]byte(fullCmd))
if err != nil {
return fmt.Errorf("отправка команды: %w", err)
}
return nil
}
// readEventsLoop читает асинхронные сообщения (события) от FS.
// Не держит мьютекс во время блокирующего чтения — синхронные команды
// (Send) используют c.mu, асинхронные (SendAsync) используют sendMu.
func (c *Client) readEventsLoop() {
for {
select {
@ -263,7 +235,9 @@ func (c *Client) readEventsLoop() {
continue
}
headers, body, err := c.readMessageUnlocked()
c.mu.Lock()
headers, body, err := c.readMessageLocked()
c.mu.Unlock()
if err != nil {
if err == io.EOF || strings.Contains(err.Error(), "use of closed network connection") {
@ -330,47 +304,8 @@ func (c *Client) reconnect() {
// --- Приватные методы ---
// readMessageUnlocked читает одно ESL-сообщение: заголовки + тело.
// Должна вызываться только из readEventsLoop (одиночный читатель).
func (c *Client) readMessageUnlocked() (map[string]string, string, error) {
headers := make(map[string]string)
for {
line, err := c.reader.ReadString('\n')
if err != nil {
return nil, "", err
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break // конец заголовков
}
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
headers[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
}
// Читаем тело согласно Content-Length
cl := headers["Content-Length"]
if cl == "" {
return headers, "", nil
}
length, err := strconv.Atoi(cl)
if err != nil {
return headers, "", fmt.Errorf("некорректный Content-Length: %s", cl)
}
body := make([]byte, length)
if _, err := io.ReadFull(c.reader, body); err != nil {
return headers, "", fmt.Errorf("чтение тела (Content-Length=%d): %w", length, err)
}
return headers, strings.TrimSpace(string(body)), nil
}
// readMessageLocked читает одно ESL-сообщение: заголовки + тело.
// Вызывающий держит c.mu (для синхронных команд Send).
// Вызывающий держит c.mu.
func (c *Client) readMessageLocked() (map[string]string, string, error) {
headers := make(map[string]string)

View File

@ -11,7 +11,6 @@ import (
type EventHandler func(eventName string, headers map[string]string, body string)
// StartEventLoop запускает основной цикл реконнекта и чтения событий.
// Блокирует до тех пор, пока контекст не будет отменён (ctx.Done()).
// При успешном подключении:
// - Вызывает onConnect (например, для GatewaySyncAll)
// - Подписывается на события SOFIA::gateway_register/unregister
@ -20,70 +19,73 @@ type EventHandler func(eventName string, headers map[string]string, body string)
func (c *Client) StartEventLoop(ctx context.Context, profile string, onConnect func(), onEvent EventHandler) {
const subscribeEvents = "SOFIA::gateway_register SOFIA::gateway_unregister SOFIA::gateway_expire"
for {
select {
case <-ctx.Done():
c.Disconnect()
return
default:
}
// Пытаемся подключиться
log.Printf("[esl] попытка подключения к %s:%d", c.host, c.port)
if err := c.Connect(); err != nil {
log.Printf("[esl] ошибка подключения: %v, повтор через %v", err, c.backoff)
time.Sleep(c.backoff)
c.backoff *= 2
if c.backoff > 60*time.Second {
c.backoff = 60 * time.Second
}
continue
}
// Успешное подключение
c.backoff = 1 * time.Second
// Подписываемся на события
if err := c.Subscribe(subscribeEvents); err != nil {
log.Printf("[esl] ошибка подписки: %v", err)
c.Disconnect()
continue
}
// Вызываем callback (GatewaySyncAll)
if onConnect != nil {
onConnect()
}
// Читаем события пока соединение живо
readEvents := make(chan struct{})
go func() {
defer close(readEvents)
c.readEventsLoop()
}()
// Основной цикл обработки событий
go func() {
for {
select {
case <-ctx.Done():
c.Disconnect()
return
case <-readEvents:
// Соединение разорвалось — идём на реконнект
log.Printf("[esl] чтение событий остановлено")
goto reconnect
case evt, ok := <-c.Events():
if !ok {
goto reconnect
default:
}
// Пытаемся подключиться
log.Printf("[esl] попытка подключения к %s:%d", c.host, c.port)
if err := c.Connect(); err != nil {
log.Printf("[esl] ошибка подключения: %v, повтор через %v", err, c.backoff)
time.Sleep(c.backoff)
c.backoff *= 2
if c.backoff > 60*time.Second {
c.backoff = 60 * time.Second
}
if onEvent != nil {
onEvent(evt.Name, evt.Headers, evt.Body)
continue
}
// Успешное подключение
c.backoff = 1 * time.Second
// Подписываемся на события
if err := c.Subscribe(subscribeEvents); err != nil {
log.Printf("[esl] ошибка подписки: %v", err)
c.Disconnect()
continue
}
// Вызываем callback (GatewaySyncAll)
if onConnect != nil {
onConnect()
}
// Читаем события пока соединение живо
readEvents := make(chan struct{})
go func() {
defer close(readEvents)
c.readEventsLoop()
// Не ждём реконнект внутри readEventsLoop
}()
// Основной цикл обработки событий
for {
select {
case <-ctx.Done():
c.Disconnect()
return
case <-readEvents:
// Соединение разорвалось — идём на реконнект
log.Printf("[esl] чтение событий остановлено")
goto reconnect
case evt, ok := <-c.Events():
if !ok {
goto reconnect
}
if onEvent != nil {
onEvent(evt.Name, evt.Headers, evt.Body)
}
}
}
}
reconnect:
c.Disconnect()
time.Sleep(c.backoff)
}
reconnect:
c.Disconnect()
time.Sleep(c.backoff)
}
}()
}

View File

@ -7,29 +7,59 @@ import (
)
// GatewayAdd создаёт gateway на FreeSWITCH через Sofia профиль.
// Использует bgapi — асинхронная команда, результат приходит как BACKGROUND_JOB событие.
// Возвращает nil если gateway создан или уже существует (идемпотентность).
func (c *Client) GatewayAdd(profile, name, proxy string) error {
cmd := fmt.Sprintf("bgapi sofia profile %s gwadd %s %s", profile, name, proxy)
if err := c.SendAsync(cmd); err != nil {
log.Printf("[esl] GatewayAdd %s: ошибка: %v", name, err)
cmd := fmt.Sprintf("api sofia profile %s gwadd %s %s", profile, name, proxy)
headers, body, err := c.Send(cmd)
c.IncGatewayOps()
if err != nil {
log.Printf("[esl] GatewayAdd %s: ошибка команды: %v", name, err)
return err
}
c.IncGatewayOps()
log.Printf("[esl] GatewayAdd %s: отправлен (%s → %s)", name, profile, proxy)
return nil
reply := headers["Reply-Text"]
if strings.HasPrefix(reply, "+OK") {
log.Printf("[esl] GatewayAdd %s: создан (%s → %s)", name, profile, proxy)
return nil
}
// Gateway уже существует — не ошибка
if strings.Contains(body, "GATEWAY ALREADY") || strings.Contains(reply, "GATEWAY ALREADY") {
log.Printf("[esl] GatewayAdd %s: уже существует, ок", name)
return nil
}
log.Printf("[esl] GatewayAdd %s: ошибка FS — Reply: %s, Body: %s", name, reply, body)
return fmt.Errorf("GatewayAdd %s: %s (%s)", name, reply, body)
}
// GatewayDelete удаляет gateway на FreeSWITCH.
// Использует bgapi — асинхронная команда, результат приходит как BACKGROUND_JOB событие.
// Возвращает nil если gateway удалён или не найден (идемпотентность).
func (c *Client) GatewayDelete(profile, name string) error {
cmd := fmt.Sprintf("bgapi sofia profile %s gwdel %s", profile, name)
if err := c.SendAsync(cmd); err != nil {
log.Printf("[esl] GatewayDelete %s: ошибка: %v", name, err)
cmd := fmt.Sprintf("api sofia profile %s gwdel %s", profile, name)
headers, body, err := c.Send(cmd)
c.IncGatewayOps()
if err != nil {
log.Printf("[esl] GatewayDelete %s: ошибка команды: %v", name, err)
return err
}
c.IncGatewayOps()
log.Printf("[esl] GatewayDelete %s: отправлен", name)
return nil
reply := headers["Reply-Text"]
if strings.HasPrefix(reply, "+OK") {
log.Printf("[esl] GatewayDelete %s: удалён", name)
return nil
}
// Gateway не найден — не ошибка
if strings.Contains(body, "GATEWAY NOT FOUND") || strings.Contains(reply, "GATEWAY NOT FOUND") {
log.Printf("[esl] GatewayDelete %s: не найден, ок", name)
return nil
}
log.Printf("[esl] GatewayDelete %s: ошибка FS — Reply: %s, Body: %s", name, reply, body)
return fmt.Errorf("GatewayDelete %s: %s (%s)", name, reply, body)
}
// GatewayList возвращает список имён gateway в заданном профиле.

View File

@ -15,7 +15,6 @@ type NodeMetric struct {
IdleCPU float64 `json:"idle_cpu"` // 0..100 %
LoadAvg float64 `json:"load_avg"` // system load average (1m)
CallFailureRate float64 `json:"call_failure_rate"` // 0.0..100.0 %
SIPGateway string `json:"sip_gateway,omitempty"` // SIP-адрес ноды для авто-создания транка
}
// --- In-memory состояние ноды в engine ---

View File

@ -18,12 +18,11 @@ const subject = "pulse.metrics.>"
// Subscriber подписывается на NATS и обновляет engine + пишет лог.
type Subscriber struct {
nc *natsgo.Conn
sub *natsgo.Subscription
engine *engine.Engine
logger *filelog.Logger
onNewNode func(nodeID, sipGateway string) // вызывается при первой метрике от новой ноды
wg sync.WaitGroup
nc *natsgo.Conn
sub *natsgo.Subscription
engine *engine.Engine
logger *filelog.Logger
wg sync.WaitGroup
}
// NewSubscriber подключается к NATS и запускает подписку.
@ -58,11 +57,6 @@ func NewSubscriber(natsURL, user, password string, eng *engine.Engine, l *filelo
return s, nil
}
// SetOnNewNode устанавливает callback, вызываемый при первой метрике от новой ноды.
func (s *Subscriber) SetOnNewNode(fn func(nodeID, sipGateway string)) {
s.onNewNode = fn
}
// handleMetric — обработчик входящих сообщений NATS.
func (s *Subscriber) handleMetric(msg *natsgo.Msg) {
var metric models.NodeMetric
@ -80,16 +74,8 @@ func (s *Subscriber) handleMetric(msg *natsgo.Msg) {
metric.MaxCalls = 250 // разумное значение по умолчанию
}
// Проверка: это новая нода с sip_gateway?
isNew := !s.engine.HasNode(metric.NodeID)
ns := s.engine.UpdateMetric(&metric)
s.logger.Log(ns)
// Авто-создание balance-транка для новой ноды
if isNew && metric.SIPGateway != "" && s.onNewNode != nil {
s.onNewNode(metric.NodeID, metric.SIPGateway)
}
}
// IsConnected возвращает true, если NATS-соединение активно.

View File

@ -1,18 +1,14 @@
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { onMount, onDestroy } from 'svelte';
import { isAuthenticated, getTrunks, createTrunk, updateTrunk, deleteTrunk, connectWebSocket, type Trunk } from '$lib/api';
import { onMount } from 'svelte';
import { isAuthenticated, getTrunks, createTrunk, updateTrunk, deleteTrunk, type Trunk } from '$lib/api';
import { toast } from 'svelte-sonner';
import { Plus, Trash2, Save, X, Cable } from 'lucide-svelte';
let trunks: Trunk[] = [];
let loading = true;
// Gateway статусы с ESLa (up/down/unknown)
let ws: WebSocket | null = null;
let gatewayStatuses: Record<string, string> = {};
// Edit mode
let editing: string | null = null; // trunk ID being edited
let editData: Partial<Trunk> = {};
@ -28,19 +24,7 @@
if (browser && !isAuthenticated()) { goto('/login'); }
onMount(() => {
loadTrunks();
ws = connectWebSocket((msg) => {
if (msg.type === 'gateway_update') {
const { trunk_id, status } = msg.payload;
gatewayStatuses = { ...gatewayStatuses, [trunk_id]: status };
}
});
});
onDestroy(() => {
ws?.close();
});
onMount(loadTrunks);
async function loadTrunks() {
loading = true;
@ -118,13 +102,6 @@
}
}
function gatewayIndicator(trunkID: string): string {
const s = gatewayStatuses[trunkID] || 'unknown';
if (s === 'up') return '🟢';
if (s === 'down') return '🔴';
return '⚪';
}
function onFilterChange(e: Event) {
typeFilter = (e.target as HTMLSelectElement).value;
loadTrunks();
@ -231,11 +208,7 @@
<tr class="border-t hover:bg-secondary/50">
<td class="px-4 py-3 font-medium">{t.name}</td>
<td class="px-4 py-3"><span class="px-2 py-1 rounded text-xs font-medium {typeBadge(t.type)}">{typeLabel(t.type)}</span></td>
<td class="px-4 py-3 font-mono text-xs">
{t.gateway}
{#if t.type === 'ingress'}
<span class="ml-2" title="Gateway: {gatewayStatuses[t.id] || 'unknown'}">{gatewayIndicator(t.id)}</span>
{/if}
<td class="px-4 py-3 font-mono text-xs">{t.gateway}</td>
<td class="px-4 py-3 font-mono text-xs">{t.node_id || '—'}</td>
<td class="px-4 py-3">{t.enabled ? '🟢 Вкл' : '🔴 Выкл'}</td>
<td class="px-4 py-3 text-right">