diff --git a/README.md b/README.md index 671154d..c9f5ab7 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,9 @@ stops. Its routes: It is an `fx`-wired Go application: `cmd/netwatch-server/` starts it, and its packages are in `internal/`: +- **`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 otherwise the server, with the `fx` wiring. - **`config`**: Loads configuration from environment variables and config files via Viper. - **`handlers`**: HTTP request handlers for the API (health check, report diff --git a/TODO.md b/TODO.md index d63ca15..7a6a4f1 100644 --- a/TODO.md +++ b/TODO.md @@ -21,6 +21,18 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of # Completed Steps +- 2026-10-19: `main()` in `cmd/netwatch-server/main.go` is one call into a new + package, `internal/cli`, as the Go style guide asks + ([#160](https://git.eeqj.de/sneak/netwatch/issues/160)). `internal/cli` runs + the `check-cidr` and `prepare-data-dir` subcommands, with the same arguments, + messages and exit codes, and otherwise the server, with the `fx` wiring. + `Appname` and `Version` stay in `main`, where the build sets `Version`, and + `main` gives `cli.Main` the function that runs the server, which the tests + replace with one that fails them. Unit tests call `cli.Main` with `check-cidr` + and a CIDR it takes and one it refuses, and with `prepare-data-dir` and a path + outside `/data`, and check the exit code. Others give each subcommand input it + takes and input it refuses, and check the exit code and what it writes to + stderr - 2026-10-19: report file writes no longer overlap ([#150](https://git.eeqj.de/sneak/netwatch/issues/150)), so the reports of failed writes are written in the order they arrived. Two writes that failed at diff --git a/cmd/netwatch-server/main.go b/cmd/netwatch-server/main.go index d9f3c17..21f7f77 100644 --- a/cmd/netwatch-server/main.go +++ b/cmd/netwatch-server/main.go @@ -1,22 +1,11 @@ -// Package main is the entry point for netwatch-server. +// Package main is the entry point for netwatch-server; package cli +// does the work. package main import ( - "fmt" "os" - "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" + "sneak.berlin/go/netwatch/internal/cli" ) //nolint:gochecknoglobals // set via ldflags at build time @@ -26,64 +15,5 @@ var ( ) func main() { - // "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(os.Args) == 3 && os.Args[1] == "check-cidr" { - _, err := middleware.ParseTrustedProxies(os.Args[2:]) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - - return - } - - // "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(os.Args) == 3 && os.Args[1] == "prepare-data-dir" { - netwatch, err := user.Lookup("netwatch") - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - - err = reportbuf.PrepareDataDir("/data", os.Args[2], netwatch) - if err != nil { - fmt.Fprintf(os.Stderr, "DATA_DIR '%s': %v\n", os.Args[2], err) - os.Exit(1) - } - - return - } - - 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() + os.Exit(cli.Main(Appname, Version, os.Args[1:], cli.RunServer)) } diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..2b8717d --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,128 @@ +// 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 otherwise the server, with the +// fx wiring. +package cli + +import ( + "fmt" + "io" + "os" + "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" +) + +// Main runs netwatch-server with args, its command line after the +// program's name, and returns the exit code. Unless args are +// "check-cidr CIDR" or "prepare-data-dir DATA_DIR", Main runs the +// server by calling serve with appname and version, the name and +// version the server logs and reports. main passes RunServer; the +// tests pass a function that fails them, so that no test starts the +// server. When serve is RunServer and the server fails to start or to +// stop, fx's Run exits the process with status 1 and Main never +// returns. +func Main( + 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(os.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(os.Stderr, "/data", args[1], "netwatch") + } + + 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 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() +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..bf2e905 --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,145 @@ +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 for a CIDR the +// server takes in its TRUSTED_PROXIES and 1 for one it refuses, and +// does not start the server. +func TestMainRunsCheckCIDR(t *testing.T) { + t.Parallel() + + for cidr, want := range map[string]int{"10.0.0.0/8": 0, "1.2.3/32": 1} { + code := cli.Main("netwatch", "test", []string{"check-cidr", cidr}, + failIfServerStarts(t)) + if code != want { + t.Errorf("check-cidr %q: exit %d, want %d", cidr, code, want) + } + } +} + +// TestMainRunsPrepareDataDir: Main given prepare-data-dir returns 1 for +// a DATA_DIR outside /data, and does not start the server. The test +// image has no netwatch user, so there the 1 comes from looking that +// user up; TestPrepareDataDirRefusesPathOutsideVolume checks the path. +func TestMainRunsPrepareDataDir(t *testing.T) { + t.Parallel() + + code := cli.Main("netwatch", "test", []string{"prepare-data-dir", "/etc"}, + failIfServerStarts(t)) + if code != 1 { + t.Errorf("prepare-data-dir /etc: exit %d, want 1", code) + } +} + +// 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") + } +} diff --git a/internal/cli/export_test.go b/internal/cli/export_test.go new file mode 100644 index 0000000..24522b6 --- /dev/null +++ b/internal/cli/export_test.go @@ -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) +} diff --git a/internal/globals/globals.go b/internal/globals/globals.go index c6a48a2..8920243 100644 --- a/internal/globals/globals.go +++ b/internal/globals/globals.go @@ -4,7 +4,7 @@ package globals import "go.uber.org/fx" -//nolint:gochecknoglobals // set from main before fx starts +//nolint:gochecknoglobals // set by cli.RunServer before fx starts var ( // Appname is the application name. Appname string