Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7f0a32135 | ||
|
|
405c15f756 | ||
|
|
6f1312dfcf | ||
|
|
bd296c75da | ||
|
|
f007725140 | ||
|
|
40345a03d3 | ||
|
|
97a2be3504 | ||
|
|
15a4133502 | ||
|
|
64a5043655 |
No files matched your search
@@ -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,65 @@ 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"
|
||||
```
|
||||
|
||||
> ⚠️ **WARNING**:: Tunneling may introduce additional latency, especially if the connection to the tunnel is retried frequently.
|
||||
> This may lead to inaccurate response time measurements.
|
||||
|
||||
|
||||
### 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 +1881,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 |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
}
|
||||
+17
-2
@@ -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
|
||||
}
|
||||
|
||||
+106
-48
@@ -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,26 +573,23 @@ func validateUniqueKeys(config *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSecurityConfig(config *Config) error {
|
||||
func ValidateSecurityConfig(config *Config) error {
|
||||
if config.Security != nil {
|
||||
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.
|
||||
if !config.Security.ValidateAndSetDefaults() {
|
||||
logr.Debug("[config.ValidateSecurityConfig] Basic security configuration has been validated")
|
||||
return ErrInvalidSecurityConfig
|
||||
}
|
||||
}
|
||||
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 +644,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 +658,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 +672,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 +680,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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -31,17 +31,17 @@ var (
|
||||
//
|
||||
// Uses UTC by default.
|
||||
type Config struct {
|
||||
Enabled *bool `yaml:"enabled"` // Whether the maintenance period is enabled. Enabled by default if nil.
|
||||
Start string `yaml:"start"` // Time at which the maintenance period starts (e.g. 23:00)
|
||||
Duration time.Duration `yaml:"duration"` // Duration of the maintenance period (e.g. 4h)
|
||||
Timezone string `yaml:"timezone"` // Timezone in string format which the maintenance period is configured (e.g. America/Sao_Paulo)
|
||||
Enabled *bool `yaml:"enabled"` // Whether the maintenance period is enabled. Enabled by default if nil.
|
||||
Start string `yaml:"start,omitempty"` // Time at which the maintenance period starts (e.g. 23:00)
|
||||
Duration time.Duration `yaml:"duration,omitempty"` // Duration of the maintenance period (e.g. 4h)
|
||||
Timezone string `yaml:"timezone,omitempty"` // Timezone in string format which the maintenance period is configured (e.g. America/Sao_Paulo)
|
||||
|
||||
// Every is a list of days of the week during which maintenance period applies.
|
||||
// See longDayNames for list of valid values.
|
||||
// Every day if empty.
|
||||
Every []string `yaml:"every"`
|
||||
Every []string `yaml:"every,omitempty"`
|
||||
|
||||
TimezoneLocation *time.Location // Timezone in location format which the maintenance period is configured
|
||||
timezoneLocation *time.Location
|
||||
durationToStartFromMidnight time.Duration
|
||||
}
|
||||
|
||||
@@ -90,13 +90,13 @@ func (c *Config) ValidateAndSetDefaults() error {
|
||||
return errInvalidMaintenanceDuration
|
||||
}
|
||||
if c.Timezone != "" {
|
||||
c.TimezoneLocation, err = time.LoadLocation(c.Timezone)
|
||||
c.timezoneLocation, err = time.LoadLocation(c.Timezone)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", errInvalidTimezone, err)
|
||||
}
|
||||
} else {
|
||||
c.Timezone = "UTC"
|
||||
c.TimezoneLocation = time.UTC
|
||||
c.timezoneLocation = time.UTC
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -107,8 +107,8 @@ func (c *Config) IsUnderMaintenance() bool {
|
||||
return false
|
||||
}
|
||||
now := time.Now()
|
||||
if c.TimezoneLocation != nil {
|
||||
now = now.In(c.TimezoneLocation)
|
||||
if c.timezoneLocation != nil {
|
||||
now = now.In(c.timezoneLocation)
|
||||
}
|
||||
adjustedDate := now.Day()
|
||||
if now.Hour() < int(c.durationToStartFromMidnight.Hours()) {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
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()
|
||||
}
|
||||
// Attempt dial with exponential backoff retry
|
||||
const maxRetries = 3
|
||||
const baseDelay = 500 * time.Millisecond
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
// Exponential backoff: 500ms, 1s, 2s
|
||||
delay := baseDelay << (attempt - 1)
|
||||
time.Sleep(delay)
|
||||
// Close stale connection and reconnect
|
||||
t.mu.Lock()
|
||||
if t.client != nil {
|
||||
_ = t.client.Close()
|
||||
t.client = nil
|
||||
}
|
||||
if err := t.connectUnsafe(); err != nil {
|
||||
t.mu.Unlock()
|
||||
lastErr = fmt.Errorf("reconnect attempt %d failed: %w", attempt, err)
|
||||
continue
|
||||
}
|
||||
client = t.client
|
||||
t.mu.Unlock()
|
||||
}
|
||||
conn, err := client.Dial(network, addr)
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return nil, fmt.Errorf("SSH tunnel dial failed after %d attempts: %w", maxRetries, lastErr)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -648,7 +648,7 @@ func TestHandleAlertingWithMinimumReminderInterval(t *testing.T) {
|
||||
SuccessThreshold: 3,
|
||||
SendOnResolved: &enabled,
|
||||
Triggered: false,
|
||||
MinimumReminderInterval: 1 * time.Second,
|
||||
MinimumReminderInterval: 5 * time.Minute,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
/*
|
||||
! tailwindcss v3.1.8 | MIT License | https://tailwindcss.com
|
||||
*/*,:after,:before{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:after,:before{--tw-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:Consolas,Monaco,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}:root{--background:0 0% 100%;--foreground:222.2 84% 4.9%;--card:0 0% 100%;--card-foreground:222.2 84% 4.9%;--popover:0 0% 100%;--popover-foreground:222.2 84% 4.9%;--primary:222.2 47.4% 11.2%;--primary-foreground:210 40% 98%;--secondary:210 40% 96.1%;--secondary-foreground:222.2 47.4% 11.2%;--muted:210 40% 96.1%;--muted-foreground:215.4 16.3% 46.9%;--accent:210 40% 96.1%;--accent-foreground:222.2 47.4% 11.2%;--destructive:0 84.2% 60.2%;--destructive-foreground:210 40% 98%;--border:214.3 31.8% 91.4%;--input:214.3 31.8% 91.4%;--ring:222.2 84% 4.9%;--radius:0.5rem}:root.dark{--background:222.2 84% 4.9%;--foreground:210 40% 98%;--card:222.2 84% 4.9%;--card-foreground:210 40% 98%;--popover:222.2 84% 4.9%;--popover-foreground:210 40% 98%;--primary:210 40% 98%;--primary-foreground:222.2 47.4% 11.2%;--secondary:217.2 32.6% 17.5%;--secondary-foreground:210 40% 98%;--muted:217.2 32.6% 17.5%;--muted-foreground:215 20.2% 65.1%;--accent:217.2 32.6% 17.5%;--accent-foreground:210 40% 98%;--destructive:0 62.8% 30.6%;--destructive-foreground:210 40% 98%;--border:217.2 32.6% 17.5%;--input:217.2 32.6% 17.5%;--ring:212.7 26.8% 83.9%}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground))}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-bluLine truncated
|
||||
*/*,:after,:before{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:after,:before{--tw-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:Consolas,Monaco,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}:root{--background:0 0% 100%;--foreground:222.2 84% 4.9%;--card:0 0% 100%;--card-foreground:222.2 84% 4.9%;--popover:0 0% 100%;--popover-foreground:222.2 84% 4.9%;--primary:222.2 47.4% 11.2%;--primary-foreground:210 40% 98%;--secondary:210 40% 96.1%;--secondary-foreground:222.2 47.4% 11.2%;--muted:210 40% 96.1%;--muted-foreground:215.4 16.3% 46.9%;--accent:210 40% 96.1%;--accent-foreground:222.2 47.4% 11.2%;--destructive:0 84.2% 60.2%;--destructive-foreground:210 40% 98%;--border:214.3 31.8% 91.4%;--input:214.3 31.8% 91.4%;--ring:222.2 84% 4.9%;--radius:0.5rem}:root.dark{--background:222.2 84% 4.9%;--foreground:210 40% 98%;--card:222.2 84% 4.9%;--card-foreground:210 40% 98%;--popover:222.2 84% 4.9%;--popover-foreground:210 40% 98%;--primary:210 40% 98%;--primary-foreground:222.2 47.4% 11.2%;--secondary:217.2 32.6% 17.5%;--secondary-foreground:210 40% 98%;--muted:217.2 32.6% 17.5%;--muted-foreground:215 20.2% 65.1%;--accent:217.2 32.6% 17.5%;--accent-foreground:210 40% 98%;--destructive:0 62.8% 30.6%;--destructive-foreground:210 40% 98%;--border:217.2 32.6% 17.5%;--input:217.2 32.6% 17.5%;--ring:212.7 26.8% 83.9%}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground))}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-bluLine truncated
|
||||
@@ -1 +1 @@
|
||||
(function(){"use strict";var e={671:function(e,t,s){s.d(t,{L:function(){return xn}});var l=s(963),a=s(252),r=s(577),n=s(262),o=s.p+"img/logo.svg",u=s(201),i=s(507),d=s(970),c=s(135),g=s(3),p=s(512),m=s(388);function v(...e){return(0,m.m6)((0,p.W)(e))}const f=["disabled"];var w={__name:"Button",props:{variant:{type:String,default:"default"},size:{type:String,default:"default"},disabled:{type:Boolean,default:!1}},setup(e){const t=(0,g.j)("inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",{variants:{variant:{default:"bg-primary text-primary-foreground hover:bg-primary/90",destructive:"bg-destructive text-destructive-foreground hover:bg-destructive/90",outline:"border border-input bg-background hover:bg-accent hover:text-accent-foreground",secondary:"bg-secondary text-secondary-foreground hover:bg-secondary/80",ghost:"hover:bg-accent hover:text-accent-foreground",link:"text-primary underline-offset-4 hover:underline"},size:{default:"h-10 px-4 py-2",sm:"h-9 rounded-md px-3",lg:"h-11 rounded-md px-8",icon:"h-10 w-10"}},defaultVariants:{variant:"default",size:"default"}});return(s,l)=>((0,a.wg)(),(0,a.iD)("button",{class:(0,r.C_)((0,n.SU)(v)((0,n.SU)(t)({variant:e.variant,size:e.size}),s.$attrs.class??"")),disabled:e.disabled},[(0,a.WI)(s.$slots,"default")],10,f))}};const x=w;var h=x,b={__name:"Card",setup(e){return(e,t)=>((0,a.wg)(),(0,a.iD)("div",{class:(0,r.C_)((0,n.SU)(v)("rounded-lg border bg-card text-card-foreground shadow-sm",e.$attrs.class??""))},[(0,a.WI)(e.$slots,"default")],2))}};const y=b;var k=y,_={__name:"CardHeader",setup(e){return(e,t)=>((0,a.wg)(),(0,a.iD)("div",{class:(0,r.C_)((0,n.SU)(v)("flex flex-col space-y-1.5 p-6",e.$attrs.class??""))},[(0,a.WI)(e.$slots,"default")],2))}};const S=_;var D=S,U={__name:"CardTitle",setup(e){return(e,t)=>((0,a.wg)(),(0,a.iD)("h3",{class:(0,r.C_)((0,n.SU)(v)("text-2xl font-semibold leading-none tracking-tight",e.$attrs.class??""))},[(0,a.WI)(e.$slots,"default")],2))}};const z=U;var W=z,C={__name:"CardContent",setup(e){return(e,t)=>((0,a.wg)(),(0,a.iD)("div",{class:(0,r.C_)((0,n.SU)(v)("p-6 pt-0",e.$attrs.class??""))},[(0,a.WI)(e.$slots,"default")],2))}};const j=C;var H=j;const R={id:"social"};function F(e,t){return(0,a.wg)(),(0,a.iD)("div",R,t[0]||(t[0]=[(0,a._)("a",{href:"https://github.com/TwiN/gatus",target:"_blank",title:"Gatus on GitHub"},[(0,a._)("svg",{xmlns:"http://www.w3.org/2000/svg",width:"32",height:"32",viewBox:"0 0 16 16",class:"hover:scale-110"},[(0,a._)("path",{fill:"gray",d:"M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"})])],-1)]))}var q=s(744);const $={},Z=(0,q.Z)($,[["render",F],["__scopeId","data-v-788af9ce"]]);var T=Z;const E=e=>{let t=(new Date).getTime()-new Date(e).getTime();if(t<500)return"now";if(t>2592e5){let e=(t/864e5).toFixed(0);return e+" day"+("1"!==e?"s":"")+" ago"}if(t>36e5){let e=(t/36e5).toFixed(0);return e+" hour"+("1"!==e?"s":"")+" ago"}if(t>6e4){let e=(t/6e4).toFixed(0);return e+" minute"+("1"!==e?"s":"")+" ago"}let s=(t/1e3).toFixed(0);return s+" second"+("1"!==s?"s":"")+" ago"},L=(e,t)=>{let s=Math.ceil((new Date(e)-new Date(t))/1e3/60);return s+(1===s?" minute":" minutes")},A=e=>{let t=new Date(e),s=t.getFullYear(),l=(t.getMonth()+1<10?"0":"")+(t.getMonth()+1),a=(t.getDate()<10?"0":"")+t.getDate(),r=(t.getHours()<10?"0":"")+t.getHours(),n=(t.getMinutes()<10?"0":"")+t.getMinutes(),o=(t.getSeconds()<10?"0":"")+t.getSeconds();return s+"-"+l+"-"+a+" "+r+":"+n+":"+o},N={key:0,class:"space-y-2"},M={key:0,class:"flex items-center gap-2"},I={class:"text-xs font-semibold"},Y={class:"font-mono text-xs"},O={key:1},K={class:"font-mono text-xs"},P={key:0,class:"mt-1 space-y-0.5"},V={class:"truncate"},G={class:"text-muted-foreground"},B={key:0,class:"text-xs text-muted-foreground"},J={class:"text-xs font-semibold text-muted-foreground uppercase tracking-wider"},Q={class:"font-mono text-xs"},X={key:2},ee={class:"font-mono text-xs space-y-0.5"},te={class:"break-all"},se={key:3},le={class:"font-mono text-xs space-y-0.5"};var ae={__name:"Tooltip",props:{event:{type:[Event,Object],default:null},result:{type:Object,default:null}},setup(e){const t=e,s=(0,n.iH)(!0),l=(0,n.iH)(0),o=(0,n.iH)(0),u=(0,n.iH)(null),i=(0,a.Fl)((()=>t.result&&void 0!==t.result.endpointResults)),d=(0,a.Fl)((()=>i.value&&t.resulLine truncated
|
||||
(function(){"use strict";var e={671:function(e,t,s){s.d(t,{L:function(){return xn}});var l=s(963),a=s(252),r=s(577),n=s(262),o=s.p+"img/logo.svg",u=s(201),i=s(507),d=s(970),c=s(135),g=s(3),p=s(512),m=s(388);function v(...e){return(0,m.m6)((0,p.W)(e))}const f=["disabled"];var w={__name:"Button",props:{variant:{type:String,default:"default"},size:{type:String,default:"default"},disabled:{type:Boolean,default:!1}},setup(e){const t=(0,g.j)("inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",{variants:{variant:{default:"bg-primary text-primary-foreground hover:bg-primary/90",destructive:"bg-destructive text-destructive-foreground hover:bg-destructive/90",outline:"border border-input bg-background hover:bg-accent hover:text-accent-foreground",secondary:"bg-secondary text-secondary-foreground hover:bg-secondary/80",ghost:"hover:bg-accent hover:text-accent-foreground",link:"text-primary underline-offset-4 hover:underline"},size:{default:"h-10 px-4 py-2",sm:"h-9 rounded-md px-3",lg:"h-11 rounded-md px-8",icon:"h-10 w-10"}},defaultVariants:{variant:"default",size:"default"}});return(s,l)=>((0,a.wg)(),(0,a.iD)("button",{class:(0,r.C_)((0,n.SU)(v)((0,n.SU)(t)({variant:e.variant,size:e.size}),s.$attrs.class??"")),disabled:e.disabled},[(0,a.WI)(s.$slots,"default")],10,f))}};const x=w;var h=x,b={__name:"Card",setup(e){return(e,t)=>((0,a.wg)(),(0,a.iD)("div",{class:(0,r.C_)((0,n.SU)(v)("rounded-lg border bg-card text-card-foreground shadow-sm",e.$attrs.class??""))},[(0,a.WI)(e.$slots,"default")],2))}};const y=b;var k=y,_={__name:"CardHeader",setup(e){return(e,t)=>((0,a.wg)(),(0,a.iD)("div",{class:(0,r.C_)((0,n.SU)(v)("flex flex-col space-y-1.5 p-6",e.$attrs.class??""))},[(0,a.WI)(e.$slots,"default")],2))}};const S=_;var D=S,U={__name:"CardTitle",setup(e){return(e,t)=>((0,a.wg)(),(0,a.iD)("h3",{class:(0,r.C_)((0,n.SU)(v)("text-2xl font-semibold leading-none tracking-tight",e.$attrs.class??""))},[(0,a.WI)(e.$slots,"default")],2))}};const z=U;var W=z,C={__name:"CardContent",setup(e){return(e,t)=>((0,a.wg)(),(0,a.iD)("div",{class:(0,r.C_)((0,n.SU)(v)("p-6 pt-0",e.$attrs.class??""))},[(0,a.WI)(e.$slots,"default")],2))}};const j=C;var H=j;const R={id:"social"};function F(e,t){return(0,a.wg)(),(0,a.iD)("div",R,t[0]||(t[0]=[(0,a._)("a",{href:"https://github.com/TwiN/gatus",target:"_blank",title:"Gatus on GitHub"},[(0,a._)("svg",{xmlns:"http://www.w3.org/2000/svg",width:"32",height:"32",viewBox:"0 0 16 16",class:"hover:scale-110"},[(0,a._)("path",{fill:"gray",d:"M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"})])],-1)]))}var q=s(744);const $={},Z=(0,q.Z)($,[["render",F],["__scopeId","data-v-788af9ce"]]);var T=Z;const E=e=>{let t=(new Date).getTime()-new Date(e).getTime();if(t<500)return"now";if(t>2592e5){let e=(t/864e5).toFixed(0);return e+" day"+("1"!==e?"s":"")+" ago"}if(t>36e5){let e=(t/36e5).toFixed(0);return e+" hour"+("1"!==e?"s":"")+" ago"}if(t>6e4){let e=(t/6e4).toFixed(0);return e+" minute"+("1"!==e?"s":"")+" ago"}let s=(t/1e3).toFixed(0);return s+" second"+("1"!==s?"s":"")+" ago"},L=(e,t)=>{let s=Math.ceil((new Date(e)-new Date(t))/1e3/60);return s+(1===s?" minute":" minutes")},A=e=>{let t=new Date(e),s=t.getFullYear(),l=(t.getMonth()+1<10?"0":"")+(t.getMonth()+1),a=(t.getDate()<10?"0":"")+t.getDate(),r=(t.getHours()<10?"0":"")+t.getHours(),n=(t.getMinutes()<10?"0":"")+t.getMinutes(),o=(t.getSeconds()<10?"0":"")+t.getSeconds();return s+"-"+l+"-"+a+" "+r+":"+n+":"+o},N={key:0,class:"space-y-2"},M={key:0,class:"flex items-center gap-2"},I={class:"text-xs font-semibold"},Y={class:"font-mono text-xs"},O={key:1},K={class:"font-mono text-xs"},P={key:0,class:"mt-1 space-y-0.5"},V={class:"truncate"},G={class:"text-muted-foreground"},B={key:0,class:"text-xs text-muted-foreground"},J={class:"text-xs font-semibold text-muted-foreground uppercase tracking-wider"},Q={class:"font-mono text-xs"},X={key:2},ee={class:"font-mono text-xs space-y-0.5"},te={class:"break-all"},se={key:3},le={class:"font-mono text-xs space-y-0.5"};var ae={__name:"Tooltip",props:{event:{type:[Event,Object],default:null},result:{type:Object,default:null}},setup(e){const t=e,s=(0,n.iH)(!0),l=(0,n.iH)(0),o=(0,n.iH)(0),u=(0,n.iH)(null),i=(0,a.Fl)((()=>t.result&&void 0!==t.result.endpointResults)),d=(0,a.Fl)((()=>i.value&&t.resulLine truncated
|
||||
Reference in new issue
Block a user