// Command stewardd is Steward's daemon: the DHCP server, its REST API and // the web UI. It also has the commands that work on the database directly // (init, user, backup, restore, check). package main import ( "bufio" "context" "errors" "fmt" "io" "log/slog" "net" "os" "os/signal" "path/filepath" "sort" "strings" "syscall" "time" "github.com/spf13/cobra" "golang.org/x/term" "steward/internal/auth" "steward/internal/certs" "steward/internal/config" "steward/internal/dhcpd" "steward/internal/engine" "steward/internal/jarl" "steward/internal/model" "steward/internal/server" "steward/internal/store" "steward/internal/validate" "steward/internal/version" "steward/internal/web" ) var configPath string func main() { if err := rootCmd().Execute(); err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } } func rootCmd() *cobra.Command { root := &cobra.Command{ Use: "stewardd", Short: "Steward's DHCP server, API and web UI", SilenceUsage: true, SilenceErrors: true, Version: version.String(), } root.PersistentFlags().StringVarP(&configPath, "config", "c", config.DefaultPath, "configuration file") root.AddCommand(serveCmd(), initCmd(), userCmd(), checkCmd(), backupCmd(), restoreCmd(), configCmd(), versionCmd()) return root } func newLogger(cfg *config.Config, w io.Writer) *slog.Logger { var level slog.Level switch cfg.Log.Level { case "debug": level = slog.LevelDebug case "warn": level = slog.LevelWarn case "error": level = slog.LevelError } opts := &slog.HandlerOptions{Level: level} if cfg.Log.Format == "json" { return slog.New(slog.NewJSONHandler(w, opts)) } // The journal adds its own timestamps. if os.Getenv("JOURNAL_STREAM") != "" { opts.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr { if a.Key == slog.TimeKey && len(groups) == 0 { return slog.Attr{} } return a } } return slog.New(slog.NewTextHandler(w, opts)) } type app struct { cfg *config.Config log *slog.Logger store *store.Store } func openApp(allowMissing bool) (*app, error) { cfg, err := config.Load(configPath, allowMissing) if err != nil { return nil, fmt.Errorf("loading the configuration: %w", err) } log := newLogger(cfg, os.Stderr) if err := os.MkdirAll(filepath.Dir(cfg.Database), 0o750); err != nil { return nil, err } st, err := store.Open(cfg.Database) if err != nil { return nil, fmt.Errorf("opening the database %s: %w", cfg.Database, err) } return &app{cfg: cfg, log: log, store: st}, nil } // hostEnv describes this machine's interfaces for validation. func hostEnv(h *dhcpd.HostInterfaces) func() *validate.Env { return func() *validate.Env { all, err := h.All() if err != nil { return nil } return &validate.Env{Interfaces: all} } } func serveCmd() *cobra.Command { return &cobra.Command{ Use: "serve", Short: "Serve DHCP, the API and the web UI", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { a, err := openApp(true) if err != nil { return err } defer a.store.Close() release, err := lockDatabase(a.cfg.Database) if err != nil { return err } defer release() ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() a.log.Info("starting", "version", version.String(), "database", a.cfg.Database) if users, err := a.store.ListUsers(ctx); err == nil && len(users) == 0 { a.log.Warn("there are no users yet; create an administrator with 'stewardd init' (root can use 'steward' on the local socket meanwhile)") } eng, err := engine.New(ctx, a.store, a.log) if err != nil { return err } host := &dhcpd.HostInterfaces{} eng.Env = hostEnv(host) pinger := &dhcpd.ICMPPinger{Log: a.log} d := dhcpd.New(a.log, a.store, host, pinger) if err := d.LoadLeases(ctx); err != nil { return fmt.Errorf("reading the leases: %w", err) } d.SetConfig(eng.State()) eng.OnChange = d.SetConfig for _, p := range eng.Problems() { a.log.Warn("configuration problem: "+p.Message, "object", p.Object, "code", p.Code) } var acme *certs.Manager if a.cfg.TLS.ACME.Domain != "" { tok, err := a.cfg.Jarl.ReadToken() if err != nil { return err } jc, err := jarl.New(a.cfg.Jarl.Server, tok, a.cfg.Jarl.CACert, a.cfg.Jarl.Insecure) if err != nil { return err } acme = certs.New(ctx, certs.Config{ACME: a.cfg.TLS.ACME, Version: version.Short()}, jc, a.store, a.log) go acme.Run(ctx) } srv := server.New(server.Config{ Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile, TrustedProxies: a.cfg.Proxies(), SessionLifetime: time.Duration(a.cfg.Session.Lifetime), SessionIdle: time.Duration(a.cfg.Session.IdleTimeout), CookieSecure: a.cfg.Session.CookieSecure, Socket: a.cfg.Socket, PingProblem: pinger.Problem, ACME: acme, }, eng, d, host, a.log, web.FS()) go d.Run(ctx) hup := make(chan os.Signal, 1) signal.Notify(hup, syscall.SIGHUP) go func() { for range hup { sdNotify("RELOADING=1") srv.ReloadTLS() sdNotify("READY=1") } }() err = srv.Serve(ctx, func() { sdNotify("READY=1") }) sdNotify("STOPPING=1") a.log.Info("stopped") return err }, } } func sdNotify(state string) { sock := os.Getenv("NOTIFY_SOCKET") if sock == "" { return } addr := &net.UnixAddr{Name: sock, Net: "unixgram"} if strings.HasPrefix(sock, "@") { addr.Name = "\x00" + sock[1:] } c, err := net.DialUnix("unixgram", nil, addr) if err != nil { return } defer c.Close() c.Write([]byte(state)) } // ---- init and users func readPassword(prompt string, fromStdin bool) (string, error) { if fromStdin || !term.IsTerminal(int(os.Stdin.Fd())) { line, err := bufio.NewReader(os.Stdin).ReadString('\n') if err != nil && !errors.Is(err, io.EOF) { return "", err } return strings.TrimRight(line, "\r\n"), nil } fmt.Fprint(os.Stderr, prompt) b, err := term.ReadPassword(int(os.Stdin.Fd())) fmt.Fprintln(os.Stderr) if err != nil { return "", err } fmt.Fprint(os.Stderr, "Repeat: ") b2, err := term.ReadPassword(int(os.Stdin.Fd())) fmt.Fprintln(os.Stderr) if err != nil { return "", err } if string(b) != string(b2) { return "", errors.New("the passwords don't match") } return string(b), nil } // readLine reads one line a byte at a time, so nothing after it is // buffered away from the password prompt that follows. func readLine(r io.Reader) string { var b []byte one := make([]byte, 1) for { n, err := r.Read(one) if n == 1 { if one[0] == '\n' { break } b = append(b, one[0]) } if err != nil { break } } return strings.TrimRight(string(b), "\r") } func localPrincipal(u *model.User) *auth.Principal { return &auth.Principal{User: u, Method: "local", IP: "local"} } func audit(ctx context.Context, st *store.Store, p *auth.Principal, action, object string) { ev := &model.AuditEvent{Action: action, Object: object, Result: "success", Auth: p.Method, Username: p.Name()} if p.User != nil { ev.UserID = p.User.ID } st.AddAudit(ctx, ev) } func createToken(ctx context.Context, st *store.Store, u *model.User, name string) (string, error) { secret, pid, hash := auth.NewToken() if err := st.CreateToken(ctx, &model.Token{UserID: u.ID, Name: name, PublicID: pid, SecretHash: hash}); err != nil { return "", err } return secret, nil } func initCmd() *cobra.Command { var username string var stdin, withToken bool cmd := &cobra.Command{ Use: "init", Short: "Create the database and the first administrator", Long: `Creates Steward's database with a first administrator. Steward starts with no subnets: add them with 'steward subnet add', in the web UI, or import a Kea configuration with 'steward import kea'.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { a, err := openApp(true) if err != nil { return err } defer a.store.Close() ctx := context.Background() users, err := a.store.ListUsers(ctx) if err != nil { return err } if len(users) > 0 { return fmt.Errorf("Steward is already set up (%d users); add users with 'stewardd user add' or 'steward user add'", len(users)) } // Asked for when not given, on a terminal. if !cmd.Flags().Changed("user") && !stdin && term.IsTerminal(int(os.Stdin.Fd())) { fmt.Fprint(os.Stderr, "The first administrator's user name [admin]: ") if name := strings.TrimSpace(readLine(os.Stdin)); name != "" { username = name } } if err := server.ValidUsername(username); err != nil { return err } pw, err := readPassword("Password for "+username+": ", stdin) if err != nil { return err } hash, err := auth.HashPassword(pw) if err != nil { return err } u := &model.User{Username: username, PasswordHash: hash, Admin: true, Comment: "created by stewardd init"} if err := a.store.CreateUser(ctx, u); err != nil { return err } audit(ctx, a.store, localPrincipal(u), "system.init", "user "+u.Username) fmt.Printf("Created the administrator %q in %s.\n", u.Username, a.cfg.Database) if withToken { tok, err := createToken(ctx, a.store, u, "cli") if err != nil { return err } fmt.Printf("\nAPI token for the CLI (shown only once):\n\n %s\n", tok) } fmt.Println("\nNext steps:") fmt.Println(" 1. Start the daemon: sudo systemctl enable --now steward") fmt.Println(" 2. Add a subnet: sudo steward subnet add 10.0.10.0/24 --interface eth0 --pool 10.0.10.100-10.0.10.199 --option routers=10.0.10.1") fmt.Println(" or import Kea's setup: sudo steward import kea /etc/kea/kea-dhcp4.conf --leases /var/lib/kea/kea-leases4.csv") fmt.Printf(" 3. Open the web UI: http://%s/ (see listen in %s)\n", a.cfg.Listen[0], configPath) return nil }, } cmd.Flags().StringVarP(&username, "user", "u", "admin", "the administrator's name (best your own: each person should have their own account); asked for on a terminal") cmd.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input") cmd.Flags().BoolVar(&withToken, "token", false, "also make an API token for the CLI") return cmd } func userCmd() *cobra.Command { cmd := &cobra.Command{Use: "user", Short: "Manage users directly in the database (when the API can't be used)"} var admin, stdin bool add := &cobra.Command{ Use: "add NAME", Short: "Add a user", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { a, err := openApp(true) if err != nil { return err } defer a.store.Close() if err := server.ValidUsername(args[0]); err != nil { return err } pw, err := readPassword("Password for "+args[0]+": ", stdin) if err != nil { return err } hash, err := auth.HashPassword(pw) if err != nil { return err } u := &model.User{Username: args[0], PasswordHash: hash, Admin: admin} if err := a.store.CreateUser(context.Background(), u); err != nil { if errors.Is(err, store.ErrConflict) { return fmt.Errorf("user %s already exists", args[0]) } return err } audit(context.Background(), a.store, localPrincipal(&model.User{Username: "root"}), "user.create", "user "+u.Username) fmt.Printf("added user %s\n", u.Username) return nil }, } add.Flags().BoolVar(&admin, "admin", false, "an administrator") add.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input") cmd.AddCommand(add) pw := &cobra.Command{ Use: "password NAME", Short: "Set a user's password (and log them out)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { a, err := openApp(true) if err != nil { return err } defer a.store.Close() ctx := context.Background() u, err := a.store.GetUserByName(ctx, args[0]) if err != nil { return fmt.Errorf("no user %s", args[0]) } p, err := readPassword("New password for "+u.Username+": ", stdin) if err != nil { return err } if u.PasswordHash, err = auth.HashPassword(p); err != nil { return err } u.Disabled = false if err := a.store.UpdateUser(ctx, u); err != nil { return err } a.store.DeleteUserSessions(ctx, u.ID, "") audit(ctx, a.store, localPrincipal(&model.User{Username: "root"}), "user.password", "user "+u.Username) fmt.Printf("set the password of %s\n", u.Username) return nil }, } pw.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input") cmd.AddCommand(pw) cmd.AddCommand(&cobra.Command{ Use: "token NAME TOKEN-NAME", Short: "Make an API token for a user (printed once)", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { a, err := openApp(true) if err != nil { return err } defer a.store.Close() ctx := context.Background() u, err := a.store.GetUserByName(ctx, args[0]) if err != nil { return fmt.Errorf("no user %s", args[0]) } tok, err := createToken(ctx, a.store, u, args[1]) if err != nil { return err } audit(ctx, a.store, localPrincipal(&model.User{Username: "root"}), "token.create", "token "+u.Username+"/"+args[1]) fmt.Println(tok) return nil }, }) return cmd } func checkCmd() *cobra.Command { return &cobra.Command{ Use: "check", Short: "Check the configuration in the database against this machine", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { a, err := openApp(false) if err != nil { return err } defer a.store.Close() eng, err := engine.New(context.Background(), a.store, a.log) if err != nil { return err } eng.Env = hostEnv(&dhcpd.HostInterfaces{}) ps := eng.Problems() for _, p := range ps { fmt.Printf("%s: %s: %s [%s]\n", p.Severity, p.Object, p.Message, p.Code) } if validate.HasErrors(ps) { return errors.New("the configuration has errors") } if len(ps) == 0 { fmt.Println("ok: no problems") } return nil }, } } // ---- backups // backupName is a backup's file name; the time in it orders and prunes // backups. const backupPrefix, backupSuffix = "steward-", ".db" func backupCmd() *cobra.Command { return &cobra.Command{ Use: "backup [FILE]", Short: "Write a backup of the database (and delete old ones)", Long: `Writes a consistent copy of the database, also while stewardd runs. Without FILE it goes to backup.dir as steward-