Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ modules:
fail_if_body_not_matches_regexp:
[ - <regex>, ... ]

# Probe fails if the reponse body does not match one of the given hashes.
# Hashes must be valid hexadecimal encoded strings.
fail_if_body_not_matches_hash:
[ - <string>, ... ]

# Probe fails if response header matches regex. For headers with multiple values, fails if *at least one* matches.
fail_if_header_matches:
[ - <http_header_match_spec>, ... ]
Expand Down Expand Up @@ -166,6 +171,14 @@ modules:
# It is mutually exclusive with `body`.
[ body_file: <filename> ]

# Hashing algorithm used for `fail_if_hash_not_matches` and `export_hash` (sha256, sha512)
[ hash_algorithm: <string> | default = "sha256" ]

# Export a hash of the response body.
# The processed data is limited by `body_size_limit`.
# NOTE: only use this on resources that seldom change, as it may lead to high label cardinality.
[ export_hash: <bool> | default = false ]

```

#### `<http_header_match_spec>`
Expand Down
3 changes: 3 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ type HTTPProbe struct {
Headers map[string]string `yaml:"headers,omitempty"`
FailIfBodyMatchesRegexp []Regexp `yaml:"fail_if_body_matches_regexp,omitempty"`
FailIfBodyNotMatchesRegexp []Regexp `yaml:"fail_if_body_not_matches_regexp,omitempty"`
FailIfBodyNotMatchesHash []string `yaml:"fail_if_body_not_matches_hash,omitempty"`
FailIfBodyJsonMatchesCEL *CELProgram `yaml:"fail_if_body_json_matches_cel,omitempty"`
FailIfBodyJsonNotMatchesCEL *CELProgram `yaml:"fail_if_body_json_not_matches_cel,omitempty"`
FailIfHeaderMatchesRegexp []HeaderMatch `yaml:"fail_if_header_matches,omitempty"`
Expand All @@ -291,6 +292,8 @@ type HTTPProbe struct {
HTTPClientConfig config.HTTPClientConfig `yaml:"http_client_config,inline"`
Compression string `yaml:"compression,omitempty"`
BodySizeLimit units.Base2Bytes `yaml:"body_size_limit,omitempty"`
HashAlgorithm string `yaml:"hash_algorithm,omitempty"`
ExportHash bool `yaml:"export_hash,omitempty"`
}

type GRPCProbe struct {
Expand Down
73 changes: 72 additions & 1 deletion prober/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,15 @@ import (
"compress/flate"
"compress/gzip"
"context"
"crypto/sha256"
"crypto/sha512"
"crypto/tls"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"hash"
"hash/fnv"
"io"
"log/slog"
"net"
Expand All @@ -30,6 +35,7 @@ import (
"net/textproto"
"net/url"
"os"
"slices"
"strconv"
"strings"
"sync"
Expand Down Expand Up @@ -299,6 +305,14 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr
Name: "probe_http_content_length",
Help: "Length of http content response",
})
contentChecksumGauge = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "probe_http_content_checksum",
Help: "Contains the FNV-1a checksum of the page body as a value",
})
contentHashGaugeVec = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "probe_http_content_hash",
Help: "Contains the cryptographic hash of the page body as a label",
}, []string{module.HTTP.HashAlgorithm})
bodyUncompressedLengthGauge = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "probe_http_uncompressed_body_length",
Help: "Length of uncompressed response body",
Expand Down Expand Up @@ -349,6 +363,10 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr
Name: "probe_failed_due_to_regex",
Help: "Indicates if probe failed due to regex",
})
probeFailedDueToHash = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "probe_failed_due_to_hash",
Help: "Indicates if probe failed due to a hash mismatch",
})

probeFailedDueToCEL = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "probe_failed_due_to_cel",
Expand All @@ -369,6 +387,7 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr
registry.MustRegister(statusCodeGauge)
registry.MustRegister(probeHTTPVersionGauge)
registry.MustRegister(probeFailedDueToRegex)
registry.MustRegister(probeFailedDueToHash)

httpConfig := module.HTTP

Expand Down Expand Up @@ -620,7 +639,9 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr
}

if !requestErrored {
_, err = io.Copy(io.Discard, byteCounter)
enableHash := len(httpConfig.FailIfBodyNotMatchesHash) > 0 || httpConfig.ExportHash

hashStr, checksum, err := hashContent(byteCounter, enableHash, httpConfig.HashAlgorithm)
if err != nil {
logger.Info("Failed to read HTTP response body", "err", err)
success = false
Expand All @@ -634,6 +655,25 @@ func ProbeHTTP(ctx context.Context, target string, module config.Module, registr
// case it contains useful information as to what's the problem.
logger.Info("Error while closing response from server", "error", err.Error())
}

if success {
registry.MustRegister(contentChecksumGauge)
contentChecksumGauge.Set(float64(checksum))

if len(httpConfig.FailIfBodyNotMatchesHash) > 0 {
success = slices.Contains(httpConfig.FailIfBodyNotMatchesHash, hashStr)
if success {
probeFailedDueToHash.Set(0)
} else {
probeFailedDueToHash.Set(1)
}
}

if httpConfig.ExportHash {
registry.MustRegister(contentHashGaugeVec)
contentHashGaugeVec.WithLabelValues(hashStr).Set(1)
}
}
}

// At this point body is fully read and we can write end time.
Expand Down Expand Up @@ -754,3 +794,34 @@ func getDecompressionReader(algorithm string, origBody io.ReadCloser) (io.ReadCl
return nil, errors.New("unsupported compression algorithm")
}
}

func hashContent(src io.Reader, hashBody bool, useHash string) (hashStr string, checksum uint32, err error) {
fnvHash := fnv.New32a()
cryptoHash := hash.Hash(nil)

if hashBody {
switch useHash {
case "", "sha256":
cryptoHash = sha256.New()
case "sha512":
cryptoHash = sha512.New()
default:
return "", 0, errors.New("unsupported hash algorithm")
}
}

if cryptoHash != nil {
src = io.TeeReader(src, cryptoHash)
}

_, err = io.Copy(fnvHash, src)
if err != nil {
return "", 0, err
}

if cryptoHash != nil {
return hex.EncodeToString(cryptoHash.Sum(nil)), fnvHash.Sum32(), nil
}

return "", fnvHash.Sum32(), nil
}
114 changes: 114 additions & 0 deletions prober/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1766,3 +1766,117 @@ func TestBody(t *testing.T) {
}
}
}

func TestProbeHTTP_checksums(t *testing.T) {
tests := map[string]struct {
Body []byte
Hash string
Match []string
Export bool
ExpFNV float64
ExpSuccess bool
ExpHashFail float64
ExpHash string
}{
"no body, no hash": {
Body: nil,
Export: false,
Hash: "no",
ExpFNV: float64(uint32(0x811c9dc5)),
ExpSuccess: true,
ExpHashFail: 0,
},
"export sha256": {
Body: []byte("testVector"),
Hash: "sha256",
Export: true,
ExpFNV: float64(uint32(0xe1ff1c02)),
ExpSuccess: true,
ExpHashFail: 0,
ExpHash: "9a85c8667798425f82e41d72a4bf3c5901ccfb726f62868048ddbc1934ab18ad",
},
"export sha512": {
Body: []byte("testVector"),
Hash: "sha512",
Export: true,
ExpFNV: float64(uint32(0xe1ff1c02)),
ExpSuccess: true,
ExpHashFail: 0,
ExpHash: "d32b14dd7cc9bf27a18037c057b27bebe05eb536f9035324a64d598bfd642f32" +
"d7732d1855be0a8ec7c464cc6b9a4cc74a69883e74875105c7203b751170121e",
},
"match sha256": {
Body: []byte("testVector"),
Hash: "sha256",
Match: []string{"9a85c8667798425f82e41d72a4bf3c5901ccfb726f62868048ddbc1934ab18ad"},
ExpFNV: float64(uint32(0xe1ff1c02)),
ExpSuccess: true,
ExpHashFail: 0,
},
"no match sha256": {
Body: []byte("tampered body"),
Hash: "sha256",
Export: false,
Match: []string{"9a85c8667798425f82e41d72a4bf3c5901ccfb726f62868048ddbc1934ab18ad"},
ExpFNV: float64(uint32(0x03a95ccb)),
ExpSuccess: false,
ExpHashFail: 1,
},
"invalid hash": {
Body: []byte("testVector"),
Hash: "invalid",
Export: true,
ExpSuccess: false,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(test.Body)
}))
defer ts.Close()

module := config.Module{
Timeout: time.Second,
HTTP: config.HTTPProbe{
IPProtocolFallback: true,
FailIfBodyNotMatchesHash: test.Match,
HashAlgorithm: test.Hash,
ExportHash: test.Export,
},
}
registry := prometheus.NewRegistry()
testCTX, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

if res := ProbeHTTP(testCTX, ts.URL, module, registry, promslog.NewNopLogger()); res != test.ExpSuccess {
t.Errorf("Expected result %t, got %t", test.ExpSuccess, res)
}

mfs, err := registry.Gather()
if err != nil {
t.Fatal(err)
}

if test.Hash == "invalid" {
return
}

if test.Export {
expectedLabels := map[string]map[string]string{
"probe_http_content_hash": {
test.Hash: test.ExpHash,
},
}
checkRegistryLabels(expectedLabels, mfs, t)
}

expectedResults := map[string]float64{
"probe_http_content_length": float64(len(test.Body)),
"probe_http_content_checksum": test.ExpFNV,
"probe_failed_due_to_hash": float64(test.ExpHashFail),
}
checkRegistryResults(expectedResults, mfs, t)
})
}
}