go-git, чистий Go без cgo. Голий репозиторій на тенанта, гілка на пристрій за ідентифікатором (ім'я змінюють, історія не має від цього розсипатись), файл за іменем. Однаковий вміст нового коміту не створює. netpulse-gitsync переливає накопичену історію й відтворює втрачений репозиторій із бази — тіла конфігів там і так лежать зашифрованими. У вебі з'явився вибір версії, з якою порівнювати: сервер це вмів (?from=), інтерфейс — ні. Дорогою виправлено чотири тести, які CI запустив уперше з базою. Серед них справжня помилка: машинний токен не міг читати мапи — ACL мап отримував порожній рядок замість uuid і давав 500. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
204 lines
7.5 KiB
Go
204 lines
7.5 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/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=<hex|base64>[,...] — потрібні для каналів сповіщень")
|
||
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}))
|
||
}
|