feat: Add [DOMAIN_EXPIRATION] placeholder for monitoring domain expiration using WHOIS (#325)

* feat: Add [DOMAIN_EXPIRATION] placeholder for monitoring domain expiration using WHOIS

* test: Fix issue caused by possibility of millisecond elapsed during previous tests

* test: Fix test with different behavior based on architecture

* docs: Revert accidental change to starttls example

* docs: Fix mistake in comment for Condition.hasIPPlaceholder()
This commit is contained in:
TwiN
2022-09-06 21:22:02 -04:00
committed by GitHub
parent 4857b43771
commit 01484832fc
16 changed files with 559 additions and 78 deletions

View File

@ -46,6 +46,9 @@ const (
// Values that could replace the placeholder: 4461677039 (~52 days)
CertificateExpirationPlaceholder = "[CERTIFICATE_EXPIRATION]"
// DomainExpirationPlaceholder is a placeholder for the duration before the domain expires, in milliseconds.
DomainExpirationPlaceholder = "[DOMAIN_EXPIRATION]"
// LengthFunctionPrefix is the prefix for the length function
//
// Usage: len([BODY].articles) == 10, len([BODY].name) > 5
@ -142,9 +145,21 @@ func (c Condition) hasBodyPlaceholder() bool {
return strings.Contains(string(c), BodyPlaceholder)
}
// hasDomainExpirationPlaceholder checks whether the condition has a DomainExpirationPlaceholder
// Used for determining whether a whois operation is necessary
func (c Condition) hasDomainExpirationPlaceholder() bool {
return strings.Contains(string(c), DomainExpirationPlaceholder)
}
// hasIPPlaceholder checks whether the condition has an IPPlaceholder
// Used for determining whether an IP lookup is necessary
func (c Condition) hasIPPlaceholder() bool {
return strings.Contains(string(c), IPPlaceholder)
}
// isEqual compares two strings.
//
// Supports the pattern and the any functions.
// Supports the "pat" and the "any" functions.
// i.e. if one of the parameters starts with PatternFunctionPrefix and ends with FunctionSuffix, it will be treated like
// a pattern.
func isEqual(first, second string) bool {
@ -219,6 +234,8 @@ func sanitizeAndResolve(elements []string, result *Result) ([]string, []string)
element = strconv.FormatBool(result.Connected)
case CertificateExpirationPlaceholder:
element = strconv.FormatInt(result.CertificateExpiration.Milliseconds(), 10)
case DomainExpirationPlaceholder:
element = strconv.FormatInt(result.DomainExpiration.Milliseconds(), 10)
default:
// if contains the BodyPlaceholder, then evaluate json path
if strings.Contains(element, BodyPlaceholder) {

View File

@ -16,6 +16,7 @@ import (
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core/ui"
"github.com/TwiN/gatus/v4/util"
"github.com/TwiN/whois"
)
type EndpointType string
@ -138,7 +139,7 @@ func (endpoint Endpoint) Type() EndpointType {
}
}
// ValidateAndSetDefaults validates the endpoint's configuration and sets the default value of fields that have one
// ValidateAndSetDefaults validates the endpoint's configuration and sets the default value of args that have one
func (endpoint *Endpoint) ValidateAndSetDefaults() error {
// Set default values
if endpoint.ClientConfig == nil {
@ -220,7 +221,26 @@ func (endpoint Endpoint) Key() string {
// EvaluateHealth sends a request to the endpoint's URL and evaluates the conditions of the endpoint.
func (endpoint *Endpoint) EvaluateHealth() *Result {
result := &Result{Success: true, Errors: []string{}}
endpoint.getIP(result)
// Parse or extract hostname from URL
if endpoint.DNS != nil {
result.Hostname = strings.TrimSuffix(endpoint.URL, ":53")
} else {
urlObject, err := url.Parse(endpoint.URL)
if err != nil {
result.AddError(err.Error())
} else {
result.Hostname = urlObject.Hostname()
}
}
// Retrieve IP if necessary
if endpoint.needsToRetrieveIP() {
endpoint.getIP(result)
}
// Retrieve domain expiration if necessary
if endpoint.needsToRetrieveDomainExpiration() && len(result.Hostname) > 0 {
endpoint.getDomainExpiration(result)
}
//
if len(result.Errors) == 0 {
endpoint.call(result)
} else {
@ -251,16 +271,6 @@ func (endpoint *Endpoint) EvaluateHealth() *Result {
}
func (endpoint *Endpoint) getIP(result *Result) {
if endpoint.DNS != nil {
result.Hostname = strings.TrimSuffix(endpoint.URL, ":53")
} else {
urlObject, err := url.Parse(endpoint.URL)
if err != nil {
result.AddError(err.Error())
return
}
result.Hostname = urlObject.Hostname()
}
ips, err := net.LookupIP(result.Hostname)
if err != nil {
result.AddError(err.Error())
@ -269,6 +279,15 @@ func (endpoint *Endpoint) getIP(result *Result) {
result.IP = ips[0].String()
}
func (endpoint *Endpoint) getDomainExpiration(result *Result) {
whoisClient := whois.NewClient()
if whoisResponse, err := whoisClient.QueryAndParse(result.Hostname); err != nil {
result.AddError("error querying and parsing hostname using whois client: " + err.Error())
} else {
result.DomainExpiration = time.Until(whoisResponse.ExpirationDate)
}
}
func (endpoint *Endpoint) call(result *Result) {
var request *http.Request
var response *http.Response
@ -317,7 +336,7 @@ func (endpoint *Endpoint) call(result *Result) {
if endpoint.needsToReadBody() {
result.body, err = io.ReadAll(response.Body)
if err != nil {
result.AddError(err.Error())
result.AddError("error reading response body:" + err.Error())
}
}
}
@ -344,7 +363,7 @@ func (endpoint *Endpoint) buildHTTPRequest() *http.Request {
return request
}
// needsToReadBody checks if there's any conditions that requires the response body to be read
// needsToReadBody checks if there's any condition that requires the response body to be read
func (endpoint *Endpoint) needsToReadBody() bool {
for _, condition := range endpoint.Conditions {
if condition.hasBodyPlaceholder() {
@ -353,3 +372,23 @@ func (endpoint *Endpoint) needsToReadBody() bool {
}
return false
}
// needsToRetrieveDomainExpiration checks if there's any condition that requires a whois query to be performed
func (endpoint *Endpoint) needsToRetrieveDomainExpiration() bool {
for _, condition := range endpoint.Conditions {
if condition.hasDomainExpirationPlaceholder() {
return true
}
}
return false
}
// needsToRetrieveIP checks if there's any condition that requires an IP lookup
func (endpoint *Endpoint) needsToRetrieveIP() bool {
for _, condition := range endpoint.Conditions {
if condition.hasIPPlaceholder() {
return true
}
}
return false
}

View File

@ -1,7 +1,11 @@
package core
import (
"bytes"
"crypto/tls"
"crypto/x509"
"io"
"net/http"
"strings"
"testing"
"time"
@ -9,8 +13,233 @@ import (
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core/ui"
"github.com/TwiN/gatus/v4/test"
)
func TestEndpoint(t *testing.T) {
defer client.InjectHTTPClient(nil)
scenarios := []struct {
Name string
Endpoint Endpoint
ExpectedResult *Result
MockRoundTripper test.MockRoundTripper
}{
{
Name: "success",
Endpoint: Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{"[STATUS] == 200", "[BODY].status == UP", "[CERTIFICATE_EXPIRATION] > 24h"},
},
ExpectedResult: &Result{
Success: true,
Connected: true,
Hostname: "twin.sh",
ConditionResults: []*ConditionResult{
{Condition: "[STATUS] == 200", Success: true},
{Condition: "[BODY].status == UP", Success: true},
{Condition: "[CERTIFICATE_EXPIRATION] > 24h", Success: true},
},
DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
},
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(`{"status": "UP"}`)),
TLS: &tls.ConnectionState{PeerCertificates: []*x509.Certificate{{NotAfter: time.Now().Add(9999 * time.Hour)}}},
}
}),
},
{
Name: "failed-body-condition",
Endpoint: Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{"[STATUS] == 200", "[BODY].status == UP"},
},
ExpectedResult: &Result{
Success: false,
Connected: true,
Hostname: "twin.sh",
ConditionResults: []*ConditionResult{
{Condition: "[STATUS] == 200", Success: true},
{Condition: "[BODY].status (DOWN) == UP", Success: false},
},
DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
},
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`{"status": "DOWN"}`))}
}),
},
{
Name: "failed-status-condition",
Endpoint: Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{"[STATUS] == 200"},
},
ExpectedResult: &Result{
Success: false,
Connected: true,
Hostname: "twin.sh",
ConditionResults: []*ConditionResult{
{Condition: "[STATUS] (502) == 200", Success: false},
},
DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
},
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusBadGateway, Body: http.NoBody}
}),
},
{
Name: "condition-with-failed-certificate-expiration",
Endpoint: Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{"[CERTIFICATE_EXPIRATION] > 100h"},
UIConfig: &ui.Config{DontResolveFailedConditions: true},
},
ExpectedResult: &Result{
Success: false,
Connected: true,
Hostname: "twin.sh",
ConditionResults: []*ConditionResult{
// Because UIConfig.DontResolveFailedConditions is true, the values in the condition should not be resolved
{Condition: "[CERTIFICATE_EXPIRATION] > 100h", Success: false},
},
DomainExpiration: 0, // Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
},
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Body: http.NoBody,
TLS: &tls.ConnectionState{PeerCertificates: []*x509.Certificate{{NotAfter: time.Now().Add(5 * time.Hour)}}},
}
}),
},
{
Name: "domain-expiration",
Endpoint: Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{"[DOMAIN_EXPIRATION] > 100h"},
},
ExpectedResult: &Result{
Success: true,
Connected: true,
Hostname: "twin.sh",
ConditionResults: []*ConditionResult{
{Condition: "[DOMAIN_EXPIRATION] > 100h", Success: true},
},
DomainExpiration: 999999 * time.Hour, // Note that this test only checks if it's non-zero.
},
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
},
{
Name: "endpoint-that-will-time-out-and-hidden-hostname",
Endpoint: Endpoint{
Name: "endpoint-that-will-time-out",
URL: "https://twin.sh/health",
Conditions: []Condition{"[CONNECTED] == true"},
UIConfig: &ui.Config{HideHostname: true},
ClientConfig: &client.Config{Timeout: time.Millisecond},
},
ExpectedResult: &Result{
Success: false,
Connected: false,
Hostname: "", // Because Endpoint.UIConfig.HideHostname is true, this should be empty.
ConditionResults: []*ConditionResult{
{Condition: "[CONNECTED] (false) == true", Success: false},
},
// Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
DomainExpiration: 0,
// Because Endpoint.UIConfig.HideHostname is true, the hostname should be replaced by <redacted>.
Errors: []string{`Get "https://<redacted>/health": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`},
},
MockRoundTripper: nil,
},
{
Name: "endpoint-that-will-time-out-and-hidden-url",
Endpoint: Endpoint{
Name: "endpoint-that-will-time-out",
URL: "https://twin.sh/health",
Conditions: []Condition{"[CONNECTED] == true"},
UIConfig: &ui.Config{HideURL: true},
ClientConfig: &client.Config{Timeout: time.Millisecond},
},
ExpectedResult: &Result{
Success: false,
Connected: false,
Hostname: "twin.sh",
ConditionResults: []*ConditionResult{
{Condition: "[CONNECTED] (false) == true", Success: false},
},
// Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
DomainExpiration: 0,
// Because Endpoint.UIConfig.HideURL is true, the URL should be replaced by <redacted>.
Errors: []string{`Get "<redacted>": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`},
},
MockRoundTripper: nil,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
if scenario.MockRoundTripper != nil {
mockClient := &http.Client{Transport: scenario.MockRoundTripper}
if scenario.Endpoint.ClientConfig != nil && scenario.Endpoint.ClientConfig.Timeout > 0 {
mockClient.Timeout = scenario.Endpoint.ClientConfig.Timeout
}
client.InjectHTTPClient(mockClient)
} else {
client.InjectHTTPClient(nil)
}
scenario.Endpoint.ValidateAndSetDefaults()
result := scenario.Endpoint.EvaluateHealth()
if result.Success != scenario.ExpectedResult.Success {
t.Errorf("Expected success to be %v, got %v", scenario.ExpectedResult.Success, result.Success)
}
if result.Connected != scenario.ExpectedResult.Connected {
t.Errorf("Expected connected to be %v, got %v", scenario.ExpectedResult.Connected, result.Connected)
}
if result.Hostname != scenario.ExpectedResult.Hostname {
t.Errorf("Expected hostname to be %v, got %v", scenario.ExpectedResult.Hostname, result.Hostname)
}
if len(result.ConditionResults) != len(scenario.ExpectedResult.ConditionResults) {
t.Errorf("Expected %v condition results, got %v", len(scenario.ExpectedResult.ConditionResults), len(result.ConditionResults))
} else {
for i, conditionResult := range result.ConditionResults {
if conditionResult.Condition != scenario.ExpectedResult.ConditionResults[i].Condition {
t.Errorf("Expected condition to be %v, got %v", scenario.ExpectedResult.ConditionResults[i].Condition, conditionResult.Condition)
}
if conditionResult.Success != scenario.ExpectedResult.ConditionResults[i].Success {
t.Errorf("Expected success of condition '%s' to be %v, got %v", conditionResult.Condition, scenario.ExpectedResult.ConditionResults[i].Success, conditionResult.Success)
}
}
}
if len(result.Errors) != len(scenario.ExpectedResult.Errors) {
t.Errorf("Expected %v errors, got %v", len(scenario.ExpectedResult.Errors), len(result.Errors))
} else {
for i, err := range result.Errors {
if err != scenario.ExpectedResult.Errors[i] {
t.Errorf("Expected error to be %v, got %v", scenario.ExpectedResult.Errors[i], err)
}
}
}
if result.DomainExpiration != scenario.ExpectedResult.DomainExpiration {
// Note that DomainExpiration is only resolved if there's a condition with the DomainExpirationPlaceholder in it.
// In other words, if there's no condition with [DOMAIN_EXPIRATION] in it, the DomainExpiration field will be 0.
// Because this is a live call, mocking it would be too much of a pain, so we're just going to check if
// the actual value is non-zero when the expected result is non-zero.
if scenario.ExpectedResult.DomainExpiration.Hours() > 0 && !(result.DomainExpiration.Hours() > 0) {
t.Errorf("Expected domain expiration to be non-zero, got %v", result.DomainExpiration)
}
}
})
}
}
func TestEndpoint_IsEnabled(t *testing.T) {
if !(Endpoint{Enabled: nil}).IsEnabled() {
t.Error("endpoint.IsEnabled() should've returned true, because Enabled was set to nil")
@ -349,26 +578,6 @@ func TestIntegrationEvaluateHealth(t *testing.T) {
}
}
func TestIntegrationEvaluateHealthWithFailure(t *testing.T) {
condition := Condition("[STATUS] == 500")
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{condition},
}
endpoint.ValidateAndSetDefaults()
result := endpoint.EvaluateHealth()
if result.ConditionResults[0].Success {
t.Errorf("Condition '%s' should have been a failure", condition)
}
if !result.Connected {
t.Error("Because the connection has been established, result.Connected should've been true")
}
if result.Success {
t.Error("Because one of the conditions failed, result.Success should have been false")
}
}
func TestIntegrationEvaluateHealthWithInvalidCondition(t *testing.T) {
condition := Condition("[STATUS] invalid 200")
endpoint := Endpoint{
@ -389,32 +598,6 @@ func TestIntegrationEvaluateHealthWithInvalidCondition(t *testing.T) {
}
}
func TestIntegrationEvaluateHealthWithError(t *testing.T) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{
Name: "invalid-host",
URL: "http://invalid/health",
Conditions: []Condition{condition},
UIConfig: &ui.Config{
HideHostname: true,
},
}
endpoint.ValidateAndSetDefaults()
result := endpoint.EvaluateHealth()
if result.Success {
t.Error("Because one of the conditions was invalid, result.Success should have been false")
}
if len(result.Errors) == 0 {
t.Error("There should've been an error")
}
if !strings.Contains(result.Errors[0], "<redacted>") {
t.Error("result.Errors[0] should've had the hostname redacted because ui.hide-hostname is set to true")
}
if result.Hostname != "" {
t.Error("result.Hostname should've been empty because ui.hide-hostname is set to true")
}
}
func TestIntegrationEvaluateHealthWithErrorAndHideURL(t *testing.T) {
endpoint := Endpoint{
Name: "invalid-url",
@ -455,7 +638,7 @@ func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
endpoint.ValidateAndSetDefaults()
result := endpoint.EvaluateHealth()
if !result.ConditionResults[0].Success {
t.Errorf("Conditions '%s' and %s should have been a success", conditionSuccess, conditionBody)
t.Errorf("Conditions '%s' and '%s' should have been a success", conditionSuccess, conditionBody)
}
if !result.Connected {
t.Error("Because the connection has been established, result.Connected should've been true")
@ -466,16 +649,15 @@ func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
}
func TestIntegrationEvaluateHealthForICMP(t *testing.T) {
conditionSuccess := Condition("[CONNECTED] == true")
endpoint := Endpoint{
Name: "icmp-test",
URL: "icmp://127.0.0.1",
Conditions: []Condition{conditionSuccess},
Conditions: []Condition{"[CONNECTED] == true"},
}
endpoint.ValidateAndSetDefaults()
result := endpoint.EvaluateHealth()
if !result.ConditionResults[0].Success {
t.Errorf("Conditions '%s' should have been a success", conditionSuccess)
t.Errorf("Conditions '%s' should have been a success", endpoint.Conditions[0])
}
if !result.Connected {
t.Error("Because the connection has been established, result.Connected should've been true")
@ -486,11 +668,10 @@ func TestIntegrationEvaluateHealthForICMP(t *testing.T) {
}
func TestEndpoint_getIP(t *testing.T) {
conditionSuccess := Condition("[CONNECTED] == true")
endpoint := Endpoint{
Name: "invalid-url-test",
URL: "",
Conditions: []Condition{conditionSuccess},
Conditions: []Condition{"[CONNECTED] == true"},
}
result := &Result{}
endpoint.getIP(result)
@ -499,7 +680,7 @@ func TestEndpoint_getIP(t *testing.T) {
}
}
func TestEndpoint_NeedsToReadBody(t *testing.T) {
func TestEndpoint_needsToReadBody(t *testing.T) {
statusCondition := Condition("[STATUS] == 200")
bodyCondition := Condition("[BODY].status == UP")
bodyConditionWithLength := Condition("len([BODY].tags) > 0")
@ -522,3 +703,21 @@ func TestEndpoint_NeedsToReadBody(t *testing.T) {
t.Error("expected true, got false")
}
}
func TestEndpoint_needsToRetrieveDomainExpiration(t *testing.T) {
if (&Endpoint{Conditions: []Condition{"[STATUS] == 200"}}).needsToRetrieveDomainExpiration() {
t.Error("expected false, got true")
}
if !(&Endpoint{Conditions: []Condition{"[STATUS] == 200", "[DOMAIN_EXPIRATION] < 720h"}}).needsToRetrieveDomainExpiration() {
t.Error("expected true, got false")
}
}
func TestEndpoint_needsToRetrieveIP(t *testing.T) {
if (&Endpoint{Conditions: []Condition{"[STATUS] == 200"}}).needsToRetrieveIP() {
t.Error("expected false, got true")
}
if !(&Endpoint{Conditions: []Condition{"[STATUS] == 200", "[IP] == 127.0.0.1"}}).needsToRetrieveIP() {
t.Error("expected true, got false")
}
}

View File

@ -41,6 +41,9 @@ type Result struct {
// CertificateExpiration is the duration before the certificate expires
CertificateExpiration time.Duration `json:"-"`
// DomainExpiration is the duration before the domain expires
DomainExpiration time.Duration `json:"-"`
// body is the response body
//
// Note that this variable is only used during the evaluation of an Endpoint's health.