Пакування: образи, повний стек, TLS, CI, процедура бекапу
Два образи замість пʼяти: серверні команди їдуть з одного модуля й мусять ходити в схему БД однією версією, а зонд лишається окремо — у чужій мережі йому не місце з 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:
parent
2c12bed017
commit
a0eb2ff5f1
11 changed files with 1285 additions and 14 deletions
125
.forgejo/workflows/ci.yml
Normal file
125
.forgejo/workflows/ci.yml
Normal 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
9
.gitignore
vendored
|
|
@ -3,8 +3,13 @@
|
||||||
/dist/
|
/dist/
|
||||||
*.exe
|
*.exe
|
||||||
*.test
|
*.test
|
||||||
netpulse-agent
|
# Прив'язка до кореня обов'язкова: голе "netpulse-agent" збігається не
|
||||||
netpulse-server
|
# лише зі зібраним бінарником, а й із каталогом cmd/netpulse-agent — і
|
||||||
|
# git мовчки не бере звідти нові файли. Помітно це стає тоді, коли
|
||||||
|
# половина команди зникла з коміту.
|
||||||
|
/netpulse-*
|
||||||
|
/server/netpulse-*
|
||||||
|
/agent/netpulse-*
|
||||||
|
|
||||||
# Секрети й локальні налаштування
|
# Секрети й локальні налаштування
|
||||||
.env
|
.env
|
||||||
|
|
|
||||||
93
HISTORY.md
93
HISTORY.md
|
|
@ -2057,3 +2057,96 @@ source_handle = CASE $19 WHEN '' THEN source_handle
|
||||||
видалення edges.remove → ребра немає
|
видалення edges.remove → ребра немає
|
||||||
дублікат та сама пара вузлів → 400 «такий запис уже існує»
|
дублікат та сама пара вузлів → 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 недоступний ні локально, ні на стенді.
|
||||||
|
|
|
||||||
280
agent/cmd/netpulse-agent/main.go
Normal file
280
agent/cmd/netpulse-agent/main.go
Normal 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
49
deploy/.env.example
Normal 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
57
deploy/Caddyfile
Normal 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
68
deploy/Dockerfile.agent
Normal 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
71
deploy/Dockerfile.server
Normal 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
184
deploy/README.md
Normal 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 оновлює його сам.
|
||||||
|
|
@ -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.
|
name: netpulse
|
||||||
# Міграції накочуються скриптом db/migrate.ps1 (або psql -f у порядку номерів).
|
|
||||||
|
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:
|
services:
|
||||||
db:
|
db:
|
||||||
image: timescale/timescaledb:2.17.2-pg16
|
image: timescale/timescaledb:2.17.2-pg16
|
||||||
container_name: netpulse-db
|
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: netpulse
|
POSTGRES_USER: netpulse
|
||||||
POSTGRES_PASSWORD: netpulse
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?потрібен POSTGRES_PASSWORD}
|
||||||
POSTGRES_DB: netpulse
|
POSTGRES_DB: netpulse
|
||||||
TIMESCALEDB_TELEMETRY: "off"
|
TIMESCALEDB_TELEMETRY: "off"
|
||||||
command:
|
command:
|
||||||
|
|
@ -19,11 +32,9 @@ services:
|
||||||
- -c
|
- -c
|
||||||
- max_connections=200
|
- max_connections=200
|
||||||
- -c
|
- -c
|
||||||
- shared_buffers=512MB
|
- shared_buffers=${PG_SHARED_BUFFERS:-512MB}
|
||||||
- -c
|
- -c
|
||||||
- timescaledb.max_background_workers=8
|
- timescaledb.max_background_workers=8
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
volumes:
|
||||||
- db-data:/var/lib/postgresql/data
|
- db-data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
@ -31,17 +42,130 @@ services:
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 20
|
retries: 20
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
cache:
|
cache:
|
||||||
image: docker.dragonflydb.io/dragonflydb/dragonfly:latest
|
image: docker.dragonflydb.io/dragonflydb/dragonfly:v1.25.5
|
||||||
container_name: netpulse-cache
|
|
||||||
ulimits:
|
ulimits:
|
||||||
memlock: -1
|
memlock: -1
|
||||||
ports:
|
|
||||||
- "6379:6379"
|
|
||||||
volumes:
|
volumes:
|
||||||
- cache-data:/data
|
- 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:
|
volumes:
|
||||||
db-data:
|
db-data:
|
||||||
cache-data:
|
cache-data:
|
||||||
|
caddy-data:
|
||||||
|
caddy-config:
|
||||||
|
agent-identity:
|
||||||
|
|
|
||||||
215
server/cmd/netpulse-server/main.go
Normal file
215
server/cmd/netpulse-server/main.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue