feat: CID-based routing rules engine with flexible match conditions

Routing rules:
- RouteRule model: 3 match fields (caller_id, dest, ingress_trunk regex),
  3 action types (node/pool/auto), time-based (days of week, time range)
- Engine router.go: CompiledRule with pre-compiled regex,
  PickNodeForCall with cascade (dead node → next rule), pickNodeFromPool
- Separate sync.RWMutex for Router — zero contention admin vs traffic
- Validate-before-save pattern: engine validates before JSON save
- Graceful degradation: corrupted rules skipped, no rules → PickNode()

Persistence:
- data/routing.json with atomic save (tmp→rename), config.Manager
- Auto-created empty on first start

API (admin only):
- GET/POST/PUT/DELETE /api/routing/rules
- PUT /api/routing/rules/:id/toggle
- PUT /api/routing/rules/reorder

Integration:
- /api/route now uses PickNodeForCall with caller_id/dest/ingress_trunk
  query params (already sent by route.lua)
- RouteResponse includes matched_rule field for debugging
- Main.go loads routing rules on startup with graceful fallback

Web UI:
- /routing page: table with priority arrows, inline edit, create form,
  regex test tool, toggle, delete, action badges, hits counter

Tests:
- 28 tests: matchRule, time matching, PickNodeForCall cascade,
  pool scoring, validation, hit counting, compilation
This commit is contained in:
Maksim Totmin
2026-06-25 21:51:37 +07:00
parent 2d9d179b0a
commit a7722af29e
13 changed files with 1928 additions and 16 deletions
+18 -9
View File
@@ -7,16 +7,23 @@ import (
)
// handleRoute — GET /api/route — главный эндпоинт маршрутизации вызова.
// Принимает query-параметры: caller_id, dest, ingress_trunk (передаются из route.lua).
// Использует routing rules engine (PickNodeForCall) с priority-based first-match разрешением.
func (a *API) handleRoute(w http.ResponseWriter, r *http.Request) {
a.engine.IncrementRouteRequests()
nodeID, score, fallback := a.engine.PickNode()
callerID := r.URL.Query().Get("caller_id")
dest := r.URL.Query().Get("dest")
ingressTrunk := r.URL.Query().Get("ingress_trunk")
nodeID, score, fallback, matchedRule := a.engine.PickNodeForCall(callerID, dest, ingressTrunk)
// Нет зарегистрированных нод
if nodeID == "" {
a.engine.IncrementRouteFallbacks()
writeJSON(w, http.StatusServiceUnavailable, models.RouteResponse{
Error: "no_nodes_registered",
Error: "no_nodes_registered",
MatchedRule: matchedRule,
})
return
}
@@ -28,10 +35,11 @@ func (a *API) handleRoute(w http.ResponseWriter, r *http.Request) {
nodes := a.getRouteNodeInfo()
writeJSON(w, http.StatusOK, models.RouteResponse{
Fallback: true,
SIPGateway: fallbackGW,
Reason: "all_nodes_unhealthy",
Nodes: nodes,
Fallback: true,
SIPGateway: fallbackGW,
Reason: "all_nodes_unhealthy",
Nodes: nodes,
MatchedRule: matchedRule,
})
return
}
@@ -44,9 +52,10 @@ func (a *API) handleRoute(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, models.RouteResponse{
NodeID: nodeID,
Score: score,
SIPGateway: gw,
NodeID: nodeID,
Score: score,
SIPGateway: gw,
MatchedRule: matchedRule,
})
}
+9
View File
@@ -103,6 +103,15 @@ func (a *API) Handler() http.Handler {
mux.Handle("PUT /api/users/{id}", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleUpdateUser))))
mux.Handle("DELETE /api/users/{id}", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleDeleteUser))))
// --- Админка: Routing Rules (admin + viewer for GET, admin only for write) ---
mux.Handle("GET /api/routing/rules", a.authMiddleware(http.HandlerFunc(a.handleGetRoutingRules)))
mux.Handle("POST /api/routing/rules", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleCreateRoutingRule))))
mux.Handle("PUT /api/routing/rules/{id}", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleUpdateRoutingRule))))
mux.Handle("DELETE /api/routing/rules/{id}", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleDeleteRoutingRule))))
mux.Handle("PUT /api/routing/rules/{id}/toggle", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleToggleRoutingRule))))
mux.Handle("PUT /api/routing/rules/reorder", a.authMiddleware(a.adminOnly(http.HandlerFunc(a.handleReorderRoutingRules))))
// Логирование + CORS (навешиваем после mux)
var wrapped http.Handler = mux
wrapped = requestLoggingMiddleware(a.logFormat)(wrapped)
+317
View File
@@ -0,0 +1,317 @@
package api
import (
"fmt"
"net/http"
"sort"
"time"
"github.com/pulse-lets-go/internal/models"
"github.com/pulse-lets-go/internal/util"
)
// handleGetRoutingRules — GET /api/routing/rules — список всех правил с hits.
func (a *API) handleGetRoutingRules(w http.ResponseWriter, r *http.Request) {
rules, err := a.configManager.ReadRoutingRules()
if err != nil {
writeError(w, http.StatusInternalServerError, "ошибка чтения правил маршрутизации")
return
}
// Сортировка по priority
sort.Slice(rules, func(i, j int) bool {
return rules[i].Priority < rules[j].Priority
})
// Подмешиваем hits из engine в модель
compiledRules := a.engine.GetCompiledRules()
hitsMap := make(map[string]int64, len(compiledRules))
for _, cr := range compiledRules {
hitsMap[cr.ID] = cr.GetHits()
}
type RuleWithHits struct {
models.RouteRule
Hits int64 `json:"hits"`
}
result := make([]RuleWithHits, len(rules))
for i, r := range rules {
result[i] = RuleWithHits{RouteRule: r, Hits: hitsMap[r.ID]}
}
writeJSON(w, http.StatusOK, result)
}
// handleCreateRoutingRule — POST /api/routing/rules — создание нового правила.
func (a *API) handleCreateRoutingRule(w http.ResponseWriter, r *http.Request) {
var rule models.RouteRule
if err := decodeJSON(r, &rule); err != nil {
writeError(w, http.StatusBadRequest, "некорректный JSON")
return
}
// Генерируем ID
rule.ID = "rr-" + util.RandomHex(6)
now := time.Now().UTC()
rule.CreatedAt = now
rule.UpdatedAt = now
rule.Enabled = true
if rule.Priority <= 0 {
rule.Priority = 10
}
// Читаем текущие правила
rules, err := a.configManager.ReadRoutingRules()
if err != nil {
writeError(w, http.StatusInternalServerError, "ошибка чтения правил")
return
}
// Добавляем новое правило
rules = append(rules, rule)
// Валидируем (validate-before-save)
if err := a.engine.ValidateRules(rules); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Сохраняем на диск
if err := a.configManager.SaveRoutingRules(rules); err != nil {
writeError(w, http.StatusInternalServerError, "ошибка сохранения правила")
return
}
// Загружаем в память
a.engine.SetRules(rules)
writeJSON(w, http.StatusCreated, rule)
}
// handleUpdateRoutingRule — PUT /api/routing/rules/{id} — обновление правила.
func (a *API) handleUpdateRoutingRule(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
writeError(w, http.StatusBadRequest, "id обязателен")
return
}
var updates models.RouteRule
if err := decodeJSON(r, &updates); err != nil {
writeError(w, http.StatusBadRequest, "некорректный JSON")
return
}
rules, err := a.configManager.ReadRoutingRules()
if err != nil {
writeError(w, http.StatusInternalServerError, "ошибка чтения правил")
return
}
idx := -1
for i, rule := range rules {
if rule.ID == id {
idx = i
break
}
}
if idx < 0 {
writeError(w, http.StatusNotFound, "правило не найдено")
return
}
// Частичное обновление: применяем только непустые поля
target := &rules[idx]
if updates.Name != "" {
target.Name = updates.Name
}
if updates.Priority > 0 {
target.Priority = updates.Priority
}
if updates.MatchCallerID != "" {
target.MatchCallerID = updates.MatchCallerID
}
if updates.MatchDestination != "" {
target.MatchDestination = updates.MatchDestination
}
if updates.MatchIngressTrunk != "" {
target.MatchIngressTrunk = updates.MatchIngressTrunk
}
if updates.MatchDaysOfWeek != "" {
target.MatchDaysOfWeek = updates.MatchDaysOfWeek
}
if updates.MatchTimeStart != "" {
target.MatchTimeStart = updates.MatchTimeStart
}
if updates.MatchTimeEnd != "" {
target.MatchTimeEnd = updates.MatchTimeEnd
}
if updates.ActionType != "" {
target.ActionType = updates.ActionType
target.ActionNodeID = updates.ActionNodeID
target.ActionPoolIDs = updates.ActionPoolIDs
}
if updates.Description != "" {
target.Description = updates.Description
}
// Позволяем очистить match-поля через пустую строку в JSON (omitempty не подходит)
// Для этого используем указатели, но для упрощения: если поле не передано — оставляем старое
// Если передано явно (даже пустое) — нужно смотреть raw JSON, здесь упрощённо
target.UpdatedAt = time.Now().UTC()
// Валидируем
if err := a.engine.ValidateRules(rules); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Сохраняем
if err := a.configManager.SaveRoutingRules(rules); err != nil {
writeError(w, http.StatusInternalServerError, "ошибка сохранения правила")
return
}
// Загружаем в память
a.engine.SetRules(rules)
writeJSON(w, http.StatusOK, *target)
}
// handleDeleteRoutingRule — DELETE /api/routing/rules/{id} — удаление правила.
func (a *API) handleDeleteRoutingRule(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
writeError(w, http.StatusBadRequest, "id обязателен")
return
}
rules, err := a.configManager.ReadRoutingRules()
if err != nil {
writeError(w, http.StatusInternalServerError, "ошибка чтения правил")
return
}
found := false
filtered := make([]models.RouteRule, 0, len(rules))
for _, rule := range rules {
if rule.ID == id {
found = true
continue
}
filtered = append(filtered, rule)
}
if !found {
writeError(w, http.StatusNotFound, "правило не найдено")
return
}
if err := a.configManager.SaveRoutingRules(filtered); err != nil {
writeError(w, http.StatusInternalServerError, "ошибка сохранения правил")
return
}
// Загружаем в память (валидация не нужна — удаление не может нарушить валидность)
a.engine.SetRules(filtered)
writeJSON(w, http.StatusNoContent, nil)
}
// handleToggleRoutingRule — PUT /api/routing/rules/{id}/toggle — вкл/выкл правила.
func (a *API) handleToggleRoutingRule(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
writeError(w, http.StatusBadRequest, "id обязателен")
return
}
var req struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "некорректный JSON")
return
}
rules, err := a.configManager.ReadRoutingRules()
if err != nil {
writeError(w, http.StatusInternalServerError, "ошибка чтения правил")
return
}
idx := -1
for i, rule := range rules {
if rule.ID == id {
idx = i
break
}
}
if idx < 0 {
writeError(w, http.StatusNotFound, "правило не найдено")
return
}
rules[idx].Enabled = req.Enabled
rules[idx].UpdatedAt = time.Now().UTC()
if err := a.configManager.SaveRoutingRules(rules); err != nil {
writeError(w, http.StatusInternalServerError, "ошибка сохранения правила")
return
}
a.engine.SetRules(rules)
writeJSON(w, http.StatusOK, rules[idx])
}
// handleReorderRoutingRules — PUT /api/routing/rules/reorder — переупорядочивание правил.
// Принимает массив ID в новом порядке. Переназначает Priority = index * 10.
func (a *API) handleReorderRoutingRules(w http.ResponseWriter, r *http.Request) {
var order []string
if err := decodeJSON(r, &order); err != nil {
writeError(w, http.StatusBadRequest, "некорректный JSON: ожидается массив id")
return
}
if len(order) == 0 {
writeError(w, http.StatusBadRequest, "пустой порядок")
return
}
rules, err := a.configManager.ReadRoutingRules()
if err != nil {
writeError(w, http.StatusInternalServerError, "ошибка чтения правил")
return
}
// Строим map для быстрого поиска
ruleMap := make(map[string]*models.RouteRule, len(rules))
for i := range rules {
ruleMap[rules[i].ID] = &rules[i]
}
// Проверяем что все ID из order присутствуют
for _, id := range order {
if _, ok := ruleMap[id]; !ok {
writeError(w, http.StatusBadRequest, fmt.Sprintf("правило с id '%s' не найдено", id))
return
}
}
// Переназначаем приоритеты
now := time.Now().UTC()
for i, id := range order {
r := ruleMap[id]
r.Priority = (i + 1) * 10
r.UpdatedAt = now
}
if err := a.configManager.SaveRoutingRules(rules); err != nil {
writeError(w, http.StatusInternalServerError, "ошибка сохранения порядка")
return
}
a.engine.SetRules(rules)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}