diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index 851da904..e0dc44d7 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -37,3 +37,4 @@ unipromos Samsung wenet qwertiko +setuplistener diff --git a/cmd/anubis/main.go b/cmd/anubis/main.go index fad6c9dd..d6ce5000 100644 --- a/cmd/anubis/main.go +++ b/cmd/anubis/main.go @@ -17,12 +17,10 @@ import ( "net" "net/http" "net/http/httputil" - "net/http/pprof" "net/url" "os" "os/signal" "path/filepath" - "strconv" "strings" "sync" "syscall" @@ -33,12 +31,12 @@ import ( "github.com/TecharoHQ/anubis/internal" libanubis "github.com/TecharoHQ/anubis/lib" "github.com/TecharoHQ/anubis/lib/config" + "github.com/TecharoHQ/anubis/lib/metrics" botPolicy "github.com/TecharoHQ/anubis/lib/policy" "github.com/TecharoHQ/anubis/lib/thoth" "github.com/TecharoHQ/anubis/web" "github.com/facebookgo/flagenv" _ "github.com/joho/godotenv/autoload" - "github.com/prometheus/client_golang/prometheus/promhttp" healthv1 "google.golang.org/grpc/health/grpc_health_v1" ) @@ -119,33 +117,6 @@ func doHealthCheck() error { return nil } -// parseBindNetFromAddr determine bind network and address based on the given network and address. -func parseBindNetFromAddr(address string) (string, string) { - defaultScheme := "http://" - if !strings.Contains(address, "://") { - if strings.HasPrefix(address, ":") { - address = defaultScheme + "localhost" + address - } else { - address = defaultScheme + address - } - } - - bindUri, err := url.Parse(address) - if err != nil { - log.Fatal(fmt.Errorf("failed to parse bind URL: %w", err)) - } - - switch bindUri.Scheme { - case "unix": - return "unix", bindUri.Path - case "tcp", "http", "https": - return "tcp", bindUri.Host - default: - log.Fatal(fmt.Errorf("unsupported network scheme %s in address %s", bindUri.Scheme, address)) - } - return "", address -} - func parseSameSite(s string) http.SameSite { switch strings.ToLower(s) { case "none": @@ -162,53 +133,6 @@ func parseSameSite(s string) http.SameSite { return http.SameSiteDefaultMode } -func setupListener(network string, address string) (net.Listener, string) { - formattedAddress := "" - - if network == "" { - // keep compatibility - network, address = parseBindNetFromAddr(address) - } - - switch network { - case "unix": - formattedAddress = "unix:" + address - case "tcp": - if strings.HasPrefix(address, ":") { // assume it's just a port e.g. :4259 - formattedAddress = "http://localhost" + address - } else { - formattedAddress = "http://" + address - } - default: - formattedAddress = fmt.Sprintf(`(%s) %s`, network, address) - } - - listener, err := net.Listen(network, address) - if err != nil { - log.Fatal(fmt.Errorf("failed to bind to %s: %w", formattedAddress, err)) - } - - // additional permission handling for unix sockets - if network == "unix" { - mode, err := strconv.ParseUint(*socketMode, 8, 0) - if err != nil { - listener.Close() - log.Fatal(fmt.Errorf("could not parse socket mode %s: %w", *socketMode, err)) - } - - err = os.Chmod(address, os.FileMode(mode)) - if err != nil { - err := listener.Close() - if err != nil { - log.Printf("failed to close listener: %v", err) - } - log.Fatal(fmt.Errorf("could not change socket mode: %w", err)) - } - } - - return listener, formattedAddress -} - func makeReverseProxy(target string, targetSNI string, targetHost string, insecureSkipVerify bool, targetDisableKeepAlive bool) (http.Handler, error) { targetUri, err := url.Parse(target) if err != nil { @@ -304,11 +228,6 @@ func main() { wg := new(sync.WaitGroup) - if *metricsBind != "" { - wg.Add(1) - go metricsServer(ctx, *lg.With("subsystem", "metrics"), wg.Done) - } - var rp http.Handler // when using anubis via Systemd and environment variables, then it is not possible to set targe to an empty string but only to space if strings.TrimSpace(*target) != "" { @@ -348,6 +267,26 @@ func main() { lg.Debug("swapped to new logger") slog.SetDefault(lg) + if *metricsBind != "" || policy.Metrics != nil { + wg.Add(1) + + ms := &metrics.Server{ + Config: policy.Metrics, + Log: lg, + } + + if policy.Metrics == nil { + lg.Debug("migrating flags to metrics config", "bind", *metricsBind, "network", *metricsBindNetwork, "socket-mode", *socketMode) + ms.Config = &config.Metrics{ + Bind: *metricsBind, + Network: *metricsBindNetwork, + SocketMode: *socketMode, + } + } + + go ms.Run(ctx, wg.Done) + } + // Warn if persistent storage is used without a configured signing key if policy.Store.IsPersistent() { if *hs512Secret == "" && *ed25519PrivateKeyHex == "" && *ed25519PrivateKeyHexFile == "" { @@ -484,7 +423,11 @@ func main() { h = internal.JA4H(h) srv := http.Server{Handler: h, ErrorLog: internal.GetFilteredHTTPLogger()} - listener, listenerUrl := setupListener(*bindNetwork, *bind) + listener, listenerUrl, err := internal.SetupListener(*bindNetwork, *bind, *socketMode) + if err != nil { + log.Fatalf("SetupListener(%q, %q, %q): %v", *bindNetwork, *bind, *socketMode, err) + } + lg.Info( "listening", "url", listenerUrl, @@ -519,53 +462,6 @@ func main() { wg.Wait() } -func metricsServer(ctx context.Context, lg slog.Logger, done func()) { - defer done() - - mux := http.NewServeMux() - mux.HandleFunc("GET /debug/pprof/", pprof.Index) - mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline) - mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile) - mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol) - mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace) - mux.Handle("/metrics", promhttp.Handler()) - mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { - st, ok := internal.GetHealth("anubis") - if !ok { - slog.Error("health service anubis does not exist, file a bug") - } - - switch st { - case healthv1.HealthCheckResponse_NOT_SERVING: - http.Error(w, "NOT OK", http.StatusInternalServerError) - return - case healthv1.HealthCheckResponse_SERVING: - fmt.Fprintln(w, "OK") - return - default: - http.Error(w, "UNKNOWN", http.StatusFailedDependency) - return - } - }) - - srv := http.Server{Handler: mux, ErrorLog: internal.GetFilteredHTTPLogger()} - listener, metricsUrl := setupListener(*metricsBindNetwork, *metricsBind) - lg.Debug("listening for metrics", "url", metricsUrl) - - go func() { - <-ctx.Done() - c, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := srv.Shutdown(c); err != nil { - log.Printf("cannot shut down: %v", err) - } - }() - - if err := srv.Serve(listener); !errors.Is(err, http.ErrServerClosed) { - log.Fatal(err) - } -} - func extractEmbedFS(fsys embed.FS, root string, destDir string) error { return fs.WalkDir(fsys, root, func(path string, d fs.DirEntry, err error) error { if err != nil { diff --git a/data/botPolicies.yaml b/data/botPolicies.yaml index ad7fb1aa..fdb8539b 100644 --- a/data/botPolicies.yaml +++ b/data/botPolicies.yaml @@ -166,6 +166,15 @@ status_codes: CHALLENGE: 200 DENY: 200 +# # Configuration for the metrics server. See the docs for more information: +# # +# # https://anubis.techaro.lol/docs/admin/policies#metrics-server +# # +# # This is commented out by default so that command line flags take precedence. +# metrics: +# bind: ":9090" +# network: "tcp" + # Anubis can store temporary data in one of a few backends. See the storage # backends section of the docs for more information: # diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md index b961722a..785dfa03 100644 --- a/docs/docs/CHANGELOG.md +++ b/docs/docs/CHANGELOG.md @@ -11,14 +11,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] + + +- Move metrics server configuration to [the policy file](./admin/policies.mdx#metrics-server). - Expose [pprof endpoints](https://pkg.go.dev/net/http/pprof) on the metrics listener to enable profiling Anubis in production. - fix: prevent nil pointer panic in challenge validation when threshold rules match during PassChallenge (#1463) - Instruct reverse proxies to not cache error pages. - Fixed mixed tab/space indentation in Caddy documentation code block - Improve error messages and fix broken REDIRECT_DOMAINS link in docs ([#1193](https://github.com/TecharoHQ/anubis/issues/1193)) - 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)). ## v1.25.0: Necron diff --git a/docs/docs/admin/installation.mdx b/docs/docs/admin/installation.mdx index 3a4b0516..d87730b4 100644 --- a/docs/docs/admin/installation.mdx +++ b/docs/docs/admin/installation.mdx @@ -87,15 +87,15 @@ Anubis uses these environment variables for configuration: | `ED25519_PRIVATE_KEY_HEX_FILE` | unset | Path to a file containing the hex-encoded ed25519 private key. Only one of this or its sister option may be set. **Required when using persistent storage backends** (like bbolt) to ensure challenges survive service restarts. When running multiple instances on the same base domain, the key must be the same across all instances. | | `ERROR_TITLE` | unset | If set, override the translation stack to show a custom title for error pages such as "Something went wrong!". See [Customizing messages](./botstopper.mdx#customizing-messages) for more details. | | `JWT_RESTRICTION_HEADER` | `X-Real-IP` | If set, the JWT is only valid if the current value of this header matches the value when the JWT was created. You can use it e.g. to restrict a JWT to the source IP of the user using `X-Real-IP`. | -| `METRICS_BIND` | `:9090` | The network address that Anubis serves Prometheus metrics on. See `BIND` for more information. | -| `METRICS_BIND_NETWORK` | `tcp` | The address family that the Anubis metrics server listens on. See `BIND_NETWORK` for more information. | +| `METRICS_BIND` | `:9090` | The legacy configuration value for the network address that Anubis serves Prometheus metrics on. Please migrate this to [the policy file](./policies.mdx#metrics-server) as soon as possible. | +| `METRICS_BIND_NETWORK` | `tcp` | The legacy configuration value for the address family that Anubis serves Prometheus metrics on. Please migrate this to [the policy file](./policies.mdx#metrics-server) as soon as possible. | | `OG_EXPIRY_TIME` | `24h` | The expiration time for the Open Graph tag cache. Prefer using [the policy file](./configuration/open-graph.mdx) to configure the Open Graph subsystem. | | `OG_PASSTHROUGH` | `false` | If set to `true`, Anubis will enable Open Graph tag passthrough. Prefer using [the policy file](./configuration/open-graph.mdx) to configure the Open Graph subsystem. | | `OG_CACHE_CONSIDER_HOST` | `false` | If set to `true`, Anubis will consider the host in the Open Graph tag cache key. Prefer using [the policy file](./configuration/open-graph.mdx) to configure the Open Graph subsystem. | | `OVERLAY_FOLDER` | unset | If set, treat the given path as an [overlay folder](./botstopper.mdx#custom-images-and-css), allowing you to customize CSS, fonts, images, and add other assets to BotStopper deployments. | | `POLICY_FNAME` | unset | The file containing [bot policy configuration](./policies.mdx). See the bot policy documentation for more details. If unset, the default bot policy configuration is used. | | `PUBLIC_URL` | unset | The externally accessible URL for this Anubis instance, used for constructing redirect URLs (e.g., for Traefik forwardAuth). Leave it unset when Anubis terminates traffic directly (sidecar/standalone deployments) or redirect building will fail with `redir=null`. | -| `REDIRECT_DOMAINS` | unset | Comma-separated list of domain names that Anubis should allow redirects to when passing a challenge. See [Redirect Domain Configuration](./configuration/redirect-domains.mdx) for more details. | +| `REDIRECT_DOMAINS` | unset | Comma-separated list of domain names that Anubis should allow redirects to when passing a challenge. See [Redirect Domain Configuration](./configuration/redirect-domains.mdx) for more details. | | `SERVE_ROBOTS_TXT` | `false` | If set `true`, Anubis will serve a default `robots.txt` file that disallows all known AI scrapers by name and then additionally disallows every scraper. This is useful if facts and circumstances make it difficult to change the underlying service to serve such a `robots.txt` file. | | `SLOG_LEVEL` | `INFO` | The log level for structured logging. Valid values are `DEBUG`, `INFO`, `WARN`, and `ERROR`. Set to `DEBUG` to see all requests, evaluations, and detailed diagnostic information. | | `SOCKET_MODE` | `0770` | _Only used when at least one of the `*_BIND_NETWORK` variables are set to `unix`._ The socket mode (permissions) for Unix domain sockets. | diff --git a/docs/docs/admin/policies.mdx b/docs/docs/admin/policies.mdx index 24317a41..37998948 100644 --- a/docs/docs/admin/policies.mdx +++ b/docs/docs/admin/policies.mdx @@ -117,6 +117,27 @@ remote_addresses: - 100.64.0.0/10 ``` +## Metrics server + +Anubis includes support for [Prometheus-style metrics](https://prometheus.io/docs/introduction/overview/), allowing systems administrators to monitor Anubis' performance and effectiveness. This is a separate HTTP server with metrics, health checking, and debug routes. + +Anubis' metrics server is configured with the `metrics` block in the configuration file: + +```yaml +metrics: + bind: ":9090" + network: "tcp" +``` + +If you want to bind metrics to a Unix socket, make sure to set the network to `unix` and add a socket mode: + +```yaml +metrics: + bind: "/tmp/anubis_metrics.sock" + network: unix + socketMode: "0700" # must be a string +``` + ## 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. diff --git a/internal/setuplistener.go b/internal/setuplistener.go new file mode 100644 index 00000000..fc076657 --- /dev/null +++ b/internal/setuplistener.go @@ -0,0 +1,92 @@ +package internal + +import ( + "errors" + "fmt" + "net" + "net/url" + "os" + "strconv" + "strings" +) + +// parseBindNetFromAddr determine bind network and address based on the given network and address. +func parseBindNetFromAddr(address string) (string, string, error) { + defaultScheme := "http://" + if !strings.Contains(address, "://") { + if strings.HasPrefix(address, ":") { + address = defaultScheme + "localhost" + address + } else { + address = defaultScheme + address + } + } + + bindUri, err := url.Parse(address) + if err != nil { + return "", "", fmt.Errorf("failed to parse bind URL: %w", err) + } + + switch bindUri.Scheme { + case "unix": + return "unix", bindUri.Path, nil + case "tcp", "http", "https": + return "tcp", bindUri.Host, nil + default: + return "", "", fmt.Errorf("unsupported network scheme %s in address %s", bindUri.Scheme, address) + } +} + +// SetupListener sets up a network listener based on the input from configuration +// envvars. It returns a network listener and the URL to that listener or an error. +func SetupListener(network, address, socketMode string) (net.Listener, string, error) { + formattedAddress := "" + var err error + + if network == "" { + // keep compatibility + network, address, err = parseBindNetFromAddr(address) + } + + if err != nil { + return nil, "", fmt.Errorf("can't parse bind and network: %w", err) + } + + switch network { + case "unix": + formattedAddress = "unix:" + address + case "tcp": + if strings.HasPrefix(address, ":") { // assume it's just a port e.g. :4259 + formattedAddress = "http://localhost" + address + } else { + formattedAddress = "http://" + address + } + default: + formattedAddress = fmt.Sprintf(`(%s) %s`, network, address) + } + + ln, err := net.Listen(network, address) + if err != nil { + return nil, "", fmt.Errorf("failed to bind to %s: %w", formattedAddress, err) + } + + // additional permission handling for unix sockets + if network == "unix" { + mode, err := strconv.ParseUint(socketMode, 8, 0) + if err != nil { + ln.Close() + return nil, "", fmt.Errorf("could not parse socket mode %s: %w", socketMode, err) + } + + err = os.Chmod(address, os.FileMode(mode)) + if err != nil { + err := fmt.Errorf("could not change socket mode: %w", err) + clErr := ln.Close() + if clErr != nil { + return nil, "", errors.Join(err, clErr) + } + return nil, "", err + } + } + + return ln, formattedAddress, nil +} diff --git a/internal/setuplistener_test.go b/internal/setuplistener_test.go new file mode 100644 index 00000000..026be531 --- /dev/null +++ b/internal/setuplistener_test.go @@ -0,0 +1,180 @@ +package internal + +import ( + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +func TestParseBindNetFromAddr(t *testing.T) { + for _, tt := range []struct { + name string + address string + wantErr bool + network string + bind string + }{ + { + name: "simple tcp", + address: "localhost:9090", + wantErr: false, + network: "tcp", + bind: "localhost:9090", + }, + { + name: "simple unix", + address: "unix:///tmp/foo.sock", + wantErr: false, + network: "unix", + bind: "/tmp/foo.sock", + }, + { + name: "invalid network", + address: "foo:///tmp/bar.sock", + wantErr: true, + }, + { + name: "tcp uri", + address: "tcp://[::]:9090", + wantErr: false, + network: "tcp", + bind: "[::]:9090", + }, + { + name: "http uri", + address: "http://[::]:9090", + wantErr: false, + network: "tcp", + bind: "[::]:9090", + }, + { + name: "https uri", + address: "https://[::]:9090", + wantErr: false, + network: "tcp", + bind: "[::]:9090", + }, + } { + t.Run(tt.name, func(t *testing.T) { + network, bind, err := parseBindNetFromAddr(tt.address) + + switch { + case tt.wantErr && err == nil: + t.Errorf("parseBindNetFromAddr(%q) should have errored but did not", tt.address) + case !tt.wantErr && err != nil: + t.Errorf("parseBindNetFromAddr(%q) threw an error: %v", tt.address, err) + } + + if network != tt.network { + t.Errorf("parseBindNetFromAddr(%q) wanted network: %q, got: %q", tt.address, tt.network, network) + } + + if bind != tt.bind { + t.Errorf("parseBindNetFromAddr(%q) wanted bind: %q, got: %q", tt.address, tt.bind, bind) + } + }) + } +} + +func TestSetupListener(t *testing.T) { + td := t.TempDir() + + for _, tt := range []struct { + name string + network, address, socketMode string + wantErr bool + socketURLPrefix string + }{ + { + name: "simple tcp", + network: "", + address: ":0", + wantErr: false, + socketURLPrefix: "http://localhost:", + }, + { + name: "simple unix", + network: "", + address: "unix://" + filepath.Join(td, "a"), + socketMode: "0770", + wantErr: false, + socketURLPrefix: "unix:" + filepath.Join(td, "a"), + }, + { + name: "tcp", + network: "tcp", + address: ":0", + wantErr: false, + socketURLPrefix: "http://localhost:", + }, + { + name: "udp", + network: "udp", + address: ":0", + wantErr: true, + socketURLPrefix: "http://localhost:", + }, + { + name: "unix socket", + network: "unix", + socketMode: "0770", + address: filepath.Join(td, "a"), + wantErr: false, + socketURLPrefix: "unix:" + filepath.Join(td, "a"), + }, + { + name: "invalid socket mode", + network: "unix", + socketMode: "taco bell", + address: filepath.Join(td, "a"), + wantErr: true, + socketURLPrefix: "unix:" + filepath.Join(td, "a"), + }, + { + name: "empty socket mode", + network: "unix", + socketMode: "", + address: filepath.Join(td, "a"), + wantErr: true, + socketURLPrefix: "unix:" + filepath.Join(td, "a"), + }, + } { + t.Run(tt.name, func(t *testing.T) { + ln, socketURL, err := SetupListener(tt.network, tt.address, tt.socketMode) + switch { + case tt.wantErr && err == nil: + t.Errorf("SetupListener(%q, %q, %q) should have errored but did not", tt.network, tt.address, tt.socketMode) + case !tt.wantErr && err != nil: + t.Fatalf("SetupListener(%q, %q, %q) threw an error: %v", tt.network, tt.address, tt.socketMode, err) + } + + if ln != nil { + defer ln.Close() + } + + if !tt.wantErr && !strings.HasPrefix(socketURL, tt.socketURLPrefix) { + t.Errorf("SetupListener(%q, %q, %q) should have returned a URL with prefix %q but got: %q", tt.network, tt.address, tt.socketMode, tt.socketURLPrefix, socketURL) + } + + if tt.socketMode != "" { + mode, err := strconv.ParseUint(tt.socketMode, 8, 0) + if err != nil { + return + } + + sockPath := strings.TrimPrefix(socketURL, "unix:") + st, err := os.Stat(sockPath) + if err != nil { + t.Fatalf("can't os.Stat(%q): %v", sockPath, err) + } + + if st.Mode().Perm() != fs.FileMode(mode) { + t.Errorf("file mode of %q should be %s but is actually %s", sockPath, strconv.FormatUint(mode, 8), strconv.FormatUint(uint64(st.Mode()), 8)) + } + } + }) + } +} diff --git a/lib/config/config.go b/lib/config/config.go index b4c5865a..7e3264af 100644 --- a/lib/config/config.go +++ b/lib/config/config.go @@ -334,6 +334,7 @@ type fileConfig struct { DNSBL bool `json:"dnsbl"` DNSTTL DnsTTL `json:"dns_ttl"` Logging *Logging `json:"logging"` + Metrics *Metrics `json:"metrics,omitempty"` } func (c *fileConfig) Valid() error { @@ -375,6 +376,12 @@ func (c *fileConfig) Valid() error { } } + if c.Metrics != nil { + if err := c.Metrics.Valid(); err != nil { + errs = append(errs, err) + } + } + if len(errs) != 0 { return fmt.Errorf("config is not valid:\n%w", errors.Join(errs...)) } @@ -417,6 +424,7 @@ func Load(fin io.Reader, fname string) (*Config, error) { StatusCodes: c.StatusCodes, Store: c.Store, Logging: c.Logging, + Metrics: c.Metrics, } if c.OpenGraph.TimeToLive != "" { @@ -508,6 +516,7 @@ type Config struct { Logging *Logging DNSBL bool DNSTTL DnsTTL + Metrics *Metrics } func (c Config) Valid() error { diff --git a/lib/config/metrics.go b/lib/config/metrics.go new file mode 100644 index 00000000..37585ab2 --- /dev/null +++ b/lib/config/metrics.go @@ -0,0 +1,54 @@ +package config + +import ( + "errors" + "fmt" + "strconv" +) + +var ( + ErrInvalidMetricsConfig = errors.New("config: invalid metrics configuration") + ErrNoMetricsBind = errors.New("config.Metrics: must define bind") + ErrNoMetricsNetwork = errors.New("config.Metrics: must define network") + ErrNoMetricsSocketMode = errors.New("config.Metrics: must define socket mode when using unix sockets") + ErrInvalidMetricsSocketMode = errors.New("config.Metrics: invalid unix socket mode") + ErrInvalidMetricsNetwork = errors.New("config.Metrics: invalid metrics network") +) + +type Metrics struct { + Bind string `json:"bind" yaml:"bind"` + Network string `json:"network" yaml:"network"` + SocketMode string `json:"socketMode" yaml:"socketMode"` +} + +func (m *Metrics) Valid() error { + var errs []error + + if m.Bind == "" { + errs = append(errs, ErrNoMetricsBind) + } + + if m.Network == "" { + errs = append(errs, ErrNoMetricsNetwork) + } + + switch m.Network { + case "tcp", "tcp4", "tcp6": // https://pkg.go.dev/net#Listen + case "unix": + if m.SocketMode == "" { + errs = append(errs, ErrNoMetricsSocketMode) + } + + if _, err := strconv.ParseUint(m.SocketMode, 8, 0); err != nil { + errs = append(errs, fmt.Errorf("%w: %w", ErrInvalidMetricsSocketMode, err)) + } + default: + errs = append(errs, ErrInvalidMetricsNetwork) + } + + if len(errs) != 0 { + return errors.Join(ErrInvalidMetricsConfig, errors.Join(errs...)) + } + + return nil +} diff --git a/lib/config/metrics_test.go b/lib/config/metrics_test.go new file mode 100644 index 00000000..a92277b6 --- /dev/null +++ b/lib/config/metrics_test.go @@ -0,0 +1,87 @@ +package config + +import ( + "errors" + "testing" +) + +func TestMetricsValid(t *testing.T) { + for _, tt := range []struct { + name string + input *Metrics + err error + }{ + { + name: "basic TCP", + input: &Metrics{ + Bind: ":9090", + Network: "tcp", + }, + }, + { + name: "basic TCP4", + input: &Metrics{ + Bind: ":9090", + Network: "tcp4", + }, + }, + { + name: "basic TCP6", + input: &Metrics{ + Bind: ":9090", + Network: "tcp6", + }, + }, + { + name: "basic unix", + input: &Metrics{ + Bind: "/tmp/anubis-metrics.sock", + Network: "unix", + SocketMode: "0770", + }, + }, + { + name: "no bind", + input: &Metrics{}, + err: ErrNoMetricsBind, + }, + { + name: "no network", + input: &Metrics{}, + err: ErrNoMetricsNetwork, + }, + { + name: "no unix socket mode", + input: &Metrics{ + Bind: "/tmp/anubis-metrics.sock", + Network: "unix", + }, + err: ErrNoMetricsSocketMode, + }, + { + name: "invalid unix socket mode", + input: &Metrics{ + Bind: "/tmp/anubis-metrics.sock", + Network: "unix", + SocketMode: "taco bell", + }, + err: ErrInvalidMetricsSocketMode, + }, + { + name: "invalid network", + input: &Metrics{ + Bind: ":9090", + Network: "taco", + }, + err: ErrInvalidMetricsNetwork, + }, + } { + 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") + } + }) + } +} diff --git a/lib/config/testdata/bad/metrics-invalid-net.yaml b/lib/config/testdata/bad/metrics-invalid-net.yaml new file mode 100644 index 00000000..3d3dfc71 --- /dev/null +++ b/lib/config/testdata/bad/metrics-invalid-net.yaml @@ -0,0 +1,3 @@ +metrics: + bind: ":9090" + network: taco \ No newline at end of file diff --git a/lib/config/testdata/good/allow_everyone.json b/lib/config/testdata/good/allow_everyone.json index de298c6b..b1d6eb0a 100644 --- a/lib/config/testdata/good/allow_everyone.json +++ b/lib/config/testdata/good/allow_everyone.json @@ -5,5 +5,9 @@ "remote_addresses": ["0.0.0.0/0", "::/0"], "action": "ALLOW" } - ] + ], + "metrics": { + "bind": ":9090", + "network": "tcp" + } } diff --git a/lib/config/testdata/good/allow_everyone.yaml b/lib/config/testdata/good/allow_everyone.yaml index 80dae8b2..085ca775 100644 --- a/lib/config/testdata/good/allow_everyone.yaml +++ b/lib/config/testdata/good/allow_everyone.yaml @@ -4,3 +4,7 @@ bots: - "0.0.0.0/0" - "::/0" action: ALLOW + +metrics: + bind: ":9090" + network: "tcp" diff --git a/lib/config/testdata/good/block_cf_workers.json b/lib/config/testdata/good/block_cf_workers.json index 380fdf04..867533ee 100644 --- a/lib/config/testdata/good/block_cf_workers.json +++ b/lib/config/testdata/good/block_cf_workers.json @@ -8,5 +8,9 @@ "action": "DENY" } ], - "dnsbl": false + "dnsbl": false, + "metrics": { + "bind": ":9090", + "network": "tcp" + } } diff --git a/lib/config/testdata/good/block_cf_workers.yaml b/lib/config/testdata/good/block_cf_workers.yaml index 353c921a..d539be95 100644 --- a/lib/config/testdata/good/block_cf_workers.yaml +++ b/lib/config/testdata/good/block_cf_workers.yaml @@ -3,3 +3,7 @@ bots: headers_regex: CF-Worker: .* action: DENY + +metrics: + bind: ":9090" + network: "tcp" diff --git a/lib/config/testdata/good/challenge_cloudflare.yaml b/lib/config/testdata/good/challenge_cloudflare.yaml index 1c728cba..31de3333 100644 --- a/lib/config/testdata/good/challenge_cloudflare.yaml +++ b/lib/config/testdata/good/challenge_cloudflare.yaml @@ -4,3 +4,7 @@ bots: asns: match: - 13335 # Cloudflare + +metrics: + bind: ":9090" + network: "tcp" diff --git a/lib/config/testdata/good/challengemozilla.json b/lib/config/testdata/good/challengemozilla.json index eaee8490..30b60b92 100644 --- a/lib/config/testdata/good/challengemozilla.json +++ b/lib/config/testdata/good/challengemozilla.json @@ -5,5 +5,9 @@ "user_agent_regex": "Mozilla", "action": "CHALLENGE" } - ] + ], + "metrics": { + "bind": ":9090", + "network": "tcp" + } } diff --git a/lib/config/testdata/good/challengemozilla.yaml b/lib/config/testdata/good/challengemozilla.yaml index f6d8e9ac..eb87a4f0 100644 --- a/lib/config/testdata/good/challengemozilla.yaml +++ b/lib/config/testdata/good/challengemozilla.yaml @@ -2,3 +2,7 @@ bots: - name: generic-browser user_agent_regex: Mozilla action: CHALLENGE + +metrics: + bind: ":9090" + network: "tcp" diff --git a/lib/config/testdata/good/dns-ttl-custom.yaml b/lib/config/testdata/good/dns-ttl-custom.yaml index eb89e126..66fa30e2 100644 --- a/lib/config/testdata/good/dns-ttl-custom.yaml +++ b/lib/config/testdata/good/dns-ttl-custom.yaml @@ -6,3 +6,7 @@ bots: - name: "test" user_agent_regex: ".*" action: "DENY" + +metrics: + bind: ":9090" + network: "tcp" diff --git a/lib/config/testdata/good/entropy.yaml b/lib/config/testdata/good/entropy.yaml index 80110c13..47be9d48 100644 --- a/lib/config/testdata/good/entropy.yaml +++ b/lib/config/testdata/good/entropy.yaml @@ -6,3 +6,7 @@ bots: - '"Accept" in headers' - headers["Accept"].contains("text/html") - randInt(1) == 0 + +metrics: + bind: ":9090" + network: "tcp" diff --git a/lib/config/testdata/good/everything_blocked.json b/lib/config/testdata/good/everything_blocked.json index ab694f25..98b35eaf 100644 --- a/lib/config/testdata/good/everything_blocked.json +++ b/lib/config/testdata/good/everything_blocked.json @@ -6,5 +6,9 @@ "action": "DENY" } ], - "dnsbl": false + "dnsbl": false, + "metrics": { + "bind": ":9090", + "network": "tcp" + } } diff --git a/lib/config/testdata/good/everything_blocked.yaml b/lib/config/testdata/good/everything_blocked.yaml index 1c805581..3f5e1dbe 100644 --- a/lib/config/testdata/good/everything_blocked.yaml +++ b/lib/config/testdata/good/everything_blocked.yaml @@ -2,3 +2,7 @@ bots: - name: everything user_agent_regex: .* action: DENY + +metrics: + bind: ":9090" + network: "tcp" diff --git a/lib/config/testdata/good/geoip_us.yaml b/lib/config/testdata/good/geoip_us.yaml index b5e42804..6c3c6b7a 100644 --- a/lib/config/testdata/good/geoip_us.yaml +++ b/lib/config/testdata/good/geoip_us.yaml @@ -4,3 +4,7 @@ bots: geoip: countries: - US + +metrics: + bind: ":9090" + network: "tcp" diff --git a/lib/config/testdata/good/git_client.json b/lib/config/testdata/good/git_client.json index 52c5e11f..b1be750f 100644 --- a/lib/config/testdata/good/git_client.json +++ b/lib/config/testdata/good/git_client.json @@ -10,5 +10,9 @@ ] } } - ] + ], + "metrics": { + "bind": ":9090", + "network": "tcp" + } } diff --git a/lib/config/testdata/good/git_client.yaml b/lib/config/testdata/good/git_client.yaml index 1c496789..6620b673 100644 --- a/lib/config/testdata/good/git_client.yaml +++ b/lib/config/testdata/good/git_client.yaml @@ -6,3 +6,7 @@ bots: - userAgent.startsWith("git/") || userAgent.contains("libgit") - > "Git-Protocol" in headers && headers["Git-Protocol"] == "version=2" + +metrics: + bind: ":9090" + network: "tcp" diff --git a/lib/config/testdata/good/import_filesystem.json b/lib/config/testdata/good/import_filesystem.json index ba0228a7..a45881d4 100644 --- a/lib/config/testdata/good/import_filesystem.json +++ b/lib/config/testdata/good/import_filesystem.json @@ -3,5 +3,9 @@ { "import": "./testdata/hack-test.json" } - ] + ], + "metrics": { + "bind": ":9090", + "network": "tcp" + } } diff --git a/lib/config/testdata/good/import_filesystem.yaml b/lib/config/testdata/good/import_filesystem.yaml index 2ea0d54e..cac73096 100644 --- a/lib/config/testdata/good/import_filesystem.yaml +++ b/lib/config/testdata/good/import_filesystem.yaml @@ -1,2 +1,6 @@ bots: - import: ./testdata/hack-test.yaml + +metrics: + bind: ":9090" + network: "tcp" diff --git a/lib/config/testdata/good/import_keep_internet_working.json b/lib/config/testdata/good/import_keep_internet_working.json index 06b08fe1..53d6743e 100644 --- a/lib/config/testdata/good/import_keep_internet_working.json +++ b/lib/config/testdata/good/import_keep_internet_working.json @@ -3,5 +3,9 @@ { "import": "(data)/common/keep-internet-working.yaml" } - ] + ], + "metrics": { + "bind": ":9090", + "network": "tcp" + } } diff --git a/lib/config/testdata/good/import_keep_internet_working.yaml b/lib/config/testdata/good/import_keep_internet_working.yaml index 19ff33eb..dbb191e6 100644 --- a/lib/config/testdata/good/import_keep_internet_working.yaml +++ b/lib/config/testdata/good/import_keep_internet_working.yaml @@ -1,2 +1,6 @@ bots: - import: (data)/common/keep-internet-working.yaml + +metrics: + bind: ":9090" + network: "tcp" diff --git a/lib/config/testdata/good/impressum.yaml b/lib/config/testdata/good/impressum.yaml index 9a1a03fc..460e26d1 100644 --- a/lib/config/testdata/good/impressum.yaml +++ b/lib/config/testdata/good/impressum.yaml @@ -8,3 +8,7 @@ impressum: page: title: Test body:

This is a test

+ +metrics: + bind: ":9090" + network: "tcp" diff --git a/lib/config/testdata/good/logging-file.yaml b/lib/config/testdata/good/logging-file.yaml index c1f09b36..a2a63ede 100644 --- a/lib/config/testdata/good/logging-file.yaml +++ b/lib/config/testdata/good/logging-file.yaml @@ -13,3 +13,7 @@ logs: oldFileTimeFormat: 2006-01-02T15-04-05 # RFC 3339-ish compress: true useLocalTime: false # timezone for rotated files is UTC + +metrics: + bind: ":9090" + network: "tcp" diff --git a/lib/config/testdata/good/logging-stdio.yaml b/lib/config/testdata/good/logging-stdio.yaml index d0fcf54b..26c94037 100644 --- a/lib/config/testdata/good/logging-stdio.yaml +++ b/lib/config/testdata/good/logging-stdio.yaml @@ -5,3 +5,7 @@ bots: logging: sink: "stdio" + +metrics: + bind: ":9090" + network: "tcp" diff --git a/lib/config/testdata/good/no-thresholds.yaml b/lib/config/testdata/good/no-thresholds.yaml index bf981fdd..66ec9905 100644 --- a/lib/config/testdata/good/no-thresholds.yaml +++ b/lib/config/testdata/good/no-thresholds.yaml @@ -6,3 +6,7 @@ bots: adjust: 5 thresholds: [] + +metrics: + bind: ":9090" + network: "tcp" diff --git a/lib/config/testdata/good/old_xesite.json b/lib/config/testdata/good/old_xesite.json index 763d6785..39c2fbe1 100644 --- a/lib/config/testdata/good/old_xesite.json +++ b/lib/config/testdata/good/old_xesite.json @@ -75,5 +75,9 @@ "user_agent_regex": "Mozilla", "action": "CHALLENGE" } - ] + ], + "metrics": { + "bind": ":9090", + "network": "tcp" + } } diff --git a/lib/config/testdata/good/opengraph_all_good.yaml b/lib/config/testdata/good/opengraph_all_good.yaml index ae26d99b..f514c39c 100644 --- a/lib/config/testdata/good/opengraph_all_good.yaml +++ b/lib/config/testdata/good/opengraph_all_good.yaml @@ -10,3 +10,7 @@ openGraph: default: "og:title": "Xe's magic land of fun" "og:description": "We're no strangers to love, you know the rules and so do I" + +metrics: + bind: ":9090" + network: "tcp" diff --git a/lib/config/testdata/good/simple-weight.yaml b/lib/config/testdata/good/simple-weight.yaml index ec7a92e9..3130f182 100644 --- a/lib/config/testdata/good/simple-weight.yaml +++ b/lib/config/testdata/good/simple-weight.yaml @@ -4,3 +4,7 @@ bots: user_agent_regex: Mozilla weight: adjust: 5 + +metrics: + bind: ":9090" + network: "tcp" diff --git a/lib/config/testdata/good/status-codes-paranoid.json b/lib/config/testdata/good/status-codes-paranoid.json index f84dde91..716a1922 100644 --- a/lib/config/testdata/good/status-codes-paranoid.json +++ b/lib/config/testdata/good/status-codes-paranoid.json @@ -9,5 +9,9 @@ "status_codes": { "CHALLENGE": 200, "DENY": 200 + }, + "metrics": { + "bind": ":9090", + "network": "tcp" } } diff --git a/lib/config/testdata/good/status-codes-paranoid.yaml b/lib/config/testdata/good/status-codes-paranoid.yaml index 0310388b..783482b7 100644 --- a/lib/config/testdata/good/status-codes-paranoid.yaml +++ b/lib/config/testdata/good/status-codes-paranoid.yaml @@ -6,3 +6,7 @@ bots: status_codes: CHALLENGE: 200 DENY: 200 + +metrics: + bind: ":9090" + network: "tcp" diff --git a/lib/config/testdata/good/status-codes-rfc.json b/lib/config/testdata/good/status-codes-rfc.json index 2fdaac05..da169a1c 100644 --- a/lib/config/testdata/good/status-codes-rfc.json +++ b/lib/config/testdata/good/status-codes-rfc.json @@ -9,5 +9,9 @@ "status_codes": { "CHALLENGE": 403, "DENY": 403 + }, + "metrics": { + "bind": ":9090", + "network": "tcp" } } diff --git a/lib/config/testdata/good/status-codes-rfc.yaml b/lib/config/testdata/good/status-codes-rfc.yaml index c70ea126..26c35791 100644 --- a/lib/config/testdata/good/status-codes-rfc.yaml +++ b/lib/config/testdata/good/status-codes-rfc.yaml @@ -6,3 +6,7 @@ bots: status_codes: CHALLENGE: 403 DENY: 403 + +metrics: + bind: ":9090" + network: "tcp" diff --git a/lib/config/testdata/good/thresholds.yaml b/lib/config/testdata/good/thresholds.yaml index 9365c715..06966e80 100644 --- a/lib/config/testdata/good/thresholds.yaml +++ b/lib/config/testdata/good/thresholds.yaml @@ -33,3 +33,7 @@ thresholds: challenge: algorithm: fast difficulty: 4 + +metrics: + bind: ":9090" + network: "tcp" diff --git a/lib/config/testdata/good/weight-no-weight.yaml b/lib/config/testdata/good/weight-no-weight.yaml index f137f8ac..d88bd785 100644 --- a/lib/config/testdata/good/weight-no-weight.yaml +++ b/lib/config/testdata/good/weight-no-weight.yaml @@ -2,3 +2,7 @@ bots: - name: weight action: WEIGH user_agent_regex: Mozilla + +metrics: + bind: ":9090" + network: "tcp" diff --git a/lib/metrics/metrics.go b/lib/metrics/metrics.go new file mode 100644 index 00000000..c0384be4 --- /dev/null +++ b/lib/metrics/metrics.go @@ -0,0 +1,81 @@ +package metrics + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "net/http/pprof" + "time" + + "github.com/TecharoHQ/anubis/internal" + "github.com/TecharoHQ/anubis/lib/config" + "github.com/prometheus/client_golang/prometheus/promhttp" + healthv1 "google.golang.org/grpc/health/grpc_health_v1" +) + +type Server struct { + Config *config.Metrics + Log *slog.Logger +} + +func (s *Server) Run(ctx context.Context, done func()) error { + defer done() + lg := s.Log.With("subsystem", "metrics") + + mux := http.NewServeMux() + mux.HandleFunc("GET /debug/pprof/", pprof.Index) + mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline) + mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile) + mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol) + mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace) + mux.Handle("/metrics", promhttp.Handler()) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + st, ok := internal.GetHealth("anubis") + if !ok { + slog.Error("health service anubis does not exist, file a bug") + } + + switch st { + case healthv1.HealthCheckResponse_NOT_SERVING: + http.Error(w, "NOT OK", http.StatusInternalServerError) + return + case healthv1.HealthCheckResponse_SERVING: + fmt.Fprintln(w, "OK") + return + default: + http.Error(w, "UNKNOWN", http.StatusFailedDependency) + return + } + }) + + srv := http.Server{ + Handler: mux, + ErrorLog: internal.GetFilteredHTTPLogger(), + } + + ln, metricsURL, err := internal.SetupListener(s.Config.Bind, s.Config.Network, s.Config.SocketMode) + if err != nil { + return fmt.Errorf("can't setup listener: %w", err) + } + + defer ln.Close() + + lg.Debug("listening for metrics", "url", metricsURL) + + go func() { + <-ctx.Done() + c, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(c); err != nil { + lg.Error("can't shut down metrics server", "err", err) + } + }() + + if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("can't serve metrics server: %w", err) + } + + return nil +} diff --git a/lib/policy/policy.go b/lib/policy/policy.go index 5bd4958d..25e2148a 100644 --- a/lib/policy/policy.go +++ b/lib/policy/policy.go @@ -46,6 +46,7 @@ type ParsedConfig struct { DnsCache *dns.DnsCache Dns *dns.Dns Logger *slog.Logger + Metrics *config.Metrics } func newParsedConfig(orig *config.Config) *ParsedConfig { @@ -53,6 +54,7 @@ func newParsedConfig(orig *config.Config) *ParsedConfig { orig: orig, OpenGraph: orig.OpenGraph, StatusCodes: orig.StatusCodes, + Metrics: orig.Metrics, } }