diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..8e332e1 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,125 @@ +# Складання і перевірки NetPulse на Forgejo Actions. +# +# Три роботи паралельно, а не одна послідовна: фронтенд, сервер і зонд +# ламаються незалежно, і чекати збірки Go заради помилки типізації в +# TypeScript — марно витрачений час на кожному пуші. + +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + GO_VERSION: "1.25" + NODE_VERSION: "22" + +jobs: + web: + runs-on: docker + container: + image: node:22-alpine + steps: + - uses: actions/checkout@v4 + + - name: Залежності + working-directory: web + run: npm ci + + - name: Типи + working-directory: web + run: npx tsc --noEmit + + - name: Збірка + working-directory: web + run: npm run build + + - uses: actions/upload-artifact@v3 + with: + name: web-dist + path: web/dist/ + + server: + runs-on: docker + container: + image: golang:1.25-alpine + services: + db: + image: timescale/timescaledb:2.17.2-pg16 + env: + POSTGRES_USER: netpulse + POSTGRES_PASSWORD: netpulse + POSTGRES_DB: netpulse_ci + TIMESCALEDB_TELEMETRY: "off" + steps: + - uses: actions/checkout@v4 + + - name: Інструменти + run: apk add --no-cache git postgresql16-client + + - name: Формат + working-directory: server + run: | + # gofmt -l друкує список, а не код виходу: без перевірки + # порожнечі крива форма проїжджає в main непоміченою. + bad=$(gofmt -l .) + if [ -n "$bad" ]; then + echo "не відформатовано:"; echo "$bad"; exit 1 + fi + + - name: Vet + working-directory: server + run: go vet ./... + + - name: Схема + working-directory: server + env: + NETPULSE_DSN: postgres://netpulse:netpulse@db:5432/netpulse_ci?sslmode=disable + run: | + until pg_isready -h db -U netpulse -d netpulse_ci; do sleep 1; done + go run ./cmd/netpulse-migrate + + - name: Тести + working-directory: server + env: + NETPULSE_TEST_DSN: postgres://netpulse:netpulse@db:5432/netpulse_ci?sslmode=disable + run: go test ./... + + agent: + runs-on: docker + container: + image: golang:1.25-alpine + steps: + - uses: actions/checkout@v4 + + - name: Інструменти + run: apk add --no-cache git + + - name: Формат + working-directory: agent + run: | + bad=$(gofmt -l .) + if [ -n "$bad" ]; then + echo "не відформатовано:"; echo "$bad"; exit 1 + fi + + - name: Vet + working-directory: agent + run: go vet ./... + + - name: Тести + working-directory: agent + run: go test ./... + + # Зонд їде на чуже залізо: перевіряємо, що збирається під усі + # платформи, які обіцяємо, а не лише під ту, де крутиться CI. + - name: Крос-збірка + working-directory: agent + run: | + for target in linux/amd64 linux/arm64 linux/arm windows/amd64 darwin/arm64; do + os=${target%/*}; arch=${target#*/} + echo "== $os/$arch" + CGO_ENABLED=0 GOOS=$os GOARCH=$arch go build -trimpath \ + -o /tmp/netpulse-agent-$os-$arch ./cmd/netpulse-agent + done diff --git a/.gitignore b/.gitignore index 05f7f36..1ebf24b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,13 @@ /dist/ *.exe *.test -netpulse-agent -netpulse-server +# Прив'язка до кореня обов'язкова: голе "netpulse-agent" збігається не +# лише зі зібраним бінарником, а й із каталогом cmd/netpulse-agent — і +# git мовчки не бере звідти нові файли. Помітно це стає тоді, коли +# половина команди зникла з коміту. +/netpulse-* +/server/netpulse-* +/agent/netpulse-* # Секрети й локальні налаштування .env diff --git a/HISTORY.md b/HISTORY.md index a8ad20b..a77f2e2 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -2057,3 +2057,96 @@ source_handle = CASE $19 WHEN '' THEN source_handle видалення edges.remove → ребра немає дублікат та сама пара вузлів → 400 «такий запис уже існує» ``` + +## Пакування + +### Два образи, а не пʼять + +`deploy/Dockerfile.server` збирає з одного модуля всі команди — `api`, +`server`, `migrate`, `user`, `secret`. Розкладати їх по окремих образах +означало б пʼять разів качати ту саму базу й дати API та колектору +можливість розʼїхатись версіями саме там, де це найдорожче: вони ходять +в одну схему БД. + +`deploy/Dockerfile.agent` — окремо. Зонд їде в чужу мережу, і DSN, ключі +шифрування та команди заведення користувачів не повинні бути в тому +образі навіть як невикористані файли. + +Контекст збірки обох — корінь репозиторію: `server` і `agent` посилаються +на `../gen/go` через `replace`, і вужчий контекст їх не збере. + +Інтерфейс лягає в дерево до збірки Go: `httpapi` віддає його через +`//go:embed`, а embed читає файли на етапі компіляції, не в рантаймі. + +### Стек + +`docker-compose.yml` піднімає БД, кеш, міграції, API, колектор і Caddy. +Назовні дивиться лише проксі. + +Міграції — окремою службою з `condition: service_completed_successfully`, +а не на старті API: API піднімається в кількох примірниках, і накочування +схеми зі старту означало б гонку між ними. + +TLS знімає Caddy, всередині мережі — h2c. Прострочений сертифікат на +системі, яка сама має повідомляти про проблеми, — найгірший спосіб +дізнатись про проблему. + +Зондам виділено окремий порт 9443 замість розрізняння gRPC і HTTP за +шляхом на 443: зайва крихкість там, де порт коштує нічого. + +### Дві пастки, знайдені при написанні + +**Том на неіснуючому шляху.** `VOLUME /var/lib/netpulse` без попереднього +`mkdir` + `chown` docker створює власністю root. Зонд під непривілейованим +користувачем реєструється успішно, але посвідчення не записує — і після +перезапуску знову просить запрошення, ніби нічого не було. + +**`.gitignore` без прив'язки до кореня.** Рядки `netpulse-agent` і +`netpulse-server` мали ловити зібрані бінарники, а ловили ще й каталоги +`cmd/netpulse-agent` і `cmd/netpulse-server`. Наявні файли лишались у +git як уже відстежувані, тож помітно це стало б лише тоді, коли новий +файл команди мовчки не потрапив би в коміт. Виправлено на `/netpulse-*`, +`/server/netpulse-*`, `/agent/netpulse-*`. + +### Бекап + +`deploy/README.md` описує процедуру повністю. Головне, що з неї не можна +викинути: бекап — це три речі, а не одна. Дамп БД, `NETPULSE_DEK` і +`NETPULSE_JWT_SECRET`. Без ключа шифрування з дампа не дістати жодного +збереженого пароля — у БД лежить самий шифротекст, і виглядатиме це після +відновлення як зламані креденшели, а не як втрачений ключ. + +Відновлення TimescaleDB вимагає рамки `timescaledb_pre_restore()` / +`timescaledb_post_restore()`: без неї фонові процеси агрегації +втручаються в наливання даних. + +### CI + +`.forgejo/workflows/ci.yml` — три роботи паралельно: фронтенд, сервер, +зонд. Вони ламаються незалежно, і чекати збірки Go заради помилки +типізації в TypeScript — марно витрачений час на кожному пуші. + +`gofmt -l` перевіряється на порожнечу виводу, а не за кодом виходу: він +друкує список і виходить нулем, тож крива форма інакше проїжджає в main +непоміченою. + +Зонд крос-збирається під пʼять платформ — він їде на чуже залізо, і +перевіряти треба ті цілі, які обіцяємо, а не лише ту, де крутиться CI. + +### Перевірено + +Docker на стенді немає, тож перевірено те, на що спираються образи: + +``` +npm run build → web/dist з assets/ +dist → server/webui/dist → go build ./cmd/... : 5 бінарників +netpulse-api → / віддає SPA, max-age=300 + /assets/*.js — immutable, 1 рік + /map → 200 (маршрут SPA) + /api/v1/nope → JSON 404, не index.html +netpulse-migrate -dry-run → «схема актуальна» +крос-збірка зонда → linux/amd64, arm64, arm; windows/amd64; darwin/arm64 +YAML → compose і workflow розбираються, злиття якорів працює +``` + +Самі образи не збиралися: docker недоступний ні локально, ні на стенді. diff --git a/agent/cmd/netpulse-agent/main.go b/agent/cmd/netpulse-agent/main.go new file mode 100644 index 0000000..e1984a9 --- /dev/null +++ b/agent/cmd/netpulse-agent/main.go @@ -0,0 +1,280 @@ +// Команда 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/icmp" + "github.com/netpulse/netpulse/agent/internal/modules/snmp" + "github.com/netpulse/netpulse/agent/internal/modules/topology" + "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, + } + + // Реєстрація перед усім іншим. + // + // Посвідчення зберігається на диск одразу: одноразовий токен згорає + // на сервері, і другої спроби не буде — впасти після успішного + // 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, err := config.LoadIdentity(cfg.IdentityPath) + if err != nil { + return err + } + 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 + } + reg.EnsureDefaults(cfg.DefaultModules...) + + 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, + }) + + 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) + }() + + 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 +} diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..4cedfb6 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,49 @@ +# Приклад оточення для docker compose. Скопіювати в .env у корені +# репозиторію й заповнити. Файл .env у git не потрапляє. + +# Домен, на який дивиться A-запис. Caddy візьме під нього сертифікат. +NETPULSE_DOMAIN=netpulse.example.com + +# Пошта для Let's Encrypt: на неї приходять листи про проблеми з +# продовженням сертифіката. Порожньо — ACME без контакту. +ACME_EMAIL=admin@example.com + +# Пароль ролі netpulse у PostgreSQL. +# openssl rand -base64 24 +POSTGRES_PASSWORD= + +# Ключ шифрування секретів (паролі SSH, SNMP-community). +# Формат: =<32 байти hex>. Id потрібен для зміни ключа: старий +# лишається в списку, щоб розшифрувати вже записане. +# echo "np1=$(openssl rand -hex 32)" +# +# ВТРАТА ЦЬОГО КЛЮЧА — ВТРАТА ВСІХ ЗБЕРЕЖЕНИХ ПАРОЛІВ. Бекап бази без +# нього не відновлюється: у БД лежить лише шифротекст. +NETPULSE_DEK= + +# Ключ підпису сесійних токенів, не менше 32 байтів. +# openssl rand -base64 48 +NETPULSE_JWT_SECRET= + +# --- необовʼязкове --------------------------------------------------- + +# Часовий пояс: у ньому рахуються розклади бекапів і вікна тиші. +TZ=Europe/Kyiv + +# Як часто перевіряються правила сповіщень. +NETPULSE_ALERT_INTERVAL=30s + +# debug|info|warn|error +NETPULSE_LOG_LEVEL=info + +# Розмір shared_buffers PostgreSQL. Орієнтир — чверть памʼяті хоста. +PG_SHARED_BUFFERS=512MB + +# Версія для тегів образів і рядка -version у бінарниках. +NETPULSE_VERSION=dev +NETPULSE_COMMIT=none + +# Запрошення для локального зонда (профіль agent). Береться в +# інтерфейсі: Зонди → Додати зонд. +NETPULSE_ENROLL= +NETPULSE_AGENT_NAME=локальний зонд diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..64aaad3 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,57 @@ +# Зворотний проксі NetPulse. +# +# Caddy бере на себе сертифікати Let's Encrypt і оновлює їх сам. Це +# знімає з розгортання найчастішу причину нічного дзвінка — прострочений +# сертифікат на системі, яка сама має повідомляти про проблеми. + +{ + email {$ACME_EMAIL} +} + +# --- інтерфейс і API ------------------------------------------------- +{$NETPULSE_DOMAIN} { + encode zstd gzip + + # WebSocket живих оновлень. Окремим блоком, бо йому не можна + # ставити таймаут на відповідь: зʼєднання висить годинами й + # мовчить, поки на мапі нічого не змінюється. + @ws path /api/v1/ws + reverse_proxy @ws api:8080 { + flush_interval -1 + } + + # API і вшитий у бінарник інтерфейс — за однією адресою, тож + # розділяти їх не потрібно: SPA віддає той самий процес. + reverse_proxy api:8080 + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + -Server + } + + log { + output stdout + format console + } +} + +# --- колектор зондів ------------------------------------------------- +# +# Зонди говорять gRPC поверх HTTP/2. Усередині мережі — h2c без TLS: +# сертифікат живе тут, а не в кожному примірнику колектора. +{$NETPULSE_DOMAIN}:9443 { + reverse_proxy h2c://collector:9443 { + # Потік Control двонаправлений і довгий: агент тримає його + # відкритим, доки живий. Буферизація тут ламає саме те, заради + # чого потік існує — миттєву доставку команд. + flush_interval -1 + } + + log { + output stdout + format console + } +} diff --git a/deploy/Dockerfile.agent b/deploy/Dockerfile.agent new file mode 100644 index 0000000..79e924b --- /dev/null +++ b/deploy/Dockerfile.agent @@ -0,0 +1,68 @@ +# syntax=docker/dockerfile:1.7 +# +# Образ зонда NetPulse. +# +# Окремий образ, а не спільний із сервером: зонд їде до клієнта в чужу +# мережу, і все, чого там бути не має — DSN, ключі шифрування, команди +# заведення користувачів — не повинно потрапити в образ навіть як +# невикористаний файл. +# +# Контекст збірки — корінь репозиторію (replace на ../gen/go). + +FROM golang:1.25-alpine AS build +WORKDIR /src + +RUN apk add --no-cache git + +COPY gen/go/go.mod gen/go/go.sum gen/go/ +COPY agent/go.mod agent/go.sum agent/ +WORKDIR /src/agent +RUN --mount=type=cache,target=/go/pkg/mod go mod download + +WORKDIR /src +COPY gen/ gen/ +COPY agent/ agent/ + +ARG VERSION=dev +ARG COMMIT=none + +WORKDIR /src/agent +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 go build -trimpath \ + -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" \ + -o /out/netpulse-agent ./cmd/netpulse-agent + +FROM alpine:3.20 + +RUN apk add --no-cache ca-certificates tzdata \ + && adduser -D -u 10001 netpulse + +COPY --from=build /out/netpulse-agent /usr/local/bin/netpulse-agent + +# Право на сирі сокети замість запуску від root: ICMP інакше не +# надіслати, але решті зонда root не потрібен. +# +# Docker при цьому має віддати контейнеру CAP_NET_RAW (він є в наборі +# за замовчуванням) — див. cap_add у compose для середовищ, де набір +# урізали. +RUN apk add --no-cache libcap \ + && setcap cap_net_raw+ep /usr/local/bin/netpulse-agent \ + && apk del libcap + +# Каталог створюємо в образі й віддаємо його зонду. +# +# Том, оголошений на неіснуючому шляху, docker створює власністю root, і +# зонд під непривілейованим користувачем не може записати посвідчення — +# реєстрація проходить, а після перезапуску зонд знову просить +# запрошення, ніби нічого не було. +RUN mkdir -p /var/lib/netpulse && chown netpulse:netpulse /var/lib/netpulse + +USER netpulse + +# Посвідчення зонда переживає перезапуск контейнера: без тому кожен +# старт вимагав би нового запрошення з інтерфейсу. +VOLUME ["/var/lib/netpulse"] +ENV NETPULSE_IDENTITY=/var/lib/netpulse/agent.json + +ENTRYPOINT ["netpulse-agent"] diff --git a/deploy/Dockerfile.server b/deploy/Dockerfile.server new file mode 100644 index 0000000..5eae444 --- /dev/null +++ b/deploy/Dockerfile.server @@ -0,0 +1,71 @@ +# syntax=docker/dockerfile:1.7 +# +# Образ серверної частини NetPulse: HTTP API з вшитим інтерфейсом, +# gRPC-колектор і допоміжні команди. +# +# Один образ на чотири бінарники, а не чотири образи: вони збираються з +# одного модуля, ділять половину коду й завжди їдуть однією версією. +# Розкласти їх по різних образах означало б чотири рази качати ту саму +# базу й отримати можливість розʼїхатись версіями там, де це найдорожче +# коштує — між API та колектором, що ходять в одну схему БД. +# +# Контекст збірки — корінь репозиторію: модулі server і agent посилаються +# на ../gen/go через replace, і вужчий контекст їх не збере. + +# --- інтерфейс ------------------------------------------------------- +FROM node:22-alpine AS web +WORKDIR /src/web + +# Спершу маніфести, потім код: правка компонента не має інвалідувати +# шар із npm ci, а він найдовший у цій стадії. +COPY web/package.json web/package-lock.json ./ +RUN npm ci + +COPY web/ ./ +RUN npm run build + +# --- бінарники ------------------------------------------------------- +FROM golang:1.25-alpine AS build +WORKDIR /src + +RUN apk add --no-cache git + +COPY gen/go/go.mod gen/go/go.sum gen/go/ +COPY server/go.mod server/go.sum server/ +WORKDIR /src/server +RUN --mount=type=cache,target=/go/pkg/mod go mod download + +WORKDIR /src +COPY gen/ gen/ +COPY server/ server/ + +# Інтерфейс лягає в дерево ДО збірки: httpapi віддає його через +# //go:embed, а embed читає файли на етапі компіляції. +COPY --from=web /src/web/dist/ server/webui/dist/ + +ARG VERSION=dev +ARG COMMIT=none + +WORKDIR /src/server +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 go build -trimpath \ + -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" \ + -o /out/ ./cmd/... + +# --- образ ----------------------------------------------------------- +FROM alpine:3.20 + +# ca-certificates — для вебхуків і SMTP через TLS; tzdata — бо розклади +# бекапів і тиша сповіщень задаються в часовому поясі тенанта. wget не +# ставимо: healthcheck обходиться тим, що вже є в busybox. +RUN apk add --no-cache ca-certificates tzdata \ + && adduser -D -u 10001 netpulse + +COPY --from=build /out/ /usr/local/bin/ + +USER netpulse +WORKDIR /home/netpulse + +EXPOSE 8080 9443 +ENTRYPOINT ["netpulse-api"] diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..e7cb625 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,184 @@ +# Розгортання NetPulse + +Один хост, `docker compose`, автоматичний TLS. Такого розгортання +вистачає до кількох тисяч хостів на моніторингу; розносити служби по +машинах має сенс тоді, коли впирається БД, а не застосунок. + +## Що з чого складається + +| Служба | Роль | Порт | +| ----------- | ------------------------------------------------ | ----- | +| `db` | PostgreSQL 16 + TimescaleDB — усі дані | — | +| `cache` | DragonflyDB — черги й тимчасові стани | — | +| `migrate` | накочування схеми; відпрацьовує і зупиняється | — | +| `api` | HTTP API + вшитий інтерфейс | 8080 | +| `collector` | gRPC-колектор, до якого підключаються зонди | 9443 | +| `proxy` | Caddy: сертифікати, HTTPS, проксі до двох служб | 80/443/9443 | + +Назовні дивиться лише `proxy`. `api` і `collector` портів не публікують: +до них ходять через нього. + +## Перший запуск + +```sh +git clone <репозиторій> netpulse && cd netpulse +cp deploy/.env.example .env +``` + +Заповнити `.env`. Три значення обовʼязкові й генеруються так: + +```sh +openssl rand -base64 24 # POSTGRES_PASSWORD +echo "np1=$(openssl rand -hex 32)" # NETPULSE_DEK +openssl rand -base64 48 # NETPULSE_JWT_SECRET +``` + +Далі: + +```sh +docker compose up -d --build +docker compose run --rm api netpulse-user \ + -tenant default -login admin -role owner -name "Адміністратор" +``` + +Пароль команда спитає інтерактивно — щоб він не осів в історії оболонки +й у списку процесів. + +Інтерфейс — на `https://`. + +## Підключення зонда + +В інтерфейсі: **Зонди → Додати зонд**. Видане запрошення (`np_enr_…`) +одноразове — після реєстрації воно згоряє. + +На машині, де стоятиме зонд: + +```sh +docker run -d --name netpulse-agent --restart unless-stopped \ + --cap-add NET_RAW \ + -v netpulse-agent:/var/lib/netpulse \ + netpulse/agent:dev \ + -server netpulse.example.com:9443 \ + -enroll np_enr_… \ + -name "Зонд у Львові" \ + -modules icmp,snmp,topology,ncm +``` + +Зонд обміняє запрошення на постійний токен і збереже його в +`/var/lib/netpulse/agent.json` (права 0600). Наступні запуски токена вже +не потребують — том із посвідченням має пережити перестворення +контейнера, інакше кожен старт вимагатиме нового запрошення. + +Усі зʼєднання зонда вихідні: у мережі клієнта не треба відкривати +жодного порту. + +## Бекап + +Три речі, і всі три обовʼязкові: + +1. **База** — усе, крім секретів у відкритому вигляді. +2. **`NETPULSE_DEK`** — без нього паролі SSH і SNMP-community з бекапу не + розшифрувати. У БД лежить лише шифротекст. +3. **`NETPULSE_JWT_SECRET`** — без нього після відновлення всі активні + сесії відваляться. Не смертельно, але користувачі помітять. + +```sh +docker compose exec -T db \ + pg_dump -U netpulse -d netpulse -Fc --no-owner \ + > netpulse-$(date +%F).dump +``` + +Формат `-Fc` (custom), а не простий SQL: він стискається і дозволяє +відновлювати вибірково. + +Ключі зберігати **окремо від дампа** — інакше сенс шифрування секретів +зникає: той, хто дістав бекап, дістав і ключ до нього. + +### Автоматично, щодня + +```cron +15 3 * * * cd /opt/netpulse && docker compose exec -T db pg_dump -U netpulse -d netpulse -Fc --no-owner > /var/backups/netpulse-$(date +\%F).dump && find /var/backups -name 'netpulse-*.dump' -mtime +30 -delete +``` + +## Відновлення + +TimescaleDB вимагає рамки навколо відновлення: без неї фонові процеси +агрегації втручаються в наливання даних і дамп лягає пошкодженим. + +```sh +docker compose stop api collector + +docker compose exec -T db psql -U netpulse -d postgres -c \ + 'DROP DATABASE IF EXISTS netpulse; CREATE DATABASE netpulse;' + +docker compose exec -T db psql -U netpulse -d netpulse -c \ + 'CREATE EXTENSION IF NOT EXISTS timescaledb;' + +docker compose exec -T db psql -U netpulse -d netpulse -c \ + 'SELECT timescaledb_pre_restore();' + +docker compose exec -T db pg_restore -U netpulse -d netpulse --no-owner \ + < netpulse-2026-08-25.dump + +docker compose exec -T db psql -U netpulse -d netpulse -c \ + 'SELECT timescaledb_post_restore();' + +docker compose up -d api collector +``` + +У `.env` має лежати **той самий** `NETPULSE_DEK`, що й на момент дампа. +Інакше застосунок підніметься, але кожна спроба скористатись збереженим +паролем поверне помилку розшифрування — і виглядатиме це як зламані +креденшели, а не як втрачений ключ. + +Перевірка після відновлення: + +```sh +docker compose exec -T db psql -U netpulse -d netpulse -c \ + 'SELECT count(*) FROM core.devices;' +curl -sf https:///healthz && echo OK +``` + +## Оновлення + +```sh +git pull +docker compose up -d --build +``` + +`migrate` відпрацює першим і не дасть піднятись API, якщо схема не +накотилась. Міграції йдуть по одній у транзакції; уже застосований файл +зі зміненою контрольною сумою зупиняє весь запуск — це захист від +мовчазного розходження схеми з кодом. + +Відкат схеми не передбачений: зворотні міграції на даних телеметрії +коштують дорожче, ніж відновлення з бекапу. + +## Зміна ключа шифрування + +Ключі перелічуються через кому, новий — першим: + +``` +NETPULSE_DEK=np2=<новий hex>,np1=<старий hex> +``` + +Нові секрети шифруються першим ключем, старі читаються своїм. Прибирати +старий ключ можна лише після того, як усі секрети перезаписані. + +## Чому саме так + +**Два образи, а не пʼять.** `api`, `collector`, `migrate`, `netpulse-user` +і `netpulse-secret` — з одного модуля, з половиною спільного коду. Один +образ гарантує, що API і колектор ходять у схему БД однією версією; окремі +образи дають їм можливість розʼїхатись саме там, де це найдорожче. + +Зонд — окремо: він їде в чужу мережу, і DSN, ключі шифрування та команди +заведення користувачів не повинні бути в тому образі навіть як +невикористані файли. + +**Міграції окремою службою.** API піднімається в кількох примірниках; +накочування схеми зі старту означало б гонку між ними. + +**TLS на проксі, а не в застосунку.** Прострочений сертифікат на системі, +яка сама має повідомляти про проблеми, — найгірший спосіб дізнатись про +проблему. Caddy оновлює його сам. diff --git a/docker-compose.yml b/docker-compose.yml index 11fbb77..90e7506 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,28 @@ -version: "3.9" +# Повний стек NetPulse: БД, кеш, міграції, API з інтерфейсом, колектор +# зондів і зворотний проксі з автоматичним TLS. +# +# Швидкий старт: +# cp deploy/.env.example .env # і заповнити секрети +# docker compose up -d +# docker compose run --rm api netpulse-user -tenant default -login admin -role owner +# +# Тільки стенд для розробки (БД і кеш, решта — з go run): +# docker compose up -d db cache -# Локальний стенд для перевірки схеми: TimescaleDB + Redis-сумісний DragonflyDB. -# Міграції накочуються скриптом db/migrate.ps1 (або psql -f у порядку номерів). +name: netpulse + +x-server-env: &server-env + NETPULSE_DSN: postgres://netpulse:${POSTGRES_PASSWORD:?потрібен POSTGRES_PASSWORD}@db:5432/netpulse?sslmode=disable + NETPULSE_DEK: ${NETPULSE_DEK:?потрібен NETPULSE_DEK — див. deploy/README.md} + NETPULSE_LOG_LEVEL: ${NETPULSE_LOG_LEVEL:-info} + TZ: ${TZ:-Europe/Kyiv} services: db: image: timescale/timescaledb:2.17.2-pg16 - container_name: netpulse-db environment: POSTGRES_USER: netpulse - POSTGRES_PASSWORD: netpulse + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?потрібен POSTGRES_PASSWORD} POSTGRES_DB: netpulse TIMESCALEDB_TELEMETRY: "off" command: @@ -19,11 +32,9 @@ services: - -c - max_connections=200 - -c - - shared_buffers=512MB + - shared_buffers=${PG_SHARED_BUFFERS:-512MB} - -c - timescaledb.max_background_workers=8 - ports: - - "5432:5432" volumes: - db-data:/var/lib/postgresql/data healthcheck: @@ -31,17 +42,130 @@ services: interval: 5s timeout: 5s retries: 20 + restart: unless-stopped cache: - image: docker.dragonflydb.io/dragonflydb/dragonfly:latest - container_name: netpulse-cache + image: docker.dragonflydb.io/dragonflydb/dragonfly:v1.25.5 ulimits: memlock: -1 - ports: - - "6379:6379" volumes: - cache-data:/data + restart: unless-stopped + + # Міграції окремою службою, а не на старті API. + # + # API запускається в кількох примірниках, і накочування схеми зі старту + # означало б гонку між ними. Тут же — один запуск, який мусить + # завершитись успіхом, перш ніж піднімуться API й колектор. + migrate: + build: &server-build + context: . + dockerfile: deploy/Dockerfile.server + args: + VERSION: ${NETPULSE_VERSION:-dev} + COMMIT: ${NETPULSE_COMMIT:-none} + image: netpulse/server:${NETPULSE_VERSION:-dev} + entrypoint: ["netpulse-migrate"] + environment: *server-env + depends_on: + db: + condition: service_healthy + restart: "no" + + api: + build: *server-build + image: netpulse/server:${NETPULSE_VERSION:-dev} + entrypoint: ["netpulse-api"] + command: + - -listen=:8080 + - -alert-interval=${NETPULSE_ALERT_INTERVAL:-30s} + environment: + <<: *server-env + NETPULSE_JWT_SECRET: ${NETPULSE_JWT_SECRET:?потрібен NETPULSE_JWT_SECRET — не менше 32 байтів} + depends_on: + migrate: + condition: service_completed_successfully + cache: + condition: service_started + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"] + interval: 10s + timeout: 3s + retries: 6 + start_period: 15s + restart: unless-stopped + + # Колектор зондів. TLS знімає Caddy, всередині мережі — h2c, тому + # -insecure. Порт назовні сам не публікує: до нього ходять через + # проксі, який має справжній сертифікат. + collector: + build: *server-build + image: netpulse/server:${NETPULSE_VERSION:-dev} + entrypoint: ["netpulse-server"] + command: + - -listen=:9443 + - -insecure + environment: *server-env + depends_on: + migrate: + condition: service_completed_successfully + restart: unless-stopped + + proxy: + image: caddy:2.8-alpine + environment: + NETPULSE_DOMAIN: ${NETPULSE_DOMAIN:?потрібен NETPULSE_DOMAIN} + # Порожній рядок Caddy не приймає: директива email без значення — + # помилка розбору, і проксі не підніметься взагалі. Тому підстановка + # спрацьовує і на незадану змінну, і на задану порожньою. + ACME_EMAIL: ${ACME_EMAIL:-admin@${NETPULSE_DOMAIN}} + volumes: + - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + ports: + - "80:80" + - "443:443" + # Окремий порт для зондів: вони говорять gRPC, а не HTTP, і + # ділити з ним 443 означало б розрізняти протоколи за шляхом — + # зайва крихкість там, де порт коштує нічого. + - "9443:9443" + depends_on: + - api + - collector + restart: unless-stopped + + # Зонд на тому ж хості, що й сервер: базовий моніторинг самої + # інсталяції. Профіль, а не звичайна служба: у типовому розгортанні + # зонди стоять у мережах клієнтів, а не поруч із сервером. + agent: + build: + context: . + dockerfile: deploy/Dockerfile.agent + args: + VERSION: ${NETPULSE_VERSION:-dev} + COMMIT: ${NETPULSE_COMMIT:-none} + image: netpulse/agent:${NETPULSE_VERSION:-dev} + profiles: ["agent"] + command: + - -server=collector:9443 + - -insecure + - -modules=icmp,snmp,topology,ncm + environment: + NETPULSE_ENROLL: ${NETPULSE_ENROLL:-} + NETPULSE_NAME: ${NETPULSE_AGENT_NAME:-локальний зонд} + TZ: ${TZ:-Europe/Kyiv} + cap_add: + - NET_RAW + volumes: + - agent-identity:/var/lib/netpulse + depends_on: + - collector + restart: unless-stopped volumes: db-data: cache-data: + caddy-data: + caddy-config: + agent-identity: diff --git a/server/cmd/netpulse-server/main.go b/server/cmd/netpulse-server/main.go new file mode 100644 index 0000000..1b67d8d --- /dev/null +++ b/server/cmd/netpulse-server/main.go @@ -0,0 +1,215 @@ +// Команда netpulse-server — приймальна сторона AgentService. +// +// Зонди самі підключаються сюди; сервер до них не ходить. +package main + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "flag" + "fmt" + "log/slog" + "net" + "os" + "os/signal" + "syscall" + "time" + + "github.com/netpulse/netpulse/server/internal/crypto" + "github.com/netpulse/netpulse/server/internal/grpcapi" + "github.com/netpulse/netpulse/server/internal/store" + npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/keepalive" +) + +var version = "dev" + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "netpulse-server:", err) + os.Exit(1) + } +} + +func run() error { + var ( + listen = flag.String("listen", envOr("NETPULSE_LISTEN", ":9443"), "адреса прослуховування gRPC") + dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL") + certFile = flag.String("cert", os.Getenv("NETPULSE_CERT"), "сертифікат сервера") + keyFile = flag.String("key", os.Getenv("NETPULSE_KEY"), "приватний ключ") + caFile = flag.String("client-ca", os.Getenv("NETPULSE_CLIENT_CA"), "CA для перевірки сертифікатів зондів (mTLS)") + insecure = flag.Bool("insecure", os.Getenv("NETPULSE_INSECURE") == "1", "без TLS — лише локальний стенд") + keysFlag = flag.String("dek", os.Getenv("NETPULSE_DEK"), "ключі шифрування: key_id=[,...]") + logLevel = flag.String("log-level", envOr("NETPULSE_LOG_LEVEL", "info"), "debug|info|warn|error") + ) + flag.Parse() + + if *dsn == "" { + return errors.New("не вказано -dsn (або NETPULSE_DSN)") + } + + log := newLogger(*logLevel) + + ring, err := buildKeyring(*keysFlag) + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + st, err := store.New(ctx, *dsn) + if err != nil { + return fmt.Errorf("підключення до БД: %w", err) + } + defer st.Close() + + svc := grpcapi.New(st, ring, log) + + opts := []grpc.ServerOption{ + grpc.ChainUnaryInterceptor(svc.UnaryInterceptor), + grpc.ChainStreamInterceptor(svc.StreamInterceptor), + // Зонди сидять за NAT: без keepalive проміжний маршрутизатор + // тихо викидає сесію, і сервер вважає мертвого агента живим. + grpc.KeepaliveParams(keepalive.ServerParameters{ + Time: 60 * time.Second, + Timeout: 20 * time.Second, + }), + grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{ + MinTime: 20 * time.Second, + PermitWithoutStream: true, + }), + // Конфіги великих маршрутизаторів бувають на кілька МБ, + // але їх шлють чанками — ліміт лишається захистом. + grpc.MaxRecvMsgSize(16 << 20), + } + + if !*insecure { + tc, err := serverTLS(*certFile, *keyFile, *caFile) + if err != nil { + return err + } + opts = append(opts, grpc.Creds(credentials.NewTLS(tc))) + } else { + log.Warn("запуск без TLS — припустимо лише для локального стенду") + } + + srv := grpc.NewServer(opts...) + npv1.RegisterAgentServiceServer(srv, svc) + // Реєстрація йде БЕЗ токена зонда, тож окремим сервісом: інакше + // довелося б робити виняток усередині інтерсептора автентифікації. + npv1.RegisterEnrollmentServiceServer(srv, grpcapi.NewEnrollService(st, *listen)) + + lis, err := net.Listen("tcp", *listen) + if err != nil { + return fmt.Errorf("listen %s: %w", *listen, err) + } + + log.Info("запуск", "version", version, "listen", *listen, "tls", !*insecure) + + // Диспетчер збору конфігів: черга наповнюється REST-процесом, а + // живі сесії зондів тримає саме цей. + go svc.DispatchConfigJobs(ctx, 5*time.Second) + // Розклад бекапів. Кілька екземплярів безпечні: тік бере + // advisory-блокування, тож розкручує його рівно один. + go svc.ScheduleBackups(ctx) + // Звірка планів: чеки міняє REST-процес, а перезалити план може + // лише той, хто тримає сесію зонда. + go svc.SyncPlans(ctx) + + errCh := make(chan error, 1) + go func() { errCh <- srv.Serve(lis) }() + + select { + case <-ctx.Done(): + log.Info("зупинка", "сесій_онлайн", svc.SessionCount()) + // GracefulStop дає активним стрімам догратись: обірваний + // посеред батчу агент однаково перешле його, але зайвих + // ретраїв краще уникнути. + done := make(chan struct{}) + go func() { srv.GracefulStop(); close(done) }() + select { + case <-done: + case <-time.After(15 * time.Second): + log.Warn("м'яка зупинка не встигла — примусова") + srv.Stop() + } + return nil + case err := <-errCh: + return err + } +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func newLogger(level string) *slog.Logger { + lv := slog.LevelInfo + switch level { + case "debug": + lv = slog.LevelDebug + case "warn": + lv = slog.LevelWarn + case "error": + lv = slog.LevelError + } + return slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: lv})) +} + +// buildKeyring розбирає ключі шифрування. +// +// Ключі приходять через оточення або секрет-менеджер і ніколи не +// лежать поруч із дампом БД: інакше шифрування конфігів і паролів +// від обладнання не давало б нічого. На відміну від REST-процесу, +// тут ключ обов'язковий: без нього AgentService не може ані видати +// креденшели, ані зберегти конфіг. +func buildKeyring(spec string) (*crypto.Keyring, error) { + ring, err := crypto.ParseKeyring(spec) + if err != nil { + return nil, err + } + if ring == nil { + return nil, errors.New("не вказано -dek: без ключа неможливо ані видати креденшели, ані зберегти конфіг") + } + return ring, nil +} + +func serverTLS(certFile, keyFile, clientCA string) (*tls.Config, error) { + if certFile == "" || keyFile == "" { + return nil, errors.New("потрібні -cert і -key (або -insecure для локального стенду)") + } + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, fmt.Errorf("сертифікат сервера: %w", err) + } + + tc := &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS13, + } + + if clientCA != "" { + pem, err := os.ReadFile(clientCA) + if err != nil { + return nil, fmt.Errorf("CA зондів: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return nil, errors.New("CA зондів: не вдалося розібрати PEM") + } + tc.ClientCAs = pool + // Токен каже, ЯКИЙ це зонд; сертифікат — що він узагалі має + // право говорити з сервером. Обидва обов'язкові. + tc.ClientAuth = tls.RequireAndVerifyClientCert + } + + return tc, nil +}