Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (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 серпня.
369 lines
13 KiB
Go
369 lines
13 KiB
Go
// Команда netpulse-agent — легкий зонд збору телеметрії.
|
||
//
|
||
// Єдиний статичний бінарник. Усі з'єднання вихідні: у мережі клієнта
|
||
// не потрібно відкривати жодного порту.
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"crypto/tls"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"os"
|
||
"os/signal"
|
||
"runtime"
|
||
"runtime/debug"
|
||
"sync"
|
||
"syscall"
|
||
"time"
|
||
|
||
"github.com/netpulse/netpulse/agent/internal/config"
|
||
"github.com/netpulse/netpulse/agent/internal/module"
|
||
"github.com/netpulse/netpulse/agent/internal/modules/filecfg"
|
||
"github.com/netpulse/netpulse/agent/internal/modules/httpx"
|
||
"github.com/netpulse/netpulse/agent/internal/modules/icmp"
|
||
"github.com/netpulse/netpulse/agent/internal/modules/snmp"
|
||
"github.com/netpulse/netpulse/agent/internal/modules/syslog"
|
||
"github.com/netpulse/netpulse/agent/internal/modules/topology"
|
||
"github.com/netpulse/netpulse/agent/internal/modules/traps"
|
||
"github.com/netpulse/netpulse/agent/internal/scheduler"
|
||
"github.com/netpulse/netpulse/agent/internal/session"
|
||
"github.com/netpulse/netpulse/agent/internal/telemetry"
|
||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||
"google.golang.org/grpc"
|
||
"google.golang.org/grpc/credentials"
|
||
"google.golang.org/grpc/credentials/insecure"
|
||
"google.golang.org/grpc/keepalive"
|
||
)
|
||
|
||
// version підставляється при збірці: -ldflags "-X main.version=1.2.3"
|
||
var (
|
||
version = "dev"
|
||
commit = "none"
|
||
)
|
||
|
||
func main() {
|
||
if err := run(); err != nil {
|
||
fmt.Fprintln(os.Stderr, "netpulse-agent:", err)
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
|
||
func run() error {
|
||
cfg, err := config.Parse(os.Args[1:])
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Для реєстрації досить версії й платформи: перелік скомпільованих
|
||
// модулів збирається нижче, разом із реєстром, і чекати на нього
|
||
// заради Enroll немає сенсу.
|
||
enrollBuild := &npv1.AgentBuild{
|
||
Version: version,
|
||
Commit: commit,
|
||
Os: runtime.GOOS,
|
||
Arch: runtime.GOARCH,
|
||
}
|
||
|
||
// Наявне посвідчення важить більше за запрошення.
|
||
//
|
||
// Запрошення одноразове, а живе воно в змінних оточення чи в unit-
|
||
// файлі — тобто лишається на місці назавжди. Кожен перезапуск зонда
|
||
// намагався б зареєструватись повторно й падав із «запрошення
|
||
// недійсне», хоча посвідчення поруч і цілком робоче.
|
||
//
|
||
// Знайдено живим розгортанням: контейнер із NETPULSE_ENROLL у .env
|
||
// піднявся один раз, а після першого ж restart уже не вставав.
|
||
saved, err := config.LoadIdentity(cfg.IdentityPath)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if saved != nil && cfg.EnrollToken != "" {
|
||
fmt.Fprintf(os.Stderr,
|
||
"netpulse-agent: посвідчення вже є (%s), запрошення проігноровано\n",
|
||
cfg.IdentityPath)
|
||
cfg.EnrollToken = ""
|
||
}
|
||
|
||
// Реєстрація перед усім іншим.
|
||
//
|
||
// Посвідчення зберігається на диск одразу: одноразовий токен згорає
|
||
// на сервері, і другої спроби не буде — впасти після успішного
|
||
// Enroll означало б залишити людину без агента й без запрошення.
|
||
if cfg.EnrollToken != "" {
|
||
id, err := enroll(cfg, enrollBuild)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
cfg.AgentID, cfg.Token, cfg.Endpoint = id.AgentID, id.Token, id.Endpoint
|
||
fmt.Fprintf(os.Stderr, "netpulse-agent: зареєстровано як %q, посвідчення в %s\n",
|
||
id.AgentName, cfg.IdentityPath)
|
||
} else if cfg.AgentID == "" || cfg.Token == "" {
|
||
id := saved
|
||
if id == nil {
|
||
return fmt.Errorf(
|
||
"зонд не зареєстрований: посвідчення не знайдено (%s). "+
|
||
"Створіть запрошення в UI і запустіть із -enroll <токен>",
|
||
cfg.IdentityPath)
|
||
}
|
||
cfg.AgentID, cfg.Token = id.AgentID, id.Token
|
||
if cfg.Endpoint == "" {
|
||
cfg.Endpoint = id.Endpoint
|
||
}
|
||
}
|
||
|
||
if cfg.AgentID == "" || cfg.Token == "" {
|
||
return errors.New("зонд не зареєстрований: потрібні -enroll або -agent-id з -token")
|
||
}
|
||
|
||
log := newLogger(cfg)
|
||
|
||
// Бюджет пам'яті — вимога, а не побажання: зонд часто живе на
|
||
// роутері або в контейнері зі 64 МБ. GOMEMLIMIT змушує збирач
|
||
// працювати агресивніше замість того, щоб дати OOM killer'у
|
||
// вбити процес і осліпити моніторинг саме тоді, коли він потрібен.
|
||
debug.SetMemoryLimit(48 << 20)
|
||
|
||
reg := module.NewRegistry()
|
||
if err := reg.Register(icmp.New()); err != nil {
|
||
return err
|
||
}
|
||
if err := reg.Register(snmp.New()); err != nil {
|
||
return err
|
||
}
|
||
if err := reg.Register(topology.New()); err != nil {
|
||
return err
|
||
}
|
||
if err := reg.Register(httpx.New()); err != nil {
|
||
return err
|
||
}
|
||
// Модуль конфіг-файлів реєструється завжди, а не за переліком
|
||
// -modules: він не виконує чеків і нічого не споживає, доки його не
|
||
// попросили зібрати. Реєстрація тут лише для того, щоб він
|
||
// потрапив у compiled_modules — оголошення «цей зонд уміє читати
|
||
// локальні файли». Чи буде щось прочитано, вирішує не це й не
|
||
// сервер, а локальний перелік файлів на самій машині.
|
||
fileCfg := filecfg.New(cfg.FileCfgPolicy)
|
||
if err := reg.Register(fileCfg); err != nil {
|
||
return err
|
||
}
|
||
reg.EnsureDefaults(cfg.DefaultModules...)
|
||
|
||
// Приймач syslog не модуль реєстру: у нього немає задач і розкладу,
|
||
// він просто слухає порт. Вмикається тим самим переліком -modules,
|
||
// щоб людині не треба було знати про цю різницю.
|
||
//
|
||
// Приймач трапів — те саме: слухає 162/udp і теж не має ані задач,
|
||
// ані розкладу. Вмикається окремим модулем, а не разом із syslog:
|
||
// це другий порт, який має бути відкритий у фаєрволі клієнта, і
|
||
// відкривати його «заодно» ми не маємо права.
|
||
var (
|
||
syslogRecv *syslog.Receiver
|
||
trapRecv *traps.Receiver
|
||
)
|
||
for _, m := range cfg.DefaultModules {
|
||
switch m {
|
||
case "syslog":
|
||
syslogRecv = syslog.New(cfg.SyslogListen, log)
|
||
case "traps":
|
||
trapRecv = traps.New(cfg.TrapsListen, log)
|
||
}
|
||
}
|
||
|
||
buf := telemetry.NewBuffer(telemetry.Options{
|
||
MaxItems: cfg.BufferMaxItems,
|
||
MaxBytes: cfg.BufferMaxBytes,
|
||
})
|
||
|
||
build := &npv1.AgentBuild{
|
||
Version: version,
|
||
Commit: commit,
|
||
Os: runtime.GOOS,
|
||
Arch: runtime.GOARCH,
|
||
GoVersion: runtime.Version(),
|
||
CompiledModules: reg.Compiled(),
|
||
}
|
||
|
||
dial, err := dialer(cfg)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
sess := session.New(session.Config{
|
||
AgentID: cfg.AgentID,
|
||
Hostname: cfg.Hostname,
|
||
Build: build,
|
||
Registry: reg,
|
||
Buffer: buf,
|
||
Dial: dial,
|
||
Logger: log,
|
||
MinBackoff: cfg.MinBackoff,
|
||
MaxBackoff: cfg.MaxBackoff,
|
||
DefaultModules: cfg.DefaultModules,
|
||
Syslog: syslogRecv,
|
||
Traps: trapRecv,
|
||
FileCfg: fileCfg,
|
||
})
|
||
|
||
sched := scheduler.New(scheduler.Config{
|
||
Registry: reg,
|
||
Sink: buf,
|
||
OnStatus: sess.ReportStatus,
|
||
Credentials: sess.Credentials,
|
||
OnDiscovery: sess.ReportDiscovery,
|
||
MaxConcurrency: cfg.MaxConcurrency,
|
||
})
|
||
sess.SetScheduler(sched)
|
||
|
||
ctx, stop := signal.NotifyContext(context.Background(),
|
||
os.Interrupt, syscall.SIGTERM)
|
||
defer stop()
|
||
|
||
log.Info("запуск",
|
||
"version", version,
|
||
"server", cfg.Endpoint,
|
||
"agent_id", cfg.AgentID,
|
||
"modules", reg.Compiled())
|
||
|
||
var wg sync.WaitGroup
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
sched.Run(ctx)
|
||
}()
|
||
|
||
// Приймач живе поза сесією: обрив зв'язку з сервером не має
|
||
// зупиняти збір журналу. Події лягають у чергу й доїдуть, коли
|
||
// сесія відновиться — саме заради цього черга й існує.
|
||
if syslogRecv != nil {
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
if err := syslogRecv.Run(ctx); err != nil {
|
||
// Зайнятий порт або брак прав — не привід зупиняти
|
||
// зонд: решта модулів працює, а причину видно в журналі.
|
||
log.Error("приймач syslog не запустився",
|
||
"адреса", cfg.SyslogListen, "err", err)
|
||
}
|
||
}()
|
||
}
|
||
if trapRecv != nil {
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
if err := trapRecv.Run(ctx); err != nil {
|
||
// Найчастіша причина саме тут: 162 нижче 1024, і без
|
||
// CAP_NET_BIND_SERVICE зонд його не займе. Помилка в
|
||
// журналі, а не мовчазна відсутність трапів, — бо
|
||
// відсутність трапів виглядає точнісінько як спокійна
|
||
// мережа.
|
||
log.Error("приймач трапів не запустився",
|
||
"адреса", cfg.TrapsListen, "err", err)
|
||
}
|
||
}()
|
||
}
|
||
|
||
runErr := sess.Run(ctx)
|
||
|
||
wg.Wait()
|
||
for _, e := range reg.CloseAll() {
|
||
log.Warn("помилка при закритті модуля", "err", e)
|
||
}
|
||
|
||
if runErr != nil && !errors.Is(runErr, context.Canceled) {
|
||
return runErr
|
||
}
|
||
log.Info("зупинено")
|
||
return nil
|
||
}
|
||
|
||
func newLogger(cfg *config.Config) *slog.Logger {
|
||
level := slog.LevelInfo
|
||
switch cfg.LogLevel {
|
||
case "debug":
|
||
level = slog.LevelDebug
|
||
case "warn":
|
||
level = slog.LevelWarn
|
||
case "error":
|
||
level = slog.LevelError
|
||
}
|
||
|
||
opts := &slog.HandlerOptions{Level: level}
|
||
if cfg.LogJSON {
|
||
return slog.New(slog.NewJSONHandler(os.Stderr, opts))
|
||
}
|
||
return slog.New(slog.NewTextHandler(os.Stderr, opts))
|
||
}
|
||
|
||
// tokenCreds додає токен зонда в метадані кожного виклику.
|
||
type tokenCreds struct {
|
||
token string
|
||
allowInsecure bool
|
||
}
|
||
|
||
func (t tokenCreds) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
|
||
return map[string]string{"authorization": "Bearer " + t.token}, nil
|
||
}
|
||
|
||
func (t tokenCreds) RequireTransportSecurity() bool { return !t.allowInsecure }
|
||
|
||
func dialer(cfg *config.Config) (session.DialFunc, error) {
|
||
tc, err := cfg.TLSConfig()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
creds := insecure.NewCredentials()
|
||
if tc != nil {
|
||
creds = credentials.NewTLS(tc)
|
||
}
|
||
|
||
opts := []grpc.DialOption{
|
||
grpc.WithTransportCredentials(creds),
|
||
// Токен їде в метаданих кожного виклику. gRPC відмовиться
|
||
// слати його по незашифрованому каналу, якщо ми явно не
|
||
// дозволимо це для локального стенду.
|
||
grpc.WithPerRPCCredentials(tokenCreds{token: cfg.Token, allowInsecure: cfg.Insecure}),
|
||
// Keepalive потрібен через NAT: без нього проміжний
|
||
// маршрутизатор тихо викидає сесію після кількох хвилин
|
||
// мовчання, і сервер бачить агента живим, коли той уже глухий.
|
||
grpc.WithKeepaliveParams(keepalive.ClientParameters{
|
||
Time: 30 * time.Second,
|
||
Timeout: 10 * time.Second,
|
||
PermitWithoutStream: true,
|
||
}),
|
||
}
|
||
|
||
return func(ctx context.Context) (session.Conn, error) {
|
||
return grpc.NewClient(cfg.Endpoint, opts...)
|
||
}, nil
|
||
}
|
||
|
||
// enroll реєструє зонд і зберігає посвідчення.
|
||
func enroll(cfg *config.Config, build *npv1.AgentBuild) (*config.Identity, error) {
|
||
var tlsCfg *tls.Config
|
||
if !cfg.Insecure {
|
||
var err error
|
||
tlsCfg, err = cfg.TLSConfig()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
id, err := config.Enroll(context.Background(), cfg.Endpoint,
|
||
cfg.EnrollToken, cfg.EnrollName, tlsCfg, build)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := config.SaveIdentity(cfg.IdentityPath, id); err != nil {
|
||
// Токен уже згорів на сервері, тож повідомлення має нести сам
|
||
// токен: інакше людині доведеться створювати нове запрошення
|
||
// лише через те, що каталог виявився недоступним для запису.
|
||
return nil, fmt.Errorf(
|
||
"зонд зареєстровано (id=%s), але посвідчення не збереглося в %s: %w\n"+
|
||
"збережіть вручну: {\"agent_id\":%q,\"token\":%q}",
|
||
id.AgentID, cfg.IdentityPath, err, id.AgentID, id.Token)
|
||
}
|
||
return id, nil
|
||
}
|