Досі доступ давав машинний токен зі змінної збірки — одні права на всіх і жодного способу відрізнити, хто що зробив. Тепер продукт уміє впустити людину. Сервер: - argon2id для паролів, власний HS256 JWT (15 хв) + refresh-сесія в httpOnly-кукі на 30 днів з ротацією при кожному обміні - Principal зводить людину й машинний токен до одного набору прав; права читаються з БД на кожному запиті, а не з claims, щоб відкликана роль не жила до кінця TTL - 9 ендпоїнтів: auth/login|refresh|logout|password, me, team CRUD, roles - netpulse-user — CLI для першого власника: публічна реєстрація в B2B це дірка, а «перший через веб, поки нікого немає» — нечесна гонка - міграція 0012: три RLS-політики винятку для шляху входу (без них вхід неможливий за побудовою — щоб знайти користувача за email, треба знати тенант, який відомий лише після пошуку) і login_attempts для тротлінгу Фронтенд: - сторінка входу з вибором організації, access-токен у замиканні модуля замість localStorage, тихе відновлення сесії по кукі - один refresh на всі паралельні запити: інакше ротація зробила б усі, крім першого, недійсними й викинула б людину на вхід - дії без права не показуються; полотно нередаговане для глядача - мобільний адаптив: висувна бічна панель, інспектор нижнім аркушем 11 нових тестів (37 у httpapi), go vet і tsc чисто, живий прогін з 8 кроків проти netpulse_it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
158 lines
4.9 KiB
Go
158 lines
4.9 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/auth"
|
||
"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")
|
||
)
|
||
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()
|
||
|
||
api := httpapi.New(st, signer, log)
|
||
|
||
go api.Hub().Run(ctx)
|
||
go pruneLoop(ctx, st, log, *pruneAge)
|
||
|
||
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}))
|
||
}
|