main's tree, with next merged on 26 October, as one commit onto prod, on sneak's order. Model: opus-5-5 Co-authored-by: clawbot <clawbot@noreply.example.org>
This commit was merged in pull request #198.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
// Package cli runs netwatch-server from its command line: the
|
||||
// check-cidr and prepare-data-dir subcommands, which bin/entrypoint.sh
|
||||
// runs before it starts the server, and, given no arguments, the
|
||||
// server, with the fx wiring.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os/user"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/config"
|
||||
"sneak.berlin/go/netwatch/internal/globals"
|
||||
"sneak.berlin/go/netwatch/internal/handlers"
|
||||
"sneak.berlin/go/netwatch/internal/healthcheck"
|
||||
"sneak.berlin/go/netwatch/internal/logger"
|
||||
"sneak.berlin/go/netwatch/internal/middleware"
|
||||
"sneak.berlin/go/netwatch/internal/reportbuf"
|
||||
"sneak.berlin/go/netwatch/internal/server"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxevent"
|
||||
)
|
||||
|
||||
// usageExitCode is what Main returns for a command line it does not
|
||||
// understand, the usual exit code for a usage error.
|
||||
const usageExitCode = 2
|
||||
|
||||
// Main runs netwatch-server with args, its command line after the
|
||||
// program's name, and returns the exit code. With no args, Main runs
|
||||
// the server by calling serve with appname and version, the name and
|
||||
// version the server logs and reports. With "check-cidr CIDR" or
|
||||
// "prepare-data-dir DATA_DIR" it runs that subcommand, which writes its
|
||||
// errors to stderr. Any other args get a one-line usage message on
|
||||
// stderr and exit code 2. main passes os.Stderr and RunServer; the
|
||||
// tests pass a buffer and a function of their own, so that no test
|
||||
// starts the server. When serve is RunServer and the server fails to
|
||||
// start, to listen or to stop, fx's Run exits the process with status 1
|
||||
// and Main never returns.
|
||||
func Main(
|
||||
stderr io.Writer,
|
||||
appname, version string,
|
||||
args []string,
|
||||
serve func(appname, version string),
|
||||
) int {
|
||||
// "netwatch-server check-cidr CIDR" exits 1, with the error, if
|
||||
// this server would refuse CIDR in its TRUSTED_PROXIES.
|
||||
// bin/entrypoint.sh runs it on each entry it gives nginx.
|
||||
if len(args) == 2 && args[0] == "check-cidr" {
|
||||
return checkCIDR(stderr, args[1])
|
||||
}
|
||||
|
||||
// "netwatch-server prepare-data-dir DATA_DIR" gets DATA_DIR ready
|
||||
// for the netwatch user, or exits 1 with the error; see
|
||||
// reportbuf.PrepareDataDir. bin/entrypoint.sh runs it as root
|
||||
// before it starts this server as that user.
|
||||
if len(args) == 2 && args[0] == "prepare-data-dir" {
|
||||
return prepareDataDir(stderr, "/data", args[1], "netwatch")
|
||||
}
|
||||
|
||||
// Any other command line is refused, so that a mistyped one does
|
||||
// not start the server.
|
||||
if len(args) > 0 {
|
||||
_, _ = fmt.Fprintln(stderr,
|
||||
"usage: netwatch-server [check-cidr CIDR | prepare-data-dir DATA_DIR]")
|
||||
|
||||
return usageExitCode
|
||||
}
|
||||
|
||||
serve(appname, version)
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// checkCIDR returns 0 if the server would take cidr in its
|
||||
// TRUSTED_PROXIES, and otherwise writes the error to stderr and
|
||||
// returns 1.
|
||||
func checkCIDR(stderr io.Writer, cidr string) int {
|
||||
_, err := middleware.ParseTrustedProxies([]string{cidr})
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(stderr, err)
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// prepareDataDir gets dataDir ready for the user named username with
|
||||
// reportbuf.PrepareDataDir, volume being where the data volume is
|
||||
// mounted, and returns 0. Otherwise it writes the error to stderr and
|
||||
// returns 1.
|
||||
func prepareDataDir(stderr io.Writer, volume, dataDir, username string) int {
|
||||
owner, err := user.Lookup(username)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(stderr, err)
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
err = reportbuf.PrepareDataDir(volume, dataDir, owner)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(stderr, "DATA_DIR '%s': %v\n", dataDir, err)
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// RunServer runs the server until it is told to stop, with appname and
|
||||
// version as the name and version it logs and reports. If the server
|
||||
// fails to start, to listen or to stop, fx's Run exits the process
|
||||
// with status 1 itself.
|
||||
func RunServer(appname, version string) {
|
||||
globals.Appname = appname
|
||||
globals.Version = version
|
||||
|
||||
fx.New(
|
||||
// fx logs each step of starting and stopping through the
|
||||
// server's own logger, so off a terminal those lines are
|
||||
// JSON like every other line.
|
||||
fx.WithLogger(func(log *logger.Logger) fxevent.Logger {
|
||||
return &fxevent.SlogLogger{Logger: log.Get()}
|
||||
}),
|
||||
fx.Provide(
|
||||
config.New,
|
||||
globals.New,
|
||||
handlers.New,
|
||||
healthcheck.New,
|
||||
logger.New,
|
||||
middleware.New,
|
||||
reportbuf.New,
|
||||
server.New,
|
||||
),
|
||||
fx.Invoke(
|
||||
// First, so the name and version are logged even when
|
||||
// a setting stops the start.
|
||||
func(log *logger.Logger) { log.Identify() },
|
||||
func(*server.Server) {},
|
||||
),
|
||||
).Run()
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/cli"
|
||||
"sneak.berlin/go/netwatch/internal/reportbuf"
|
||||
)
|
||||
|
||||
// TestMainRunsCheckCIDR: Main given check-cidr returns 0, writing
|
||||
// nothing, for a CIDR the server takes in its TRUSTED_PROXIES, and 1,
|
||||
// with an error naming TRUSTED_PROXIES and the CIDR, for one it
|
||||
// refuses, and does not start the server.
|
||||
func TestMainRunsCheckCIDR(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := cli.Main(&stderr, "netwatch", "test",
|
||||
[]string{"check-cidr", "10.0.0.0/8"}, failIfServerStarts(t))
|
||||
if code != 0 || stderr.Len() != 0 {
|
||||
t.Errorf("check-cidr 10.0.0.0/8: exit %d, stderr %q; want exit 0 and no output",
|
||||
code, stderr.String())
|
||||
}
|
||||
|
||||
stderr.Reset()
|
||||
|
||||
code = cli.Main(&stderr, "netwatch", "test",
|
||||
[]string{"check-cidr", "1.2.3/32"}, failIfServerStarts(t))
|
||||
|
||||
want := `TRUSTED_PROXIES "1.2.3/32": `
|
||||
if code != 1 || !strings.HasPrefix(stderr.String(), want) {
|
||||
t.Errorf("check-cidr 1.2.3/32: exit %d, stderr %q; want exit 1 and %q...",
|
||||
code, stderr.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMainRunsPrepareDataDir: Main given prepare-data-dir runs the
|
||||
// prepare-data-dir subcommand for the netwatch user, and does not start
|
||||
// the server. The test image has no netwatch user, so that subcommand
|
||||
// exits 1 with the error from looking the user up;
|
||||
// TestPrepareDataDirRefusesPathOutsideVolume checks the path.
|
||||
func TestMainRunsPrepareDataDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := cli.Main(&stderr, "netwatch", "test",
|
||||
[]string{"prepare-data-dir", "/etc"}, failIfServerStarts(t))
|
||||
|
||||
want := user.UnknownUserError("netwatch").Error() + "\n"
|
||||
if code != 1 || stderr.String() != want {
|
||||
t.Errorf("prepare-data-dir /etc: exit %d, stderr %q; want exit 1 and %q",
|
||||
code, stderr.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMainRunsServerWithNoArguments: Main given no arguments starts the
|
||||
// server with the name and version it was given, writes nothing and
|
||||
// returns 0.
|
||||
func TestMainRunsServerWithNoArguments(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
stderr bytes.Buffer
|
||||
gotAppname, gotVersion string
|
||||
)
|
||||
|
||||
code := cli.Main(&stderr, "netwatch", "test", []string{},
|
||||
func(appname, version string) {
|
||||
gotAppname, gotVersion = appname, version
|
||||
})
|
||||
if code != 0 || stderr.Len() != 0 {
|
||||
t.Errorf("exit %d, stderr %q; want exit 0 and no output",
|
||||
code, stderr.String())
|
||||
}
|
||||
|
||||
if gotAppname != "netwatch" || gotVersion != "test" {
|
||||
t.Errorf("server started with %q %q; want %q %q",
|
||||
gotAppname, gotVersion, "netwatch", "test")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMainRefusesOtherCommandLines: Main given a subcommand with too
|
||||
// few or too many values, or a misspelt subcommand, writes the usage
|
||||
// message to stderr, returns 2 and does not start the server.
|
||||
func TestMainRefusesOtherCommandLines(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
want := "usage: netwatch-server " +
|
||||
"[check-cidr CIDR | prepare-data-dir DATA_DIR]\n"
|
||||
|
||||
for _, commandLine := range []string{
|
||||
"netwatch-server check-cidr",
|
||||
"netwatch-server check-cidr 192.0.2.0/24 198.51.100.0/24",
|
||||
"netwatch-server prepare-data-dir",
|
||||
"netwatch-server prepare-data-dir /data/reports /data/other",
|
||||
"netwatch-server check-cdir 192.0.2.0/24",
|
||||
} {
|
||||
var stderr bytes.Buffer
|
||||
|
||||
// Main takes the command line after the program's name.
|
||||
args := strings.Fields(commandLine)[1:]
|
||||
|
||||
code := cli.Main(&stderr, "netwatch", "test", args, failIfServerStarts(t))
|
||||
if code != 2 || stderr.String() != want {
|
||||
t.Errorf("%q: exit %d, stderr %q; want exit 2 and %q",
|
||||
commandLine, code, stderr.String(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckCIDRTakesCIDR: check-cidr exits 0, writing nothing, for a
|
||||
// CIDR the server takes in its TRUSTED_PROXIES.
|
||||
func TestCheckCIDRTakesCIDR(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, cidr := range []string{"10.0.0.0/8", "192.0.2.1/32", "2001:db8::/32"} {
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := cli.CheckCIDR(&stderr, cidr)
|
||||
if code != 0 || stderr.Len() != 0 {
|
||||
t.Errorf("%q: exit %d, stderr %q; want exit 0 and no output",
|
||||
cidr, code, stderr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckCIDRRefusesOtherInput: check-cidr exits 1, with an error
|
||||
// naming TRUSTED_PROXIES and the input, for anything the server would
|
||||
// refuse in its TRUSTED_PROXIES, such as 1.2.3/32, which nginx would
|
||||
// look up as a hostname.
|
||||
func TestCheckCIDRRefusesOtherInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, cidr := range []string{
|
||||
"1.2.3/32", "example.com/32", "10.0.0.0/33", "10.0.0.1", "",
|
||||
} {
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := cli.CheckCIDR(&stderr, cidr)
|
||||
|
||||
want := "TRUSTED_PROXIES " + strconv.Quote(cidr) + ": "
|
||||
if code != 1 || !strings.HasPrefix(stderr.String(), want) {
|
||||
t.Errorf("%q: exit %d, stderr %q; want exit 1 and %q...",
|
||||
cidr, code, stderr.String(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareDataDirTakesPathInVolume: prepare-data-dir exits 0,
|
||||
// writing nothing, for a DATA_DIR below the volume, and creates it.
|
||||
func TestPrepareDataDirTakesPathInVolume(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
volume := t.TempDir()
|
||||
dataDir := filepath.Join(volume, "reports")
|
||||
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := cli.PrepareDataDir(&stderr, volume, dataDir, currentUsername(t))
|
||||
if code != 0 || stderr.Len() != 0 {
|
||||
t.Errorf("exit %d, stderr %q; want exit 0 and no output",
|
||||
code, stderr.String())
|
||||
}
|
||||
|
||||
info, err := os.Stat(dataDir)
|
||||
if err != nil || !info.IsDir() {
|
||||
t.Errorf("DATA_DIR not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareDataDirRefusesPathOutsideVolume: prepare-data-dir exits
|
||||
// 1, with an error naming DATA_DIR and its value, for a DATA_DIR
|
||||
// outside the volume.
|
||||
func TestPrepareDataDirRefusesPathOutsideVolume(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
volume := t.TempDir()
|
||||
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := cli.PrepareDataDir(&stderr, volume, "/etc", currentUsername(t))
|
||||
|
||||
want := "DATA_DIR '/etc': " + reportbuf.ErrDataDirOutsideVolume.Error() + "\n"
|
||||
if code != 1 || stderr.String() != want {
|
||||
t.Errorf("exit %d, stderr %q; want exit 1 and %q",
|
||||
code, stderr.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// currentUsername is the name of the user the test runs as, the only
|
||||
// owner a test not run as root can give files to.
|
||||
func currentUsername(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
current, err := user.Current()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return current.Username
|
||||
}
|
||||
|
||||
// failIfServerStarts is what the tests give Main in place of
|
||||
// cli.RunServer: it fails the test instead of starting the server.
|
||||
func failIfServerStarts(t *testing.T) func(appname, version string) {
|
||||
t.Helper()
|
||||
|
||||
return func(string, string) {
|
||||
t.Error("Main started the server")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package cli
|
||||
|
||||
import "io"
|
||||
|
||||
// CheckCIDR exposes the check-cidr subcommand to the external tests,
|
||||
// which read what it writes to stderr.
|
||||
func CheckCIDR(stderr io.Writer, cidr string) int {
|
||||
return checkCIDR(stderr, cidr)
|
||||
}
|
||||
|
||||
// PrepareDataDir exposes the prepare-data-dir subcommand to the
|
||||
// external tests, which give it a volume and a user of their own in
|
||||
// place of /data and netwatch.
|
||||
func PrepareDataDir(stderr io.Writer, volume, dataDir, username string) int {
|
||||
return prepareDataDir(stderr, volume, dataDir, username)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// Package config loads application configuration from
|
||||
// environment variables, .env files, and config files.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/globals"
|
||||
"sneak.berlin/go/netwatch/internal/logger"
|
||||
|
||||
_ "github.com/joho/godotenv/autoload" // loads .env file
|
||||
"github.com/spf13/viper"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
// defaultTrustedProxies lists the networks whose forwarded
|
||||
// headers are honoured when TRUSTED_PROXIES is unset: the
|
||||
// RFC 1918 ranges. The container image does not use it:
|
||||
// bin/entrypoint.sh gives the server 127.0.0.1/32, since
|
||||
// nginx is its only client there.
|
||||
const defaultTrustedProxies = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||
|
||||
// Default limits on stored reports; README.md gives the reasons for
|
||||
// these values.
|
||||
const (
|
||||
defaultReportsPerMinute = 60
|
||||
defaultDataDirMaxBytes = 1 << 30 // 1 GiB
|
||||
)
|
||||
|
||||
var (
|
||||
errNotPositive = errors.New("must be a positive whole number")
|
||||
errNotOrigin = errors.New(
|
||||
"must be an origin, scheme://host with an optional port",
|
||||
)
|
||||
errNotPort = errors.New("must be a port number, 1 to 65535")
|
||||
errNotIP = errors.New("must be an IP address, or empty")
|
||||
|
||||
errMetricsCredentials = errors.New(
|
||||
"METRICS_USERNAME and METRICS_PASSWORD must be set together, " +
|
||||
"or neither",
|
||||
)
|
||||
errMetricsUsernameColon = errors.New(
|
||||
"METRICS_USERNAME must not contain \":\", " +
|
||||
"which basic auth cannot carry in a user name",
|
||||
)
|
||||
)
|
||||
|
||||
// Params defines the dependencies for Config.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Globals *globals.Globals
|
||||
Logger *logger.Logger
|
||||
}
|
||||
|
||||
// Config holds the resolved application configuration.
|
||||
type Config struct {
|
||||
BindAddress string
|
||||
CORSAllowedOrigins []string
|
||||
DataDir string
|
||||
DataDirMaxBytes int64
|
||||
Debug bool
|
||||
MetricsPassword string
|
||||
MetricsUsername string
|
||||
Port int
|
||||
ReportsPerMinute int
|
||||
SentryDSN string
|
||||
TrustedProxies []string
|
||||
log *slog.Logger
|
||||
params *Params
|
||||
}
|
||||
|
||||
// New loads configuration from env, .env files, and config
|
||||
// files, returning a fully resolved Config. It fails, with an error
|
||||
// naming the setting, on a value the server cannot use, and on a
|
||||
// config file it finds but cannot read.
|
||||
func New(
|
||||
_ fx.Lifecycle,
|
||||
params Params,
|
||||
) (*Config, error) {
|
||||
log := params.Logger.Get()
|
||||
name := params.Globals.Appname
|
||||
|
||||
viper.SetConfigName(name)
|
||||
viper.SetConfigType("yaml")
|
||||
viper.AddConfigPath("/etc/" + name)
|
||||
viper.AddConfigPath("$HOME/.config/" + name)
|
||||
|
||||
viper.AutomaticEnv()
|
||||
|
||||
// An empty CORS_ALLOWED_ORIGINS allows no other origin.
|
||||
viper.SetDefault("CORS_ALLOWED_ORIGINS", "")
|
||||
viper.SetDefault("DATA_DIR", "./data/reports")
|
||||
viper.SetDefault("DATA_DIR_MAX_BYTES", defaultDataDirMaxBytes)
|
||||
viper.SetDefault("DEBUG", "")
|
||||
// An empty BIND_ADDRESS listens on every interface.
|
||||
viper.SetDefault("BIND_ADDRESS", "")
|
||||
viper.SetDefault("PORT", "8080")
|
||||
viper.SetDefault("REPORTS_PER_MINUTE", defaultReportsPerMinute)
|
||||
viper.SetDefault("SENTRY_DSN", "")
|
||||
viper.SetDefault("METRICS_USERNAME", "")
|
||||
viper.SetDefault("METRICS_PASSWORD", "")
|
||||
viper.SetDefault("TRUSTED_PROXIES", defaultTrustedProxies)
|
||||
|
||||
err := viper.ReadInConfig()
|
||||
if err != nil {
|
||||
var notFound viper.ConfigFileNotFoundError
|
||||
if !errors.As(err, ¬Found) {
|
||||
return nil, fmt.Errorf("config file %s: %w",
|
||||
viper.ConfigFileUsed(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Read with strconv: viper's GetInt would read a value it cannot
|
||||
// parse as 0 instead of failing.
|
||||
port, err := strconv.Atoi(viper.GetString("PORT"))
|
||||
if err != nil || port < 1 || port > math.MaxUint16 {
|
||||
return nil, fmt.Errorf("PORT %q: %w",
|
||||
viper.GetString("PORT"), errNotPort)
|
||||
}
|
||||
|
||||
// Any non-empty value turns debug logging on, "false" and "0"
|
||||
// included, as the code style guide says.
|
||||
debug := viper.GetString("DEBUG") != ""
|
||||
|
||||
// An empty TRUSTED_PROXIES trusts no proxy, but viper reads an
|
||||
// empty environment variable as unset and would give the default,
|
||||
// so the environment is read first.
|
||||
trustedProxies, set := os.LookupEnv("TRUSTED_PROXIES")
|
||||
if !set {
|
||||
trustedProxies = viper.GetString("TRUSTED_PROXIES")
|
||||
}
|
||||
|
||||
s := &Config{
|
||||
BindAddress: viper.GetString("BIND_ADDRESS"),
|
||||
CORSAllowedOrigins: splitList(viper.GetString("CORS_ALLOWED_ORIGINS")),
|
||||
DataDir: viper.GetString("DATA_DIR"),
|
||||
DataDirMaxBytes: viper.GetInt64("DATA_DIR_MAX_BYTES"),
|
||||
Debug: debug,
|
||||
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
||||
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
||||
Port: port,
|
||||
ReportsPerMinute: viper.GetInt("REPORTS_PER_MINUTE"),
|
||||
SentryDSN: viper.GetString("SENTRY_DSN"),
|
||||
TrustedProxies: splitList(trustedProxies),
|
||||
log: log,
|
||||
params: ¶ms,
|
||||
}
|
||||
|
||||
err = s.check()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.Debug {
|
||||
params.Logger.EnableDebugLogging()
|
||||
s.log = params.Logger.Get()
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// check fails with an error naming the first setting here whose value
|
||||
// the server cannot use. New checks PORT as it reads it, and the
|
||||
// middleware checks TRUSTED_PROXIES as it parses it.
|
||||
func (s *Config) check() error {
|
||||
// viper reads a value that is not a number as 0, so this also
|
||||
// catches a mistyped setting.
|
||||
if s.ReportsPerMinute <= 0 {
|
||||
return fmt.Errorf("REPORTS_PER_MINUTE %q: %w",
|
||||
viper.GetString("REPORTS_PER_MINUTE"), errNotPositive)
|
||||
}
|
||||
|
||||
if s.DataDirMaxBytes <= 0 {
|
||||
return fmt.Errorf("DATA_DIR_MAX_BYTES %q: %w",
|
||||
viper.GetString("DATA_DIR_MAX_BYTES"), errNotPositive)
|
||||
}
|
||||
|
||||
if s.BindAddress != "" {
|
||||
_, err := netip.ParseAddr(s.BindAddress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("BIND_ADDRESS %q: %w", s.BindAddress, errNotIP)
|
||||
}
|
||||
}
|
||||
|
||||
// The server records and serves metrics only with both set, so
|
||||
// one alone is a mistake that would otherwise go unnoticed.
|
||||
if (s.MetricsUsername == "") != (s.MetricsPassword == "") {
|
||||
return errMetricsCredentials
|
||||
}
|
||||
|
||||
// Basic auth splits the credentials at the first ":", so with one
|
||||
// in the user name every request to /metrics would get 401.
|
||||
if strings.Contains(s.MetricsUsername, ":") {
|
||||
return errMetricsUsernameColon
|
||||
}
|
||||
|
||||
return checkOrigins(s.CORSAllowedOrigins)
|
||||
}
|
||||
|
||||
// checkOrigins fails on the first CORS_ALLOWED_ORIGINS entry that is
|
||||
// not a plain origin, scheme://host with an optional port, as browsers
|
||||
// send it; anything more, such as a trailing "/", would match no page.
|
||||
// go-chi/cors reads a "*" anywhere in an entry as a wildcard, so no
|
||||
// entry may contain one.
|
||||
func checkOrigins(origins []string) error {
|
||||
for _, origin := range origins {
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" ||
|
||||
strings.Contains(origin, "*") ||
|
||||
origin != u.Scheme+"://"+u.Host {
|
||||
return fmt.Errorf("CORS_ALLOWED_ORIGINS %q: %w",
|
||||
origin, errNotOrigin)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitList turns a comma-separated setting into a trimmed
|
||||
// slice, dropping empty entries.
|
||||
func splitList(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/config"
|
||||
"sneak.berlin/go/netwatch/internal/globals"
|
||||
"sneak.berlin/go/netwatch/internal/logger"
|
||||
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
// requireConfigError builds the config as cli.RunServer does and fails
|
||||
// the test unless that fails with an error naming setting. It uses
|
||||
// fx.New, because fxtest.New fails the test itself on an error.
|
||||
func requireConfigError(t *testing.T, setting string) {
|
||||
t.Helper()
|
||||
|
||||
app := fx.New(
|
||||
fx.NopLogger,
|
||||
fx.Provide(globals.New, logger.New, config.New),
|
||||
fx.Invoke(func(*config.Config) {}),
|
||||
)
|
||||
|
||||
err := app.Err()
|
||||
if err == nil || !strings.Contains(err.Error(), setting) {
|
||||
t.Fatalf("config error = %v, want one naming %s", err, setting)
|
||||
}
|
||||
}
|
||||
|
||||
// requireTrustedProxies builds the config as cli.RunServer does and
|
||||
// fails the test unless its TrustedProxies are want.
|
||||
func requireTrustedProxies(t *testing.T, want []string) {
|
||||
t.Helper()
|
||||
|
||||
var cfg *config.Config
|
||||
|
||||
app := fx.New(
|
||||
fx.NopLogger,
|
||||
fx.Provide(globals.New, logger.New, config.New),
|
||||
fx.Populate(&cfg),
|
||||
)
|
||||
|
||||
err := app.Err()
|
||||
if err != nil {
|
||||
t.Fatalf("config error = %v", err)
|
||||
}
|
||||
|
||||
if !slices.Equal(cfg.TrustedProxies, want) {
|
||||
t.Fatalf("TrustedProxies = %q, want %q", cfg.TrustedProxies, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrustedProxiesUnsetTrustsRFC1918: unset, the server trusts the
|
||||
// RFC 1918 ranges and nothing else, loopback included.
|
||||
func TestTrustedProxiesUnsetTrustsRFC1918(t *testing.T) {
|
||||
// t.Setenv first, so the variable is put back as it was after the
|
||||
// test.
|
||||
t.Setenv("TRUSTED_PROXIES", "")
|
||||
|
||||
err := os.Unsetenv("TRUSTED_PROXIES")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
requireTrustedProxies(t,
|
||||
[]string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"})
|
||||
}
|
||||
|
||||
// TestTrustedProxiesEmptyTrustsNothing: set and empty, the server
|
||||
// trusts no proxy. viper alone would read the empty variable as unset
|
||||
// and give the default.
|
||||
func TestTrustedProxiesEmptyTrustsNothing(t *testing.T) {
|
||||
t.Setenv("TRUSTED_PROXIES", "")
|
||||
|
||||
requireTrustedProxies(t, []string{})
|
||||
}
|
||||
|
||||
// TestTrustedProxiesListReplacesDefault: a list is used as given, with
|
||||
// none of the default ranges added.
|
||||
func TestTrustedProxiesListReplacesDefault(t *testing.T) {
|
||||
t.Setenv("TRUSTED_PROXIES", "172.17.0.1/32, 2001:db8::/32")
|
||||
|
||||
requireTrustedProxies(t, []string{"172.17.0.1/32", "2001:db8::/32"})
|
||||
}
|
||||
|
||||
// TestSettingsLoadAsGiven: valid values pass the checks and are used
|
||||
// as given. bin/entrypoint.sh starts the server with these
|
||||
// BIND_ADDRESS and PORT values.
|
||||
func TestSettingsLoadAsGiven(t *testing.T) {
|
||||
t.Setenv("BIND_ADDRESS", "127.0.0.1")
|
||||
t.Setenv("PORT", "8081")
|
||||
t.Setenv("DEBUG", "true")
|
||||
|
||||
var cfg *config.Config
|
||||
|
||||
app := fx.New(
|
||||
fx.NopLogger,
|
||||
fx.Provide(globals.New, logger.New, config.New),
|
||||
fx.Populate(&cfg),
|
||||
)
|
||||
|
||||
err := app.Err()
|
||||
if err != nil {
|
||||
t.Fatalf("config error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.BindAddress != "127.0.0.1" || cfg.Port != 8081 || !cfg.Debug {
|
||||
t.Fatalf("BindAddress, Port, Debug = %q, %d, %t; "+
|
||||
"want \"127.0.0.1\", 8081, true",
|
||||
cfg.BindAddress, cfg.Port, cfg.Debug)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPortMustBeAPortNumber: viper reads a value that is not a number
|
||||
// as 0, on which the server would listen on a random port.
|
||||
func TestPortMustBeAPortNumber(t *testing.T) {
|
||||
for _, value := range []string{"abc", "0", "65536", "8080.5"} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
t.Setenv("PORT", value)
|
||||
|
||||
requireConfigError(t, "PORT")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDebugIsOnForAnyNonEmptyValue: unset or empty, debug logging is
|
||||
// off; any other value turns it on, "false" and "0" included, and none
|
||||
// stops the start.
|
||||
func TestDebugIsOnForAnyNonEmptyValue(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
value string
|
||||
unset bool
|
||||
want bool
|
||||
}{
|
||||
{name: "unset", unset: true, want: false},
|
||||
{name: "empty", value: "", want: false},
|
||||
{name: "false", value: "false", want: true},
|
||||
{name: "0", value: "0", want: true},
|
||||
{name: "yes", value: "yes", want: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// t.Setenv first, so the variable is put back as it was
|
||||
// after the test.
|
||||
t.Setenv("DEBUG", tc.value)
|
||||
|
||||
if tc.unset {
|
||||
err := os.Unsetenv("DEBUG")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var cfg *config.Config
|
||||
|
||||
app := fx.New(
|
||||
fx.NopLogger,
|
||||
fx.Provide(globals.New, logger.New, config.New),
|
||||
fx.Populate(&cfg),
|
||||
)
|
||||
|
||||
err := app.Err()
|
||||
if err != nil {
|
||||
t.Fatalf("config error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.Debug != tc.want {
|
||||
t.Fatalf("Debug = %t, want %t", cfg.Debug, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBindAddressMustBeAnIPAddress: a host name would be looked up
|
||||
// only once the server starts listening, and a mistyped one would stop
|
||||
// it then with an error that does not name the setting.
|
||||
func TestBindAddressMustBeAnIPAddress(t *testing.T) {
|
||||
t.Setenv("BIND_ADDRESS", "localhost")
|
||||
|
||||
requireConfigError(t, "BIND_ADDRESS")
|
||||
}
|
||||
|
||||
// TestReportsPerMinuteMustBePositive: unchecked, zero would panic
|
||||
// when the routes are built, and a negative rate would lift the
|
||||
// limit.
|
||||
func TestReportsPerMinuteMustBePositive(t *testing.T) {
|
||||
t.Setenv("REPORTS_PER_MINUTE", "0")
|
||||
|
||||
requireConfigError(t, "REPORTS_PER_MINUTE")
|
||||
}
|
||||
|
||||
// TestDataDirMaxBytesMustBeANumber: viper reads a value that is not
|
||||
// a number, such as "1GB", as 0, which would refuse every report.
|
||||
func TestDataDirMaxBytesMustBeANumber(t *testing.T) {
|
||||
t.Setenv("DATA_DIR_MAX_BYTES", "1GB")
|
||||
|
||||
requireConfigError(t, "DATA_DIR_MAX_BYTES")
|
||||
}
|
||||
|
||||
// TestMetricsCredentialsGoTogether: with only one of the two set, the
|
||||
// server would quietly serve no metrics, so the start fails, naming
|
||||
// both.
|
||||
func TestMetricsCredentialsGoTogether(t *testing.T) {
|
||||
for _, set := range []string{"METRICS_USERNAME", "METRICS_PASSWORD"} {
|
||||
t.Run(set, func(t *testing.T) {
|
||||
t.Setenv("METRICS_USERNAME", "")
|
||||
t.Setenv("METRICS_PASSWORD", "")
|
||||
t.Setenv(set, "prometheus")
|
||||
|
||||
requireConfigError(t, "METRICS_USERNAME")
|
||||
requireConfigError(t, "METRICS_PASSWORD")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetricsUsernameMustNotContainColon: basic auth splits the
|
||||
// credentials at the first ":", so such a user name would get 401 on
|
||||
// every request to /metrics.
|
||||
func TestMetricsUsernameMustNotContainColon(t *testing.T) {
|
||||
t.Setenv("METRICS_USERNAME", "prom:etheus")
|
||||
t.Setenv("METRICS_PASSWORD", "secret")
|
||||
|
||||
requireConfigError(t, "METRICS_USERNAME")
|
||||
}
|
||||
|
||||
// TestCORSAllowedOriginsMustBeOrigins: "*" would let every origin in,
|
||||
// and an entry that is not a plain origin would match no page.
|
||||
func TestCORSAllowedOriginsMustBeOrigins(t *testing.T) {
|
||||
for _, entry := range []string{
|
||||
"*",
|
||||
"https://*.netwatch.example",
|
||||
"netwatch.example",
|
||||
"https://netwatch.example/",
|
||||
} {
|
||||
t.Run(entry, func(t *testing.T) {
|
||||
t.Setenv("CORS_ALLOWED_ORIGINS", entry)
|
||||
|
||||
requireConfigError(t, "CORS_ALLOWED_ORIGINS")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Package globals provides build-time variables injected via
|
||||
// ldflags and made available through dependency injection.
|
||||
package globals
|
||||
|
||||
import "go.uber.org/fx"
|
||||
|
||||
//nolint:gochecknoglobals // set by cli.RunServer before fx starts
|
||||
var (
|
||||
// Appname is the application name.
|
||||
Appname string
|
||||
// Version is the git version tag.
|
||||
Version string
|
||||
)
|
||||
|
||||
// Globals holds build-time metadata for the application.
|
||||
type Globals struct {
|
||||
Appname string
|
||||
Version string
|
||||
}
|
||||
|
||||
// New creates a Globals instance from package-level variables.
|
||||
func New(_ fx.Lifecycle) (*Globals, error) {
|
||||
return &Globals{
|
||||
Appname: Appname,
|
||||
Version: Version,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package handlers
|
||||
|
||||
import "log/slog"
|
||||
|
||||
// NewForTest builds a Handlers around a report sink and logger,
|
||||
// bypassing the fx graph so handler behaviour (including the
|
||||
// storage failure path) is exercisable in unit tests.
|
||||
func NewForTest(buf reportAppender, log *slog.Logger) *Handlers {
|
||||
return &Handlers{buf: buf, log: log}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Package handlers implements HTTP request handlers for the
|
||||
// netwatch-server API.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net/http"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/globals"
|
||||
"sneak.berlin/go/netwatch/internal/healthcheck"
|
||||
"sneak.berlin/go/netwatch/internal/logger"
|
||||
"sneak.berlin/go/netwatch/internal/reportbuf"
|
||||
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
const jsonContentType = "application/json; charset=utf-8"
|
||||
|
||||
// errNotJSON is what decodeJSON returns for a request whose
|
||||
// Content-Type is not application/json.
|
||||
var errNotJSON = errors.New("content type is not application/json")
|
||||
|
||||
// reportAppender is the subset of the report buffer the handlers
|
||||
// depend on. Defining it here keeps the storage failure path
|
||||
// exercisable with a stub in tests.
|
||||
type reportAppender interface {
|
||||
Append(v any) error
|
||||
}
|
||||
|
||||
// Params defines the dependencies for Handlers.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Buffer *reportbuf.Buffer
|
||||
Globals *globals.Globals
|
||||
Healthcheck *healthcheck.Healthcheck
|
||||
Logger *logger.Logger
|
||||
}
|
||||
|
||||
// Handlers provides HTTP handler factories for all endpoints.
|
||||
type Handlers struct {
|
||||
buf reportAppender
|
||||
hc *healthcheck.Healthcheck
|
||||
log *slog.Logger
|
||||
params *Params
|
||||
}
|
||||
|
||||
// New creates a Handlers instance.
|
||||
func New(
|
||||
lc fx.Lifecycle,
|
||||
params Params,
|
||||
) (*Handlers, error) {
|
||||
s := new(Handlers)
|
||||
s.buf = params.Buffer
|
||||
s.params = ¶ms
|
||||
s.log = params.Logger.Get()
|
||||
s.hc = params.Healthcheck
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Handlers) respondJSON(
|
||||
w http.ResponseWriter,
|
||||
_ *http.Request,
|
||||
data any,
|
||||
status int,
|
||||
) {
|
||||
w.Header().Set("Content-Type", jsonContentType)
|
||||
w.WriteHeader(status)
|
||||
|
||||
if data != nil {
|
||||
err := json.NewEncoder(w).Encode(data)
|
||||
if err != nil {
|
||||
s.log.Error("json encode error", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// decodeJSON decodes the request body into v. It returns errNotJSON,
|
||||
// reading nothing, unless the request's Content-Type is
|
||||
// application/json, with or without parameters such as charset. The
|
||||
// body is expected to already be bounded by the body-size middleware,
|
||||
// so a caller can distinguish an over-limit body from malformed JSON
|
||||
// by testing the returned error for *http.MaxBytesError.
|
||||
func (s *Handlers) decodeJSON(
|
||||
_ http.ResponseWriter,
|
||||
r *http.Request,
|
||||
v any,
|
||||
) error {
|
||||
// A page on another origin can POST text/plain or a form without
|
||||
// a CORS preflight, so its visitors' browsers would send it
|
||||
// whatever the CORS settings. A JSON POST waits for the
|
||||
// preflight, so only JSON is taken.
|
||||
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil || mediaType != "application/json" {
|
||||
return errNotJSON
|
||||
}
|
||||
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package handlers
|
||||
|
||||
import "net/http"
|
||||
|
||||
// HandleHealthCheck returns a handler for the health check
|
||||
// endpoint. It answers 200 while the status is "ok" and 503 otherwise,
|
||||
// so that the image's HEALTHCHECK fails.
|
||||
func (s *Handlers) HandleHealthCheck() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := s.hc.Healthcheck()
|
||||
|
||||
code := http.StatusOK
|
||||
if resp.Status != "ok" {
|
||||
code = http.StatusServiceUnavailable
|
||||
}
|
||||
|
||||
s.respondJSON(w, r, resp, code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/config"
|
||||
"sneak.berlin/go/netwatch/internal/globals"
|
||||
"sneak.berlin/go/netwatch/internal/handlers"
|
||||
"sneak.berlin/go/netwatch/internal/healthcheck"
|
||||
"sneak.berlin/go/netwatch/internal/logger"
|
||||
"sneak.berlin/go/netwatch/internal/reportbuf"
|
||||
|
||||
"go.uber.org/fx/fxtest"
|
||||
)
|
||||
|
||||
// newStartedHandlers builds Handlers with a real health check for the
|
||||
// server named in g, and a report buffer in a temporary directory, and
|
||||
// starts them, which records the time the uptime counts from.
|
||||
func newStartedHandlers(t *testing.T, g *globals.Globals) *handlers.Handlers {
|
||||
t.Helper()
|
||||
|
||||
lc := fxtest.NewLifecycle(t)
|
||||
|
||||
log, err := logger.New(lc, logger.Params{Globals: g})
|
||||
if err != nil {
|
||||
t.Fatalf("logger: %v", err)
|
||||
}
|
||||
|
||||
buf, err := reportbuf.New(lc, reportbuf.Params{
|
||||
Config: &config.Config{DataDir: t.TempDir(), DataDirMaxBytes: 1 << 20},
|
||||
Logger: log,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("report buffer: %v", err)
|
||||
}
|
||||
|
||||
hc, err := healthcheck.New(lc,
|
||||
healthcheck.Params{Buffer: buf, Globals: g, Logger: log})
|
||||
if err != nil {
|
||||
t.Fatalf("health check: %v", err)
|
||||
}
|
||||
|
||||
h, err := handlers.New(lc, handlers.Params{
|
||||
Buffer: buf, Globals: g, Healthcheck: hc, Logger: log,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("handlers: %v", err)
|
||||
}
|
||||
|
||||
lc.RequireStart()
|
||||
t.Cleanup(lc.RequireStop)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// TestHandleHealthCheck checks the health check's answer at start, when
|
||||
// no write of a report file has failed: 200, a JSON content type, and a
|
||||
// JSON object with exactly the fields of healthcheck.HealthcheckResponse,
|
||||
// carrying "status":"ok", this server's name and version and an uptime
|
||||
// counted from its start. The answer while writes fail is checked in
|
||||
// internal/reportbuf, whose tests can make a write fail.
|
||||
func TestHandleHealthCheck(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := &globals.Globals{Appname: "netwatch-server", Version: "v1.2.3"}
|
||||
h := newStartedHandlers(t, g)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodGet, "/.well-known/healthcheck", http.NoBody)
|
||||
|
||||
h.HandleHealthCheck().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
contentType := rec.Header().Get("Content-Type")
|
||||
if contentType != "application/json; charset=utf-8" {
|
||||
t.Errorf("Content-Type = %q, want %q",
|
||||
contentType, "application/json; charset=utf-8")
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
if err != nil {
|
||||
t.Fatalf("body not a JSON object: %v (%q)", err, rec.Body.String())
|
||||
}
|
||||
|
||||
fields := []string{
|
||||
"appname", "now", "status", "uptime_human", "uptime_seconds", "version",
|
||||
}
|
||||
if got := slices.Sorted(maps.Keys(body)); !slices.Equal(got, fields) {
|
||||
t.Fatalf("fields = %v, want %v", got, fields)
|
||||
}
|
||||
|
||||
for field, want := range map[string]string{
|
||||
"appname": g.Appname, "status": "ok", "version": g.Version,
|
||||
} {
|
||||
if body[field] != want {
|
||||
t.Errorf("%s = %v, want %q", field, body[field], want)
|
||||
}
|
||||
}
|
||||
|
||||
now, _ := body["now"].(string)
|
||||
|
||||
at, err := time.Parse(time.RFC3339Nano, now)
|
||||
if err != nil || time.Since(at).Abs() > time.Minute {
|
||||
t.Errorf("now = %q, want the current time in RFC 3339 (%v)", now, err)
|
||||
}
|
||||
|
||||
// Started just now, so the uptime is well under a minute.
|
||||
human, _ := body["uptime_human"].(string)
|
||||
|
||||
uptime, err := time.ParseDuration(human)
|
||||
if err != nil || uptime > time.Minute {
|
||||
t.Errorf("uptime_human = %q, want a duration under a minute (%v)",
|
||||
human, err)
|
||||
}
|
||||
|
||||
seconds, ok := body["uptime_seconds"].(float64)
|
||||
if !ok || seconds < 0 || seconds > time.Minute.Seconds() {
|
||||
t.Errorf("uptime_seconds = %v, want a number of seconds under a minute",
|
||||
body["uptime_seconds"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/logger"
|
||||
"sneak.berlin/go/netwatch/internal/reportbuf"
|
||||
)
|
||||
|
||||
type reportSample struct {
|
||||
T int64 `json:"t"`
|
||||
Latency *int `json:"latency"`
|
||||
Error *string `json:"error"`
|
||||
}
|
||||
|
||||
type reportHost struct {
|
||||
History []reportSample `json:"history"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type report struct {
|
||||
ClientID string `json:"clientId"`
|
||||
Geo json.RawMessage `json:"geo"`
|
||||
Hosts []reportHost `json:"hosts"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// HandleReport returns a handler that accepts telemetry
|
||||
// reports from NetWatch clients, sent as application/json.
|
||||
func (s *Handlers) HandleReport() http.HandlerFunc {
|
||||
type response struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var rpt report
|
||||
|
||||
err := s.decodeJSON(w, r, &rpt)
|
||||
if err != nil {
|
||||
s.respondJSON(w, r,
|
||||
&response{Status: "error"},
|
||||
s.decodeErrorStatus(err),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
s.logReportReceived(rpt)
|
||||
|
||||
err = s.buf.Append(rpt)
|
||||
if err != nil {
|
||||
s.respondJSON(w, r,
|
||||
&response{Status: "error"},
|
||||
s.appendErrorStatus(err),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
s.respondJSON(w, r, &response{Status: "ok"}, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// decodeErrorStatus logs a report decode failure and returns the
|
||||
// status to send: 415 when the report was not sent as JSON, 413 when
|
||||
// the body exceeded the size limit, otherwise 400 for malformed JSON.
|
||||
func (s *Handlers) decodeErrorStatus(err error) int {
|
||||
if errors.Is(err, errNotJSON) {
|
||||
s.log.Warn("report refused: Content-Type is not application/json")
|
||||
|
||||
return http.StatusUnsupportedMediaType
|
||||
}
|
||||
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
s.log.Warn("report body too large", "limit_bytes", tooLarge.Limit)
|
||||
|
||||
return http.StatusRequestEntityTooLarge
|
||||
}
|
||||
|
||||
// The decoder's error text can quote request bytes (a whole
|
||||
// oversized number, for example), so it is bounded too.
|
||||
s.log.Error("failed to decode report",
|
||||
"error", logger.BoundedForLog(err.Error()),
|
||||
)
|
||||
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
|
||||
// appendErrorStatus logs a failure to store a report and returns
|
||||
// the status to send: 507 when the reports waiting to be written fill
|
||||
// the size cap, otherwise 500, as when report files cannot be written.
|
||||
func (s *Handlers) appendErrorStatus(err error) int {
|
||||
if errors.Is(err, reportbuf.ErrFull) {
|
||||
s.log.Warn("report refused: " +
|
||||
"reports waiting to be written fill the size cap")
|
||||
|
||||
return http.StatusInsufficientStorage
|
||||
}
|
||||
|
||||
s.log.Error("failed to buffer report", "error", err)
|
||||
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// logReportReceived logs an accepted report. Untrusted fields are
|
||||
// bounded (client_id, timestamp) or reduced to a length
|
||||
// (geo_bytes) so the raw attacker-controlled body never reaches
|
||||
// the log.
|
||||
func (s *Handlers) logReportReceived(rpt report) {
|
||||
totalSamples := 0
|
||||
for _, h := range rpt.Hosts {
|
||||
totalSamples += len(h.History)
|
||||
}
|
||||
|
||||
s.log.Info("report received",
|
||||
"client_id", logger.BoundedForLog(rpt.ClientID),
|
||||
"timestamp", logger.BoundedForLog(rpt.Timestamp),
|
||||
"host_count", len(rpt.Hosts),
|
||||
"total_samples", totalSamples,
|
||||
"geo_bytes", len(rpt.Geo),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/handlers"
|
||||
"sneak.berlin/go/netwatch/internal/logger"
|
||||
"sneak.berlin/go/netwatch/internal/middleware"
|
||||
"sneak.berlin/go/netwatch/internal/reportbuf"
|
||||
)
|
||||
|
||||
var errStorageFailed = errors.New("storage failed")
|
||||
|
||||
// stubAppender drives the storage success/failure path without a
|
||||
// real buffer or disk.
|
||||
type stubAppender struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (s stubAppender) Append(any) error { return s.err }
|
||||
|
||||
// recordingAppender keeps every report the handler stores.
|
||||
type recordingAppender struct {
|
||||
reports []any
|
||||
}
|
||||
|
||||
func (a *recordingAppender) Append(v any) error {
|
||||
a.reports = append(a.reports, v)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTestHandlers(buf stubAppender, out io.Writer) *handlers.Handlers {
|
||||
return handlers.NewForTest(buf, slog.New(slog.NewJSONHandler(out, nil)))
|
||||
}
|
||||
|
||||
func decodeStatus(t *testing.T, body []byte) string {
|
||||
t.Helper()
|
||||
|
||||
var resp struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal(body, &resp)
|
||||
if err != nil {
|
||||
t.Fatalf("response body not JSON: %v (%q)", err, body)
|
||||
}
|
||||
|
||||
return resp.Status
|
||||
}
|
||||
|
||||
// TestHandleReportAcceptsValidReports checks the answer to a valid
|
||||
// report, one with no hosts and one shaped as the frontend sends them:
|
||||
// 200 and {"status":"ok"} as JSON.
|
||||
func TestHandleReportAcceptsValidReports(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "no hosts",
|
||||
body: `{"clientId":"c1","geo":null,"hosts":[],` +
|
||||
`"timestamp":"2026-10-03T12:00:00.000Z"}`,
|
||||
},
|
||||
{
|
||||
name: "a host with a latency and an error sample",
|
||||
body: `{"clientId":"c1","geo":null,"hosts":[{` +
|
||||
`"name":"Example","url":"https://example.com/",` +
|
||||
`"status":"error","history":[` +
|
||||
`{"t":1790000000000,"latency":42,"error":null},` +
|
||||
`{"t":1790000003000,"latency":null,"error":"timeout"}]}],` +
|
||||
`"timestamp":"2026-10-03T12:00:00.000Z"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := newTestHandlers(stubAppender{}, io.Discard)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodPost, "/api/v1/reports",
|
||||
strings.NewReader(tt.body),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.HandleReport().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
contentType := rec.Header().Get("Content-Type")
|
||||
if contentType != "application/json; charset=utf-8" {
|
||||
t.Errorf("Content-Type = %q, want %q",
|
||||
contentType, "application/json; charset=utf-8")
|
||||
}
|
||||
|
||||
if got := rec.Body.String(); got != "{\"status\":\"ok\"}\n" {
|
||||
t.Errorf("body = %q, want %q", got, "{\"status\":\"ok\"}\n")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleReportStoresOnlyJSON checks that a report is stored only
|
||||
// when sent as application/json, as the page sends it. One sent as
|
||||
// text/plain or as a form, which a page on another origin can send
|
||||
// without a CORS preflight, is refused with 415.
|
||||
func TestHandleReportStoresOnlyJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
contentType string
|
||||
wantStatus int
|
||||
wantStored int
|
||||
}{
|
||||
{
|
||||
name: "text/plain",
|
||||
contentType: "text/plain;charset=UTF-8",
|
||||
wantStatus: http.StatusUnsupportedMediaType,
|
||||
wantStored: 0,
|
||||
},
|
||||
{
|
||||
name: "a urlencoded form",
|
||||
contentType: "application/x-www-form-urlencoded",
|
||||
wantStatus: http.StatusUnsupportedMediaType,
|
||||
wantStored: 0,
|
||||
},
|
||||
{
|
||||
name: "a multipart form",
|
||||
contentType: "multipart/form-data; boundary=x",
|
||||
wantStatus: http.StatusUnsupportedMediaType,
|
||||
wantStored: 0,
|
||||
},
|
||||
{
|
||||
name: "no Content-Type",
|
||||
contentType: "",
|
||||
wantStatus: http.StatusUnsupportedMediaType,
|
||||
wantStored: 0,
|
||||
},
|
||||
{
|
||||
name: "application/json",
|
||||
contentType: "application/json",
|
||||
wantStatus: http.StatusOK,
|
||||
wantStored: 1,
|
||||
},
|
||||
{
|
||||
name: "application/json with a charset",
|
||||
contentType: "application/json;charset=UTF-8",
|
||||
wantStatus: http.StatusOK,
|
||||
wantStored: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var stored recordingAppender
|
||||
|
||||
h := handlers.NewForTest(&stored, slog.New(slog.DiscardHandler))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodPost, "/api/v1/reports",
|
||||
strings.NewReader(`{"clientId":"c1","hosts":[]}`),
|
||||
)
|
||||
|
||||
if tt.contentType != "" {
|
||||
req.Header.Set("Content-Type", tt.contentType)
|
||||
}
|
||||
|
||||
h.HandleReport().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
|
||||
}
|
||||
|
||||
if len(stored.reports) != tt.wantStored {
|
||||
t.Errorf("reports stored = %d, want %d",
|
||||
len(stored.reports), tt.wantStored)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReportStorageFailureIsNon2xx(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := newTestHandlers(stubAppender{err: errStorageFailed}, io.Discard)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodPost, "/api/v1/reports",
|
||||
strings.NewReader(`{"clientId":"c1","hosts":[]}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.HandleReport().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code < 500 {
|
||||
t.Fatalf("storage failure status = %d, want a 5xx", rec.Code)
|
||||
}
|
||||
|
||||
if got := decodeStatus(t, rec.Body.Bytes()); got != "error" {
|
||||
t.Fatalf("status field = %q, want %q", got, "error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleReportFullIs507 checks the answer when the reports waiting
|
||||
// to be written fill the size cap: 507 and the usual error body, which
|
||||
// tells the client nothing more.
|
||||
func TestHandleReportFullIs507(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := newTestHandlers(stubAppender{err: reportbuf.ErrFull}, io.Discard)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodPost, "/api/v1/reports",
|
||||
strings.NewReader(`{"clientId":"c1","hosts":[]}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.HandleReport().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusInsufficientStorage {
|
||||
t.Fatalf("status = %d, want %d",
|
||||
rec.Code, http.StatusInsufficientStorage)
|
||||
}
|
||||
|
||||
if got := rec.Body.String(); got != "{\"status\":\"error\"}\n" {
|
||||
t.Errorf("body = %q, want %q", got, "{\"status\":\"error\"}\n")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReportMalformedJSONIs400(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := newTestHandlers(stubAppender{}, io.Discard)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodPost, "/api/v1/reports",
|
||||
strings.NewReader(`{not json`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.HandleReport().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("malformed status = %d, want %d",
|
||||
rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReportOversizeIs413(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const limit = 32
|
||||
|
||||
h := newTestHandlers(stubAppender{}, io.Discard)
|
||||
handler := (&middleware.Middleware{}).MaxBodyBytes(limit)(
|
||||
h.HandleReport(),
|
||||
)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodPost, "/api/v1/reports",
|
||||
strings.NewReader(`{"clientId":"`+strings.Repeat("x", 200)+`"}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// No declared length, so only the middleware's read cap can
|
||||
// stop this body.
|
||||
req.ContentLength = -1
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("oversize status = %d, want %d",
|
||||
rec.Code, http.StatusRequestEntityTooLarge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReportDoesNotLogRawGeo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const sentinel = "SENSITIVE-GEO-BLOB"
|
||||
|
||||
var logbuf bytes.Buffer
|
||||
|
||||
h := newTestHandlers(stubAppender{}, &logbuf)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodPost, "/api/v1/reports",
|
||||
strings.NewReader(
|
||||
`{"clientId":"c1","geo":{"raw":"`+sentinel+`"},"hosts":[]}`,
|
||||
),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.HandleReport().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
if strings.Contains(logbuf.String(), sentinel) {
|
||||
t.Fatal("raw geo bytes were written to the log")
|
||||
}
|
||||
|
||||
if !strings.Contains(logbuf.String(), "geo_bytes") {
|
||||
t.Fatal("expected a bounded geo_bytes field in the log")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReportLogsClientIDCutToBound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
long := strings.Repeat("c", 2*logger.MaxLoggedFieldBytes)
|
||||
|
||||
var logbuf bytes.Buffer
|
||||
|
||||
h := newTestHandlers(stubAppender{}, &logbuf)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodPost, "/api/v1/reports",
|
||||
strings.NewReader(
|
||||
`{"clientId":"`+long+`","timestamp":"`+long+`","hosts":[]}`,
|
||||
),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.HandleReport().ServeHTTP(rec, req)
|
||||
|
||||
var logged map[string]any
|
||||
|
||||
err := json.Unmarshal(logbuf.Bytes(), &logged)
|
||||
if err != nil {
|
||||
t.Fatalf("log line not JSON: %v (%q)", err, logbuf.String())
|
||||
}
|
||||
|
||||
want := long[:logger.MaxLoggedFieldBytes]
|
||||
|
||||
if logged["client_id"] != want {
|
||||
t.Fatalf("logged client_id not cut to %d bytes: %q",
|
||||
logger.MaxLoggedFieldBytes, logged["client_id"])
|
||||
}
|
||||
|
||||
if logged["timestamp"] != want {
|
||||
t.Fatalf("logged timestamp not cut to %d bytes: %q",
|
||||
logger.MaxLoggedFieldBytes, logged["timestamp"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReportDecodeErrorLogIsBounded(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A number too large for its int64 field makes the decoder's
|
||||
// error text quote the whole number.
|
||||
huge := strings.Repeat("9", 2*logger.MaxLoggedFieldBytes)
|
||||
|
||||
var logbuf bytes.Buffer
|
||||
|
||||
h := newTestHandlers(stubAppender{}, &logbuf)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodPost, "/api/v1/reports",
|
||||
strings.NewReader(`{"hosts":[{"history":[{"t":`+huge+`}]}]}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.HandleReport().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if strings.Contains(logbuf.String(), huge) {
|
||||
t.Fatal("the whole oversized number was written to the log")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Package healthcheck provides a service that reports
|
||||
// application health, uptime, and version information.
|
||||
package healthcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/config"
|
||||
"sneak.berlin/go/netwatch/internal/globals"
|
||||
"sneak.berlin/go/netwatch/internal/logger"
|
||||
"sneak.berlin/go/netwatch/internal/reportbuf"
|
||||
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
// Params defines the dependencies for Healthcheck.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Buffer *reportbuf.Buffer
|
||||
Config *config.Config
|
||||
Globals *globals.Globals
|
||||
Logger *logger.Logger
|
||||
}
|
||||
|
||||
// Healthcheck tracks startup time and builds health responses.
|
||||
type Healthcheck struct {
|
||||
StartupTime time.Time
|
||||
log *slog.Logger
|
||||
params *Params
|
||||
}
|
||||
|
||||
// HealthcheckResponse is the JSON payload returned by the health
|
||||
// check endpoint. Its name and its snake_case keys are the ones
|
||||
// GO_HTTP_SERVER_CONVENTIONS.md gives.
|
||||
//
|
||||
//nolint:revive,tagliatelle // name and keys from the conventions
|
||||
type HealthcheckResponse struct {
|
||||
Appname string `json:"appname"`
|
||||
Now string `json:"now"`
|
||||
Status string `json:"status"`
|
||||
UptimeHuman string `json:"uptime_human"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// New creates a Healthcheck, recording startup time via an
|
||||
// fx lifecycle hook.
|
||||
func New(
|
||||
lc fx.Lifecycle,
|
||||
params Params,
|
||||
) (*Healthcheck, error) {
|
||||
s := new(Healthcheck)
|
||||
s.params = ¶ms
|
||||
s.log = params.Logger.Get()
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
s.StartupTime = time.Now().UTC()
|
||||
|
||||
return nil
|
||||
},
|
||||
OnStop: func(_ context.Context) error {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Healthcheck returns the current health status of the application:
|
||||
// "ok", or "error" while report files cannot be written.
|
||||
func (s *Healthcheck) Healthcheck() *HealthcheckResponse {
|
||||
status := "ok"
|
||||
if s.params.Buffer.LastWriteFailed() {
|
||||
status = "error"
|
||||
}
|
||||
|
||||
return &HealthcheckResponse{
|
||||
Appname: s.params.Globals.Appname,
|
||||
Now: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Status: status,
|
||||
UptimeHuman: s.uptime().String(),
|
||||
UptimeSeconds: int64(s.uptime().Seconds()),
|
||||
Version: s.params.Globals.Version,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Healthcheck) uptime() time.Duration {
|
||||
return time.Since(s.StartupTime)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Package logger provides a configured slog.Logger with TTY
|
||||
// detection for development vs production output.
|
||||
package logger
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/globals"
|
||||
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
// MaxLoggedFieldBytes bounds untrusted text (request fields,
|
||||
// header values, decode error text) before it is logged, so a
|
||||
// caller cannot inflate log volume with an oversized value.
|
||||
const MaxLoggedFieldBytes = 128
|
||||
|
||||
// BoundedForLog truncates an untrusted string to a fixed byte
|
||||
// bound so an attacker-controlled field cannot dominate the log.
|
||||
func BoundedForLog(s string) string {
|
||||
if len(s) > MaxLoggedFieldBytes {
|
||||
return s[:MaxLoggedFieldBytes]
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Params defines the dependencies for Logger.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Globals *globals.Globals
|
||||
}
|
||||
|
||||
// Logger wraps slog.Logger with dynamic level control.
|
||||
type Logger struct {
|
||||
log *slog.Logger
|
||||
level *slog.LevelVar
|
||||
params Params
|
||||
}
|
||||
|
||||
// New creates a Logger with TTY-aware output formatting.
|
||||
func New(_ fx.Lifecycle, params Params) (*Logger, error) {
|
||||
l := new(Logger)
|
||||
l.level = new(slog.LevelVar)
|
||||
l.level.Set(slog.LevelInfo)
|
||||
l.params = params
|
||||
|
||||
tty := false
|
||||
|
||||
if fileInfo, _ := os.Stdout.Stat(); fileInfo != nil {
|
||||
if (fileInfo.Mode() & os.ModeCharDevice) != 0 {
|
||||
tty = true
|
||||
}
|
||||
}
|
||||
|
||||
var handler slog.Handler
|
||||
if tty {
|
||||
handler = slog.NewTextHandler(
|
||||
os.Stdout,
|
||||
&slog.HandlerOptions{
|
||||
Level: l.level,
|
||||
AddSource: true,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
handler = slog.NewJSONHandler(
|
||||
os.Stdout,
|
||||
&slog.HandlerOptions{
|
||||
Level: l.level,
|
||||
AddSource: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
l.log = slog.New(handler)
|
||||
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// EnableDebugLogging sets the log level to debug.
|
||||
func (l *Logger) EnableDebugLogging() {
|
||||
l.level.Set(slog.LevelDebug)
|
||||
l.log.Debug("debug logging enabled", "debug", true)
|
||||
}
|
||||
|
||||
// Get returns the underlying slog.Logger.
|
||||
func (l *Logger) Get() *slog.Logger {
|
||||
return l.log
|
||||
}
|
||||
|
||||
// Identify logs the application's build-time metadata.
|
||||
func (l *Logger) Identify() {
|
||||
l.log.Info("starting",
|
||||
"appname", l.params.Globals.Appname,
|
||||
"version", l.params.Globals.Version,
|
||||
"arch", runtime.GOARCH,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
// Test-only wrappers exposing unexported helpers to the
|
||||
// external middleware_test package.
|
||||
|
||||
// NewWithLogger builds a Middleware around a logger for tests
|
||||
// that exercise the logging paths without the fx graph.
|
||||
func NewWithLogger(log *slog.Logger) *Middleware {
|
||||
return &Middleware{log: log}
|
||||
}
|
||||
|
||||
// NewWithTrustedProxies builds a Middleware that honours forwarded
|
||||
// headers from the given networks, for tests of the client address
|
||||
// paths without the fx graph.
|
||||
func NewWithTrustedProxies(trusted []netip.Prefix) *Middleware {
|
||||
return &Middleware{trustedProxies: trusted}
|
||||
}
|
||||
|
||||
func ClientIP(
|
||||
remoteAddr string,
|
||||
header http.Header,
|
||||
trusted []netip.Prefix,
|
||||
) string {
|
||||
return clientIP(remoteAddr, header, trusted)
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
// Package middleware provides HTTP middleware for logging,
|
||||
// CORS, and other cross-cutting concerns.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/config"
|
||||
"sneak.berlin/go/netwatch/internal/globals"
|
||||
"sneak.berlin/go/netwatch/internal/logger"
|
||||
|
||||
basicauth "github.com/99designs/basicauth-go"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/go-chi/httprate"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||
"github.com/slok/go-http-metrics/middleware/std"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
const corsMaxAgeSec = 300
|
||||
|
||||
// jsonErrorBody is the body written for errors raised inside
|
||||
// middleware, matching the {"status":"error"} shape the handlers
|
||||
// return so clients see one error contract across the API.
|
||||
const (
|
||||
jsonContentType = "application/json; charset=utf-8"
|
||||
jsonErrorBody = "{\"status\":\"error\"}\n"
|
||||
)
|
||||
|
||||
// Security header values. The backend is a JSON API with no
|
||||
// HTML surface, so the CSP forbids every resource type and
|
||||
// framing outright.
|
||||
const (
|
||||
hstsValue = "max-age=31536000; includeSubDomains"
|
||||
cspValue = "default-src 'none'; frame-ancestors 'none'"
|
||||
permissionsPolicyValue = "camera=(), microphone=(), geolocation=()"
|
||||
)
|
||||
|
||||
// Params defines the dependencies for Middleware.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Config *config.Config
|
||||
Globals *globals.Globals
|
||||
Logger *logger.Logger
|
||||
}
|
||||
|
||||
// Middleware holds shared state for middleware factories.
|
||||
type Middleware struct {
|
||||
log *slog.Logger
|
||||
params *Params
|
||||
trustedProxies []netip.Prefix
|
||||
}
|
||||
|
||||
// New creates a Middleware instance.
|
||||
func New(
|
||||
_ fx.Lifecycle,
|
||||
params Params,
|
||||
) (*Middleware, error) {
|
||||
trusted, err := ParseTrustedProxies(params.Config.TrustedProxies)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := new(Middleware)
|
||||
s.params = ¶ms
|
||||
s.log = params.Logger.Get()
|
||||
s.trustedProxies = trusted
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ParseTrustedProxies converts the TRUSTED_PROXIES entries into
|
||||
// prefixes, failing fast on any malformed entry. Each entry must be
|
||||
// a CIDR; a lone address is refused. "netwatch-server check-cidr"
|
||||
// runs it too.
|
||||
func ParseTrustedProxies(cidrs []string) ([]netip.Prefix, error) {
|
||||
prefixes := make([]netip.Prefix, 0, len(cidrs))
|
||||
|
||||
for _, cidr := range cidrs {
|
||||
prefix, err := netip.ParsePrefix(cidr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"TRUSTED_PROXIES %q: %w", cidr, err,
|
||||
)
|
||||
}
|
||||
|
||||
prefixes = append(prefixes, prefix.Masked())
|
||||
}
|
||||
|
||||
return prefixes, nil
|
||||
}
|
||||
|
||||
type loggingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func newLoggingResponseWriter(
|
||||
w http.ResponseWriter,
|
||||
) *loggingResponseWriter {
|
||||
return &loggingResponseWriter{w, http.StatusOK}
|
||||
}
|
||||
|
||||
func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
||||
lrw.statusCode = code
|
||||
lrw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func ipFromHostPort(hostPort string) string {
|
||||
host, _, err := net.SplitHostPort(hostPort)
|
||||
if err != nil {
|
||||
return hostPort
|
||||
}
|
||||
|
||||
return host
|
||||
}
|
||||
|
||||
// clientIP resolves the caller's address. X-Forwarded-For and
|
||||
// X-Real-IP are honoured only when the direct peer is a
|
||||
// trusted proxy; otherwise the direct peer is returned so a
|
||||
// spoofed header cannot forge the logged address.
|
||||
//
|
||||
// Each proxy appends to X-Forwarded-For the address it took the
|
||||
// request from, so the entries on the right are written by the
|
||||
// trusted proxies and those further left by the client. The
|
||||
// client's address is therefore the right-most entry that is not
|
||||
// a trusted proxy. An entry written as an address with a port is
|
||||
// read as that address, both for the trust check and as the
|
||||
// result. When every entry is a trusted proxy, or the walk from
|
||||
// the right meets an entry that is not an address at all (such as
|
||||
// "unknown"), the peer is used. X-Real-IP is read only when there
|
||||
// is no X-Forwarded-For entry at all.
|
||||
func clientIP(
|
||||
remoteAddr string,
|
||||
header http.Header,
|
||||
trusted []netip.Prefix,
|
||||
) string {
|
||||
peer := ipFromHostPort(remoteAddr)
|
||||
|
||||
if !addrInAny(peer, trusted) {
|
||||
return peer
|
||||
}
|
||||
|
||||
forwarded := forwardedFor(header)
|
||||
|
||||
if len(forwarded) == 0 {
|
||||
if xr := strings.TrimSpace(header.Get("X-Real-IP")); validIP(xr) {
|
||||
return xr
|
||||
}
|
||||
|
||||
return peer
|
||||
}
|
||||
|
||||
for _, entry := range slices.Backward(forwarded) {
|
||||
if !validIP(entry) {
|
||||
return peer
|
||||
}
|
||||
|
||||
if !addrInAny(entry, trusted) {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
|
||||
return peer
|
||||
}
|
||||
|
||||
// forwardedFor returns the entries of X-Forwarded-For in order,
|
||||
// leaving out blank ones. A proxy may add its entry as a header
|
||||
// line of its own rather than append it to the line the client
|
||||
// sent, so every line is read, not only the first. Some proxies
|
||||
// write an entry as an address with a port, such as
|
||||
// 198.51.100.7:5000 or [2001:db8::1]:443; the port is removed so
|
||||
// that the entry is read as its address.
|
||||
func forwardedFor(header http.Header) []string {
|
||||
value := strings.Join(header.Values("X-Forwarded-For"), ",")
|
||||
|
||||
var entries []string
|
||||
|
||||
for part := range strings.SplitSeq(value, ",") {
|
||||
entry := strings.TrimSpace(part)
|
||||
if entry != "" {
|
||||
entries = append(entries, ipFromHostPort(entry))
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
func validIP(s string) bool {
|
||||
_, err := netip.ParseAddr(s)
|
||||
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// addrInAny reports whether s parses as an address contained
|
||||
// in any of the trusted prefixes.
|
||||
func addrInAny(s string, trusted []netip.Prefix) bool {
|
||||
addr, err := netip.ParseAddr(s)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
addr = addr.Unmap()
|
||||
|
||||
for _, prefix := range trusted {
|
||||
if prefix.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Logging returns middleware that logs each request with
|
||||
// timing, status code, and client information. Every string
|
||||
// taken from the request is cut to logger.MaxLoggedFieldBytes,
|
||||
// including the request ID, which chi takes from the client's
|
||||
// X-Request-Id header when one is sent.
|
||||
func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now().UTC()
|
||||
lrw := newLoggingResponseWriter(w)
|
||||
ctx := r.Context()
|
||||
|
||||
defer func() {
|
||||
latency := time.Since(start)
|
||||
s.log.InfoContext(ctx, "request",
|
||||
"request_start", start,
|
||||
"method", logger.BoundedForLog(r.Method),
|
||||
"url", logger.BoundedForLog(r.URL.String()),
|
||||
"useragent", logger.BoundedForLog(r.UserAgent()),
|
||||
"request_id",
|
||||
logger.BoundedForLog(middleware.GetReqID(ctx)),
|
||||
"referer", logger.BoundedForLog(r.Referer()),
|
||||
"proto", logger.BoundedForLog(r.Proto),
|
||||
"remote_ip",
|
||||
logger.BoundedForLog(clientIP(
|
||||
r.RemoteAddr,
|
||||
r.Header,
|
||||
s.trustedProxies,
|
||||
)),
|
||||
"status", lrw.statusCode,
|
||||
"latency_ms",
|
||||
latency.Milliseconds(),
|
||||
)
|
||||
}()
|
||||
|
||||
next.ServeHTTP(lrw, r)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// SecurityHeaders returns middleware that sets response
|
||||
// security headers. It runs before CORS so the headers are
|
||||
// present on preflight responses the CORS handler writes.
|
||||
func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("Strict-Transport-Security", hstsValue)
|
||||
h.Set("Content-Security-Policy", cspValue)
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
h.Set("Permissions-Policy", permissionsPolicyValue)
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// writeJSONError writes the shared JSON error body with the
|
||||
// given status. Used where middleware must reject a request
|
||||
// before it reaches a handler.
|
||||
func writeJSONError(w http.ResponseWriter, status int) {
|
||||
w.Header().Set("Content-Type", jsonContentType)
|
||||
w.WriteHeader(status)
|
||||
_, _ = io.WriteString(w, jsonErrorBody)
|
||||
}
|
||||
|
||||
// MaxBodyBytes returns middleware that caps the request body at
|
||||
// limit bytes. A declared Content-Length over the limit is
|
||||
// rejected immediately with 413. Bodies without a declared
|
||||
// length (or that understate it) are capped as they are read, so
|
||||
// a handler that reads the body sees a *http.MaxBytesError it can
|
||||
// map to 413. Mounted again on a route group, it can only lower
|
||||
// the limit: a cap applied earlier in the chain still holds.
|
||||
func (s *Middleware) MaxBodyBytes(
|
||||
limit int64,
|
||||
) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.ContentLength > limit {
|
||||
writeJSONError(
|
||||
w,
|
||||
http.StatusRequestEntityTooLarge,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Recoverer returns middleware that recovers from a panic in a
|
||||
// downstream handler, logs the panic and stack trace through
|
||||
// slog, and responds 500 with no body, unless a status was already
|
||||
// written, as Write500OnPanic writes one for a handler's panic.
|
||||
// http.ErrAbortHandler is re-panicked so the server can abort the
|
||||
// response as intended.
|
||||
func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
|
||||
defer func() {
|
||||
rec := recover()
|
||||
if rec == nil {
|
||||
return
|
||||
}
|
||||
|
||||
err, ok := rec.(error)
|
||||
if ok && errors.Is(err, http.ErrAbortHandler) {
|
||||
panic(rec)
|
||||
}
|
||||
|
||||
s.log.ErrorContext(r.Context(),
|
||||
"panic recovered",
|
||||
"panic", fmt.Sprintf("%v", rec),
|
||||
"stack", string(debug.Stack()),
|
||||
)
|
||||
|
||||
if ww.Status() == 0 {
|
||||
ww.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
|
||||
next.ServeHTTP(ww, r)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Write500OnPanic returns middleware that writes 500 when a downstream
|
||||
// handler panics before writing a status, and lets the panic go on to
|
||||
// Sentry and Recoverer. Recoverer, which comes first, would write its
|
||||
// 500 only after the request log and the metrics had recorded the
|
||||
// status they saw; placed after each of them, this makes them record
|
||||
// the 500.
|
||||
func (s *Middleware) Write500OnPanic() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
|
||||
defer func() {
|
||||
rec := recover()
|
||||
if rec == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if ww.Status() == 0 {
|
||||
ww.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
panic(rec)
|
||||
}()
|
||||
|
||||
next.ServeHTTP(ww, r)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// CORS returns middleware that lets pages served from the given
|
||||
// origins call the API. With no origins it adds no CORS headers at
|
||||
// all, so only same-origin pages can use the API. That case must not
|
||||
// reach cors.Handler, which treats an empty origin list as "allow
|
||||
// every origin".
|
||||
func (s *Middleware) CORS(
|
||||
origins []string,
|
||||
) func(http.Handler) http.Handler {
|
||||
if len(origins) == 0 {
|
||||
return func(next http.Handler) http.Handler { return next }
|
||||
}
|
||||
|
||||
return cors.Handler(cors.Options{
|
||||
AllowedOrigins: origins,
|
||||
AllowedMethods: []string{http.MethodGet, http.MethodPost},
|
||||
AllowedHeaders: []string{"Content-Type"},
|
||||
AllowCredentials: false,
|
||||
MaxAge: corsMaxAgeSec,
|
||||
})
|
||||
}
|
||||
|
||||
// RateLimit returns middleware that allows each client address
|
||||
// perMinute requests a minute and answers the rest with 429, the
|
||||
// Retry-After header httprate sets, and the usual error body. The
|
||||
// address is the one clientIP resolves, so clients behind the reverse
|
||||
// proxy are limited one by one, not together as the proxy.
|
||||
func (s *Middleware) RateLimit(
|
||||
perMinute int,
|
||||
) func(http.Handler) http.Handler {
|
||||
return httprate.LimitBy(perMinute, time.Minute,
|
||||
func(r *http.Request) (string, error) {
|
||||
return clientIP(r.RemoteAddr, r.Header, s.trustedProxies), nil
|
||||
},
|
||||
httprate.WithLimitHandler(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSONError(w, http.StatusTooManyRequests)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Metrics returns middleware that records each request's duration and
|
||||
// response size, and the requests in progress, in registry. They are
|
||||
// labelled by the request path.
|
||||
func (s *Middleware) Metrics(
|
||||
registry prometheus.Registerer,
|
||||
) func(http.Handler) http.Handler {
|
||||
mdlw := ghmm.New(ghmm.Config{
|
||||
Recorder: metrics.NewRecorder(metrics.Config{Registry: registry}),
|
||||
})
|
||||
|
||||
return std.HandlerProvider("", mdlw)
|
||||
}
|
||||
|
||||
// MetricsAuth returns middleware that lets a request through only with
|
||||
// METRICS_USERNAME and METRICS_PASSWORD as its basic auth credentials,
|
||||
// and answers any other with 401.
|
||||
func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
||||
return basicauth.New("metrics", map[string][]string{
|
||||
s.params.Config.MetricsUsername: {s.params.Config.MetricsPassword},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,812 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/logger"
|
||||
"sneak.berlin/go/netwatch/internal/middleware"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
const (
|
||||
// loopbackPeer is a remote address inside the trusted-proxy allowlist.
|
||||
loopbackPeer = "127.0.0.1:5000"
|
||||
// loopbackIP is the address of loopbackPeer, which ClientIP returns
|
||||
// when it uses the peer.
|
||||
loopbackIP = "127.0.0.1"
|
||||
// forwardedIP is the client address presented via X-Forwarded-For.
|
||||
forwardedIP = "203.0.113.7"
|
||||
// realIP is the client address presented via X-Real-IP.
|
||||
realIP = "203.0.113.9"
|
||||
// appendedIP is the X-Forwarded-For entry a trusted proxy appended
|
||||
// after the entries the client wrote.
|
||||
appendedIP = "198.51.100.7"
|
||||
)
|
||||
|
||||
func mustPrefixes(t *testing.T, cidrs ...string) []netip.Prefix {
|
||||
t.Helper()
|
||||
|
||||
prefixes, err := middleware.ParseTrustedProxies(cidrs)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTrustedProxies(%v): %v", cidrs, err)
|
||||
}
|
||||
|
||||
return prefixes
|
||||
}
|
||||
|
||||
// TestParseTrustedProxiesRejectsMalformed includes entries nginx would
|
||||
// read as another address or look up as a hostname, in the CIDR form
|
||||
// bin/entrypoint.sh gives "netwatch-server check-cidr".
|
||||
func TestParseTrustedProxiesRejectsMalformed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, cidr := range []string{
|
||||
"not-a-cidr", "10.0.0.1", "1.2.3/32", "172.30/32", "10/32",
|
||||
"cafe/32", "999.1.1.1/32", "10.0.0.0/33", "::1/129",
|
||||
"fe80::1%eth0/128",
|
||||
} {
|
||||
_, err := middleware.ParseTrustedProxies([]string{cidr})
|
||||
if err == nil || !strings.Contains(err.Error(), "TRUSTED_PROXIES") {
|
||||
t.Errorf("%q: error = %v, want one naming TRUSTED_PROXIES",
|
||||
cidr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrustedProxiesAcceptsCIDRs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mustPrefixes(t, "172.17.0.1/32", "10.0.0.0/8", "2001:db8::1/128",
|
||||
"2001:db8::/32", "::ffff:192.0.2.1/128")
|
||||
}
|
||||
|
||||
type clientIPCase struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
xff string
|
||||
xRealIP string
|
||||
want string
|
||||
}
|
||||
|
||||
func clientIPCases() []clientIPCase {
|
||||
return []clientIPCase{
|
||||
{
|
||||
name: "trusted proxy uses forwarded-for",
|
||||
remoteAddr: loopbackPeer,
|
||||
xff: forwardedIP,
|
||||
want: forwardedIP,
|
||||
},
|
||||
{
|
||||
name: "trusted proxy uses right-most of chain",
|
||||
remoteAddr: loopbackPeer,
|
||||
xff: "203.0.113.9, " + appendedIP,
|
||||
want: appendedIP,
|
||||
},
|
||||
{
|
||||
name: "trusted proxy entries are skipped from the right",
|
||||
remoteAddr: loopbackPeer,
|
||||
xff: "203.0.113.9, 10.0.0.5",
|
||||
want: "203.0.113.9",
|
||||
},
|
||||
{
|
||||
name: "chain of only trusted proxies uses peer",
|
||||
remoteAddr: loopbackPeer,
|
||||
xff: "10.0.0.5, 10.1.2.3",
|
||||
want: loopbackIP,
|
||||
},
|
||||
{
|
||||
name: "entry that is not an address uses peer",
|
||||
remoteAddr: loopbackPeer,
|
||||
xff: "203.0.113.9, unknown",
|
||||
want: loopbackIP,
|
||||
},
|
||||
{
|
||||
name: "x-real-ip ignored when forwarded-for has entries",
|
||||
remoteAddr: loopbackPeer,
|
||||
xff: "10.0.0.5",
|
||||
xRealIP: realIP,
|
||||
want: loopbackIP,
|
||||
},
|
||||
{
|
||||
name: "trusted proxy falls back to x-real-ip",
|
||||
remoteAddr: loopbackPeer,
|
||||
xRealIP: realIP,
|
||||
want: realIP,
|
||||
},
|
||||
{
|
||||
name: "untrusted peer ignores forwarded-for",
|
||||
remoteAddr: "198.51.100.4:5000",
|
||||
xff: forwardedIP,
|
||||
want: "198.51.100.4",
|
||||
},
|
||||
{
|
||||
name: "untrusted peer ignores x-real-ip",
|
||||
remoteAddr: "198.51.100.4:5000",
|
||||
xRealIP: realIP,
|
||||
want: "198.51.100.4",
|
||||
},
|
||||
{
|
||||
name: "trusted proxy with no headers uses peer",
|
||||
remoteAddr: "10.1.2.3:5000",
|
||||
want: "10.1.2.3",
|
||||
},
|
||||
{
|
||||
name: "trusted proxy with garbage header uses peer",
|
||||
remoteAddr: loopbackPeer,
|
||||
xff: "not-an-ip",
|
||||
want: loopbackIP,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
trusted := mustPrefixes(t, "127.0.0.1/32", "::1/128", "10.0.0.0/8")
|
||||
|
||||
for _, tc := range clientIPCases() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
header := http.Header{}
|
||||
if tc.xff != "" {
|
||||
header.Set("X-Forwarded-For", tc.xff)
|
||||
}
|
||||
|
||||
if tc.xRealIP != "" {
|
||||
header.Set("X-Real-IP", tc.xRealIP)
|
||||
}
|
||||
|
||||
got := middleware.ClientIP(tc.remoteAddr, header, trusted)
|
||||
if got != tc.want {
|
||||
t.Errorf("ClientIP() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientIPReadsEveryForwardedForLine checks that an entry a proxy
|
||||
// adds as an X-Forwarded-For line of its own is read, and the line the
|
||||
// client sent before it is not taken.
|
||||
func TestClientIPReadsEveryForwardedForLine(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
header := http.Header{}
|
||||
header.Add("X-Forwarded-For", "203.0.113.9")
|
||||
header.Add("X-Forwarded-For", appendedIP)
|
||||
|
||||
got := middleware.ClientIP(loopbackPeer, header,
|
||||
mustPrefixes(t, "127.0.0.1/32"))
|
||||
if got != appendedIP {
|
||||
t.Errorf("ClientIP() = %q, want %q", got, appendedIP)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientIPReadsAddressWithPort checks that an X-Forwarded-For entry
|
||||
// written as an address with a port is read as that address, both for
|
||||
// the trust check and as the result.
|
||||
func TestClientIPReadsAddressWithPort(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
trusted := mustPrefixes(t, "127.0.0.1/32", "10.0.0.0/8")
|
||||
|
||||
for xff, want := range map[string]string{
|
||||
"203.0.113.9, " + appendedIP + ":5000": appendedIP,
|
||||
"203.0.113.9, [2001:db8::1]:443": "2001:db8::1",
|
||||
"203.0.113.9, 10.0.0.5:5000": "203.0.113.9",
|
||||
} {
|
||||
header := http.Header{}
|
||||
header.Set("X-Forwarded-For", xff)
|
||||
|
||||
got := middleware.ClientIP(loopbackPeer, header, trusted)
|
||||
if got != want {
|
||||
t.Errorf("X-Forwarded-For %q: ClientIP() = %q, want %q",
|
||||
xff, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := (&middleware.Middleware{}).SecurityHeaders()(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}),
|
||||
)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
want := map[string]string{
|
||||
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
|
||||
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
|
||||
}
|
||||
|
||||
for name, value := range want {
|
||||
if got := rec.Header().Get(name); got != value {
|
||||
t.Errorf("header %s = %q, want %q", name, got, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaxBodyBytesRejectsOversizeOnNonReadingRoute confirms the
|
||||
// limit is enforced even for a handler that never reads the body
|
||||
// (for example the health check), via the Content-Length check.
|
||||
func TestMaxBodyBytesRejectsOversizeOnNonReadingRoute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const limit = 16
|
||||
|
||||
called := false
|
||||
handler := (&middleware.Middleware{}).MaxBodyBytes(limit)(
|
||||
http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
}),
|
||||
)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodPost, "/.well-known/healthcheck",
|
||||
strings.NewReader(strings.Repeat("x", limit+1)),
|
||||
)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status = %d, want %d",
|
||||
rec.Code, http.StatusRequestEntityTooLarge)
|
||||
}
|
||||
|
||||
if called {
|
||||
t.Fatal("handler ran despite oversize body")
|
||||
}
|
||||
|
||||
if got := rec.Body.String(); got != "{\"status\":\"error\"}\n" {
|
||||
t.Errorf("body = %q, want %q", got, "{\"status\":\"error\"}\n")
|
||||
}
|
||||
|
||||
got := rec.Header().Get("Content-Type")
|
||||
if got != "application/json; charset=utf-8" {
|
||||
t.Errorf("Content-Type = %q, want a JSON content type", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxBodyBytesAllowsWithinLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const limit = 64
|
||||
|
||||
handler := (&middleware.Middleware{}).MaxBodyBytes(limit)(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}),
|
||||
)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodPost, "/api/v1/reports",
|
||||
strings.NewReader(`{"clientId":"c1"}`),
|
||||
)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecovererReturns500AndLogsThroughSlog(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var logbuf bytes.Buffer
|
||||
|
||||
mw := middleware.NewWithLogger(
|
||||
slog.New(slog.NewJSONHandler(&logbuf, nil)),
|
||||
)
|
||||
|
||||
handler := mw.Recoverer()(
|
||||
http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||
panic("boom")
|
||||
}),
|
||||
)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want %d",
|
||||
rec.Code, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
var record map[string]any
|
||||
|
||||
err := json.Unmarshal(logbuf.Bytes(), &record)
|
||||
if err != nil {
|
||||
t.Fatalf("panic log is not one JSON record: %v (%q)", err, logbuf.String())
|
||||
}
|
||||
|
||||
if record["msg"] != "panic recovered" || record["level"] != "ERROR" {
|
||||
t.Errorf("log record = %v, want msg %q at level ERROR",
|
||||
record, "panic recovered")
|
||||
}
|
||||
|
||||
if record["panic"] != "boom" {
|
||||
t.Errorf("panic field = %v, want %q", record["panic"], "boom")
|
||||
}
|
||||
|
||||
stack, _ := record["stack"].(string)
|
||||
if !strings.HasPrefix(stack, "goroutine ") {
|
||||
t.Errorf("stack field = %q, want a stack trace", stack)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecovererRepanicsOnAbortHandler checks that a handler aborting
|
||||
// with http.ErrAbortHandler is not treated as a crash: Recoverer
|
||||
// panics again so the server aborts the response, and logs nothing.
|
||||
func TestRecovererRepanicsOnAbortHandler(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var logbuf bytes.Buffer
|
||||
|
||||
mw := middleware.NewWithLogger(
|
||||
slog.New(slog.NewJSONHandler(&logbuf, nil)),
|
||||
)
|
||||
|
||||
handler := mw.Recoverer()(
|
||||
http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||
panic(http.ErrAbortHandler)
|
||||
}),
|
||||
)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
||||
|
||||
var recovered any
|
||||
|
||||
func() {
|
||||
defer func() { recovered = recover() }()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
}()
|
||||
|
||||
err, _ := recovered.(error)
|
||||
if !errors.Is(err, http.ErrAbortHandler) {
|
||||
t.Errorf("Recoverer panicked with %v, want http.ErrAbortHandler", recovered)
|
||||
}
|
||||
|
||||
if logbuf.Len() != 0 {
|
||||
t.Errorf("abort was logged: %q", logbuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlerPanicIsLoggedAndCountedAs500 sends a request to a handler
|
||||
// that panics through the middleware SetupRoutes puts around a route
|
||||
// with metrics, in its order, leaving out those that write no status
|
||||
// for a panic and Sentry, which TestSentry covers. The request log line
|
||||
// and the metrics must give the 500 the client gets, and the panic must
|
||||
// still reach Recoverer, which logs it.
|
||||
func TestHandlerPanicIsLoggedAndCountedAs500(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var logbuf bytes.Buffer
|
||||
|
||||
mw := middleware.NewWithLogger(
|
||||
slog.New(slog.NewJSONHandler(&logbuf, nil)),
|
||||
)
|
||||
registry := prometheus.NewRegistry()
|
||||
|
||||
handler := chi.Chain(
|
||||
mw.Recoverer(),
|
||||
chimiddleware.RequestID,
|
||||
mw.Logging(),
|
||||
mw.Write500OnPanic(),
|
||||
mw.Metrics(registry),
|
||||
mw.Write500OnPanic(),
|
||||
).HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
panic("boom")
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want %d",
|
||||
rec.Code, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
request := findLogLine(t, logbuf.Bytes(), "request")
|
||||
if request.Status != http.StatusInternalServerError {
|
||||
t.Errorf("request log status = %d, want %d",
|
||||
request.Status, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
recovered := findLogLine(t, logbuf.Bytes(), "panic recovered")
|
||||
if recovered.Panic != "boom" {
|
||||
t.Errorf("panic recovered line has panic %q, want %q",
|
||||
recovered.Panic, "boom")
|
||||
}
|
||||
|
||||
for _, name := range []string{
|
||||
"http_request_duration_seconds", "http_response_size_bytes",
|
||||
} {
|
||||
codes := metricCodes(t, registry, name)
|
||||
if len(codes) != 1 || codes[0] != "500" {
|
||||
t.Errorf("%s code labels = %v, want [500]", name, codes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// loggedLine holds the fields the tests read from a JSON log line.
|
||||
type loggedLine struct {
|
||||
Msg string `json:"msg"`
|
||||
Status int `json:"status"`
|
||||
Panic string `json:"panic"`
|
||||
}
|
||||
|
||||
// findLogLine returns the first of the JSON log lines in logged whose
|
||||
// message is msg.
|
||||
func findLogLine(t *testing.T, logged []byte, msg string) loggedLine {
|
||||
t.Helper()
|
||||
|
||||
dec := json.NewDecoder(bytes.NewReader(logged))
|
||||
|
||||
for {
|
||||
var line loggedLine
|
||||
|
||||
err := dec.Decode(&line)
|
||||
if err != nil {
|
||||
t.Fatalf("no %q line in the log: %v (%q)", msg, err, logged)
|
||||
}
|
||||
|
||||
if line.Msg == msg {
|
||||
return line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// metricCodes returns the code label of each series of the metric
|
||||
// family name in registry.
|
||||
func metricCodes(
|
||||
t *testing.T,
|
||||
registry *prometheus.Registry,
|
||||
name string,
|
||||
) []string {
|
||||
t.Helper()
|
||||
|
||||
families, err := registry.Gather()
|
||||
if err != nil {
|
||||
t.Fatalf("gather metrics: %v", err)
|
||||
}
|
||||
|
||||
var codes []string
|
||||
|
||||
for _, family := range families {
|
||||
if family.GetName() != name {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, metric := range family.GetMetric() {
|
||||
for _, label := range metric.GetLabel() {
|
||||
if label.GetName() == "code" {
|
||||
codes = append(codes, label.GetValue())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return codes
|
||||
}
|
||||
|
||||
// TestRecovererWritesNoSecondStatus checks, on a real server, that
|
||||
// Recoverer does not write its 500 again after Write500OnPanic has
|
||||
// written one, which the server would log as a superfluous
|
||||
// WriteHeader call.
|
||||
func TestRecovererWritesNoSecondStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var serverLog bytes.Buffer
|
||||
|
||||
mw := middleware.NewWithLogger(slog.New(slog.DiscardHandler))
|
||||
|
||||
server := httptest.NewUnstartedServer(chi.Chain(
|
||||
mw.Recoverer(),
|
||||
mw.Write500OnPanic(),
|
||||
).HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
panic("boom")
|
||||
}))
|
||||
server.Config.ErrorLog = log.New(&serverLog, "", 0)
|
||||
server.Start()
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(),
|
||||
http.MethodGet, server.URL, http.NoBody)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := server.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Close waits for the handler to return, so whatever the server
|
||||
// logs for this request is in serverLog after it.
|
||||
server.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Errorf("status = %d, want %d",
|
||||
resp.StatusCode, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if serverLog.Len() != 0 {
|
||||
t.Errorf("server logged %q", serverLog.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoggingCutsRequestStringsToBound sends an over-long URL and
|
||||
// over-long header values, and checks the request log writes each
|
||||
// one cut to logger.MaxLoggedFieldBytes.
|
||||
func TestLoggingCutsRequestStringsToBound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
long := strings.Repeat("a", 2*logger.MaxLoggedFieldBytes)
|
||||
|
||||
var logbuf bytes.Buffer
|
||||
|
||||
mw := middleware.NewWithLogger(
|
||||
slog.New(slog.NewJSONHandler(&logbuf, nil)),
|
||||
)
|
||||
|
||||
handler := chimiddleware.RequestID(mw.Logging()(okHandler()))
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodGet, "/"+long, http.NoBody)
|
||||
req.Header.Set("User-Agent", long)
|
||||
req.Header.Set("Referer", long)
|
||||
req.Header.Set("X-Request-ID", long)
|
||||
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
var logged map[string]any
|
||||
|
||||
err := json.Unmarshal(logbuf.Bytes(), &logged)
|
||||
if err != nil {
|
||||
t.Fatalf("log line not JSON: %v (%q)", err, logbuf.String())
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
"url": ("/" + long)[:logger.MaxLoggedFieldBytes],
|
||||
"useragent": long[:logger.MaxLoggedFieldBytes],
|
||||
"referer": long[:logger.MaxLoggedFieldBytes],
|
||||
"request_id": long[:logger.MaxLoggedFieldBytes],
|
||||
}
|
||||
|
||||
for field, value := range want {
|
||||
if logged[field] != value {
|
||||
t.Errorf("logged %s = %q, want it cut to %d bytes",
|
||||
field, logged[field], logger.MaxLoggedFieldBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// okHandler stands in for the route a middleware guards.
|
||||
func okHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
// TestRateLimitRefusesPastAllowanceThenResets checks one client
|
||||
// address: it may use its whole allowance at once, the next request
|
||||
// is refused with 429, and later it may send again.
|
||||
func TestRateLimitRefusesPastAllowanceThenResets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// synctest runs this on a fake clock: time.Sleep returns at once,
|
||||
// with the clock moved on.
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
const perMinute = 2
|
||||
|
||||
handler := (&middleware.Middleware{}).RateLimit(perMinute)(okHandler())
|
||||
|
||||
post := func() *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodPost, "/api/v1/reports", http.NoBody)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
return rec
|
||||
}
|
||||
|
||||
for i := range perMinute {
|
||||
if code := post().Code; code != http.StatusOK {
|
||||
t.Fatalf("request %d: status = %d, want %d",
|
||||
i+1, code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
rec := post()
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("request past the allowance: status = %d, want %d",
|
||||
rec.Code, http.StatusTooManyRequests)
|
||||
}
|
||||
|
||||
if got := rec.Body.String(); got != "{\"status\":\"error\"}\n" {
|
||||
t.Errorf("body = %q, want %q", got, "{\"status\":\"error\"}\n")
|
||||
}
|
||||
|
||||
if got := rec.Header().Get("Retry-After"); got != "60" {
|
||||
t.Fatalf("Retry-After = %q, want %q", got, "60")
|
||||
}
|
||||
|
||||
// httprate also counts the previous minute's requests, fading
|
||||
// them out over the current one, so two minutes on the whole
|
||||
// allowance is back.
|
||||
time.Sleep(2 * time.Minute)
|
||||
|
||||
for i := range perMinute {
|
||||
if code := post().Code; code != http.StatusOK {
|
||||
t.Fatalf("two minutes later, request %d: status = %d, want %d",
|
||||
i+1, code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// postForwarded sends handler a report from peer that names client in
|
||||
// X-Forwarded-For, and returns the status.
|
||||
func postForwarded(
|
||||
t *testing.T,
|
||||
handler http.Handler,
|
||||
peer, client string,
|
||||
) int {
|
||||
t.Helper()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodPost, "/api/v1/reports", http.NoBody)
|
||||
req.RemoteAddr = peer
|
||||
req.Header.Set("X-Forwarded-For", client)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
// TestRateLimitIsPerForwardedClient checks that clients behind a
|
||||
// trusted proxy each get their own allowance: the limit is keyed on
|
||||
// the client address clientIP resolves, not on the proxy's.
|
||||
func TestRateLimitIsPerForwardedClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const otherClient = "203.0.113.8"
|
||||
|
||||
mw := middleware.NewWithTrustedProxies(mustPrefixes(t, "127.0.0.1/32"))
|
||||
handler := mw.RateLimit(1)(okHandler())
|
||||
|
||||
code := postForwarded(t, handler, loopbackPeer, forwardedIP)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("first request: status = %d, want %d", code, http.StatusOK)
|
||||
}
|
||||
|
||||
code = postForwarded(t, handler, loopbackPeer, forwardedIP)
|
||||
if code != http.StatusTooManyRequests {
|
||||
t.Fatalf("same client again: status = %d, want %d",
|
||||
code, http.StatusTooManyRequests)
|
||||
}
|
||||
|
||||
code = postForwarded(t, handler, loopbackPeer, otherClient)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("other client behind the same proxy: status = %d, want %d",
|
||||
code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimitIgnoresForwardedForFromUntrustedPeer checks that a
|
||||
// peer that is not a trusted proxy cannot get a fresh allowance by
|
||||
// naming a different client in X-Forwarded-For on each request.
|
||||
func TestRateLimitIgnoresForwardedForFromUntrustedPeer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const untrustedPeer = "198.51.100.4:5000"
|
||||
|
||||
mw := middleware.NewWithTrustedProxies(mustPrefixes(t, "127.0.0.1/32"))
|
||||
handler := mw.RateLimit(1)(okHandler())
|
||||
|
||||
code := postForwarded(t, handler, untrustedPeer, "203.0.113.8")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("first request: status = %d, want %d", code, http.StatusOK)
|
||||
}
|
||||
|
||||
code = postForwarded(t, handler, untrustedPeer, "203.0.113.9")
|
||||
if code != http.StatusTooManyRequests {
|
||||
t.Fatalf("same peer naming another client: status = %d, want %d",
|
||||
code, http.StatusTooManyRequests)
|
||||
}
|
||||
}
|
||||
|
||||
// preflight sends cors the preflight request a browser makes before
|
||||
// it POSTs JSON from origin.
|
||||
func preflight(
|
||||
t *testing.T,
|
||||
cors func(http.Handler) http.Handler,
|
||||
origin string,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodOptions, "/api/v1/reports", http.NoBody)
|
||||
req.Header.Set("Origin", origin)
|
||||
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
|
||||
req.Header.Set("Access-Control-Request-Headers", "content-type")
|
||||
cors(okHandler()).ServeHTTP(rec, req)
|
||||
|
||||
return rec
|
||||
}
|
||||
|
||||
// TestCORSWithoutOriginsAddsNoHeaders checks the default: with no
|
||||
// origins configured, no origin is given any CORS header.
|
||||
func TestCORSWithoutOriginsAddsNoHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := preflight(t,
|
||||
(&middleware.Middleware{}).CORS(nil), "https://elsewhere.example")
|
||||
|
||||
for name := range rec.Header() {
|
||||
if strings.HasPrefix(name, "Access-Control-") {
|
||||
t.Errorf("CORS header %s set with no origins configured", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSAllowsOnlyListedOrigins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const listed = "https://netwatch.example"
|
||||
|
||||
cors := (&middleware.Middleware{}).CORS([]string{listed})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
origin string
|
||||
want string
|
||||
}{
|
||||
{name: "listed origin allowed", origin: listed, want: listed},
|
||||
{name: "other origin refused", origin: "https://elsewhere.example"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := preflight(t, cors, tc.origin)
|
||||
|
||||
got := rec.Header().Get("Access-Control-Allow-Origin")
|
||||
if got != tc.want {
|
||||
t.Errorf("Access-Control-Allow-Origin = %q, want %q",
|
||||
got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package reportbuf
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// ErrDataDirOutsideVolume is returned by PrepareDataDir for a DATA_DIR
|
||||
// that is not the volume or a path below it, written in full.
|
||||
var ErrDataDirOutsideVolume = errors.New(
|
||||
"must be /data or a path below it, with no '.', '..' or extra '/'")
|
||||
|
||||
// PrepareDataDir gets dir, the server's DATA_DIR, ready for owner, the
|
||||
// user the server runs as, so that a host directory mounted at volume,
|
||||
// /data in the image, needs no preparing: it creates dir, gives volume
|
||||
// and the directories, regular files and symbolic links in it to
|
||||
// owner, and gives volume and dir the mode the server gives a directory
|
||||
// it creates. It leaves a file with a second name (hard link) as it
|
||||
// is, as that name could be outside volume, and logs its path; it
|
||||
// leaves a device, named pipe or socket as it is too (see chownFile).
|
||||
// dir must be volume or a path below it, with no '.', '..', empty part
|
||||
// or '/' at the end. On any system but Linux, the system of the image, it
|
||||
// fails at the first file in volume that is not a directory (see
|
||||
// chownFile).
|
||||
//
|
||||
// bin/entrypoint.sh runs this as root, which would follow a symbolic
|
||||
// link anywhere, so every step goes through an os.Root opened on
|
||||
// volume: it follows a link only when it is written as a relative
|
||||
// path that stays inside volume, and refuses any other. A process on
|
||||
// the host can swap a link onto a path in volume at any moment while
|
||||
// this runs. Even then, the os.Root checks each link as it reaches
|
||||
// it. MkdirAll creates each directory inside a parent it already has
|
||||
// open, never following a link at the name it creates, and follows a
|
||||
// link on the path only as the os.Root allows, so a relative link
|
||||
// inside volume can lead it to create directories elsewhere inside
|
||||
// volume. The owners and modes are set on files already opened (see
|
||||
// chownAll and chmodDir), so the most that process can do is make a
|
||||
// step fail or wait, or act on something else inside volume.
|
||||
func PrepareDataDir(volume, dir string, owner *user.User) error {
|
||||
// rel is dir as a path from volume; IsLocal is false for one that
|
||||
// leads out of it.
|
||||
rel, err := filepath.Rel(volume, dir)
|
||||
if err != nil || dir != filepath.Clean(dir) || !filepath.IsLocal(rel) {
|
||||
return ErrDataDirOutsideVolume
|
||||
}
|
||||
|
||||
uid, err := strconv.Atoi(owner.Uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gid, err := strconv.Atoi(owner.Gid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
root, err := os.OpenRoot(volume)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() { _ = root.Close() }()
|
||||
|
||||
err = root.MkdirAll(rel, dirPerms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = chownAll(root, ".", uid, gid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = chmodDir(root, ".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return chmodDir(root, rel)
|
||||
}
|
||||
|
||||
// chownAll gives name, a directory inside root, and the directories,
|
||||
// regular files and symbolic links in it to uid and gid, but not the
|
||||
// files chownFile leaves. It opens each through root and gives the
|
||||
// open file to them, as chmodDir sets a mode on the open directory.
|
||||
// Root.Lchown would not hold: it acts on whatever is at the name by
|
||||
// then, and a hard link swapped in there shares its owner with a file
|
||||
// that could be outside root. A symbolic link is given to them itself,
|
||||
// never what it points to, because in a directory that anyone can
|
||||
// write to and that has the sticky bit set, as /tmp has, the kernel
|
||||
// follows a link only for the link's owner or when the link and the
|
||||
// directory have the same owner. chownAll goes into an entry only when
|
||||
// the read found a directory there, so it follows no link it finds;
|
||||
// one swapped in afterwards for a directory is followed only as the
|
||||
// os.Root allows, and one swapped in for any other file is not
|
||||
// followed at all (see chownFile).
|
||||
func chownAll(root *os.Root, name string, uid, gid int) error {
|
||||
entries, err := chownDir(root, name, uid, gid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
entryName := filepath.Join(name, entry.Name())
|
||||
if entry.IsDir() {
|
||||
err = chownAll(root, entryName, uid, gid)
|
||||
} else {
|
||||
err = chownFile(root, entryName, uid, gid)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// chownDir gives name, a directory inside root, to uid and gid, and
|
||||
// returns what it holds. Like chmodDir, it opens name through root and
|
||||
// refuses anything but a directory, which has no second name (hard
|
||||
// link). It reads the open directory, not root.FS(), which refuses a
|
||||
// name that is not valid UTF-8.
|
||||
func chownDir(root *os.Root, name string, uid, gid int) ([]fs.DirEntry, error) {
|
||||
dir, err := root.Open(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() { _ = dir.Close() }()
|
||||
|
||||
info, err := dir.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
return nil, &fs.PathError{Op: "chown", Path: name, Err: syscall.ENOTDIR}
|
||||
}
|
||||
|
||||
err = dir.Chown(uid, gid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dir.ReadDir(-1)
|
||||
}
|
||||
|
||||
// chmodDir gives name, a directory inside root, the mode the server
|
||||
// gives a directory it creates. Root.Chmod would not hold: on Linux it
|
||||
// checks that name is not a symbolic link, then sets the mode by name,
|
||||
// following a link swapped in between. So chmodDir opens name through
|
||||
// root and sets the mode on the open directory. It refuses anything
|
||||
// but a directory: a directory has no second name (hard link), so the
|
||||
// one opened is inside root, where any other file could be a hard link
|
||||
// to one outside.
|
||||
func chmodDir(root *os.Root, name string) error {
|
||||
dir, err := root.Open(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() { _ = dir.Close() }()
|
||||
|
||||
info, err := dir.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
return &fs.PathError{Op: "chmod", Path: name, Err: syscall.ENOTDIR}
|
||||
}
|
||||
|
||||
return dir.Chmod(dirPerms)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//go:build linux
|
||||
|
||||
package reportbuf
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// chownFile gives name, a file inside root that is not a directory, to
|
||||
// uid and gid if it is a regular file or symbolic link with one name.
|
||||
// It leaves, and logs, a file of any kind with a second name (hard
|
||||
// link): all the names of a file share its owner, and the other could
|
||||
// be outside root. It leaves any other file with one name, such as a
|
||||
// device, named pipe or socket: the server needs to own none of them.
|
||||
// It opens name through root with O_PATH, which reads nothing from the
|
||||
// file, so opening a device or named pipe neither waits nor acts on
|
||||
// it; os.Root adds O_NOFOLLOW, and the two together open a symbolic
|
||||
// link itself, never what it points to. It reads the kind of file and
|
||||
// its number of names from the open file and gives that file to them,
|
||||
// so a hard link swapped in at name is caught too.
|
||||
//
|
||||
// This file is built only on Linux: prepare-data-dir runs only in the
|
||||
// Linux image, and macOS, where the backend is also built for
|
||||
// development, has neither O_PATH nor AT_EMPTY_PATH. On any other
|
||||
// system chownFile returns an error (see datadir_other.go).
|
||||
func chownFile(root *os.Root, name string, uid, gid int) error {
|
||||
file, err := root.OpenFile(name, unix.O_PATH, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok || stat.Nlink > 1 {
|
||||
slog.Warn("owner not changed: file has a second name (hard link)",
|
||||
"path", file.Name())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if !info.Mode().IsRegular() && info.Mode().Type() != fs.ModeSymlink {
|
||||
return nil
|
||||
}
|
||||
|
||||
// File.Chown would fail: fchown refuses a file opened with O_PATH.
|
||||
// Fchownat with an empty name and AT_EMPTY_PATH acts on the open
|
||||
// file instead.
|
||||
err = unix.Fchownat(int(file.Fd()), "", uid, gid, unix.AT_EMPTY_PATH)
|
||||
if err != nil {
|
||||
return &fs.PathError{Op: "chown", Path: file.Name(), Err: err}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build !linux
|
||||
|
||||
package reportbuf
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
)
|
||||
|
||||
// chownFile returns an error on every system but Linux. Opening a file
|
||||
// that is not a directory without reading it or following it, and
|
||||
// giving it to a user on the open file, never by name, is built only
|
||||
// for Linux (see datadir_linux.go): prepare-data-dir runs only in the
|
||||
// Linux image, and macOS, where the backend is also built for
|
||||
// development, has neither O_PATH nor AT_EMPTY_PATH. This file lets
|
||||
// the package build elsewhere, such as on macOS, where a developer
|
||||
// runs make build and make run.
|
||||
func chownFile(_ *os.Root, name string, _, _ int) error {
|
||||
return &fs.PathError{Op: "chown", Path: name, Err: errors.ErrUnsupported}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package reportbuf_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/reportbuf"
|
||||
)
|
||||
|
||||
// reports is the last part of DATA_DIR in these tests, as in the
|
||||
// image's /data/reports.
|
||||
const reports = "reports"
|
||||
|
||||
// currentUser is the user the test runs as, the only owner a test not
|
||||
// run as root can give files to.
|
||||
func currentUser() *user.User {
|
||||
return &user.User{
|
||||
Uid: strconv.Itoa(os.Getuid()),
|
||||
Gid: strconv.Itoa(os.Getgid()),
|
||||
}
|
||||
}
|
||||
|
||||
// tempDirMode700 is a new directory in a t.TempDir with mode 0700, so
|
||||
// a test can tell that PrepareDataDir left its mode alone.
|
||||
func tempDirMode700(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
dir := filepath.Join(t.TempDir(), "d")
|
||||
|
||||
err := os.Mkdir(dir, 0o700)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
func requireMode(t *testing.T, path string, want fs.FileMode) {
|
||||
t.Helper()
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if info.Mode() != want {
|
||||
t.Errorf("%s: mode %v, want %v", path, info.Mode(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func requireMissing(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
|
||||
_, err := os.Lstat(path)
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("%s: created, or Lstat failed: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func requireOwner(t *testing.T, path string, uid, gid uint32) {
|
||||
t.Helper()
|
||||
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stat, _ := info.Sys().(*syscall.Stat_t)
|
||||
if stat.Uid != uid || stat.Gid != gid {
|
||||
t.Errorf("%s: owner %d:%d, want %d:%d", path, stat.Uid, stat.Gid,
|
||||
uid, gid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareDataDirCreatesDataDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
volume := t.TempDir()
|
||||
dir := filepath.Join(volume, "a", reports)
|
||||
|
||||
err := reportbuf.PrepareDataDir(volume, dir, currentUser())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
requireMode(t, volume, fs.ModeDir|0o750)
|
||||
requireMode(t, dir, fs.ModeDir|0o750)
|
||||
}
|
||||
|
||||
// TestPrepareDataDirSetsModeOfExistingDataDir: a DATA_DIR already on
|
||||
// the host with another mode gets the mode too, not only a new one.
|
||||
func TestPrepareDataDirSetsModeOfExistingDataDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
volume := t.TempDir()
|
||||
dir := filepath.Join(volume, reports)
|
||||
|
||||
err := os.Mkdir(dir, 0o700)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = reportbuf.PrepareDataDir(volume, dir, currentUser())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
requireMode(t, dir, fs.ModeDir|0o750)
|
||||
}
|
||||
|
||||
func TestPrepareDataDirTakesTheVolumeItself(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
volume := t.TempDir()
|
||||
|
||||
err := reportbuf.PrepareDataDir(volume, volume, currentUser())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
requireMode(t, volume, fs.ModeDir|0o750)
|
||||
}
|
||||
|
||||
// TestPrepareDataDirRefusesDataDirOutsideVolume covers a DATA_DIR that
|
||||
// is relative, outside the volume, or not written in full.
|
||||
func TestPrepareDataDirRefusesDataDirOutsideVolume(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
volume := tempDirMode700(t)
|
||||
for _, dir := range []string{
|
||||
reports, volume + "/../new", volume + "/", volume + "//" + reports,
|
||||
volume + "/./" + reports, volume + "/" + reports + "/..", volume + "x",
|
||||
"/etc",
|
||||
} {
|
||||
err := reportbuf.PrepareDataDir(volume, dir, currentUser())
|
||||
if !errors.Is(err, reportbuf.ErrDataDirOutsideVolume) {
|
||||
t.Errorf("%q: error = %v, want ErrDataDirOutsideVolume", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
requireMissing(t, filepath.Join(filepath.Dir(volume), "new"))
|
||||
requireMode(t, volume, fs.ModeDir|0o700)
|
||||
}
|
||||
|
||||
// TestPrepareDataDirRefusesLinkOutOfVolume puts a symbolic link to a
|
||||
// directory outside the volume on the path to DATA_DIR, written as a
|
||||
// full path and as one that climbs out with '..', and as DATA_DIR
|
||||
// itself, where the mode would be set through it.
|
||||
func TestPrepareDataDirRefusesLinkOutOfVolume(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
climbsOut bool
|
||||
link, dir string
|
||||
}{
|
||||
{"full path", false, "x", "x/reports"},
|
||||
{"climbs out", true, "x", "x/reports"},
|
||||
{"DATA_DIR itself", false, reports, reports},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
outside := tempDirMode700(t)
|
||||
volume := t.TempDir()
|
||||
|
||||
climbOut, err := filepath.Rel(volume, outside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
target := outside
|
||||
if tc.climbsOut {
|
||||
target = climbOut
|
||||
}
|
||||
|
||||
err = os.Symlink(target, filepath.Join(volume, tc.link))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = reportbuf.PrepareDataDir(volume,
|
||||
filepath.Join(volume, tc.dir), currentUser())
|
||||
if err == nil {
|
||||
t.Error("no error")
|
||||
}
|
||||
|
||||
requireMissing(t, filepath.Join(outside, reports))
|
||||
requireMode(t, outside, fs.ModeDir|0o700)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareDataDirRefusesDanglingLink: DATA_DIR is a symbolic link
|
||||
// to a name in the volume that does not exist, which is not created.
|
||||
func TestPrepareDataDirRefusesDanglingLink(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
volume := t.TempDir()
|
||||
|
||||
err := os.Symlink("missing", filepath.Join(volume, reports))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = reportbuf.PrepareDataDir(volume, filepath.Join(volume, reports),
|
||||
currentUser())
|
||||
if err == nil {
|
||||
t.Error("no error")
|
||||
}
|
||||
|
||||
requireMissing(t, filepath.Join(volume, "missing"))
|
||||
}
|
||||
|
||||
// TestPrepareDataDirTakesDirectoryNamedInLatin1: a host directory can
|
||||
// hold names that are not valid UTF-8, here "café" written in Latin-1.
|
||||
// A test not run as root can only check that PrepareDataDir goes into
|
||||
// such a directory and gives it, and what it holds, to the current
|
||||
// user.
|
||||
func TestPrepareDataDirTakesDirectoryNamedInLatin1(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
volume := t.TempDir()
|
||||
latin1 := filepath.Join(volume, "caf\xe9")
|
||||
|
||||
err := os.Mkdir(latin1, 0o700)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(filepath.Join(latin1, "f"), nil, 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
owner := currentUser()
|
||||
|
||||
err = reportbuf.PrepareDataDir(volume, filepath.Join(volume, reports),
|
||||
owner)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
uid, _ := strconv.ParseUint(owner.Uid, 10, 32)
|
||||
gid, _ := strconv.ParseUint(owner.Gid, 10, 32)
|
||||
|
||||
requireOwner(t, latin1, uint32(uid), uint32(gid))
|
||||
requireOwner(t, filepath.Join(latin1, "f"), uint32(uid), uint32(gid))
|
||||
}
|
||||
|
||||
// TestPrepareDataDirGivesVolumeToOwner gives the directories, regular
|
||||
// files and symbolic links in the volume to a uid and gid that own
|
||||
// nothing, which only root can do. A symbolic link in the volume to a
|
||||
// directory outside it is given to them itself; what it points to is
|
||||
// left as it was. A hard link to a file outside the volume, one to a
|
||||
// symbolic link outside it and one to a named pipe outside it are left
|
||||
// as they were, and so is a named pipe in the volume.
|
||||
func TestPrepareDataDirGivesVolumeToOwner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if os.Geteuid() != 0 {
|
||||
t.Skip("only root can give files to another uid")
|
||||
}
|
||||
|
||||
outside := t.TempDir()
|
||||
volume := t.TempDir()
|
||||
old := filepath.Join(volume, "old")
|
||||
|
||||
err := os.WriteFile(filepath.Join(outside, "f"), nil, 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = os.Symlink("f", filepath.Join(outside, "link"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = syscall.Mkfifo(filepath.Join(outside, "fifo"), 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = os.Mkdir(old, 0o700)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(filepath.Join(old, "f"), nil, 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = os.Symlink(outside, filepath.Join(old, "link"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = syscall.Mkfifo(filepath.Join(old, "fifo"), 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// os.Link does not follow a symbolic link: it gives the link itself
|
||||
// a second name.
|
||||
for _, name := range []string{"f", "link", "fifo"} {
|
||||
err = os.Link(filepath.Join(outside, name),
|
||||
filepath.Join(old, "hardlink-to-"+name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
err = reportbuf.PrepareDataDir(volume, filepath.Join(volume, reports),
|
||||
&user.User{Uid: "4242", Gid: "4343"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
volume, filepath.Join(volume, reports), old, filepath.Join(old, "f"),
|
||||
filepath.Join(old, "link"),
|
||||
} {
|
||||
requireOwner(t, path, 4242, 4343)
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
outside, filepath.Join(outside, "f"), filepath.Join(outside, "link"),
|
||||
filepath.Join(outside, "fifo"), filepath.Join(old, "fifo"),
|
||||
} {
|
||||
requireOwner(t, path, 0, 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package reportbuf
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FlushSizeThreshold exposes the buffer size at which Append starts
|
||||
// writing a report file to the external tests.
|
||||
const FlushSizeThreshold = flushSizeThreshold
|
||||
|
||||
// Flush writes the buffered reports to a file now, as the periodic
|
||||
// flush does, so tests need not wait a minute for it.
|
||||
func (b *Buffer) Flush() error {
|
||||
return b.flushLocked()
|
||||
}
|
||||
|
||||
// StopClock makes every report file the buffer writes from now on
|
||||
// carry the timestamp at, as if all were written in one millisecond.
|
||||
func (b *Buffer) StopClock(at time.Time) {
|
||||
b.now = func() time.Time { return at }
|
||||
}
|
||||
|
||||
// OnFileCreated makes the buffer call fn with each report file it
|
||||
// writes from now on, once the file is created and before anything is
|
||||
// written to it.
|
||||
func (b *Buffer) OnFileCreated(fn func(f *os.File)) {
|
||||
b.fileCreated = fn
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
// Package reportbuf accumulates telemetry reports in memory
|
||||
// and periodically flushes them to zstd-compressed JSONL files,
|
||||
// deleting the oldest files to keep them under a size cap. Before the
|
||||
// server starts, PrepareDataDir gets the directory those files are
|
||||
// kept in, DATA_DIR, ready for the user the server runs as: it creates
|
||||
// it and gives it and the volume it is on to that user.
|
||||
package reportbuf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/config"
|
||||
"sneak.berlin/go/netwatch/internal/logger"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
const (
|
||||
flushSizeThreshold = 10 << 20 // 10 MiB
|
||||
flushInterval = 1 * time.Minute
|
||||
defaultDataDir = "./data/reports"
|
||||
dirPerms fs.FileMode = 0o750
|
||||
filePerms fs.FileMode = 0o640
|
||||
|
||||
// Report files are named filePrefix + timestamp + "-" + number +
|
||||
// fileSuffix; see writeFile.
|
||||
filePrefix = "reports-"
|
||||
fileSuffix = ".jsonl.zst"
|
||||
)
|
||||
|
||||
// ErrFull is returned by Append when the reports waiting to be
|
||||
// written leave no room for the report under the configured maximum
|
||||
// size, however many report files are deleted.
|
||||
var ErrFull = errors.New("reports waiting to be written fill the size cap")
|
||||
|
||||
// Params defines the dependencies for Buffer.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Config *config.Config
|
||||
Logger *logger.Logger
|
||||
}
|
||||
|
||||
// reportFile is a report file that may be deleted to make room, with
|
||||
// the size it counts for in usedBytes.
|
||||
type reportFile struct {
|
||||
name string
|
||||
size int64
|
||||
}
|
||||
|
||||
// Buffer accumulates JSON lines in memory and flushes them
|
||||
// to zstd-compressed files on disk.
|
||||
type Buffer struct {
|
||||
buf bytes.Buffer
|
||||
dataDir string
|
||||
done chan struct{}
|
||||
// fileCreated is called with each report file once it is
|
||||
// created, before anything is written to it: it does nothing,
|
||||
// except in tests that hold the write open or make it fail.
|
||||
fileCreated func(f *os.File)
|
||||
// files are the report files that may be deleted to make room,
|
||||
// oldest first: those in dataDir at start, in name order, then
|
||||
// each one this buffer writes, once it is complete. A file still
|
||||
// being written is not among them. filesBytes is their total size.
|
||||
files []reportFile
|
||||
filesBytes int64
|
||||
log *slog.Logger
|
||||
maxBytes int64
|
||||
mu sync.Mutex
|
||||
// now is the clock report files are named by: time.Now, except
|
||||
// in tests that need two flushes to share a timestamp.
|
||||
now func() time.Time
|
||||
// seq numbers the report files, so that two named in the same
|
||||
// millisecond still get different names.
|
||||
seq atomic.Int64
|
||||
stopOnce sync.Once
|
||||
// thresholdWriteWaiting is set while a write Append started at
|
||||
// flushSizeThreshold waits for writeMu; Append starts no other
|
||||
// while it is set.
|
||||
thresholdWriteWaiting atomic.Bool
|
||||
// usedBytes is what Append checks against maxBytes: the size
|
||||
// of the report files in dataDir, plus the reports waiting to
|
||||
// be written to one at their uncompressed size.
|
||||
usedBytes int64
|
||||
// writeErr is the error of the last write of a report file to
|
||||
// finish, nil if it succeeded or none has. While it is set, Append
|
||||
// refuses reports, and the reports of that write wait in buf for
|
||||
// the next flush.
|
||||
writeErr error
|
||||
// writeMu is held across each write of a report file, so writes
|
||||
// never overlap: a failed write puts its reports back in buf before
|
||||
// the next write takes them, which keeps them in the order they
|
||||
// were appended.
|
||||
writeMu sync.Mutex
|
||||
// writers are the goroutines that write report files: flushLoop,
|
||||
// and each one Append starts at flushSizeThreshold. OnStop waits
|
||||
// for them before the final flush.
|
||||
writers sync.WaitGroup
|
||||
}
|
||||
|
||||
// New creates a Buffer and registers lifecycle hooks to
|
||||
// manage the data directory and flush goroutine.
|
||||
func New(
|
||||
lc fx.Lifecycle,
|
||||
params Params,
|
||||
) (*Buffer, error) {
|
||||
dir := params.Config.DataDir
|
||||
if dir == "" {
|
||||
dir = defaultDataDir
|
||||
}
|
||||
|
||||
b := &Buffer{
|
||||
dataDir: dir,
|
||||
done: make(chan struct{}),
|
||||
fileCreated: func(*os.File) {},
|
||||
log: params.Logger.Get(),
|
||||
maxBytes: params.Config.DataDirMaxBytes,
|
||||
now: time.Now,
|
||||
}
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
// The errors name DATA_DIR, as config.New's do for the
|
||||
// settings it checks.
|
||||
err := os.MkdirAll(b.dataDir, dirPerms)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DATA_DIR %q: %w", b.dataDir, err)
|
||||
}
|
||||
|
||||
// Creating and removing a file stops the start when no
|
||||
// file can be created in DATA_DIR, instead of the first
|
||||
// write of a report file failing after reports were
|
||||
// answered 200. A full disk is not caught: the empty
|
||||
// file is still created.
|
||||
f, err := os.CreateTemp(b.dataDir, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("DATA_DIR %q: %w", b.dataDir, err)
|
||||
}
|
||||
|
||||
err = errors.Join(f.Close(), os.Remove(f.Name()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("DATA_DIR %q: %w", b.dataDir, err)
|
||||
}
|
||||
|
||||
// Report files left by earlier runs count too, and are
|
||||
// the first to be deleted to make room.
|
||||
files, err := reportFiles(b.dataDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DATA_DIR %q: %w", b.dataDir, err)
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
|
||||
b.files = files
|
||||
for _, f := range files {
|
||||
b.filesBytes += f.size
|
||||
}
|
||||
|
||||
b.usedBytes = b.filesBytes
|
||||
|
||||
// The files may be past the cap, if it was lowered since
|
||||
// the last run.
|
||||
b.deleteOldestFiles(0)
|
||||
|
||||
b.mu.Unlock()
|
||||
|
||||
b.writers.Go(b.flushLoop)
|
||||
|
||||
return nil
|
||||
},
|
||||
OnStop: func(_ context.Context) error {
|
||||
// stopOnce makes OnStop idempotent: a second
|
||||
// invocation must not close an already-closed channel
|
||||
// (which would panic) or flush again.
|
||||
var err error
|
||||
|
||||
b.stopOnce.Do(func() {
|
||||
// Closing done ends flushLoop after any flush it is
|
||||
// running. Wait for it and for the writes Append
|
||||
// started, which the process exiting would cut off;
|
||||
// one that fails puts its reports back in buf, for the
|
||||
// final flush to write.
|
||||
close(b.done)
|
||||
b.writers.Wait()
|
||||
|
||||
err = b.flushLocked()
|
||||
})
|
||||
|
||||
// A failed final flush fails the stop, so the process
|
||||
// exits non-zero.
|
||||
return err
|
||||
},
|
||||
})
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// Append marshals v as a single JSON line and appends it to
|
||||
// the buffer. It stores nothing and returns an error if the last
|
||||
// write of a report file failed, until a write succeeds again. If the
|
||||
// line would take usedBytes past maxBytes, the oldest report files are
|
||||
// deleted to make room; it stores nothing and returns ErrFull if that
|
||||
// cannot make room. If the buffer reaches the size threshold, it starts
|
||||
// a write of the reports in the background (see flushAtThreshold),
|
||||
// unless one it started is still waiting.
|
||||
func (b *Buffer) Append(v any) error {
|
||||
line, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal report: %w", err)
|
||||
}
|
||||
|
||||
lineBytes := int64(len(line)) + 1 // with its newline
|
||||
|
||||
b.mu.Lock()
|
||||
|
||||
if b.writeErr != nil {
|
||||
err = fmt.Errorf("report files cannot be written: %w", b.writeErr)
|
||||
b.mu.Unlock()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
b.deleteOldestFiles(lineBytes)
|
||||
|
||||
if b.usedBytes+lineBytes > b.maxBytes {
|
||||
b.mu.Unlock()
|
||||
|
||||
return ErrFull
|
||||
}
|
||||
|
||||
b.usedBytes += lineBytes
|
||||
b.buf.Write(line)
|
||||
b.buf.WriteByte('\n')
|
||||
|
||||
if b.buf.Len() >= flushSizeThreshold &&
|
||||
b.thresholdWriteWaiting.CompareAndSwap(false, true) {
|
||||
b.writers.Go(b.flushAtThreshold)
|
||||
}
|
||||
|
||||
b.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LastWriteFailed reports whether the last write of a report file to
|
||||
// finish failed: false at start until a write fails, and false again
|
||||
// once a write succeeds. While it is true, Append refuses reports.
|
||||
func (b *Buffer) LastWriteFailed() bool {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
return b.writeErr != nil
|
||||
}
|
||||
|
||||
// deleteOldestFiles deletes report files, oldest first, until n more
|
||||
// bytes fit under maxBytes. It deletes none when the reports waiting
|
||||
// to be written leave no room for n even with every file gone, since
|
||||
// that would lose the files for nothing. The caller must hold b.mu.
|
||||
func (b *Buffer) deleteOldestFiles(n int64) {
|
||||
for b.usedBytes+n > b.maxBytes && len(b.files) > 0 {
|
||||
if b.usedBytes-b.filesBytes+n > b.maxBytes {
|
||||
return
|
||||
}
|
||||
|
||||
f := b.files[0]
|
||||
b.files = b.files[1:]
|
||||
b.filesBytes -= f.size
|
||||
|
||||
// A file already gone, deleted by hand, has freed its room too.
|
||||
err := os.Remove(filepath.Join(b.dataDir, f.name))
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
// The file is still there, so it still counts. It is
|
||||
// not tried again until the next start.
|
||||
b.log.Error("delete report file failed",
|
||||
"file", f.name, "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
b.usedBytes -= f.size
|
||||
b.log.Info("deleted report file to make room",
|
||||
"file", f.name, "bytes", f.size)
|
||||
}
|
||||
}
|
||||
|
||||
// flushLoop runs a ticker that periodically flushes buffered
|
||||
// data to disk until the done channel is closed.
|
||||
func (b *Buffer) flushLoop() {
|
||||
ticker := time.NewTicker(flushInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
err := b.flushLocked()
|
||||
if err != nil {
|
||||
b.log.Error("flush reports failed", "error", err)
|
||||
}
|
||||
case <-b.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flushLocked waits for any write of a report file under way, then
|
||||
// writes the reports waiting in the buffer to a new one.
|
||||
func (b *Buffer) flushLocked() error {
|
||||
b.writeMu.Lock()
|
||||
defer b.writeMu.Unlock()
|
||||
|
||||
return b.writeFile()
|
||||
}
|
||||
|
||||
// flushAtThreshold is the write Append starts when the buffer reaches
|
||||
// flushSizeThreshold. Once any write under way is done, it lets Append
|
||||
// start another and writes the reports waiting in the buffer.
|
||||
func (b *Buffer) flushAtThreshold() {
|
||||
b.writeMu.Lock()
|
||||
defer b.writeMu.Unlock()
|
||||
|
||||
b.thresholdWriteWaiting.Store(false)
|
||||
|
||||
err := b.writeFile()
|
||||
if err != nil {
|
||||
b.log.Error("flush reports failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// drainBuf copies the buffer contents and resets it.
|
||||
// The caller must hold b.mu.
|
||||
func (b *Buffer) drainBuf() []byte {
|
||||
data := make([]byte, b.buf.Len())
|
||||
copy(data, b.buf.Bytes())
|
||||
b.buf.Reset()
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// writeFile takes the reports waiting in the buffer, if any, and writes
|
||||
// them to a new timestamped zstd-compressed JSONL file in the data
|
||||
// directory. If the write fails, it puts the reports back in the
|
||||
// buffer. The caller must hold b.writeMu.
|
||||
func (b *Buffer) writeFile() error {
|
||||
b.mu.Lock()
|
||||
|
||||
if b.buf.Len() == 0 {
|
||||
b.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
data := b.drainBuf()
|
||||
b.mu.Unlock()
|
||||
|
||||
// The timestamp comes first, so the names sort by time; the number
|
||||
// after it tells apart files named in the same millisecond.
|
||||
ts := b.now().UTC().Format("2006-01-02T15-04-05.000Z")
|
||||
name := fmt.Sprintf("%s%s-%d%s", filePrefix, ts, b.seq.Add(1), fileSuffix)
|
||||
|
||||
size, err := b.createFile(filepath.Join(b.dataDir, name), data)
|
||||
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
b.writeErr = err
|
||||
|
||||
if err != nil {
|
||||
// The reports go back in front of any appended since, for the
|
||||
// next flush to write, and still count at their uncompressed
|
||||
// size.
|
||||
appended := bytes.Clone(b.buf.Bytes())
|
||||
b.buf.Reset()
|
||||
b.buf.Write(data)
|
||||
b.buf.Write(appended)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// The reports no longer wait to be written, so they stop counting
|
||||
// at their uncompressed size and count as the file, which from here
|
||||
// on may be deleted to make room.
|
||||
b.usedBytes -= int64(len(data))
|
||||
b.usedBytes += size
|
||||
b.filesBytes += size
|
||||
b.files = append(b.files, reportFile{name: name, size: size})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createFile creates the file at path holding data compressed with
|
||||
// zstd, and returns its size. If the write fails once the file is
|
||||
// created, it removes the file, so that a failed write leaves nothing
|
||||
// behind to take room.
|
||||
func (b *Buffer) createFile(path string, data []byte) (int64, error) {
|
||||
// path is built from the operator-supplied dataDir plus a
|
||||
// generated timestamp and number, so it carries no external input.
|
||||
f, err := os.OpenFile( //nolint:gosec // see comment above
|
||||
path,
|
||||
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
|
||||
filePerms,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create report file: %w", err)
|
||||
}
|
||||
|
||||
b.fileCreated(f)
|
||||
|
||||
size, err := writeCompressed(f, data)
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
|
||||
return 0, errors.Join(err, os.Remove(path))
|
||||
}
|
||||
|
||||
err = f.Close()
|
||||
if err != nil {
|
||||
err = fmt.Errorf("close report file: %w", err)
|
||||
|
||||
return 0, errors.Join(err, os.Remove(path))
|
||||
}
|
||||
|
||||
return size, nil
|
||||
}
|
||||
|
||||
// writeCompressed writes data to f compressed with zstd, and returns
|
||||
// the size of f.
|
||||
func writeCompressed(f *os.File, data []byte) (int64, error) {
|
||||
enc, err := zstd.NewWriter(f)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create zstd encoder: %w", err)
|
||||
}
|
||||
|
||||
_, err = enc.Write(data)
|
||||
if err != nil {
|
||||
_ = enc.Close()
|
||||
|
||||
return 0, fmt.Errorf("write compressed data: %w", err)
|
||||
}
|
||||
|
||||
err = enc.Close()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("close zstd encoder: %w", err)
|
||||
}
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("stat report file: %w", err)
|
||||
}
|
||||
|
||||
return info.Size(), nil
|
||||
}
|
||||
|
||||
// reportFiles returns the report files in dir, oldest first:
|
||||
// os.ReadDir sorts them by name, and the names sort by time.
|
||||
func reportFiles(dir string) ([]reportFile, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read data dir: %w", err)
|
||||
}
|
||||
|
||||
files := make([]reportFile, 0, len(entries))
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if !strings.HasPrefix(name, filePrefix) ||
|
||||
!strings.HasSuffix(name, fileSuffix) {
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat report file: %w", err)
|
||||
}
|
||||
|
||||
files = append(files, reportFile{name: name, size: info.Size()})
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
package server
|
||||
|
||||
import "github.com/go-chi/chi/v5"
|
||||
|
||||
// Router exposes the router to the external tests, which add routes
|
||||
// of their own to it after SetupRoutes.
|
||||
func (s *Server) Router() *chi.Mux {
|
||||
return s.router
|
||||
}
|
||||
|
||||
// MaxRequestBodyBytes exposes the router-wide body limit to the
|
||||
// external tests.
|
||||
const MaxRequestBodyBytes = maxRequestBodyBytes
|
||||
|
||||
// MetricsRequestsPerMinute exposes the /metrics rate limit to the
|
||||
// external tests.
|
||||
const MetricsRequestsPerMinute = metricsRequestsPerMinute
|
||||
|
||||
// ListenAddr exposes the address the server listens on to the
|
||||
// external tests.
|
||||
func (s *Server) ListenAddr() string {
|
||||
return s.newHTTPServer().Addr
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
const (
|
||||
readTimeout = 10 * time.Second
|
||||
readHeaderTimeout = 5 * time.Second
|
||||
idleTimeout = 60 * time.Second
|
||||
maxHeaderBytes = 1 << 20 // 1 MiB
|
||||
|
||||
// requestTimeout (routes.go) is the single per-request
|
||||
// processing budget, enforced by chi's middleware.Timeout.
|
||||
// writeTimeout must exceed that budget so a handler can write
|
||||
// its 503 when the chi timeout fires; if it were shorter the
|
||||
// server would abort the write first and the chi budget would
|
||||
// be unreachable dead configuration.
|
||||
writeTimeout = requestTimeout + 5*time.Second
|
||||
)
|
||||
|
||||
// newHTTPServer constructs the http.Server. It performs no I/O
|
||||
// and does not start listening.
|
||||
func (s *Server) newHTTPServer() *http.Server {
|
||||
listenAddr := net.JoinHostPort(
|
||||
s.params.Config.BindAddress,
|
||||
strconv.Itoa(s.params.Config.Port),
|
||||
)
|
||||
|
||||
return &http.Server{
|
||||
Addr: listenAddr,
|
||||
Handler: s,
|
||||
MaxHeaderBytes: maxHeaderBytes,
|
||||
ReadTimeout: readTimeout,
|
||||
ReadHeaderTimeout: readHeaderTimeout,
|
||||
WriteTimeout: writeTimeout,
|
||||
IdleTimeout: idleTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
// listenAndServe runs the listener until the server is shut
|
||||
// down. A genuine listen failure (not the expected
|
||||
// ErrServerClosed from a clean shutdown) requests process
|
||||
// shutdown through fx with a non-zero exit code, so the failure
|
||||
// is visible to any supervisor.
|
||||
func (s *Server) listenAndServe() {
|
||||
s.log.Info("http begin listen",
|
||||
"listenaddr", s.httpServer.Addr,
|
||||
"version", s.params.Globals.Version,
|
||||
"arch", runtime.GOARCH,
|
||||
)
|
||||
|
||||
err := s.httpServer.ListenAndServe()
|
||||
if err == nil || errors.Is(err, http.ErrServerClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
s.log.Error("listen error", "error", err)
|
||||
|
||||
shutdownErr := s.shutdowner.Shutdown(fx.ExitCode(1))
|
||||
if shutdownErr != nil {
|
||||
s.log.Error("request shutdown failed", "error", shutdownErr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package server_test
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestListenAddress checks that the server listens on BIND_ADDRESS
|
||||
// and PORT, and on port 8080 on every interface when neither is set.
|
||||
// The container image sets both, to keep the backend on loopback
|
||||
// behind nginx.
|
||||
func TestListenAddress(t *testing.T) {
|
||||
tests := []struct {
|
||||
bindAddress string
|
||||
port string
|
||||
want string
|
||||
}{
|
||||
{bindAddress: "", port: "", want: ":8080"},
|
||||
{bindAddress: "127.0.0.1", port: "8081", want: "127.0.0.1:8081"},
|
||||
{bindAddress: "::1", port: "8081", want: "[::1]:8081"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.want, func(t *testing.T) {
|
||||
// t.Setenv rules out t.Parallel.
|
||||
t.Setenv("BIND_ADDRESS", tt.bindAddress)
|
||||
t.Setenv("PORT", tt.port)
|
||||
|
||||
got := newServer(t).ListenAddr()
|
||||
if got != tt.want {
|
||||
t.Errorf("listen address = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
const (
|
||||
requestTimeout = 60 * time.Second
|
||||
|
||||
// maxRequestBodyBytes caps every request body. A route group
|
||||
// can mount s.mw.MaxBodyBytes with a smaller value to lower
|
||||
// its bound, but cannot raise it: this cap runs first.
|
||||
maxRequestBodyBytes int64 = 1 << 20 // 1 MiB
|
||||
|
||||
// metricsRequestsPerMinute is how many requests to /metrics each
|
||||
// client address may make a minute, whatever their credentials. A
|
||||
// scraper polling every 2 seconds sends half of it, which httprate
|
||||
// never refuses.
|
||||
metricsRequestsPerMinute = 60
|
||||
)
|
||||
|
||||
// SetupRoutes configures the chi router with middleware and
|
||||
// all application routes.
|
||||
func (s *Server) SetupRoutes() {
|
||||
s.router = chi.NewRouter()
|
||||
|
||||
s.router.Use(s.mw.Recoverer())
|
||||
s.router.Use(middleware.RequestID)
|
||||
s.router.Use(s.mw.Logging())
|
||||
// s.mw.Recoverer would write a panic's 500 only after the panic has
|
||||
// passed the request log, so it is written here, where the log sees it.
|
||||
s.router.Use(s.mw.Write500OnPanic())
|
||||
s.router.Use(s.mw.SecurityHeaders())
|
||||
s.router.Use(s.mw.CORS(s.params.Config.CORSAllowedOrigins))
|
||||
s.router.Use(s.mw.MaxBodyBytes(maxRequestBodyBytes))
|
||||
s.router.Use(middleware.Timeout(requestTimeout))
|
||||
|
||||
// Sentry reports a panic, then panics again, so that it still
|
||||
// reaches s.mw.Recoverer.
|
||||
if s.params.Config.SentryDSN != "" {
|
||||
s.router.Use(sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle)
|
||||
}
|
||||
|
||||
// The metrics go in a registry of this server's own, not in
|
||||
// Prometheus' default one, which takes them only once per process.
|
||||
registry := prometheus.NewRegistry()
|
||||
registry.MustRegister(
|
||||
collectors.NewGoCollector(),
|
||||
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
|
||||
)
|
||||
|
||||
// Requests are measured only once chi has matched them to one of
|
||||
// these routes, by path and method. The metrics are labelled with
|
||||
// both, which any client can make up, so measuring every request
|
||||
// would let clients add labels without bound. A Route here would
|
||||
// be matched by its path prefix alone, so each path is given in
|
||||
// full.
|
||||
s.router.Group(func(r chi.Router) {
|
||||
// config.New refuses one of the two credentials without the
|
||||
// other.
|
||||
if s.params.Config.MetricsUsername != "" {
|
||||
r.Use(s.mw.Metrics(registry))
|
||||
// Likewise inside the metrics.
|
||||
r.Use(s.mw.Write500OnPanic())
|
||||
}
|
||||
|
||||
r.Get("/.well-known/healthcheck", s.h.HandleHealthCheck())
|
||||
|
||||
r.With(s.mw.RateLimit(s.params.Config.ReportsPerMinute)).
|
||||
Post("/api/v1/reports", s.h.HandleReport())
|
||||
})
|
||||
|
||||
// The rate limit comes before the basic auth, so a client past it
|
||||
// gets 429 and its password is not checked.
|
||||
if s.params.Config.MetricsUsername != "" {
|
||||
s.router.With(
|
||||
s.mw.RateLimit(metricsRequestsPerMinute),
|
||||
s.mw.MetricsAuth(),
|
||||
).Get("/metrics", promhttp.HandlerFor(
|
||||
registry, promhttp.HandlerOpts{},
|
||||
).ServeHTTP)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/config"
|
||||
"sneak.berlin/go/netwatch/internal/globals"
|
||||
"sneak.berlin/go/netwatch/internal/handlers"
|
||||
"sneak.berlin/go/netwatch/internal/healthcheck"
|
||||
"sneak.berlin/go/netwatch/internal/logger"
|
||||
"sneak.berlin/go/netwatch/internal/middleware"
|
||||
"sneak.berlin/go/netwatch/internal/reportbuf"
|
||||
"sneak.berlin/go/netwatch/internal/server"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxtest"
|
||||
)
|
||||
|
||||
// newServer builds a Server from the same constructors as
|
||||
// cli.RunServer, configured from the environment. It is never started,
|
||||
// so nothing listens.
|
||||
func newServer(t *testing.T) *server.Server {
|
||||
t.Helper()
|
||||
|
||||
var srv *server.Server
|
||||
|
||||
app := fxtest.New(t,
|
||||
fx.Provide(
|
||||
config.New,
|
||||
globals.New,
|
||||
handlers.New,
|
||||
healthcheck.New,
|
||||
logger.New,
|
||||
middleware.New,
|
||||
reportbuf.New,
|
||||
server.New,
|
||||
),
|
||||
fx.Populate(&srv),
|
||||
)
|
||||
|
||||
err := app.Err()
|
||||
if err != nil {
|
||||
t.Fatalf("build server: %v", err)
|
||||
}
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
// TestReportsAreRateLimited checks that POST /api/v1/reports is
|
||||
// behind the per-address rate limit, set here to two a minute.
|
||||
func TestReportsAreRateLimited(t *testing.T) {
|
||||
t.Setenv("REPORTS_PER_MINUTE", "2")
|
||||
|
||||
srv := newServer(t)
|
||||
srv.SetupRoutes()
|
||||
|
||||
post := func() int {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodPost, "/api/v1/reports",
|
||||
strings.NewReader(`{"clientId":"c1","hosts":[]}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
srv.ServeHTTP(rec, req)
|
||||
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
for i := range 2 {
|
||||
if code := post(); code != http.StatusOK {
|
||||
t.Fatalf("report %d: status = %d, want %d",
|
||||
i+1, code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
if code := post(); code != http.StatusTooManyRequests {
|
||||
t.Fatalf("third report in a minute: status = %d, want %d",
|
||||
code, http.StatusTooManyRequests)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCORSAllowedOriginsReachTheRouter checks that an origin listed in
|
||||
// CORS_ALLOWED_ORIGINS is allowed by the router, not only when handed
|
||||
// to the CORS middleware directly.
|
||||
func TestCORSAllowedOriginsReachTheRouter(t *testing.T) {
|
||||
const origin = "https://netwatch.example:8443"
|
||||
|
||||
t.Setenv("CORS_ALLOWED_ORIGINS", origin)
|
||||
|
||||
srv := newServer(t)
|
||||
srv.SetupRoutes()
|
||||
|
||||
// The preflight a browser sends before it POSTs JSON from origin.
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodOptions, "/api/v1/reports", http.NoBody)
|
||||
req.Header.Set("Origin", origin)
|
||||
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
|
||||
req.Header.Set("Access-Control-Request-Headers", "content-type")
|
||||
srv.ServeHTTP(rec, req)
|
||||
|
||||
got := rec.Header().Get("Access-Control-Allow-Origin")
|
||||
if got != origin {
|
||||
t.Fatalf("Access-Control-Allow-Origin = %q, want %q", got, origin)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoMetricsWithoutCredentials: with neither metrics setting set,
|
||||
// there is no /metrics.
|
||||
func TestNoMetricsWithoutCredentials(t *testing.T) {
|
||||
t.Setenv("METRICS_USERNAME", "")
|
||||
t.Setenv("METRICS_PASSWORD", "")
|
||||
|
||||
srv := newServer(t)
|
||||
srv.SetupRoutes()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodGet, "/metrics", http.NoBody)
|
||||
srv.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetricsBehindBasicAuth: with both metrics settings set, /metrics
|
||||
// answers only with them as basic auth credentials, and shows a
|
||||
// request to a route but not one to a path no route has.
|
||||
func TestMetricsBehindBasicAuth(t *testing.T) {
|
||||
t.Setenv("METRICS_USERNAME", "prometheus")
|
||||
t.Setenv("METRICS_PASSWORD", "right")
|
||||
|
||||
srv := newServer(t)
|
||||
srv.SetupRoutes()
|
||||
|
||||
get := func(path, username, password string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodGet, path, http.NoBody)
|
||||
|
||||
if username != "" {
|
||||
req.SetBasicAuth(username, password)
|
||||
}
|
||||
|
||||
srv.ServeHTTP(rec, req)
|
||||
|
||||
return rec
|
||||
}
|
||||
|
||||
get("/.well-known/healthcheck", "", "")
|
||||
get("/api/v1/no-such-route", "", "")
|
||||
|
||||
for _, creds := range [][2]string{
|
||||
{"", ""},
|
||||
{"prometheus", "wrong"},
|
||||
{"someone", "right"},
|
||||
} {
|
||||
rec := get("/metrics", creds[0], creds[1])
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("credentials %q: status = %d, want %d",
|
||||
creds, rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
rec := get("/metrics", "prometheus", "right")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("right credentials: status = %d, want %d",
|
||||
rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, `handler="/.well-known/healthcheck"`) {
|
||||
t.Errorf("metrics show no health check request:\n%s", body)
|
||||
}
|
||||
|
||||
if strings.Contains(body, "no-such-route") {
|
||||
t.Errorf("metrics show a request to a path no route has:\n%s", body)
|
||||
}
|
||||
|
||||
if !strings.Contains(body, "go_goroutines") {
|
||||
t.Errorf("metrics show no Go runtime metrics:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetricsAreRateLimited: a client that has used up its /metrics
|
||||
// allowance on wrong passwords gets 429 even with the right one, which
|
||||
// is then not checked, while another client behind the same nginx
|
||||
// still gets in.
|
||||
func TestMetricsAreRateLimited(t *testing.T) {
|
||||
t.Setenv("METRICS_USERNAME", "prometheus")
|
||||
t.Setenv("METRICS_PASSWORD", "right")
|
||||
// As in the container: nginx connects from loopback and names the
|
||||
// client in X-Forwarded-For.
|
||||
t.Setenv("TRUSTED_PROXIES", "127.0.0.1/32")
|
||||
|
||||
srv := newServer(t)
|
||||
srv.SetupRoutes()
|
||||
|
||||
get := func(client, password string) int {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodGet, "/metrics", http.NoBody)
|
||||
req.RemoteAddr = "127.0.0.1:40000"
|
||||
req.Header.Set("X-Forwarded-For", client)
|
||||
req.SetBasicAuth("prometheus", password)
|
||||
srv.ServeHTTP(rec, req)
|
||||
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
for i := range server.MetricsRequestsPerMinute {
|
||||
if code := get("203.0.113.7", "wrong"); code != http.StatusUnauthorized {
|
||||
t.Fatalf("guess %d: status = %d, want %d",
|
||||
i+1, code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
if code := get("203.0.113.7", "right"); code != http.StatusTooManyRequests {
|
||||
t.Fatalf("right password past the limit: status = %d, want %d",
|
||||
code, http.StatusTooManyRequests)
|
||||
}
|
||||
|
||||
if code := get("203.0.113.8", "right"); code != http.StatusOK {
|
||||
t.Fatalf("another client: status = %d, want %d",
|
||||
code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetricsInTwoServers: two servers in one process can both have
|
||||
// metrics on.
|
||||
func TestMetricsInTwoServers(t *testing.T) {
|
||||
t.Setenv("METRICS_USERNAME", "prometheus")
|
||||
t.Setenv("METRICS_PASSWORD", "right")
|
||||
|
||||
for range 2 {
|
||||
newServer(t).SetupRoutes()
|
||||
}
|
||||
}
|
||||
|
||||
// TestSentry: with SENTRY_DSN empty there is no Sentry client. With it
|
||||
// pointing at a local server standing in for Sentry, a panic in a
|
||||
// handler reaches that server, and the request still gets the 500 from
|
||||
// the panic recovery.
|
||||
func TestSentry(t *testing.T) {
|
||||
const panicMessage = "handler panic for TestSentry"
|
||||
|
||||
// sentry.Init sets the client for the whole process; take it away
|
||||
// again so that no other test reports to Sentry.
|
||||
t.Cleanup(func() { sentry.CurrentHub().BindClient(nil) })
|
||||
|
||||
t.Setenv("SENTRY_DSN", "")
|
||||
newServer(t)
|
||||
|
||||
if sentry.CurrentHub().Client() != nil {
|
||||
t.Fatal("a Sentry client exists with SENTRY_DSN empty")
|
||||
}
|
||||
|
||||
// The body of the first request the stand-in for Sentry receives.
|
||||
received := make(chan string, 1)
|
||||
|
||||
sentryServer := httptest.NewServer(http.HandlerFunc(
|
||||
func(_ http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
|
||||
select {
|
||||
case received <- string(body):
|
||||
default:
|
||||
}
|
||||
},
|
||||
))
|
||||
defer sentryServer.Close()
|
||||
|
||||
t.Setenv("SENTRY_DSN",
|
||||
"http://key@"+sentryServer.Listener.Addr().String()+"/1")
|
||||
|
||||
srv := newServer(t)
|
||||
srv.SetupRoutes()
|
||||
srv.Router().Get("/panic", func(http.ResponseWriter, *http.Request) {
|
||||
panic(panicMessage)
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodGet, "/panic", http.NoBody)
|
||||
srv.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want %d",
|
||||
rec.Code, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// Sentry sends from a goroutine of its own.
|
||||
select {
|
||||
case body := <-received:
|
||||
if !strings.Contains(body, panicMessage) {
|
||||
t.Fatalf("the Sentry server received no report of the panic:\n%s",
|
||||
body)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("nothing reached the Sentry server")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthCheckRejectsOversizeBody sends the health check, which
|
||||
// never reads its body, a body one byte over the limit. Only the
|
||||
// router-wide body limit can reject it.
|
||||
func TestHealthCheckRejectsOversizeBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := newServer(t)
|
||||
srv.SetupRoutes()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodGet, "/.well-known/healthcheck",
|
||||
strings.NewReader(
|
||||
strings.Repeat("x", int(server.MaxRequestBodyBytes)+1),
|
||||
),
|
||||
)
|
||||
|
||||
srv.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status = %d, want %d",
|
||||
rec.Code, http.StatusRequestEntityTooLarge)
|
||||
}
|
||||
|
||||
if got := rec.Body.String(); got != "{\"status\":\"error\"}\n" {
|
||||
t.Errorf("body = %q, want %q", got, "{\"status\":\"error\"}\n")
|
||||
}
|
||||
|
||||
got := rec.Header().Get("Content-Type")
|
||||
if got != "application/json; charset=utf-8" {
|
||||
t.Errorf("Content-Type = %q, want a JSON content type", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Package server provides the HTTP server lifecycle,
|
||||
// including startup, routing, and graceful shutdown. The
|
||||
// process lifetime is owned by fx: shutdown is requested
|
||||
// through fx.Shutdowner so every component's OnStop hook runs
|
||||
// in dependency order.
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/config"
|
||||
"sneak.berlin/go/netwatch/internal/globals"
|
||||
"sneak.berlin/go/netwatch/internal/handlers"
|
||||
"sneak.berlin/go/netwatch/internal/logger"
|
||||
"sneak.berlin/go/netwatch/internal/middleware"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
// sentryFlushTimeout is how long shutdown waits for Sentry to send
|
||||
// what it still holds.
|
||||
const sentryFlushTimeout = 2 * time.Second
|
||||
|
||||
// Params defines the dependencies for Server.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Config *config.Config
|
||||
Globals *globals.Globals
|
||||
Handlers *handlers.Handlers
|
||||
Logger *logger.Logger
|
||||
Middleware *middleware.Middleware
|
||||
Shutdowner fx.Shutdowner
|
||||
}
|
||||
|
||||
// Server is the top-level HTTP server orchestrator.
|
||||
type Server struct {
|
||||
h *handlers.Handlers
|
||||
httpServer *http.Server
|
||||
log *slog.Logger
|
||||
mw *middleware.Middleware
|
||||
params Params
|
||||
router *chi.Mux
|
||||
shutdowner fx.Shutdowner
|
||||
}
|
||||
|
||||
// New creates a Server and registers lifecycle hooks for
|
||||
// starting and stopping it.
|
||||
func New(
|
||||
lc fx.Lifecycle,
|
||||
params Params,
|
||||
) (*Server, error) {
|
||||
s := new(Server)
|
||||
s.params = params
|
||||
s.mw = params.Middleware
|
||||
s.h = params.Handlers
|
||||
s.log = params.Logger.Get()
|
||||
s.shutdowner = params.Shutdowner
|
||||
|
||||
err := s.enableSentry()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
// Build the router and http.Server synchronously
|
||||
// here, before spawning the serving goroutine, so
|
||||
// httpServer is fully constructed by the time OnStop
|
||||
// (or an early signal) can read it. fx guarantees
|
||||
// OnStart returns before OnStop runs, so no
|
||||
// synchronization or nil check is needed at shutdown.
|
||||
s.SetupRoutes()
|
||||
s.httpServer = s.newHTTPServer()
|
||||
|
||||
go func() { s.listenAndServe() }()
|
||||
|
||||
return nil
|
||||
},
|
||||
OnStop: func(ctx context.Context) error {
|
||||
return s.shutdown(ctx)
|
||||
},
|
||||
})
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ServeHTTP delegates to the chi router.
|
||||
func (s *Server) ServeHTTP(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) {
|
||||
s.router.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// enableSentry sets Sentry up when SENTRY_DSN is set, so that
|
||||
// SetupRoutes can report panics to it. With SENTRY_DSN empty it does
|
||||
// nothing. A DSN Sentry refuses stops the start.
|
||||
func (s *Server) enableSentry() error {
|
||||
if s.params.Config.SentryDSN == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := sentry.Init(sentry.ClientOptions{
|
||||
Dsn: s.params.Config.SentryDSN,
|
||||
Release: s.params.Globals.Appname + "-" + s.params.Globals.Version,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("SENTRY_DSN: %w", err)
|
||||
}
|
||||
|
||||
s.log.Info("sentry error reporting activated")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// shutdown gracefully stops the HTTP server within the
|
||||
// deadline of the context fx provides for OnStop, then gives
|
||||
// Sentry, if set up, time to send what it still holds.
|
||||
func (s *Server) shutdown(ctx context.Context) error {
|
||||
err := s.httpServer.Shutdown(ctx)
|
||||
|
||||
if s.params.Config.SentryDSN != "" {
|
||||
sentry.Flush(sentryFlushTimeout)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
s.log.Error("server clean shutdown failed", "error", err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
s.log.Info("server stopped")
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user