netpulse-migrate замість PowerShell-скрипта: у контейнері немає ані psql, ані PowerShell, а тягнути клієнт Postgres в образ заради одного запуску — це половина дистрибутива на порожньому місці. Міграції вшиті через embed і переїхали в server/migrations: embed не бачить нічого за межами кореня свого модуля, а міграції поруч із бінарником, який їх накочує, не можуть розійтися версіями. Накочування під advisory-блокуванням: два інстанси при rolling update інакше застосували б ту саму міграцію двічі. Кожен файл в одній транзакції разом із записом у schema_migrations; виняток — continuous aggregates, які TimescaleDB забороняє в транзакції. Змінена вже застосована міграція зупиняє запуск: у різних інсталяціях інакше опиниться різна схема під одним номером. Перевірено на чистій базі: 23 міграції, 101 таблиця, повторний запуск каже «схема актуальна». Веб віддає сам API через embed: на self-hosted це прибирає з інструкції встановлення цілий компонент. Три політики кешування — назавжди для assets із хешем у імені, ніколи для index.html, коротко для решти. Знайдено живим прогоном: невідомий шлях під /api/ віддавав 200 з index.html, і клієнт падав на розборі HTML як JSON замість чесного 404. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
193 lines
6.8 KiB
Go
193 lines
6.8 KiB
Go
// Команда 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/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=<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)
|
||
|
||
// Зібраний інтерфейс, якщо він є в цій збірці. Порожній 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}))
|
||
}
|