Netpulse_SasS/server/internal/store/agents.go
byrsapty ddaae60fa0 Етап 8: реєстрація зонда одноразовим запрошенням
Найбільше вузьке місце до запуску: агент заводився INSERT-ом у базу, а
токен вписувався в командний рядок руками. Поставити зонд у клієнта було
неможливо.

core.agent_enrollments тримає sha256 одноразового токена; сам токен
повертається рівно один раз. Видача під FOR UPDATE в одній транзакції:
два агенти з однієї скопійованої команди інакше створили б два зонди з
одного запрошення. Відповідь на «немає», «згоріло» і «використано»
однакова — розрізняти їх означає підказувати тому, хто підбирає токени.

Токен зонда їде окремим полем agent_token, а не в certificate:
сертифікат відповідає на інше питання й живе за іншим циклом.

Агент зберігає посвідчення в /etc/netpulse/agent.json з правами 0600,
через тимчасовий файл і перейменування — обрив живлення посеред запису
інакше лишив би половину токена.

Знайдено живим прогоном: реєстрація не проходила автентифікацію, бо
інтерсептор стоїть на всьому сервері, а не на окремому сервісі — мій же
коментар стверджував протилежне. І запуск із самим посвідченням падав:
validate() вимагав -agent-id, не знаючи про файл.

Сторінка зондів: команда встановлення з токеном, відкликання
запрошень, керування модулями й лімітами, видалення.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 00:50:57 +03:00

314 lines
9.6 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.

package store
import (
"context"
"encoding/json"
"crypto/sha256"
"crypto/subtle"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
)
var ErrAgentNotFound = errors.New("зонд не знайдено або токен відкликано")
// Agent — ідентичність зонда після автентифікації.
type Agent struct {
ID string
TenantID string
Name string
SiteID string
Modules []string
Limits Limits
}
// Limits — ліміти опитування, які сервер диктує зонду в Welcome.
type Limits struct {
MaxConcurrency int
IcmpRatePPS int
BatchSize int
BatchInterval time.Duration
MaxInFlight int
HeartbeatEvery time.Duration
}
func defaultLimits() Limits {
return Limits{
MaxConcurrency: 256,
IcmpRatePPS: 500,
BatchSize: 500,
BatchInterval: 5 * time.Second,
MaxInFlight: 4,
HeartbeatEvery: 30 * time.Second,
}
}
// AuthenticateAgent знаходить зонда за токеном.
//
// У БД лежить лише sha256 — токен у відкритому вигляді не зберігається
// ніде. Порівняння хешів робиться в SQL за унікальним індексом; на
// знайденому рядку додатково звіряємо constant-time, щоб форма запиту
// не залежала від того, як індекс порівнював байти.
func (s *Store) AuthenticateAgent(ctx context.Context, token string) (*Agent, error) {
if token == "" {
return nil, ErrAgentNotFound
}
sum := sha256.Sum256([]byte(token))
var (
a Agent
siteID *string
hash []byte
status string
limits map[string]any
)
err := s.pool.QueryRow(ctx, `
SELECT id::text, tenant_id::text, name, site_id::text, token_hash,
status::text, enabled_modules, limits
FROM core.agents
WHERE token_hash = $1
`, sum[:]).Scan(&a.ID, &a.TenantID, &a.Name, &siteID, &hash, &status, &a.Modules, &limits)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrAgentNotFound
}
if err != nil {
return nil, err
}
if subtle.ConstantTimeCompare(hash, sum[:]) != 1 {
return nil, ErrAgentNotFound
}
if status == "disabled" {
return nil, fmt.Errorf("%w: зонд вимкнено", ErrAgentNotFound)
}
if siteID != nil {
a.SiteID = *siteID
}
a.Limits = defaultLimits()
applyLimitOverrides(&a.Limits, limits)
return &a, nil
}
func applyLimitOverrides(l *Limits, m map[string]any) {
num := func(key string) (int, bool) {
v, ok := m[key]
if !ok {
return 0, false
}
switch n := v.(type) {
case float64:
return int(n), true
case int64:
return int(n), true
}
return 0, false
}
if v, ok := num("max_concurrency"); ok && v > 0 {
l.MaxConcurrency = v
}
if v, ok := num("icmp_rate_pps"); ok && v > 0 {
l.IcmpRatePPS = v
}
if v, ok := num("batch_size"); ok && v > 0 {
l.BatchSize = v
}
if v, ok := num("max_in_flight"); ok && v > 0 {
l.MaxInFlight = v
}
}
// MarkAgentOnline фіксує підключення зонда та його версію.
func (s *Store) MarkAgentOnline(ctx context.Context, a *Agent, hello *npv1.Hello) error {
build := hello.GetBuild()
return s.InTenantTx(ctx, a.TenantID, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
UPDATE core.agents
SET status = 'online',
last_heartbeat_at = now(),
version = COALESCE(NULLIF($2,''), version),
os = COALESCE(NULLIF($3,''), os),
arch = COALESCE(NULLIF($4,''), arch),
hostname= COALESCE(NULLIF($5,''), hostname),
updated_at = now()
WHERE id = $1 AND tenant_id = $6
`, a.ID, build.GetVersion(), build.GetOs(), build.GetArch(),
hello.GetHostname(), a.TenantID)
return err
})
}
// MarkAgentOffline викликається при розриві сесії.
func (s *Store) MarkAgentOffline(ctx context.Context, a *Agent) error {
return s.InTenantTx(ctx, a.TenantID, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
UPDATE core.agents SET status = 'offline', updated_at = now()
WHERE id = $1 AND tenant_id = $2
`, a.ID, a.TenantID)
return err
})
}
// RecordHeartbeat пише самометрики зонда в ts.agent_health і оновлює
// зведення в core.agents.health для швидкого показу в UI.
func (s *Store) RecordHeartbeat(ctx context.Context, a *Agent, hb *npv1.Heartbeat) error {
h := hb.GetHealth()
ts := hb.GetTs().AsTime()
if ts.IsZero() {
ts = time.Now()
}
batch := &pgx.Batch{}
batch.Queue(`
INSERT INTO ts.agent_health
(ts, agent_id, tenant_id, cpu_pct, rss_bytes, goroutines,
queue_depth, checks_per_sec, errors_per_min)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
ON CONFLICT (ts, agent_id) DO NOTHING
`, ts, a.ID, a.TenantID, h.GetCpuPct(), int64(h.GetRssBytes()),
int32(h.GetGoroutines()), int32(h.GetQueueDepth()),
h.GetChecksPerSec(), h.GetErrorsPerMin())
// dropped_samples у зведенні — це видима діра в даних; вона має
// бути помітна оператору, а не тільки в графіку самометрик.
batch.Queue(`
UPDATE core.agents
SET last_heartbeat_at = now(),
status = 'online',
health = jsonb_build_object(
'rss_bytes', $3::bigint,
'queue_depth', $4::int,
'dropped_samples', $5::bigint,
'tasks_running', $6::int,
'clock_skew_ms', $7::bigint
)
WHERE id = $1 AND tenant_id = $2
`, a.ID, a.TenantID, int64(h.GetRssBytes()), int32(h.GetQueueDepth()),
int64(h.GetDroppedSamples()), int32(hb.GetTasksRunning()),
h.GetClockSkew().AsDuration().Milliseconds())
res := s.pool.SendBatch(ctx, batch)
defer res.Close()
for i := 0; i < batch.Len(); i++ {
if _, err := res.Exec(); err != nil {
return fmt.Errorf("heartbeat[%d]: %w", i, err)
}
}
return nil
}
// RecordTaskStatus оновлює core.checks за доповіддю агента.
func (s *Store) RecordTaskStatus(ctx context.Context, a *Agent, u *npv1.TaskStatusUpdate) error {
var errText any
if u.GetError() != nil {
errText = u.GetError().GetCode() + ": " + u.GetError().GetMessage()
}
return s.InTenantTx(ctx, a.TenantID, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
UPDATE core.checks
SET last_run_at = COALESCE($3, now()),
last_error = $4,
updated_at = now()
WHERE id = $1 AND tenant_id = $2
`, u.GetCheckId(), a.TenantID, tsOrNil(u), errText)
return err
})
}
func tsOrNil(u *npv1.TaskStatusUpdate) any {
if u.GetTs() == nil {
return nil
}
return u.GetTs().AsTime()
}
// AgentUpdate — що можна змінити в зонді з UI.
//
// Порожні поля не чіпаються: форма може надіслати лише ліміти, не
// торкаючись імені й модулів.
type AgentUpdate struct {
Name string
Modules []string
Limits *Limits
}
// UpdateAgent змінює налаштування зонда.
//
// Зміна доїжджає до живого зонда не миттєво: модулі й ліміти їдуть у
// Welcome при наступному підключенні, а план — звіркою раз на п'ять
// секунд. Це свідомо: тримати окрему чергу «керівних» повідомлень
// заради налаштувань, які міняють раз на місяць, дорожче за саму
// затримку.
func (s *Store) UpdateAgent(ctx context.Context, tenantID, agentID string, u AgentUpdate) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
if u.Name != "" {
if _, err := tx.Exec(ctx, `
UPDATE core.agents SET name = $3 WHERE id = $1 AND tenant_id = $2
`, agentID, tenantID, u.Name); err != nil {
return err
}
}
if u.Modules != nil {
if _, err := tx.Exec(ctx, `
UPDATE core.agents SET enabled_modules = $3::core.slug[]
WHERE id = $1 AND tenant_id = $2
`, agentID, tenantID, u.Modules); err != nil {
return err
}
}
if u.Limits != nil {
// Зливаємо в наявний jsonb, а не заміняємо: у limits можуть
// лежати поля, яких форма не знає, і затирати їх мовчки —
// найшвидший спосіб зламати те, чого не бачив.
patch := map[string]any{}
if u.Limits.MaxConcurrency > 0 {
patch["max_concurrency"] = u.Limits.MaxConcurrency
}
if u.Limits.IcmpRatePPS > 0 {
patch["icmp_rate_pps"] = u.Limits.IcmpRatePPS
}
if u.Limits.BatchSize > 0 {
patch["batch_size"] = u.Limits.BatchSize
}
if u.Limits.MaxInFlight > 0 {
patch["max_in_flight"] = u.Limits.MaxInFlight
}
if len(patch) > 0 {
b, err := json.Marshal(patch)
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `
UPDATE core.agents SET limits = limits || $3::jsonb
WHERE id = $1 AND tenant_id = $2
`, agentID, tenantID, string(b)); err != nil {
return err
}
}
}
return nil
})
}
// DeleteAgent прибирає зонд.
//
// Хости, які він опитував, лишаються без зонда (ON DELETE SET NULL) —
// видаляти їх разом означало б втратити історію через заміну заліза.
func (s *Store) DeleteAgent(ctx context.Context, tenantID, agentID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
ct, err := tx.Exec(ctx,
`DELETE FROM core.agents WHERE id = $1 AND tenant_id = $2`, agentID, tenantID)
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
}