Public source validation / validate (push) Failing after 3m8s
267 lines
9.3 KiB
Go
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 }
|