From 32b0b003df2fd6de86b1be8226f928a4bcf9226b Mon Sep 17 00:00:00 2001 From: byrsapty Date: Tue, 25 Aug 2026 18:37:58 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D1=80=D1=96=D0=B1=D0=BD=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=B3:=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB?= =?UTF-8?q?=D1=8C=20http/ssl=20=D1=96=20=D0=B7=D1=80=D0=BE=D0=B7=D1=83?= =?UTF-8?q?=D0=BC=D1=96=D0=BB=D1=96=20=D0=BF=D0=BE=D0=BC=D0=B8=D0=BB=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BD=D0=B0=20=D0=BC=D0=B0=D0=BF=D1=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Плагін http стояв у сіді як базовий — тобто обіцяний усім одразу, — а модуля не існувало. Тепер є: код відповіді, час, збіг слова в тілі й залишок днів до кінця сертифіката. Недоступність повертається нулем, а не помилкою чека: на графіку це читається як провал, і саме за цим ставлять тригер. Довіру до ланцюга сертифікатів свідомо не перевіряємо — питають строк, а самопідписаний теж має дату. Повторна лінія між вузлами й повторно доданий хост давали однакове «такий запис уже існує». Тепер кожен випадок каже, що робити далі. Co-Authored-By: Claude Opus 5 --- HISTORY.md | 44 +++++ ROADMAP.md | 9 +- agent/cmd/netpulse-agent/main.go | 4 + agent/internal/modules/httpx/httpx.go | 225 ++++++++++++++++++++++++++ server/cmd/netpulse-user/main.go | 4 +- server/internal/store/maps_write.go | 11 ++ 6 files changed, 291 insertions(+), 6 deletions(-) create mode 100644 agent/internal/modules/httpx/httpx.go diff --git a/HISTORY.md b/HISTORY.md index c69a1c5..a04474a 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -2998,3 +2998,47 @@ docker compose run --rm api netpulse-user … хост «перевірка звʼязку» → up, 5 зразків ICMP, rtt 16.73 мс icmp-basic сам створив 2 правила сповіщень ``` + +## Дрібний борг + +### Незрозуміла помилка на мапі + +Повторна лінія між тими самими вузлами й повторно доданий хост давали +однакове «такий запис уже існує». Обидва випадки трапляються щодня, і це +не казало людині, що робити далі. + +Тепер розрізняємо за іменем індексу: «між цими вузлами вже є звʼязок — +щоб змінити його вигляд, оберіть наявну лінію» і «цей хост уже на схемі». + +### Модуль http/ssl написано + +Плагін `http` стояв у сіді з `is_core = true` — тобто система обіцяла +його всім і одразу, — а модуля не існувало. Тепер є: + +`http.status` — код відповіді, час, збіг слова в тілі. Редиректи не +ходимо: перевіряють конкретну адресу, і «200 після трьох перекидань на +іншу машину» — відповідь не на те питання. Недоступність повертається +нулем, а не помилкою чека: на графіку це читається як провал, і саме за +цим зручно ставити тригер. + +`http.ssl_expiry` — скільки днів лишилось. Довіру до ланцюга свідомо не +перевіряємо: питають строк, а самопідписаний сертифікат теж має дату. +Значення може бути відʼємним — «протермінувався вчора» і +«протермінувався пів року тому» не одне й те саме. + +Якщо сертифікат уже прийшов із відповіддю на `http.status`, залишок днів +рахується там само, без другого підключення. + +`modbus` і `netflow` лишаються ненаписаними, і система це вже показує +чесно: обидва з `is_core = false`, тож у переліку перевірок вони +недоступні. Писати Modbus без жодного інвертора під рукою означало б +віддати неперевірений промисловий протокол у мережу з живим обладнанням. + +### Перевірено на бойовому сервері + +``` +типи http.status і http.ssl_expiry — доступні +тимчасовий хост із двома чеками +через 8 с http.status, http.latency_ms, http.ok, ssl.days_left +хост прибрано +``` diff --git a/ROADMAP.md b/ROADMAP.md index 5e5c8c8..33ae726 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -31,7 +31,7 @@ API віддає готове полотно з живими статусами, | Веб-інтерфейс (навігація, сторінки) | — | ✅ | | Дашборди, NOC TV | ✅ | ✅ | | Білінг, ліцензії | ✅ | ❌ | -| Пакування, розгортання | — | ⚠️ образи й стек описані, жодного разу не зібрані | +| Пакування, розгортання | — | ✅ зібрано й розгорнуто на живому сервері | --- @@ -325,10 +325,11 @@ Diff* — усе з ТЗ. ## Дрібний борг -- Повторне малювання зв'язку між тією ж парою вузлів дає загальне «такий запис - уже існує» замість зрозумілого пояснення. - Перетягування вузла через UI не покрите автотестом: синтетичні події не запускають d3-drag у React Flow. Шлях перевірений тестами сервера. - Підкладки-плани приміщень віддаються API, але полотно їх не малює — потрібен прийом і роздача файлів (S3/MinIO або локальний диск). -- Модулі `http`/`ssl` і `modbus` оголошені в сіді плагінів, але не написані. +- `modbus` і `netflow` оголошені в сіді плагінів і не написані. Обидва позначені + `is_core = false`, тож у переліку перевірок система показує їх недоступними — + обіцянки немає. Писати Modbus без жодного інвертора під рукою означало б + віддати неперевірений промисловий протокол. diff --git a/agent/cmd/netpulse-agent/main.go b/agent/cmd/netpulse-agent/main.go index dc74a94..91b4e91 100644 --- a/agent/cmd/netpulse-agent/main.go +++ b/agent/cmd/netpulse-agent/main.go @@ -20,6 +20,7 @@ import ( "github.com/netpulse/netpulse/agent/internal/config" "github.com/netpulse/netpulse/agent/internal/module" + "github.com/netpulse/netpulse/agent/internal/modules/httpx" "github.com/netpulse/netpulse/agent/internal/modules/icmp" "github.com/netpulse/netpulse/agent/internal/modules/snmp" "github.com/netpulse/netpulse/agent/internal/modules/syslog" @@ -132,6 +133,9 @@ func run() error { if err := reg.Register(topology.New()); err != nil { return err } + if err := reg.Register(httpx.New()); err != nil { + return err + } reg.EnsureDefaults(cfg.DefaultModules...) // Приймач syslog не модуль реєстру: у нього немає задач і розкладу, diff --git a/agent/internal/modules/httpx/httpx.go b/agent/internal/modules/httpx/httpx.go new file mode 100644 index 0000000..32452ab --- /dev/null +++ b/agent/internal/modules/httpx/httpx.go @@ -0,0 +1,225 @@ +// Package httpx — перевірки веб-сервісів і строку дії сертифікатів. +// +// Два чеки, які в моніторингу питають найчастіше після пінгу: «сайт +// віддає що треба?» і «коли протермінується сертифікат?». Друге не +// метрика стану, а зворотний відлік: про нього дізнаються або за місяць, +// або в неділю о третій ночі, коли все лягло. +package httpx + +import ( + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" + + "github.com/netpulse/netpulse/agent/internal/module" +) + +type Module struct { + client *http.Client +} + +func New() *Module { + return &Module{ + client: &http.Client{ + // Редиректи не ходимо: перевіряють конкретну адресу, і + // «200 після трьох перекидань на іншу машину» — відповідь + // не на те питання. Код 301/302 сам по собі теж стан. + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + } +} + +func (m *Module) Key() string { return "http" } +func (m *Module) CheckTypes() []string { + return []string{"http.status", "http.ssl_expiry"} +} +func (m *Module) Close() error { return nil } + +type statusParams struct { + URL string `json:"url"` + // Який код вважати справним. 0 — будь-який 2xx. + ExpectStatus int `json:"expect_status"` + // Слово, яке має бути в тілі. Порожньо — тіло не читаємо. + Keyword string `json:"keyword"` + // Не перевіряти сертифікат: у мережах повно заліза з + // самопідписаними, і вимагати від них валідності означає не + // моніторити їх узагалі. + Insecure bool `json:"insecure"` +} + +type sslParams struct { + Host string `json:"host"` + Port int `json:"port"` +} + +func (m *Module) Run(ctx context.Context, task module.Task) (module.Result, error) { + switch task.CheckName() { + case "status": + return m.runStatus(ctx, task) + case "ssl_expiry": + return m.runSSL(ctx, task) + default: + return module.Result{}, fmt.Errorf("http: невідомий чек %q", task.CheckType) + } +} + +func (m *Module) runStatus(ctx context.Context, task module.Task) (module.Result, error) { + var p statusParams + if len(task.Params) > 0 { + if err := json.Unmarshal(task.Params, &p); err != nil { + return module.Result{}, fmt.Errorf("http.status: невалідні params: %w", err) + } + } + if p.URL == "" { + // Адреса хоста як запасний варіант: чек, доданий одним рухом із + // картки, не має вимагати ще й переписати адресу вручну. + if task.Target.Address == "" { + return module.Result{}, errors.New("http.status: не вказано url") + } + p.URL = "http://" + task.Target.Address + } + if !strings.Contains(p.URL, "://") { + p.URL = "http://" + p.URL + } + + ctx, cancel := context.WithTimeout(ctx, timeoutOr(task.Timeout, 10*time.Second)) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.URL, nil) + if err != nil { + return module.Result{}, err + } + req.Header.Set("User-Agent", "NetPulse/1.0 (моніторинг)") + + client := *m.client + if p.Insecure { + client.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // свідомо + } + } + + started := time.Now() + resp, err := client.Do(req) + latency := time.Since(started) + if err != nil { + // Недоступність — це теж результат, а не збій перевірки: нуль у + // http.status читається на графіку як провал, і сáме за ним + // зручно ставити тригер. + return module.Result{Metrics: []module.Metric{ + {MetricKey: "http.status", Value: 0, Ts: started}, + {MetricKey: "http.latency_ms", Unit: "ms", Value: float64(latency.Milliseconds()), Ts: started}, + }}, nil + } + defer resp.Body.Close() + + ok := 1.0 + if p.ExpectStatus > 0 { + if resp.StatusCode != p.ExpectStatus { + ok = 0 + } + } else if resp.StatusCode < 200 || resp.StatusCode > 299 { + ok = 0 + } + + if p.Keyword != "" { + // Читаємо не більше мегабайта: сторінка може віддавати потік, і + // перевірка не має перетворюватись на завантаження. + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if !strings.Contains(string(body), p.Keyword) { + ok = 0 + } + } + + metrics := []module.Metric{ + {MetricKey: "http.status", Value: float64(resp.StatusCode), Ts: started}, + {MetricKey: "http.latency_ms", Unit: "ms", Value: float64(latency.Milliseconds()), Ts: started}, + {MetricKey: "http.ok", Value: ok, Ts: started}, + } + + // Сертифікат уже перед очима — рахуємо залишок тут само, без + // другого підключення. + if resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 { + metrics = append(metrics, module.Metric{ + MetricKey: "ssl.days_left", + Unit: "d", + Value: daysLeft(resp.TLS.PeerCertificates[0].NotAfter, started), + Ts: started, + }) + } + return module.Result{Metrics: metrics}, nil +} + +func (m *Module) runSSL(ctx context.Context, task module.Task) (module.Result, error) { + var p sslParams + if len(task.Params) > 0 { + if err := json.Unmarshal(task.Params, &p); err != nil { + return module.Result{}, fmt.Errorf("http.ssl_expiry: невалідні params: %w", err) + } + } + host := p.Host + if host == "" { + host = task.Target.Address + } + if host == "" { + return module.Result{}, errors.New("http.ssl_expiry: не вказано хост") + } + port := p.Port + if port == 0 { + port = 443 + } + + ctx, cancel := context.WithTimeout(ctx, timeoutOr(task.Timeout, 10*time.Second)) + defer cancel() + + d := &tls.Dialer{ + Config: &tls.Config{ + ServerName: host, + // Перевіряємо строк, а не довіру: самопідписаний сертифікат + // теж має дату, і саме її тут питають. Невалідний ланцюг — + // окреме питання, і зривати через нього відлік днів + // означало б осліпнути там, де найчастіше й забувають + // продовжити. + InsecureSkipVerify: true, //nolint:gosec // свідомо, див. коментар + }, + } + conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, fmt.Sprint(port))) + if err != nil { + return module.Result{}, err + } + defer conn.Close() + + state := conn.(*tls.Conn).ConnectionState() + if len(state.PeerCertificates) == 0 { + return module.Result{}, errors.New("сервер не надіслав сертифіката") + } + now := time.Now() + return module.Result{Metrics: []module.Metric{{ + MetricKey: "ssl.days_left", + Unit: "d", + Value: daysLeft(state.PeerCertificates[0].NotAfter, now), + Ts: now, + }}}, nil +} + +// daysLeft — скільки днів лишилось. Може бути відʼємним: прострочений +// сертифікат теж треба бачити, і обрізати його нулем означало б +// зрівняти «протермінувався вчора» з «протермінувався пів року тому». +func daysLeft(notAfter, now time.Time) float64 { + return notAfter.Sub(now).Hours() / 24 +} + +func timeoutOr(v, def time.Duration) time.Duration { + if v <= 0 { + return def + } + return v +} diff --git a/server/cmd/netpulse-user/main.go b/server/cmd/netpulse-user/main.go index 10efb94..ba98151 100644 --- a/server/cmd/netpulse-user/main.go +++ b/server/cmd/netpulse-user/main.go @@ -31,8 +31,8 @@ func main() { func run() error { var ( - dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL") - tenant = flag.String("tenant", "", "slug або uuid тенанта") + dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL") + tenant = flag.String("tenant", "", "slug або uuid тенанта") newTenant = flag.String("create-tenant", "", "створити тенанта з таким іменем, якщо його ще немає") login = flag.String("login", "", "логін користувача") diff --git a/server/internal/store/maps_write.go b/server/internal/store/maps_write.go index 814f464..c1623cf 100644 --- a/server/internal/store/maps_write.go +++ b/server/internal/store/maps_write.go @@ -667,6 +667,17 @@ func mapPgError(err error) error { } switch pgErr.Code { case "23505": // unique_violation + // Ім'я індексу — єдине, що відрізняє «намалював лінію + // вдруге» від «додав хост, який уже на схемі». Обидва + // випадки трапляються щодня, і загальне «такий запис уже + // існує» не каже людині, що саме робити далі. + switch pgErr.ConstraintName { + case "map_edges_ports_uniq": + return fmt.Errorf("%w: між цими вузлами вже є звʼязок — "+ + "щоб змінити його вигляд, оберіть наявну лінію", ErrInvalid) + case "map_nodes_device_uniq": + return fmt.Errorf("%w: цей хост уже на схемі", ErrInvalid) + } return fmt.Errorf("%w: такий запис уже існує", ErrInvalid) case "23503": // foreign_key_violation return fmt.Errorf("%w: посилання на неіснуючий об'єкт", ErrInvalid)