mirror of
https://github.com/prymitive/karma
synced 2026-05-21 04:33:07 +00:00
feat(backend): support header based auth
This commit is contained in:
@@ -67,6 +67,25 @@ func customJS(c *gin.Context) {
|
||||
serveFileOr404(config.Config.Custom.JS, "application/javascript", c)
|
||||
}
|
||||
|
||||
func headerAuth(name, valueRegex string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user := c.Request.Header.Get(name)
|
||||
if user == "" {
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
r := regexp.MustCompile("^" + valueRegex + "$")
|
||||
matches := r.FindAllStringSubmatch(user, 1)
|
||||
if len(matches) > 0 && len(matches[0]) > 1 {
|
||||
c.Set(gin.AuthUserKey, matches[0][1])
|
||||
} else {
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupRouter(router *gin.Engine) {
|
||||
router.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||
|
||||
@@ -99,9 +118,14 @@ func setupRouter(router *gin.Engine) {
|
||||
}))
|
||||
|
||||
var protected *gin.RouterGroup
|
||||
if len(config.Config.Authentication.Users) > 0 {
|
||||
if config.Config.Authentication.Header.Name != "" {
|
||||
config.Config.Authentication.Enabled = true
|
||||
protected = router.Group(getViewURL("/"),
|
||||
headerAuth(config.Config.Authentication.Header.Name, config.Config.Authentication.Header.ValueRegex))
|
||||
} else if len(config.Config.Authentication.BasicAuth.Users) > 0 {
|
||||
config.Config.Authentication.Enabled = true
|
||||
users := map[string]string{}
|
||||
for _, u := range config.Config.Authentication.Users {
|
||||
for _, u := range config.Config.Authentication.BasicAuth.Users {
|
||||
users[u.Username] = u.Password
|
||||
}
|
||||
protected = router.Group(getViewURL("/"), gin.BasicAuth(users))
|
||||
|
||||
@@ -11,8 +11,13 @@ import (
|
||||
)
|
||||
|
||||
func mainShoulFail() int {
|
||||
defer func() { log.StandardLogger().ExitFunc = nil }()
|
||||
var wasFatal bool
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
wasFatal = true
|
||||
}
|
||||
}()
|
||||
defer func() { log.StandardLogger().ExitFunc = nil }()
|
||||
log.StandardLogger().ExitFunc = func(int) { wasFatal = true }
|
||||
|
||||
_, err := mainSetup(pflag.ContinueOnError)
|
||||
|
||||
14
cmd/karma/tests/testscript/auth_basicAuth_no_credentials.txt
Normal file
14
cmd/karma/tests/testscript/auth_basicAuth_no_credentials.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
# Raises an error if basic auth credentials are missing
|
||||
karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml
|
||||
! stdout .
|
||||
stderr 'msg="authentication.basicAuth.users require both username and password to be set"'
|
||||
|
||||
-- karma.yaml --
|
||||
alertmanager:
|
||||
servers:
|
||||
- name: default
|
||||
uri: https://localhost:9093
|
||||
authentication:
|
||||
basicAuth:
|
||||
users:
|
||||
- foo: bar
|
||||
14
cmd/karma/tests/testscript/auth_basicAuth_no_password.txt
Normal file
14
cmd/karma/tests/testscript/auth_basicAuth_no_password.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
# Raises an error if basic auth password is missing
|
||||
karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml
|
||||
! stdout .
|
||||
stderr 'msg="authentication.basicAuth.users require both username and password to be set"'
|
||||
|
||||
-- karma.yaml --
|
||||
alertmanager:
|
||||
servers:
|
||||
- name: default
|
||||
uri: https://localhost:9093
|
||||
authentication:
|
||||
basicAuth:
|
||||
users:
|
||||
- username: me
|
||||
14
cmd/karma/tests/testscript/auth_basicAuth_no_username.txt
Normal file
14
cmd/karma/tests/testscript/auth_basicAuth_no_username.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
# Raises an error if basic auth username is missing
|
||||
karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml
|
||||
! stdout .
|
||||
stderr 'msg="authentication.basicAuth.users require both username and password to be set"'
|
||||
|
||||
-- karma.yaml --
|
||||
alertmanager:
|
||||
servers:
|
||||
- name: default
|
||||
uri: https://localhost:9093
|
||||
authentication:
|
||||
basicAuth:
|
||||
users:
|
||||
- password: foo
|
||||
18
cmd/karma/tests/testscript/auth_header_and_basicAuth.txt
Normal file
18
cmd/karma/tests/testscript/auth_header_and_basicAuth.txt
Normal file
@@ -0,0 +1,18 @@
|
||||
# Raises an error if both header & basic auth authentication is enabled
|
||||
karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml
|
||||
! stdout .
|
||||
stderr 'msg="Both authentication.basicAuth.users and authentication.header.name is set, only one can be enabled"'
|
||||
|
||||
-- karma.yaml --
|
||||
alertmanager:
|
||||
servers:
|
||||
- name: default
|
||||
uri: https://localhost:9093
|
||||
authentication:
|
||||
header:
|
||||
name: "foo"
|
||||
value_re: ".+"
|
||||
basicAuth:
|
||||
users:
|
||||
- username: me
|
||||
password: foo
|
||||
13
cmd/karma/tests/testscript/auth_header_no_name.txt
Normal file
13
cmd/karma/tests/testscript/auth_header_no_name.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
# Raises an error if header authentication config is missing name
|
||||
karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml
|
||||
! stdout .
|
||||
stderr 'msg="authentication.header.name is required when authentication.header.value_re is set"'
|
||||
|
||||
-- karma.yaml --
|
||||
alertmanager:
|
||||
servers:
|
||||
- name: default
|
||||
uri: https://localhost:9093
|
||||
authentication:
|
||||
header:
|
||||
value_re: ".+"
|
||||
13
cmd/karma/tests/testscript/auth_header_no_regex.txt
Normal file
13
cmd/karma/tests/testscript/auth_header_no_regex.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
# Raises an error if header authentication config is missing regex rule
|
||||
karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml
|
||||
! stdout .
|
||||
stderr 'msg="authentication.header.value_re is required when authentication.header.name is set"'
|
||||
|
||||
-- karma.yaml --
|
||||
alertmanager:
|
||||
servers:
|
||||
- name: default
|
||||
uri: https://localhost:9093
|
||||
authentication:
|
||||
header:
|
||||
name: "foo"
|
||||
14
cmd/karma/tests/testscript/auth_header_regex_invalid.txt
Normal file
14
cmd/karma/tests/testscript/auth_header_regex_invalid.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
# Raises an error if header authentication config contains invalid regex rule
|
||||
karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml
|
||||
! stdout .
|
||||
stderr 'msg="Invalid regex for authentication.header.value_re: error parsing regexp: invalid nested repetition operator: `\+\+`"'
|
||||
|
||||
-- karma.yaml --
|
||||
alertmanager:
|
||||
servers:
|
||||
- name: default
|
||||
uri: https://localhost:9093
|
||||
authentication:
|
||||
header:
|
||||
name: "foo"
|
||||
value_re: ".++***"
|
||||
@@ -70,7 +70,11 @@ cmp stderr expected.stderr
|
||||
level=info msg="Version: dev"
|
||||
level=info msg="Parsed configuration:"
|
||||
level=info msg="authentication:"
|
||||
level=info msg=" users: []"
|
||||
level=info msg=" header:"
|
||||
level=info msg=" name: \"\""
|
||||
level=info msg=" value_re: \"\""
|
||||
level=info msg=" basicAuth:"
|
||||
level=info msg=" users: []"
|
||||
level=info msg="alertmanager:"
|
||||
level=info msg=" interval: 10s"
|
||||
level=info msg=" servers:"
|
||||
|
||||
@@ -5,11 +5,12 @@ cmp stderr expected.stderr
|
||||
|
||||
-- custom.yaml --
|
||||
authentication:
|
||||
users:
|
||||
- username: number
|
||||
password: 1234
|
||||
- username: string
|
||||
password: '1234'
|
||||
basicAuth:
|
||||
users:
|
||||
- username: number
|
||||
password: 1234
|
||||
- username: string
|
||||
password: '1234'
|
||||
alertmanager:
|
||||
interval: 10s
|
||||
servers:
|
||||
@@ -238,11 +239,15 @@ level=info msg="Reading configuration file custom.yaml"
|
||||
level=info msg="Version: dev"
|
||||
level=info msg="Parsed configuration:"
|
||||
level=info msg="authentication:"
|
||||
level=info msg=" users:"
|
||||
level=info msg=" - username: number"
|
||||
level=info msg=" password: '***'"
|
||||
level=info msg=" - username: string"
|
||||
level=info msg=" password: '***'"
|
||||
level=info msg=" header:"
|
||||
level=info msg=" name: \"\""
|
||||
level=info msg=" value_re: \"\""
|
||||
level=info msg=" basicAuth:"
|
||||
level=info msg=" users:"
|
||||
level=info msg=" - username: number"
|
||||
level=info msg=" password: '***'"
|
||||
level=info msg=" - username: string"
|
||||
level=info msg=" password: '***'"
|
||||
level=info msg="alertmanager:"
|
||||
level=info msg=" interval: 10s"
|
||||
level=info msg=" servers:"
|
||||
|
||||
@@ -144,7 +144,7 @@ func alerts(c *gin.Context) {
|
||||
ts, _ := start.UTC().MarshalText()
|
||||
|
||||
var username string
|
||||
if len(config.Config.Authentication.Users) > 0 {
|
||||
if config.Config.Authentication.Enabled {
|
||||
username = c.MustGet(gin.AuthUserKey).(string)
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ func alerts(c *gin.Context) {
|
||||
},
|
||||
}
|
||||
resp.Authentication = models.AuthenticationInfo{
|
||||
Enabled: len(config.Config.Authentication.Users) > 0,
|
||||
Enabled: config.Config.Authentication.Enabled,
|
||||
Username: username,
|
||||
}
|
||||
|
||||
@@ -209,6 +209,7 @@ func alerts(c *gin.Context) {
|
||||
}
|
||||
newResp.Settings = resp.Settings
|
||||
newResp.Timestamp = string(ts)
|
||||
newResp.Authentication = resp.Authentication
|
||||
newData, err := json.Marshal(&newResp)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
|
||||
@@ -819,17 +819,149 @@ func TestEmptySettings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuth(t *testing.T) {
|
||||
config.Config.Authentication.Users = []config.AuthenticationUser{
|
||||
{Username: "john", Password: "foobar"},
|
||||
func TestAuthentication(t *testing.T) {
|
||||
type authTest struct {
|
||||
name string
|
||||
headerName string
|
||||
headerRe string
|
||||
basicAuthUsers []config.AuthenticationUser
|
||||
requestHeaders map[string]string
|
||||
requestBasicAuthUser string
|
||||
requestBasicAuthPassword string
|
||||
responseCode int
|
||||
responseUsername string
|
||||
}
|
||||
r := ginTestEngine()
|
||||
for _, path := range []string{"/", "/alerts.json", "/autocomplete.json"} {
|
||||
req := httptest.NewRequest("GET", path, nil)
|
||||
resp := httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
if resp.Code != 401 {
|
||||
t.Errorf("Expected 401 from %s, got %d", path, resp.Code)
|
||||
}
|
||||
|
||||
authTests := []authTest{
|
||||
{
|
||||
name: "basic auth, request without credentials, 401",
|
||||
basicAuthUsers: []config.AuthenticationUser{
|
||||
{Username: "john", Password: "foobar"},
|
||||
},
|
||||
responseCode: 401,
|
||||
},
|
||||
{
|
||||
name: "basic auth, missing password, 401",
|
||||
basicAuthUsers: []config.AuthenticationUser{
|
||||
{Username: "john", Password: "foobar"},
|
||||
},
|
||||
requestBasicAuthUser: "john",
|
||||
responseCode: 401,
|
||||
},
|
||||
{
|
||||
name: "basic auth, missing username, 401",
|
||||
basicAuthUsers: []config.AuthenticationUser{
|
||||
{Username: "john", Password: "foobar"},
|
||||
},
|
||||
requestBasicAuthPassword: "foobar",
|
||||
responseCode: 401,
|
||||
},
|
||||
{
|
||||
name: "basic auth, wrong password, 401",
|
||||
basicAuthUsers: []config.AuthenticationUser{
|
||||
{Username: "john", Password: "foobar"},
|
||||
},
|
||||
requestBasicAuthUser: "john",
|
||||
requestBasicAuthPassword: "foobarx",
|
||||
responseCode: 401,
|
||||
},
|
||||
{
|
||||
name: "basic auth, correct credentials, 200",
|
||||
basicAuthUsers: []config.AuthenticationUser{
|
||||
{Username: "john", Password: "foobar"},
|
||||
},
|
||||
requestBasicAuthUser: "john",
|
||||
requestBasicAuthPassword: "foobar",
|
||||
responseCode: 200,
|
||||
responseUsername: "john",
|
||||
},
|
||||
{
|
||||
name: "header auth, missing header, 401",
|
||||
headerName: "X-Auth",
|
||||
headerRe: "(.+)",
|
||||
responseCode: 401,
|
||||
},
|
||||
{
|
||||
name: "header auth, header value doesn't match, 401",
|
||||
headerName: "X-Auth",
|
||||
headerRe: "Username (.+)",
|
||||
requestHeaders: map[string]string{
|
||||
"X-Auth": "xxx",
|
||||
},
|
||||
responseCode: 401,
|
||||
},
|
||||
{
|
||||
name: "header auth, header value doesn't match #2, 401",
|
||||
headerName: "X-Auth",
|
||||
headerRe: "Username (.+)",
|
||||
requestHeaders: map[string]string{
|
||||
"X-Auth": "xxx Username xxx",
|
||||
},
|
||||
responseCode: 401,
|
||||
},
|
||||
{
|
||||
name: "header auth, header correct, 200",
|
||||
headerName: "X-Auth",
|
||||
headerRe: "(.+)",
|
||||
requestHeaders: map[string]string{
|
||||
"X-Auth": "john",
|
||||
},
|
||||
responseCode: 200,
|
||||
responseUsername: "john",
|
||||
},
|
||||
{
|
||||
name: "header auth, header correct #2, 200",
|
||||
headerName: "X-Auth",
|
||||
headerRe: "Username (.+)",
|
||||
requestHeaders: map[string]string{
|
||||
"X-Auth": "Username john",
|
||||
},
|
||||
responseCode: 200,
|
||||
responseUsername: "john",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range authTests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
config.Config.Authentication.Header.Name = testCase.headerName
|
||||
config.Config.Authentication.Header.ValueRegex = testCase.headerRe
|
||||
config.Config.Authentication.BasicAuth.Users = testCase.basicAuthUsers
|
||||
r := ginTestEngine()
|
||||
for _, path := range []string{
|
||||
"/",
|
||||
"/alerts.json",
|
||||
"/autocomplete.json?term=foo",
|
||||
"/labelNames.json",
|
||||
"/labelValues.json?name=foo",
|
||||
"/silences.json",
|
||||
"/custom.css",
|
||||
"/custom.js",
|
||||
} {
|
||||
req := httptest.NewRequest("GET", path, nil)
|
||||
for k, v := range testCase.requestHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.SetBasicAuth(testCase.requestBasicAuthUser, testCase.requestBasicAuthPassword)
|
||||
resp := httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
if resp.Code != testCase.responseCode {
|
||||
t.Errorf("Expected %d from %s, got %d", testCase.responseCode, path, resp.Code)
|
||||
}
|
||||
|
||||
if resp.Code == 200 && path == "/alerts.json" {
|
||||
ur := models.AlertsResponse{}
|
||||
err := json.Unmarshal(resp.Body.Bytes(), &ur)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to unmarshal response: %s", err)
|
||||
}
|
||||
if ur.Authentication.Enabled != true {
|
||||
t.Errorf("Got Authentication.Enabled=%v", ur.Authentication.Enabled)
|
||||
}
|
||||
if ur.Authentication.Username != testCase.responseUsername {
|
||||
t.Errorf("Got Authentication.Username=%s, expected %s", ur.Authentication.Username, testCase.responseUsername)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,18 +25,77 @@ CONFIG_FILE="docs/example.yaml"
|
||||
### Authentication
|
||||
|
||||
`authentication` sections allows enabling authentication support in karma.
|
||||
When set users will be require to authenticate to access karma.
|
||||
When set users will be required to authenticate when trying to access karma.
|
||||
There are currently two supported authentication methods:
|
||||
|
||||
- [Basic HTTP Authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#Basic_authentication_scheme).
|
||||
Karma will be performing authentication using configured list of username &
|
||||
password pairs. This method is only recommended for testing.
|
||||
- External authentication via headers. Karma doesn't perform any authentication
|
||||
itself, it is done by a frontend service (SSO or nginx reverse proxy) that
|
||||
sets a header with username on every request.
|
||||
|
||||
Only one method can be enabled in the config.
|
||||
Enabling authentication will also force silences to be created with usernames
|
||||
passed from credentials.
|
||||
|
||||
```YAML
|
||||
authentication:
|
||||
users:
|
||||
- username: string
|
||||
password: string
|
||||
header:
|
||||
name: string
|
||||
value_re: string
|
||||
basicAuth:
|
||||
users:
|
||||
- username: string
|
||||
password: string
|
||||
```
|
||||
|
||||
- `authentication:users:header:name` - name of the header that will contain the
|
||||
username. If this header is missing from a request access will be forbidden.
|
||||
When set header authentication is used.
|
||||
- `authentication:users:header:value_re` -
|
||||
[regex](https://golang.org/s/re2syntax) used to extract the username from the
|
||||
request header value (when `authentication:users:header:name` is set).
|
||||
It must include one numbered capturing group, whatever is matched by that
|
||||
group will be used as the silence form author field.
|
||||
This option must be set when `authentication:users:header:name` is set.
|
||||
- `authentication:users` - list of users (username & password) allowed to login.
|
||||
Passwords are stored plain without any encryption.
|
||||
When set HTTP basic authentication will be used.
|
||||
|
||||
Defaults:
|
||||
|
||||
```YAML
|
||||
authentication:
|
||||
header:
|
||||
name: ""
|
||||
value_re: ""
|
||||
basicAuth:
|
||||
users: []
|
||||
```
|
||||
|
||||
Example where HTTP Basic Authentication will be used with a list of username
|
||||
and password pairs set in karma config file.
|
||||
|
||||
```YAML
|
||||
authentication:
|
||||
basicAuth:
|
||||
users:
|
||||
- username: alice
|
||||
password: secret
|
||||
- username: bob
|
||||
password: moreSecret
|
||||
```
|
||||
|
||||
Example where the `X-Auth` header will be used for authentication, raw header
|
||||
value will be used as username.
|
||||
|
||||
```YAML
|
||||
authentication:
|
||||
header:
|
||||
name: X-Auth
|
||||
value_re: ^(.+)$
|
||||
```
|
||||
|
||||
### Alertmanagers
|
||||
|
||||
|
||||
@@ -265,10 +265,30 @@ func (config *configSchema) Read(flags *pflag.FlagSet) string {
|
||||
config.SilenceForm.Strip.Labels = []string{}
|
||||
}
|
||||
|
||||
for _, u := range config.Authentication.Users {
|
||||
if u.Username == "" || u.Password == "" {
|
||||
log.Fatalf("authentication.users require both username and password to be set")
|
||||
if config.Authentication.Header.Name != "" && len(config.Authentication.BasicAuth.Users) > 0 {
|
||||
log.Fatalf("Both authentication.basicAuth.users and authentication.header.name is set, only one can be enabled")
|
||||
}
|
||||
|
||||
if config.Authentication.Header.ValueRegex != "" {
|
||||
_, err = regexp.Compile(config.Authentication.Header.ValueRegex)
|
||||
if err != nil {
|
||||
log.Fatalf("Invalid regex for authentication.header.value_re: %s", err.Error())
|
||||
}
|
||||
if config.Authentication.Header.Name == "" {
|
||||
log.Fatalf("authentication.header.name is required when authentication.header.value_re is set")
|
||||
}
|
||||
} else if config.Authentication.Header.Name != "" {
|
||||
log.Fatalf("authentication.header.value_re is required when authentication.header.name is set")
|
||||
}
|
||||
|
||||
for _, u := range config.Authentication.BasicAuth.Users {
|
||||
if u.Username == "" || u.Password == "" {
|
||||
log.Fatalf("authentication.basicAuth.users require both username and password to be set")
|
||||
}
|
||||
}
|
||||
|
||||
if config.Authentication.Header.Name != "" || len(config.Authentication.BasicAuth.Users) > 0 {
|
||||
config.Authentication.Enabled = true
|
||||
}
|
||||
|
||||
if config.SilenceForm.Author.PopulateFromHeader.ValueRegex != "" {
|
||||
@@ -352,14 +372,14 @@ func (config *configSchema) LogValues() {
|
||||
cfg := configSchema(*config)
|
||||
|
||||
auth := []AuthenticationUser{}
|
||||
for _, u := range cfg.Authentication.Users {
|
||||
for _, u := range cfg.Authentication.BasicAuth.Users {
|
||||
uu := AuthenticationUser{
|
||||
Username: u.Username,
|
||||
Password: "***",
|
||||
}
|
||||
auth = append(auth, uu)
|
||||
}
|
||||
cfg.Authentication.Users = auth
|
||||
cfg.Authentication.BasicAuth.Users = auth
|
||||
|
||||
// replace passwords in Alertmanager URIs with 'xxx'
|
||||
servers := []AlertmanagerConfig{}
|
||||
|
||||
@@ -21,7 +21,11 @@ func resetEnv() {
|
||||
|
||||
func testReadConfig(t *testing.T) {
|
||||
expectedConfig := `authentication:
|
||||
users: []
|
||||
header:
|
||||
name: ""
|
||||
value_re: ""
|
||||
basicAuth:
|
||||
users: []
|
||||
alertmanager:
|
||||
interval: 1s
|
||||
servers:
|
||||
|
||||
@@ -47,7 +47,14 @@ type AuthenticationUser struct {
|
||||
|
||||
type configSchema struct {
|
||||
Authentication struct {
|
||||
Users []AuthenticationUser
|
||||
Enabled bool `yaml:"-" koanf:"-"`
|
||||
Header struct {
|
||||
Name string
|
||||
ValueRegex string `yaml:"value_re" koanf:"value_re"`
|
||||
}
|
||||
BasicAuth struct {
|
||||
Users []AuthenticationUser
|
||||
} `yaml:"basicAuth" koanf:"basicAuth"`
|
||||
}
|
||||
Alertmanager struct {
|
||||
Interval time.Duration
|
||||
|
||||
Reference in New Issue
Block a user