Netpulse_SasS/server/cmd/netpulse-api/main.go
byrsapty 215bf8c4db Етап 9: алерти й сповіщення
Система вміла малювати мапу, але мовчала, коли щось падало. Схема alr.*
лежала готовою з Етапу 1 і повністю порожньою.

Движок:
- обчислює правила з icmp / interface / metric / no_data і не тримає
  стану між тіками: вікно for_seconds — це запит по часу до TSDB, тож
  перезапуск нічого не збиває
- дві семантики вікна: без agg умова має триматися всі виміри (антифлап),
  з agg порівнюється агрегат
- кореляція за топологією: причина аварії — той, у кого лишився живий
  сусід; хто оточений мертвими, той наслідок. Заради цього й будувалась
  topo.links: інакше падіння маршрутизатора дає сорок сповіщень
- вікна обслуговування, ручне заглушення зі стелею в тиждень
- advisory-блокування на тік: кілька API за балансувальником безпечні

Доставка:
- Telegram, webhook, SMTP; токени в core.secrets тим самим кільцем, що
  й паролі від обладнання
- тенант без маршрутів отримує все в усі придатні канали — підключили
  Telegram, має працювати
- тиха година не глушить disaster
- вебхуки на внутрішні адреси заблоковано; для self-hosted знімається
  прапорцем процесу, бо там ця мережа своя

UI: індикатор у шапці, панель зі списком, ack/mute/close, окремий фільтр
придушених, спільна шина подій замість другого WebSocket.

Знайдено живою роботою й виправлено:
- вимкнене або видалене правило лишало алерти сиротами назавжди
- перехід у suppressed не публікував події — UI дізнавався лише після
  перезавантаження сторінки
- кнопки дій на телефоні були 26 px

20 нових тестів, go vet і tsc чисто. Живий прогін: поріг посередині
розділив два справжні пристрої, 5 доставок на 5 подій без повторів.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:09:19 +03:00

185 lines
6.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Команда netpulse-api — REST і WebSocket для фронтенду.
//
// Окремий процес від netpulse-server (AgentService): зонди й браузери
// мають різні профілі навантаження й різні мережеві периметри. Спільний
// у них лише шар store, тому дані обидва бачать однакові.
package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/netpulse/netpulse/server/internal/alerting"
"github.com/netpulse/netpulse/server/internal/auth"
"github.com/netpulse/netpulse/server/internal/crypto"
"github.com/netpulse/netpulse/server/internal/httpapi"
"github.com/netpulse/netpulse/server/internal/store"
)
var version = "dev"
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "netpulse-api:", err)
os.Exit(1)
}
}
func run() error {
var (
listen = flag.String("listen", envOr("NETPULSE_API_LISTEN", ":8080"), "адреса HTTP")
dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL")
certFile = flag.String("cert", os.Getenv("NETPULSE_API_CERT"), "сертифікат TLS")
keyFile = flag.String("key", os.Getenv("NETPULSE_API_KEY"), "приватний ключ TLS")
logLevel = flag.String("log-level", envOr("NETPULSE_LOG_LEVEL", "info"), "debug|info|warn|error")
jwtKey = flag.String("jwt-secret", os.Getenv("NETPULSE_JWT_SECRET"),
"секрет підпису токенів доступу, щонайменше 32 байти")
pruneAge = flag.Duration("event-retention", 24*time.Hour,
"скільки тримати доставлені події в core.event_outbox")
keysFlag = flag.String("dek", os.Getenv("NETPULSE_DEK"),
"ключі шифрування: key_id=<hex|base64>[,...] — потрібні для каналів сповіщень")
alertEvery = flag.Duration("alert-interval", 30*time.Second,
"як часто обчислювати правила алертів; 0 — не запускати движок")
alertKeep = flag.Duration("alert-retention", 7*24*time.Hour,
"скільки тримати закриті алерти до переносу в історію")
privateHooks = flag.Bool("allow-private-webhooks",
os.Getenv("NETPULSE_ALLOW_PRIVATE_WEBHOOKS") == "1",
"дозволити вебхуки на внутрішні адреси — для self-hosted інсталяцій")
)
flag.Parse()
if *dsn == "" {
return errors.New("не вказано -dsn (або NETPULSE_DSN)")
}
log := newLogger(*logLevel)
// Секрет обовʼязковий і не генерується автоматично: випадковий
// ключ при кожному старті означав би, що будь-який перезапуск
// викидає всіх користувачів, а кілька екземплярів API за
// балансувальником не приймали б токени один одного.
signer, err := auth.NewSigner([]byte(*jwtKey))
if err != nil {
return fmt.Errorf("-jwt-secret: %w", err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
st, err2 := store.New(ctx, *dsn)
err = err2
if err != nil {
return fmt.Errorf("підключення до БД: %w", err)
}
defer st.Close()
ring, err := crypto.ParseKeyring(*keysFlag)
if err != nil {
return fmt.Errorf("-dek: %w", err)
}
api := httpapi.New(st, signer, log)
go api.Hub().Run(ctx)
go pruneLoop(ctx, st, log, *pruneAge)
// Движок алертів живе тут, а не в netpulse-server, бо саме цей
// процес уже читає БД для UI і має ключі для каналів доставки.
// Одночасний запуск кількох екземплярів безпечний: тік бере
// advisory-блокування, тож обчислює завжди рівно один.
if *alertEvery > 0 {
eng := alerting.New(st, ring, log, *alertEvery, *privateHooks)
api = api.WithNotifications(ring, eng.Notifier())
go eng.Run(ctx)
go eng.RunHousekeeping(ctx, *alertKeep)
}
srv := &http.Server{
Addr: *listen,
Handler: api.Handler(),
// Читання й заголовки обмежені, а от загального WriteTimeout
// немає навмисно: він рубав би довгі WebSocket-з'єднання
// рівно посеред роботи NOC-екрана.
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
log.Info("запуск", "version", version, "listen", *listen, "tls", *certFile != "")
errCh := make(chan error, 1)
go func() {
if *certFile != "" && *keyFile != "" {
errCh <- srv.ListenAndServeTLS(*certFile, *keyFile)
return
}
log.Warn("запуск без TLS — припустимо лише за зворотним проксі")
errCh <- srv.ListenAndServe()
}()
select {
case <-ctx.Done():
log.Info("зупинка", "підписників", api.Hub().SubscriberCount())
shutCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return srv.Shutdown(shutCtx)
case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
}
// pruneLoop прибирає доставлені події.
//
// Без нього core.event_outbox росла б вічно: подій там небагато, але
// «небагато» помножене на роки — це той самий терабайт.
func pruneLoop(ctx context.Context, st *store.Store, log *slog.Logger, age time.Duration) {
t := time.NewTicker(time.Hour)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
n, err := st.PruneEvents(ctx, age)
if err != nil {
log.Warn("прибирання журналу подій", "err", err)
continue
}
if n > 0 {
log.Info("прибрано подій", "рядків", n)
}
}
}
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func newLogger(level string) *slog.Logger {
lv := slog.LevelInfo
switch level {
case "debug":
lv = slog.LevelDebug
case "warn":
lv = slog.LevelWarn
case "error":
lv = slog.LevelError
}
return slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: lv}))
}