Netpulse_SasS/agent/internal/modules/httpx/httpx.go
byrsapty 32b0b003df
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run
Дрібний борг: модуль http/ssl і зрозумілі помилки на мапі
Плагін http стояв у сіді як базовий — тобто обіцяний усім одразу, — а
модуля не існувало. Тепер є: код відповіді, час, збіг слова в тілі й
залишок днів до кінця сертифіката.

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 18:37:58 +03:00

225 lines
7.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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