Netpulse_SasS/server/internal/httpapi/agents_manage.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

151 lines
5.2 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 httpapi
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/netpulse/netpulse/server/internal/store"
)
// Модулі, які вміє зонд. Дублює перелік у бінарнику навмисно: увімкнути
// в UI те, чого агент не вміє, означає мовчки не отримати даних.
var validAgentModules = map[string]bool{
"icmp": true, "snmp": true, "topology": true,
"ncm": true, "syslog": true, "http": true,
}
func (s *Server) handleListEnrollments(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "agents:read") {
return
}
list, err := s.store.ListEnrollments(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "запрошення зондів", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"enrollments": list})
}
// handleCreateEnrollment видає одноразовий токен.
//
// Токен повертається рівно тут і більше ніде: у базі лежить лише його
// sha256. Якщо людина закрила вікно, не скопіювавши команду — простіше
// створити нове запрошення, ніж тримати чинний доступ у базі заради
// такого випадку.
func (s *Server) handleCreateEnrollment(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "agents:write") {
return
}
var in struct {
NameHint string `json:"name_hint"`
Modules []string `json:"modules"`
TTLHours int `json:"ttl_hours"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
return
}
for _, m := range in.Modules {
if !validAgentModules[m] {
writeError(w, http.StatusBadRequest, "bad_module", "невідомий модуль: "+m)
return
}
}
ttl := time.Duration(in.TTLHours) * time.Hour
if in.TTLHours <= 0 {
ttl = store.EnrollmentTTL
}
// Тиждень — стеля. Запрошення, що живе місяць, перестає бути
// запрошенням і стає постійним ключем у чиємусь чаті.
if ttl > 7*24*time.Hour {
ttl = 7 * 24 * time.Hour
}
e, err := s.store.CreateEnrollment(r.Context(), p.TenantID, p.UserID,
strings.TrimSpace(in.NameHint), in.Modules, ttl)
if err != nil {
s.writeStoreError(w, "створення запрошення", err)
return
}
writeJSON(w, http.StatusCreated, e)
}
func (s *Server) handleDeleteEnrollment(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "agents:write") {
return
}
if err := s.store.DeleteEnrollment(r.Context(), p.TenantID, r.PathValue("id")); err != nil {
s.writeStoreError(w, "видалення запрошення", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------
// Керування зондом
// ---------------------------------------------------------------------
func (s *Server) handleUpdateAgent(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "agents:write") {
return
}
var in struct {
Name string `json:"name"`
Modules []string `json:"modules"`
Limits *struct {
MaxConcurrency int `json:"max_concurrency"`
IcmpRatePPS int `json:"icmp_rate_pps"`
BatchSize int `json:"batch_size"`
MaxInFlight int `json:"max_in_flight"`
} `json:"limits"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
return
}
for _, m := range in.Modules {
if !validAgentModules[m] {
writeError(w, http.StatusBadRequest, "bad_module", "невідомий модуль: "+m)
return
}
}
upd := store.AgentUpdate{Name: strings.TrimSpace(in.Name), Modules: in.Modules}
if in.Limits != nil {
upd.Limits = &store.Limits{
MaxConcurrency: in.Limits.MaxConcurrency,
IcmpRatePPS: in.Limits.IcmpRatePPS,
BatchSize: in.Limits.BatchSize,
MaxInFlight: in.Limits.MaxInFlight,
}
}
if err := s.store.UpdateAgent(r.Context(), p.TenantID, r.PathValue("id"), upd); err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate", "зонд із такою назвою вже є")
return
}
s.writeStoreError(w, "оновлення зонда", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleDeleteAgent прибирає зонд.
//
// Хости, які він опитував, лишаються — просто без зонда. Видаляти їх
// разом означало б втратити історію через заміну заліза, на якому
// стояв агент.
func (s *Server) handleDeleteAgent(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "agents:write") {
return
}
if err := s.store.DeleteAgent(r.Context(), p.TenantID, r.PathValue("id")); err != nil {
s.writeStoreError(w, "видалення зонда", err)
return
}
w.WriteHeader(http.StatusNoContent)
}