Пакування: образи, повний стек, TLS, CI, процедура бекапу
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run

Два образи замість пʼяти: серверні команди їдуть з одного модуля й
мусять ходити в схему БД однією версією, а зонд лишається окремо —
у чужій мережі йому не місце з DSN і ключами шифрування.

Стек піднімає БД, кеш, міграції, API, колектор і Caddy з автоматичним
TLS. Міграції окремою службою, бо API піднімається в кількох
примірниках і гонка за схему нікому не потрібна.

deploy/README.md описує бекап як три речі: дамп, DEK і JWT-ключ.
Без DEK дамп не відновлюється — у БД лише шифротекст.

Дорогою виправлено .gitignore: голі "netpulse-agent" і
"netpulse-server" ігнорували ще й каталоги cmd/ з кодом команд.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-25 12:47:54 +03:00
parent 2c12bed017
commit a0eb2ff5f1
11 changed files with 1285 additions and 14 deletions

125
.forgejo/workflows/ci.yml Normal file
View file

@ -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

9
.gitignore vendored
View file

@ -3,8 +3,13 @@
/dist/
*.exe
*.test
netpulse-agent
netpulse-server
# Прив'язка до кореня обов'язкова: голе "netpulse-agent" збігається не
# лише зі зібраним бінарником, а й із каталогом cmd/netpulse-agent — і
# git мовчки не бере звідти нові файли. Помітно це стає тоді, коли
# половина команди зникла з коміту.
/netpulse-*
/server/netpulse-*
/agent/netpulse-*
# Секрети й локальні налаштування
.env

View file

@ -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 недоступний ні локально, ні на стенді.

View file

@ -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
}

49
deploy/.env.example Normal file
View file

@ -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).
# Формат: <id>=<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=локальний зонд

57
deploy/Caddyfile Normal file
View file

@ -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
}
}

68
deploy/Dockerfile.agent Normal file
View file

@ -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"]

71
deploy/Dockerfile.server Normal file
View file

@ -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"]

184
deploy/README.md Normal file
View file

@ -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://<NETPULSE_DOMAIN>`.
## Підключення зонда
В інтерфейсі: **Зонди → Додати зонд**. Видане запрошення (`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://<NETPULSE_DOMAIN>/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 оновлює його сам.

View file

@ -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:

View file

@ -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=<hex|base64>[,...]")
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
}