first commit
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
// Эмулятор метрик для pulse-lets-go.
|
||||
// Публикует метрики от эмулируемых нод в NATS, имитируя разные сценарии нагрузки.
|
||||
//
|
||||
// Сценарии:
|
||||
// normal — все ноды здоровы, случайная загрузка (проверка weighted score + best_node)
|
||||
// overload — последняя нода перегружена (active_calls >= max_calls → lethal)
|
||||
// cpu-low — последняя нода с низким idle_cpu (< 5 → lethal)
|
||||
// fail-high — последняя нода с высоким call_failure_rate (> 15 → lethal)
|
||||
// node-down — последняя нода status="down" (→ lethal)
|
||||
// stale — первая нода перестаёт отправлять метрики через 10 сек (→ lethal)
|
||||
// chaos — случайное чередование lethal-состояний у случайных нод
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
natsgo "github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// --- Модель метрики ---
|
||||
|
||||
type nodeMetric struct {
|
||||
NodeID string `json:"node_id"`
|
||||
TS int64 `json:"ts"`
|
||||
Status string `json:"status"`
|
||||
ActiveCalls int `json:"active_calls"`
|
||||
MaxCalls int `json:"max_calls"`
|
||||
IdleCPU float64 `json:"idle_cpu"`
|
||||
LoadAvg float64 `json:"load_avg"`
|
||||
CallFailureRate float64 `json:"call_failure_rate"`
|
||||
}
|
||||
|
||||
// --- Спецификация значения (фиксированное или случайный диапазон) ---
|
||||
|
||||
type valueSpec struct {
|
||||
fixed float64
|
||||
min float64
|
||||
max float64
|
||||
isRand bool
|
||||
}
|
||||
|
||||
func fixed(v float64) valueSpec { return valueSpec{fixed: v} }
|
||||
func rrange(min, max float64) valueSpec {
|
||||
return valueSpec{min: min, max: max, isRand: true}
|
||||
}
|
||||
|
||||
func (v valueSpec) get() float64 {
|
||||
if v.isRand {
|
||||
return v.min + rand.Float64()*(v.max-v.min)
|
||||
}
|
||||
return v.fixed
|
||||
}
|
||||
|
||||
// --- Конфигурация ноды в эмуляторе ---
|
||||
|
||||
type nodeSpec struct {
|
||||
nodeID string
|
||||
status valueSpec // строка: ok / down
|
||||
calls valueSpec
|
||||
maxCalls float64 // фиксированное
|
||||
idleCPU valueSpec
|
||||
loadAvg valueSpec
|
||||
failRate valueSpec
|
||||
}
|
||||
|
||||
func (ns *nodeSpec) generate() nodeMetric {
|
||||
return nodeMetric{
|
||||
NodeID: ns.nodeID,
|
||||
TS: time.Now().Unix(),
|
||||
Status: statusFromValue(ns.status.get()),
|
||||
ActiveCalls: int(ns.calls.get()),
|
||||
MaxCalls: int(ns.maxCalls),
|
||||
IdleCPU: ns.idleCPU.get(),
|
||||
LoadAvg: ns.loadAvg.get(),
|
||||
CallFailureRate: ns.failRate.get(),
|
||||
}
|
||||
}
|
||||
|
||||
func statusFromValue(v float64) string {
|
||||
if v < 0.5 {
|
||||
return "down"
|
||||
}
|
||||
return "ok"
|
||||
}
|
||||
|
||||
// --- Сценарии ---
|
||||
|
||||
func scenarioNormal() []nodeSpec {
|
||||
return []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),
|
||||
},
|
||||
{
|
||||
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),
|
||||
},
|
||||
{
|
||||
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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func scenarioOverload() []nodeSpec {
|
||||
specs := scenarioNormal()
|
||||
// Последняя нода перегружена: active_calls = max_calls → lethal
|
||||
specs[len(specs)-1].calls = fixed(250)
|
||||
specs[len(specs)-1].idleCPU = fixed(10)
|
||||
specs[len(specs)-1].loadAvg = fixed(5.0)
|
||||
return specs
|
||||
}
|
||||
|
||||
func scenarioCPULow() []nodeSpec {
|
||||
specs := scenarioNormal()
|
||||
specs[len(specs)-1].idleCPU = fixed(3) // < 5 → lethal
|
||||
return specs
|
||||
}
|
||||
|
||||
func scenarioFailHigh() []nodeSpec {
|
||||
specs := scenarioNormal()
|
||||
specs[len(specs)-1].failRate = fixed(20) // > 15 → lethal
|
||||
return specs
|
||||
}
|
||||
|
||||
func scenarioNodeDown() []nodeSpec {
|
||||
specs := scenarioNormal()
|
||||
specs[len(specs)-1].status = fixed(0) // status=down → lethal
|
||||
return specs
|
||||
}
|
||||
|
||||
func scenarioStale() []nodeSpec {
|
||||
// Identical to normal — stale управляется логикой в main (пропуск отправки)
|
||||
return scenarioNormal()
|
||||
}
|
||||
|
||||
// --- Main ---
|
||||
|
||||
func main() {
|
||||
scenario := flag.String("scenario", "normal",
|
||||
"Сценарий: normal | overload | cpu-low | fail-high | node-down | stale | chaos")
|
||||
natsURL := flag.String("nats", "nats://localhost:4222", "NATS URL")
|
||||
interval := flag.Duration("interval", 5*time.Second, "Интервал отправки метрик")
|
||||
flag.Parse()
|
||||
|
||||
nc, err := natsgo.Connect(*natsURL, natsgo.MaxReconnects(-1))
|
||||
if err != nil {
|
||||
log.Fatalf("ошибка NATS: %v", err)
|
||||
}
|
||||
defer nc.Close()
|
||||
|
||||
var specs []nodeSpec
|
||||
switch *scenario {
|
||||
case "normal":
|
||||
specs = scenarioNormal()
|
||||
case "overload":
|
||||
specs = scenarioOverload()
|
||||
case "cpu-low":
|
||||
specs = scenarioCPULow()
|
||||
case "fail-high":
|
||||
specs = scenarioFailHigh()
|
||||
case "node-down":
|
||||
specs = scenarioNodeDown()
|
||||
case "stale":
|
||||
specs = scenarioStale()
|
||||
case "chaos":
|
||||
specs = scenarioChaos()
|
||||
default:
|
||||
log.Fatalf("неизвестный сценарий: %s", *scenario)
|
||||
}
|
||||
|
||||
log.Printf("[emulator] сценарий=%s, нод=%d, NATS=%s, интервал=%s",
|
||||
*scenario, len(specs), *natsURL, *interval)
|
||||
|
||||
if *scenario == "chaos" {
|
||||
runChaos(nc, specs, *interval)
|
||||
return
|
||||
}
|
||||
|
||||
if *scenario == "stale" {
|
||||
runStale(nc, specs, *interval)
|
||||
return
|
||||
}
|
||||
|
||||
runNormal(nc, specs, *interval)
|
||||
}
|
||||
|
||||
// runNormal — обычный цикл: все ноды шлют метрики каждые interval.
|
||||
func runNormal(nc *natsgo.Conn, specs []nodeSpec, interval time.Duration) {
|
||||
log.Printf("[emulator] запущен, Ctrl+C для остановки")
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
// Первая отправка сразу
|
||||
publishAll(nc, specs)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
publishAll(nc, specs)
|
||||
case <-quit:
|
||||
log.Println("[emulator] остановлен")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runStale — первая нода шлёт метрики только первые 10 секунд, потом замолкает.
|
||||
func runStale(nc *natsgo.Conn, specs []nodeSpec, interval time.Duration) {
|
||||
log.Printf("[emulator] сценарий stale: нода %s замолкнет через 10 сек", specs[0].nodeID)
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
startTime := time.Now()
|
||||
staleNodeID := specs[0].nodeID
|
||||
|
||||
publishAll(nc, specs)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
elapsed := time.Since(startTime)
|
||||
for _, spec := range specs {
|
||||
if spec.nodeID == staleNodeID && elapsed > 10*time.Second {
|
||||
continue // пропускаем stale-ноду
|
||||
}
|
||||
metric := spec.generate()
|
||||
publish(nc, metric)
|
||||
}
|
||||
case <-quit:
|
||||
log.Println("[emulator] остановлен")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runChaos — случайно чередует lethal-состояния у случайных нод.
|
||||
func runChaos(nc *natsgo.Conn, specs []nodeSpec, interval time.Duration) {
|
||||
log.Printf("[emulator] сценарий chaos: случайные lethal-состояния")
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
// Сохраняем оригинальные спецификации для восстановления
|
||||
original := make([]nodeSpec, len(specs))
|
||||
copy(original, specs)
|
||||
|
||||
var mu sync.Mutex
|
||||
|
||||
publishAll(nc, specs)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
mu.Lock()
|
||||
// Каждые 15-30 секунд случайно портим случайную ноду
|
||||
if rand.Intn(4) == 0 { // ~25% шанс на каждом тике
|
||||
idx := rand.Intn(len(specs))
|
||||
kind := rand.Intn(5)
|
||||
switch kind {
|
||||
case 0:
|
||||
specs[idx].status = fixed(0) // down
|
||||
case 1:
|
||||
specs[idx].calls = fixed(250) // overload
|
||||
case 2:
|
||||
specs[idx].idleCPU = fixed(2) // idle_cpu < 5
|
||||
case 3:
|
||||
specs[idx].failRate = fixed(25) // failure rate > 15
|
||||
case 4:
|
||||
// Восстановить оригинал
|
||||
specs[idx] = original[idx]
|
||||
}
|
||||
}
|
||||
publishAll(nc, specs)
|
||||
mu.Unlock()
|
||||
case <-quit:
|
||||
log.Println("[emulator] остановлен")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func scenarioChaos() []nodeSpec {
|
||||
// Начинаем с normal
|
||||
return scenarioNormal()
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func publishAll(nc *natsgo.Conn, specs []nodeSpec) {
|
||||
for _, spec := range specs {
|
||||
metric := spec.generate()
|
||||
publish(nc, metric)
|
||||
}
|
||||
}
|
||||
|
||||
func publish(nc *natsgo.Conn, m nodeMetric) {
|
||||
subject := fmt.Sprintf("pulse.metrics.%s", m.NodeID)
|
||||
data, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
log.Printf("[emulator] ошибка сериализации %s: %v", m.NodeID, err)
|
||||
return
|
||||
}
|
||||
if err := nc.Publish(subject, data); err != nil {
|
||||
log.Printf("[emulator] ошибка публикации %s: %v", m.NodeID, err)
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// pulse-lets-go — балансировщик телефонной нагрузки на базе FreeSWITCH + NATS.
|
||||
// Принимает метрики от нижестоящих АТС, вычисляет score готовности,
|
||||
// возвращает лучшую ноду для следующего вызова через /api/route.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/pulse-lets-go/internal/api"
|
||||
"github.com/pulse-lets-go/internal/config"
|
||||
"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/nats"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Определяем рабочую директорию
|
||||
exe, _ := os.Executable()
|
||||
exeDir := filepath.Dir(exe)
|
||||
dataDir := filepath.Join(exeDir, "data")
|
||||
|
||||
// Разрешаем переопределение через переменную окружения
|
||||
if envDir := os.Getenv("PULSE_DATA_DIR"); envDir != "" {
|
||||
dataDir = envDir
|
||||
}
|
||||
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
log.Printf("[main] запуск pulse-lets-go, data_dir=%s", dataDir)
|
||||
|
||||
// 1. Инициализация конфиг-менеджера (создаёт data/ и дефолтные JSON при первом запуске)
|
||||
cfgMgr, err := config.NewManager(dataDir)
|
||||
if err != nil {
|
||||
log.Fatalf("[main] ошибка инициализации конфигов: %v", err)
|
||||
}
|
||||
|
||||
// 2. Чтение конфига
|
||||
cfg, err := cfgMgr.ReadConfig()
|
||||
if err != nil {
|
||||
log.Fatalf("[main] ошибка чтения config.json: %v", err)
|
||||
}
|
||||
|
||||
// 2.1 Валидация конфига
|
||||
if err := cfg.Validate(); err != nil {
|
||||
log.Fatalf("[main] ошибка валидации конфига:\n%v", err)
|
||||
}
|
||||
|
||||
log.Printf("[main] NATS=%s, listen=%s, stale_threshold=%dсек, tls=%v, log=%s/%s",
|
||||
cfg.NatsURL, cfg.ListenAddr, cfg.StaleThresholdSec, cfg.TLS.Enabled, cfg.Log.Level, cfg.Log.Format)
|
||||
|
||||
// 3. Engine
|
||||
eng := engine.NewEngine(cfg)
|
||||
|
||||
// 4. ASCII логгер метрик
|
||||
metricsLogger, err := filelog.NewLogger(dataDir)
|
||||
if err != nil {
|
||||
log.Fatalf("[main] ошибка создания логгера: %v", err)
|
||||
}
|
||||
|
||||
// 5. NATS подписчик
|
||||
sub, err := nats.NewSubscriber(cfg.NatsURL, cfg.NatsUser, cfg.NatsPassword, eng, metricsLogger)
|
||||
if err != nil {
|
||||
log.Fatalf("[main] ошибка NATS: %v", err)
|
||||
}
|
||||
defer sub.Close()
|
||||
|
||||
// 6. HTTP API
|
||||
apiHandler := api.NewAPI(eng, cfgMgr, cfg.JWTSecret, cfg.MonitoringAPIKey, sub.IsConnected, cfg)
|
||||
handler := apiHandler.Handler()
|
||||
|
||||
// 6.1 ESL-клиент (опционально — если сконфигурирован)
|
||||
var eslClient *esl.Client
|
||||
if cfg.ESL.Host != "" {
|
||||
eslPassword := cfg.ESL.GetPassword()
|
||||
eslClient = esl.NewClient(cfg.ESL.Host, cfg.ESL.Port, eslPassword)
|
||||
apiHandler.SetESLClient(eslClient)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
eslClient.StartEventLoop(ctx, cfg.ESL.SofiaProfile,
|
||||
func() {
|
||||
trunks, err := cfgMgr.ReadTrunks()
|
||||
if err != nil {
|
||||
log.Printf("[esl] ошибка чтения транков для синхронизации: %v", err)
|
||||
return
|
||||
}
|
||||
var specs []esl.GatewaySpec
|
||||
for _, t := range trunks {
|
||||
if t.Type == "ingress" && t.Enabled {
|
||||
specs = append(specs, esl.GatewaySpec{
|
||||
Name: fmt.Sprintf("pulse-ingress-%s", t.ID),
|
||||
Proxy: extractHostFromGateway(t.Gateway),
|
||||
})
|
||||
}
|
||||
}
|
||||
eslClient.GatewaySyncAll(cfg.ESL.SofiaProfile, specs)
|
||||
},
|
||||
func(eventName string, headers map[string]string, body string) {
|
||||
log.Printf("[esl] событие: %s", eventName)
|
||||
},
|
||||
)
|
||||
cancel()
|
||||
}()
|
||||
}
|
||||
|
||||
// ESL shutdown при завершении main
|
||||
defer func() {
|
||||
if eslClient != nil {
|
||||
eslClient.Disconnect()
|
||||
}
|
||||
}()
|
||||
|
||||
// Подключаем раздачу SvelteKit статики, если директория существует
|
||||
webDir := findWebDir(exeDir)
|
||||
if webDir != "" {
|
||||
log.Printf("[main] раздача SvelteKit статики из %s", webDir)
|
||||
handler = withSPA(webDir, handler)
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.ListenAddr,
|
||||
Handler: handler,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// 7. Запуск HTTP-сервера
|
||||
go func() {
|
||||
log.Printf("[main] HTTP сервер запущен на %s (tls=%v)", cfg.ListenAddr, cfg.TLS.Enabled)
|
||||
log.Printf("[main] готов к работе:")
|
||||
log.Printf(" GET /api/health — статус зависимостей")
|
||||
log.Printf(" POST /api/auth/login — аутентификация")
|
||||
log.Printf(" GET /api/route — маршрутизация (FreeSWITCH)")
|
||||
log.Printf(" GET /api/nodes — список нод")
|
||||
log.Printf(" GET /api/trunks — управление транками")
|
||||
log.Printf(" GET /api/users — управление пользователями")
|
||||
log.Printf(" GET /api/monitoring/zabbix — Zabbix LLD + items")
|
||||
log.Printf(" GET /api/monitoring/prometheus — Prometheus метрики")
|
||||
log.Printf(" WS /ws/metrics — real-time метрики на фронт")
|
||||
|
||||
var err error
|
||||
if cfg.TLS.Enabled {
|
||||
err = srv.ListenAndServeTLS(cfg.TLS.CertFile, cfg.TLS.KeyFile)
|
||||
} else {
|
||||
err = srv.ListenAndServe()
|
||||
}
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("[main] ошибка HTTP сервера: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 8. Graceful shutdown
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
sig := <-quit
|
||||
log.Printf("[main] получен сигнал %s, завершение работы...", sig)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Printf("[main] ошибка остановки HTTP сервера: %v", err)
|
||||
}
|
||||
|
||||
sub.Close()
|
||||
metricsLogger.Close()
|
||||
|
||||
log.Println("[main] pulse-lets-go остановлен")
|
||||
}
|
||||
|
||||
// findWebDir ищет директорию со SvelteKit сборкой (web/build/).
|
||||
// Пробует по порядку: переменная окружения, рядом с бинарником, в корне проекта, CWD.
|
||||
func findWebDir(exeDir string) string {
|
||||
candidates := []string{
|
||||
os.Getenv("PULSE_WEB_DIR"),
|
||||
filepath.Join(exeDir, "web", "build"), // bin/web/build
|
||||
filepath.Join(filepath.Dir(exeDir), "web", "build"), // ../web/build (корень проекта)
|
||||
"web/build", // ./web/build (CWD)
|
||||
}
|
||||
for _, d := range candidates {
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
if stat, err := os.Stat(d); err == nil && stat.IsDir() {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// withSPA — middleware для раздачи SvelteKit SPA.
|
||||
// Запросы к /api/* и /ws/* проксируются в API handler.
|
||||
// Остальные запросы ищут статический файл в webDir; если файл не найден,
|
||||
// возвращается index.html (SPA fallback для клиентского роутинга).
|
||||
func withSPA(webDir string, apiHandler http.Handler) http.Handler {
|
||||
fs := http.FileServer(http.Dir(webDir))
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
|
||||
// API и WebSocket запросы — передаём в API
|
||||
if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/ws/") {
|
||||
apiHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Пробуем отдать статический файл
|
||||
filePath := filepath.Join(webDir, path)
|
||||
info, err := os.Stat(filePath)
|
||||
|
||||
// Если файл существует и не является директорией — отдаём
|
||||
if err == nil && !info.IsDir() {
|
||||
fs.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// SPA fallback: index.html
|
||||
indexPath := filepath.Join(webDir, "index.html")
|
||||
if _, err := os.Stat(indexPath); err == nil {
|
||||
http.ServeFile(w, r, indexPath)
|
||||
return
|
||||
}
|
||||
|
||||
// Если index.html тоже нет — 404
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// extractHostFromGateway извлекает хост из SIP URI.
|
||||
// "sip:mts-gw.lan:5060" → "mts-gw.lan"
|
||||
func extractHostFromGateway(gateway string) string {
|
||||
s := strings.TrimPrefix(gateway, "sip:")
|
||||
s = strings.TrimPrefix(s, "sips:")
|
||||
if idx := strings.LastIndex(s, ":"); idx > 0 {
|
||||
port := s[idx+1:]
|
||||
isNumeric := true
|
||||
for _, c := range port {
|
||||
if c < '0' || c > '9' {
|
||||
isNumeric = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if isNumeric {
|
||||
s = s[:idx]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user