diff --git a/README.md b/README.md index 7be1e170..62a54464 100644 --- a/README.md +++ b/README.md @@ -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.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 Unlike regular endpoints, external endpoints are not monitored by Gatus, but they are instead pushed programmatically. @@ -2125,8 +2132,9 @@ endpoints: conditions: - "[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. This works for applications such as databases (Postgres, MySQL, etc.) and caches (Redis, Memcached, etc.). @@ -2146,7 +2154,9 @@ endpoints: - "[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. This works for UDP based application. @@ -2181,7 +2191,8 @@ endpoints: ``` 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 @@ -2293,6 +2304,11 @@ endpoints: - "[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 You can monitor the expiration of a domain with all endpoint types except for DNS by using the `[DOMAIN_EXPIRATION]` diff --git a/client/client.go b/client/client.go index adc4477e..5ac30238 100644 --- a/client/client.go +++ b/client/client.go @@ -76,24 +76,37 @@ func GetDomainExpiration(hostname string) (domainExpiration time.Duration, err e return domainExpiration, nil } -// CanCreateTCPConnection checks whether a connection can be established with a TCP endpoint -func CanCreateTCPConnection(address string, config *Config) bool { - conn, err := net.DialTimeout("tcp", address, config.Timeout) - if err != nil { - return false - } - _ = conn.Close() - return true +// parseLocalAddressPlaceholder returns a string with the local address replaced +func parseLocalAddressPlaceholder(item string, localAddr net.Addr) string { + item = strings.ReplaceAll(item, "[LOCAL_ADDRESS]", localAddr.String()) + return item } -// CanCreateUDPConnection checks whether a connection can be established with a UDP endpoint -func CanCreateUDPConnection(address string, config *Config) bool { - conn, err := net.DialTimeout("udp", address, config.Timeout) +// CanCreateNetworkConnection checks whether a connection can be established with a TCP or UDP endpoint +func CanCreateNetworkConnection(netType string, address string, body string, config *Config) (bool, []byte) { + const ( + MaximumMessageSize = 1024 // in bytes + ) + connection, err := net.DialTimeout(netType, address, config.Timeout) if err != nil { - return false + return false, nil } - _ = conn.Close() - return true + defer connection.Close() + 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 @@ -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 -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{ InsecureSkipVerify: config.Insecure, }) @@ -166,9 +182,27 @@ func CanPerformTLS(address string, config *Config) (connected bool, certificate // Reference: https://pkg.go.dev/crypto/tls#PeerCertificates if len(verifiedChains) == 0 || len(verifiedChains[0]) == 0 { 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 @@ -234,6 +268,7 @@ func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool } defer sshClient.Close() var b Body + body = parseLocalAddressPlaceholder(body, sshClient.Conn.LocalAddr()) if err := json.Unmarshal([]byte(body), &b); err != nil { 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) } defer ws.Close() + body = parseLocalAddressPlaceholder(body, ws.LocalAddr()) // Write message if _, err := ws.Write([]byte(body)); err != nil { return false, nil, fmt.Errorf("error writing websocket body: %w", err) diff --git a/client/client_test.go b/client/client_test.go index 46b08229..f91fd461 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -223,7 +223,7 @@ func TestCanPerformTLS(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { 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 { t.Errorf("CanPerformTLS() err=%v, wantErr=%v", err, tt.wantErr) return @@ -235,11 +235,13 @@ func TestCanPerformTLS(t *testing.T) { } } -func TestCanCreateTCPConnection(t *testing.T) { - if CanCreateTCPConnection("127.0.0.1", &Config{Timeout: 5 * time.Second}) { +func TestCanCreateConnection(t *testing.T) { + 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") } - 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") } } diff --git a/config/connectivity/connectivity.go b/config/connectivity/connectivity.go index f366415d..994e98c5 100644 --- a/config/connectivity/connectivity.go +++ b/config/connectivity/connectivity.go @@ -42,7 +42,8 @@ type Checker struct { } 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 { diff --git a/config/endpoint/endpoint.go b/config/endpoint/endpoint.go index 39e26e9e..ab50f683 100644 --- a/config/endpoint/endpoint.go +++ b/config/endpoint/endpoint.go @@ -7,9 +7,12 @@ import ( "errors" "fmt" "io" + "math/rand" "net" "net/http" "net/url" + "regexp" + "strconv" "strings" "time" @@ -229,7 +232,7 @@ func (e *Endpoint) ValidateAndSetDefaults() error { } } // 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 { return err } @@ -326,6 +329,26 @@ func (e *Endpoint) EvaluateHealth() *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) { if ips, err := net.LookupIP(result.Hostname); err != nil { result.AddError(err.Error()) @@ -356,7 +379,7 @@ func (e *Endpoint) call(result *Result) { if endpointType == TypeSTARTTLS { result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(e.URL, "starttls://"), e.ClientConfig) } 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 { result.AddError(err.Error()) @@ -365,10 +388,10 @@ func (e *Endpoint) call(result *Result) { result.Duration = time.Since(startTime) result.CertificateExpiration = time.Until(certificate.NotAfter) } 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) } 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) } else if endpointType == TypeSCTP { 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 { result.Connected, result.Duration = client.Ping(strings.TrimPrefix(e.URL, "icmp://"), e.ClientConfig) } 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 { result.AddError(err.Error()) return @@ -401,7 +424,7 @@ func (e *Endpoint) call(result *Result) { result.AddError(err.Error()) 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 { result.AddError(err.Error()) return @@ -435,12 +458,12 @@ func (e *Endpoint) buildHTTPRequest() *http.Request { var bodyBuffer *bytes.Buffer if e.GraphQL { graphQlBody := map[string]string{ - "query": e.Body, + "query": e.getParsedBody(), } body, _ := json.Marshal(graphQlBody) bodyBuffer = bytes.NewBuffer(body) } else { - bodyBuffer = bytes.NewBuffer([]byte(e.Body)) + bodyBuffer = bytes.NewBuffer([]byte(e.getParsedBody())) } request, _ := http.NewRequest(e.Method, e.URL, bodyBuffer) for k, v := range e.Headers {