Netpulse_SasS/server/cmd/netpulse-api/main.go
byrsapty a4faf31fcd Етап 5: користувачі, вхід і права
Досі доступ давав машинний токен зі змінної збірки — одні права на всіх
і жодного способу відрізнити, хто що зробив. Тепер продукт уміє впустити
людину.

Сервер:
- 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>
2026-08-15 10:24:58 +03:00

158 lines
4.9 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/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}))
}