mirror of
https://github.com/TecharoHQ/anubis.git
synced 2026-04-23 08:36:41 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b3cce4d4f | |||
| 56ec19d2da | |||
| 11ab78ec33 | |||
| 11f944128f | |||
| dfeb02b4ae | |||
| b66630df74 | |||
| 63e6a15280 | |||
| 888c477933 | |||
| cda06f8c71 |
@@ -38,4 +38,3 @@ Samsung
|
|||||||
wenet
|
wenet
|
||||||
qwertiko
|
qwertiko
|
||||||
setuplistener
|
setuplistener
|
||||||
mba
|
|
||||||
|
|||||||
@@ -175,13 +175,6 @@ status_codes:
|
|||||||
# bind: ":9090"
|
# bind: ":9090"
|
||||||
# network: "tcp"
|
# network: "tcp"
|
||||||
#
|
#
|
||||||
# # To protect your metrics server with basic auth, set credentials below:
|
|
||||||
# #
|
|
||||||
# # https://anubis.techaro.lol/docs/admin/policies#http-basic-authentication
|
|
||||||
# basicAuth:
|
|
||||||
# username: ""
|
|
||||||
# password: ""
|
|
||||||
#
|
|
||||||
# # To serve metrics over TLS, set the path to the right TLS certificate and key
|
# # To serve metrics over TLS, set the path to the right TLS certificate and key
|
||||||
# # here. When the files change on disk, they will automatically be reloaded.
|
# # here. When the files change on disk, they will automatically be reloaded.
|
||||||
# #
|
# #
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Add Bulgarian locale ([#1394](https://github.com/TecharoHQ/anubis/pull/1394))
|
- Add Bulgarian locale ([#1394](https://github.com/TecharoHQ/anubis/pull/1394))
|
||||||
- Fix CEL internal errors when iterating `headers`/`query` map wrappers by implementing map iterators for `HTTPHeaders` and `URLValues` ([#1465](https://github.com/TecharoHQ/anubis/pull/1465)).
|
- Fix CEL internal errors when iterating `headers`/`query` map wrappers by implementing map iterators for `HTTPHeaders` and `URLValues` ([#1465](https://github.com/TecharoHQ/anubis/pull/1465)).
|
||||||
- Enable [metrics serving via TLS](./admin/policies.mdx#tls), including [mutual TLS (mTLS)](./admin/policies.mdx#mtls).
|
- Enable [metrics serving via TLS](./admin/policies.mdx#tls), including [mutual TLS (mTLS)](./admin/policies.mdx#mtls).
|
||||||
- Enable [HTTP basic auth](./admin/policies.mdx#http-basic-authentication) for the metrics server.
|
|
||||||
|
|
||||||
## v1.25.0: Necron
|
## v1.25.0: Necron
|
||||||
|
|
||||||
|
|||||||
@@ -171,24 +171,6 @@ metrics:
|
|||||||
|
|
||||||
As it is not expected for certificate authority certificates to change often, the CA certificate will NOT be automatically reloaded when the respective file changes.
|
As it is not expected for certificate authority certificates to change often, the CA certificate will NOT be automatically reloaded when the respective file changes.
|
||||||
|
|
||||||
### HTTP basic authentication
|
|
||||||
|
|
||||||
Anubis' metrics server also supports setting HTTP basic auth as a lightweight protection against unauthorized users viewing metrics data. As the basic auth credentials are hardcoded in the configuration file, administrators SHOULD use randomly generated credentials, such as type-4 UUIDs or other high entropy strings. These credentials MUST NOT be sensitive or used to protect other high value systems.
|
|
||||||
|
|
||||||
Configure it with the `basicAuth` block under `metrics`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
metrics:
|
|
||||||
bind: ":9090"
|
|
||||||
network: "tcp"
|
|
||||||
|
|
||||||
basicAuth:
|
|
||||||
username: azurediamond
|
|
||||||
password: hunter2
|
|
||||||
```
|
|
||||||
|
|
||||||
If you have Python installed, you can generate a high entropy password with `python -c 'import secrets; print(secrets.token_urlsafe(32))'`.
|
|
||||||
|
|
||||||
## Imprint / Impressum support
|
## Imprint / Impressum support
|
||||||
|
|
||||||
Anubis has support for showing imprint / impressum information. This is defined in the `impressum` block of your configuration. See [Imprint / Impressum configuration](./configuration/impressum.mdx) for more information.
|
Anubis has support for showing imprint / impressum information. This is defined in the `impressum` block of your configuration. See [Imprint / Impressum configuration](./configuration/impressum.mdx) for more information.
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
package internal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/subtle"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BasicAuth wraps next in HTTP Basic authentication using the provided
|
|
||||||
// credentials. If either username or password is empty, next is returned
|
|
||||||
// unchanged and a debug log line is emitted.
|
|
||||||
//
|
|
||||||
// Credentials are compared in constant time to avoid leaking information
|
|
||||||
// through timing side channels.
|
|
||||||
func BasicAuth(realm, username, password string, next http.Handler) http.Handler {
|
|
||||||
if username == "" || password == "" {
|
|
||||||
slog.Debug("skipping middleware, basic auth credentials are empty")
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedUser := sha256.Sum256([]byte(username))
|
|
||||||
expectedPass := sha256.Sum256([]byte(password))
|
|
||||||
challenge := fmt.Sprintf("Basic realm=%q, charset=\"UTF-8\"", realm)
|
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
user, pass, ok := r.BasicAuth()
|
|
||||||
if !ok {
|
|
||||||
unauthorized(w, challenge)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
gotUser := sha256.Sum256([]byte(user))
|
|
||||||
gotPass := sha256.Sum256([]byte(pass))
|
|
||||||
|
|
||||||
userMatch := subtle.ConstantTimeCompare(gotUser[:], expectedUser[:])
|
|
||||||
passMatch := subtle.ConstantTimeCompare(gotPass[:], expectedPass[:])
|
|
||||||
|
|
||||||
if userMatch&passMatch != 1 {
|
|
||||||
unauthorized(w, challenge)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func unauthorized(w http.ResponseWriter, challenge string) {
|
|
||||||
w.Header().Set("WWW-Authenticate", challenge)
|
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
package internal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func okHandler() http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBasicAuth(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
realm = "test-realm"
|
|
||||||
username = "admin"
|
|
||||||
password = "hunter2"
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, tt := range []struct {
|
|
||||||
name string
|
|
||||||
setAuth bool
|
|
||||||
user string
|
|
||||||
pass string
|
|
||||||
wantStatus int
|
|
||||||
wantBody string
|
|
||||||
wantChall bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "valid credentials",
|
|
||||||
setAuth: true,
|
|
||||||
user: username,
|
|
||||||
pass: password,
|
|
||||||
wantStatus: http.StatusOK,
|
|
||||||
wantBody: "ok",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing credentials",
|
|
||||||
setAuth: false,
|
|
||||||
wantStatus: http.StatusUnauthorized,
|
|
||||||
wantChall: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wrong username",
|
|
||||||
setAuth: true,
|
|
||||||
user: "nobody",
|
|
||||||
pass: password,
|
|
||||||
wantStatus: http.StatusUnauthorized,
|
|
||||||
wantChall: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wrong password",
|
|
||||||
setAuth: true,
|
|
||||||
user: username,
|
|
||||||
pass: "wrong",
|
|
||||||
wantStatus: http.StatusUnauthorized,
|
|
||||||
wantChall: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty supplied credentials",
|
|
||||||
setAuth: true,
|
|
||||||
user: "",
|
|
||||||
pass: "",
|
|
||||||
wantStatus: http.StatusUnauthorized,
|
|
||||||
wantChall: true,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
h := BasicAuth(realm, username, password, okHandler())
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
if tt.setAuth {
|
|
||||||
req.SetBasicAuth(tt.user, tt.pass)
|
|
||||||
}
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != tt.wantStatus {
|
|
||||||
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tt.wantBody != "" && rec.Body.String() != tt.wantBody {
|
|
||||||
t.Errorf("body = %q, want %q", rec.Body.String(), tt.wantBody)
|
|
||||||
}
|
|
||||||
|
|
||||||
chall := rec.Header().Get("WWW-Authenticate")
|
|
||||||
if tt.wantChall {
|
|
||||||
if chall == "" {
|
|
||||||
t.Error("WWW-Authenticate header missing on 401")
|
|
||||||
}
|
|
||||||
if !strings.Contains(chall, realm) {
|
|
||||||
t.Errorf("WWW-Authenticate = %q, want realm %q", chall, realm)
|
|
||||||
}
|
|
||||||
} else if chall != "" {
|
|
||||||
t.Errorf("unexpected WWW-Authenticate header: %q", chall)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBasicAuthPassthrough(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tt := range []struct {
|
|
||||||
name string
|
|
||||||
username string
|
|
||||||
password string
|
|
||||||
}{
|
|
||||||
{name: "empty username", username: "", password: "hunter2"},
|
|
||||||
{name: "empty password", username: "admin", password: ""},
|
|
||||||
{name: "both empty", username: "", password: ""},
|
|
||||||
} {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
h := BasicAuth("realm", tt.username, tt.password, okHandler())
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Errorf("status = %d, want %d (passthrough expected)", rec.Code, http.StatusOK)
|
|
||||||
}
|
|
||||||
if rec.Body.String() != "ok" {
|
|
||||||
t.Errorf("body = %q, want %q", rec.Body.String(), "ok")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
var (
|
var (
|
||||||
ErrInvalidMetricsConfig = errors.New("config: invalid metrics configuration")
|
ErrInvalidMetricsConfig = errors.New("config: invalid metrics configuration")
|
||||||
ErrInvalidMetricsTLSConfig = errors.New("config: invalid metrics TLS configuration")
|
ErrInvalidMetricsTLSConfig = errors.New("config: invalid metrics TLS configuration")
|
||||||
ErrInvalidMetricsBasicAuthConfig = errors.New("config: invalid metrics basic auth configuration")
|
|
||||||
ErrNoMetricsBind = errors.New("config.Metrics: must define bind")
|
ErrNoMetricsBind = errors.New("config.Metrics: must define bind")
|
||||||
ErrNoMetricsNetwork = errors.New("config.Metrics: must define network")
|
ErrNoMetricsNetwork = errors.New("config.Metrics: must define network")
|
||||||
ErrNoMetricsSocketMode = errors.New("config.Metrics: must define socket mode when using unix sockets")
|
ErrNoMetricsSocketMode = errors.New("config.Metrics: must define socket mode when using unix sockets")
|
||||||
@@ -23,8 +22,6 @@ var (
|
|||||||
ErrInvalidMetricsTLSKeypair = errors.New("config.Metrics.TLS: keypair is invalid")
|
ErrInvalidMetricsTLSKeypair = errors.New("config.Metrics.TLS: keypair is invalid")
|
||||||
ErrInvalidMetricsCACertificate = errors.New("config.Metrics.TLS: invalid CA certificate")
|
ErrInvalidMetricsCACertificate = errors.New("config.Metrics.TLS: invalid CA certificate")
|
||||||
ErrCantReadFile = errors.New("config: can't read required file")
|
ErrCantReadFile = errors.New("config: can't read required file")
|
||||||
ErrNoMetricsBasicAuthUsername = errors.New("config.Metrics.BasicAuth: must define username")
|
|
||||||
ErrNoMetricsBasicAuthPassword = errors.New("config.Metrics.BasicAuth: must define password")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Metrics struct {
|
type Metrics struct {
|
||||||
@@ -32,7 +29,6 @@ type Metrics struct {
|
|||||||
Network string `json:"network" yaml:"network"`
|
Network string `json:"network" yaml:"network"`
|
||||||
SocketMode string `json:"socketMode" yaml:"socketMode"`
|
SocketMode string `json:"socketMode" yaml:"socketMode"`
|
||||||
TLS *MetricsTLS `json:"tls" yaml:"tls"`
|
TLS *MetricsTLS `json:"tls" yaml:"tls"`
|
||||||
BasicAuth *MetricsBasicAuth `json:"basicAuth" yaml:"basicAuth"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Metrics) Valid() error {
|
func (m *Metrics) Valid() error {
|
||||||
@@ -66,12 +62,6 @@ func (m *Metrics) Valid() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.BasicAuth != nil {
|
|
||||||
if err := m.BasicAuth.Valid(); err != nil {
|
|
||||||
errs = append(errs, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(errs) != 0 {
|
if len(errs) != 0 {
|
||||||
return errors.Join(ErrInvalidMetricsConfig, errors.Join(errs...))
|
return errors.Join(ErrInvalidMetricsConfig, errors.Join(errs...))
|
||||||
}
|
}
|
||||||
@@ -141,26 +131,3 @@ func canReadFile(fname string) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type MetricsBasicAuth struct {
|
|
||||||
Username string `json:"username" yaml:"username"`
|
|
||||||
Password string `json:"password" yaml:"password"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mba *MetricsBasicAuth) Valid() error {
|
|
||||||
var errs []error
|
|
||||||
|
|
||||||
if mba.Username == "" {
|
|
||||||
errs = append(errs, ErrNoMetricsBasicAuthUsername)
|
|
||||||
}
|
|
||||||
|
|
||||||
if mba.Password == "" {
|
|
||||||
errs = append(errs, ErrNoMetricsBasicAuthPassword)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(errs) != 0 {
|
|
||||||
return errors.Join(ErrInvalidMetricsBasicAuthConfig, errors.Join(errs...))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -157,79 +157,6 @@ func TestMetricsValid(t *testing.T) {
|
|||||||
},
|
},
|
||||||
err: ErrInvalidMetricsCACertificate,
|
err: ErrInvalidMetricsCACertificate,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "basic auth credentials set",
|
|
||||||
input: &Metrics{
|
|
||||||
Bind: ":9090",
|
|
||||||
Network: "tcp",
|
|
||||||
BasicAuth: &MetricsBasicAuth{
|
|
||||||
Username: "admin",
|
|
||||||
Password: "hunter2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid basic auth config",
|
|
||||||
input: &Metrics{
|
|
||||||
Bind: ":9090",
|
|
||||||
Network: "tcp",
|
|
||||||
BasicAuth: &MetricsBasicAuth{},
|
|
||||||
},
|
|
||||||
err: ErrInvalidMetricsBasicAuthConfig,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if err := tt.input.Valid(); !errors.Is(err, tt.err) {
|
|
||||||
t.Logf("wanted error: %v", tt.err)
|
|
||||||
t.Logf("got error: %v", err)
|
|
||||||
t.Error("validation failed")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMetricsBasicAuthValid(t *testing.T) {
|
|
||||||
for _, tt := range []struct {
|
|
||||||
name string
|
|
||||||
input *MetricsBasicAuth
|
|
||||||
err error
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "both set",
|
|
||||||
input: &MetricsBasicAuth{
|
|
||||||
Username: "admin",
|
|
||||||
Password: "hunter2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty username and password",
|
|
||||||
input: &MetricsBasicAuth{},
|
|
||||||
err: ErrInvalidMetricsBasicAuthConfig,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing username",
|
|
||||||
input: &MetricsBasicAuth{
|
|
||||||
Password: "hunter2",
|
|
||||||
},
|
|
||||||
err: ErrNoMetricsBasicAuthUsername,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing password",
|
|
||||||
input: &MetricsBasicAuth{
|
|
||||||
Username: "admin",
|
|
||||||
},
|
|
||||||
err: ErrNoMetricsBasicAuthPassword,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing both surfaces wrapper error",
|
|
||||||
input: &MetricsBasicAuth{},
|
|
||||||
err: ErrNoMetricsBasicAuthUsername,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing both surfaces password error",
|
|
||||||
input: &MetricsBasicAuth{},
|
|
||||||
err: ErrNoMetricsBasicAuthPassword,
|
|
||||||
},
|
|
||||||
} {
|
} {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if err := tt.input.Valid(); !errors.Is(err, tt.err) {
|
if err := tt.input.Valid(); !errors.Is(err, tt.err) {
|
||||||
|
|||||||
@@ -97,13 +97,6 @@ func (s *Server) run(ctx context.Context, lg *slog.Logger) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.Config.BasicAuth != nil {
|
|
||||||
var h http.Handler = mux
|
|
||||||
h = internal.BasicAuth("anubis-metrics", s.Config.BasicAuth.Username, s.Config.BasicAuth.Password, mux)
|
|
||||||
|
|
||||||
srv.Handler = h
|
|
||||||
}
|
|
||||||
|
|
||||||
lg.Debug("listening for metrics", "url", metricsURL)
|
lg.Debug("listening for metrics", "url", metricsURL)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
Reference in New Issue
Block a user