Дрібний борг: модуль 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 мс
|
||||
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 | ✅ | ✅ |
|
||||
| Білінг, ліцензії | ✅ | ❌ |
|
||||
| Пакування, розгортання | — | ⚠️ образи й стек описані, жодного разу не зібрані |
|
||||
| Пакування, розгортання | — | ✅ зібрано й розгорнуто на живому сервері |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -325,10 +325,11 @@ Diff* — усе з ТЗ.
|
|||
|
||||
## Дрібний борг
|
||||
|
||||
- Повторне малювання зв'язку між тією ж парою вузлів дає загальне «такий запис
|
||||
уже існує» замість зрозумілого пояснення.
|
||||
- Перетягування вузла через UI не покрите автотестом: синтетичні події не
|
||||
запускають d3-drag у React Flow. Шлях перевірений тестами сервера.
|
||||
- Підкладки-плани приміщень віддаються API, але полотно їх не малює — потрібен
|
||||
прийом і роздача файлів (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/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 не модуль реєстру: у нього немає задач і розкладу,
|
||||
|
|
|
|||
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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue