diff --git a/cmd/siptest/main.go b/cmd/siptest/main.go new file mode 100644 index 0000000..72c8135 --- /dev/null +++ b/cmd/siptest/main.go @@ -0,0 +1,235 @@ +// 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: \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: ;tag=caller-%d\r\n"+ + "To: \r\n"+ + "Call-ID: %s\r\n"+ + "CSeq: 1 INVITE\r\n"+ + "Contact: \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] +}