Дрібний борг: модуль http/ssl і зрозумілі помилки на мапі
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run

Плагін http стояв у сіді як базовий — тобто обіцяний усім одразу, — а
модуля не існувало. Тепер є: код відповіді, час, збіг слова в тілі й
залишок днів до кінця сертифіката.

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

Повторна лінія між вузлами й повторно доданий хост давали однакове
«такий запис уже існує». Тепер кожен випадок каже, що робити далі.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-25 18:37:58 +03:00
parent 55e10cbd08
commit 32b0b003df
6 changed files with 291 additions and 6 deletions

View file

@ -2998,3 +2998,47 @@ docker compose run --rm api netpulse-user …
хост «перевірка звʼязку» → up, 5 зразків ICMP, rtt 16.73 мс хост «перевірка звʼязку» → up, 5 зразків ICMP, rtt 16.73 мс
icmp-basic сам створив 2 правила сповіщень 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
хост прибрано
```

View file

@ -31,7 +31,7 @@ API віддає готове полотно з живими статусами,
| Веб-інтерфейс (навігація, сторінки) | — | ✅ | | Веб-інтерфейс (навігація, сторінки) | — | ✅ |
| Дашборди, NOC TV | ✅ | ✅ | | Дашборди, NOC TV | ✅ | ✅ |
| Білінг, ліцензії | ✅ | ❌ | | Білінг, ліцензії | ✅ | ❌ |
| Пакування, розгортання | — | ⚠️ образи й стек описані, жодного разу не зібрані | | Пакування, розгортання | — | ✅ зібрано й розгорнуто на живому сервері |
--- ---
@ -325,10 +325,11 @@ Diff* — усе з ТЗ.
## Дрібний борг ## Дрібний борг
- Повторне малювання зв'язку між тією ж парою вузлів дає загальне «такий запис
уже існує» замість зрозумілого пояснення.
- Перетягування вузла через UI не покрите автотестом: синтетичні події не - Перетягування вузла через UI не покрите автотестом: синтетичні події не
запускають d3-drag у React Flow. Шлях перевірений тестами сервера. запускають d3-drag у React Flow. Шлях перевірений тестами сервера.
- Підкладки-плани приміщень віддаються API, але полотно їх не малює — потрібен - Підкладки-плани приміщень віддаються API, але полотно їх не малює — потрібен
прийом і роздача файлів (S3/MinIO або локальний диск). прийом і роздача файлів (S3/MinIO або локальний диск).
- Модулі `http`/`ssl` і `modbus` оголошені в сіді плагінів, але не написані. - `modbus` і `netflow` оголошені в сіді плагінів і не написані. Обидва позначені
`is_core = false`, тож у переліку перевірок система показує їх недоступними —
обіцянки немає. Писати Modbus без жодного інвертора під рукою означало б
віддати неперевірений промисловий протокол.

View file

@ -20,6 +20,7 @@ import (
"github.com/netpulse/netpulse/agent/internal/config" "github.com/netpulse/netpulse/agent/internal/config"
"github.com/netpulse/netpulse/agent/internal/module" "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/icmp"
"github.com/netpulse/netpulse/agent/internal/modules/snmp" "github.com/netpulse/netpulse/agent/internal/modules/snmp"
"github.com/netpulse/netpulse/agent/internal/modules/syslog" "github.com/netpulse/netpulse/agent/internal/modules/syslog"
@ -132,6 +133,9 @@ func run() error {
if err := reg.Register(topology.New()); err != nil { if err := reg.Register(topology.New()); err != nil {
return err return err
} }
if err := reg.Register(httpx.New()); err != nil {
return err
}
reg.EnsureDefaults(cfg.DefaultModules...) reg.EnsureDefaults(cfg.DefaultModules...)
// Приймач syslog не модуль реєстру: у нього немає задач і розкладу, // Приймач syslog не модуль реєстру: у нього немає задач і розкладу,

View file

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

View file

@ -31,8 +31,8 @@ func main() {
func run() error { func run() error {
var ( var (
dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL") dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL")
tenant = flag.String("tenant", "", "slug або uuid тенанта") tenant = flag.String("tenant", "", "slug або uuid тенанта")
newTenant = flag.String("create-tenant", "", newTenant = flag.String("create-tenant", "",
"створити тенанта з таким іменем, якщо його ще немає") "створити тенанта з таким іменем, якщо його ще немає")
login = flag.String("login", "", "логін користувача") login = flag.String("login", "", "логін користувача")

View file

@ -667,6 +667,17 @@ func mapPgError(err error) error {
} }
switch pgErr.Code { switch pgErr.Code {
case "23505": // unique_violation 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) return fmt.Errorf("%w: такий запис уже існує", ErrInvalid)
case "23503": // foreign_key_violation case "23503": // foreign_key_violation
return fmt.Errorf("%w: посилання на неіснуючий об'єкт", ErrInvalid) return fmt.Errorf("%w: посилання на неіснуючий об'єкт", ErrInvalid)