Плагін http стояв у сіді як базовий — тобто обіцяний усім одразу, — а модуля не існувало. Тепер є: код відповіді, час, збіг слова в тілі й залишок днів до кінця сертифіката. Недоступність повертається нулем, а не помилкою чека: на графіку це читається як провал, і саме за цим ставлять тригер. Довіру до ланцюга сертифікатів свідомо не перевіряємо — питають строк, а самопідписаний теж має дату. Повторна лінія між вузлами й повторно доданий хост давали однакове «такий запис уже існує». Тепер кожен випадок каже, що робити далі. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
225 lines
7.7 KiB
Go
225 lines
7.7 KiB
Go
// 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
|
||
}
|