Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86d5dabf90 | ||
|
|
a81c81e42c | ||
|
|
bec2820969 | ||
|
|
0bf2271a73 | ||
|
|
bd4b91bbbd | ||
|
|
fdec317df0 | ||
|
|
8970ad5ad5 | ||
|
|
c4255e65bc | ||
|
|
fcf046cbe8 | ||
|
|
6932edc6d0 |
@@ -7,7 +7,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.yaml:/config/config.yaml
|
- ./config:/config
|
||||||
networks:
|
networks:
|
||||||
- metrics
|
- metrics
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.yaml:/config/config.yaml
|
- ./config:/config
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.yaml:/config/config.yaml
|
- ./config:/config
|
||||||
- ./data:/data/
|
- ./data:/data/
|
||||||
@@ -5,4 +5,4 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 8080:8080
|
- 8080:8080
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.yaml:/config/config.yaml
|
- ./config:/config
|
||||||
|
|||||||
53
README.md
53
README.md
@@ -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:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
#### 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
120
vendor/golang.org/x/oauth2/clientcredentials/clientcredentials.go
generated
vendored
Normal file
120
vendor/golang.org/x/oauth2/clientcredentials/clientcredentials.go
generated
vendored
Normal 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
1
vendor/modules.txt
vendored
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user