Найбільше вузьке місце до запуску: агент заводився 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>
151 lines
5.2 KiB
Go
151 lines
5.2 KiB
Go
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)
|
||
}
|