Compare commits

..

12 Commits

Author SHA1 Message Date
TwiN
bd296c75da chore: Export validation function (#1301) 2025-09-29 23:01:27 -04:00
TwiN
f007725140 fix(ui): Make sure EndpointCard aligns even if no group + hide-hostname (#1300) 2025-09-29 22:55:11 -04:00
TwiN
40345a03d3 feat(client): Add support for SSH tunneling (#1298)
* feat(client): Add support for SSH tunneling

* Fix test
2025-09-28 14:26:12 -04:00
Rahul Chordiya
97a2be3504 fix(alerting): Added description block in teams-workflows (#1275)
* fix(alerting): Added description block in teams-workflows

* Update teamsworkflows_test.go

---------

Co-authored-by: TwiN <twin@linux.com>
2025-09-25 16:28:22 -04:00
TwiN
15a4133502 fix(alerting): Limit minimum-reminder-interval to >5m (#1290) 2025-09-25 16:24:15 -04:00
Ron
64a5043655 docs(alerting): Remove SIGNL4 untested warning (#1289)
Update README.md

SIGNL4 warning removed. I have tested it and both, triggering and resolving of alerts work fine.
2025-09-24 06:33:57 -04:00
TwiN
5a06a74cc3 fix(events): Retrieve newest events instead of oldest events (#1283)
Fixes #1040
2025-09-21 15:40:17 -04:00
TwiN
d6fa2c955b fix(suites): Handle invalid paths in store and update needsToReadBody to check store (#1282)
* fix(suites): Invalid path in store parameter should return an error

* Refactor

* fix(suites): Update needsToReadBody to check store mappings for body placeholders
2025-09-21 13:15:59 -04:00
mehdiMj
e6576e9080 fix(alerting): Support custom slack title (#1079) 2025-09-20 20:21:46 -04:00
TwiN
cd10b31ab5 fix(condition): Properly format conditions with invalid context placeholders (#1281) 2025-09-20 19:28:27 -04:00
dependabot[bot]
d1ef0b72a4 chore(deps): bump golang.org/x/sync from 0.16.0 to 0.17.0 (#1269)
Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.16.0 to 0.17.0.
- [Commits](https://github.com/golang/sync/compare/v0.16.0...v0.17.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sync
  dependency-version: 0.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-20 12:22:13 -04:00
TwiN
327a39964d fix(security): Make OIDC session TTL configurable (#1280)
* fix(security): Increase session cookie from 1h to 8h

* fix(security): Make OIDC session TTL configurable

* revert accidental change
2025-09-20 07:29:25 -04:00
34 changed files with 1406 additions and 148 deletions

121
README.md
View File

@@ -51,6 +51,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Functions](#functions)
- [Storage](#storage)
- [Client configuration](#client-configuration)
- [Tunneling](#tunneling)
- [Alerting](#alerting)
- [Configuring AWS SES alerts](#configuring-aws-ses-alerts)
- [Configuring Datadog alerts](#configuring-datadog-alerts)
@@ -597,24 +598,25 @@ See [examples/docker-compose-postgres-storage](.examples/docker-compose-postgres
In order to support a wide range of environments, each monitored endpoint has a unique configuration for
the client used to send the request.
| Parameter | Description | Default |
|:---------------------------------------|:----------------------------------------------------------------------------|:----------------|
| `client.insecure` | Whether to skip verifying the server's certificate chain and host name. | `false` |
| `client.ignore-redirect` | Whether to ignore redirects (true) or follow them (false, default). | `false` |
| `client.timeout` | Duration before timing out. | `10s` |
| `client.dns-resolver` | Override the DNS resolver using the format `{proto}://{host}:{port}`. | `""` |
| `client.oauth2` | OAuth2 client configuration. | `{}` |
| `client.oauth2.token-url` | The token endpoint URL | required `""` |
| `client.oauth2.client-id` | The client id which should be used for the `Client credentials flow` | required `""` |
| `client.oauth2.client-secret` | The client secret which should be used for the `Client credentials flow` | required `""` |
| `client.oauth2.scopes[]` | A list of `scopes` which should be used for the `Client credentials flow`. | required `[""]` |
| `client.proxy-url` | The URL of the proxy to use for the client | `""` |
| `client.identity-aware-proxy` | Google Identity-Aware-Proxy client configuration. | `{}` |
| `client.identity-aware-proxy.audience` | The Identity-Aware-Proxy audience. (client-id of the IAP oauth2 credential) | required `""` |
| `client.tls.certificate-file` | Path to a client certificate (in PEM format) for mTLS configurations. | `""` |
| `client.tls.private-key-file` | Path to a client private key (in PEM format) for mTLS configurations. | `""` |
| `client.tls.renegotiation` | Type of renegotiation support to provide. (`never`, `freely`, `once`). | `"never"` |
| `client.network` | The network to use for ICMP endpoint client (`ip`, `ip4` or `ip6`). | `"ip"` |
| Parameter | Description | Default |
|:---------------------------------------|:------------------------------------------------------------------------------|:----------------|
| `client.insecure` | Whether to skip verifying the server's certificate chain and host name. | `false` |
| `client.ignore-redirect` | Whether to ignore redirects (true) or follow them (false, default). | `false` |
| `client.timeout` | Duration before timing out. | `10s` |
| `client.dns-resolver` | Override the DNS resolver using the format `{proto}://{host}:{port}`. | `""` |
| `client.oauth2` | OAuth2 client configuration. | `{}` |
| `client.oauth2.token-url` | The token endpoint URL | required `""` |
| `client.oauth2.client-id` | The client id which should be used for the `Client credentials flow` | required `""` |
| `client.oauth2.client-secret` | The client secret which should be used for the `Client credentials flow` | required `""` |
| `client.oauth2.scopes[]` | A list of `scopes` which should be used for the `Client credentials flow`. | required `[""]` |
| `client.proxy-url` | The URL of the proxy to use for the client | `""` |
| `client.identity-aware-proxy` | Google Identity-Aware-Proxy client configuration. | `{}` |
| `client.identity-aware-proxy.audience` | The Identity-Aware-Proxy audience. (client-id of the IAP oauth2 credential) | required `""` |
| `client.tls.certificate-file` | Path to a client certificate (in PEM format) for mTLS configurations. | `""` |
| `client.tls.private-key-file` | Path to a client private key (in PEM format) for mTLS configurations. | `""` |
| `client.tls.renegotiation` | Type of renegotiation support to provide. (`never`, `freely`, `once`). | `"never"` |
| `client.network` | The network to use for ICMP endpoint client (`ip`, `ip4` or `ip6`). | `"ip"` |
| `client.tunnel` | Name of the SSH tunnel to use for this endpoint. See [Tunneling](#tunneling). | `""` |
> 📝 Some of these parameters are ignored based on the type of endpoint. For instance, there's no certificate involved
@@ -705,23 +707,62 @@ endpoints:
> 📝 Note that if running in a container, you must volume mount the certificate and key into the container.
### Tunneling
Gatus supports SSH tunneling to monitor internal services through jump hosts or bastion servers.
This is particularly useful for monitoring services that are not directly accessible from where Gatus is deployed.
SSH tunnels are defined globally in the `tunneling` section and then referenced by name in endpoint client configurations.
| Parameter | Description | Default |
|:--------------------------------------|:------------------------------------------------------------|:--------------|
| `tunneling` | SSH tunnel configurations | `{}` |
| `tunneling.<tunnel-name>` | Configuration for a named SSH tunnel | `{}` |
| `tunneling.<tunnel-name>.type` | Type of tunnel (currently only `SSH` is supported) | Required `""` |
| `tunneling.<tunnel-name>.host` | SSH server hostname or IP address | Required `""` |
| `tunneling.<tunnel-name>.port` | SSH server port | `22` |
| `tunneling.<tunnel-name>.username` | SSH username | Required `""` |
| `tunneling.<tunnel-name>.password` | SSH password (use either this or private-key) | `""` |
| `tunneling.<tunnel-name>.private-key` | SSH private key in PEM format (use either this or password) | `""` |
| `client.tunnel` | Name of the tunnel to use for this endpoint | `""` |
```yaml
tunneling:
production:
type: SSH
host: "jumphost.example.com"
username: "monitoring"
private-key: |
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA...
-----END RSA PRIVATE KEY-----
endpoints:
- name: "internal-api"
url: "http://internal-api.example.com:8080/health"
client:
tunnel: "production"
conditions:
- "[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.
Alerts are configured at the endpoint level like so:
| Parameter | Description | Default |
|:-------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------|:--------------|
| `alerts` | List of all alerts for a given endpoint. | `[]` |
| `alerts[].type` | Type of alert. <br />See table below for all valid types. | Required `""` |
| `alerts[].enabled` | Whether to enable the alert. | `true` |
| `alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert. | `3` |
| `alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` |
| `alerts[].minimum-reminder-interval` | Minimum time interval between alert reminders. E.g. `"30m"`, `"1h45m30s"` or `"24h"`. If empty or `0`, reminders are disabled. | `0` |
| `alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved. | `false` |
| `alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` |
| `alerts[].provider-override` | Alerting provider configuration override for the given alert type | `{}` |
| Parameter | Description | Default |
|:-------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------|:--------------|
| `alerts` | List of all alerts for a given endpoint. | `[]` |
| `alerts[].type` | Type of alert. <br />See table below for all valid types. | Required `""` |
| `alerts[].enabled` | Whether to enable the alert. | `true` |
| `alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert. | `3` |
| `alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` |
| `alerts[].minimum-reminder-interval` | Minimum time interval between alert reminders. E.g. `"30m"`, `"1h45m30s"` or `"24h"`. If empty or `0`, reminders are disabled. Cannot be lower than `5m`. | `0` |
| `alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved. | `false` |
| `alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` |
| `alerts[].provider-override` | Alerting provider configuration override for the given alert type | `{}` |
Here's an example of what an alert configuration might look like at the endpoint level:
```yaml
@@ -1837,8 +1878,6 @@ endpoints:
#### Configuring SIGNL4 alerts
> ⚠️ **WARNING**: This alerting provider has not been tested yet. If you've tested it and confirmed that it works, please remove this warning and create a pull request, or comment on [#1223](https://github.com/TwiN/gatus/discussions/1223) with whether the provider works as intended. Thank you for your cooperation.
SIGNL4 is a mobile alerting and incident management service that sends critical alerts to team members via mobile push, SMS, voice calls, and email.
| Parameter | Description | Default |
@@ -1902,14 +1941,15 @@ endpoints:
#### Configuring Slack alerts
| Parameter | Description | Default |
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.slack` | Configuration for alerts of type `slack` | `{}` |
| `alerting.slack.webhook-url` | Slack Webhook URL | Required `""` |
| `alerting.slack.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.slack.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.slack.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.slack.overrides[].*` | See `alerting.slack.*` parameters | `{}` |
| Parameter | Description | Default |
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:------------------------------------|
| `alerting.slack` | Configuration for alerts of type `slack` | `{}` |
| `alerting.slack.webhook-url` | Slack Webhook URL | Required `""` |
| `alerting.slack.title` | Title of the notification | `":helmet_with_white_cross: Gatus"` |
| `alerting.slack.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.slack.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.slack.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.slack.overrides[].*` | See `alerting.slack.*` parameters | `{}` |
```yaml
alerting:
@@ -2579,6 +2619,7 @@ security:
| `security.oidc.client-secret` | Client secret | Required `""` |
| `security.oidc.scopes` | Scopes to request. The only scope you need is `openid`. | Required `[]` |
| `security.oidc.allowed-subjects` | List of subjects to allow. If empty, all subjects are allowed. | `[]` |
| `security.oidc.session-ttl` | Session time-to-live (e.g. `8h`, `1h30m`, `2h`). | `8h` |
```yaml
security:
@@ -2590,6 +2631,8 @@ security:
scopes: ["openid"]
# You may optionally specify a list of allowed subjects. If this is not specified, all subjects will be allowed.
#allowed-subjects: ["johndoe@example.com"]
# You may optionally specify a session time-to-live. If this is not specified, defaults to 8 hours.
#session-ttl: 8h
```
Confused? Read [Securing Gatus with OIDC using Auth0](https://twin.sh/articles/56/securing-gatus-with-oidc-using-auth0).

View File

@@ -15,6 +15,8 @@ import (
var (
// ErrAlertWithInvalidDescription is the error with which Gatus will panic if an alert has an invalid character
ErrAlertWithInvalidDescription = errors.New("alert description must not have \" or \\")
ErrAlertWithInvalidMinimumReminderInterval = errors.New("minimum-reminder-interval must be either omitted or be at least 5m")
)
// Alert is endpoint.Endpoint's alert configuration
@@ -78,6 +80,9 @@ func (alert *Alert) ValidateAndSetDefaults() error {
if alert.SuccessThreshold <= 0 {
alert.SuccessThreshold = 2
}
if alert.MinimumReminderInterval != 0 && alert.MinimumReminderInterval < 5*time.Minute {
return ErrAlertWithInvalidMinimumReminderInterval
}
if strings.ContainsAny(alert.GetDescription(), "\"\\") {
return ErrAlertWithInvalidDescription
}

View File

@@ -3,6 +3,7 @@ package alert
import (
"errors"
"testing"
"time"
)
func TestAlert_ValidateAndSetDefaults(t *testing.T) {
@@ -36,6 +37,61 @@ func TestAlert_ValidateAndSetDefaults(t *testing.T) {
expectedFailureThreshold: 10,
expectedSuccessThreshold: 5,
},
{
name: "valid-minimum-reminder-interval-0",
alert: Alert{
MinimumReminderInterval: 0,
FailureThreshold: 10,
SuccessThreshold: 5,
},
expectedError: nil,
expectedFailureThreshold: 10,
expectedSuccessThreshold: 5,
},
{
name: "valid-minimum-reminder-interval-5m",
alert: Alert{
MinimumReminderInterval: 5 * time.Minute,
FailureThreshold: 10,
SuccessThreshold: 5,
},
expectedError: nil,
expectedFailureThreshold: 10,
expectedSuccessThreshold: 5,
},
{
name: "valid-minimum-reminder-interval-10m",
alert: Alert{
MinimumReminderInterval: 10 * time.Minute,
FailureThreshold: 10,
SuccessThreshold: 5,
},
expectedError: nil,
expectedFailureThreshold: 10,
expectedSuccessThreshold: 5,
},
{
name: "invalid-minimum-reminder-interval-1m",
alert: Alert{
MinimumReminderInterval: 1 * time.Minute,
FailureThreshold: 10,
SuccessThreshold: 5,
},
expectedError: ErrAlertWithInvalidMinimumReminderInterval,
expectedFailureThreshold: 10,
expectedSuccessThreshold: 5,
},
{
name: "invalid-minimum-reminder-interval-1s",
alert: Alert{
MinimumReminderInterval: 1 * time.Second,
FailureThreshold: 10,
SuccessThreshold: 5,
},
expectedError: ErrAlertWithInvalidMinimumReminderInterval,
expectedFailureThreshold: 10,
expectedSuccessThreshold: 5,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {

View File

@@ -20,7 +20,8 @@ var (
)
type Config struct {
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
}
func (cfg *Config) Validate() error {
@@ -34,6 +35,9 @@ func (cfg *Config) Merge(override *Config) {
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
if len(override.Title) > 0 {
cfg.Title = override.Title
}
}
// AlertProvider is the configuration necessary for sending an alert using Slack
@@ -73,7 +77,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil {
return err
@@ -111,7 +115,7 @@ type Field struct {
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, color string
if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
@@ -138,13 +142,16 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
Text: "",
Attachments: []Attachment{
{
Title: ":helmet_with_white_cross: Gatus",
Title: cfg.Title,
Text: message + description,
Short: false,
Color: color,
},
},
}
if len(body.Attachments[0].Title) == 0 {
body.Attachments[0].Title = ":helmet_with_white_cross: Gatus"
}
if len(formattedConditionResults) > 0 {
body.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{
Title: "Condition results",

View File

@@ -150,7 +150,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{
{
Name: "triggered",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
@@ -158,7 +158,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
{
Name: "triggered-with-group",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
@@ -167,7 +167,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{
Name: "triggered-with-no-conditions",
NoConditions: true,
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
@@ -175,7 +175,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
{
Name: "resolved",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
@@ -183,12 +183,20 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
{
Name: "resolved-with-group",
Provider: AlertProvider{},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
},
{
Name: "resolved-with-group-and-custom-title",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com", Title: "custom title"}},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\"custom title\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
@@ -199,7 +207,12 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
}
}
cfg, err := scenario.Provider.GetConfig(scenario.Endpoint.Group, &scenario.Alert)
if err != nil {
t.Fatal("couldn't get config:", err.Error())
}
body := scenario.Provider.buildRequestBody(
cfg,
&scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{

View File

@@ -166,7 +166,10 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
Value: conditionResult.Condition,
})
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = "**Description**: " + alertDescription
}
cardContent := AdaptiveCardBody{
Type: "AdaptiveCard",
Version: "1.4", // Version 1.5 and 1.6 doesn't seem to be supported by Teams as of 27/08/2024
@@ -190,6 +193,11 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
Text: message,
Wrap: true,
},
{
Type: "TextBlock",
Text: description,
Wrap: true,
},
{
Type: "FactSet",
Facts: facts,

View File

@@ -152,14 +152,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"❌\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"❌\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Attention\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row.\",\"wrap\":true},{\"type\":\"TextBlock\",\"text\":\"**Description**: description-1\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"❌\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"❌\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Attention\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"✅\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"✅\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"TextBlock\",\"text\":\"**Description**: description-2\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"✅\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"✅\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
},
{
Name: "resolved-with-no-conditions",
@@ -167,7 +167,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"TextBlock\",\"text\":\"**Description**: description-2\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
},
}
for _, scenario := range scenarios {

View File

@@ -4,8 +4,8 @@ import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
@@ -516,4 +516,4 @@ func reverseNameForIP(ipStr string) (string, error) {
nibbles[i], nibbles[j] = nibbles[j], nibbles[i]
}
return strings.Join(nibbles, ".") + ".ip6.arpa.", nil
}
}

View File

@@ -11,6 +11,7 @@ import (
"strconv"
"time"
"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel"
"github.com/TwiN/logr"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
@@ -69,13 +70,19 @@ type Config struct {
// IAPConfig is the Google Cloud Identity-Aware-Proxy configuration used for the client. (e.g. audience)
IAPConfig *IAPConfig `yaml:"identity-aware-proxy,omitempty"`
httpClient *http.Client
// Network (ip, ip4 or ip6) for the ICMP client
Network string `yaml:"network"`
// TLS configuration (optional)
TLS *TLSConfig `yaml:"tls,omitempty"`
// Tunnel is the name of the SSH tunnel to use for the client
Tunnel string `yaml:"tunnel,omitempty"`
// ResolvedTunnel is the resolved SSH tunnel for this specific Config
ResolvedTunnel *sshtunnel.SSHTunnel `yaml:"-"`
httpClient *http.Client
}
// DNSResolverConfig is the parsed configuration from the DNSResolver config string.
@@ -265,6 +272,14 @@ func (c *Config) getHTTPClient() *http.Client {
} else if c.HasIAPConfig() {
c.httpClient = configureIAP(c.httpClient, *c.IAPConfig)
}
if c.ResolvedTunnel != nil {
// Use SSH tunnel dialer
if transport, ok := c.httpClient.Transport.(*http.Transport); ok {
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return c.ResolvedTunnel.Dial(network, addr)
}
}
}
}
return c.httpClient
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/TwiN/gatus/v5/alerting"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/announcement"
"github.com/TwiN/gatus/v5/config/connectivity"
"github.com/TwiN/gatus/v5/config/endpoint"
@@ -21,6 +22,7 @@ import (
"github.com/TwiN/gatus/v5/config/maintenance"
"github.com/TwiN/gatus/v5/config/remote"
"github.com/TwiN/gatus/v5/config/suite"
"github.com/TwiN/gatus/v5/config/tunneling"
"github.com/TwiN/gatus/v5/config/ui"
"github.com/TwiN/gatus/v5/config/web"
"github.com/TwiN/gatus/v5/security"
@@ -114,6 +116,9 @@ type Config struct {
// Connectivity is the configuration for connectivity
Connectivity *connectivity.Config `yaml:"connectivity,omitempty"`
// Tunneling is the configuration for SSH tunneling
Tunneling *tunneling.Config `yaml:"tunneling,omitempty"`
// Announcements is the list of system-wide announcements
Announcements []*announcement.Announcement `yaml:"announcements,omitempty"`
@@ -295,55 +300,111 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
logr.Warn("WARNING: Please use the GATUS_LOG_LEVEL environment variable instead")
}
// XXX: End of v6.0.0 removals
validateAlertingConfig(config.Alerting, config.Endpoints, config.ExternalEndpoints)
if err := validateSecurityConfig(config); err != nil {
ValidateAlertingConfig(config.Alerting, config.Endpoints, config.ExternalEndpoints)
if err := ValidateSecurityConfig(config); err != nil {
return nil, err
}
if err := validateEndpointsConfig(config); err != nil {
if err := ValidateEndpointsConfig(config); err != nil {
return nil, err
}
if err := validateWebConfig(config); err != nil {
if err := ValidateWebConfig(config); err != nil {
return nil, err
}
if err := validateUIConfig(config); err != nil {
if err := ValidateUIConfig(config); err != nil {
return nil, err
}
if err := validateMaintenanceConfig(config); err != nil {
if err := ValidateMaintenanceConfig(config); err != nil {
return nil, err
}
if err := validateStorageConfig(config); err != nil {
if err := ValidateStorageConfig(config); err != nil {
return nil, err
}
if err := validateRemoteConfig(config); err != nil {
if err := ValidateRemoteConfig(config); err != nil {
return nil, err
}
if err := validateConnectivityConfig(config); err != nil {
if err := ValidateConnectivityConfig(config); err != nil {
return nil, err
}
if err := validateAnnouncementsConfig(config); err != nil {
if err := ValidateTunnelingConfig(config); err != nil {
return nil, err
}
if err := validateSuitesConfig(config); err != nil {
if err := ValidateAnnouncementsConfig(config); err != nil {
return nil, err
}
if err := validateUniqueKeys(config); err != nil {
if err := ValidateSuitesConfig(config); err != nil {
return nil, err
}
validateAndSetConcurrencyDefaults(config)
if err := ValidateUniqueKeys(config); err != nil {
return nil, err
}
ValidateAndSetConcurrencyDefaults(config)
// Cross-config changes
config.UI.MaximumNumberOfResults = config.Storage.MaximumNumberOfResults
}
return
}
func validateConnectivityConfig(config *Config) error {
func ValidateConnectivityConfig(config *Config) error {
if config.Connectivity != nil {
return config.Connectivity.ValidateAndSetDefaults()
}
return nil
}
func validateAnnouncementsConfig(config *Config) error {
// ValidateTunnelingConfig validates the tunneling configuration and resolves tunnel references
// NOTE: This must be called after ValidateEndpointsConfig and ValidateSuitesConfig
// because it resolves tunnel references in endpoint and suite client configurations
func ValidateTunnelingConfig(config *Config) error {
if config.Tunneling != nil {
if err := config.Tunneling.ValidateAndSetDefaults(); err != nil {
return err
}
// Resolve tunnel references in all endpoints
for _, ep := range config.Endpoints {
if err := resolveTunnelForClientConfig(config, ep.ClientConfig); err != nil {
return fmt.Errorf("endpoint '%s': %w", ep.Key(), err)
}
}
// Resolve tunnel references in suite endpoints
for _, s := range config.Suites {
for _, ep := range s.Endpoints {
if err := resolveTunnelForClientConfig(config, ep.ClientConfig); err != nil {
return fmt.Errorf("suite '%s' endpoint '%s': %w", s.Key(), ep.Key(), err)
}
}
}
// TODO: Add tunnel support for alert providers when needed
}
return nil
}
// resolveTunnelForClientConfig resolves tunnel references in a client configuration
func resolveTunnelForClientConfig(config *Config, clientConfig *client.Config) error {
if clientConfig == nil || clientConfig.Tunnel == "" {
return nil
}
// Validate tunnel name
tunnelName := strings.TrimSpace(clientConfig.Tunnel)
if tunnelName == "" {
return fmt.Errorf("tunnel name cannot be empty")
}
if config.Tunneling == nil {
return fmt.Errorf("tunnel '%s' referenced but no tunneling configuration defined", tunnelName)
}
_, exists := config.Tunneling.Tunnels[tunnelName]
if !exists {
return fmt.Errorf("tunnel '%s' not found in tunneling configuration", tunnelName)
}
// Get or create the SSH tunnel instance and store it directly in client config
tunnel, err := config.Tunneling.GetTunnel(tunnelName)
if err != nil {
return fmt.Errorf("failed to get tunnel '%s': %w", tunnelName, err)
}
clientConfig.ResolvedTunnel = tunnel
return nil
}
func ValidateAnnouncementsConfig(config *Config) error {
if config.Announcements != nil {
if err := announcement.ValidateAndSetDefaults(config.Announcements); err != nil {
return err
@@ -354,7 +415,7 @@ func validateAnnouncementsConfig(config *Config) error {
return nil
}
func validateRemoteConfig(config *Config) error {
func ValidateRemoteConfig(config *Config) error {
if config.Remote != nil {
if err := config.Remote.ValidateAndSetDefaults(); err != nil {
return err
@@ -363,7 +424,7 @@ func validateRemoteConfig(config *Config) error {
return nil
}
func validateStorageConfig(config *Config) error {
func ValidateStorageConfig(config *Config) error {
if config.Storage == nil {
config.Storage = &storage.Config{
Type: storage.TypeMemory,
@@ -378,7 +439,7 @@ func validateStorageConfig(config *Config) error {
return nil
}
func validateMaintenanceConfig(config *Config) error {
func ValidateMaintenanceConfig(config *Config) error {
if config.Maintenance == nil {
config.Maintenance = maintenance.GetDefaultConfig()
} else {
@@ -389,7 +450,7 @@ func validateMaintenanceConfig(config *Config) error {
return nil
}
func validateUIConfig(config *Config) error {
func ValidateUIConfig(config *Config) error {
if config.UI == nil {
config.UI = ui.GetDefaultConfig()
} else {
@@ -400,7 +461,7 @@ func validateUIConfig(config *Config) error {
return nil
}
func validateWebConfig(config *Config) error {
func ValidateWebConfig(config *Config) error {
if config.Web == nil {
config.Web = web.GetDefaultConfig()
} else {
@@ -409,11 +470,11 @@ func validateWebConfig(config *Config) error {
return nil
}
func validateEndpointsConfig(config *Config) error {
func ValidateEndpointsConfig(config *Config) error {
duplicateValidationMap := make(map[string]bool)
// Validate endpoints
for _, ep := range config.Endpoints {
logr.Debugf("[config.validateEndpointsConfig] Validating endpoint with key %s", ep.Key())
logr.Debugf("[config.ValidateEndpointsConfig] Validating endpoint with key %s", ep.Key())
if endpointKey := ep.Key(); duplicateValidationMap[endpointKey] {
return fmt.Errorf("invalid endpoint %s: name and group combination must be unique", ep.Key())
} else {
@@ -423,10 +484,10 @@ func validateEndpointsConfig(config *Config) error {
return fmt.Errorf("invalid endpoint %s: %w", ep.Key(), err)
}
}
logr.Infof("[config.validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
logr.Infof("[config.ValidateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
// Validate external endpoints
for _, ee := range config.ExternalEndpoints {
logr.Debugf("[config.validateEndpointsConfig] Validating external endpoint '%s'", ee.Key())
logr.Debugf("[config.ValidateEndpointsConfig] Validating external endpoint '%s'", ee.Key())
if endpointKey := ee.Key(); duplicateValidationMap[endpointKey] {
return fmt.Errorf("invalid external endpoint %s: name and group combination must be unique", ee.Key())
} else {
@@ -436,13 +497,13 @@ func validateEndpointsConfig(config *Config) error {
return fmt.Errorf("invalid external endpoint %s: %w", ee.Key(), err)
}
}
logr.Infof("[config.validateEndpointsConfig] Validated %d external endpoints", len(config.ExternalEndpoints))
logr.Infof("[config.ValidateEndpointsConfig] Validated %d external endpoints", len(config.ExternalEndpoints))
return nil
}
func validateSuitesConfig(config *Config) error {
func ValidateSuitesConfig(config *Config) error {
if config.Suites == nil || len(config.Suites) == 0 {
logr.Info("[config.validateSuitesConfig] No suites configured")
logr.Info("[config.ValidateSuitesConfig] No suites configured")
return nil
}
suiteNames := make(map[string]bool)
@@ -471,11 +532,11 @@ func validateSuitesConfig(config *Config) error {
}
}
}
logr.Infof("[config.validateSuitesConfig] Validated %d suite(s)", len(config.Suites))
logr.Infof("[config.ValidateSuitesConfig] Validated %d suite(s)", len(config.Suites))
return nil
}
func validateUniqueKeys(config *Config) error {
func ValidateUniqueKeys(config *Config) error {
keyMap := make(map[string]string) // key -> description for error messages
// Check all endpoints
for _, ep := range config.Endpoints {
@@ -512,10 +573,10 @@ func validateUniqueKeys(config *Config) error {
return nil
}
func validateSecurityConfig(config *Config) error {
func ValidateSecurityConfig(config *Config) error {
if config.Security != nil {
if config.Security.IsValid() {
logr.Debug("[config.validateSecurityConfig] Basic security configuration has been validated")
if config.Security.ValidateAndSetDefaults() {
logr.Debug("[config.ValidateSecurityConfig] Basic security configuration has been validated")
} else {
// If there was an attempt to configure security, then it must mean that some confidential or private
// data are exposed. As a result, we'll force a panic because it's better to be safe than sorry.
@@ -525,13 +586,13 @@ func validateSecurityConfig(config *Config) error {
return nil
}
// validateAlertingConfig validates the alerting configuration
// ValidateAlertingConfig validates the alerting configuration
// Note that the alerting configuration has to be validated before the endpoint configuration, because the default alert
// returned by provider.AlertProvider.GetDefaultAlert() must be parsed before endpoint.Endpoint.ValidateAndSetDefaults()
// sets the default alert values when none are set.
func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoint.Endpoint, externalEndpoints []*endpoint.ExternalEndpoint) {
func ValidateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoint.Endpoint, externalEndpoints []*endpoint.ExternalEndpoint) {
if alertingConfig == nil {
logr.Info("[config.validateAlertingConfig] Alerting is not configured")
logr.Info("[config.ValidateAlertingConfig] Alerting is not configured")
return
}
alertTypes := []alert.Type{
@@ -586,12 +647,12 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
for _, ep := range endpoints {
for alertIndex, endpointAlert := range ep.Alerts {
if alertType == endpointAlert.Type {
logr.Debugf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ep.Key())
logr.Debugf("[config.ValidateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ep.Key())
provider.MergeProviderDefaultAlertIntoEndpointAlert(alertProvider.GetDefaultAlert(), endpointAlert)
// Validate the endpoint alert's overrides, if applicable
if len(endpointAlert.ProviderOverride) > 0 {
if err = alertProvider.ValidateOverrides(ep.Group, endpointAlert); err != nil {
logr.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ep.Key(), alertType, err.Error())
logr.Warnf("[config.ValidateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ep.Key(), alertType, err.Error())
}
}
}
@@ -600,12 +661,12 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
for _, ee := range externalEndpoints {
for alertIndex, endpointAlert := range ee.Alerts {
if alertType == endpointAlert.Type {
logr.Debugf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ee.Key())
logr.Debugf("[config.ValidateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ee.Key())
provider.MergeProviderDefaultAlertIntoEndpointAlert(alertProvider.GetDefaultAlert(), endpointAlert)
// Validate the endpoint alert's overrides, if applicable
if len(endpointAlert.ProviderOverride) > 0 {
if err = alertProvider.ValidateOverrides(ee.Group, endpointAlert); err != nil {
logr.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ee.Key(), alertType, err.Error())
logr.Warnf("[config.ValidateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ee.Key(), alertType, err.Error())
}
}
}
@@ -614,7 +675,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
}
validProviders = append(validProviders, alertType)
} else {
logr.Warnf("[config.validateAlertingConfig] Ignoring provider=%s due to error=%s", alertType, err.Error())
logr.Warnf("[config.ValidateAlertingConfig] Ignoring provider=%s due to error=%s", alertType, err.Error())
invalidProviders = append(invalidProviders, alertType)
alertingConfig.SetAlertingProviderToNil(alertProvider)
}
@@ -622,19 +683,19 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
invalidProviders = append(invalidProviders, alertType)
}
}
logr.Infof("[config.validateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders)
logr.Infof("[config.ValidateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders)
}
func validateAndSetConcurrencyDefaults(config *Config) {
func ValidateAndSetConcurrencyDefaults(config *Config) {
if config.DisableMonitoringLock {
config.Concurrency = 0
logr.Warn("WARNING: The 'disable-monitoring-lock' configuration has been deprecated and will be removed in v6.0.0")
logr.Warn("WARNING: Please set 'concurrency: 0' instead")
logr.Debug("[config.validateAndSetConcurrencyDefaults] DisableMonitoringLock is true, setting unlimited (0) concurrency")
logr.Debug("[config.ValidateAndSetConcurrencyDefaults] DisableMonitoringLock is true, setting unlimited (0) concurrency")
} else if config.Concurrency <= 0 && !config.DisableMonitoringLock {
config.Concurrency = DefaultConcurrency
logr.Debugf("[config.validateAndSetConcurrencyDefaults] Setting default concurrency to %d", config.Concurrency)
logr.Debugf("[config.ValidateAndSetConcurrencyDefaults] Setting default concurrency to %d", config.Concurrency)
} else {
logr.Debugf("[config.validateAndSetConcurrencyDefaults] Using configured concurrency of %d", config.Concurrency)
logr.Debugf("[config.ValidateAndSetConcurrencyDefaults] Using configured concurrency of %d", config.Concurrency)
}
}

View File

@@ -53,6 +53,9 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/suite"
"github.com/TwiN/gatus/v5/config/tunneling"
"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel"
"github.com/TwiN/gatus/v5/config/web"
"github.com/TwiN/gatus/v5/storage"
"gopkg.in/yaml.v3"
@@ -1850,7 +1853,7 @@ endpoints:
if config.Security == nil {
t.Fatal("config.Security shouldn't have been nil")
}
if !config.Security.IsValid() {
if !config.Security.ValidateAndSetDefaults() {
t.Error("Security config should've been valid")
}
if config.Security.Basic == nil {
@@ -2484,3 +2487,193 @@ suites:
})
}
}
func TestValidateTunnelingConfig(t *testing.T) {
tests := []struct {
name string
config *Config
wantErr bool
errMsg string
}{
{
name: "valid tunneling config",
config: &Config{
Tunneling: &tunneling.Config{
Tunnels: map[string]*sshtunnel.Config{
"test": {
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
},
},
},
Endpoints: []*endpoint.Endpoint{
{
Name: "test-endpoint",
URL: "http://example.com/health",
ClientConfig: &client.Config{
Tunnel: "test",
},
Conditions: []endpoint.Condition{"[STATUS] == 200"},
},
},
},
wantErr: false,
},
{
name: "invalid tunnel reference in endpoint",
config: &Config{
Tunneling: &tunneling.Config{
Tunnels: map[string]*sshtunnel.Config{
"test": {
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
},
},
},
Endpoints: []*endpoint.Endpoint{
{
Name: "test-endpoint",
URL: "http://example.com/health",
ClientConfig: &client.Config{
Tunnel: "nonexistent",
},
Conditions: []endpoint.Condition{"[STATUS] == 200"},
},
},
},
wantErr: true,
errMsg: "endpoint '_test-endpoint': tunnel 'nonexistent' not found in tunneling configuration",
},
{
name: "invalid tunnel reference in suite endpoint",
config: &Config{
Tunneling: &tunneling.Config{
Tunnels: map[string]*sshtunnel.Config{
"test": {
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
},
},
},
Suites: []*suite.Suite{
{
Name: "test-suite",
Endpoints: []*endpoint.Endpoint{
{
Name: "suite-endpoint",
URL: "http://example.com/health",
ClientConfig: &client.Config{
Tunnel: "invalid",
},
Conditions: []endpoint.Condition{"[STATUS] == 200"},
},
},
},
},
},
wantErr: true,
errMsg: "suite '_test-suite' endpoint '_suite-endpoint': tunnel 'invalid' not found in tunneling configuration",
},
{
name: "no tunneling config",
config: &Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "test-endpoint",
URL: "http://example.com/health",
Conditions: []endpoint.Condition{"[STATUS] == 200"},
},
},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateTunnelingConfig(tt.config)
if tt.wantErr {
if err == nil {
t.Error("ValidateTunnelingConfig() expected error but got none")
return
}
if err.Error() != tt.errMsg {
t.Errorf("ValidateTunnelingConfig() error = %v, want %v", err.Error(), tt.errMsg)
}
return
}
if err != nil {
t.Errorf("ValidateTunnelingConfig() unexpected error = %v", err)
}
})
}
}
func TestResolveTunnelForClientConfig(t *testing.T) {
config := &Config{
Tunneling: &tunneling.Config{
Tunnels: map[string]*sshtunnel.Config{
"test": {
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
},
},
},
}
err := config.Tunneling.ValidateAndSetDefaults()
if err != nil {
t.Fatalf("Failed to validate tunnel config: %v", err)
}
tests := []struct {
name string
clientConfig *client.Config
wantErr bool
errMsg string
}{
{
name: "valid tunnel reference",
clientConfig: &client.Config{
Tunnel: "test",
},
wantErr: false,
},
{
name: "invalid tunnel reference",
clientConfig: &client.Config{
Tunnel: "nonexistent",
},
wantErr: true,
errMsg: "tunnel 'nonexistent' not found in tunneling configuration",
},
{
name: "no tunnel reference",
clientConfig: &client.Config{},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := resolveTunnelForClientConfig(config, tt.clientConfig)
if tt.wantErr {
if err == nil {
t.Error("resolveTunnelForClientConfig() expected error but got none")
return
}
if err.Error() != tt.errMsg {
t.Errorf("resolveTunnelForClientConfig() error = %v, want %v", err.Error(), tt.errMsg)
}
return
}
if err != nil {
t.Errorf("resolveTunnelForClientConfig() unexpected error = %v", err)
}
})
}
}

View File

@@ -214,30 +214,35 @@ func prettifyNumericalParameters(parameters []string, resolvedParameters []int64
// prettify returns a string representation of a condition with its parameters resolved between parentheses
func prettify(parameters []string, resolvedParameters []string, operator string) string {
// Since, in the event of an invalid path, the resolvedParameters also contain the condition itself,
// we'll return the resolvedParameters as-is.
if strings.HasSuffix(resolvedParameters[0], InvalidConditionElementSuffix) || strings.HasSuffix(resolvedParameters[1], InvalidConditionElementSuffix) {
return resolvedParameters[0] + " " + operator + " " + resolvedParameters[1]
}
// If using the pattern function, truncate the parameter it's being compared to if said parameter is long enough
// Handle pattern function truncation first
if strings.HasPrefix(parameters[0], PatternFunctionPrefix) && strings.HasSuffix(parameters[0], FunctionSuffix) && len(resolvedParameters[1]) > maximumLengthBeforeTruncatingWhenComparedWithPattern {
resolvedParameters[1] = fmt.Sprintf("%.25s...(truncated)", resolvedParameters[1])
}
if strings.HasPrefix(parameters[1], PatternFunctionPrefix) && strings.HasSuffix(parameters[1], FunctionSuffix) && len(resolvedParameters[0]) > maximumLengthBeforeTruncatingWhenComparedWithPattern {
resolvedParameters[0] = fmt.Sprintf("%.25s...(truncated)", resolvedParameters[0])
}
// First element is a placeholder
if parameters[0] != resolvedParameters[0] && parameters[1] == resolvedParameters[1] {
return parameters[0] + " (" + resolvedParameters[0] + ") " + operator + " " + parameters[1]
// Determine the state of each parameter
leftChanged := parameters[0] != resolvedParameters[0]
rightChanged := parameters[1] != resolvedParameters[1]
leftInvalid := resolvedParameters[0] == parameters[0]+" "+InvalidConditionElementSuffix
rightInvalid := resolvedParameters[1] == parameters[1]+" "+InvalidConditionElementSuffix
// Build the output based on what was resolved
var left, right string
// Format left side
if leftChanged && !leftInvalid {
left = parameters[0] + " (" + resolvedParameters[0] + ")"
} else if leftInvalid {
left = resolvedParameters[0] // Already has (INVALID)
} else {
left = parameters[0] // Unchanged
}
// Second element is a placeholder
if parameters[0] == resolvedParameters[0] && parameters[1] != resolvedParameters[1] {
return parameters[0] + " " + operator + " " + parameters[1] + " (" + resolvedParameters[1] + ")"
// Format right side
if rightChanged && !rightInvalid {
right = parameters[1] + " (" + resolvedParameters[1] + ")"
} else if rightInvalid {
right = resolvedParameters[1] // Already has (INVALID)
} else {
right = parameters[1] // Unchanged
}
// Both elements are placeholders...?
if parameters[0] != resolvedParameters[0] && parameters[1] != resolvedParameters[1] {
return parameters[0] + " (" + resolvedParameters[0] + ") " + operator + " " + parameters[1] + " (" + resolvedParameters[1] + ")"
}
// Neither elements are placeholders
return parameters[0] + " " + operator + " " + parameters[1]
return left + " " + operator + " " + right
}

View File

@@ -6,6 +6,8 @@ import (
"strconv"
"testing"
"time"
"github.com/TwiN/gatus/v5/config/gontext"
)
func TestCondition_Validate(t *testing.T) {
@@ -777,3 +779,77 @@ func TestCondition_evaluateWithInvalidOperator(t *testing.T) {
t.Error("condition was invalid, result should've had an error")
}
}
func TestConditionEvaluateWithInvalidContextPlaceholder(t *testing.T) {
// Test case: Suite endpoint with invalid context placeholder
// This should display the original placeholder names with resolved values
condition := Condition("[STATUS] == [CONTEXT].expected_statusz")
result := &Result{HTTPStatus: 200}
ctx := gontext.New(map[string]interface{}{
// Note: expected_statusz is not in the context (typo - should be expected_status)
"expected_status": 200,
"max_response_time": 5000,
})
// Simulate suite endpoint evaluation with context
success := condition.evaluate(result, false, ctx) // false = don't skip resolution (default)
if success {
t.Error("Condition should have failed because [CONTEXT].expected_statusz doesn't exist")
}
if len(result.ConditionResults) == 0 {
t.Fatal("No condition results found")
}
actualDisplay := result.ConditionResults[0].Condition
// The expected format should preserve the placeholder names
expectedDisplay := "[STATUS] (200) == [CONTEXT].expected_statusz (INVALID)"
if actualDisplay != expectedDisplay {
t.Errorf("Incorrect condition display for failed context placeholder\nExpected: %s\nActual: %s", expectedDisplay, actualDisplay)
}
}
func TestConditionEvaluateWithValidContextPlaceholder(t *testing.T) {
// Test case: Suite endpoint with valid context placeholder
condition := Condition("[STATUS] == [CONTEXT].expected_status")
result := &Result{HTTPStatus: 200}
ctx := gontext.New(map[string]interface{}{
"expected_status": 200,
})
// Simulate suite endpoint evaluation with context
success := condition.evaluate(result, false, ctx)
if !success {
t.Error("Condition should have succeeded")
}
if len(result.ConditionResults) == 0 {
t.Fatal("No condition results found")
}
actualDisplay := result.ConditionResults[0].Condition
// For successful conditions, just the original condition is shown
expectedDisplay := "[STATUS] == [CONTEXT].expected_status"
if actualDisplay != expectedDisplay {
t.Errorf("Incorrect condition display for successful context placeholder\nExpected: %s\nActual: %s", expectedDisplay, actualDisplay)
}
}
func TestConditionEvaluateWithMixedValidAndInvalidContext(t *testing.T) {
// Test case: One valid placeholder, one invalid
// Note: For numerical comparisons, invalid placeholders that can't be parsed as numbers
// default to 0 due to sanitizeAndResolveNumericalWithContext's behavior
condition := Condition("[RESPONSE_TIME] < [CONTEXT].invalid_key")
result := &Result{Duration: 100 * 1000000} // 100ms in nanoseconds
ctx := gontext.New(map[string]interface{}{
"valid_key": 5000,
})
// Simulate suite endpoint evaluation with context
success := condition.evaluate(result, false, ctx)
if success {
t.Error("Condition should have failed because [CONTEXT].invalid_key doesn't exist")
}
if len(result.ConditionResults) == 0 {
t.Fatal("No condition results found")
}
actualDisplay := result.ConditionResults[0].Condition
// For numerical comparisons, invalid context placeholders become 0
expectedDisplay := "[RESPONSE_TIME] (100) < [CONTEXT].invalid_key (0)"
if actualDisplay != expectedDisplay {
t.Errorf("Incorrect condition display\nExpected: %s\nActual: %s", expectedDisplay, actualDisplay)
}
}

View File

@@ -565,13 +565,21 @@ func (e *Endpoint) buildHTTPRequest() *http.Request {
return request
}
// needsToReadBody checks if there's any condition that requires the response Body to be read
// needsToReadBody checks if there's any condition or store mapping that requires the response Body to be read
func (e *Endpoint) needsToReadBody() bool {
for _, condition := range e.Conditions {
if condition.hasBodyPlaceholder() {
return true
}
}
// Check store values for body placeholders
if e.Store != nil {
for _, value := range e.Store {
if strings.Contains(value, BodyPlaceholder) {
return true
}
}
}
return false
}

View File

@@ -914,6 +914,40 @@ func TestEndpoint_needsToReadBody(t *testing.T) {
if !(&Endpoint{Conditions: []Condition{bodyConditionWithLength, statusCondition}}).needsToReadBody() {
t.Error("expected true, got false")
}
// Test store configuration with body placeholder
storeWithBodyPlaceholder := map[string]string{
"token": "[BODY].accessToken",
}
if !(&Endpoint{
Conditions: []Condition{statusCondition},
Store: storeWithBodyPlaceholder,
}).needsToReadBody() {
t.Error("expected true when store has body placeholder, got false")
}
// Test store configuration without body placeholder
storeWithoutBodyPlaceholder := map[string]string{
"status": "[STATUS]",
}
if (&Endpoint{
Conditions: []Condition{statusCondition},
Store: storeWithoutBodyPlaceholder,
}).needsToReadBody() {
t.Error("expected false when store has no body placeholder, got true")
}
// Test empty store
if (&Endpoint{
Conditions: []Condition{statusCondition},
Store: map[string]string{},
}).needsToReadBody() {
t.Error("expected false when store is empty, got true")
}
// Test nil store
if (&Endpoint{
Conditions: []Condition{statusCondition},
Store: nil,
}).needsToReadBody() {
t.Error("expected false when store is nil, got true")
}
}
func TestEndpoint_needsToRetrieveDomainExpiration(t *testing.T) {

View File

@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/TwiN/gatus/v5/config/endpoint"
@@ -175,10 +176,12 @@ func StoreResultValues(ctx *gontext.Gontext, mappings map[string]string, result
return nil, nil
}
storedValues := make(map[string]interface{})
var extractionErrors []string
for contextKey, placeholder := range mappings {
value, err := extractValueForStorage(placeholder, result)
if err != nil {
// Continue storing other values even if one fails
extractionErrors = append(extractionErrors, fmt.Sprintf("%s: %v", contextKey, err))
storedValues[contextKey] = fmt.Sprintf("ERROR: %v", err)
continue
}
@@ -187,6 +190,10 @@ func StoreResultValues(ctx *gontext.Gontext, mappings map[string]string, result
}
storedValues[contextKey] = value
}
// Return an error if any values failed to extract
if len(extractionErrors) > 0 {
return storedValues, fmt.Errorf("failed to extract values: %s", strings.Join(extractionErrors, "; "))
}
return storedValues, nil
}
@@ -197,6 +204,11 @@ func extractValueForStorage(placeholder string, result *endpoint.Result) (interf
if err != nil {
return nil, err
}
// Check if the resolution resulted in an INVALID placeholder
// This happens when a path doesn't exist (e.g., [BODY].nonexistent)
if strings.HasSuffix(resolved, " "+endpoint.InvalidConditionElementSuffix) {
return nil, fmt.Errorf("invalid path: %s", strings.TrimSuffix(resolved, " "+endpoint.InvalidConditionElementSuffix))
}
// Try to parse as number or boolean to store as proper types
// Try int first for whole numbers
if num, err := strconv.ParseInt(resolved, 10, 64); err == nil {

View File

@@ -1,6 +1,7 @@
package suite
import (
"strings"
"testing"
"time"
@@ -240,6 +241,50 @@ func TestStoreResultValues(t *testing.T) {
}
}
func TestStoreResultValuesWithInvalidPath(t *testing.T) {
ctx := gontext.New(map[string]interface{}{})
result := &endpoint.Result{
HTTPStatus: 200,
Body: []byte(`{"data": {"name": "john"}}`),
}
// Define store mappings with invalid paths
mappings := map[string]string{
"valid_status": "[STATUS]",
"invalid_token": "[BODY].accessToken", // This path doesn't exist
"invalid_nested": "[BODY].user.id.invalid", // This nested path doesn't exist
}
// Store values - should return error for invalid paths
stored, err := StoreResultValues(ctx, mappings, result)
if err == nil {
t.Fatal("Expected error when storing invalid paths, got nil")
}
// Check that the error message contains information about the invalid paths
if !strings.Contains(err.Error(), "invalid_token") {
t.Errorf("Error should mention invalid_token, got: %v", err)
}
if !strings.Contains(err.Error(), "invalid path") {
t.Errorf("Error should mention 'invalid path', got: %v", err)
}
// Verify that valid values were still stored
if stored["valid_status"] != int64(200) {
t.Errorf("Expected valid_status=200, got %v", stored["valid_status"])
}
// Verify that invalid values show error messages in stored map
if !strings.Contains(stored["invalid_token"].(string), "ERROR") {
t.Errorf("Expected invalid_token to contain ERROR, got %v", stored["invalid_token"])
}
// Verify that invalid values are NOT in context
_, err = ctx.Get("invalid_token")
if err == nil {
t.Error("Invalid token should not be stored in context")
}
// Verify that valid value IS in context
val, err := ctx.Get("valid_status")
if err != nil || val != int64(200) {
t.Errorf("Expected valid_status=200 in context, got %v, err=%v", val, err)
}
}
func TestSuite_ExecuteWithAlwaysRunEndpoints(t *testing.T) {
suite := &Suite{
Name: "test-suite",

View File

@@ -0,0 +1,157 @@
package sshtunnel
import (
"fmt"
"net"
"sync"
"time"
"golang.org/x/crypto/ssh"
)
// Config represents the configuration for an SSH tunnel
type Config struct {
Type string `yaml:"type"`
Host string `yaml:"host"`
Port int `yaml:"port,omitempty"`
Username string `yaml:"username"`
PrivateKey string `yaml:"private-key,omitempty"`
Password string `yaml:"password,omitempty"`
}
// ValidateAndSetDefaults validates the SSH tunnel configuration and sets defaults
func (c *Config) ValidateAndSetDefaults() error {
if c.Type != "SSH" {
return fmt.Errorf("unsupported tunnel type: %s", c.Type)
}
if c.Host == "" {
return fmt.Errorf("host is required")
}
if c.Username == "" {
return fmt.Errorf("username is required")
}
if c.PrivateKey == "" && c.Password == "" {
return fmt.Errorf("either private-key or password is required")
}
if c.Port == 0 {
c.Port = 22
}
return nil
}
// SSHTunnel represents an SSH tunnel connection
type SSHTunnel struct {
config *Config
mu sync.RWMutex
client *ssh.Client
// Cached authentication methods to avoid reparsing private keys
authMethods []ssh.AuthMethod
}
// New creates a new SSH tunnel with the given configuration
func New(config *Config) *SSHTunnel {
tunnel := &SSHTunnel{
config: config,
}
// Parse authentication methods once during initialization to avoid
// expensive cryptographic operations on every connection attempt
if config.PrivateKey != "" {
if signer, err := ssh.ParsePrivateKey([]byte(config.PrivateKey)); err == nil {
tunnel.authMethods = []ssh.AuthMethod{ssh.PublicKeys(signer)}
}
// Note: We don't return error here to maintain backward compatibility.
// Invalid keys will be caught during first connection attempt.
} else if config.Password != "" {
tunnel.authMethods = []ssh.AuthMethod{ssh.Password(config.Password)}
}
return tunnel
}
// Connect establishes the SSH connection
func (t *SSHTunnel) Connect() error {
t.mu.Lock()
defer t.mu.Unlock()
return t.connectUnsafe()
}
// connectUnsafe establishes the SSH connection without acquiring locks
// Must be called with t.mu.Lock() already held
func (t *SSHTunnel) connectUnsafe() error {
// Use cached authentication methods to avoid expensive crypto operations
if len(t.authMethods) == 0 {
return fmt.Errorf("no authentication method available")
}
config := &ssh.ClientConfig{
User: t.config.Username,
Timeout: 30 * time.Second,
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // Skip host key verification
Auth: t.authMethods, // Use pre-parsed authentication
}
// Connect to SSH server
addr := fmt.Sprintf("%s:%d", t.config.Host, t.config.Port)
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
return fmt.Errorf("SSH connection failed: %w", err)
}
t.client = client
return nil
}
// Close closes the SSH connection
func (t *SSHTunnel) Close() error {
t.mu.Lock()
defer t.mu.Unlock()
if t.client != nil {
err := t.client.Close()
t.client = nil
return err
}
return nil
}
// Dial creates a connection through the SSH tunnel
func (t *SSHTunnel) Dial(network, addr string) (net.Conn, error) {
t.mu.RLock()
client := t.client
t.mu.RUnlock()
// Ensure we have an SSH connection
if client == nil {
// Use write lock to prevent race condition during connection
t.mu.Lock()
// Double-check client after acquiring lock
if t.client == nil {
if err := t.connectUnsafe(); err != nil {
t.mu.Unlock()
return nil, err
}
}
client = t.client
t.mu.Unlock()
}
// Create connection through SSH tunnel
conn, err := client.Dial(network, addr)
if err != nil {
// Close stale connection before retry to prevent leak
t.mu.Lock()
if t.client != nil {
t.client.Close()
t.client = nil
}
t.mu.Unlock()
// Retry once - connection might be stale
if connErr := t.Connect(); connErr != nil {
return nil, fmt.Errorf("SSH tunnel dial failed: %w (retry failed: %v)", err, connErr)
}
t.mu.RLock()
client = t.client
t.mu.RUnlock()
conn, err = client.Dial(network, addr)
if err != nil {
return nil, fmt.Errorf("SSH tunnel dial failed after retry: %w", err)
}
}
return conn, nil
}

View File

@@ -0,0 +1,158 @@
package sshtunnel
import (
"testing"
)
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
tests := []struct {
name string
config *Config
wantErr bool
errMsg string
}{
{
name: "valid SSH config with private key",
config: &Config{
Type: "SSH",
Host: "example.com",
Username: "test",
PrivateKey: "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----",
},
wantErr: false,
},
{
name: "valid SSH config with password",
config: &Config{
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
},
wantErr: false,
},
{
name: "valid SSH config with custom port",
config: &Config{
Type: "SSH",
Host: "example.com",
Port: 2222,
Username: "test",
Password: "secret",
},
wantErr: false,
},
{
name: "sets default port 22",
config: &Config{
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
},
wantErr: false,
},
{
name: "invalid type",
config: &Config{
Type: "INVALID",
Host: "example.com",
Username: "test",
Password: "secret",
},
wantErr: true,
errMsg: "unsupported tunnel type: INVALID",
},
{
name: "missing host",
config: &Config{
Type: "SSH",
Username: "test",
Password: "secret",
},
wantErr: true,
errMsg: "host is required",
},
{
name: "missing username",
config: &Config{
Type: "SSH",
Host: "example.com",
Password: "secret",
},
wantErr: true,
errMsg: "username is required",
},
{
name: "missing authentication",
config: &Config{
Type: "SSH",
Host: "example.com",
Username: "test",
},
wantErr: true,
errMsg: "either private-key or password is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
originalPort := tt.config.Port
err := tt.config.ValidateAndSetDefaults()
if tt.wantErr {
if err == nil {
t.Errorf("ValidateAndSetDefaults() expected error but got none")
return
}
if err.Error() != tt.errMsg {
t.Errorf("ValidateAndSetDefaults() error = %v, want %v", err.Error(), tt.errMsg)
}
return
}
if err != nil {
t.Errorf("ValidateAndSetDefaults() unexpected error = %v", err)
return
}
// Check that default port is set
if originalPort == 0 && tt.config.Port != 22 {
t.Errorf("ValidateAndSetDefaults() expected default port 22, got %d", tt.config.Port)
}
})
}
}
func TestNew(t *testing.T) {
config := &Config{
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
}
tunnel := New(config)
if tunnel == nil {
t.Error("New() returned nil")
return
}
if tunnel.config != config {
t.Error("New() did not set config correctly")
}
}
func TestSSHTunnel_Close(t *testing.T) {
config := &Config{
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
}
tunnel := New(config)
// Test closing when no client is set
err := tunnel.Close()
if err != nil {
t.Errorf("Close() with no client returned error: %v", err)
}
// Test closing multiple times
err = tunnel.Close()
if err != nil {
t.Errorf("Close() called twice returned error: %v", err)
}
}

View File

@@ -0,0 +1,70 @@
package tunneling
import (
"fmt"
"strings"
"sync"
"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel"
)
// Config represents the tunneling configuration
type Config struct {
// Tunnels is a map of SSH tunnel configurations in which the key is the name of the tunnel
Tunnels map[string]*sshtunnel.Config `yaml:",inline"`
mu sync.RWMutex `yaml:"-"`
connections map[string]*sshtunnel.SSHTunnel `yaml:"-"`
}
// ValidateAndSetDefaults validates the tunneling configuration and sets defaults
func (tc *Config) ValidateAndSetDefaults() error {
if tc.connections == nil {
tc.connections = make(map[string]*sshtunnel.SSHTunnel)
}
for name, config := range tc.Tunnels {
if err := config.ValidateAndSetDefaults(); err != nil {
return fmt.Errorf("tunnel '%s': %w", name, err)
}
}
return nil
}
// GetTunnel returns the SSH tunnel for the given name, creating it if necessary
func (tc *Config) GetTunnel(name string) (*sshtunnel.SSHTunnel, error) {
if name == "" {
return nil, fmt.Errorf("tunnel name cannot be empty")
}
tc.mu.Lock()
defer tc.mu.Unlock()
// Check if tunnel already exists
if tunnel, exists := tc.connections[name]; exists {
return tunnel, nil
}
// Get config for this tunnel
config, exists := tc.Tunnels[name]
if !exists {
return nil, fmt.Errorf("tunnel '%s' not found in configuration", name)
}
// Create and store new tunnel
tunnel := sshtunnel.New(config)
tc.connections[name] = tunnel
return tunnel, nil
}
// Close closes all SSH tunnel connections
func (tc *Config) Close() error {
tc.mu.Lock()
defer tc.mu.Unlock()
var errors []string
for name, tunnel := range tc.connections {
if err := tunnel.Close(); err != nil {
errors = append(errors, fmt.Sprintf("tunnel '%s': %v", name, err))
}
delete(tc.connections, name)
}
if len(errors) > 0 {
return fmt.Errorf("failed to close tunnels: %s", strings.Join(errors, ", "))
}
return nil
}

View File

@@ -0,0 +1,191 @@
package tunneling
import (
"testing"
"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel"
)
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
tests := []struct {
name string
config *Config
wantErr bool
errMsg string
}{
{
name: "valid config with SSH tunnel",
config: &Config{
Tunnels: map[string]*sshtunnel.Config{
"test": {
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
},
},
},
wantErr: false,
},
{
name: "multiple valid tunnels",
config: &Config{
Tunnels: map[string]*sshtunnel.Config{
"tunnel1": {
Type: "SSH",
Host: "host1.com",
Username: "user1",
PrivateKey: "key1",
},
"tunnel2": {
Type: "SSH",
Host: "host2.com",
Username: "user2",
Password: "pass2",
},
},
},
wantErr: false,
},
{
name: "invalid tunnel config",
config: &Config{
Tunnels: map[string]*sshtunnel.Config{
"invalid": {
Type: "INVALID",
Host: "example.com",
Username: "test",
Password: "secret",
},
},
},
wantErr: true,
errMsg: "tunnel 'invalid': unsupported tunnel type: INVALID",
},
{
name: "missing host in tunnel",
config: &Config{
Tunnels: map[string]*sshtunnel.Config{
"nohost": {
Type: "SSH",
Username: "test",
Password: "secret",
},
},
},
wantErr: true,
errMsg: "tunnel 'nohost': host is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.config.ValidateAndSetDefaults()
if tt.wantErr {
if err == nil {
t.Errorf("ValidateAndSetDefaults() expected error but got none")
return
}
if err.Error() != tt.errMsg {
t.Errorf("ValidateAndSetDefaults() error = %v, want %v", err.Error(), tt.errMsg)
}
return
}
if err != nil {
t.Errorf("ValidateAndSetDefaults() unexpected error = %v", err)
return
}
// Check that connections map is initialized
if tt.config != nil && tt.config.connections == nil {
t.Error("ValidateAndSetDefaults() did not initialize connections map")
}
})
}
}
func TestConfig_GetTunnel(t *testing.T) {
config := &Config{
Tunnels: map[string]*sshtunnel.Config{
"test": {
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
},
},
}
err := config.ValidateAndSetDefaults()
if err != nil {
t.Fatalf("ValidateAndSetDefaults() failed: %v", err)
}
// Test getting existing tunnel
tunnel1, err := config.GetTunnel("test")
if err != nil {
t.Errorf("GetTunnel() error = %v", err)
return
}
if tunnel1 == nil {
t.Error("GetTunnel() returned nil tunnel")
return
}
// Test getting same tunnel again (should return same instance)
tunnel2, err := config.GetTunnel("test")
if err != nil {
t.Errorf("GetTunnel() second call error = %v", err)
return
}
if tunnel1 != tunnel2 {
t.Error("GetTunnel() should return same instance for same tunnel name")
}
// Test getting non-existent tunnel
_, err = config.GetTunnel("nonexistent")
if err == nil {
t.Error("GetTunnel() expected error for non-existent tunnel")
return
}
expectedErr := "tunnel 'nonexistent' not found in configuration"
if err.Error() != expectedErr {
t.Errorf("GetTunnel() error = %v, want %v", err.Error(), expectedErr)
}
}
func TestConfig_Close(t *testing.T) {
// Test closing config with tunnels
config := &Config{
Tunnels: map[string]*sshtunnel.Config{
"test1": {
Type: "SSH",
Host: "example1.com",
Username: "test",
Password: "secret",
},
"test2": {
Type: "SSH",
Host: "example2.com",
Username: "test",
Password: "secret",
},
},
}
err := config.ValidateAndSetDefaults()
if err != nil {
t.Fatalf("ValidateAndSetDefaults() failed: %v", err)
}
// Create some tunnels
_, err = config.GetTunnel("test1")
if err != nil {
t.Fatalf("GetTunnel() failed: %v", err)
}
_, err = config.GetTunnel("test2")
if err != nil {
t.Fatalf("GetTunnel() failed: %v", err)
}
// Test closing
err = config.Close()
if err != nil {
t.Errorf("Close() returned error: %v", err)
}
// Verify connections map is empty
if len(config.connections) != 0 {
t.Errorf("Close() did not clear connections map, got %d connections", len(config.connections))
}
}

2
go.mod
View File

@@ -28,7 +28,7 @@ require (
golang.org/x/crypto v0.40.0
golang.org/x/net v0.42.0
golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.16.0
golang.org/x/sync v0.17.0
google.golang.org/api v0.242.0
gopkg.in/mail.v2 v2.3.1
gopkg.in/yaml.v3 v3.0.1

4
go.sum
View File

@@ -195,8 +195,8 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

View File

@@ -59,6 +59,7 @@ func stop(cfg *config.Config) {
watchdog.Shutdown(cfg)
controller.Shutdown()
metrics.UnregisterPrometheusMetrics()
closeTunnels(cfg)
}
func save() {
@@ -187,6 +188,14 @@ func initializeStorage(cfg *config.Config) {
}
}
func closeTunnels(cfg *config.Config) {
if cfg.Tunneling != nil {
if err := cfg.Tunneling.Close(); err != nil {
logr.Errorf("[main.closeTunnels] Error closing SSH tunnels: %v", err)
}
}
}
func listenToConfigurationFileChanges(cfg *config.Config) {
for {
time.Sleep(30 * time.Second)

View File

@@ -26,9 +26,9 @@ type Config struct {
gate *g8.Gate
}
// IsValid returns whether the security configuration is valid or not
func (c *Config) IsValid() bool {
return (c.Basic != nil && c.Basic.isValid()) || (c.OIDC != nil && c.OIDC.isValid())
// ValidateAndSetDefaults returns whether the security configuration is valid or not and sets default values.
func (c *Config) ValidateAndSetDefaults() bool {
return (c.Basic != nil && c.Basic.isValid()) || (c.OIDC != nil && c.OIDC.ValidateAndSetDefaults())
}
// RegisterHandlers registers all handlers required based on the security configuration

View File

@@ -9,12 +9,12 @@ import (
"golang.org/x/oauth2"
)
func TestConfig_IsValid(t *testing.T) {
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
c := &Config{
Basic: nil,
OIDC: nil,
}
if c.IsValid() {
if c.ValidateAndSetDefaults() {
t.Error("expected empty config to be valid")
}
}
@@ -65,6 +65,7 @@ func TestConfig_ApplySecurityMiddleware(t *testing.T) {
RedirectURL: "http://localhost:80/authorization-code/callback",
Scopes: []string{"openid"},
AllowedSubjects: []string{"user1@example.com"},
SessionTTL: DefaultOIDCSessionTTL,
oauth2Config: oauth2.Config{},
verifier: nil,
}}

View File

@@ -13,21 +13,29 @@ import (
"golang.org/x/oauth2"
)
const (
DefaultOIDCSessionTTL = 8 * time.Hour
)
// OIDCConfig is the configuration for OIDC authentication
type OIDCConfig struct {
IssuerURL string `yaml:"issuer-url"` // e.g. https://dev-12345678.okta.com
RedirectURL string `yaml:"redirect-url"` // e.g. http://localhost:8080/authorization-code/callback
ClientID string `yaml:"client-id"`
ClientSecret string `yaml:"client-secret"`
Scopes []string `yaml:"scopes"` // e.g. ["openid"]
AllowedSubjects []string `yaml:"allowed-subjects"` // e.g. ["user1@example.com"]. If empty, all subjects are allowed
IssuerURL string `yaml:"issuer-url"` // e.g. https://dev-12345678.okta.com
RedirectURL string `yaml:"redirect-url"` // e.g. http://localhost:8080/authorization-code/callback
ClientID string `yaml:"client-id"`
ClientSecret string `yaml:"client-secret"`
Scopes []string `yaml:"scopes"` // e.g. ["openid"]
AllowedSubjects []string `yaml:"allowed-subjects"` // e.g. ["user1@example.com"]. If empty, all subjects are allowed
SessionTTL time.Duration `yaml:"session-ttl"` // e.g. 8h. Defaults to 8 hours
oauth2Config oauth2.Config
verifier *oidc.IDTokenVerifier
}
// isValid returns whether the basic security configuration is valid or not
func (c *OIDCConfig) isValid() bool {
// ValidateAndSetDefaults returns whether the OIDC configuration is valid and sets default values.
func (c *OIDCConfig) ValidateAndSetDefaults() bool {
if c.SessionTTL <= 0 {
c.SessionTTL = DefaultOIDCSessionTTL
}
return len(c.IssuerURL) > 0 && len(c.RedirectURL) > 0 && strings.HasSuffix(c.RedirectURL, "/authorization-code/callback") && len(c.ClientID) > 0 && len(c.ClientSecret) > 0 && len(c.Scopes) > 0
}
@@ -131,12 +139,12 @@ func (c *OIDCConfig) callbackHandler(w http.ResponseWriter, r *http.Request) { /
func (c *OIDCConfig) setSessionCookie(w http.ResponseWriter, idToken *oidc.IDToken) {
// At this point, the user has been confirmed. All that's left to do is create a session.
sessionID := uuid.NewString()
sessions.SetWithTTL(sessionID, idToken.Subject, time.Hour)
sessions.SetWithTTL(sessionID, idToken.Subject, c.SessionTTL)
http.SetCookie(w, &http.Cookie{
Name: cookieNameSession,
Value: sessionID,
Path: "/",
MaxAge: int(time.Hour.Seconds()),
MaxAge: int(c.SessionTTL.Seconds()),
SameSite: http.SameSiteStrictMode,
})
}

View File

@@ -4,11 +4,12 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/coreos/go-oidc/v3/oidc"
)
func TestOIDCConfig_isValid(t *testing.T) {
func TestOIDCConfig_ValidateAndSetDefaults(t *testing.T) {
c := &OIDCConfig{
IssuerURL: "https://sso.gatus.io/",
RedirectURL: "http://localhost:80/authorization-code/callback",
@@ -16,10 +17,14 @@ func TestOIDCConfig_isValid(t *testing.T) {
ClientSecret: "client-secret",
Scopes: []string{"openid"},
AllowedSubjects: []string{"user1@example.com"},
SessionTTL: 0, // Not set! ValidateAndSetDefaults should set it to DefaultOIDCSessionTTL
}
if !c.isValid() {
if !c.ValidateAndSetDefaults() {
t.Error("OIDCConfig should be valid")
}
if c.SessionTTL != DefaultOIDCSessionTTL {
t.Error("expected SessionTTL to be set to DefaultOIDCSessionTTL")
}
}
func TestOIDCConfig_callbackHandler(t *testing.T) {
@@ -68,3 +73,18 @@ func TestOIDCConfig_setSessionCookie(t *testing.T) {
t.Error("expected cookie to be set")
}
}
func TestOIDCConfig_setSessionCookieWithCustomTTL(t *testing.T) {
customTTL := 30 * time.Minute
c := &OIDCConfig{SessionTTL: customTTL}
responseRecorder := httptest.NewRecorder()
c.setSessionCookie(responseRecorder, &oidc.IDToken{Subject: "test@example.com"})
cookies := responseRecorder.Result().Cookies()
if len(cookies) == 0 {
t.Error("expected cookie to be set")
}
sessionCookie := cookies[0]
if sessionCookie.MaxAge != int(customTTL.Seconds()) {
t.Errorf("expected cookie MaxAge to be %d, but was %d", int(customTTL.Seconds()), sessionCookie.MaxAge)
}
}

View File

@@ -289,6 +289,7 @@ func (s *Store) InsertEndpointResult(ep *endpoint.Endpoint, result *endpoint.Res
// Get the success value of the previous result
var lastResultSuccess bool
if lastResultSuccess, err = s.getLastEndpointResultSuccessValue(tx, endpointID); err != nil {
// Silently fail
logr.Errorf("[sql.InsertEndpointResult] Failed to retrieve outcome of previous result for endpoint with key=%s: %s", ep.Key(), err.Error())
} else {
// If we managed to retrieve the outcome of the previous result, we'll compare it with the new result.
@@ -758,13 +759,19 @@ func (s *Store) getEndpointIDGroupAndNameByKey(tx *sql.Tx, key string) (id int64
}
func (s *Store) getEndpointEventsByEndpointID(tx *sql.Tx, endpointID int64, page, pageSize int) (events []*endpoint.Event, err error) {
// We need to get the most recent events, but return them in chronological order (oldest to newest)
// First, get the most recent events using a subquery, then order them chronologically
rows, err := tx.Query(
`
SELECT event_type, event_timestamp
FROM endpoint_events
WHERE endpoint_id = $1
FROM (
SELECT event_type, event_timestamp, endpoint_event_id
FROM endpoint_events
WHERE endpoint_id = $1
ORDER BY endpoint_event_id DESC
LIMIT $2 OFFSET $3
) AS recent_events
ORDER BY endpoint_event_id ASC
LIMIT $2 OFFSET $3
`,
endpointID,
pageSize,

View File

@@ -886,3 +886,59 @@ func TestStore_HasEndpointStatusNewerThan(t *testing.T) {
t.Error("expected not to have a newer status in the future")
}
}
// TestEventOrderingFix specifically tests the SQL ordering fix for issue #1040
// This test verifies that getEndpointEventsByEndpointID returns the most recent events
// in chronological order (oldest to newest)
func TestEventOrderingFix(t *testing.T) {
store, _ := NewStore("sqlite", t.TempDir()+"/test.db", false, 100, 100)
defer store.Close()
ep := &endpoint.Endpoint{
Name: "ordering-test",
Group: "test",
URL: "https://example.com",
}
// Create many events over time
baseTime := time.Now().Add(-100 * time.Hour) // Start 100 hours ago
for i := 0; i < 50; i++ {
result := &endpoint.Result{
Success: i%2 == 0, // Alternate between true/false to create events
Timestamp: baseTime.Add(time.Duration(i) * time.Hour),
}
err := store.InsertEndpointResult(ep, result)
if err != nil {
t.Fatalf("Failed to insert result %d: %v", i, err)
}
}
// Now retrieve events with pagination to test the ordering
tx, _ := store.db.Begin()
endpointID, _, _, _ := store.getEndpointIDGroupAndNameByKey(tx, ep.Key())
// Get the first page (should get the MOST RECENT events, but in chronological order)
events, err := store.getEndpointEventsByEndpointID(tx, endpointID, 1, 10)
tx.Commit()
if err != nil {
t.Fatalf("Failed to get events: %v", err)
}
if len(events) != 10 {
t.Errorf("Expected 10 events, got %d", len(events))
}
// Verify the events are in chronological order (oldest to newest)
for i := 1; i < len(events); i++ {
if events[i].Timestamp.Before(events[i-1].Timestamp) {
t.Errorf("Events not in chronological order: event %d timestamp %v is before event %d timestamp %v",
i, events[i].Timestamp, i-1, events[i-1].Timestamp)
}
}
// Verify these are the most recent events
// The last event in the returned list should be close to "now" (within the last few events we created)
lastEventTime := events[len(events)-1].Timestamp
expectedRecentTime := baseTime.Add(49 * time.Hour) // The most recent event we created
timeDiff := expectedRecentTime.Sub(lastEventTime)
if timeDiff > 10*time.Hour { // Allow some margin for events
t.Errorf("Events are not the most recent ones. Last event time: %v, expected around: %v (diff: %v)",
lastEventTime, expectedRecentTime, timeDiff)
}
t.Logf("Successfully retrieved %d most recent events in chronological order", len(events))
t.Logf("First event: %s at %v", events[0].Type, events[0].Timestamp)
t.Logf("Last event: %s at %v", events[len(events)-1].Type, events[len(events)-1].Timestamp)
}

View File

@@ -648,7 +648,7 @@ func TestHandleAlertingWithMinimumReminderInterval(t *testing.T) {
SuccessThreshold: 3,
SendOnResolved: &enabled,
Triggered: false,
MinimumReminderInterval: 1 * time.Second,
MinimumReminderInterval: 5 * time.Minute,
},
},
}

View File

@@ -15,7 +15,7 @@
{{ endpoint.name }}
</span>
</CardTitle>
<div class="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground">
<div class="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground min-h-[1.25rem]">
<span v-if="endpoint.group" class="truncate" :title="endpoint.group">{{ endpoint.group }}</span>
<span v-if="endpoint.group && hostname"></span>
<span v-if="hostname" class="truncate" :title="hostname">{{ hostname }}</span>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long