feat: Add body to TCP, UDP, and TLS endpoints and templating (#1134)

* feat(endpoints): Add body to TCP, UDP, and TLS endpoints and templating

* Changed the template to be more consistent with the
rest of the application and added additional substritutions.

* Changed getModifiedBody to getParsedBody and fixed connected response

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Update client/client.go

---------

Co-authored-by: TwiN <twin@linux.com>
This commit is contained in:
jasonshugart
2025-07-19 19:24:03 -06:00
committed by GitHub
parent f4a667549e
commit bdaffbca77
5 changed files with 111 additions and 33 deletions

View File

@@ -288,6 +288,13 @@ You can then configure alerts to be triggered when an endpoint is unhealthy once
| `endpoints[].ui.dont-resolve-failed-conditions` | Whether to resolve failed conditions for the UI. | `false` | | `endpoints[].ui.dont-resolve-failed-conditions` | Whether to resolve failed conditions for the UI. | `false` |
| `endpoints[].ui.badge.response-time` | List of response time thresholds. Each time a threshold is reached, the badge has a different color. | `[50, 200, 300, 500, 750]` | | `endpoints[].ui.badge.response-time` | List of response time thresholds. Each time a threshold is reached, the badge has a different color. | `[50, 200, 300, 500, 750]` |
You may use the following placeholders in the body (`endpoints[].body`):
- `[ENDPOINT_NAME]` (resolved from `endpoints[].name`)
- `[ENDPOINT_GROUP]` (resolved from `endpoints[].group`)
- `[ENDPOINT_URL]` (resolved from `endpoints[].url`)
- `[LOCAL_ADDRESS]` (resolves to the local IP and port like `192.0.2.1:25` or `[2001:db8::1]:80`)
- `[RANDOM_STRING_N]` (resolves to a random string of numbers and letters of length N)
### External Endpoints ### External Endpoints
Unlike regular endpoints, external endpoints are not monitored by Gatus, but they are instead pushed programmatically. Unlike regular endpoints, external endpoints are not monitored by Gatus, but they are instead pushed programmatically.
@@ -2125,8 +2132,9 @@ endpoints:
conditions: conditions:
- "[CONNECTED] == true" - "[CONNECTED] == true"
``` ```
If `endpoints[].body` is set then it is sent and the first 1024 bytes of the response will be in `[BODY]`.
Placeholders `[STATUS]` and `[BODY]` as well as the fields `endpoints[].body`, `endpoints[].headers`, Placeholder `[STATUS]` as well as the fields `endpoints[].headers`,
`endpoints[].method` and `endpoints[].graphql` are not supported for TCP endpoints. `endpoints[].method` and `endpoints[].graphql` are not supported for TCP endpoints.
This works for applications such as databases (Postgres, MySQL, etc.) and caches (Redis, Memcached, etc.). This works for applications such as databases (Postgres, MySQL, etc.) and caches (Redis, Memcached, etc.).
@@ -2146,7 +2154,9 @@ endpoints:
- "[CONNECTED] == true" - "[CONNECTED] == true"
``` ```
Placeholders `[STATUS]` and `[BODY]` as well as the fields `endpoints[].body`, `endpoints[].headers`, If `endpoints[].body` is set then it is sent and the first 1024 bytes of the response will be in `[BODY]`.
Placeholder `[STATUS]` as well as the fields `endpoints[].headers`,
`endpoints[].method` and `endpoints[].graphql` are not supported for UDP endpoints. `endpoints[].method` and `endpoints[].graphql` are not supported for UDP endpoints.
This works for UDP based application. This works for UDP based application.
@@ -2181,7 +2191,8 @@ endpoints:
``` ```
The `[BODY]` placeholder contains the output of the query, and `[CONNECTED]` The `[BODY]` placeholder contains the output of the query, and `[CONNECTED]`
shows whether the connection was successfully established. shows whether the connection was successfully established. You can use Go template
syntax. The functions LocalAddr and RandomString with a length can be used.
### Monitoring an endpoint using ICMP ### Monitoring an endpoint using ICMP
@@ -2293,6 +2304,11 @@ endpoints:
- "[CERTIFICATE_EXPIRATION] > 48h" - "[CERTIFICATE_EXPIRATION] > 48h"
``` ```
If `endpoints[].body` is set then it is sent and the first 1024 bytes of the response will be in `[BODY]`.
Placeholder `[STATUS]` as well as the fields `endpoints[].headers`,
`endpoints[].method` and `endpoints[].graphql` are not supported for TLS endpoints.
### Monitoring domain expiration ### Monitoring domain expiration
You can monitor the expiration of a domain with all endpoint types except for DNS by using the `[DOMAIN_EXPIRATION]` You can monitor the expiration of a domain with all endpoint types except for DNS by using the `[DOMAIN_EXPIRATION]`

View File

@@ -76,24 +76,37 @@ func GetDomainExpiration(hostname string) (domainExpiration time.Duration, err e
return domainExpiration, nil return domainExpiration, nil
} }
// CanCreateTCPConnection checks whether a connection can be established with a TCP endpoint // parseLocalAddressPlaceholder returns a string with the local address replaced
func CanCreateTCPConnection(address string, config *Config) bool { func parseLocalAddressPlaceholder(item string, localAddr net.Addr) string {
conn, err := net.DialTimeout("tcp", address, config.Timeout) item = strings.ReplaceAll(item, "[LOCAL_ADDRESS]", localAddr.String())
if err != nil { return item
return false
}
_ = conn.Close()
return true
} }
// CanCreateUDPConnection checks whether a connection can be established with a UDP endpoint // CanCreateNetworkConnection checks whether a connection can be established with a TCP or UDP endpoint
func CanCreateUDPConnection(address string, config *Config) bool { func CanCreateNetworkConnection(netType string, address string, body string, config *Config) (bool, []byte) {
conn, err := net.DialTimeout("udp", address, config.Timeout) const (
MaximumMessageSize = 1024 // in bytes
)
connection, err := net.DialTimeout(netType, address, config.Timeout)
if err != nil { if err != nil {
return false return false, nil
} }
_ = conn.Close() defer connection.Close()
return true if body != "" {
body = parseLocalAddressPlaceholder(body, connection.LocalAddr())
connection.SetDeadline(time.Now().Add(config.Timeout))
_, err = connection.Write([]byte(body))
if err != nil {
return false, nil
}
buf := make([]byte, MaximumMessageSize)
n, err := connection.Read(buf)
if err != nil {
return false, nil
}
return true, buf[:n]
}
return true, nil
} }
// CanCreateSCTPConnection checks whether a connection can be established with a SCTP endpoint // CanCreateSCTPConnection checks whether a connection can be established with a SCTP endpoint
@@ -152,7 +165,10 @@ func CanPerformStartTLS(address string, config *Config) (connected bool, certifi
} }
// CanPerformTLS checks whether a connection can be established to an address using the TLS protocol // CanPerformTLS checks whether a connection can be established to an address using the TLS protocol
func CanPerformTLS(address string, config *Config) (connected bool, certificate *x509.Certificate, err error) { func CanPerformTLS(address string, body string, config *Config) (connected bool, response []byte, certificate *x509.Certificate, err error) {
const (
MaximumMessageSize = 1024 // in bytes
)
connection, err := tls.DialWithDialer(&net.Dialer{Timeout: config.Timeout}, "tcp", address, &tls.Config{ connection, err := tls.DialWithDialer(&net.Dialer{Timeout: config.Timeout}, "tcp", address, &tls.Config{
InsecureSkipVerify: config.Insecure, InsecureSkipVerify: config.Insecure,
}) })
@@ -166,9 +182,27 @@ func CanPerformTLS(address string, config *Config) (connected bool, certificate
// Reference: https://pkg.go.dev/crypto/tls#PeerCertificates // Reference: https://pkg.go.dev/crypto/tls#PeerCertificates
if len(verifiedChains) == 0 || len(verifiedChains[0]) == 0 { if len(verifiedChains) == 0 || len(verifiedChains[0]) == 0 {
peerCertificates := connection.ConnectionState().PeerCertificates peerCertificates := connection.ConnectionState().PeerCertificates
return true, peerCertificates[0], nil certificate = peerCertificates[0]
} else {
certificate = verifiedChains[0][0]
} }
return true, verifiedChains[0][0], nil connected = true
if body != "" {
body = parseLocalAddressPlaceholder(body, connection.LocalAddr())
connection.SetDeadline(time.Now().Add(config.Timeout))
_, err = connection.Write([]byte(body))
if err != nil {
return
}
buf := make([]byte, MaximumMessageSize)
var n int
n, err = connection.Read(buf)
if err != nil {
return
}
response = buf[:n]
}
return
} }
// CanCreateSSHConnection checks whether a connection can be established and a command can be executed to an address // CanCreateSSHConnection checks whether a connection can be established and a command can be executed to an address
@@ -234,6 +268,7 @@ func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool
} }
defer sshClient.Close() defer sshClient.Close()
var b Body var b Body
body = parseLocalAddressPlaceholder(body, sshClient.Conn.LocalAddr())
if err := json.Unmarshal([]byte(body), &b); err != nil { if err := json.Unmarshal([]byte(body), &b); err != nil {
return false, 0, err return false, 0, err
} }
@@ -304,6 +339,7 @@ func QueryWebSocket(address, body string, config *Config) (bool, []byte, error)
return false, nil, fmt.Errorf("error dialing websocket: %w", err) return false, nil, fmt.Errorf("error dialing websocket: %w", err)
} }
defer ws.Close() defer ws.Close()
body = parseLocalAddressPlaceholder(body, ws.LocalAddr())
// Write message // Write message
if _, err := ws.Write([]byte(body)); err != nil { if _, err := ws.Write([]byte(body)); err != nil {
return false, nil, fmt.Errorf("error writing websocket body: %w", err) return false, nil, fmt.Errorf("error writing websocket body: %w", err)

View File

@@ -223,7 +223,7 @@ func TestCanPerformTLS(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
connected, _, err := CanPerformTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second}) connected, _, _, err := CanPerformTLS(tt.args.address, "", &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {
t.Errorf("CanPerformTLS() err=%v, wantErr=%v", err, tt.wantErr) t.Errorf("CanPerformTLS() err=%v, wantErr=%v", err, tt.wantErr)
return return
@@ -235,11 +235,13 @@ func TestCanPerformTLS(t *testing.T) {
} }
} }
func TestCanCreateTCPConnection(t *testing.T) { func TestCanCreateConnection(t *testing.T) {
if CanCreateTCPConnection("127.0.0.1", &Config{Timeout: 5 * time.Second}) { connected, _ := CanCreateNetworkConnection("tcp", "127.0.0.1", "", &Config{Timeout: 5 * time.Second})
if connected {
t.Error("should've failed, because there's no port in the address") t.Error("should've failed, because there's no port in the address")
} }
if !CanCreateTCPConnection("1.1.1.1:53", &Config{Timeout: 5 * time.Second}) { connected, _ = CanCreateNetworkConnection("tcp", "1.1.1.1:53", "", &Config{Timeout: 5 * time.Second})
if !connected {
t.Error("should've succeeded, because that IP should always™ be up") t.Error("should've succeeded, because that IP should always™ be up")
} }
} }

View File

@@ -42,7 +42,8 @@ type Checker struct {
} }
func (c *Checker) Check() bool { func (c *Checker) Check() bool {
return client.CanCreateTCPConnection(c.Target, &client.Config{Timeout: 5 * time.Second}) connected, _ := client.CanCreateNetworkConnection("tcp", c.Target, "", &client.Config{Timeout: 5 * time.Second})
return connected
} }
func (c *Checker) IsConnected() bool { func (c *Checker) IsConnected() bool {

View File

@@ -7,9 +7,12 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"math/rand"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"regexp"
"strconv"
"strings" "strings"
"time" "time"
@@ -229,7 +232,7 @@ func (e *Endpoint) ValidateAndSetDefaults() error {
} }
} }
// Make sure that the request can be created // Make sure that the request can be created
_, err := http.NewRequest(e.Method, e.URL, bytes.NewBuffer([]byte(e.Body))) _, err := http.NewRequest(e.Method, e.URL, bytes.NewBuffer([]byte(e.getParsedBody())))
if err != nil { if err != nil {
return err return err
} }
@@ -326,6 +329,26 @@ func (e *Endpoint) EvaluateHealth() *Result {
return result return result
} }
func (e *Endpoint) getParsedBody() string {
body := e.Body
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", e.Name)
body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", e.Group)
body = strings.ReplaceAll(body, "[ENDPOINT_URL]", e.URL)
randRegex, err := regexp.Compile(`\[RANDOM_STRING_\d+\]`)
if err == nil {
body = randRegex.ReplaceAllStringFunc(body, func(match string) string {
n, _ := strconv.Atoi(match[15 : len(match)-1])
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n)
for i := range b {
b[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))]
}
return string(b)
})
}
return body
}
func (e *Endpoint) getIP(result *Result) { func (e *Endpoint) getIP(result *Result) {
if ips, err := net.LookupIP(result.Hostname); err != nil { if ips, err := net.LookupIP(result.Hostname); err != nil {
result.AddError(err.Error()) result.AddError(err.Error())
@@ -356,7 +379,7 @@ func (e *Endpoint) call(result *Result) {
if endpointType == TypeSTARTTLS { if endpointType == TypeSTARTTLS {
result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(e.URL, "starttls://"), e.ClientConfig) result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(e.URL, "starttls://"), e.ClientConfig)
} else { } else {
result.Connected, certificate, err = client.CanPerformTLS(strings.TrimPrefix(e.URL, "tls://"), e.ClientConfig) result.Connected, result.Body, certificate, err = client.CanPerformTLS(strings.TrimPrefix(e.URL, "tls://"), e.getParsedBody(), e.ClientConfig)
} }
if err != nil { if err != nil {
result.AddError(err.Error()) result.AddError(err.Error())
@@ -365,10 +388,10 @@ func (e *Endpoint) call(result *Result) {
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
result.CertificateExpiration = time.Until(certificate.NotAfter) result.CertificateExpiration = time.Until(certificate.NotAfter)
} else if endpointType == TypeTCP { } else if endpointType == TypeTCP {
result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(e.URL, "tcp://"), e.ClientConfig) result.Connected, result.Body = client.CanCreateNetworkConnection("tcp", strings.TrimPrefix(e.URL, "tcp://"), e.getParsedBody(), e.ClientConfig)
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
} else if endpointType == TypeUDP { } else if endpointType == TypeUDP {
result.Connected = client.CanCreateUDPConnection(strings.TrimPrefix(e.URL, "udp://"), e.ClientConfig) result.Connected, result.Body = client.CanCreateNetworkConnection("udp", strings.TrimPrefix(e.URL, "udp://"), e.getParsedBody(), e.ClientConfig)
result.Duration = time.Since(startTime) result.Duration = time.Since(startTime)
} else if endpointType == TypeSCTP { } else if endpointType == TypeSCTP {
result.Connected = client.CanCreateSCTPConnection(strings.TrimPrefix(e.URL, "sctp://"), e.ClientConfig) result.Connected = client.CanCreateSCTPConnection(strings.TrimPrefix(e.URL, "sctp://"), e.ClientConfig)
@@ -376,7 +399,7 @@ func (e *Endpoint) call(result *Result) {
} else if endpointType == TypeICMP { } else if endpointType == TypeICMP {
result.Connected, result.Duration = client.Ping(strings.TrimPrefix(e.URL, "icmp://"), e.ClientConfig) result.Connected, result.Duration = client.Ping(strings.TrimPrefix(e.URL, "icmp://"), e.ClientConfig)
} else if endpointType == TypeWS { } else if endpointType == TypeWS {
result.Connected, result.Body, err = client.QueryWebSocket(e.URL, e.Body, e.ClientConfig) result.Connected, result.Body, err = client.QueryWebSocket(e.URL, e.getParsedBody(), e.ClientConfig)
if err != nil { if err != nil {
result.AddError(err.Error()) result.AddError(err.Error())
return return
@@ -401,7 +424,7 @@ func (e *Endpoint) call(result *Result) {
result.AddError(err.Error()) result.AddError(err.Error())
return return
} }
result.Success, result.HTTPStatus, err = client.ExecuteSSHCommand(cli, e.Body, e.ClientConfig) result.Success, result.HTTPStatus, err = client.ExecuteSSHCommand(cli, e.getParsedBody(), e.ClientConfig)
if err != nil { if err != nil {
result.AddError(err.Error()) result.AddError(err.Error())
return return
@@ -435,12 +458,12 @@ func (e *Endpoint) buildHTTPRequest() *http.Request {
var bodyBuffer *bytes.Buffer var bodyBuffer *bytes.Buffer
if e.GraphQL { if e.GraphQL {
graphQlBody := map[string]string{ graphQlBody := map[string]string{
"query": e.Body, "query": e.getParsedBody(),
} }
body, _ := json.Marshal(graphQlBody) body, _ := json.Marshal(graphQlBody)
bodyBuffer = bytes.NewBuffer(body) bodyBuffer = bytes.NewBuffer(body)
} else { } else {
bodyBuffer = bytes.NewBuffer([]byte(e.Body)) bodyBuffer = bytes.NewBuffer([]byte(e.getParsedBody()))
} }
request, _ := http.NewRequest(e.Method, e.URL, bodyBuffer) request, _ := http.NewRequest(e.Method, e.URL, bodyBuffer)
for k, v := range e.Headers { for k, v := range e.Headers {