Files
ITWorx-Pulse-Public/internal/probe/executor.go
T
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

267 lines
9.3 KiB
Go

package probe
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"net"
"net/http"
"net/netip"
"net/url"
"reflect"
"strconv"
"strings"
"time"
)
type HTTPDoer interface {
Do(*http.Request) (*http.Response, error)
}
type ProbeExecutor struct {
Policy NetworkPolicy
Resolver Resolver
HTTP HTTPDoer
DialContext func(context.Context, string, string) (net.Conn, error)
Now func() time.Time
}
func (e ProbeExecutor) Execute(ctx context.Context, definition Definition) (Result, error) {
if ctx == nil {
return Result{ProbeID: definition.ID, State: "unknown", ErrorClass: "invalid_context"}, errors.New("probe context is nil")
}
if err := ctx.Err(); err != nil {
return Result{ProbeID: definition.ID, State: "unknown", ErrorClass: "canceled"}, err
}
if err := definition.Validate(); err != nil {
return Result{ProbeID: definition.ID, State: "unknown", ErrorClass: "invalid_probe"}, err
}
if e.Resolver == nil {
e.Resolver = NetResolver{}
}
if e.Now == nil {
e.Now = func() time.Time { return time.Now().UTC() }
}
probeContext, cancel := context.WithTimeout(ctx, definition.Timeout)
defer cancel()
switch definition.Type {
case TypeHTTP:
return e.executeHTTP(probeContext, definition)
case TypeTCP:
return e.executeTCP(probeContext, definition)
case TypeDNS:
return e.executeDNS(probeContext, definition)
case TypeTLS:
return e.executeTLS(probeContext, definition)
case TypeICMP:
return Result{ProbeID: definition.ID, ObservedAt: e.Now().UTC(), CompletedAt: e.Now().UTC(), State: "unknown", ErrorClass: "unsupported"}, nil
default:
return Result{ProbeID: definition.ID, ObservedAt: e.Now().UTC(), CompletedAt: e.Now().UTC(), State: "unknown", ErrorClass: "unsupported"}, nil
}
}
func (e ProbeExecutor) executeHTTP(ctx context.Context, definition Definition) (Result, error) {
started := e.Now().UTC()
scheme := strings.ToLower(strings.TrimSpace(definition.Target.Scheme))
if scheme == "" {
scheme = "http"
}
port := defaultPort(scheme, 80)
if definition.Target.Port > 0 {
port = definition.Target.Port
}
if _, err := e.Policy.ValidateTarget(ctx, e.Resolver, scheme, definition.Target.Host, port); err != nil {
return transportFailure(definition.ID, err), err
}
target := &url.URL{Scheme: scheme, Host: net.JoinHostPort(definition.Target.Host, strconv.Itoa(port)), Path: definition.Target.Path}
if target.Path == "" {
target.Path = "/"
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
if err != nil {
return transportFailure(definition.ID, err), err
}
if e.HTTP == nil {
client, clientErr := NewSafeClient(e.Policy, e.Resolver)
if clientErr != nil {
return transportFailure(definition.ID, clientErr), clientErr
}
if !definition.FollowRedirects {
client.HTTP.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
}
e.HTTP = client
}
response, err := e.HTTP.Do(request)
if err != nil {
return transportFailure(definition.ID, err), err
}
if response == nil {
err = errors.New("probe response is invalid")
return transportFailure(definition.ID, err), err
}
body, bodyErr := ReadLimitedBody(response, e.Policy.withDefaults().MaxResponseBytes)
result := Result{ProbeID: definition.ID, ObservedAt: started, CompletedAt: e.Now().UTC(), ResponseTimeMS: intPointer(int(time.Since(started).Milliseconds())), StatusCode: intPointer(response.StatusCode)}
if bodyErr != nil {
result.State = "unknown"
result.ErrorClass = "response_too_large"
return result, nil
}
if !expectedStatus(definition.ExpectedStatusCodes, response.StatusCode) {
result.State = "down"
result.ErrorClass = "status_not_expected"
return result, nil
}
if assertionErr := assertBody(body, definition.ContentAssertion); assertionErr != nil {
result.State = "degraded"
result.ErrorClass = "content_assertion_failed"
return result, nil
}
result.State = "up"
return result, nil
}
func (e ProbeExecutor) executeTCP(ctx context.Context, definition Definition) (Result, error) {
started := e.Now().UTC()
port := definition.Target.Port
if port == 0 {
return transportFailure(definition.ID, errors.New("tcp port is required")), errors.New("tcp port is required")
}
addresses, err := e.Policy.ValidateTarget(ctx, e.Resolver, TypeTCP, definition.Target.Host, port)
if err != nil {
return transportFailure(definition.ID, err), err
}
connection, err := e.dial(ctx, "tcp", addresses[0], port)
if err != nil {
return transportFailure(definition.ID, err), err
}
if connection == nil {
err = errors.New("probe dial returned nil connection")
return transportFailure(definition.ID, err), err
}
_ = connection.Close()
return Result{ProbeID: definition.ID, ObservedAt: started, CompletedAt: e.Now().UTC(), State: "up", ResponseTimeMS: intPointer(int(time.Since(started).Milliseconds()))}, nil
}
func (e ProbeExecutor) executeDNS(ctx context.Context, definition Definition) (Result, error) {
started := e.Now().UTC()
addresses, err := e.Policy.ValidateTarget(ctx, e.Resolver, TypeDNS, definition.Target.Host, defaultPort("dns", 53))
if err != nil {
return transportFailure(definition.ID, err), err
}
return Result{ProbeID: definition.ID, ObservedAt: started, CompletedAt: e.Now().UTC(), State: "up", ResponseTimeMS: intPointer(int(time.Since(started).Milliseconds())), Attributes: map[string]any{"addressCount": len(addresses)}}, nil
}
func (e ProbeExecutor) executeTLS(ctx context.Context, definition Definition) (Result, error) {
started := e.Now().UTC()
port := defaultPort(definition.Target.Scheme, 443)
if definition.Target.Port > 0 {
port = definition.Target.Port
}
addresses, err := e.Policy.ValidateTarget(ctx, e.Resolver, TypeTLS, definition.Target.Host, port)
if err != nil {
return transportFailure(definition.ID, err), err
}
connection, err := e.dial(ctx, "tcp", addresses[0], port)
if err != nil {
return transportFailure(definition.ID, err), err
}
if connection == nil {
err = errors.New("probe dial returned nil connection")
return transportFailure(definition.ID, err), err
}
defer connection.Close()
tlsConnection := tls.Client(connection, &tls.Config{ServerName: definition.Target.Host, MinVersion: tls.VersionTLS12, InsecureSkipVerify: !definition.VerifyTLS})
if err := tlsConnection.HandshakeContext(ctx); err != nil {
return transportFailure(definition.ID, err), err
}
state := tlsConnection.ConnectionState()
result := Result{ProbeID: definition.ID, ObservedAt: started, CompletedAt: e.Now().UTC(), State: "up", ResponseTimeMS: intPointer(int(time.Since(started).Milliseconds()))}
if len(state.PeerCertificates) > 0 {
result.Certificate = certificateFromX509(state.PeerCertificates[0], definition, e.Now)
if result.Certificate.VerificationState == "attention" || result.Certificate.VerificationState == "invalid" {
result.State = "degraded"
}
}
return result, nil
}
func (e ProbeExecutor) dial(ctx context.Context, network string, address netip.Addr, port int) (net.Conn, error) {
if e.DialContext != nil {
return e.DialContext(ctx, network, net.JoinHostPort(address.String(), strconv.Itoa(port)))
}
dialer := &net.Dialer{Timeout: e.Policy.withDefaults().Timeout}
return dialer.DialContext(ctx, network, net.JoinHostPort(address.String(), strconv.Itoa(port)))
}
func certificateFromX509(certificate *x509.Certificate, definition Definition, now func() time.Time) *Certificate {
if certificate == nil {
return nil
}
observed := now().UTC()
hostnameValid := certificate.VerifyHostname(definition.Target.Host) == nil
verification := "valid"
if !hostnameValid {
verification = "invalid"
} else if !certificate.NotAfter.After(observed) {
verification = "invalid"
} else if certificate.NotAfter.Before(observed.Add(30 * 24 * time.Hour)) {
verification = "attention"
}
return &Certificate{ID: definition.ID + "-certificate", ServiceID: definition.ServiceID, EndpointID: definition.EndpointID, ObservedAt: observed, ExpiresAt: &certificate.NotAfter, Issuer: certificate.Issuer.String(), Subject: certificate.Subject.String(), HostnameValid: &hostnameValid, VerificationState: verification}
}
func assertBody(body []byte, assertion map[string]any) error {
if len(assertion) == 0 {
return nil
}
if keyword, ok := assertion["keyword"].(string); ok && !strings.Contains(string(body), keyword) {
return errors.New("keyword assertion failed")
}
if expected, ok := assertion["json"].(map[string]any); ok {
var actual map[string]any
if err := json.Unmarshal(body, &actual); err != nil {
return errors.New("json assertion body is invalid")
}
for key, value := range expected {
if !reflect.DeepEqual(actual[key], value) {
return errors.New("json assertion failed")
}
}
}
return nil
}
func expectedStatus(expected []int, status int) bool {
if len(expected) == 0 {
return status >= 200 && status < 400
}
for _, candidate := range expected {
if candidate == status {
return true
}
}
return false
}
func defaultPort(scheme string, fallback int) int {
if scheme == "http" {
return 80
}
if scheme == "https" || scheme == "tls" {
return 443
}
return fallback
}
func transportFailure(id string, err error) Result {
result := Result{ProbeID: id, State: "unknown", ErrorClass: "transport_error"}
if err != nil {
result.ErrorMessage = boundText(err.Error(), 256)
}
return result
}
func intPointer(value int) *int { return &value }