// 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 ( "context" "fmt" "io" "log/slog" "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/dig" "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, and returns serve's exit code. // 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. func Main( stderr io.Writer, appname, version string, args []string, serve func(appname, version string) int, ) 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 } return serve(appname, version) } // 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, and returns the // exit code. If the server fails to start, to listen or to stop, it // returns 1. A failed start logs one line with the error's cause, such // as the setting refused and why, as the server's last line. func RunServer(appname, version string) int { globals.Appname = appname globals.Version = version var log *logger.Logger app := fx.New( // fx logs each step of starting and stopping, errors included, // at debug level through the server's own logger: they show // only with DEBUG set, and off a terminal they are JSON like // every other line. fx.WithLogger(func(log *logger.Logger) fxevent.Logger { fxLog := &fxevent.SlogLogger{Logger: log.Get()} fxLog.UseLogLevel(slog.LevelDebug) fxLog.UseErrorLevel(slog.LevelDebug) return fxLog }), fx.Provide( config.New, globals.New, handlers.New, healthcheck.New, logger.New, middleware.New, reportbuf.New, server.New, ), // First, so that log is set for the error below even when a // setting stops the start. fx.Populate(&log), fx.Invoke( // Before the server, so that the name and version are // logged even when a setting stops the start. func(log *logger.Logger) { log.Identify() }, func(*server.Server) {}, ), ) startCtx, cancelStart := context.WithTimeout( context.Background(), app.StartTimeout()) defer cancelStart() err := app.Start(startCtx) if err != nil { // fx wraps a constructor's error in its own text; the cause // alone names the setting. log is nil only if fx failed // before it built the logger. cause := dig.RootCause(err) if log == nil { _, _ = fmt.Fprintln(os.Stderr, cause) } else { log.Get().Error("cannot start", "error", cause) } return 1 } // A signal, or the server asking to stop after a listen error. shutdown := <-app.Wait() stopCtx, cancelStop := context.WithTimeout( context.Background(), app.StopTimeout()) defer cancelStop() err = app.Stop(stopCtx) if err != nil { log.Get().Error("cannot stop cleanly", "error", err) return 1 } return shutdown.ExitCode }