Дрібний борг: модуль http/ssl і зрозумілі помилки на мапі
Плагін http стояв у сіді як базовий — тобто обіцяний усім одразу, — а модуля не існувало. Тепер є: код відповіді, час, збіг слова в тілі й залишок днів до кінця сертифіката. Недоступність повертається нулем, а не помилкою чека: на графіку це читається як провал, і саме за цим ставлять тригер. Довіру до ланцюга сертифікатів свідомо не перевіряємо — питають строк, а самопідписаний теж має дату. Повторна лінія між вузлами й повторно доданий хост давали однакове «такий запис уже існує». Тепер кожен випадок каже, що робити далі. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
55e10cbd08
commit
32b0b003df
6 changed files with 291 additions and 6 deletions
44
HISTORY.md
44
HISTORY.md
|
|
@ -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
|
||||||
|
хост прибрано
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -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 без жодного інвертора під рукою означало б
|
||||||
|
віддати неперевірений промисловий протокол.
|
||||||
|
|
|
||||||
|
|
@ -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 не модуль реєстру: у нього немає задач і розкладу,
|
||||||
|
|
|
||||||
225
agent/internal/modules/httpx/httpx.go
Normal file
225
agent/internal/modules/httpx/httpx.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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", "", "логін користувача")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue