Compare commits

..

10 Commits

Author SHA1 Message Date
TwiN
86d5dabf90 security: Pin dependency versions
Too many cases of open source projects in the JS ecosystem just going wild lately
2022-03-22 19:34:55 -04:00
Bo-Yi Wu
a81c81e42c feat(alert): Add group-specific to email list (#264)
* feat(alert): Add group-specific to email list

Add group-specific to list for email alert

https://github.com/TwiN/gatus/issues/96

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>

* docs: update

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>

* Update README.md

* Update README.md

* Update README.md

* chore: update

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>

* Update README.md
2022-03-20 21:54:20 -04:00
Bo-Yi Wu
bec2820969 docs(example): move config.yaml to config folder (#265)
ref: https://github.com/TwiN/gatus/issues/151#issuecomment-912932934

update all exmaple in docker-compose file.

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2022-03-20 00:04:13 -04:00
TwiN
0bf2271a73 test: Improve coverage for endpoint health evaluation edge cases (#262) 2022-03-15 20:53:03 -04:00
TwiN
bd4b91bbbd fix: Display "<redacted>" instead of "host" in errors (#262) 2022-03-15 20:51:59 -04:00
Shashank D
fdec317df0 fix(config): replace hostname in error string if opted (#262) 2022-03-15 20:17:57 -04:00
TwiN
8970ad5ad5 refactor: Align new code from #259 with existing code 2022-03-09 21:05:57 -05:00
Andre Bindewald
c4255e65bc feat(client): OAuth2 Client credential support (#259)
* Initial implementation

* Added OAuth2 support to `client` config

* Revert "Initial implementation"

This reverts commit 7f2f3a603a.

* Restore vendored clientcredentials

* configureOAuth2 is now a func (including tests)

* README update

* Use the same OAuth2Config in all related tests

* Cleanup & comments
2022-03-09 20:53:51 -05:00
Jonah
fcf046cbe8 feat(alerting): Add support for custom Telegram API URL (#257) 2022-03-05 15:44:11 -05:00
TwiN
6932edc6d0 docs: Fix Google Chat alerting configuration example 2022-02-14 20:03:08 -05:00
21 changed files with 539 additions and 55 deletions

View File

@@ -7,7 +7,7 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:
- ./config.yaml:/config/config.yaml - ./config:/config
networks: networks:
- metrics - metrics

View File

@@ -6,7 +6,7 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:
- ./config.yaml:/config/config.yaml - ./config:/config
networks: networks:
- default - default

View File

@@ -19,7 +19,7 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:
- ./config.yaml:/config/config.yaml - ./config:/config
networks: networks:
- web - web
depends_on: depends_on:

View File

@@ -5,5 +5,5 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:
- ./config.yaml:/config/config.yaml - ./config:/config
- ./data:/data/ - ./data:/data/

View File

@@ -5,4 +5,4 @@ services:
ports: ports:
- 8080:8080 - 8080:8080
volumes: volumes:
- ./config.yaml:/config/config.yaml - ./config:/config

View File

@@ -274,10 +274,15 @@ In order to support a wide range of environments, each monitored endpoint has a
the client used to send the request. the client used to send the request.
| Parameter | Description | Default | | Parameter | Description | Default |
|:-------------------------|:------------------------------------------------------------------------|:--------| |:------------------------------|:---------------------------------------------------------------------------|:----------------|
| `client.insecure` | Whether to skip verifying the server's certificate chain and host name. | `false` | | `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.ignore-redirect` | Whether to ignore redirects (true) or follow them (false, default). | `false` |
| `client.timeout` | Duration before timing out. | `10s` | | `client.timeout` | Duration before timing out. | `10s` |
| `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 `[""]` |
Note that some of these parameters are ignored based on the type of endpoint. For instance, there's no certificate involved Note that some of these parameters are ignored based on the type of endpoint. For instance, there's no certificate involved
in ICMP requests (ping), therefore, setting `client.insecure` to `true` for an endpoint of that type will not do anything. in ICMP requests (ping), therefore, setting `client.insecure` to `true` for an endpoint of that type will not do anything.
@@ -304,6 +309,20 @@ endpoints:
- "[STATUS] == 200" - "[STATUS] == 200"
``` ```
This example shows how you can use the `client.oauth2` configuration to query a backend API with `Bearer token`:
```yaml
endpoints:
- name: website
url: "https://your.health.api/getHealth"
client:
oauth2:
token-url: https://your-token-server/token
client-id: 00000000-0000-0000-0000-000000000000
client-secret: your-client-secret
scopes: ['https://your.health.api/.default']
conditions:
- "[STATUS] == 200"
```
### Alerting ### Alerting
Gatus supports multiple alerting providers, such as Slack and PagerDuty, and supports different alerts for each Gatus supports multiple alerting providers, such as Slack and PagerDuty, and supports different alerts for each
@@ -358,7 +377,7 @@ endpoints:
#### Configuring Email alerts #### Configuring Email alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:-------------------------------|:-------------------------------------------------------------------------------------------|:----------------------| |:---------------------------------- |:------------------------------------------------------------------------------------------ |:------------- |
| `alerting.email` | Configuration for alerts of type `email` | `{}` | | `alerting.email` | Configuration for alerts of type `email` | `{}` |
| `alerting.email.from` | Email used to send the alert | Required `""` | | `alerting.email.from` | Email used to send the alert | Required `""` |
| `alerting.email.username` | Username of the SMTP server used to send the alert. If empty, uses `alerting.email.from`. | `""` | | `alerting.email.username` | Username of the SMTP server used to send the alert. If empty, uses `alerting.email.from`. | `""` |
@@ -367,6 +386,9 @@ endpoints:
| `alerting.email.port` | Port the mail server is listening to (e.g. `587`) | Required `0` | | `alerting.email.port` | Port the mail server is listening to (e.g. `587`) | Required `0` |
| `alerting.email.to` | Email(s) to send the alerts to | Required `""` | | `alerting.email.to` | Email(s) to send the alerts to | Required `""` |
| `alerting.email.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A | | `alerting.email.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| `alerting.email.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
| `alerting.email.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
| `alerting.email.overrides[].to` | Email(s) to send the alerts to | `""` |
```yaml ```yaml
alerting: alerting:
@@ -377,6 +399,11 @@ alerting:
host: "mail.example.com" host: "mail.example.com"
port: 587 port: 587
to: "recipient1@example.com,recipient2@example.com" to: "recipient1@example.com,recipient2@example.com"
# You can also add group-specific to keys, which will
# override the to key above for the specified groups
overrides:
- group: "core"
to: "recipient3@example.com,recipient4@example.com"
endpoints: endpoints:
- name: website - name: website
@@ -391,6 +418,19 @@ endpoints:
enabled: true enabled: true
description: "healthcheck failed" description: "healthcheck failed"
send-on-resolved: true send-on-resolved: true
- name: back-end
group: core
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[CERTIFICATE_EXPIRATION] > 48h"
alerts:
- type: email
enabled: true
description: "healthcheck failed"
send-on-resolved: true
``` ```
**NOTE:** Some mail servers are painfully slow. **NOTE:** Some mail servers are painfully slow.
@@ -405,7 +445,7 @@ endpoints:
```yaml ```yaml
alerting: alerting:
mattermost: googlechat:
webhook-url: "https://chat.googleapis.com/v1/spaces/*******/messages?key=**********&token=********" webhook-url: "https://chat.googleapis.com/v1/spaces/*******/messages?key=**********&token=********"
endpoints: endpoints:
@@ -423,10 +463,6 @@ endpoints:
send-on-resolved: true send-on-resolved: true
``` ```
Here's an example of what the notifications look like:
![Google chat notifications](.github/assets/googlechat-alerts.png)
#### Configuring Mattermost alerts #### Configuring Mattermost alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:------------------------------------|:--------------------------------------------------------------------------------------------|:--------------| |:------------------------------------|:--------------------------------------------------------------------------------------------|:--------------|
@@ -648,10 +684,11 @@ Here's an example of what the notifications look like:
#### Configuring Telegram alerts #### Configuring Telegram alerts
| Parameter | Description | Default | | Parameter | Description | Default |
|:----------------------------------|:-------------------------------------------------------------------------------------------|:--------------| |:----------------------------------|:-------------------------------------------------------------------------------------------|:---------------------------|
| `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` | | `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` |
| `alerting.telegram.token` | Telegram Bot Token | Required `""` | | `alerting.telegram.token` | Telegram Bot Token | Required `""` |
| `alerting.telegram.id` | Telegram User ID | Required `""` | | `alerting.telegram.id` | Telegram User ID | Required `""` |
| `alerting.telegram.api-url` | Telegram API URL | `https://api.telegram.org` |
| `alerting.telegram.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A | | `alerting.telegram.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
```yaml ```yaml

View File

@@ -21,10 +21,29 @@ type AlertProvider struct {
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
To string `yaml:"to"`
} }
// IsValid returns whether the provider's configuration is valid // IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) IsValid() bool {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
return false
}
registeredGroups[override.Group] = true
}
}
return len(provider.From) > 0 && len(provider.Password) > 0 && len(provider.Host) > 0 && len(provider.To) > 0 && provider.Port > 0 && provider.Port < math.MaxUint16 return len(provider.From) > 0 && len(provider.Password) > 0 && len(provider.Host) > 0 && len(provider.To) > 0 && provider.Port > 0 && provider.Port < math.MaxUint16
} }
@@ -39,7 +58,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
subject, body := provider.buildMessageSubjectAndBody(endpoint, alert, result, resolved) subject, body := provider.buildMessageSubjectAndBody(endpoint, alert, result, resolved)
m := gomail.NewMessage() m := gomail.NewMessage()
m.SetHeader("From", provider.From) m.SetHeader("From", provider.From)
m.SetHeader("To", strings.Split(provider.To, ",")...) m.SetHeader("To", strings.Split(provider.getToForGroup(endpoint.Group), ",")...)
m.SetHeader("Subject", subject) m.SetHeader("Subject", subject)
m.SetBody("text/plain", body) m.SetBody("text/plain", body)
d := gomail.NewDialer(provider.Host, provider.Port, username, provider.Password) d := gomail.NewDialer(provider.Host, provider.Port, username, provider.Password)
@@ -72,6 +91,18 @@ func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoin
return subject, message + description + "\n\nCondition results:\n" + results return subject, message + description + "\n\nCondition results:\n" + results
} }
// getToForGroup returns the appropriate email integration to for a given group
func (provider *AlertProvider) getToForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.To
}
}
}
return provider.To
}
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert { func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert

View File

@@ -7,7 +7,7 @@ import (
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v3/core"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertDefaultProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{} invalidProvider := AlertProvider{}
if invalidProvider.IsValid() { if invalidProvider.IsValid() {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
@@ -18,6 +18,47 @@ func TestAlertProvider_IsValid(t *testing.T) {
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
To: "to@example.com",
Group: "",
},
},
}
if providerWithInvalidOverrideGroup.IsValid() {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
To: "",
Group: "group",
},
},
}
if providerWithInvalidOverrideTo.IsValid() {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
From: "from@example.com",
Password: "password",
Host: "smtp.gmail.com",
Port: 587,
To: "to@example.com",
Overrides: []Override{
{
To: "to@example.com",
Group: "group",
},
},
}
if !providerWithValidOverride.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) { func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1" firstDescription := "description-1"
secondDescription := "description-2" secondDescription := "description-2"
@@ -77,3 +118,66 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
func TestAlertProvider_getToForGroup(t *testing.T) {
tests := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
To: "to@example.com",
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "to@example.com",
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
To: "to@example.com",
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "to@example.com",
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
To: "to@example.com",
Overrides: []Override{
{
Group: "group",
To: "to01@example.com",
},
},
},
InputGroup: "",
ExpectedOutput: "to@example.com",
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
To: "to@example.com",
Overrides: []Override{
{
Group: "group",
To: "to01@example.com",
},
},
},
InputGroup: "group",
ExpectedOutput: "to01@example.com",
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
if got := tt.Provider.getToForGroup(tt.InputGroup); got != tt.ExpectedOutput {
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
}
})
}
}

View File

@@ -11,10 +11,13 @@ import (
"github.com/TwiN/gatus/v3/core" "github.com/TwiN/gatus/v3/core"
) )
const defaultAPIURL = "https://api.telegram.org"
// AlertProvider is the configuration necessary for sending an alert using Telegram // AlertProvider is the configuration necessary for sending an alert using Telegram
type AlertProvider struct { type AlertProvider struct {
Token string `yaml:"token"` Token string `yaml:"token"`
ID string `yaml:"id"` ID string `yaml:"id"`
APIURL string `yaml:"api-url"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
@@ -28,7 +31,11 @@ func (provider *AlertProvider) IsValid() bool {
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved))) buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), buffer) apiURL := provider.APIURL
if apiURL == "" {
apiURL = defaultAPIURL
}
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/bot%s/sendMessage", apiURL, provider.Token), buffer)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -1,8 +1,13 @@
package client package client
import ( import (
"bytes"
"io/ioutil"
"net/http"
"testing" "testing"
"time" "time"
"github.com/TwiN/gatus/v3/test"
) )
func TestGetHTTPClient(t *testing.T) { func TestGetHTTPClient(t *testing.T) {
@@ -10,6 +15,12 @@ func TestGetHTTPClient(t *testing.T) {
Insecure: false, Insecure: false,
IgnoreRedirect: false, IgnoreRedirect: false,
Timeout: 0, Timeout: 0,
OAuth2Config: &OAuth2Config{
ClientID: "00000000-0000-0000-0000-000000000000",
ClientSecret: "secretsauce",
TokenURL: "https://token-server.local/token",
Scopes: []string{"https://application.local/.default"},
},
} }
cfg.ValidateAndSetDefaults() cfg.ValidateAndSetDefaults()
if GetHTTPClient(cfg) == nil { if GetHTTPClient(cfg) == nil {
@@ -146,3 +157,71 @@ func TestCanCreateTCPConnection(t *testing.T) {
t.Error("should've failed, because there's no port in the address") t.Error("should've failed, because there's no port in the address")
} }
} }
// This test checks if a HTTP client configured with `configureOAuth2()` automatically
// performs a Client Credentials OAuth2 flow and adds the obtained token as a `Authorization`
// header to all outgoing HTTP calls.
func TestHttpClientProvidesOAuth2BearerToken(t *testing.T) {
defer InjectHTTPClient(nil)
oAuth2Config := &OAuth2Config{
ClientID: "00000000-0000-0000-0000-000000000000",
ClientSecret: "secretsauce",
TokenURL: "https://token-server.local/token",
Scopes: []string{"https://application.local/.default"},
}
mockHttpClient := &http.Client{
Transport: test.MockRoundTripper(func(r *http.Request) *http.Response {
// if the mock HTTP client tries to get a token from the `token-server`
// we provide the expected token response
if r.Host == "token-server.local" {
return &http.Response{
StatusCode: http.StatusOK,
Body: ioutil.NopCloser(bytes.NewReader(
[]byte(
`{"token_type":"Bearer","expires_in":3599,"ext_expires_in":3599,"access_token":"secret-token"}`,
),
)),
}
}
// to verify the headers were sent as expected, we echo them back in the
// `X-Org-Authorization` header and check if the token value matches our
// mocked `token-server` response
return &http.Response{
StatusCode: http.StatusOK,
Header: map[string][]string{
"X-Org-Authorization": {r.Header.Get("Authorization")},
},
Body: http.NoBody,
}
}),
}
mockHttpClientWithOAuth := configureOAuth2(mockHttpClient, *oAuth2Config)
InjectHTTPClient(mockHttpClientWithOAuth)
request, err := http.NewRequest(http.MethodPost, "http://127.0.0.1:8282", http.NoBody)
if err != nil {
t.Error("expected no error, got", err.Error())
}
response, err := mockHttpClientWithOAuth.Do(request)
if err != nil {
t.Error("expected no error, got", err.Error())
}
if response.Header == nil {
t.Error("expected response headers, but got nil")
}
// the mock response echos the Authorization header used in the request back
// to us as `X-Org-Authorization` header, we check here if the value matches
// our expected token `secret-token`
if response.Header.Get("X-Org-Authorization") != "Bearer secret-token" {
t.Error("exptected `secret-token` as Bearer token in the mocked response header `X-Org-Authorization`, but got", response.Header.Get("X-Org-Authorization"))
}
}

View File

@@ -1,9 +1,14 @@
package client package client
import ( import (
"context"
"crypto/tls" "crypto/tls"
"errors"
"net/http" "net/http"
"time" "time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
) )
const ( const (
@@ -11,7 +16,8 @@ const (
) )
var ( var (
// DefaultConfig is the default client configuration ErrInvalidClientOAuth2Config = errors.New("invalid OAuth2 configuration, all fields are required")
defaultConfig = Config{ defaultConfig = Config{
Insecure: false, Insecure: false,
IgnoreRedirect: false, IgnoreRedirect: false,
@@ -28,22 +34,50 @@ func GetDefaultConfig() *Config {
// Config is the configuration for clients // Config is the configuration for clients
type Config struct { type Config struct {
// Insecure determines whether to skip verifying the server's certificate chain and host name // Insecure determines whether to skip verifying the server's certificate chain and host name
Insecure bool `yaml:"insecure"` Insecure bool `yaml:"insecure,omitempty"`
// IgnoreRedirect determines whether to ignore redirects (true) or follow them (false, default) // IgnoreRedirect determines whether to ignore redirects (true) or follow them (false, default)
IgnoreRedirect bool `yaml:"ignore-redirect"` IgnoreRedirect bool `yaml:"ignore-redirect,omitempty"`
// Timeout for the client // Timeout for the client
Timeout time.Duration `yaml:"timeout"` Timeout time.Duration `yaml:"timeout"`
// OAuth2Config is the OAuth2 configuration used for the client.
//
// If non-nil, the http.Client returned by getHTTPClient will automatically retrieve a token if necessary.
// See configureOAuth2 for more details.
OAuth2Config *OAuth2Config `yaml:"oauth2,omitempty"`
httpClient *http.Client httpClient *http.Client
} }
// OAuth2Config is the configuration for the OAuth2 client credentials flow
type OAuth2Config struct {
TokenURL string `yaml:"token-url"` // e.g. https://dev-12345678.okta.com/token
ClientID string `yaml:"client-id"`
ClientSecret string `yaml:"client-secret"`
Scopes []string `yaml:"scopes"` // e.g. ["openid"]
}
// ValidateAndSetDefaults validates the client configuration and sets the default values if necessary // ValidateAndSetDefaults validates the client configuration and sets the default values if necessary
func (c *Config) ValidateAndSetDefaults() { func (c *Config) ValidateAndSetDefaults() error {
if c.Timeout < time.Millisecond { if c.Timeout < time.Millisecond {
c.Timeout = 10 * time.Second c.Timeout = 10 * time.Second
} }
if c.HasOAuth2Config() && !c.OAuth2Config.isValid() {
return ErrInvalidClientOAuth2Config
}
return nil
}
// HasOAuth2Config returns true if the client has OAuth2 configuration parameters
func (c *Config) HasOAuth2Config() bool {
return c.OAuth2Config != nil
}
// isValid() returns true if the OAuth2 configuration is valid
func (c *OAuth2Config) isValid() bool {
return len(c.TokenURL) > 0 && len(c.ClientID) > 0 && len(c.ClientSecret) > 0 && len(c.Scopes) > 0
} }
// GetHTTPClient return an HTTP client matching the Config's parameters. // GetHTTPClient return an HTTP client matching the Config's parameters.
@@ -68,6 +102,22 @@ func (c *Config) getHTTPClient() *http.Client {
return nil return nil
}, },
} }
if c.HasOAuth2Config() {
c.httpClient = configureOAuth2(c.httpClient, *c.OAuth2Config)
}
} }
return c.httpClient return c.httpClient
} }
// configureOAuth2 returns an HTTP client that will obtain and refresh tokens as necessary.
// The returned Client and its Transport should not be modified.
func configureOAuth2(httpClient *http.Client, c OAuth2Config) *http.Client {
oauth2cfg := clientcredentials.Config{
ClientID: c.ClientID,
ClientSecret: c.ClientSecret,
Scopes: c.Scopes,
TokenURL: c.TokenURL,
}
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient)
return oauth2cfg.Client(ctx)
}

View File

@@ -111,7 +111,9 @@ func (endpoint *Endpoint) ValidateAndSetDefaults() error {
if endpoint.ClientConfig == nil { if endpoint.ClientConfig == nil {
endpoint.ClientConfig = client.GetDefaultConfig() endpoint.ClientConfig = client.GetDefaultConfig()
} else { } else {
endpoint.ClientConfig.ValidateAndSetDefaults() if err := endpoint.ClientConfig.ValidateAndSetDefaults(); err != nil {
return err
}
} }
if endpoint.UIConfig == nil { if endpoint.UIConfig == nil {
endpoint.UIConfig = ui.GetDefaultConfig() endpoint.UIConfig = ui.GetDefaultConfig()
@@ -195,6 +197,9 @@ func (endpoint *Endpoint) EvaluateHealth() *Result {
result.body = nil result.body = nil
// Clean up parameters that we don't need to keep in the results // Clean up parameters that we don't need to keep in the results
if endpoint.UIConfig.HideHostname { if endpoint.UIConfig.HideHostname {
for errIdx, errorString := range result.Errors {
result.Errors[errIdx] = strings.ReplaceAll(errorString, result.Hostname, "<redacted>")
}
result.Hostname = "" result.Hostname = ""
} }
return result return result

View File

@@ -8,6 +8,7 @@ import (
"github.com/TwiN/gatus/v3/alerting/alert" "github.com/TwiN/gatus/v3/alerting/alert"
"github.com/TwiN/gatus/v3/client" "github.com/TwiN/gatus/v3/client"
"github.com/TwiN/gatus/v3/core/ui"
) )
func TestEndpoint_IsEnabled(t *testing.T) { func TestEndpoint_IsEnabled(t *testing.T) {
@@ -270,6 +271,9 @@ func TestIntegrationEvaluateHealth(t *testing.T) {
if !result.Success { if !result.Success {
t.Error("Because all conditions passed, this should have been a success") t.Error("Because all conditions passed, this should have been a success")
} }
if result.Hostname != "twin.sh" {
t.Error("result.Hostname should've been twin.sh, but was", result.Hostname)
}
} }
func TestIntegrationEvaluateHealthWithFailure(t *testing.T) { func TestIntegrationEvaluateHealthWithFailure(t *testing.T) {
@@ -288,7 +292,53 @@ func TestIntegrationEvaluateHealthWithFailure(t *testing.T) {
t.Error("Because the connection has been established, result.Connected should've been true") t.Error("Because the connection has been established, result.Connected should've been true")
} }
if result.Success { if result.Success {
t.Error("Because one of the conditions failed, success should have been false") t.Error("Because one of the conditions failed, result.Success should have been false")
}
}
func TestIntegrationEvaluateHealthWithInvalidCondition(t *testing.T) {
condition := Condition("[STATUS] invalid 200")
endpoint := Endpoint{
Name: "invalid-condition",
URL: "https://twin.sh/health",
Conditions: []*Condition{&condition},
}
if err := endpoint.ValidateAndSetDefaults(); err != nil {
// XXX: Should this really not return an error? After all, the condition is not valid and conditions are part of the endpoint...
t.Error("endpoint validation should've been successful, but wasn't")
}
result := endpoint.EvaluateHealth()
if result.Success {
t.Error("Because one of the conditions was invalid, result.Success should have been false")
}
if len(result.Errors) == 0 {
t.Error("There should've been an error")
}
}
func TestIntegrationEvaluateHealthWithError(t *testing.T) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{
Name: "invalid-host",
URL: "http://invalid/health",
Conditions: []*Condition{&condition},
UIConfig: &ui.Config{
HideHostname: true,
},
}
endpoint.ValidateAndSetDefaults()
result := endpoint.EvaluateHealth()
if result.Success {
t.Error("Because one of the conditions was invalid, result.Success should have been false")
}
if len(result.Errors) == 0 {
t.Error("There should've been an error")
}
if !strings.Contains(result.Errors[0], "<redacted>") {
t.Error("result.Errors[0] should've had the hostname redacted because ui.hide-hostname is set to true")
}
if result.Hostname != "" {
t.Error("result.Hostname should've been empty because ui.hide-hostname is set to true")
} }
} }

View File

@@ -0,0 +1,120 @@
// Copyright 2014 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package clientcredentials implements the OAuth2.0 "client credentials" token flow,
// also known as the "two-legged OAuth 2.0".
//
// This should be used when the client is acting on its own behalf or when the client
// is the resource owner. It may also be used when requesting access to protected
// resources based on an authorization previously arranged with the authorization
// server.
//
// See https://tools.ietf.org/html/rfc6749#section-4.4
package clientcredentials // import "golang.org/x/oauth2/clientcredentials"
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"golang.org/x/oauth2"
"golang.org/x/oauth2/internal"
)
// Config describes a 2-legged OAuth2 flow, with both the
// client application information and the server's endpoint URLs.
type Config struct {
// ClientID is the application's ID.
ClientID string
// ClientSecret is the application's secret.
ClientSecret string
// TokenURL is the resource server's token endpoint
// URL. This is a constant specific to each server.
TokenURL string
// Scope specifies optional requested permissions.
Scopes []string
// EndpointParams specifies additional parameters for requests to the token endpoint.
EndpointParams url.Values
// AuthStyle optionally specifies how the endpoint wants the
// client ID & client secret sent. The zero value means to
// auto-detect.
AuthStyle oauth2.AuthStyle
}
// Token uses client credentials to retrieve a token.
//
// The provided context optionally controls which HTTP client is used. See the oauth2.HTTPClient variable.
func (c *Config) Token(ctx context.Context) (*oauth2.Token, error) {
return c.TokenSource(ctx).Token()
}
// Client returns an HTTP client using the provided token.
// The token will auto-refresh as necessary.
//
// The provided context optionally controls which HTTP client
// is returned. See the oauth2.HTTPClient variable.
//
// The returned Client and its Transport should not be modified.
func (c *Config) Client(ctx context.Context) *http.Client {
return oauth2.NewClient(ctx, c.TokenSource(ctx))
}
// TokenSource returns a TokenSource that returns t until t expires,
// automatically refreshing it as necessary using the provided context and the
// client ID and client secret.
//
// Most users will use Config.Client instead.
func (c *Config) TokenSource(ctx context.Context) oauth2.TokenSource {
source := &tokenSource{
ctx: ctx,
conf: c,
}
return oauth2.ReuseTokenSource(nil, source)
}
type tokenSource struct {
ctx context.Context
conf *Config
}
// Token refreshes the token by using a new client credentials request.
// tokens received this way do not include a refresh token
func (c *tokenSource) Token() (*oauth2.Token, error) {
v := url.Values{
"grant_type": {"client_credentials"},
}
if len(c.conf.Scopes) > 0 {
v.Set("scope", strings.Join(c.conf.Scopes, " "))
}
for k, p := range c.conf.EndpointParams {
// Allow grant_type to be overridden to allow interoperability with
// non-compliant implementations.
if _, ok := v[k]; ok && k != "grant_type" {
return nil, fmt.Errorf("oauth2: cannot overwrite parameter %q", k)
}
v[k] = p
}
tk, err := internal.RetrieveToken(c.ctx, c.conf.ClientID, c.conf.ClientSecret, c.conf.TokenURL, v, internal.AuthStyle(c.conf.AuthStyle))
if err != nil {
if rErr, ok := err.(*internal.RetrieveError); ok {
return nil, (*oauth2.RetrieveError)(rErr)
}
return nil, err
}
t := &oauth2.Token{
AccessToken: tk.AccessToken,
TokenType: tk.TokenType,
RefreshToken: tk.RefreshToken,
Expiry: tk.Expiry,
}
return t.WithExtra(tk.Raw), nil
}

1
vendor/modules.txt vendored
View File

@@ -116,6 +116,7 @@ golang.org/x/net/ipv6
# golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c # golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c
## explicit; go 1.11 ## explicit; go 1.11
golang.org/x/oauth2 golang.org/x/oauth2
golang.org/x/oauth2/clientcredentials
golang.org/x/oauth2/internal golang.org/x/oauth2/internal
# golang.org/x/sync v0.0.0-20210220032951-036812b2e83c # golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
## explicit ## explicit

View File

@@ -1,6 +1,6 @@
{ {
"name": "gatus", "name": "gatus",
"version": "3.4.0", "version": "3.6.0",
"private": true, "private": true,
"scripts": { "scripts": {
"serve": "vue-cli-service serve --mode development", "serve": "vue-cli-service serve --mode development",
@@ -8,9 +8,9 @@
"lint": "vue-cli-service lint" "lint": "vue-cli-service lint"
}, },
"dependencies": { "dependencies": {
"core-js": "^3.19.1", "core-js": "3.21.0",
"vue": "3.2.21", "vue": "3.2.21",
"vue-router": "^4.0.11" "vue-router": "4.0.12"
}, },
"devDependencies": { "devDependencies": {
"@vue/cli-plugin-babel": "5.0.0-rc.2", "@vue/cli-plugin-babel": "5.0.0-rc.2",
@@ -18,12 +18,12 @@
"@vue/cli-plugin-router": "5.0.0-rc.2", "@vue/cli-plugin-router": "5.0.0-rc.2",
"@vue/cli-service": "5.0.0-rc.2", "@vue/cli-service": "5.0.0-rc.2",
"@vue/compiler-sfc": "3.2.29", "@vue/compiler-sfc": "3.2.29",
"autoprefixer": "^10.4.1", "autoprefixer": "10.4.2",
"babel-eslint": "^10.1.0", "babel-eslint": "10.1.0",
"eslint": "^7.32.0", "eslint": "7.32.0",
"eslint-plugin-vue": "^7.17.0", "eslint-plugin-vue": "7.20.0",
"postcss": "^8.4.5", "postcss": "8.4.6",
"tailwindcss": "^3.0.8" "tailwindcss": "3.0.18"
}, },
"eslintConfig": { "eslintConfig": {
"root": true, "root": true,