Files
steward/cmd/stewardd/main.go
T
abrown38andClaude Opus 5.5 3c0808591f Steward 0.1.0 (in development): a DHCPv4 server with a web UI, API and CLI
Subnets served per interface, pools, reservations, options at three
levels, leases in SQLite saved before the ACK, ping and decline conflict
checks, validated changes with history and undo, users and tokens, an
audit log, a Kea importer, a sandboxed systemd unit and Debian packages.
Tested with real udhcpc and dhcpcd clients in Docker (scripts/e2e.sh).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:14:07 +01:00

615 lines
18 KiB
Go

// 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/config"
"steward/internal/dhcpd"
"steward/internal/engine"
"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)
}
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,
}, 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
}
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))
}
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)")
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-<time>.db, and backups older than
backup.retention are deleted (keeping at least backup.keep_at_least).`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configPath, true)
if err != nil {
return err
}
if _, err := os.Stat(cfg.Database); err != nil {
return fmt.Errorf("no database at %s yet (run 'stewardd init')", cfg.Database)
}
st, err := store.OpenExisting(cfg.Database)
if err != nil {
return err
}
defer st.Close()
dest := ""
if len(args) == 1 {
dest = args[0]
} else {
if err := os.MkdirAll(cfg.Backup.Dir, 0o700); err != nil {
return err
}
dest = filepath.Join(cfg.Backup.Dir, backupPrefix+time.Now().UTC().Format("20060102T150405Z")+backupSuffix)
}
tmp := dest + ".tmp"
os.Remove(tmp)
if err := st.Backup(context.Background(), tmp); err != nil {
return fmt.Errorf("writing the backup: %w", err)
}
if err := os.Rename(tmp, dest); err != nil {
return err
}
fmt.Println(dest)
if len(args) == 0 {
return prune(cfg)
}
return nil
},
}
}
func prune(cfg *config.Config) error {
entries, err := os.ReadDir(cfg.Backup.Dir)
if err != nil {
return err
}
type bk struct {
name string
t time.Time
}
var list []bk
for _, e := range entries {
n := e.Name()
if !strings.HasPrefix(n, backupPrefix) || !strings.HasSuffix(n, backupSuffix) {
continue
}
t, err := time.Parse("20060102T150405Z", strings.TrimSuffix(strings.TrimPrefix(n, backupPrefix), backupSuffix))
if err != nil {
continue
}
list = append(list, bk{n, t})
}
sort.Slice(list, func(i, j int) bool { return list[i].t.After(list[j].t) })
cut := time.Now().Add(-time.Duration(cfg.Backup.Retention))
for i, b := range list {
if i < cfg.Backup.KeepAtLeast || b.t.After(cut) {
continue
}
os.Remove(filepath.Join(cfg.Backup.Dir, b.name))
}
return nil
}
func restoreCmd() *cobra.Command {
var yes bool
cmd := &cobra.Command{
Use: "restore FILE",
Short: "Replace the database with a backup (stewardd must be stopped)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configPath, true)
if err != nil {
return err
}
release, err := lockDatabase(cfg.Database)
if err != nil {
return fmt.Errorf("%w; stop it first: systemctl stop steward", err)
}
defer release()
src, err := store.OpenExisting(args[0])
if err != nil {
return fmt.Errorf("%s isn't a database: %w", args[0], err)
}
v, err := src.Version(context.Background())
if err != nil {
src.Close()
return fmt.Errorf("%s isn't a Steward database: %w", args[0], err)
}
var ok string
if err := src.DB().QueryRow(`PRAGMA integrity_check`).Scan(&ok); err != nil || ok != "ok" {
src.Close()
return fmt.Errorf("%s fails SQLite's integrity check: %s %v", args[0], ok, err)
}
src.Close()
if v > store.SchemaVersion() {
return fmt.Errorf("the backup is from a newer Steward (schema %d, this one has %d)", v, store.SchemaVersion())
}
if !yes {
fmt.Fprintf(os.Stderr, "Replace %s with %s? [y/N] ", cfg.Database, args[0])
var a string
fmt.Scanln(&a)
if a != "y" && a != "yes" {
return errors.New("not restored")
}
}
stamp := time.Now().UTC().Format("20060102T150405Z")
for _, ext := range []string{"", "-wal", "-shm"} {
if _, err := os.Stat(cfg.Database + ext); err == nil {
if err := os.Rename(cfg.Database+ext, cfg.Database+".before-restore-"+stamp+ext); err != nil {
return err
}
}
}
b, err := os.ReadFile(args[0])
if err != nil {
return err
}
if err := os.WriteFile(cfg.Database, b, 0o640); err != nil {
return err
}
st, err := store.Open(cfg.Database)
if err != nil {
return err
}
defer st.Close()
audit(context.Background(), st, localPrincipal(&model.User{Username: "root"}), "system.restore", args[0])
fmt.Printf("restored %s; the old database is %s.before-restore-%s\n", args[0], cfg.Database, stamp)
return nil
},
}
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
return cmd
}
func configCmd() *cobra.Command {
var example bool
cmd := &cobra.Command{
Use: "config", Short: "Show the configuration in use (--example: a commented example)", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if example {
fmt.Print(config.Example)
return nil
}
cfg, err := config.Load(configPath, true)
if err != nil {
return err
}
fmt.Printf("listen: %s\nsocket: %s\ndatabase: %s\nbackups: %s\n", strings.Join(cfg.Listen, ", "), cfg.Socket, cfg.Database, cfg.Backup.Dir)
return nil
},
}
cmd.Flags().BoolVar(&example, "example", false, "print a commented example configuration")
return cmd
}
func versionCmd() *cobra.Command {
return &cobra.Command{
Use: "version", Short: "Print the version", Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) { fmt.Println("stewardd", version.String()) },
}
}