Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.
ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ
0058 подієві алерти: syslog, ncm, compliance спрацьовують у мить
події; правило з нереалізованим джерелом більше не зберігається
мовчки
0059 snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
описуються шаблоном, а не Go
0060 відкат конфігу: план як різниця, маскування паролів із підписом
плану, обов'язковий контрольний збір, verifying при обриві
0061 кнопки Telegram: довге опитування, авторизація не з callback_data
0062 аудит і архів хостів; тест на AST, що падає на ключі без назви
0063 RLS: три ролі, окремий пул для фонових тактів
0064 строки зберігання даних і сторінка сховища
0065 приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
переклад v1→v2 за RFC 3584 дає правильний OID
0066 ескалації сповіщень
0067 алерт про вичерпання диска
0068 поля заливки конфігу переїхали в каталог профілів
Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх
вад, знайдених ними й виправлених, і виправлення двох інтеграційних
тестів grpcapi, які мовчки пропускались півтора року.
ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ
netpulse установник: одна команда замість 18 змінних і
593 рядків інструкції
RLS з першого запуску нова інсталяція під політиками одразу;
RLS-EXISTING-INSTALL.md лишається тільки для
старих інсталяцій
.forgejo + CI раннер не зареєстрований
Ці три перевірені компіляцією й міркуванням, але не виконанням.
ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ
Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.
Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.
Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
358 lines
14 KiB
Go
358 lines
14 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||
"google.golang.org/protobuf/types/known/durationpb"
|
||
)
|
||
|
||
// ServerFilesConfigType — під якою назвою набір конфіг-файлів сервера
|
||
// лежить в архіві поруч із running-конфігами заліза.
|
||
//
|
||
// Один config_type на весь набір, а не по одному на файл. Причина
|
||
// написана в агента (agent/internal/localfiles/bundle.go); тут важлива
|
||
// її серверна половина: шлях у Git виводиться з пари (ім'я хоста,
|
||
// config_type) у ДВОХ місцях — StoreConfig і SyncGit, — і версія на
|
||
// файл вимагала б вигадати другу розкладку репозиторію поряд із
|
||
// наявною. Набір лягає в наявну як є: <хост>/files.cfg, гілка
|
||
// device/<id>. Ретеншен, перегляд, порівняння, дзеркалення — без
|
||
// жодної правки.
|
||
const ServerFilesConfigType = "files"
|
||
|
||
// Стелі, які сервер надсилає зонду.
|
||
//
|
||
// Вони вміють лише ЗВУЖУВАТИ дозволене машиною — зонд бере мінімум зі
|
||
// свого й цього. Тримати їх тут усе одно варто: тенант із вузьким
|
||
// каналом має чим обмежити набір, не ходячи по всіх серверах.
|
||
const (
|
||
ServerFilesMaxFileBytes = 1 << 20
|
||
ServerFilesMaxFiles = 64
|
||
ServerFilesMaxTotalBytes = 8 << 20
|
||
)
|
||
|
||
// ErrMachineChanged — набір приїхав з іншої машини, ніж закріплена.
|
||
var ErrMachineChanged = errors.New("зонд працює на іншій машині, ніж закріплена за цим хостом")
|
||
|
||
// SelfDeviceOf каже, чи є цей хост машиною власного зонда.
|
||
//
|
||
// Повертає зонда, бо саме він виконуватиме завдання: для такого хоста
|
||
// не існує ні адреси, ні доступу, ні профілю — збирати нема куди йти.
|
||
func (s *Store) SelfDeviceOf(ctx context.Context, tenantID, deviceID string) (string, bool, error) {
|
||
var agentID *string
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
return tx.QueryRow(ctx, `
|
||
SELECT self_agent_id::text
|
||
FROM inv.devices
|
||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||
`, deviceID, tenantID).Scan(&agentID)
|
||
})
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return "", false, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return "", false, err
|
||
}
|
||
if agentID == nil || *agentID == "" {
|
||
return "", false, nil
|
||
}
|
||
return *agentID, true, nil
|
||
}
|
||
|
||
// BindSelfDevice оголошує хост машиною зонда.
|
||
//
|
||
// Робить три речі одним рухом, і кожна потрібна.
|
||
//
|
||
// Перша — власне прив'язка. Друга — agent_id: завдання роздаються за
|
||
// ним (ncm.jobs.agent_id береться з inv.devices.agent_id), і хост, що
|
||
// збирає сам себе, мусить бути «за» тим самим зондом, інакше завдання
|
||
// поїде тому, хто не має цих файлів. Третя — рядок розкладу: без нього
|
||
// сервер збирав би файли лише кнопкою, а конфіги заліза — за cron, і
|
||
// два шляхи розійшлися б рівно там, де їх найважче звести назад.
|
||
//
|
||
// Відбиток машини скидається: прив'язка до іншого зонда — це інша
|
||
// машина, і закріплений відбиток попередньої зробив би перший же збір
|
||
// відмовою.
|
||
func (s *Store) BindSelfDevice(ctx context.Context, tenantID, deviceID, agentID string) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
var ok bool
|
||
if err := tx.QueryRow(ctx, `
|
||
SELECT true FROM core.agents WHERE id = $1 AND tenant_id = $2
|
||
`, agentID, tenantID).Scan(&ok); err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return fmt.Errorf("зонда %s немає в цьому кабінеті", agentID)
|
||
}
|
||
return err
|
||
}
|
||
|
||
tag, err := tx.Exec(ctx, `
|
||
UPDATE inv.devices
|
||
SET self_agent_id = $3::uuid,
|
||
self_machine_id = NULL,
|
||
agent_id = $3::uuid
|
||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||
`, deviceID, tenantID, agentID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return ErrNotFound
|
||
}
|
||
|
||
// Розклад заводиться, лише якщо його ще немає: людина могла
|
||
// налаштувати cron до прив'язки, і перезаписати його типовим
|
||
// означало б тихо змінити час збору.
|
||
_, err = tx.Exec(ctx, `
|
||
INSERT INTO ncm.device_policies (device_id, tenant_id)
|
||
VALUES ($1, $2)
|
||
ON CONFLICT (device_id) DO NOTHING
|
||
`, deviceID, tenantID)
|
||
return err
|
||
})
|
||
}
|
||
|
||
// UnbindSelfDevice знімає прив'язку.
|
||
//
|
||
// agent_id не чіпаємо: хост міг би далі опитуватись тим самим зондом
|
||
// звичайним способом, і зняти йому зонда «заразом» означало б осліпити
|
||
// моніторинг дією, про яку просили зовсім інше.
|
||
func (s *Store) UnbindSelfDevice(ctx context.Context, tenantID, deviceID string) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
tag, err := tx.Exec(ctx, `
|
||
UPDATE inv.devices
|
||
SET self_agent_id = NULL, self_machine_id = NULL
|
||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||
`, deviceID, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// PinSelfMachine звіряє відбиток машини й закріплює його за карткою.
|
||
//
|
||
// Перший набір закріплює, наступні звіряються. Розбіжність — відмова:
|
||
// краще втратити один збір і сказати про це, ніж дописати файли іншого
|
||
// сервера в чужу історію так, що це виглядатиме звичайною зміною
|
||
// конфігу.
|
||
//
|
||
// Скидання — це UnbindSelfDevice + BindSelfDevice, тобто свідома дія
|
||
// людини в кабінеті. Автоматичного «ну добре, значить переїхав» тут
|
||
// немає навмисно: саме воно й зробило б перевірку декоративною.
|
||
func (s *Store) PinSelfMachine(ctx context.Context, tenantID, deviceID, machineID string) error {
|
||
if machineID == "" {
|
||
return nil // не набір локальних файлів
|
||
}
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
var (
|
||
pinned *string
|
||
bound *string
|
||
present bool
|
||
)
|
||
err := tx.QueryRow(ctx, `
|
||
SELECT self_machine_id, self_agent_id::text, true
|
||
FROM inv.devices
|
||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||
`, deviceID, tenantID).Scan(&pinned, &bound, &present)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if bound == nil || *bound == "" {
|
||
return errors.New("хост не оголошено машиною зонда")
|
||
}
|
||
|
||
if pinned == nil || *pinned == "" {
|
||
_, err = tx.Exec(ctx, `
|
||
UPDATE inv.devices SET self_machine_id = $3
|
||
WHERE id = $1 AND tenant_id = $2
|
||
`, deviceID, tenantID, machineID)
|
||
return err
|
||
}
|
||
if *pinned != machineID {
|
||
return fmt.Errorf("%w: закріплено «%s», прийшло «%s»",
|
||
ErrMachineChanged, *pinned, machineID)
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// SelfDeviceRow — рядок сторінки «Конфіги серверів».
|
||
type SelfDeviceRow struct {
|
||
DeviceID string `json:"device_id"`
|
||
DeviceName string `json:"device_name"`
|
||
Kind string `json:"kind"`
|
||
|
||
AgentID string `json:"agent_id"`
|
||
AgentName string `json:"agent_name"`
|
||
AgentStatus string `json:"agent_status"`
|
||
|
||
// Відбиток машини, закріплений за карткою. Порожній до першого
|
||
// прийнятого набору — і саме тому показується: людина має бачити,
|
||
// що прив'язка ще нічим не підтверджена.
|
||
MachineID string `json:"machine_id,omitempty"`
|
||
|
||
ScheduleEnabled bool `json:"schedule_enabled"`
|
||
ScheduleCron string `json:"schedule_cron,omitempty"`
|
||
|
||
LastCollectedAt *time.Time `json:"last_collected_at,omitempty"`
|
||
Versions int `json:"versions"`
|
||
LastConfigID string `json:"last_config_id,omitempty"`
|
||
LastJobStatus string `json:"last_job_status,omitempty"`
|
||
LastJobError string `json:"last_job_error,omitempty"`
|
||
}
|
||
|
||
// ListSelfDevices — усі хости кабінету, оголошені машинами зондів.
|
||
//
|
||
// Межі — Readable: на цю сторінку приходять дивитись архів.
|
||
func (s *Store) ListSelfDevices(ctx context.Context, tenantID string, sc Scope) ([]SelfDeviceRow, error) {
|
||
sql := `
|
||
SELECT d.id::text, d.name, d.kind::text,
|
||
a.id::text, a.name, a.status::text,
|
||
COALESCE(d.self_machine_id,''),
|
||
COALESCE(p.enabled,false), COALESCE(p.cron,''),
|
||
c.collected_at, COALESCE(c.n,0), COALESCE(c.id::text,''),
|
||
COALESCE(j.status::text,''), COALESCE(j.error,'')
|
||
FROM inv.devices d
|
||
JOIN core.agents a ON a.id = d.self_agent_id
|
||
LEFT JOIN ncm.device_policies p ON p.device_id = d.id
|
||
LEFT JOIN LATERAL (
|
||
SELECT x.id, x.collected_at, count(*) OVER () AS n
|
||
FROM ncm.configs x
|
||
WHERE x.device_id = d.id AND x.config_type = $2
|
||
ORDER BY x.collected_at DESC
|
||
LIMIT 1
|
||
) c ON true
|
||
LEFT JOIN LATERAL (
|
||
SELECT x.status, x.error FROM ncm.jobs x
|
||
WHERE x.device_id = d.id
|
||
ORDER BY x.created_at DESC LIMIT 1
|
||
) j ON true
|
||
WHERE d.tenant_id = $1 AND d.deleted_at IS NULL
|
||
`
|
||
args := []any{tenantID, ServerFilesConfigType}
|
||
if !sc.Unrestricted {
|
||
sql += ` AND d.id = ANY($3::uuid[])`
|
||
args = append(args, sc.Readable)
|
||
}
|
||
sql += ` ORDER BY d.name`
|
||
|
||
var out []SelfDeviceRow
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, sql, args...)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var r SelfDeviceRow
|
||
if err := rows.Scan(&r.DeviceID, &r.DeviceName, &r.Kind,
|
||
&r.AgentID, &r.AgentName, &r.AgentStatus, &r.MachineID,
|
||
&r.ScheduleEnabled, &r.ScheduleCron,
|
||
&r.LastCollectedAt, &r.Versions, &r.LastConfigID,
|
||
&r.LastJobStatus, &r.LastJobError); err != nil {
|
||
return err
|
||
}
|
||
out = append(out, r)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
// SelfAgentOption — зонд, якого можна оголосити машиною хоста.
|
||
type SelfAgentOption struct {
|
||
AgentID string `json:"agent_id"`
|
||
Name string `json:"name"`
|
||
Hostname string `json:"hostname,omitempty"`
|
||
Status string `json:"status"`
|
||
OS string `json:"os,omitempty"`
|
||
// Уже прив'язаний до іншої картки — вибрати вдруге не можна.
|
||
BoundTo string `json:"bound_to,omitempty"`
|
||
}
|
||
|
||
// SelfAgentOptions — довідник для форми прив'язки.
|
||
func (s *Store) SelfAgentOptions(ctx context.Context, tenantID string) ([]SelfAgentOption, error) {
|
||
var out []SelfAgentOption
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT a.id::text, a.name, COALESCE(a.hostname,''), a.status::text,
|
||
COALESCE(a.os,''), COALESCE(d.name,'')
|
||
FROM core.agents a
|
||
LEFT JOIN inv.devices d
|
||
ON d.self_agent_id = a.id AND d.deleted_at IS NULL
|
||
WHERE a.tenant_id = $1
|
||
ORDER BY a.name
|
||
`, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var o SelfAgentOption
|
||
if err := rows.Scan(&o.AgentID, &o.Name, &o.Hostname, &o.Status,
|
||
&o.OS, &o.BoundTo); err != nil {
|
||
return err
|
||
}
|
||
out = append(out, o)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
// buildLocalFilesJob складає завдання «прочитай власні конфіг-файли».
|
||
//
|
||
// Найкоротше завдання в системі, і це не випадковість, а вимога
|
||
// безпеки: у ньому немає жодного поля, яким сервер міг би назвати файл.
|
||
// Ні шляху, ні маски, ні каталогу — лише прохання зібрати й стелі, які
|
||
// вміють тільки звузити дозволене машиною. Усе, що буде прочитано,
|
||
// перелічене в локальному файлі зонда, куди сервер не дотягується.
|
||
//
|
||
// Тому тут немає ні transport, ні credential, ні commands: не тому, що
|
||
// «не потрібні», а тому, що сесії до пристрою не існує взагалі.
|
||
func (s *Store) buildLocalFilesJob(ctx context.Context, tenantID, deviceID, jobID, agentID string) (*npv1.ConfigJob, error) {
|
||
var devName string
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
return tx.QueryRow(ctx, `
|
||
SELECT name FROM inv.devices
|
||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||
`, deviceID, tenantID).Scan(&devName)
|
||
})
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return &npv1.ConfigJob{
|
||
JobId: jobID,
|
||
Device: &npv1.DeviceTarget{
|
||
DeviceId: deviceID,
|
||
Name: devName,
|
||
},
|
||
ConfigType: ServerFilesConfigType,
|
||
Timeout: durationpb.New(2 * time.Minute),
|
||
MaxBytes: ServerFilesMaxTotalBytes,
|
||
LocalFiles: &npv1.LocalFilesJob{
|
||
MaxFileBytes: ServerFilesMaxFileBytes,
|
||
MaxFiles: ServerFilesMaxFiles,
|
||
MaxTotalBytes: ServerFilesMaxTotalBytes,
|
||
},
|
||
// Транскрипт тут — протокол збору: які файли взято, які
|
||
// пропущено й чому. Потрібен рівно тоді, коли в наборі виявилось
|
||
// не те, що очікували, а іншого способу це побачити немає.
|
||
CaptureTranscript: true,
|
||
}, nil
|
||
}
|