Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a4db600c9 | ||
|
|
02879e2645 | ||
|
|
00b56ecefd | ||
|
|
47dd18a0b5 | ||
|
|
1a708ebca2 | ||
|
|
5f8e62dad0 | ||
|
|
b74f7758dc | ||
|
|
899c19b2d7 | ||
|
|
35038a63c4 | ||
|
|
7b2af3c514 | ||
|
|
4ab7428599 | ||
|
|
be88af5d48 | ||
|
|
5bb3f6d0a9 | ||
|
|
17c14a7243 | ||
|
|
f44d4055e6 | ||
|
|
38054f57e5 | ||
|
|
33ce0e99b5 | ||
|
|
b5e6466c1d | ||
|
|
f89ecd5c64 | ||
|
|
e434178a5c | ||
|
|
7a3ee1b557 | ||
|
|
e51abaf5bd | ||
|
|
46d6d6c733 | ||
|
|
d9f86f1155 | ||
|
|
01484832fc | ||
|
|
4857b43771 | ||
|
|
52d7cb6f04 | ||
|
|
5c6bf84106 | ||
|
|
c84ae1cd55 | ||
|
|
daf8e3a16f | ||
|
|
df719958cf |
5
.github/workflows/publish-release.yml
vendored
5
.github/workflows/publish-release.yml
vendored
@@ -23,10 +23,9 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push docker image
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }}
|
||||
tags: ${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }},${{ env.IMAGE_REPOSITORY }}:stable
|
||||
|
||||
@@ -13,7 +13,6 @@ RUN CGO_ENABLED=0 GOOS=linux go build -mod vendor -a -installsuffix cgo -o gatus
|
||||
FROM scratch
|
||||
COPY --from=builder /app/gatus .
|
||||
COPY --from=builder /app/config.yaml ./config/config.yaml
|
||||
COPY --from=builder /app/web/static ./web/static
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
ENV PORT=8080
|
||||
EXPOSE ${PORT}
|
||||
|
||||
119
README.md
119
README.md
@@ -45,6 +45,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
||||
- [Configuring Matrix alerts](#configuring-matrix-alerts)
|
||||
- [Configuring Mattermost alerts](#configuring-mattermost-alerts)
|
||||
- [Configuring Messagebird alerts](#configuring-messagebird-alerts)
|
||||
- [Configuring Ntfy alerts](#configuring-ntfy-alerts)
|
||||
- [Configuring Opsgenie alerts](#configuring-opsgenie-alerts)
|
||||
- [Configuring PagerDuty alerts](#configuring-pagerduty-alerts)
|
||||
- [Configuring Slack alerts](#configuring-slack-alerts)
|
||||
@@ -56,7 +57,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
||||
- [Maintenance](#maintenance)
|
||||
- [Security](#security)
|
||||
- [Basic](#basic)
|
||||
- [OIDC (ALPHA)](#oidc-alpha)
|
||||
- [OIDC](#oidc)
|
||||
- [Metrics](#metrics)
|
||||
- [Remote instances (EXPERIMENTAL)](#remote-instances-experimental)
|
||||
- [Deployment](#deployment)
|
||||
@@ -74,6 +75,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
||||
- [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries)
|
||||
- [Monitoring an endpoint using STARTTLS](#monitoring-an-endpoint-using-starttls)
|
||||
- [Monitoring an endpoint using TLS](#monitoring-an-endpoint-using-tls)
|
||||
- [Monitoring domain expiration](#monitoring-domain-expiration)
|
||||
- [disable-monitoring-lock](#disable-monitoring-lock)
|
||||
- [Reloading configuration on the fly](#reloading-configuration-on-the-fly)
|
||||
- [Endpoint groups](#endpoint-groups)
|
||||
@@ -222,18 +224,20 @@ Here are some examples of conditions you can use:
|
||||
| `[BODY].name == pat(john*)` | String at JSONPath `$.name` matches pattern `john*` | `{"name":"john.doe"}` | `{"name":"bob"}` |
|
||||
| `[BODY].id == any(1, 2)` | Value at JSONPath `$.id` is equal to `1` or `2` | 1, 2 | 3, 4, 5 |
|
||||
| `[CERTIFICATE_EXPIRATION] > 48h` | Certificate expiration is more than 48h away | 49h, 50h, 123h | 1h, 24h, ... |
|
||||
| `[DOMAIN_EXPIRATION] > 720h` | The domain must expire in more than 720h | 4000h | 1h, 24h, ... |
|
||||
|
||||
|
||||
#### Placeholders
|
||||
| Placeholder | Description | Example of resolved value |
|
||||
|:---------------------------|:------------------------------------------------------------------------------------------|:---------------------------------------------|
|
||||
| `[STATUS]` | Resolves into the HTTP status of the request | 404 |
|
||||
| `[RESPONSE_TIME]` | Resolves into the response time the request took, in ms | 10 |
|
||||
| `[IP]` | Resolves into the IP of the target host | 192.168.0.232 |
|
||||
| `[STATUS]` | Resolves into the HTTP status of the request | `404` |
|
||||
| `[RESPONSE_TIME]` | Resolves into the response time the request took, in ms | `10` |
|
||||
| `[IP]` | Resolves into the IP of the target host | `192.168.0.232` |
|
||||
| `[BODY]` | Resolves into the response body. Supports JSONPath. | `{"name":"john.doe"}` |
|
||||
| `[CONNECTED]` | Resolves into whether a connection could be established | `true` |
|
||||
| `[CERTIFICATE_EXPIRATION]` | Resolves into the duration before certificate expiration (valid units are "s", "m", "h".) | `24h`, `48h`, 0 (if not protocol with certs) |
|
||||
| `[DNS_RCODE]` | Resolves into the DNS status of the response | NOERROR |
|
||||
| `[DOMAIN_EXPIRATION]` | Resolves into the duration before the domain expires (valid units are "s", "m", "h".) | `24h`, `48h`, `1234h56m78s` |
|
||||
| `[DNS_RCODE]` | Resolves into the DNS status of the response | `NOERROR` |
|
||||
|
||||
|
||||
#### Functions
|
||||
@@ -346,6 +350,7 @@ endpoints:
|
||||
- "[STATUS] == 200"
|
||||
```
|
||||
|
||||
|
||||
### Alerting
|
||||
Gatus supports multiple alerting providers, such as Slack and PagerDuty, and supports different alerts for each
|
||||
individual endpoints with configurable descriptions and thresholds.
|
||||
@@ -358,8 +363,10 @@ ignored.
|
||||
| `alerting.discord` | Configuration for alerts of type `discord`. <br />See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` |
|
||||
| `alerting.email` | Configuration for alerts of type `email`. <br />See [Configuring Email alerts](#configuring-email-alerts). | `{}` |
|
||||
| `alerting.googlechat` | Configuration for alerts of type `googlechat`. <br />See [Configuring Google Chat alerts](#configuring-google-chat-alerts). | `{}` |
|
||||
| `alerting.matrix` | Configuration for alerts of type `matrix`. <br />See [Configuring Matrix alerts](#configuring-matrix-alerts). | `{}` |
|
||||
| `alerting.mattermost` | Configuration for alerts of type `mattermost`. <br />See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` |
|
||||
| `alerting.messagebird` | Configuration for alerts of type `messagebird`. <br />See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` |
|
||||
| `alerting.ntfy` | Configuration for alerts of type `ntfy`. <br />See [Configuring Ntfy alerts](#configuring-ntfy-alerts). | `{}` |
|
||||
| `alerting.opsgenie` | Configuration for alerts of type `opsgenie`. <br />See [Configuring Opsgenie alerts](#configuring-opsgenie-alerts). | `{}` |
|
||||
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty`. <br />See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` |
|
||||
| `alerting.slack` | Configuration for alerts of type `slack`. <br />See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` |
|
||||
@@ -368,6 +375,7 @@ ignored.
|
||||
| `alerting.twilio` | Settings for alerts of type `twilio`. <br />See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` |
|
||||
| `alerting.custom` | Configuration for custom actions on failure or alerts. <br />See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` |
|
||||
|
||||
|
||||
#### Configuring Discord alerts
|
||||
|
||||
| Parameter | Description | Default |
|
||||
@@ -399,6 +407,7 @@ endpoints:
|
||||
send-on-resolved: true
|
||||
```
|
||||
|
||||
|
||||
#### Configuring Email alerts
|
||||
|
||||
| Parameter | Description | Default |
|
||||
@@ -460,6 +469,7 @@ endpoints:
|
||||
|
||||
**NOTE:** Some mail servers are painfully slow.
|
||||
|
||||
|
||||
#### Configuring Google Chat alerts
|
||||
|
||||
| Parameter | Description | Default |
|
||||
@@ -480,7 +490,7 @@ alerting:
|
||||
endpoints:
|
||||
- name: website
|
||||
url: "https://twin.sh/health"
|
||||
interval: 30s
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
@@ -492,25 +502,26 @@ endpoints:
|
||||
send-on-resolved: true
|
||||
```
|
||||
|
||||
|
||||
#### Configuring Matrix alerts
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------------|:-------------------------------------------------------------------------------------------------|:-----------------------------------|
|
||||
| `alerting.matrix` | Settings for alerts of type `matrix` | `{}` |
|
||||
| `alerting.matrix.server-url` | Homeserver URL | `https://matrix-client.matrix.org` |
|
||||
| `alerting.matrix.access-token` | Bot user access token (see https://webapps.stackexchange.com/q/131056) | Required `""` |
|
||||
| `alerting.matrix.internal-room-id` | Internal room ID of room to send alerts to (can be found in Element in Room Settings > Advanced) | Required `""` |
|
||||
| `alerting.matrix.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------------------|:-------------------------------------------------------------------------------------------|:-----------------------------------|
|
||||
| `alerting.matrix` | Configuration for alerts of type `matrix` | `{}` |
|
||||
| `alerting.matrix.server-url` | Homeserver URL | `https://matrix-client.matrix.org` |
|
||||
| `alerting.matrix.access-token` | Bot user access token (see https://webapps.stackexchange.com/q/131056) | Required `""` |
|
||||
| `alerting.matrix.internal-room-id` | Internal room ID of room to send alerts to (can be found in Room Settings > Advanced) | Required `""` |
|
||||
| `alerting.matrix.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
matrix:
|
||||
server-url: "..."
|
||||
access-token: "..."
|
||||
internal-room-id: "..."
|
||||
server-url: "https://matrix-client.matrix.org"
|
||||
access-token: "123456"
|
||||
internal-room-id: "!example:matrix.org"
|
||||
|
||||
endpoints:
|
||||
- name: website
|
||||
interval: 30s
|
||||
interval: 5m
|
||||
url: "https://twin.sh/health"
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
@@ -523,6 +534,7 @@ endpoints:
|
||||
description: "healthcheck failed"
|
||||
```
|
||||
|
||||
|
||||
#### Configuring Mattermost alerts
|
||||
| Parameter | Description | Default |
|
||||
|:----------------------------------------------|:--------------------------------------------------------------------------------------------|:--------------|
|
||||
@@ -544,7 +556,7 @@ alerting:
|
||||
endpoints:
|
||||
- name: website
|
||||
url: "https://twin.sh/health"
|
||||
interval: 30s
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
@@ -560,10 +572,11 @@ Here's an example of what the notifications look like:
|
||||
|
||||

|
||||
|
||||
|
||||
#### Configuring Messagebird alerts
|
||||
| Parameter | Description | Default |
|
||||
|:-------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.messagebird` | Settings for alerts of type `messagebird` | `{}` |
|
||||
| `alerting.messagebird` | Configuration for alerts of type `messagebird` | `{}` |
|
||||
| `alerting.messagebird.access-key` | Messagebird access key | Required `""` |
|
||||
| `alerting.messagebird.originator` | The sender of the message | Required `""` |
|
||||
| `alerting.messagebird.recipients` | The recipients of the message | Required `""` |
|
||||
@@ -579,7 +592,7 @@ alerting:
|
||||
|
||||
endpoints:
|
||||
- name: website
|
||||
interval: 30s
|
||||
interval: 5m
|
||||
url: "https://twin.sh/health"
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
@@ -594,6 +607,42 @@ endpoints:
|
||||
```
|
||||
|
||||
|
||||
#### Configuring Ntfy alerts
|
||||
| Parameter | Description | Default |
|
||||
|:------------------------------|:-------------------------------------------------------------------------------------------|:------------------|
|
||||
| `alerting.ntfy` | Configuration for alerts of type `ntfy` | `{}` |
|
||||
| `alerting.ntfy.topic` | Topic at which the alert will be sent | Required `""` |
|
||||
| `alerting.ntfy.url` | The URL of the target server | `https://ntfy.sh` |
|
||||
| `alerting.ntfy.priority` | The priority of the alert | `3` |
|
||||
| `alerting.ntfy.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
|
||||
[ntfy](https://github.com/binwiederhier/ntfy) is an amazing project that allows you to subscribe to desktop
|
||||
and mobile notifications, making it an awesome addition to Gatus.
|
||||
|
||||
Example:
|
||||
```yaml
|
||||
alerting:
|
||||
ntfy:
|
||||
topic: "gatus-test-topic"
|
||||
priority: 2
|
||||
default-alert:
|
||||
enabled: true
|
||||
failure-threshold: 3
|
||||
send-on-resolved: true
|
||||
|
||||
endpoints:
|
||||
- name: website
|
||||
interval: 5m
|
||||
url: "https://twin.sh/health"
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 300"
|
||||
alerts:
|
||||
- type: ntfy
|
||||
```
|
||||
|
||||
|
||||
#### Configuring Opsgenie alerts
|
||||
| Parameter | Description | Default |
|
||||
|:----------------------------------|:-------------------------------------------------------------------------------------------|:---------------------|
|
||||
@@ -776,6 +825,7 @@ Here's an example of what the notifications look like:
|
||||
| `alerting.telegram.token` | Telegram Bot Token | Required `""` |
|
||||
| `alerting.telegram.id` | Telegram User ID | Required `""` |
|
||||
| `alerting.telegram.api-url` | Telegram API URL | `https://api.telegram.org` |
|
||||
| `alerting.telegram.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
|
||||
| `alerting.telegram.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
|
||||
```yaml
|
||||
@@ -1052,7 +1102,7 @@ security:
|
||||
**WARNING:** Make sure to carefully select to cost of the bcrypt hash. The higher the cost, the longer it takes to compute the hash,
|
||||
and basic auth verifies the password against the hash on every request. As of 2022-01-08, I suggest a cost of 8.
|
||||
|
||||
#### OIDC (ALPHA)
|
||||
#### OIDC
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------------|:---------------------------------------------------------------|:--------------|
|
||||
| `security.oidc` | OpenID Connect configuration | `{}` |
|
||||
@@ -1075,7 +1125,7 @@ security:
|
||||
#allowed-subjects: ["johndoe@example.com"]
|
||||
```
|
||||
|
||||
**NOTE:** The OIDC feature is currently in Alpha. Breaking changes may occur. Use this feature at your own risk.
|
||||
Confused? Read [Securing Gatus with OIDC using Auth0](https://twin.sh/articles/56/securing-gatus-with-oidc-using-auth0).
|
||||
|
||||
|
||||
### Metrics
|
||||
@@ -1337,6 +1387,25 @@ endpoints:
|
||||
```
|
||||
|
||||
|
||||
### Monitoring domain expiration
|
||||
You can monitor the expiration of a domain with all endpoint types except for DNS by using the `[DOMAIN_EXPIRATION]`
|
||||
placeholder:
|
||||
```yaml
|
||||
endpoints:
|
||||
- name: check-domain-and-certificate-expiration
|
||||
url: "https://example.org"
|
||||
interval: 1h
|
||||
conditions:
|
||||
- "[DOMAIN_EXPIRATION] > 720h"
|
||||
- "[CERTIFICATE_EXPIRATION] > 240h"
|
||||
```
|
||||
|
||||
**NOTE**: 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`).
|
||||
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`.
|
||||
|
||||
|
||||
### disable-monitoring-lock
|
||||
Setting `disable-monitoring-lock` to `true` means that multiple endpoints could be monitored at the same time.
|
||||
|
||||
@@ -1408,7 +1477,7 @@ endpoints:
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
- name: random endpoint that isn't part of a group
|
||||
- name: random endpoint that is not part of a group
|
||||
url: "https://example.org/"
|
||||
interval: 5m
|
||||
conditions:
|
||||
@@ -1434,6 +1503,7 @@ web:
|
||||
port: ${PORT}
|
||||
```
|
||||
|
||||
|
||||
### Badges
|
||||
#### Uptime
|
||||

|
||||
@@ -1499,7 +1569,7 @@ Where:
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
|
||||
|
||||
##### How to change the color thresholds of the response time badge
|
||||
To change the response time badges threshold, a corresponding configuration can be added to an endpoint.
|
||||
To change the response time badges' threshold, a corresponding configuration can be added to an endpoint.
|
||||
The values in the array correspond to the levels [Awesome, Great, Good, Passable, Bad]
|
||||
All five values must be given in milliseconds (ms).
|
||||
|
||||
@@ -1517,6 +1587,7 @@ endpoints:
|
||||
thresholds: [550, 850, 1350, 1650, 1750]
|
||||
```
|
||||
|
||||
|
||||
### API
|
||||
Gatus provides a simple read-only API that can be queried in order to programmatically determine endpoint status and history.
|
||||
|
||||
|
||||
@@ -26,6 +26,12 @@ const (
|
||||
// TypeMessagebird is the Type for the messagebird alerting provider
|
||||
TypeMessagebird Type = "messagebird"
|
||||
|
||||
// TypeNtfy is the Type for the ntfy alerting provider
|
||||
TypeNtfy Type = "ntfy"
|
||||
|
||||
// TypeOpsgenie is the Type for the opsgenie alerting provider
|
||||
TypeOpsgenie Type = "opsgenie"
|
||||
|
||||
// TypePagerDuty is the Type for the pagerduty alerting provider
|
||||
TypePagerDuty Type = "pagerduty"
|
||||
|
||||
@@ -40,7 +46,4 @@ const (
|
||||
|
||||
// TypeTwilio is the Type for the twilio alerting provider
|
||||
TypeTwilio Type = "twilio"
|
||||
|
||||
// TypeOpsgenie is the Type for the opsgenie alerting provider
|
||||
TypeOpsgenie Type = "opsgenie"
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/TwiN/gatus/v4/alerting/provider/matrix"
|
||||
"github.com/TwiN/gatus/v4/alerting/provider/mattermost"
|
||||
"github.com/TwiN/gatus/v4/alerting/provider/messagebird"
|
||||
"github.com/TwiN/gatus/v4/alerting/provider/ntfy"
|
||||
"github.com/TwiN/gatus/v4/alerting/provider/opsgenie"
|
||||
"github.com/TwiN/gatus/v4/alerting/provider/pagerduty"
|
||||
"github.com/TwiN/gatus/v4/alerting/provider/slack"
|
||||
@@ -41,6 +42,12 @@ type Config struct {
|
||||
// Messagebird is the configuration for the messagebird alerting provider
|
||||
Messagebird *messagebird.AlertProvider `yaml:"messagebird,omitempty"`
|
||||
|
||||
// Ntfy is the configuration for the ntfy alerting provider
|
||||
Ntfy *ntfy.AlertProvider `yaml:"ntfy,omitempty"`
|
||||
|
||||
// Opsgenie is the configuration for the opsgenie alerting provider
|
||||
Opsgenie *opsgenie.AlertProvider `yaml:"opsgenie,omitempty"`
|
||||
|
||||
// PagerDuty is the configuration for the pagerduty alerting provider
|
||||
PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty,omitempty"`
|
||||
|
||||
@@ -55,9 +62,6 @@ type Config struct {
|
||||
|
||||
// Twilio is the configuration for the twilio alerting provider
|
||||
Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
|
||||
|
||||
// Opsgenie is the configuration for the opsgenie alerting provider
|
||||
Opsgenie *opsgenie.AlertProvider `yaml:"opsgenie,omitempty"`
|
||||
}
|
||||
|
||||
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type
|
||||
@@ -105,6 +109,12 @@ func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provid
|
||||
return nil
|
||||
}
|
||||
return config.Messagebird
|
||||
case alert.TypeNtfy:
|
||||
if config.Ntfy == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
return nil
|
||||
}
|
||||
return config.Ntfy
|
||||
case alert.TypeOpsgenie:
|
||||
if config.Opsgenie == nil {
|
||||
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
|
||||
|
||||
@@ -13,14 +13,24 @@ import (
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{URL: ""}
|
||||
if invalidProvider.IsValid() {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{URL: "https://example.com"}
|
||||
if !validProvider.IsValid() {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
t.Run("invalid-provider", func(t *testing.T) {
|
||||
invalidProvider := AlertProvider{URL: ""}
|
||||
if invalidProvider.IsValid() {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
})
|
||||
t.Run("valid-provider", func(t *testing.T) {
|
||||
validProvider := AlertProvider{URL: "https://example.com"}
|
||||
if validProvider.ClientConfig != nil {
|
||||
t.Error("provider client config should have been nil prior to IsValid() being executed")
|
||||
}
|
||||
if !validProvider.IsValid() {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
if validProvider.ClientConfig == nil {
|
||||
t.Error("provider client config should have been set after IsValid() was executed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
|
||||
@@ -159,31 +159,21 @@ func (provider *AlertProvider) getConfigForGroup(group string) MatrixProviderCon
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
return MatrixProviderConfig{
|
||||
ServerURL: override.ServerURL,
|
||||
AccessToken: override.AccessToken,
|
||||
InternalRoomID: override.InternalRoomID,
|
||||
}
|
||||
return override.MatrixProviderConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
return MatrixProviderConfig{
|
||||
ServerURL: provider.ServerURL,
|
||||
AccessToken: provider.AccessToken,
|
||||
InternalRoomID: provider.InternalRoomID,
|
||||
}
|
||||
return provider.MatrixProviderConfig
|
||||
}
|
||||
|
||||
func randStringBytes(n int) string {
|
||||
// All the compatible characters to use in a transaction ID
|
||||
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
b := make([]byte, n)
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
for i := range b {
|
||||
b[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))]
|
||||
}
|
||||
|
||||
return string(b)
|
||||
}
|
||||
|
||||
|
||||
84
alerting/provider/ntfy/ntfy.go
Normal file
84
alerting/provider/ntfy/ntfy.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package ntfy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||
"github.com/TwiN/gatus/v4/client"
|
||||
"github.com/TwiN/gatus/v4/core"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultURL = "https://ntfy.sh"
|
||||
DefaultPriority = 3
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Slack
|
||||
type AlertProvider struct {
|
||||
Topic string `yaml:"topic"`
|
||||
URL string `yaml:"url,omitempty"` // Defaults to DefaultURL
|
||||
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
if len(provider.URL) == 0 {
|
||||
provider.URL = DefaultURL
|
||||
}
|
||||
if provider.Priority == 0 {
|
||||
provider.Priority = DefaultPriority
|
||||
}
|
||||
return len(provider.URL) > 0 && len(provider.Topic) > 0 && provider.Priority > 0 && provider.Priority < 6
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
||||
request, err := http.NewRequest(http.MethodPost, provider.URL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.StatusCode > 399 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||
var message, tag string
|
||||
if len(alert.GetDescription()) > 0 {
|
||||
message = endpoint.DisplayName() + " - " + alert.GetDescription()
|
||||
} else {
|
||||
message = endpoint.DisplayName()
|
||||
}
|
||||
if resolved {
|
||||
tag = "white_check_mark"
|
||||
} else {
|
||||
tag = "x"
|
||||
}
|
||||
return fmt.Sprintf(`{
|
||||
"topic": "%s",
|
||||
"title": "Gatus",
|
||||
"message": "%s",
|
||||
"tags": ["%s"],
|
||||
"priority": %d
|
||||
}`, provider.Topic, message, tag, provider.Priority)
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
104
alerting/provider/ntfy/ntfy_test.go
Normal file
104
alerting/provider/ntfy/ntfy_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package ntfy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v4/alerting/alert"
|
||||
"github.com/TwiN/gatus/v4/core"
|
||||
)
|
||||
|
||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "no-url-should-use-default-value",
|
||||
provider: AlertProvider{Topic: "example", Priority: 1},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "invalid-topic",
|
||||
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "", Priority: 1},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "invalid-priority-too-high",
|
||||
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 6},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "invalid-priority-too-low",
|
||||
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: -1},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "no-priority-should-use-default-value",
|
||||
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example"},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
if scenario.provider.IsValid() != scenario.expected {
|
||||
t.Errorf("expected %t, got %t", scenario.expected, scenario.provider.IsValid())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\n \"topic\": \"example\",\n \"title\": \"Gatus\",\n \"message\": \"endpoint-name - description-1\",\n \"tags\": [\"x\"],\n \"priority\": 1\n}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 2},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\n \"topic\": \"example\",\n \"title\": \"Gatus\",\n \"message\": \"endpoint-name - description-2\",\n \"tags\": [\"white_check_mark\"],\n \"priority\": 2\n}",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if body != scenario.ExpectedBody {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
|
||||
}
|
||||
out := make(map[string]interface{})
|
||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/TwiN/gatus/v4/alerting/provider/matrix"
|
||||
"github.com/TwiN/gatus/v4/alerting/provider/mattermost"
|
||||
"github.com/TwiN/gatus/v4/alerting/provider/messagebird"
|
||||
"github.com/TwiN/gatus/v4/alerting/provider/ntfy"
|
||||
"github.com/TwiN/gatus/v4/alerting/provider/opsgenie"
|
||||
"github.com/TwiN/gatus/v4/alerting/provider/pagerduty"
|
||||
"github.com/TwiN/gatus/v4/alerting/provider/slack"
|
||||
@@ -61,6 +62,7 @@ var (
|
||||
_ AlertProvider = (*matrix.AlertProvider)(nil)
|
||||
_ AlertProvider = (*mattermost.AlertProvider)(nil)
|
||||
_ AlertProvider = (*messagebird.AlertProvider)(nil)
|
||||
_ AlertProvider = (*ntfy.AlertProvider)(nil)
|
||||
_ AlertProvider = (*opsgenie.AlertProvider)(nil)
|
||||
_ AlertProvider = (*pagerduty.AlertProvider)(nil)
|
||||
_ AlertProvider = (*slack.AlertProvider)(nil)
|
||||
|
||||
@@ -20,8 +20,8 @@ func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||
if !validProvider.IsValid() {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||
providerWithInvalidOverrideGroup := AlertProvider{
|
||||
Overrides: []Override{
|
||||
@@ -58,6 +58,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
|
||||
@@ -19,12 +19,18 @@ type AlertProvider struct {
|
||||
ID string `yaml:"id"`
|
||||
APIURL string `yaml:"api-url"`
|
||||
|
||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
if provider.ClientConfig == nil {
|
||||
provider.ClientConfig = client.GetDefaultConfig()
|
||||
}
|
||||
return len(provider.Token) > 0 && len(provider.ID) > 0
|
||||
}
|
||||
|
||||
@@ -40,7 +46,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -12,14 +12,24 @@ import (
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{Token: "", ID: ""}
|
||||
if invalidProvider.IsValid() {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}
|
||||
if !validProvider.IsValid() {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
t.Run("invalid-provider", func(t *testing.T) {
|
||||
invalidProvider := AlertProvider{Token: "", ID: ""}
|
||||
if invalidProvider.IsValid() {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
})
|
||||
t.Run("valid-provider", func(t *testing.T) {
|
||||
validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}
|
||||
if validProvider.ClientConfig != nil {
|
||||
t.Error("provider client config should have been nil prior to IsValid() being executed")
|
||||
}
|
||||
if !validProvider.IsValid() {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
if validProvider.ClientConfig == nil {
|
||||
t.Error("provider client config should have been set after IsValid() was executed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
|
||||
@@ -45,3 +45,9 @@ endpoints:
|
||||
interval: 1m
|
||||
conditions:
|
||||
- "[CONNECTED] == true"
|
||||
|
||||
- name: check-domain-expiration
|
||||
url: "https://example.org/"
|
||||
interval: 1h
|
||||
conditions:
|
||||
- "[DOMAIN_EXPIRATION] > 720h"
|
||||
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
@@ -267,7 +268,7 @@ func validateEndpointsConfig(config *Config) error {
|
||||
log.Printf("[config][validateEndpointsConfig] Validating endpoint '%s'", endpoint.Name)
|
||||
}
|
||||
if err := endpoint.ValidateAndSetDefaults(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("invalid endpoint %s: %s", endpoint.DisplayName(), err)
|
||||
}
|
||||
}
|
||||
log.Printf("[config][validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
|
||||
@@ -305,6 +306,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
|
||||
alert.TypeMatrix,
|
||||
alert.TypeMattermost,
|
||||
alert.TypeMessagebird,
|
||||
alert.TypeNtfy,
|
||||
alert.TypeOpsgenie,
|
||||
alert.TypePagerDuty,
|
||||
alert.TypeSlack,
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/TwiN/gatus/v4/alerting/provider/telegram"
|
||||
"github.com/TwiN/gatus/v4/alerting/provider/twilio"
|
||||
"github.com/TwiN/gatus/v4/client"
|
||||
"github.com/TwiN/gatus/v4/config/ui"
|
||||
"github.com/TwiN/gatus/v4/config/web"
|
||||
"github.com/TwiN/gatus/v4/core"
|
||||
"github.com/TwiN/gatus/v4/storage"
|
||||
@@ -39,10 +38,6 @@ func TestLoadDefaultConfigurationFile(t *testing.T) {
|
||||
|
||||
func TestParseAndValidateConfigBytes(t *testing.T) {
|
||||
file := t.TempDir() + "/test.db"
|
||||
ui.StaticFolder = "../web/static"
|
||||
defer func() {
|
||||
ui.StaticFolder = "./web/static"
|
||||
}()
|
||||
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
|
||||
storage:
|
||||
type: sqlite
|
||||
@@ -1125,7 +1120,7 @@ endpoints:
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
if err != core.ErrEndpointWithNoName {
|
||||
if err == nil {
|
||||
t.Error("should've returned an error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ func (c *Config) ValidateAndSetDefaults() error {
|
||||
if len(c.Instances) > 0 {
|
||||
log.Println("WARNING: Your configuration is using 'remote', which is in alpha and may be updated/removed in future versions.")
|
||||
log.Println("WARNING: See https://github.com/TwiN/gatus/issues/64 for more information")
|
||||
log.Println("WARNING: This feature is a candidate for removal in future versions. Please comment on the issue above if you need this feature.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"html/template"
|
||||
|
||||
"github.com/TwiN/gatus/v4/web"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -14,10 +16,6 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
// StaticFolder is the path to the location of the static folder from the root path of the project
|
||||
// The only reason this is exposed is to allow running tests from a different path than the root path of the project
|
||||
StaticFolder = "./web/static"
|
||||
|
||||
ErrButtonValidationFailed = errors.New("invalid button configuration: missing required name or link")
|
||||
)
|
||||
|
||||
@@ -71,7 +69,7 @@ func (cfg *Config) ValidateAndSetDefaults() error {
|
||||
}
|
||||
}
|
||||
// Validate that the template works
|
||||
t, err := template.ParseFiles(StaticFolder + "/index.html")
|
||||
t, err := template.ParseFS(static.FileSystem, static.IndexPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,10 +6,6 @@ import (
|
||||
)
|
||||
|
||||
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||
StaticFolder = "../../web/static"
|
||||
defer func() {
|
||||
StaticFolder = "./web/static"
|
||||
}()
|
||||
cfg := &Config{
|
||||
Title: "",
|
||||
Header: "",
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v4/config"
|
||||
"github.com/TwiN/gatus/v4/config/ui"
|
||||
"github.com/TwiN/gatus/v4/controller/handler"
|
||||
)
|
||||
|
||||
@@ -21,7 +20,7 @@ var (
|
||||
|
||||
// Handle creates the router and starts the server
|
||||
func Handle(cfg *config.Config) {
|
||||
var router http.Handler = handler.CreateRouter(ui.StaticFolder, cfg)
|
||||
var router http.Handler = handler.CreateRouter(cfg)
|
||||
if os.Getenv("ENVIRONMENT") == "dev" {
|
||||
router = handler.DevelopmentCORS(router)
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestBadge(t *testing.T) {
|
||||
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
router := CreateRouter("../../web/static", cfg)
|
||||
router := CreateRouter(cfg)
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
@@ -140,6 +140,16 @@ func TestBadge(t *testing.T) {
|
||||
Path: "/api/v1/endpoints/core_backend/response-times/24h/chart.svg",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "chart-response-time-7d",
|
||||
Path: "/api/v1/endpoints/core_frontend/response-times/7d/chart.svg",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "chart-response-time-with-invalid-duration",
|
||||
Path: "/api/v1/endpoints/core_backend/response-times/3d/chart.svg",
|
||||
ExpectedCode: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestResponseTimeChart(t *testing.T) {
|
||||
}
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
router := CreateRouter("../../web/static", cfg)
|
||||
router := CreateRouter(cfg)
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
|
||||
@@ -97,7 +97,7 @@ func TestEndpointStatus(t *testing.T) {
|
||||
}
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
router := CreateRouter("../../web/static", cfg)
|
||||
router := CreateRouter(cfg)
|
||||
|
||||
type Scenario struct {
|
||||
Name string
|
||||
@@ -153,7 +153,7 @@ func TestEndpointStatuses(t *testing.T) {
|
||||
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests
|
||||
firstResult.Timestamp = time.Time{}
|
||||
secondResult.Timestamp = time.Time{}
|
||||
router := CreateRouter("../../web/static", &config.Config{Metrics: true})
|
||||
router := CreateRouter(&config.Config{Metrics: true})
|
||||
|
||||
type Scenario struct {
|
||||
Name string
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// FavIcon handles requests for /favicon.ico
|
||||
func FavIcon(staticFolder string) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
http.ServeFile(writer, request, staticFolder+"/favicon.ico")
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v4/config"
|
||||
)
|
||||
|
||||
func TestFavIcon(t *testing.T) {
|
||||
router := CreateRouter("../../web/static", &config.Config{})
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "favicon",
|
||||
Path: "/favicon.ico",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v4/config"
|
||||
"github.com/TwiN/gatus/v4/web"
|
||||
"github.com/TwiN/health"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
func CreateRouter(staticFolder string, cfg *config.Config) *mux.Router {
|
||||
func CreateRouter(cfg *config.Config) *mux.Router {
|
||||
router := mux.NewRouter()
|
||||
if cfg.Metrics {
|
||||
router.Handle("/metrics", promhttp.Handler()).Methods("GET")
|
||||
@@ -35,11 +37,14 @@ func CreateRouter(staticFolder string, cfg *config.Config) *mux.Router {
|
||||
unprotected.HandleFunc("/v1/endpoints/{key}/response-times/{duration}/chart.svg", ResponseTimeChart).Methods("GET")
|
||||
// Misc
|
||||
router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET")
|
||||
router.HandleFunc("/favicon.ico", FavIcon(staticFolder)).Methods("GET")
|
||||
// SPA
|
||||
router.HandleFunc("/endpoints/{name}", SinglePageApplication(staticFolder, cfg.UI)).Methods("GET")
|
||||
router.HandleFunc("/", SinglePageApplication(staticFolder, cfg.UI)).Methods("GET")
|
||||
router.HandleFunc("/endpoints/{name}", SinglePageApplication(cfg.UI)).Methods("GET")
|
||||
router.HandleFunc("/", SinglePageApplication(cfg.UI)).Methods("GET")
|
||||
// Everything else falls back on static content
|
||||
router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.Dir(staticFolder))))
|
||||
staticFileSystem, err := fs.Sub(static.FileSystem, static.RootPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.FS(staticFileSystem))))
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
func TestCreateRouter(t *testing.T) {
|
||||
router := CreateRouter("../../web/static", &config.Config{Metrics: true})
|
||||
router := CreateRouter(&config.Config{Metrics: true})
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
@@ -28,16 +28,32 @@ func TestCreateRouter(t *testing.T) {
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "scripts",
|
||||
Name: "favicon.ico",
|
||||
Path: "/favicon.ico",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "app.js",
|
||||
Path: "/js/app.js",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "scripts-gzipped",
|
||||
Name: "app.js-gzipped",
|
||||
Path: "/js/app.js",
|
||||
ExpectedCode: http.StatusOK,
|
||||
Gzip: true,
|
||||
},
|
||||
{
|
||||
Name: "chunk-vendors.js",
|
||||
Path: "/js/chunk-vendors.js",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "chunk-vendors.js-gzipped",
|
||||
Path: "/js/chunk-vendors.js",
|
||||
ExpectedCode: http.StatusOK,
|
||||
Gzip: true,
|
||||
},
|
||||
{
|
||||
Name: "index-redirect",
|
||||
Path: "/index.html",
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v4/config/ui"
|
||||
"github.com/TwiN/gatus/v4/web"
|
||||
)
|
||||
|
||||
func SinglePageApplication(staticFolder string, ui *ui.Config) http.HandlerFunc {
|
||||
func SinglePageApplication(ui *ui.Config) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
t, err := template.ParseFiles(staticFolder + "/index.html")
|
||||
t, err := template.ParseFS(static.FileSystem, static.IndexPath)
|
||||
if err != nil {
|
||||
log.Println("[handler][SinglePageApplication] Failed to parse template:", err.Error())
|
||||
http.ServeFile(writer, request, staticFolder+"/index.html")
|
||||
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
|
||||
log.Println("[handler][SinglePageApplication] Failed to parse template. This should never happen, because the template is validated on start. Error:", err.Error())
|
||||
http.Error(writer, "Failed to parse template. This should never happen, because the template is validated on start.", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Type", "text/html")
|
||||
err = t.Execute(writer, ui)
|
||||
if err != nil {
|
||||
log.Println("[handler][SinglePageApplication] Failed to parse template:", err.Error())
|
||||
http.ServeFile(writer, request, staticFolder+"/index.html")
|
||||
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
|
||||
log.Println("[handler][SinglePageApplication] Failed to execute template. This should never happen, because the template is validated on start. Error:", err.Error())
|
||||
http.Error(writer, "Failed to execute template. This should never happen, because the template is validated on start.", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestSinglePageApplication(t *testing.T) {
|
||||
}
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
router := CreateRouter("../../web/static", cfg)
|
||||
router := CreateRouter(cfg)
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -33,13 +34,13 @@ const (
|
||||
// GatusUserAgent is the default user agent that Gatus uses to send requests.
|
||||
GatusUserAgent = "Gatus/1.0"
|
||||
|
||||
// EndpointType enum for the endpoint type.
|
||||
EndpointTypeDNS EndpointType = "DNS"
|
||||
EndpointTypeTCP EndpointType = "TCP"
|
||||
EndpointTypeICMP EndpointType = "ICMP"
|
||||
EndpointTypeSTARTTLS EndpointType = "STARTTLS"
|
||||
EndpointTypeTLS EndpointType = "TLS"
|
||||
EndpointTypeHTTP EndpointType = "HTTP"
|
||||
EndpointTypeUNKNOWN EndpointType = "UNKNOWN"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -54,6 +55,15 @@ var (
|
||||
|
||||
// ErrEndpointWithInvalidNameOrGroup is the error with which Gatus will panic if an endpoint has an invalid character where it shouldn't
|
||||
ErrEndpointWithInvalidNameOrGroup = errors.New("endpoint name and group must not have \" or \\")
|
||||
|
||||
// ErrUnknownEndpointType is the error with which Gatus will panic if an endpoint has an unknown type
|
||||
ErrUnknownEndpointType = errors.New("unknown endpoint type")
|
||||
|
||||
// ErrInvalidEndpointIntervalForDomainExpirationPlaceholder is the error with which Gatus will panic if an endpoint
|
||||
// has both an interval smaller than 5 minutes and a condition with DomainExpirationPlaceholder.
|
||||
// This is because the free whois service we are using should not be abused, especially considering the fact that
|
||||
// the data takes a while to be updated.
|
||||
ErrInvalidEndpointIntervalForDomainExpirationPlaceholder = errors.New("the minimum interval for an endpoint with a condition using the " + DomainExpirationPlaceholder + " placeholder is 300s (5m)")
|
||||
)
|
||||
|
||||
// Endpoint is the configuration of a monitored
|
||||
@@ -128,12 +138,14 @@ func (endpoint Endpoint) Type() EndpointType {
|
||||
return EndpointTypeSTARTTLS
|
||||
case strings.HasPrefix(endpoint.URL, "tls://"):
|
||||
return EndpointTypeTLS
|
||||
default:
|
||||
case strings.HasPrefix(endpoint.URL, "http://") || strings.HasPrefix(endpoint.URL, "https://"):
|
||||
return EndpointTypeHTTP
|
||||
default:
|
||||
return EndpointTypeUNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -185,9 +197,19 @@ func (endpoint *Endpoint) ValidateAndSetDefaults() error {
|
||||
if len(endpoint.Conditions) == 0 {
|
||||
return ErrEndpointWithNoCondition
|
||||
}
|
||||
if endpoint.Interval < 5*time.Minute {
|
||||
for _, condition := range endpoint.Conditions {
|
||||
if condition.hasDomainExpirationPlaceholder() {
|
||||
return ErrInvalidEndpointIntervalForDomainExpirationPlaceholder
|
||||
}
|
||||
}
|
||||
}
|
||||
if endpoint.DNS != nil {
|
||||
return endpoint.DNS.validateAndSetDefault()
|
||||
}
|
||||
if endpoint.Type() == EndpointTypeUNKNOWN {
|
||||
return ErrUnknownEndpointType
|
||||
}
|
||||
// Make sure that the request can be created
|
||||
_, err := http.NewRequest(endpoint.Method, endpoint.URL, bytes.NewBuffer([]byte(endpoint.Body)))
|
||||
if err != nil {
|
||||
@@ -212,7 +234,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 {
|
||||
@@ -243,16 +284,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())
|
||||
@@ -261,6 +292,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
|
||||
@@ -309,7 +349,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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -336,7 +376,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() {
|
||||
@@ -345,3 +385,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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -24,53 +253,72 @@ func TestEndpoint_IsEnabled(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEndpoint_Type(t *testing.T) {
|
||||
type fields struct {
|
||||
type args struct {
|
||||
URL string
|
||||
DNS *DNS
|
||||
}
|
||||
tests := []struct {
|
||||
fields fields
|
||||
want EndpointType
|
||||
}{{
|
||||
fields: fields{
|
||||
URL: "1.1.1.1",
|
||||
DNS: &DNS{
|
||||
QueryType: "A",
|
||||
QueryName: "example.com",
|
||||
args args
|
||||
want EndpointType
|
||||
}{
|
||||
{
|
||||
args: args{
|
||||
URL: "1.1.1.1",
|
||||
DNS: &DNS{
|
||||
QueryType: "A",
|
||||
QueryName: "example.com",
|
||||
},
|
||||
},
|
||||
want: EndpointTypeDNS,
|
||||
},
|
||||
want: EndpointTypeDNS,
|
||||
}, {
|
||||
fields: fields{
|
||||
URL: "tcp://127.0.0.1:6379",
|
||||
{
|
||||
args: args{
|
||||
URL: "tcp://127.0.0.1:6379",
|
||||
},
|
||||
want: EndpointTypeTCP,
|
||||
},
|
||||
want: EndpointTypeTCP,
|
||||
}, {
|
||||
fields: fields{
|
||||
URL: "icmp://example.com",
|
||||
{
|
||||
args: args{
|
||||
URL: "icmp://example.com",
|
||||
},
|
||||
want: EndpointTypeICMP,
|
||||
},
|
||||
want: EndpointTypeICMP,
|
||||
}, {
|
||||
fields: fields{
|
||||
URL: "starttls://smtp.gmail.com:587",
|
||||
{
|
||||
args: args{
|
||||
URL: "starttls://smtp.gmail.com:587",
|
||||
},
|
||||
want: EndpointTypeSTARTTLS,
|
||||
},
|
||||
want: EndpointTypeSTARTTLS,
|
||||
}, {
|
||||
fields: fields{
|
||||
URL: "tls://example.com:443",
|
||||
{
|
||||
args: args{
|
||||
URL: "tls://example.com:443",
|
||||
},
|
||||
want: EndpointTypeTLS,
|
||||
},
|
||||
want: EndpointTypeTLS,
|
||||
}, {
|
||||
fields: fields{
|
||||
URL: "https://twin.sh/health",
|
||||
{
|
||||
args: args{
|
||||
URL: "https://twin.sh/health",
|
||||
},
|
||||
want: EndpointTypeHTTP,
|
||||
},
|
||||
want: EndpointTypeHTTP,
|
||||
}}
|
||||
{
|
||||
args: args{
|
||||
URL: "invalid://example.org",
|
||||
},
|
||||
want: EndpointTypeUNKNOWN,
|
||||
},
|
||||
{
|
||||
args: args{
|
||||
URL: "no-scheme",
|
||||
},
|
||||
want: EndpointTypeUNKNOWN,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.want), func(t *testing.T) {
|
||||
endpoint := Endpoint{
|
||||
URL: tt.fields.URL,
|
||||
DNS: tt.fields.DNS,
|
||||
URL: tt.args.URL,
|
||||
DNS: tt.args.DNS,
|
||||
}
|
||||
if got := endpoint.Type(); got != tt.want {
|
||||
t.Errorf("Endpoint.Type() = %v, want %v", got, tt.want)
|
||||
@@ -124,11 +372,10 @@ func TestEndpoint_ValidateAndSetDefaults(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEndpoint_ValidateAndSetDefaultsWithClientConfig(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
endpoint := Endpoint{
|
||||
Name: "website-health",
|
||||
URL: "https://twin.sh/health",
|
||||
Conditions: []Condition{condition},
|
||||
Conditions: []Condition{Condition("[STATUS] == 200")},
|
||||
ClientConfig: &client.Config{
|
||||
Insecure: true,
|
||||
IgnoreRedirect: true,
|
||||
@@ -151,51 +398,10 @@ func TestEndpoint_ValidateAndSetDefaultsWithClientConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_ValidateAndSetDefaultsWithNoName(t *testing.T) {
|
||||
defer func() { recover() }()
|
||||
condition := Condition("[STATUS] == 200")
|
||||
endpoint := &Endpoint{
|
||||
Name: "",
|
||||
URL: "http://example.com",
|
||||
Conditions: []Condition{condition},
|
||||
}
|
||||
err := endpoint.ValidateAndSetDefaults()
|
||||
if err == nil {
|
||||
t.Fatal("Should've returned an error because endpoint didn't have a name, which is a mandatory field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_ValidateAndSetDefaultsWithNoUrl(t *testing.T) {
|
||||
defer func() { recover() }()
|
||||
condition := Condition("[STATUS] == 200")
|
||||
endpoint := &Endpoint{
|
||||
Name: "example",
|
||||
URL: "",
|
||||
Conditions: []Condition{condition},
|
||||
}
|
||||
err := endpoint.ValidateAndSetDefaults()
|
||||
if err == nil {
|
||||
t.Fatal("Should've returned an error because endpoint didn't have an url, which is a mandatory field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_ValidateAndSetDefaultsWithNoConditions(t *testing.T) {
|
||||
defer func() { recover() }()
|
||||
endpoint := &Endpoint{
|
||||
Name: "example",
|
||||
URL: "http://example.com",
|
||||
Conditions: nil,
|
||||
}
|
||||
err := endpoint.ValidateAndSetDefaults()
|
||||
if err == nil {
|
||||
t.Fatal("Should've returned an error because endpoint didn't have at least 1 condition")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_ValidateAndSetDefaultsWithDNS(t *testing.T) {
|
||||
endpoint := &Endpoint{
|
||||
Name: "dns-test",
|
||||
URL: "http://example.com",
|
||||
URL: "https://example.com",
|
||||
DNS: &DNS{
|
||||
QueryType: "A",
|
||||
QueryName: "example.com",
|
||||
@@ -204,13 +410,70 @@ func TestEndpoint_ValidateAndSetDefaultsWithDNS(t *testing.T) {
|
||||
}
|
||||
err := endpoint.ValidateAndSetDefaults()
|
||||
if err != nil {
|
||||
|
||||
t.Error("did not expect an error, got", err)
|
||||
}
|
||||
if endpoint.DNS.QueryName != "example.com." {
|
||||
t.Error("Endpoint.dns.query-name should be formatted with . suffix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_ValidateAndSetDefaultsWithSimpleErrors(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
endpoint *Endpoint
|
||||
expectedErr error
|
||||
}{
|
||||
{
|
||||
endpoint: &Endpoint{
|
||||
Name: "",
|
||||
URL: "https://example.com",
|
||||
Conditions: []Condition{Condition("[STATUS] == 200")},
|
||||
},
|
||||
expectedErr: ErrEndpointWithNoName,
|
||||
},
|
||||
{
|
||||
endpoint: &Endpoint{
|
||||
Name: "endpoint-with-no-url",
|
||||
URL: "",
|
||||
Conditions: []Condition{Condition("[STATUS] == 200")},
|
||||
},
|
||||
expectedErr: ErrEndpointWithNoURL,
|
||||
},
|
||||
{
|
||||
endpoint: &Endpoint{
|
||||
Name: "endpoint-with-no-conditions",
|
||||
URL: "https://example.com",
|
||||
Conditions: nil,
|
||||
},
|
||||
expectedErr: ErrEndpointWithNoCondition,
|
||||
},
|
||||
{
|
||||
endpoint: &Endpoint{
|
||||
Name: "domain-expiration-with-bad-interval",
|
||||
URL: "https://example.com",
|
||||
Interval: time.Minute,
|
||||
Conditions: []Condition{Condition("[DOMAIN_EXPIRATION] > 720h")},
|
||||
},
|
||||
expectedErr: ErrInvalidEndpointIntervalForDomainExpirationPlaceholder,
|
||||
},
|
||||
{
|
||||
endpoint: &Endpoint{
|
||||
Name: "domain-expiration-with-good-interval",
|
||||
URL: "https://example.com",
|
||||
Interval: 5 * time.Minute,
|
||||
Conditions: []Condition{Condition("[DOMAIN_EXPIRATION] > 720h")},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.endpoint.Name, func(t *testing.T) {
|
||||
if err := scenario.endpoint.ValidateAndSetDefaults(); err != scenario.expectedErr {
|
||||
t.Errorf("Expected error %v, got %v", scenario.expectedErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_buildHTTPRequest(t *testing.T) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
endpoint := Endpoint{
|
||||
@@ -330,26 +593,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{
|
||||
@@ -370,32 +613,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",
|
||||
@@ -436,7 +653,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")
|
||||
@@ -447,16 +664,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")
|
||||
@@ -466,12 +682,20 @@ func TestIntegrationEvaluateHealthForICMP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_DisplayName(t *testing.T) {
|
||||
if endpoint := (Endpoint{Name: "n"}); endpoint.DisplayName() != "n" {
|
||||
t.Error("endpoint.DisplayName() should've been 'n', but was", endpoint.DisplayName())
|
||||
}
|
||||
if endpoint := (Endpoint{Group: "g", Name: "n"}); endpoint.DisplayName() != "g/n" {
|
||||
t.Error("endpoint.DisplayName() should've been 'g/n', but was", endpoint.DisplayName())
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -480,7 +704,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")
|
||||
@@ -503,3 +727,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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package core
|
||||
|
||||
// HealthStatus is the status of Gatus
|
||||
type HealthStatus struct {
|
||||
// Status is the state of Gatus (UP/DOWN)
|
||||
Status string `json:"status"`
|
||||
|
||||
// Message is an accompanying description of why the status is as reported.
|
||||
// If the Status is UP, no message will be provided
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
7
go.mod
7
go.mod
@@ -1,11 +1,12 @@
|
||||
module github.com/TwiN/gatus/v4
|
||||
|
||||
go 1.18
|
||||
go 1.19
|
||||
|
||||
require (
|
||||
github.com/TwiN/g8 v1.3.0
|
||||
github.com/TwiN/gocache/v2 v2.1.0
|
||||
github.com/TwiN/g8 v1.4.0
|
||||
github.com/TwiN/gocache/v2 v2.1.1
|
||||
github.com/TwiN/health v1.4.0
|
||||
github.com/TwiN/whois v1.0.0
|
||||
github.com/coreos/go-oidc/v3 v3.1.0
|
||||
github.com/go-ping/ping v0.0.0-20210911151512-381826476871
|
||||
github.com/google/uuid v1.3.0
|
||||
|
||||
10
go.sum
10
go.sum
@@ -33,12 +33,14 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/TwiN/g8 v1.3.0 h1:mNv3R35GhDn1gEV0BKMl1oupZ1tDtOWPTHUKu+W/k3U=
|
||||
github.com/TwiN/g8 v1.3.0/go.mod h1:SiIdItS0agSUloFqdQQt/RObB2jGSq+nnE9WfFv3RIo=
|
||||
github.com/TwiN/gocache/v2 v2.1.0 h1:AJnSX7Sgz22fsO7rdXYQzMQ4zWpMjBKqk70ADeqtLDU=
|
||||
github.com/TwiN/gocache/v2 v2.1.0/go.mod h1:AKHAFZSwLLmoLm1a2siDOWmZ2RjIKqentRGfOFWkllY=
|
||||
github.com/TwiN/g8 v1.4.0 h1:RUk5xTtxKCdMo0GGSbBVyjtAAfi2nqVbA9E0C4u5Cxo=
|
||||
github.com/TwiN/g8 v1.4.0/go.mod h1:ECyGJsoIb99klUfvVQoS1StgRLte9yvvPigGrHdy284=
|
||||
github.com/TwiN/gocache/v2 v2.1.1 h1:W/GLImqa+pZVIH9pcWEn1cBgy1KU66fUcBjOnPhjuno=
|
||||
github.com/TwiN/gocache/v2 v2.1.1/go.mod h1:SnUuBsrwGQeNcDG6vhkOMJnqErZM0JGjgIkuKryokYA=
|
||||
github.com/TwiN/health v1.4.0 h1:Ts7lb4ihYDpVEbFSGAhSEZTSwuDOADnwJLFngFl4xzw=
|
||||
github.com/TwiN/health v1.4.0/go.mod h1:CSUh+ryfD2POS2vKtc/yO4IxgR58lKvQ0/8qnoPqPqs=
|
||||
github.com/TwiN/whois v1.0.0 h1:I+aQzXLPmhWovkFUzlPV2DdfLZUWDLrkMDlM6QwCv+Q=
|
||||
github.com/TwiN/whois v1.0.0/go.mod h1:9WbCzYlR+r5eq9vbgJVh7A4H2uR2ct4wKEB0/QITJ/c=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
|
||||
@@ -5,8 +5,8 @@ func (s *Store) createPostgresSchema() error {
|
||||
CREATE TABLE IF NOT EXISTS endpoints (
|
||||
endpoint_id BIGSERIAL PRIMARY KEY,
|
||||
endpoint_key TEXT UNIQUE,
|
||||
endpoint_name TEXT,
|
||||
endpoint_group TEXT,
|
||||
endpoint_name TEXT NOT NULL,
|
||||
endpoint_group TEXT NOT NULL,
|
||||
UNIQUE(endpoint_name, endpoint_group)
|
||||
)
|
||||
`)
|
||||
@@ -16,9 +16,9 @@ func (s *Store) createPostgresSchema() error {
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS endpoint_events (
|
||||
endpoint_event_id BIGSERIAL PRIMARY KEY,
|
||||
endpoint_id INTEGER REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,
|
||||
event_type TEXT,
|
||||
event_timestamp TIMESTAMP
|
||||
endpoint_id INTEGER NOT NULL REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
event_timestamp TIMESTAMP NOT NULL
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -27,17 +27,18 @@ func (s *Store) createPostgresSchema() error {
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS endpoint_results (
|
||||
endpoint_result_id BIGSERIAL PRIMARY KEY,
|
||||
endpoint_id BIGINT REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,
|
||||
success BOOLEAN,
|
||||
errors TEXT,
|
||||
connected BOOLEAN,
|
||||
status BIGINT,
|
||||
dns_rcode TEXT,
|
||||
certificate_expiration BIGINT,
|
||||
hostname TEXT,
|
||||
ip TEXT,
|
||||
duration BIGINT,
|
||||
timestamp TIMESTAMP
|
||||
endpoint_id BIGINT NOT NULL REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,
|
||||
success BOOLEAN NOT NULL,
|
||||
errors TEXT NOT NULL,
|
||||
connected BOOLEAN NOT NULL,
|
||||
status BIGINT NOT NULL,
|
||||
dns_rcode TEXT NOT NULL,
|
||||
certificate_expiration BIGINT NOT NULL,
|
||||
domain_expiration BIGINT NOT NULL,
|
||||
hostname TEXT NOT NULL,
|
||||
ip TEXT NOT NULL,
|
||||
duration BIGINT NOT NULL,
|
||||
timestamp TIMESTAMP NOT NULL
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -46,9 +47,9 @@ func (s *Store) createPostgresSchema() error {
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS endpoint_result_conditions (
|
||||
endpoint_result_condition_id BIGSERIAL PRIMARY KEY,
|
||||
endpoint_result_id BIGINT REFERENCES endpoint_results(endpoint_result_id) ON DELETE CASCADE,
|
||||
condition TEXT,
|
||||
success BOOLEAN
|
||||
endpoint_result_id BIGINT NOT NULL REFERENCES endpoint_results(endpoint_result_id) ON DELETE CASCADE,
|
||||
condition TEXT NOT NULL,
|
||||
success BOOLEAN NOT NULL
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -57,13 +58,15 @@ func (s *Store) createPostgresSchema() error {
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS endpoint_uptimes (
|
||||
endpoint_uptime_id BIGSERIAL PRIMARY KEY,
|
||||
endpoint_id BIGINT REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,
|
||||
hour_unix_timestamp BIGINT,
|
||||
total_executions BIGINT,
|
||||
successful_executions BIGINT,
|
||||
total_response_time BIGINT,
|
||||
endpoint_id BIGINT NOT NULL REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,
|
||||
hour_unix_timestamp BIGINT NOT NULL,
|
||||
total_executions BIGINT NOT NULL,
|
||||
successful_executions BIGINT NOT NULL,
|
||||
total_response_time BIGINT NOT NULL,
|
||||
UNIQUE(endpoint_id, hour_unix_timestamp)
|
||||
)
|
||||
`)
|
||||
// Silent table modifications
|
||||
_, _ = s.db.Exec(`ALTER TABLE endpoint_results ADD IF NOT EXISTS domain_expiration BIGINT NOT NULL DEFAULT 0`)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ func (s *Store) createSQLiteSchema() error {
|
||||
CREATE TABLE IF NOT EXISTS endpoints (
|
||||
endpoint_id INTEGER PRIMARY KEY,
|
||||
endpoint_key TEXT UNIQUE,
|
||||
endpoint_name TEXT,
|
||||
endpoint_group TEXT,
|
||||
endpoint_name TEXT NOT NULL,
|
||||
endpoint_group TEXT NOT NULL,
|
||||
UNIQUE(endpoint_name, endpoint_group)
|
||||
)
|
||||
`)
|
||||
@@ -16,9 +16,9 @@ func (s *Store) createSQLiteSchema() error {
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS endpoint_events (
|
||||
endpoint_event_id INTEGER PRIMARY KEY,
|
||||
endpoint_id INTEGER REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,
|
||||
event_type TEXT,
|
||||
event_timestamp TIMESTAMP
|
||||
endpoint_id INTEGER NOT NULL REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
event_timestamp TIMESTAMP NOT NULL
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -27,17 +27,18 @@ func (s *Store) createSQLiteSchema() error {
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS endpoint_results (
|
||||
endpoint_result_id INTEGER PRIMARY KEY,
|
||||
endpoint_id INTEGER REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,
|
||||
success INTEGER,
|
||||
errors TEXT,
|
||||
connected INTEGER,
|
||||
status INTEGER,
|
||||
dns_rcode TEXT,
|
||||
certificate_expiration INTEGER,
|
||||
hostname TEXT,
|
||||
ip TEXT,
|
||||
duration INTEGER,
|
||||
timestamp TIMESTAMP
|
||||
endpoint_id INTEGER NOT NULL REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,
|
||||
success INTEGER NOT NULL,
|
||||
errors TEXT NOT NULL,
|
||||
connected INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
dns_rcode TEXT NOT NULL,
|
||||
certificate_expiration INTEGER NOT NULL,
|
||||
domain_expiration INTEGER NOT NULL,
|
||||
hostname TEXT NOT NULL,
|
||||
ip TEXT NOT NULL,
|
||||
duration INTEGER NOT NULL,
|
||||
timestamp TIMESTAMP NOT NULL
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -46,9 +47,9 @@ func (s *Store) createSQLiteSchema() error {
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS endpoint_result_conditions (
|
||||
endpoint_result_condition_id INTEGER PRIMARY KEY,
|
||||
endpoint_result_id INTEGER REFERENCES endpoint_results(endpoint_result_id) ON DELETE CASCADE,
|
||||
condition TEXT,
|
||||
success INTEGER
|
||||
endpoint_result_id INTEGER NOT NULL REFERENCES endpoint_results(endpoint_result_id) ON DELETE CASCADE,
|
||||
condition TEXT NOT NULL,
|
||||
success INTEGER NOT NULL
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -57,13 +58,15 @@ func (s *Store) createSQLiteSchema() error {
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS endpoint_uptimes (
|
||||
endpoint_uptime_id INTEGER PRIMARY KEY,
|
||||
endpoint_id INTEGER REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,
|
||||
hour_unix_timestamp INTEGER,
|
||||
total_executions INTEGER,
|
||||
successful_executions INTEGER,
|
||||
total_response_time INTEGER,
|
||||
endpoint_id INTEGER NOT NULL REFERENCES endpoints(endpoint_id) ON DELETE CASCADE,
|
||||
hour_unix_timestamp INTEGER NOT NULL,
|
||||
total_executions INTEGER NOT NULL,
|
||||
successful_executions INTEGER NOT NULL,
|
||||
total_response_time INTEGER NOT NULL,
|
||||
UNIQUE(endpoint_id, hour_unix_timestamp)
|
||||
)
|
||||
`)
|
||||
// Silent table modifications TODO: Remove this
|
||||
_, _ = s.db.Exec(`ALTER TABLE endpoint_results ADD domain_expiration INTEGER NOT NULL DEFAULT 0`)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -439,8 +439,8 @@ func (s *Store) insertEndpointResult(tx *sql.Tx, endpointID int64, result *core.
|
||||
var endpointResultID int64
|
||||
err := tx.QueryRow(
|
||||
`
|
||||
INSERT INTO endpoint_results (endpoint_id, success, errors, connected, status, dns_rcode, certificate_expiration, hostname, ip, duration, timestamp)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
INSERT INTO endpoint_results (endpoint_id, success, errors, connected, status, dns_rcode, certificate_expiration, domain_expiration, hostname, ip, duration, timestamp)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING endpoint_result_id
|
||||
`,
|
||||
endpointID,
|
||||
@@ -450,6 +450,7 @@ func (s *Store) insertEndpointResult(tx *sql.Tx, endpointID int64, result *core.
|
||||
result.HTTPStatus,
|
||||
result.DNSRCode,
|
||||
result.CertificateExpiration,
|
||||
result.DomainExpiration,
|
||||
result.Hostname,
|
||||
result.IP,
|
||||
result.Duration,
|
||||
@@ -590,7 +591,7 @@ func (s *Store) getEndpointEventsByEndpointID(tx *sql.Tx, endpointID int64, page
|
||||
func (s *Store) getEndpointResultsByEndpointID(tx *sql.Tx, endpointID int64, page, pageSize int) (results []*core.Result, err error) {
|
||||
rows, err := tx.Query(
|
||||
`
|
||||
SELECT endpoint_result_id, success, errors, connected, status, dns_rcode, certificate_expiration, hostname, ip, duration, timestamp
|
||||
SELECT endpoint_result_id, success, errors, connected, status, dns_rcode, certificate_expiration, domain_expiration, hostname, ip, duration, timestamp
|
||||
FROM endpoint_results
|
||||
WHERE endpoint_id = $1
|
||||
ORDER BY endpoint_result_id DESC -- Normally, we'd sort by timestamp, but sorting by endpoint_result_id is faster
|
||||
@@ -608,7 +609,11 @@ func (s *Store) getEndpointResultsByEndpointID(tx *sql.Tx, endpointID int64, pag
|
||||
result := &core.Result{}
|
||||
var id int64
|
||||
var joinedErrors string
|
||||
_ = rows.Scan(&id, &result.Success, &joinedErrors, &result.Connected, &result.HTTPStatus, &result.DNSRCode, &result.CertificateExpiration, &result.Hostname, &result.IP, &result.Duration, &result.Timestamp)
|
||||
err = rows.Scan(&id, &result.Success, &joinedErrors, &result.Connected, &result.HTTPStatus, &result.DNSRCode, &result.CertificateExpiration, &result.DomainExpiration, &result.Hostname, &result.IP, &result.Duration, &result.Timestamp)
|
||||
if err != nil {
|
||||
log.Printf("[sql][getEndpointResultsByEndpointID] Silently failed to retrieve endpoint result for endpointID=%d: %s", endpointID, err.Error())
|
||||
err = nil
|
||||
}
|
||||
if len(joinedErrors) != 0 {
|
||||
result.Errors = strings.Split(joinedErrors, arraySeparator)
|
||||
}
|
||||
@@ -848,16 +853,16 @@ func extractKeyAndParamsFromCacheKey(cacheKey string) (string, *paging.EndpointS
|
||||
params := &paging.EndpointStatusParams{}
|
||||
var err error
|
||||
if params.EventsPage, err = strconv.Atoi(parts[len(parts)-4]); err != nil {
|
||||
return "", nil, fmt.Errorf("invalid cache key: %s", err.Error())
|
||||
return "", nil, fmt.Errorf("invalid cache key: %w", err)
|
||||
}
|
||||
if params.EventsPageSize, err = strconv.Atoi(parts[len(parts)-3]); err != nil {
|
||||
return "", nil, fmt.Errorf("invalid cache key: %s", err.Error())
|
||||
return "", nil, fmt.Errorf("invalid cache key: %w", err)
|
||||
}
|
||||
if params.ResultsPage, err = strconv.Atoi(parts[len(parts)-2]); err != nil {
|
||||
return "", nil, fmt.Errorf("invalid cache key: %s", err.Error())
|
||||
return "", nil, fmt.Errorf("invalid cache key: %w", err)
|
||||
}
|
||||
if params.ResultsPageSize, err = strconv.Atoi(parts[len(parts)-1]); err != nil {
|
||||
return "", nil, fmt.Errorf("invalid cache key: %s", err.Error())
|
||||
return "", nil, fmt.Errorf("invalid cache key: %w", err)
|
||||
}
|
||||
return strings.Join(parts[:len(parts)-4], "-"), params, nil
|
||||
}
|
||||
|
||||
21
vendor/github.com/TwiN/g8/LICENSE
generated
vendored
Normal file
21
vendor/github.com/TwiN/g8/LICENSE
generated
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 TwiN
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
9
vendor/github.com/TwiN/g8/LICENSE.md
generated
vendored
9
vendor/github.com/TwiN/g8/LICENSE.md
generated
vendored
@@ -1,9 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 TwiN
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
55
vendor/github.com/TwiN/g8/README.md
generated
vendored
55
vendor/github.com/TwiN/g8/README.md
generated
vendored
@@ -1,6 +1,6 @@
|
||||
# g8
|
||||
|
||||

|
||||

|
||||
[](https://goreportcard.com/report/github.com/TwiN/g8)
|
||||
[](https://codecov.io/gh/TwiN/g8)
|
||||
[](https://github.com/TwiN/g8)
|
||||
@@ -177,13 +177,42 @@ have the `backup` permission:
|
||||
router.Handle("/backup", gate.ProtectWithPermissions(&testHandler{}, []string{"read", "backup"}))
|
||||
```
|
||||
|
||||
If you're using an HTTP library that supports middlewares like [mux](https://github.com/gorilla/mux), you can protect
|
||||
an entire group of handlers instead using `gate.Protect` or `gate.PermissionMiddleware()`:
|
||||
```go
|
||||
router := mux.NewRouter()
|
||||
|
||||
userRouter := router.PathPrefix("/").Subrouter()
|
||||
userRouter.Use(gate.Protect)
|
||||
userRouter.HandleFunc("/api/v1/users/me", getUserProfile).Methods("GET")
|
||||
userRouter.HandleFunc("/api/v1/users/me/friends", getUserFriends).Methods("GET")
|
||||
userRouter.HandleFunc("/api/v1/users/me/email", updateUserEmail).Methods("PATCH")
|
||||
|
||||
adminRouter := router.PathPrefix("/").Subrouter()
|
||||
adminRouter.Use(gate.PermissionMiddleware("admin"))
|
||||
adminRouter.HandleFunc("/api/v1/users/{id}/ban", banUserByID).Methods("POST")
|
||||
adminRouter.HandleFunc("/api/v1/users/{id}/delete", deleteUserByID).Methods("DELETE")
|
||||
```
|
||||
|
||||
|
||||
## Rate limiting
|
||||
To add a rate limit of 100 requests per second:
|
||||
```
|
||||
```go
|
||||
gate := g8.New().WithRateLimit(100)
|
||||
```
|
||||
|
||||
## Special use cases
|
||||
|
||||
## Accessing the token from the protected handlers
|
||||
If you need to access the token from the handlers you are protecting with g8, you can retrieve it from the
|
||||
request context by using the key `g8.TokenContextKey`:
|
||||
```go
|
||||
http.Handle("/handle", gate.ProtectFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token, _ := r.Context().Value(g8.TokenContextKey).(string)
|
||||
// ...
|
||||
}))
|
||||
```
|
||||
|
||||
## Examples
|
||||
### Protecting a handler using session cookie
|
||||
If you want to only allow authenticated users to access a handler, you can use a custom token extractor function
|
||||
combined with a client provider.
|
||||
@@ -236,3 +265,23 @@ http.Handle("/handle", gate.ProtectFunc(func(w http.ResponseWriter, r *http.Requ
|
||||
// ...
|
||||
}))
|
||||
```
|
||||
|
||||
### Using a custom header
|
||||
The logic is the same as the example above:
|
||||
```go
|
||||
customTokenExtractorFunc := func(request *http.Request) string {
|
||||
return request.Header.Get("X-API-Token")
|
||||
}
|
||||
|
||||
clientProvider := g8.NewClientProvider(func(token string) *g8.Client {
|
||||
// We'll assume that the following function calls your database and returns a struct "User" that
|
||||
// has the user's token as well as the permissions granted to said user
|
||||
user := database.GetUserByToken(token)
|
||||
if user != nil {
|
||||
return g8.NewClient(user.Token).WithPermissions(user.Permissions)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
authorizationService := g8.NewAuthorizationService().WithClientProvider(clientProvider)
|
||||
gate := g8.New().WithAuthorizationService(authorizationService).WithCustomTokenExtractor(customTokenExtractorFunc)
|
||||
```
|
||||
|
||||
89
vendor/github.com/TwiN/g8/gate.go
generated
vendored
89
vendor/github.com/TwiN/g8/gate.go
generated
vendored
@@ -66,15 +66,16 @@ func (gate *Gate) WithCustomUnauthorizedResponseBody(unauthorizedResponseBody []
|
||||
// If a custom token extractor is not specified, the token will be extracted from the Authorization header.
|
||||
//
|
||||
// For instance, if you're using a session cookie, you can extract the token from the cookie like so:
|
||||
// authorizationService := g8.NewAuthorizationService()
|
||||
// customTokenExtractorFunc := func(request *http.Request) string {
|
||||
// sessionCookie, err := request.Cookie("session")
|
||||
// if err != nil {
|
||||
// return ""
|
||||
// }
|
||||
// return sessionCookie.Value
|
||||
// }
|
||||
// gate := g8.New().WithAuthorizationService(authorizationService).WithCustomTokenExtractor(customTokenExtractorFunc)
|
||||
//
|
||||
// authorizationService := g8.NewAuthorizationService()
|
||||
// customTokenExtractorFunc := func(request *http.Request) string {
|
||||
// sessionCookie, err := request.Cookie("session")
|
||||
// if err != nil {
|
||||
// return ""
|
||||
// }
|
||||
// return sessionCookie.Value
|
||||
// }
|
||||
// gate := g8.New().WithAuthorizationService(authorizationService).WithCustomTokenExtractor(customTokenExtractorFunc)
|
||||
//
|
||||
// You would normally use this with a client provider that matches whatever need you have.
|
||||
// For example, if you're using a session cookie, your client provider would retrieve the user from the session ID
|
||||
@@ -90,8 +91,8 @@ func (gate *Gate) WithCustomTokenExtractor(customTokenExtractorFunc func(request
|
||||
// WithRateLimit adds rate limiting to the Gate
|
||||
//
|
||||
// If you just want to use a gate for rate limiting purposes:
|
||||
// gate := g8.New().WithRateLimit(50)
|
||||
//
|
||||
// gate := g8.New().WithRateLimit(50)
|
||||
func (gate *Gate) WithRateLimit(maximumRequestsPerSecond int) *Gate {
|
||||
gate.rateLimiter = NewRateLimiter(maximumRequestsPerSecond)
|
||||
return gate
|
||||
@@ -102,12 +103,13 @@ func (gate *Gate) WithRateLimit(maximumRequestsPerSecond int) *Gate {
|
||||
// or lack thereof.
|
||||
//
|
||||
// Example:
|
||||
// gate := g8.New().WithAuthorizationService(g8.NewAuthorizationService().WithToken("token"))
|
||||
// router := http.NewServeMux()
|
||||
// // Without protection
|
||||
// router.Handle("/handle", yourHandler)
|
||||
// // With protection
|
||||
// router.Handle("/handle", gate.Protect(yourHandler))
|
||||
//
|
||||
// gate := g8.New().WithAuthorizationService(g8.NewAuthorizationService().WithToken("token"))
|
||||
// router := http.NewServeMux()
|
||||
// // Without protection
|
||||
// router.Handle("/handle", yourHandler)
|
||||
// // With protection
|
||||
// router.Handle("/handle", gate.Protect(yourHandler))
|
||||
//
|
||||
// The token extracted from the request is passed to the handlerFunc request context under the key TokenContextKey
|
||||
func (gate *Gate) Protect(handler http.Handler) http.Handler {
|
||||
@@ -118,12 +120,13 @@ func (gate *Gate) Protect(handler http.Handler) http.Handler {
|
||||
// as well as a slice of permissions that must be met.
|
||||
//
|
||||
// Example:
|
||||
// gate := g8.New().WithAuthorizationService(g8.NewAuthorizationService().WithClient(g8.NewClient("token").WithPermission("admin")))
|
||||
// router := http.NewServeMux()
|
||||
// // Without protection
|
||||
// router.Handle("/handle", yourHandler)
|
||||
// // With protection
|
||||
// router.Handle("/handle", gate.ProtectWithPermissions(yourHandler, []string{"admin"}))
|
||||
//
|
||||
// gate := g8.New().WithAuthorizationService(g8.NewAuthorizationService().WithClient(g8.NewClient("token").WithPermission("ADMIN")))
|
||||
// router := http.NewServeMux()
|
||||
// // Without protection
|
||||
// router.Handle("/handle", yourHandler)
|
||||
// // With protection
|
||||
// router.Handle("/handle", gate.ProtectWithPermissions(yourHandler, []string{"admin"}))
|
||||
//
|
||||
// The token extracted from the request is passed to the handlerFunc request context under the key TokenContextKey
|
||||
func (gate *Gate) ProtectWithPermissions(handler http.Handler, permissions []string) http.Handler {
|
||||
@@ -147,12 +150,13 @@ func (gate *Gate) ProtectWithPermission(handler http.Handler, permission string)
|
||||
// permissions or lack thereof.
|
||||
//
|
||||
// Example:
|
||||
// gate := g8.New().WithAuthorizationService(g8.NewAuthorizationService().WithToken("token"))
|
||||
// router := http.NewServeMux()
|
||||
// // Without protection
|
||||
// router.HandleFunc("/handle", yourHandlerFunc)
|
||||
// // With protection
|
||||
// router.HandleFunc("/handle", gate.ProtectFunc(yourHandlerFunc))
|
||||
//
|
||||
// gate := g8.New().WithAuthorizationService(g8.NewAuthorizationService().WithToken("token"))
|
||||
// router := http.NewServeMux()
|
||||
// // Without protection
|
||||
// router.HandleFunc("/handle", yourHandlerFunc)
|
||||
// // With protection
|
||||
// router.HandleFunc("/handle", gate.ProtectFunc(yourHandlerFunc))
|
||||
//
|
||||
// The token extracted from the request is passed to the handlerFunc request context under the key TokenContextKey
|
||||
func (gate *Gate) ProtectFunc(handlerFunc http.HandlerFunc) http.HandlerFunc {
|
||||
@@ -163,12 +167,13 @@ func (gate *Gate) ProtectFunc(handlerFunc http.HandlerFunc) http.HandlerFunc {
|
||||
// token as well as a slice of permissions that must be met.
|
||||
//
|
||||
// Example:
|
||||
// gate := g8.New().WithAuthorizationService(g8.NewAuthorizationService().WithClient(g8.NewClient("token").WithPermission("admin")))
|
||||
// router := http.NewServeMux()
|
||||
// // Without protection
|
||||
// router.HandleFunc("/handle", yourHandlerFunc)
|
||||
// // With protection
|
||||
// router.HandleFunc("/handle", gate.ProtectFuncWithPermissions(yourHandlerFunc, []string{"admin"}))
|
||||
//
|
||||
// gate := g8.New().WithAuthorizationService(g8.NewAuthorizationService().WithClient(g8.NewClient("token").WithPermission("admin")))
|
||||
// router := http.NewServeMux()
|
||||
// // Without protection
|
||||
// router.HandleFunc("/handle", yourHandlerFunc)
|
||||
// // With protection
|
||||
// router.HandleFunc("/handle", gate.ProtectFuncWithPermissions(yourHandlerFunc, []string{"admin"}))
|
||||
//
|
||||
// The token extracted from the request is passed to the handlerFunc request context under the key TokenContextKey
|
||||
func (gate *Gate) ProtectFuncWithPermissions(handlerFunc http.HandlerFunc, permissions []string) http.HandlerFunc {
|
||||
@@ -215,3 +220,19 @@ func (gate *Gate) ExtractTokenFromRequest(request *http.Request) string {
|
||||
}
|
||||
return strings.TrimPrefix(request.Header.Get(AuthorizationHeader), "Bearer ")
|
||||
}
|
||||
|
||||
// PermissionMiddleware is a middleware that behaves like ProtectWithPermission, but it is meant to be used
|
||||
// as a middleware for libraries that support such a feature.
|
||||
//
|
||||
// For instance, if you are using github.com/gorilla/mux, you can use PermissionMiddleware like so:
|
||||
//
|
||||
// router := mux.NewRouter()
|
||||
// router.Use(gate.PermissionMiddleware("admin"))
|
||||
// router.Handle("/admin/handle", adminHandler)
|
||||
//
|
||||
// If you do not want to protect a router with a specific permission, you can use Gate.Protect instead.
|
||||
func (gate *Gate) PermissionMiddleware(permissions ...string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return gate.ProtectWithPermissions(next, permissions)
|
||||
}
|
||||
}
|
||||
|
||||
2
vendor/github.com/TwiN/whois/.gitignore
generated
vendored
Normal file
2
vendor/github.com/TwiN/whois/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.idea
|
||||
bin
|
||||
21
vendor/github.com/TwiN/whois/LICENSE
generated
vendored
Normal file
21
vendor/github.com/TwiN/whois/LICENSE
generated
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 TwiN
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
4
vendor/github.com/TwiN/whois/Makefile
generated
vendored
Normal file
4
vendor/github.com/TwiN/whois/Makefile
generated
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.PHONY: build-binaries
|
||||
|
||||
build-binaries:
|
||||
./scripts/build.sh
|
||||
65
vendor/github.com/TwiN/whois/README.md
generated
vendored
Normal file
65
vendor/github.com/TwiN/whois/README.md
generated
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
# whois
|
||||

|
||||
|
||||
Lightweight library for retrieving WHOIS information on a domain.
|
||||
|
||||
It automatically retrieves the appropriate WHOIS server based on the domain's TLD by first querying IANA.
|
||||
|
||||
|
||||
## Usage
|
||||
### As an executable
|
||||
To install it:
|
||||
```console
|
||||
go install github.com/TwiN/whois/cmd/whois@latest
|
||||
```
|
||||
To run it:
|
||||
```console
|
||||
whois example.com
|
||||
```
|
||||
|
||||
### As a library
|
||||
```console
|
||||
go get github.com/TwiN/whois
|
||||
```
|
||||
|
||||
#### Query
|
||||
If all you want is the text a WHOIS server would return you, you can use the `Query` method of the `whois.Client` type:
|
||||
```go
|
||||
package main
|
||||
|
||||
import "github.com/TwiN/whois"
|
||||
|
||||
func main() {
|
||||
client := whois.NewClient()
|
||||
output, err := client.Query("example.com")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
println(output)
|
||||
}
|
||||
```
|
||||
|
||||
#### QueryAndParse
|
||||
If you want specific pieces of information, you can use the `QueryAndParse` method of the `whois.Client` type:
|
||||
```go
|
||||
package main
|
||||
|
||||
import "github.com/TwiN/whois"
|
||||
|
||||
func main() {
|
||||
client := whois.NewClient()
|
||||
response, err := client.QueryAndParse("example.com")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
println(response.ExpirationDate.String())
|
||||
}
|
||||
```
|
||||
Note that because there is no standardized format for WHOIS responses, this parsing may not be successful for every single TLD.
|
||||
|
||||
Currently, the only fields parsed are:
|
||||
- `ExpirationDate`: The time.Time at which the domain will expire
|
||||
- `DomainStatuses`: The statuses that the domain currently has (e.g. `clientTransferProhibited`)
|
||||
- `NameServers`: The nameservers currently tied to the domain
|
||||
|
||||
If you'd like one or more other fields to be parsed, please don't be shy and create an issue or a pull request.
|
||||
94
vendor/github.com/TwiN/whois/whois.go
generated
vendored
Normal file
94
vendor/github.com/TwiN/whois/whois.go
generated
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
package whois
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ianaWHOISServerAddress = "whois.iana.org:43"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
whoisServerAddress string
|
||||
}
|
||||
|
||||
func NewClient() *Client {
|
||||
return &Client{
|
||||
whoisServerAddress: ianaWHOISServerAddress,
|
||||
}
|
||||
}
|
||||
|
||||
func (c Client) Query(domain string) (string, error) {
|
||||
parts := strings.Split(domain, ".")
|
||||
output, err := c.query(c.whoisServerAddress, parts[len(parts)-1])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.Contains(output, "whois:") {
|
||||
startIndex := strings.Index(output, "whois:") + 6
|
||||
endIndex := strings.Index(output[startIndex:], "\n") + startIndex
|
||||
whois := strings.TrimSpace(output[startIndex:endIndex])
|
||||
if referOutput, err := c.query(whois+":43", domain); err == nil {
|
||||
return referOutput, nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func (c Client) query(whoisServerAddress, domain string) (string, error) {
|
||||
connection, err := net.DialTimeout("tcp", whoisServerAddress, 10*time.Second)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer connection.Close()
|
||||
connection.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
_, err = connection.Write([]byte(domain + "\r\n"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
output, err := io.ReadAll(connection)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
ExpirationDate time.Time
|
||||
DomainStatuses []string
|
||||
NameServers []string
|
||||
}
|
||||
|
||||
// QueryAndParse tries to parse the response from the WHOIS server
|
||||
// There is no standardized format for WHOIS responses, so this is an attempt at best.
|
||||
//
|
||||
// Being the selfish person that I am, I also only parse the fields that I need.
|
||||
// If you need more fields, please open an issue or pull request.
|
||||
func (c Client) QueryAndParse(domain string) (*Response, error) {
|
||||
text, err := c.Query(domain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := Response{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
valueStartIndex := strings.Index(line, ":")
|
||||
if valueStartIndex == -1 {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(line[:valueStartIndex]))
|
||||
value := strings.TrimSpace(line[valueStartIndex+1:])
|
||||
if response.ExpirationDate.Unix() != 0 && strings.Contains(key, "expir") && strings.Contains(key, "date") {
|
||||
response.ExpirationDate, _ = time.Parse(time.RFC3339, strings.ToUpper(value))
|
||||
} else if strings.Contains(key, "domain status") {
|
||||
response.DomainStatuses = append(response.DomainStatuses, value)
|
||||
} else if strings.Contains(key, "name server") {
|
||||
response.NameServers = append(response.NameServers, value)
|
||||
}
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
11
vendor/modules.txt
vendored
11
vendor/modules.txt
vendored
@@ -1,12 +1,15 @@
|
||||
# github.com/TwiN/g8 v1.3.0
|
||||
## explicit; go 1.17
|
||||
# github.com/TwiN/g8 v1.4.0
|
||||
## explicit; go 1.19
|
||||
github.com/TwiN/g8
|
||||
# github.com/TwiN/gocache/v2 v2.1.0
|
||||
## explicit; go 1.18
|
||||
# github.com/TwiN/gocache/v2 v2.1.1
|
||||
## explicit; go 1.19
|
||||
github.com/TwiN/gocache/v2
|
||||
# github.com/TwiN/health v1.4.0
|
||||
## explicit; go 1.18
|
||||
github.com/TwiN/health
|
||||
# github.com/TwiN/whois v1.0.0
|
||||
## explicit; go 1.19
|
||||
github.com/TwiN/whois
|
||||
# github.com/beorn7/perks v1.0.1
|
||||
## explicit; go 1.11
|
||||
github.com/beorn7/perks/quantile
|
||||
|
||||
@@ -29,12 +29,12 @@
|
||||
</h2>
|
||||
<div class="mt-8 py-7 px-4 rounded-sm sm:bg-gray-100 sm:border sm:border-gray-300 sm:shadow-2xl sm:px-10">
|
||||
<div class="sm:mx-auto sm:w-full">
|
||||
<h2 class="mb-4 text-center text-xl font-bold text-gray-600 dark:text-gray-200 dark:sm:text-gray-600 ">
|
||||
<h2 class="mb-3 text-center text-xl font-bold text-gray-600 dark:text-gray-200 dark:sm:text-gray-600 ">
|
||||
Sign in
|
||||
</h2>
|
||||
</div>
|
||||
<div v-if="$route && $route.query.error" class="text-red-500 text-center my-2">
|
||||
<div class="text-xl">
|
||||
<div v-if="$route && $route.query.error" class="text-red-500 text-center mb-5">
|
||||
<div class="text-sm">
|
||||
<span class="text-red-500" v-if="$route.query.error === 'access_denied'">You do not have access to this status page</span>
|
||||
<span class="text-red-500" v-else>{{ $route.query.error }}</span>
|
||||
</div>
|
||||
|
||||
13
web/static.go
Normal file
13
web/static.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package static
|
||||
|
||||
import "embed"
|
||||
|
||||
var (
|
||||
//go:embed static
|
||||
FileSystem embed.FS
|
||||
)
|
||||
|
||||
const (
|
||||
RootPath = "static"
|
||||
IndexPath = RootPath + "/index.html"
|
||||
)
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
74
web/static_test.go
Normal file
74
web/static_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package static
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEmbed(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
path string
|
||||
shouldExist bool
|
||||
expectedContainString string
|
||||
}{
|
||||
{
|
||||
path: "index.html",
|
||||
shouldExist: true,
|
||||
expectedContainString: "</body>",
|
||||
},
|
||||
{
|
||||
path: "favicon.ico",
|
||||
shouldExist: true,
|
||||
expectedContainString: "", // not checking because it's an image
|
||||
},
|
||||
{
|
||||
path: "img/logo.svg",
|
||||
shouldExist: true,
|
||||
expectedContainString: "</svg>",
|
||||
},
|
||||
{
|
||||
path: "css/app.css",
|
||||
shouldExist: true,
|
||||
expectedContainString: "background-color",
|
||||
},
|
||||
{
|
||||
path: "js/app.js",
|
||||
shouldExist: true,
|
||||
expectedContainString: "function",
|
||||
},
|
||||
{
|
||||
path: "js/chunk-vendors.js",
|
||||
shouldExist: true,
|
||||
expectedContainString: "function",
|
||||
},
|
||||
{
|
||||
path: "file-that-does-not-exist.html",
|
||||
shouldExist: false,
|
||||
},
|
||||
}
|
||||
staticFileSystem, err := fs.Sub(FileSystem, RootPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.path, func(t *testing.T) {
|
||||
content, err := fs.ReadFile(staticFileSystem, scenario.path)
|
||||
if !scenario.shouldExist {
|
||||
if err == nil {
|
||||
t.Errorf("%s should not have existed", scenario.path)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("opening %s should not have returned an error, got %s", scenario.path, err.Error())
|
||||
}
|
||||
if len(content) == 0 {
|
||||
t.Errorf("%s should have existed in the static FileSystem, but was empty", scenario.path)
|
||||
}
|
||||
if !strings.Contains(string(content), scenario.expectedContainString) {
|
||||
t.Errorf("%s should have contained %s, but did not", scenario.path, scenario.expectedContainString)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user