Найбільше вузьке місце до запуску: агент заводився 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>
164 lines
5.9 KiB
Go
164 lines
5.9 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
)
|
||
|
||
type DeviceSummary struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Address string `json:"address,omitempty"`
|
||
Kind string `json:"kind"`
|
||
Vendor string `json:"vendor,omitempty"`
|
||
Model string `json:"model,omitempty"`
|
||
SiteName string `json:"site_name,omitempty"`
|
||
Status string `json:"status"`
|
||
Enabled bool `json:"enabled"`
|
||
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
|
||
AgentID string `json:"agent_id,omitempty"`
|
||
IfaceCount int `json:"interface_count"`
|
||
GroupIDs []string `json:"group_ids"`
|
||
// Writable каже інтерфейсу, чи показувати дії. Рахується з груп
|
||
// користувача, а не з ролі: роль дозволяє редагувати пристрої взагалі,
|
||
// групи — саме цей.
|
||
Writable bool `json:"writable"`
|
||
}
|
||
|
||
// ListDevices — інвентар для таблиці в UI.
|
||
//
|
||
// Фільтр видимості накладається в самому запиті, а не після вибірки:
|
||
// відсіювати вже прочитане означало б тягнути з БД чужі рядки й
|
||
// покладатися на те, що жоден із них не проскочить у відповідь.
|
||
func (s *Store) ListDevices(ctx context.Context, tenantID string, sc Scope) ([]DeviceSummary, error) {
|
||
var out []DeviceSummary
|
||
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT d.id::text, d.name, COALESCE(host(d.address), ''), d.kind::text,
|
||
COALESCE(d.vendor,''), COALESCE(d.model,''), COALESCE(st.name,''),
|
||
d.status::text, d.enabled, d.last_seen_at, COALESCE(d.agent_id::text,''),
|
||
(SELECT count(*) FROM inv.interfaces i WHERE i.device_id = d.id)
|
||
FROM inv.devices d
|
||
LEFT JOIN inv.sites st ON st.id = d.site_id
|
||
WHERE d.tenant_id = $1 AND d.deleted_at IS NULL
|
||
AND ($2::boolean OR d.id = ANY($3::uuid[]))
|
||
ORDER BY d.name
|
||
`, tenantID, sc.Unrestricted, nonNilIDs(sc.Readable))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var d DeviceSummary
|
||
if err := rows.Scan(&d.ID, &d.Name, &d.Address, &d.Kind, &d.Vendor, &d.Model,
|
||
&d.SiteName, &d.Status, &d.Enabled, &d.LastSeenAt, &d.AgentID,
|
||
&d.IfaceCount); err != nil {
|
||
return err
|
||
}
|
||
d.Writable = sc.CanWrite(d.ID)
|
||
d.GroupIDs = []string{}
|
||
out = append(out, d)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Групи добираємо одним запитом і розкладаємо в пам'яті: агрегат у
|
||
// головному запиті змусив би його групуватись по всіх колонках.
|
||
groups, err := s.DeviceGroupsOf(ctx, tenantID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for i := range out {
|
||
if g, ok := groups[out[i].ID]; ok {
|
||
out[i].GroupIDs = g
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
type AgentSummary struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Status string `json:"status"`
|
||
Version string `json:"version,omitempty"`
|
||
OS string `json:"os,omitempty"`
|
||
Arch string `json:"arch,omitempty"`
|
||
Hostname string `json:"hostname,omitempty"`
|
||
LastHeartbeatAt *time.Time `json:"last_heartbeat_at,omitempty"`
|
||
EnabledModules []string `json:"enabled_modules"`
|
||
Health map[string]any `json:"health"`
|
||
DeviceCount int `json:"device_count"`
|
||
// Ліміти опитування. Потрібні формі налаштувань: показати типові
|
||
// значення там, де задані свої, означало б стерти їх першим же
|
||
// збереженням.
|
||
Limits map[string]any `json:"limits"`
|
||
}
|
||
|
||
// ListAgents — стан зондів для UI.
|
||
//
|
||
// health віддається як є: там лежить зведення з останнього heartbeat,
|
||
// зокрема dropped_samples. Оператор має бачити діру в даних без
|
||
// походу в графіки самометрик.
|
||
func (s *Store) ListAgents(ctx context.Context, tenantID string) ([]AgentSummary, error) {
|
||
var out []AgentSummary
|
||
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT a.id::text, a.name, a.status::text,
|
||
COALESCE(a.version,''), COALESCE(a.os,''), COALESCE(a.arch,''),
|
||
COALESCE(a.hostname,''), a.last_heartbeat_at, a.enabled_modules,
|
||
a.health, a.limits,
|
||
(SELECT count(*) FROM inv.devices d
|
||
WHERE d.agent_id = a.id AND d.deleted_at IS NULL)
|
||
FROM core.agents a
|
||
WHERE a.tenant_id = $1
|
||
ORDER BY a.name
|
||
`, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var a AgentSummary
|
||
if err := rows.Scan(&a.ID, &a.Name, &a.Status, &a.Version, &a.OS, &a.Arch,
|
||
&a.Hostname, &a.LastHeartbeatAt, &a.EnabledModules, &a.Health,
|
||
&a.Limits, &a.DeviceCount); err != nil {
|
||
return err
|
||
}
|
||
if a.EnabledModules == nil {
|
||
a.EnabledModules = []string{}
|
||
}
|
||
if a.Health == nil {
|
||
a.Health = map[string]any{}
|
||
}
|
||
if a.Limits == nil {
|
||
a.Limits = map[string]any{}
|
||
}
|
||
out = append(out, a)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
|
||
return out, err
|
||
}
|
||
|
||
// MapTenant повертає тенанта мапи — потрібно WebSocket-у, щоб не дати
|
||
// підписатись на чужу мапу за вгаданим id.
|
||
func (s *Store) MapExists(ctx context.Context, tenantID, mapID string) (bool, error) {
|
||
var exists bool
|
||
err := s.pool.QueryRow(ctx, `
|
||
SELECT EXISTS (
|
||
SELECT 1 FROM topo.maps
|
||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||
)
|
||
`, mapID, tenantID).Scan(&exists)
|
||
return exists, err
|
||
}
|