// Команда 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/gitstore" "github.com/netpulse/netpulse/server/internal/httpapi" "github.com/netpulse/netpulse/server/webui" "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=[,...] — потрібні для каналів сповіщень") alertEvery = flag.Duration("alert-interval", 30*time.Second, "як часто обчислювати правила алертів; 0 — не запускати движок") alertKeep = flag.Duration("alert-retention", 7*24*time.Hour, "скільки тримати закриті алерти до переносу в історію") gitRoot = flag.String("git-root", envOr("NETPULSE_GIT_ROOT", "/var/lib/netpulse/git"), "корінь сховища версій конфігів; порожньо — без Git") 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 й колектора: // колектор туди пише під час бекапу, API звідти читає для diff. // Різні машини потребують спільного тому — інакше половина історії // буде видна одному процесу, а половина другому. if *gitRoot != "" { st.UseGit(gitstore.New(*gitRoot)) } api := httpapi.New(st, signer, log) // Зібраний інтерфейс, якщо він є в цій збірці. Порожній dist — // робочий стан: розробка йде проти vite, а API просто віддає API. if fsys, ok := webui.FS(); ok { httpapi.SetStatic(fsys) log.Info("веб-інтерфейс вшито в бінарник") } 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})) }