Repository: TwiN/gatus

Issue:
Monitoring of OData URLs does not seem to work
### Describe the bug

Hi,

I want to monitor an application that uses OData. However no matter what I try, I cannot get it to work with Gatus. I've created the following minmal example using an OData testing service:

```
endpoints:
  - name: OData Test
    enabled: true
    group: ODataTest
    url: "https://services.odata.org/TripPinRESTierService/People/$count?$filter=FirstName%20eq%20%27Scott%27"
    interval: 2m
    conditions:
      - "[STATUS] == 200"
      - "[BODY] == 1"
```

Even when running with GATUS_LOG_LEVEL with DEBUG I cannot find the issue.

### What do you see?

I see that the server returns HTTP Status 400. After some testing it seems that the $ symboils may cause the issue.

### What do you expect to see?

I expect the URL to return plaintext "1" which I can check using body.

### List the steps that must be taken to reproduce this issue

1) Start Gatus with config supplied
2) Observe it not working

### Version

Docker image gatus:latest on arm64

### Additional information

_No response_

Hints from issue comments:
@muzy: Apparently the issue is the use of  `$` in the URL. By encoding this to `%24` it works. Other characters such as the `?` and `=` must not be encoded. However I think it is worth to figure out why this is causing an issue in config.@TwiN: It's because environment variables are substituted ($EXAMPLE).

I believe you can escape $ by using $$ - I remember discussing about it somewhere in an issue@muzy: I ran some tests and can confirm that `$` can be escaped by using `$$`. @muzy: Created pull request #1242 to clarify documentation. Thank you for the help!

Solution patch:
diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml
index 78af83dc..3e5f90e1 100644
--- a/.github/workflows/benchmark.yml
+++ b/.github/workflows/benchmark.yml
@@ -20,7 +20,7 @@ jobs:
     runs-on: ubuntu-latest
     timeout-minutes: 15
     steps:
-      - uses: actions/setup-go@v5
+      - uses: actions/setup-go@v6
         with:
           go-version: 1.24.1
           repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index fc647954..8b98de05 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -16,7 +16,7 @@ jobs:
     runs-on: ubuntu-latest
     timeout-minutes: 30
     steps:
-      - uses: actions/setup-go@v5
+      - uses: actions/setup-go@v6
         with:
           go-version: 1.24.1
       - uses: actions/checkout@v5
diff --git a/README.md b/README.md
index a87bdfc3..fc5a370d 100644
--- a/README.md
+++ b/README.md
@@ -234,6 +234,8 @@ subdirectories are merged like so:
 > 💡 You can also use environment variables in the configuration file (e.g. `$DOMAIN`, `${DOMAIN}`)
 >
 > See [examples/docker-compose-postgres-storage/config/config.yaml](.examples/docker-compose-postgres-storage/config/config.yaml) for an example.
+>
+> When your configuration parameter contains a `$` symbol, you have to escape `$` with `$$`.
 
 If you want to test it locally, see [Docker](#docker).
 
@@ -321,7 +323,6 @@ You may use the following placeholders in the body (`endpoints[].body`):
 - `[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 (max: 8192))
 
-
 ### External Endpoints
 Unlike regular endpoints, external endpoints are not monitored by Gatus, but they are instead pushed programmatically.
 This allows you to monitor anything you want, even when what you want to check lives in an environment that would not normally be accessible by Gatus.
@@ -356,7 +357,7 @@ external-endpoints:
         send-on-resolved: true
 ```
 
-To push the status of an external endpoint, the request would have to look like this:
+To push the status of an external endpoint, you can use [gatus-cli](https://github.com/TwiN/gatus-cli), or send an HTTP request:
 ```
 POST /api/v1/endpoints/{key}/external?success={success}&error={error}&duration={duration}
 ```
@@ -1369,13 +1370,11 @@ Here's an example of what the notifications look like:
 
 #### Configuring Line alerts
 
-> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
-
 | Parameter                            | Description                                                                                | Default       |
 |:-------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
 | `alerting.line`                      | Configuration for alerts of type `line`                                                    | `{}`          |
 | `alerting.line.channel-access-token` | Line Messaging API channel access token                                                    | Required `""` |
-| `alerting.line.user-ids`             | List of Line user IDs to send messages to                                                  | Required `[]` |
+| `alerting.line.user-ids`             | List of Line user IDs to send messages to (this can be user ids, room ids or group ids)    | Required `[]` |
 | `alerting.line.default-alert`        | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A           |
 | `alerting.line.overrides`            | List of overrides that may be prioritized over the default configuration                   | `[]`          |
 | `alerting.line.overrides[].group`    | Endpoint group for which the configuration will be overridden by this configuration        | `""`          |
@@ -1386,7 +1385,7 @@ alerting:
   line:
     channel-access-token: "YOUR_CHANNEL_ACCESS_TOKEN"
     user-ids:
-      - "U1234567890abcdef"
+      - "U1234567890abcdef" # This can be a group id, room id or user id
       - "U2345678901bcdefg"
 
 endpoints:
@@ -2961,6 +2960,8 @@ endpoints:
 The following placeholders are supported for endpoints of type SSH:
 - `[CONNECTED]` resolves to `true` if the SSH connection was successful, `false` otherwise
 - `[STATUS]` resolves the exit code of the command executed on the remote server (e.g. `0` for success)
+- `[IP]` resolves to the IP address of the server
+- `[RESPONSE_TIME]` resolves to the time it took to establish the connection and execute the command
 
 
 ### Monitoring an endpoint using STARTTLS
@@ -3012,8 +3013,8 @@ endpoints:
       - "[CERTIFICATE_EXPIRATION] > 240h"
 ```
 
-> ⚠ The usage of the `[DOMAIN_EXPIRATION]` placeholder requires Gatus to send a request to the official IANA WHOIS service [through a library](https://github.com/TwiN/whois)
-> and in some cases, a secondary request to a TLD-specific WHOIS server (e.g. `whois.nic.sh`).
+> ⚠ The usage of the `[DOMAIN_EXPIRATION]` placeholder requires Gatus to use RDAP, or as a fallback, send a request to the official IANA WHOIS service 
+> [through a library](https://github.com/TwiN/whois) and in some cases, a secondary request to a TLD-specific WHOIS server (e.g. `whois.nic.sh`).
 > To prevent the WHOIS service from throttling your IP address if you send too many requests, Gatus will prevent you from
 > using the `[DOMAIN_EXPIRATION]` placeholder on an endpoint with an interval of less than `5m`.
 
diff --git a/alerting/provider/signal/signal.go b/alerting/provider/signal/signal.go
index 2215e6ad..bef8e7bf 100644
--- a/alerting/provider/signal/signal.go
+++ b/alerting/provider/signal/signal.go
@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"io"
 	"net/http"
+	"strings"
 
 	"github.com/TwiN/gatus/v5/alerting/alert"
 	"github.com/TwiN/gatus/v5/client"
@@ -31,6 +32,9 @@ func (cfg *Config) Validate() error {
 	if len(cfg.ApiURL) == 0 {
 		return ErrApiURLNotSet
 	}
+	if !strings.HasSuffix(cfg.ApiURL, "/v2/send") {
+		cfg.ApiURL = cfg.ApiURL + "/v2/send"
+	}
 	if len(cfg.Number) == 0 {
 		return ErrNumberNotSet
 	}
@@ -95,7 +99,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
 			return err
 		}
 		buffer := bytes.NewBuffer(body)
-		request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/v2/send", cfg.ApiURL), buffer)
+		request, err := http.NewRequest(http.MethodPost, cfg.ApiURL, buffer)
 		if err != nil {
 			return err
 		}
diff --git a/client/client.go b/client/client.go
index bc8518e4..3172a8d1 100644
--- a/client/client.go
+++ b/client/client.go
@@ -1,9 +1,11 @@
 package client
 
 import (
+	"context"
 	"crypto/tls"
 	"crypto/x509"
 	"encoding/json"
+	"encoding/hex"
 	"errors"
 	"fmt"
 	"io"
@@ -20,6 +22,8 @@ import (
 	"github.com/ishidawataru/sctp"
 	"github.com/miekg/dns"
 	ping "github.com/prometheus-community/pro-bing"
+	"github.com/registrobr/rdap"
+	"github.com/registrobr/rdap/protocol"
 	"golang.org/x/crypto/ssh"
 	"golang.org/x/net/websocket"
 )
@@ -34,6 +38,7 @@ var (
 
 	whoisClient              = whois.NewClient().WithReferralCache(true)
 	whoisExpirationDateCache = gocache.NewCache().WithMaxSize(10000).WithDefaultTTL(24 * time.Hour)
+	rdapClient               = rdap.NewClient(nil)
 )
 
 // GetHTTPClient returns the shared HTTP client, or the client from the configuration passed
@@ -61,7 +66,12 @@ func GetDomainExpiration(hostname string) (domainExpiration time.Duration, err e
 			return domainExpiration, nil
 		}
 	}
-	if whoisResponse, err := whoisClient.QueryAndParse(hostname); err != nil {
+	whoisResponse, err := rdapQuery(hostname)
+	if err != nil {
+		// fallback to WHOIS protocol
+		whoisResponse, err = whoisClient.QueryAndParse(hostname)
+	}
+	if err != nil {
 		if !retrievedCachedValue { // Add an error unless we already retrieved a cached value
 			return 0, fmt.Errorf("error querying and parsing hostname using whois client: %w", err)
 		}
@@ -141,10 +151,39 @@ func CanPerformStartTLS(address string, config *Config) (connected bool, certifi
 	if len(hostAndPort) != 2 {
 		return false, nil, errors.New("invalid address for starttls, format must be host:port")
 	}
-	connection, err := net.DialTimeout("tcp", address, config.Timeout)
-	if err != nil {
-		return
+
+	var connection net.Conn
+	var dnsResolver *DNSResolverConfig
+
+	if config.HasCustomDNSResolver() {
+		dnsResolver, err = config.parseDNSResolver()
+
+		if err != nil {
+			// We're ignoring the error, because it should have been validated on startup ValidateAndSetDefaults.
+			// It shouldn't happen, but if it does, we'll log it... Better safe than sorry ;)
+			logr.Errorf("[client.getHTTPClient] THIS SHOULD NOT HAPPEN. Silently ignoring invalid DNS resolver due to error: %s", err.Error())
+		} else {
+			dialer := &net.Dialer{
+				Resolver: &net.Resolver{
+					PreferGo: true,
+					Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
+						d := net.Dialer{}
+						return d.DialContext(ctx, dnsResolver.Protocol, dnsResolver.Host+":"+dnsResolver.Port)
+					},
+				},
+			}
+			connection, err = dialer.DialContext(context.Background(), "tcp", address)
+			if err != nil {
+				return
+			}
+		}
+	} else {
+		connection, err = net.DialTimeout("tcp", address, config.Timeout)
+		if err != nil {
+			return
+		}
 	}
+
 	smtpClient, err := smtp.NewClient(connection, hostAndPort[0])
 	if err != nil {
 		return
@@ -372,6 +411,17 @@ func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string
 		url = fmt.Sprintf("%s:%d", url, dnsPort)
 	}
 	queryTypeAsUint16 := dns.StringToType[queryType]
+	// Special handling: if this is a PTR query and queryName looks like a plain IP,
+	// convert it to the proper reverse lookup domain automatically.
+	if queryTypeAsUint16 == dns.TypePTR &&
+		!strings.HasSuffix(queryName, ".in-addr.arpa.") &&
+		!strings.HasSuffix(queryName, ".ip6.arpa.") {
+		if rev, convErr := reverseNameForIP(queryName); convErr == nil {
+			queryName = rev
+		} else {
+			return false, "", nil, convErr
+		}
+	}
 	c := new(dns.Client)
 	m := new(dns.Msg)
 	m.SetQuestion(queryName, queryTypeAsUint16)
@@ -423,3 +473,47 @@ func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string
 func InjectHTTPClient(httpClient *http.Client) {
 	injectedHTTPClient = httpClient
 }
+
+// rdapQuery returns domain expiration via RDAP protocol
+func rdapQuery(hostname string) (*whois.Response, error) {
+	data, _, err := rdapClient.Query(hostname, nil, nil)
+	if err != nil {
+		return nil, err
+	}
+	domain, ok := data.(*protocol.Domain)
+	if !ok {
+		return nil, errors.New("invalid domain type")
+	}
+	response := whois.Response{}
+	for _, e := range domain.Events {
+		if e.Action == "expiration" {
+			response.ExpirationDate = e.Date.Time
+			break
+		}
+	}
+	return &response, nil
+}
+
+// helper to reverse IP and add in-addr.arpa. IPv4 and IPv6
+func reverseNameForIP(ipStr string) (string, error) {
+	ip := net.ParseIP(ipStr)
+	if ip == nil {
+		return "", fmt.Errorf("invalid IP: %s", ipStr)
+	}
+
+	if ipv4 := ip.To4(); ipv4 != nil {
+		parts := strings.Split(ipv4.String(), ".")
+		for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 {
+			parts[i], parts[j] = parts[j], parts[i]
+		}
+		return strings.Join(parts, ".") + ".in-addr.arpa.", nil
+	}
+
+	ip = ip.To16()
+	hexStr := hex.EncodeToString(ip)
+	nibbles := strings.Split(hexStr, "")
+	for i, j := 0, len(nibbles)-1; i < j; i, j = i+1, j-1 {
+		nibbles[i], nibbles[j] = nibbles[j], nibbles[i]
+	}
+	return strings.Join(nibbles, ".") + ".ip6.arpa.", nil
+}
\ No newline at end of file
diff --git a/go.mod b/go.mod
index b7df03b0..f14daa4e 100644
--- a/go.mod
+++ b/go.mod
@@ -20,6 +20,7 @@ require (
 	github.com/miekg/dns v1.1.68
 	github.com/prometheus-community/pro-bing v0.6.1
 	github.com/prometheus/client_golang v1.23.0
+	github.com/registrobr/rdap v1.1.8
 	github.com/valyala/fasthttp v1.64.0
 	github.com/wcharczuk/go-chart/v2 v2.1.2
 	golang.org/x/crypto v0.40.0
diff --git a/go.sum b/go.sum
index 199c84f2..80757d7f 100644
--- a/go.sum
+++ b/go.sum
@@ -22,6 +22,8 @@ github.com/TwiN/whois v1.1.11 h1:lYiYgPRSQ3kH8sQfgHcBY/uNSGGvWPRikEjn+LJZ9+Q=
 github.com/TwiN/whois v1.1.11/go.mod h1:TjipCMpJRAJYKmtz/rXQBU6UGxMh6bk8SHazu7OMnQE=
 github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
 github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
+github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b h1:uUXgbcPDK3KpW29o4iy7GtuappbWT0l5NaMo9H9pJDw=
+github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
 github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
 github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -115,6 +117,8 @@ github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2
 github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
 github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
 github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
+github.com/registrobr/rdap v1.1.8 h1:7egYAM8MsuencdP9mvF/892f8OjXvUFSyp5cT1Lg45U=
+github.com/registrobr/rdap v1.1.8/go.mod h1:VY2DVrpsJpUfy9gj2QvurGymCgZV11/11cxQz5CxO+w=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
diff --git a/storage/store/memory/memory.go b/storage/store/memory/memory.go
index e88c8c77..79f4e505 100644
--- a/storage/store/memory/memory.go
+++ b/storage/store/memory/memory.go
@@ -43,6 +43,8 @@ func NewStore(maximumNumberOfResults, maximumNumberOfEvents int) (*Store, error)
 // GetAllEndpointStatuses returns all monitored endpoint.Status
 // with a subset of endpoint.Result defined by the page and pageSize parameters
 func (s *Store) GetAllEndpointStatuses(params *paging.EndpointStatusParams) ([]*endpoint.Status, error) {
+	s.RLock()
+	defer s.RUnlock()
 	allStatuses := s.endpointCache.GetAll()
 	pagedEndpointStatuses := make([]*endpoint.Status, 0, len(allStatuses))
 	for _, v := range allStatuses {
@@ -79,6 +81,8 @@ func (s *Store) GetEndpointStatus(groupName, endpointName string, params *paging
 
 // GetEndpointStatusByKey returns the endpoint status for a given key
 func (s *Store) GetEndpointStatusByKey(key string, params *paging.EndpointStatusParams) (*endpoint.Status, error) {
+	s.RLock()
+	defer s.RUnlock()
 	endpointStatus := s.endpointCache.GetValue(key)
 	if endpointStatus == nil {
 		return nil, common.ErrEndpointNotFound
@@ -102,6 +106,8 @@ func (s *Store) GetUptimeByKey(key string, from, to time.Time) (float64, error)
 	if from.After(to) {
 		return 0, common.ErrInvalidTimeRange
 	}
+	s.RLock()
+	defer s.RUnlock()
 	endpointStatus := s.endpointCache.GetValue(key)
 	if endpointStatus == nil || endpointStatus.(*endpoint.Status).Uptime == nil {
 		return 0, common.ErrEndpointNotFound
@@ -131,6 +137,8 @@ func (s *Store) GetAverageResponseTimeByKey(key string, from, to time.Time) (int
 	if from.After(to) {
 		return 0, common.ErrInvalidTimeRange
 	}
+	s.RLock()
+	defer s.RUnlock()
 	endpointStatus := s.endpointCache.GetValue(key)
 	if endpointStatus == nil || endpointStatus.(*endpoint.Status).Uptime == nil {
 		return 0, common.ErrEndpointNotFound
@@ -159,6 +167,8 @@ func (s *Store) GetHourlyAverageResponseTimeByKey(key string, from, to time.Time
 	if from.After(to) {
 		return nil, common.ErrInvalidTimeRange
 	}
+	s.RLock()
+	defer s.RUnlock()
 	endpointStatus := s.endpointCache.GetValue(key)
 	if endpointStatus == nil || endpointStatus.(*endpoint.Status).Uptime == nil {
 		return nil, common.ErrEndpointNotFound
diff --git a/storage/store/sql/specific_postgres.go b/storage/store/sql/specific_postgres.go
index d87ba308..b895564d 100644
--- a/storage/store/sql/specific_postgres.go
+++ b/storage/store/sql/specific_postgres.go
@@ -1,7 +1,34 @@
 package sql
 
 func (s *Store) createPostgresSchema() error {
+	// Create suite tables
 	_, err := s.db.Exec(`
+		CREATE TABLE IF NOT EXISTS suites (
+			suite_id    BIGSERIAL PRIMARY KEY,
+			suite_key   TEXT UNIQUE,
+			suite_name  TEXT NOT NULL,
+			suite_group TEXT NOT NULL,
+			UNIQUE(suite_name, suite_group)
+		)
+	`)
+	if err != nil {
+		return err
+	}
+	_, err = s.db.Exec(`
+		CREATE TABLE IF NOT EXISTS suite_results (
+			suite_result_id  BIGSERIAL PRIMARY KEY,
+			suite_id         BIGINT    NOT NULL REFERENCES suites(suite_id) ON DELETE CASCADE,
+			success          BOOLEAN   NOT NULL,
+			errors           TEXT      NOT NULL,
+			duration         BIGINT    NOT NULL,
+			timestamp        TIMESTAMP NOT NULL
+		)
+	`)
+	if err != nil {
+		return err
+	}
+	// Create endpoint tables
+	_, err = s.db.Exec(`
 		CREATE TABLE IF NOT EXISTS endpoints (
 			endpoint_id    BIGSERIAL PRIMARY KEY,
 			endpoint_key   TEXT UNIQUE,
@@ -83,32 +110,6 @@ func (s *Store) createPostgresSchema() error {
 	if err != nil {
 		return err
 	}
-	// Create suite tables
-	_, err = s.db.Exec(`
-		CREATE TABLE IF NOT EXISTS suites (
-			suite_id    BIGSERIAL PRIMARY KEY,
-			suite_key   TEXT UNIQUE,
-			suite_name  TEXT NOT NULL,
-			suite_group TEXT NOT NULL,
-			UNIQUE(suite_name, suite_group)
-		)
-	`)
-	if err != nil {
-		return err
-	}
-	_, err = s.db.Exec(`
-		CREATE TABLE IF NOT EXISTS suite_results (
-			suite_result_id  BIGSERIAL PRIMARY KEY,
-			suite_id         BIGINT    NOT NULL REFERENCES suites(suite_id) ON DELETE CASCADE,
-			success          BOOLEAN   NOT NULL,
-			errors           TEXT      NOT NULL,
-			duration         BIGINT    NOT NULL,
-			timestamp        TIMESTAMP NOT NULL
-		)
-	`)
-	if err != nil {
-		return err
-	}
 	// Create index for suite_results
 	_, err = s.db.Exec(`
 		CREATE INDEX IF NOT EXISTS suite_results_suite_id_idx ON suite_results (suite_id);
diff --git a/storage/store/sql/specific_sqlite.go b/storage/store/sql/specific_sqlite.go
index b167d70b..fbb81d3c 100644
--- a/storage/store/sql/specific_sqlite.go
+++ b/storage/store/sql/specific_sqlite.go
@@ -1,7 +1,34 @@
 package sql
 
 func (s *Store) createSQLiteSchema() error {
+	// Create suite tables
 	_, err := s.db.Exec(`
+		CREATE TABLE IF NOT EXISTS suites (
+			suite_id    INTEGER PRIMARY KEY,
+			suite_key   TEXT UNIQUE,
+			suite_name  TEXT NOT NULL,
+			suite_group TEXT NOT NULL,
+			UNIQUE(suite_name, suite_group)
+		)
+	`)
+	if err != nil {
+		return err
+	}
+	_, err = s.db.Exec(`
+		CREATE TABLE IF NOT EXISTS suite_results (
+			suite_result_id  INTEGER PRIMARY KEY,
+			suite_id         INTEGER   NOT NULL REFERENCES suites(suite_id) ON DELETE CASCADE,
+			success          INTEGER   NOT NULL,
+			errors           TEXT      NOT NULL,
+			duration         INTEGER   NOT NULL,
+			timestamp        TIMESTAMP NOT NULL
+		)
+	`)
+	if err != nil {
+		return err
+	}
+	// Create endpoint tables
+	_, err = s.db.Exec(`
 		CREATE TABLE IF NOT EXISTS endpoints (
 			endpoint_id    INTEGER PRIMARY KEY,
 			endpoint_key   TEXT UNIQUE,
@@ -83,32 +110,6 @@ func (s *Store) createSQLiteSchema() error {
 	if err != nil {
 		return err
 	}
-	// Create suite tables
-	_, err = s.db.Exec(`
-		CREATE TABLE IF NOT EXISTS suites (
-			suite_id    INTEGER PRIMARY KEY,
-			suite_key   TEXT UNIQUE,
-			suite_name  TEXT NOT NULL,
-			suite_group TEXT NOT NULL,
-			UNIQUE(suite_name, suite_group)
-		)
-	`)
-	if err != nil {
-		return err
-	}
-	_, err = s.db.Exec(`
-		CREATE TABLE IF NOT EXISTS suite_results (
-			suite_result_id  INTEGER PRIMARY KEY,
-			suite_id         INTEGER   NOT NULL REFERENCES suites(suite_id) ON DELETE CASCADE,
-			success          INTEGER   NOT NULL,
-			errors           TEXT      NOT NULL,
-			duration         INTEGER   NOT NULL,
-			timestamp        TIMESTAMP NOT NULL
-		)
-	`)
-	if err != nil {
-		return err
-	}
 	// Create indices for performance reasons
 	_, err = s.db.Exec(`
 		CREATE INDEX IF NOT EXISTS endpoint_results_endpoint_id_idx ON endpoint_results (endpoint_id);



Test patch:
diff --git a/client/client_test.go b/client/client_test.go
index 632a0ec2..10bfec16 100644
--- a/client/client_test.go
+++ b/client/client_test.go
@@ -39,6 +39,20 @@ func TestGetHTTPClient(t *testing.T) {
 	}
 }
 
+func TestRdapQuery(t *testing.T) {
+	if _, err := rdapQuery("1.1.1.1"); err == nil {
+		t.Error("expected an error due to the invalid domain type")
+	}
+	if _, err := rdapQuery("eurid.eu"); err == nil {
+		t.Error("expected an error as there is no RDAP support currently in .eu")
+	}
+	if response, err := rdapQuery("example.com"); err != nil {
+		t.Fatal("expected no error, got", err.Error())
+	} else if response.ExpirationDate.Unix() <= 0 {
+		t.Error("expected to have a valid expiry date, got", response.ExpirationDate.Unix())
+	}
+}
+
 func TestGetDomainExpiration(t *testing.T) {
 	t.Parallel()
 	if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
@@ -119,6 +133,7 @@ func TestCanPerformStartTLS(t *testing.T) {
 	type args struct {
 		address  string
 		insecure bool
+		dnsresolver string
 	}
 	tests := []struct {
 		name          string
@@ -150,11 +165,20 @@ func TestCanPerformStartTLS(t *testing.T) {
 			wantConnected: true,
 			wantErr:       false,
 		},
+		{
+			name: "dns resolver",
+			args: args{
+				address: "smtp.gmail.com:587",
+				dnsresolver: "tcp://1.1.1.1:53",
+			},
+			wantConnected: true,
+			wantErr:       false,
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			t.Parallel()
-			connected, _, err := CanPerformStartTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
+			connected, _, err := CanPerformStartTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second, DNSResolver: tt.args.dnsresolver})
 			if (err != nil) != tt.wantErr {
 				t.Errorf("CanPerformStartTLS() err=%v, wantErr=%v", err, tt.wantErr)
 				return
@@ -427,6 +451,16 @@ func TestQueryDNS(t *testing.T) {
 			expectedDNSCode: "NOERROR",
 			expectedBody:    "dns.google.",
 		},
+		{
+			name: "test Config with type PTR and forward IP / no in-addr",
+			inputDNS: dns.Config{
+				QueryType: "PTR",
+				QueryName: "1.0.0.1",
+			},
+			inputURL:        "1.1.1.1",
+			expectedDNSCode: "NOERROR",
+			expectedBody:    "one.one.one.one.",
+		},
 		{
 			name: "test Config with fake type and retrieve error",
 			inputDNS: dns.Config{