pulse-lets-go/README.md
Maksim Totmin fd949ddaed chore: emulator sip_gateway, Makefile targets, full README documentation
- Emulator: add sip_gateway field to all scenarios (auto-trunk creation)
- Makefile: build-agent, build-siptest, emulate-*, siptest-* targets
- README: full documentation with agent section, production deployment
- Project structure update with all components
2026-06-25 16:59:17 +07:00

31 KiB
Raw Blame History

Pulse Lets Go

Go License NATS SvelteKit

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. Сборка

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 (упрощённо)
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:

<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
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:

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 — route.lua в FS scripts

sudo mkdir -p /etc/freeswitch/scripts
sudo cp /tmp/route.lua /etc/freeswitch/scripts/

Шаг 4 — 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"

Шаг 5 — Маппинг существующих FS gateway в pulse-lets-go транки

# Получить список 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

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 — Проверка

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 nats.service
Wants=nats.service

[Service]
Type=simple
User=root
ExecStart=/usr/local/bin/pulse-lets-go-agent -config /etc/pulse-lets-go-agent/agent.json
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

Verification

# На 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)