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>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-14 12:14:07 +01:00
commit 3c0808591f
111 files changed
+17851

No files matched your search

+700
View File
@@ -0,0 +1,700 @@
package main
import (
"encoding/json"
"fmt"
"io"
"os"
"sort"
"strconv"
"strings"
"github.com/spf13/cobra"
"golang.org/x/term"
"steward/internal/api"
"steward/internal/model"
)
func statusCmd() *cobra.Command {
return &cobra.Command{
Use: "status", Short: "Show what Steward serves, and any problems", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var st api.Status
if err := c.Get(ctx(), "/status", nil, &st); err != nil {
return err
}
return output(st, func(w io.Writer) {
fmt.Fprintf(w, "Steward:\t%s\n", st.Build.String())
fmt.Fprintf(w, "Running since:\t%s\n", st.Started.Local().Format("2006-01-02 15:04"))
fmt.Fprintln(w, "Interfaces:")
if len(st.Interfaces) == 0 {
fmt.Fprintln(w, " none: add a subnet ('steward subnet add PREFIX --interface IFACE')")
}
for _, i := range st.Interfaces {
state := "serving DHCP"
if !i.Listening {
state = "NOT serving: " + i.Error
}
fmt.Fprintf(w, " %s\t%s\t%s\n", i.Name, strings.Join(i.Addresses, ", "), state)
}
fmt.Fprintln(w, "Subnets:")
for _, s := range st.Subnets {
d := ""
if s.Disabled {
d = " (disabled)"
}
fmt.Fprintf(w, " %s\t%s\t%s leased, %d reserved%s\n", s.Label(), s.Interface, usageText(s.Usage), s.Usage.Reservations, d)
}
var states []string
for k, v := range st.Leases {
states = append(states, fmt.Sprintf("%d %s", v, k))
}
sort.Strings(states)
fmt.Fprintf(w, "Leases:\t%s\n", orDash(strings.Join(states, ", ")))
var msgs []string
for _, t := range []string{"DHCPDISCOVER", "DHCPOFFER", "DHCPREQUEST", "DHCPACK", "DHCPNAK", "DHCPRELEASE", "DHCPDECLINE", "DHCPINFORM"} {
if n := st.Messages[t]; n > 0 {
msgs = append(msgs, fmt.Sprintf("%d %s", n, strings.TrimPrefix(t, "DHCP")))
}
}
fmt.Fprintf(w, "Messages:\t%s\n", orDash(strings.Join(msgs, ", ")))
if st.PingProblem != "" {
fmt.Fprintf(w, "Ping check:\t%s\n", st.PingProblem)
}
if len(st.Problems) > 0 {
fmt.Fprintln(w, "Problems:")
for _, p := range st.Problems {
fmt.Fprintf(w, " %s: %s: %s\n", p.Severity, p.Object, p.Message)
}
}
})
},
}
}
func historyCmd() *cobra.Command {
cmd := &cobra.Command{Use: "history", Short: "The history of configuration changes, and undo"}
var limit int
list := &cobra.Command{
Use: "list", Short: "List recent changes", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var hs []model.HistoryEntry
if err := c.Get(ctx(), "/history", q("limit", strconv.Itoa(limit)), &hs); err != nil {
return err
}
return output(hs, func(w io.Writer) {
fmt.Fprintln(w, "ID\tWHEN\tBY\tVIA\tCHANGES\tDESCRIPTION")
for _, h := range hs {
desc := h.Description
if h.RevertedBy != 0 {
desc += fmt.Sprintf(" (undone by %d)", h.RevertedBy)
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%s\n", h.ID, h.Time.Local().Format("2006-01-02 15:04"), h.Username, h.Via, len(h.Changes), desc)
}
})
},
}
list.Flags().IntVar(&limit, "limit", 30, "how many")
cmd.AddCommand(list)
cmd.AddCommand(&cobra.Command{
Use: "show ID", Short: "Show a change: each object before and after", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var h model.HistoryEntry
if err := c.Get(ctx(), "/history/"+args[0], nil, &h); err != nil {
return err
}
return output(h, func(w io.Writer) {
fmt.Fprintf(w, "%d\t%s by %s (%s): %s\n", h.ID, h.Time.Local().Format("2006-01-02 15:04:05"), h.Username, h.Via, h.Description)
for _, ch := range h.Changes {
fmt.Fprintf(w, "\n%s %s %s\n", ch.Action(), ch.Kind, ch.Label)
for _, line := range diffJSON(ch.Before, ch.After) {
fmt.Fprintln(w, " "+line)
}
}
})
},
})
cmd.AddCommand(&cobra.Command{
Use: "undo ID",
Short: "Undo a change",
Long: `Undoes a change by applying it backwards. It is refused when an object the
change touched has changed since; undo the later changes first.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var resp api.MutationResponse
if err := c.Do(ctx(), "POST", "/history/"+args[0]+"/revert", nil, nil, &resp); err != nil {
return err
}
return report(&resp)
},
})
return cmd
}
// diffJSON lists the fields that differ between two versions of an
// object: "name: office -> lab".
func diffJSON(before, after json.RawMessage) []string {
var a, b map[string]any
json.Unmarshal(before, &a)
json.Unmarshal(after, &b)
keys := map[string]bool{}
for k := range a {
keys[k] = true
}
for k := range b {
keys[k] = true
}
var names []string
for k := range keys {
names = append(names, k)
}
sort.Strings(names)
var out []string
show := func(v any, ok bool) string {
if !ok {
return "-"
}
x, _ := json.Marshal(v)
return string(x)
}
for _, k := range names {
va, oka := a[k]
vb, okb := b[k]
sa, sb := show(va, oka), show(vb, okb)
if sa == sb {
continue
}
switch {
case before == nil:
out = append(out, k+": "+sb)
case after == nil:
out = append(out, k+": "+sa)
default:
out = append(out, k+": "+sa+" -> "+sb)
}
}
return out
}
func whoamiCmd() *cobra.Command {
return &cobra.Command{
Use: "whoami", Short: "Show who you are to the server", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var me api.Whoami
if err := c.Get(ctx(), "/auth/whoami", nil, &me); err != nil {
return err
}
return output(me, func(w io.Writer) {
fmt.Fprintf(w, "User:\t%s (%s)\n", me.User.Username, me.Method)
fmt.Fprintf(w, "Administrator:\t%v\n", me.Admin)
fmt.Fprintf(w, "May:\t%s\n", strings.Join(me.Permissions, ", "))
fmt.Fprintf(w, "Server:\tSteward %s\n", me.Build.String())
})
},
}
}
func readSecret(prompt string) (string, error) {
fmt.Fprint(os.Stderr, prompt)
if term.IsTerminal(int(os.Stdin.Fd())) {
b, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
return strings.TrimSpace(string(b)), err
}
var s string
_, err := fmt.Fscanln(os.Stdin, &s)
return strings.TrimSpace(s), err
}
func userID(name string) (int64, error) {
c, err := newClient()
if err != nil {
return 0, err
}
var us []api.User
if err := c.Get(ctx(), "/users", nil, &us); err != nil {
return 0, err
}
for _, u := range us {
if strings.EqualFold(u.Username, name) || strconv.FormatInt(u.ID, 10) == name {
return u.ID, nil
}
}
return 0, fmt.Errorf("no user %s", name)
}
func userCmd() *cobra.Command {
cmd := &cobra.Command{Use: "user", Aliases: []string{"users"}, Short: "Users (administrators only)"}
cmd.AddCommand(&cobra.Command{
Use: "list", Short: "List users", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var us []api.User
if err := c.Get(ctx(), "/users", nil, &us); err != nil {
return err
}
return output(us, func(w io.Writer) {
fmt.Fprintln(w, "ID\tUSER\tADMIN\tGRANTS\tPASSWORD\tDISABLED\tLAST LOGIN")
for _, u := range us {
login := "-"
if u.LastLoginAt != nil {
login = fmtWhen(*u.LastLoginAt)
}
fmt.Fprintf(w, "%d\t%s\t%v\t%s\t%v\t%v\t%s\n", u.ID, u.Username, u.Admin, orDash(strings.Join(u.GrantStrings, " ")), u.HasPassword, u.Disabled, login)
}
})
},
})
var admin, noPassword bool
var role, comment string
var grants []string
add := &cobra.Command{
Use: "add USER",
Short: "Add a user (asks for the password)",
Long: `Adds a user. Without --admin, give what they may do: a role (viewer,
operator, leases) or grants such as --grant config:read --grant leases:write.
--no-password makes a service account that only uses API tokens.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
in := api.UserInput{Username: &args[0], Admin: &admin}
if !noPassword {
pw, err := readSecret("Password for " + args[0] + ": ")
if err != nil {
return err
}
in.Password = &pw
}
if role != "" {
in.Role = &role
}
if grants != nil {
in.Grants = &grants
}
if comment != "" {
in.Comment = &comment
}
var u api.User
if err := c.Do(ctx(), "POST", "/users", nil, in, &u); err != nil {
return err
}
if !flagQuiet {
fmt.Printf("added user %s (%d)\n", u.Username, u.ID)
}
return nil
},
}
f := add.Flags()
f.BoolVar(&admin, "admin", false, "an administrator (may do everything)")
f.StringVar(&role, "role", "", "a role: viewer, operator or leases")
f.StringArrayVar(&grants, "grant", nil, "a grant, resource:access (config, leases, audit; read or write)")
f.BoolVar(&noPassword, "no-password", false, "no password: API tokens only")
f.StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(add)
var setPassword, disable, enable bool
update := &cobra.Command{
Use: "update USER", Short: "Change a user", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id, err := userID(args[0])
if err != nil {
return err
}
c, _ := newClient()
in := api.UserInput{}
fl := cmd.Flags()
if fl.Changed("admin") {
in.Admin = &admin
}
if fl.Changed("role") {
in.Role = &role
}
if fl.Changed("grant") {
in.Grants = &grants
}
if fl.Changed("comment") {
in.Comment = &comment
}
if disable || enable {
in.Disabled = &disable
}
if setPassword {
pw, err := readSecret("New password for " + args[0] + ": ")
if err != nil {
return err
}
in.Password = &pw
}
var u api.User
if err := c.Do(ctx(), "PATCH", "/users/"+strconv.FormatInt(id, 10), nil, in, &u); err != nil {
return err
}
if !flagQuiet {
fmt.Printf("updated user %s\n", u.Username)
}
return nil
},
}
f = update.Flags()
f.BoolVar(&admin, "admin", false, "make (or with --admin=false, unmake) an administrator")
f.StringVar(&role, "role", "", "replace the grants with a role's")
f.StringArrayVar(&grants, "grant", nil, "replace the grants (repeat for several)")
f.BoolVar(&setPassword, "password", false, "set a new password (asks for it)")
f.BoolVar(&disable, "disable", false, "disable the user (logs them out)")
f.BoolVar(&enable, "enable", false, "enable the user again")
f.StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(update)
cmd.AddCommand(&cobra.Command{
Use: "delete USER", Short: "Delete a user and their tokens", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id, err := userID(args[0])
if err != nil {
return err
}
c, _ := newClient()
if err := c.Do(ctx(), "DELETE", "/users/"+strconv.FormatInt(id, 10), nil, nil, nil); err != nil {
return err
}
if !flagQuiet {
fmt.Println("deleted user", args[0])
}
return nil
},
})
return cmd
}
func tokenCmd() *cobra.Command {
cmd := &cobra.Command{Use: "token", Aliases: []string{"tokens"}, Short: "API tokens"}
var all bool
var user string
list := &cobra.Command{
Use: "list", Short: "List your tokens (--all: everyone's, for administrators)", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
path := "/me/tokens"
if all {
path = "/tokens"
}
var ts []api.Token
if err := c.Get(ctx(), path, nil, &ts); err != nil {
return err
}
return output(ts, func(w io.Writer) {
fmt.Fprintln(w, "ID\tUSER\tNAME\tGRANTS\tEXPIRES\tLAST USED")
for _, t := range ts {
exp, used := "never", "never"
if t.ExpiresAt != nil {
exp = fmtWhen(*t.ExpiresAt)
}
if t.LastUsedAt != nil {
used = fmtWhen(*t.LastUsedAt) + " from " + t.LastUsedIP
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", t.ID, t.Username, t.Name, strings.Join(t.GrantStrings, " "), exp, used)
}
})
},
}
list.Flags().BoolVar(&all, "all", false, "everyone's tokens")
cmd.AddCommand(list)
var role, expires string
var grants []string
var save bool
create := &cobra.Command{
Use: "create NAME",
Short: "Make an API token (the secret is shown once)",
Long: `Makes an API token. It has all of its user's permissions unless --role or
--grant narrows it. On the server as root, --user names whose token it is.
--save puts it in the CLI's configuration.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
path := "/me/tokens"
if user != "" {
id, err := userID(user)
if err != nil {
return err
}
path = "/users/" + strconv.FormatInt(id, 10) + "/tokens"
}
in := api.TokenInput{Name: args[0], Role: role, Grants: grants, ExpiresIn: expires}
var t api.Token
if err := c.Do(ctx(), "POST", path, nil, in, &t); err != nil {
return err
}
if save {
cfg, err := loadCLIConfig()
if err != nil {
return err
}
cfg.Token = t.Secret
if err := saveCLIConfig(cfg); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "saved in", flagConfig)
}
if flagOutput == "json" || flagOutput == "yaml" {
return output(t, nil)
}
fmt.Println(t.Secret)
if !flagQuiet {
fmt.Fprintln(os.Stderr, "this is the only time the token is shown; keep it safe")
}
return nil
},
}
f := create.Flags()
f.StringVar(&role, "role", "", "narrow it to a role: viewer, operator or leases")
f.StringArrayVar(&grants, "grant", nil, "narrow it to grants (resource:access)")
f.StringVar(&expires, "expires", "", "expire after this long (90d, 720h)")
f.StringVar(&user, "user", "", "the user it belongs to (administrators)")
f.BoolVar(&save, "save", false, "save it as this CLI's token")
cmd.AddCommand(create)
cmd.AddCommand(&cobra.Command{
Use: "revoke ID", Short: "Revoke a token", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
if err := c.Do(ctx(), "DELETE", "/tokens/"+args[0], nil, nil, nil); err != nil {
return err
}
if !flagQuiet {
fmt.Println("revoked token", args[0])
}
return nil
},
})
return cmd
}
func passwordCmd() *cobra.Command {
return &cobra.Command{
Use: "password", Short: "Change your own password", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
cur, err := readSecret("Current password: ")
if err != nil {
return err
}
pw, err := readSecret("New password: ")
if err != nil {
return err
}
if err := c.Do(ctx(), "POST", "/me/password", nil, api.PasswordChange{Current: cur, Password: pw}, nil); err != nil {
return err
}
fmt.Println("password changed")
return nil
},
}
}
func auditCmd() *cobra.Command {
var user, action, search string
var limit int
cmd := &cobra.Command{
Use: "audit", Short: "Show the audit log: logins, users, tokens, changes, lease deletions", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var evs []model.AuditEvent
if err := c.Get(ctx(), "/audit", q("user", user, "action", action, "q", search, "limit", strconv.Itoa(limit)), &evs); err != nil {
return err
}
return output(evs, func(w io.Writer) {
fmt.Fprintln(w, "WHEN\tUSER\tFROM\tACTION\tOBJECT\tRESULT\tDETAIL")
for _, e := range evs {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", e.Time.Local().Format("2006-01-02 15:04:05"), orDash(e.Username), orDash(e.IP), e.Action, orDash(e.Object), e.Result, e.Detail)
}
})
},
}
f := cmd.Flags()
f.StringVar(&user, "user", "", "only this user's")
f.StringVar(&action, "action", "", "only actions starting with this (auth., lease.delete)")
f.StringVar(&search, "search", "", "only events whose object or detail contains this")
f.IntVar(&limit, "limit", 100, "how many")
return cmd
}
func configCmd() *cobra.Command {
cmd := &cobra.Command{Use: "config", Short: "This CLI's settings: the server and token"}
cmd.AddCommand(&cobra.Command{
Use: "show", Short: "Show the CLI's configuration", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCLIConfig()
if err != nil {
return err
}
tok := "-"
if cfg.Token != "" {
tok = cfg.Token[:min(len(cfg.Token), 24)] + "…"
}
fmt.Printf("file: %s\nserver: %s\ntoken: %s\ninsecure: %v\nca_cert: %s\n", flagConfig, orDash(cfg.Server)+" (empty: the local socket)", tok, cfg.Insecure, orDash(cfg.CACert))
return nil
},
})
cmd.AddCommand(&cobra.Command{
Use: "set KEY [VALUE]",
Short: "Set server, token (asks when no value), insecure or ca_cert",
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCLIConfig()
if err != nil {
return err
}
val := ""
if len(args) == 2 {
val = args[1]
} else if args[0] == "token" {
if val, err = readSecret("API token: "); err != nil {
return err
}
}
switch args[0] {
case "server":
cfg.Server = val
case "token":
cfg.Token = val
case "insecure":
cfg.Insecure = val == "true" || val == "yes" || val == "on"
case "ca_cert", "ca-cert":
cfg.CACert = val
default:
return fmt.Errorf("unknown setting %q (use server, token, insecure or ca_cert)", args[0])
}
if err := saveCLIConfig(cfg); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "saved in", flagConfig)
return nil
},
})
return cmd
}
func importCmd() *cobra.Command {
cmd := &cobra.Command{Use: "import", Short: "Import another DHCP server's configuration"}
var leases, ifaces []string
var replace bool
kea := &cobra.Command{
Use: "kea CONFIG",
Short: "Import a Kea configuration and its leases",
Long: `Imports a kea-dhcp4.conf: settings, subnets (with pools and options),
shared networks and reservations; and with --leases, Kea's current leases,
so clients keep their addresses. What Steward doesn't support is listed.
Every subnet needs its interface. Kea's own setting is used, else the
interface on this server with an address in the subnet, else the only
interface Kea listened on; give others with --interface PREFIX=IFACE.
steward import kea /etc/kea/kea-dhcp4.conf --leases /var/lib/kea/kea-leases4.csv -n
steward import kea /etc/kea/kea-dhcp4.conf --leases /var/lib/kea/kea-leases4.csv.2 \
--leases /var/lib/kea/kea-leases4.csv
Stop Kea before Steward starts serving: they can't share port 67.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
b, err := os.ReadFile(args[0])
if err != nil {
return err
}
body := map[string]any{"config": string(b), "replace": replace}
var texts []string
for _, l := range leases {
lb, err := os.ReadFile(l)
if err != nil {
return err
}
texts = append(texts, string(lb))
}
if len(texts) > 0 {
body["leases"] = texts
}
if len(ifaces) > 0 {
m := map[string]string{}
for _, i := range ifaces {
p, name, ok := strings.Cut(i, "=")
if !ok {
return fmt.Errorf("--interface %q: write it as PREFIX=IFACE, e.g. 10.0.20.0/24=eth0.20", i)
}
m[strings.TrimSpace(p)] = strings.TrimSpace(name)
}
body["interfaces"] = m
}
var res api.ImportResult
if err := c.Do(ctx(), "POST", "/import/kea", nil, body, &res); err != nil {
return err
}
if flagOutput == "json" || flagOutput == "yaml" {
return output(res, nil)
}
for _, n := range res.Notes {
fmt.Println("note:", n)
}
for _, s := range res.Skipped {
fmt.Println("not imported:", s)
}
if err := report(&res.MutationResponse); err != nil {
return err
}
switch {
case res.DryRun:
fmt.Printf("would import %d leases\n", res.Leases)
case res.Applied:
fmt.Printf("imported %d leases\n", res.Leases)
}
return nil
},
}
f := kea.Flags()
f.StringArrayVar(&leases, "leases", nil, "a Kea lease file (kea-leases4.csv); give older ones first")
f.StringArrayVar(&ifaces, "interface", nil, "a subnet's interface: PREFIX=IFACE")
f.BoolVar(&replace, "replace", false, "replace Steward's configuration if it has one")
cmd.AddCommand(kea)
return cmd
}
+404
View File
@@ -0,0 +1,404 @@
package main
import (
"fmt"
"io"
"os"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"steward/internal/api"
"steward/internal/model"
)
func leaseHost(l api.Lease) string {
switch {
case l.ReservedHostname != "" && l.Hostname != "" && l.ReservedHostname != l.Hostname:
return l.ReservedHostname + " (says " + l.Hostname + ")"
case l.ReservedHostname != "":
return l.ReservedHostname
}
return orDash(l.Hostname)
}
func leaseCmd() *cobra.Command {
cmd := &cobra.Command{Use: "lease", Aliases: []string{"leases"}, Short: "Leases: the addresses handed out"}
var subnet, state, iface, search string
var all bool
list := &cobra.Command{
Use: "list",
Short: "List leases (active and declined ones; --all for every one)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var ls []api.Lease
if err := c.Get(ctx(), "/leases", q("subnet", subnet, "state", state, "interface", iface, "q", search), &ls); err != nil {
return err
}
if !all && state == "" {
keep := ls[:0]
for _, l := range ls {
if l.State == model.LeaseActive || l.State == model.LeaseDeclined {
keep = append(keep, l)
}
}
ls = keep
}
return output(ls, func(w io.Writer) {
fmt.Fprintln(w, "IP\tMAC\tHOSTNAME\tSTATE\tENDS\tINTERFACE\tSUBNET")
for _, l := range ls {
st := l.State
if l.Reservation != 0 {
st += ", reserved"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", l.IP, orDash(l.MAC), leaseHost(l), st, fmtWhen(l.End), l.Interface, orDash(l.SubnetName))
}
})
},
}
f := list.Flags()
f.StringVar(&subnet, "subnet", "", "only this subnet's (ID, name or prefix)")
f.StringVar(&state, "state", "", "only leases in this state: active, expired, released or declined")
f.StringVar(&iface, "interface", "", "only leases of clients on this interface")
f.StringVar(&search, "search", "", "only leases whose address, MAC address or host name contains this")
f.BoolVarP(&all, "all", "a", false, "also expired and released leases (kept a while so returning clients get their old address)")
cmd.AddCommand(list)
cmd.AddCommand(&cobra.Command{
Use: "show IP", Short: "Show the lease of an address", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var l api.Lease
if err := c.Get(ctx(), "/leases/"+args[0], nil, &l); err != nil {
return err
}
return output(l, func(w io.Writer) {
fmt.Fprintf(w, "Address:\t%s (subnet %s)\n", l.IP, orDash(l.SubnetName))
fmt.Fprintf(w, "State:\t%s\n", l.State)
fmt.Fprintf(w, "MAC address:\t%s\n", orDash(l.MAC))
if l.ClientID != "" {
fmt.Fprintf(w, "Client ID:\t%s\n", l.ClientID)
}
fmt.Fprintf(w, "Host name:\t%s\n", leaseHost(l))
fmt.Fprintf(w, "Since:\t%s\n", l.Start.Local().Format("2006-01-02 15:04:05"))
fmt.Fprintf(w, "Ends:\t%s (%s)\n", l.End.Local().Format("2006-01-02 15:04:05"), fmtWhen(l.End))
fmt.Fprintf(w, "Interface:\t%s\n", l.Interface)
if l.Reservation != 0 {
fmt.Fprintf(w, "Reservation:\t%d\n", l.Reservation)
}
})
},
})
var yes bool
del := &cobra.Command{
Use: "delete IP",
Short: "Forget a lease, freeing the address",
Long: `Forgets a lease: the address is free at once. A client still using it
gets a NAK when it next renews, and then asks for a new address.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !confirm("Delete the lease of "+args[0]+"?", yes) {
return fmt.Errorf("not deleted")
}
c, err := newClient()
if err != nil {
return err
}
if err := c.Do(ctx(), "DELETE", "/leases/"+args[0], nil, nil, nil); err != nil {
return err
}
if !flagQuiet {
fmt.Println("deleted the lease of", args[0])
}
return nil
},
}
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
cmd.AddCommand(del)
var hostname, comment string
reserve := &cobra.Command{
Use: "reserve IP",
Short: "Make a lease's address its client's reservation",
Long: `Makes the address a client has now its reservation: it keeps it from
then on. The host name the client sent becomes the reservation's, unless
--hostname gives another.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
body := map[string]any{}
if cmd.Flags().Changed("hostname") {
body["hostname"] = hostname
}
if comment != "" {
body["comment"] = comment
}
var resp api.MutationResponse
if err := c.Do(ctx(), "POST", "/leases/"+args[0]+"/reserve", nil, body, &resp); err != nil {
return err
}
return report(&resp)
},
}
reserve.Flags().StringVar(&hostname, "hostname", "", "the reservation's host name")
reserve.Flags().StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(reserve)
return cmd
}
func activityCmd() *cobra.Command {
var mac, iface string
var limit int
var follow bool
cmd := &cobra.Command{
Use: "activity",
Short: "Show recent DHCP messages (requests and answers)",
Long: `Shows the latest DHCP messages Steward received and sent, newest last.
-f keeps watching. Handy to see why a device doesn't get an address:
steward activity --mac aa:bb:cc:dd:ee:01 -f`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var last time.Time
tw := func(evs []api.Event) {
sort.Slice(evs, func(i, j int) bool { return evs[i].Time.Before(evs[j].Time) })
for _, e := range evs {
if !e.Time.After(last) {
continue
}
last = e.Time
detail := ""
if e.Hostname != "" {
detail = " " + e.Hostname
}
if e.Detail != "" {
detail += " (" + e.Detail + ")"
}
fmt.Printf("%s %-8s %-13s %-17s %-15s%s\n", e.Time.Local().Format("15:04:05.000"), e.Interface, e.Type, orDash(e.MAC), orDash(e.IP), detail)
}
}
for {
var evs []api.Event
if err := c.Get(ctx(), "/events", q("mac", mac, "interface", iface, "limit", fmt.Sprint(limit)), &evs); err != nil {
return err
}
if flagOutput == "json" || flagOutput == "yaml" {
return output(evs, nil)
}
tw(evs)
if !follow {
if len(evs) == 0 {
fmt.Fprintln(os.Stderr, "no DHCP messages yet")
}
return nil
}
time.Sleep(2 * time.Second)
}
},
}
f := cmd.Flags()
f.StringVar(&mac, "mac", "", "only this client's")
f.StringVar(&iface, "interface", "", "only this interface's")
f.IntVar(&limit, "limit", 50, "at most this many")
f.BoolVarP(&follow, "follow", "f", false, "keep watching")
return cmd
}
func settingsCmd() *cobra.Command {
cmd := &cobra.Command{Use: "settings", Short: "Server-wide settings and the options every subnet gets"}
cmd.AddCommand(&cobra.Command{
Use: "show", Short: "Show the settings", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var s model.Settings
if err := c.Get(ctx(), "/settings", nil, &s); err != nil {
return err
}
return output(s, func(w io.Writer) {
fmt.Fprintf(w, "Lease time:\t%s\n", fmtSeconds(s.EffectiveLeaseTime()))
fmt.Fprintln(w, "Options:\t"+optionsText(s.Options))
ping := "off"
if s.PingCheck {
ping = "on, waiting " + s.EffectivePingTimeout().String()
}
fmt.Fprintf(w, "Ping check:\t%s\n", ping)
fmt.Fprintf(w, "Decline hold:\t%s\n", fmtSeconds(int(s.EffectiveDeclineHold()/time.Second)))
auth := "yes (requests for addresses that don't belong on the interface get a NAK)"
if s.NotAuthoritative {
auth = "no (such requests get no answer)"
}
fmt.Fprintf(w, "Authoritative:\t%s\n", auth)
})
},
})
var leaseTime, ping, pingTimeout, hold, authoritative string
var opts, unsetOpts []string
set := &cobra.Command{
Use: "set",
Short: "Change settings (only what is given changes)",
Long: `Changes the settings. Options set here go to every subnet; a subnet's or a
reservation's own option of the same name wins.
steward settings set --option domain-name-servers=10.0.10.2 --option domain-name=lan
steward settings set --lease-time 1d --ping-check off`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
in := api.SettingsInput{}
fl := cmd.Flags()
if fl.Changed("lease-time") {
n, err := parseSeconds(leaseTime)
if err != nil {
return err
}
in.LeaseTime = &n
}
onOff := func(name, v string) (*bool, error) {
switch strings.ToLower(v) {
case "on", "yes", "true":
t := true
return &t, nil
case "off", "no", "false":
f := false
return &f, nil
}
return nil, fmt.Errorf("--%s: use on or off", name)
}
if fl.Changed("ping-check") {
if in.PingCheck, err = onOff("ping-check", ping); err != nil {
return err
}
}
if fl.Changed("ping-timeout") {
d, err := time.ParseDuration(pingTimeout)
if err != nil {
return fmt.Errorf("--ping-timeout: %v (use e.g. 500ms)", err)
}
ms := int(d / time.Millisecond)
in.PingTimeout = &ms
}
if fl.Changed("decline-hold") {
n, err := parseSeconds(hold)
if err != nil {
return err
}
in.DeclineHold = &n
}
if fl.Changed("authoritative") {
a, err := onOff("authoritative", authoritative)
if err != nil {
return err
}
not := !*a
in.NotAuthoritative = &not
}
if len(opts) > 0 || len(unsetOpts) > 0 {
var cur model.Settings
if err := c.Get(ctx(), "/settings", nil, &cur); err != nil {
return err
}
s, err := optionsArg(opts)
if err != nil {
return err
}
merged, err := mergeOptions(cur.Options, s, unsetOpts)
if err != nil {
return err
}
in.Options = &merged
}
var resp api.MutationResponse
if err := c.Do(ctx(), "PATCH", "/settings", nil, in, &resp); err != nil {
return err
}
return report(&resp)
},
}
f := set.Flags()
f.StringVar(&leaseTime, "lease-time", "", "the default lease time (12h, 1d)")
f.StringVar(&ping, "ping-check", "", "ping addresses before offering them: on or off")
f.StringVar(&pingTimeout, "ping-timeout", "", "how long to wait for a ping's answer (500ms)")
f.StringVar(&hold, "decline-hold", "", "how long an address in use by someone else is held back (24h)")
f.StringVar(&authoritative, "authoritative", "", "refuse requests for addresses that don't belong on the interface: on or off")
f.StringArrayVar(&opts, "option", nil, "set an option NAME=VALUE for every subnet")
f.StringArrayVar(&unsetOpts, "unset-option", nil, "remove an option")
cmd.AddCommand(set)
return cmd
}
func optionCmd() *cobra.Command {
cmd := &cobra.Command{Use: "option", Aliases: []string{"options"}, Short: "The DHCP options Steward knows by name"}
cmd.AddCommand(&cobra.Command{
Use: "list", Short: "List the options and their value types", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var cat api.Catalog
if err := c.Get(ctx(), "/catalog", nil, &cat); err != nil {
return err
}
return output(cat, func(w io.Writer) {
fmt.Fprintln(w, "CODE\tNAME\tTYPE\tWHAT")
for _, o := range cat.Options {
fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", o.Code, o.Name, o.Type, o.Help)
}
fmt.Fprintf(w, "\nAny other option by number: --option 224=01:02 (hex), or with a type: --option 224:string=hello.\nTypes: %s.\n", strings.Join(cat.Types, ", "))
})
},
})
return cmd
}
func interfaceCmd() *cobra.Command {
cmd := &cobra.Command{Use: "interface", Aliases: []string{"interfaces"}, Short: "The server's network interfaces"}
cmd.AddCommand(&cobra.Command{
Use: "list", Short: "List the interfaces, their addresses and subnets", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var ifs []api.HostInterface
if err := c.Get(ctx(), "/interfaces", nil, &ifs); err != nil {
return err
}
return output(ifs, func(w io.Writer) {
fmt.Fprintln(w, "INTERFACE\tADDRESSES\tSUBNETS")
for _, i := range ifs {
var subs []string
for _, s := range i.Subnets {
subs = append(subs, fmt.Sprint(s))
}
addrs := strings.Join(i.Addresses, ", ")
if len(i.Addresses) == 0 {
addrs = "-"
}
fmt.Fprintf(w, "%s\t%s\t%s\n", i.Name, addrs, orDash(strings.Join(subs, ", ")))
}
})
},
})
return cmd
}
+493
View File
@@ -0,0 +1,493 @@
// Command steward is the command line client for Steward. It talks to
// stewardd's REST API: on the server itself through the local socket, or
// anywhere with the server's URL and an API token.
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"go.yaml.in/yaml/v3"
"steward/internal/api"
"steward/internal/client"
"steward/internal/model"
"steward/internal/validate"
"steward/internal/version"
)
func main() {
if err := rootCmd().Execute(); err != nil {
printError(err)
os.Exit(exitCode(err))
}
}
// Global flags.
var (
flagServer string
flagToken string
flagConfig string
flagDryRun bool
flagOutput string
flagInsecure bool
flagCACert string
flagQuiet bool
)
// DefaultSocket is stewardd's local socket.
const DefaultSocket = "/run/steward/steward.sock"
// cliConfig is the CLI's configuration file.
type cliConfig struct {
Server string `yaml:"server,omitempty"`
Token string `yaml:"token,omitempty"`
Insecure bool `yaml:"insecure,omitempty"`
CACert string `yaml:"ca_cert,omitempty"`
}
func defaultConfigPath() string {
if p := os.Getenv("STEWARD_CLI_CONFIG"); p != "" {
return p
}
dir, err := os.UserConfigDir()
if err != nil {
dir = filepath.Join(os.Getenv("HOME"), ".config")
}
return filepath.Join(dir, "steward", "config.yaml")
}
func loadCLIConfig() (*cliConfig, error) {
c := &cliConfig{}
b, err := os.ReadFile(flagConfig)
if errors.Is(err, os.ErrNotExist) {
return c, nil
}
if err != nil {
return nil, err
}
return c, yaml.Unmarshal(b, c)
}
func saveCLIConfig(c *cliConfig) error {
if err := os.MkdirAll(filepath.Dir(flagConfig), 0o700); err != nil {
return err
}
b, err := yaml.Marshal(c)
if err != nil {
return err
}
return os.WriteFile(flagConfig, b, 0o600)
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
// newClient connects to the server: --server, STEWARD_SERVER, the config
// file, or else the local socket.
func newClient() (*client.Client, error) {
cfg, err := loadCLIConfig()
if err != nil {
return nil, fmt.Errorf("reading %s: %w", flagConfig, err)
}
server := firstNonEmpty(flagServer, os.Getenv("STEWARD_SERVER"), cfg.Server)
token := firstNonEmpty(flagToken, os.Getenv("STEWARD_TOKEN"), cfg.Token)
if server == "" || server == "local" {
sock := firstNonEmpty(os.Getenv("STEWARD_SOCKET"), DefaultSocket)
if _, err := os.Stat(sock); err != nil {
return nil, fmt.Errorf("no server configured, and there is no local socket %s: use --server URL, STEWARD_SERVER or 'steward config set server https://...'; on the server itself, check that stewardd runs", sock)
}
server = "unix://" + sock
}
if strings.HasPrefix(token, "@") {
b, err := os.ReadFile(token[1:])
if err != nil {
return nil, fmt.Errorf("reading the token file: %w", err)
}
token = strings.TrimSpace(string(b))
}
if _, isSocket := client.SocketPath(server); !isSocket && token == "" {
return nil, errors.New("no API token: use --token, STEWARD_TOKEN or 'steward config set token'")
}
c, err := client.New(client.Options{Server: server, Token: token, Insecure: flagInsecure || cfg.Insecure, CACert: firstNonEmpty(flagCACert, cfg.CACert)})
if err != nil {
return nil, err
}
c.DryRun = flagDryRun
return c, nil
}
func rootCmd() *cobra.Command {
root := &cobra.Command{
Use: "steward",
Short: "Command line client for Steward, the DHCP server",
Long: `steward manages a Steward DHCP server through its API.
Every change is checked as a whole before it is saved and used, and is
recorded in the history, where 'steward history undo ID' takes it back.
--dry-run shows what a change would do.
On the server, root needs no setup: the CLI uses stewardd's local socket.
From elsewhere, set the server and an API token once:
steward config set server https://dhcp.example.com:8067
steward config set token (paste the token at the prompt)
Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
5 conflict, 6 invalid or rejected by validation, 7 server error.`,
SilenceUsage: true,
SilenceErrors: true,
Version: version.String(),
}
pf := root.PersistentFlags()
pf.StringVarP(&flagServer, "server", "s", "", "server URL, or \"local\" for the local socket (env STEWARD_SERVER)")
pf.StringVar(&flagToken, "token", "", "API token, or @FILE to read it from a file (env STEWARD_TOKEN)")
pf.StringVar(&flagConfig, "config", defaultConfigPath(), "CLI configuration file")
pf.BoolVarP(&flagDryRun, "dry-run", "n", false, "check and show a change without making it")
pf.StringVarP(&flagOutput, "output", "o", "table", "output format: table, json, yaml or id")
pf.BoolVar(&flagInsecure, "insecure", false, "don't verify the server's TLS certificate")
pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate")
pf.BoolVarP(&flagQuiet, "quiet", "q", false, "print only errors (and IDs of new objects)")
root.AddGroup(&cobra.Group{ID: "dhcp", Title: "DHCP:"}, &cobra.Group{ID: "changes", Title: "Changes:"}, &cobra.Group{ID: "admin", Title: "Administration:"})
add := func(group string, cmds ...*cobra.Command) {
for _, c := range cmds {
c.GroupID = group
root.AddCommand(c)
}
}
add("dhcp", statusCmd(), subnetCmd(), reservationCmd(), leaseCmd(), activityCmd(), settingsCmd(), optionCmd(), interfaceCmd())
add("changes", historyCmd(), importCmd())
add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), auditCmd(), configCmd())
return root
}
func ctx() context.Context { return context.Background() }
// ---- output
func output(v any, table func(w io.Writer)) error {
switch flagOutput {
case "json":
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(v)
case "yaml":
b, err := json.Marshal(v)
if err != nil {
return err
}
var x any
if err := json.Unmarshal(b, &x); err != nil {
return err
}
return yaml.NewEncoder(os.Stdout).Encode(x)
case "id":
return outputIDs(v)
case "table", "":
if table == nil {
flagOutput = "yaml"
return output(v, nil)
}
tw := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
table(tw)
return tw.Flush()
}
return fmt.Errorf("unknown output format %q (use table, json, yaml or id)", flagOutput)
}
// outputIDs prints the id (or for leases the ip) of each object.
func outputIDs(v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
dec := json.NewDecoder(bytes.NewReader(b))
dec.UseNumber()
var x any
if err := dec.Decode(&x); err != nil {
return err
}
list, ok := x.([]any)
if !ok {
list = []any{x}
}
for _, it := range list {
if m, ok := it.(map[string]any); ok {
if id, ok := m["id"]; ok {
fmt.Println(id)
} else if ip, ok := m["ip"]; ok {
fmt.Println(ip)
}
}
}
return nil
}
func printProblems(w io.Writer, ps []validate.Problem) {
for _, p := range ps {
obj := ""
if p.Object != "" {
obj = p.Object + ": "
}
fmt.Fprintf(w, "%s: %s%s [%s]\n", p.Severity, obj, p.Message, p.Code)
}
}
func printError(err error) {
var ce *client.Error
if errors.As(err, &ce) {
ps := ce.Info.Problems
if len(ps) == 1 {
fmt.Fprintf(os.Stderr, "error: %s [%s]\n", ce.Info.Message, ps[0].Code)
return
}
fmt.Fprintln(os.Stderr, "error:", ce.Info.Message)
if len(ps) > 1 {
printProblems(os.Stderr, ps)
}
return
}
fmt.Fprintln(os.Stderr, "error:", err)
}
// exitCode maps errors to exit codes: 1 other errors, 3 not authenticated
// or not allowed, 4 not found, 5 conflict, 6 invalid request or rejected
// by validation, 7 server error.
func exitCode(err error) int {
var ce *client.Error
if errors.As(err, &ce) {
switch {
case ce.Status == 401 || ce.Status == 403:
return 3
case ce.Status == 404:
return 4
case ce.Status == 409:
return 5
case ce.Status == 400 || ce.Status == 422:
return 6
case ce.Status >= 500:
return 7
}
}
return 1
}
// report prints the outcome of a change.
func report(resp *api.MutationResponse) error {
switch flagOutput {
case "json", "yaml":
return output(resp, nil)
case "id":
if len(resp.Object) > 0 {
return outputIDs(resp.Object)
}
return nil
}
w := os.Stdout
verb := func(c model.Change) string {
if resp.DryRun {
return "would " + c.Action()
}
return c.Action() + "d"
}
if !flagQuiet || resp.DryRun {
for _, c := range resp.Changes {
if c.Kind == model.KindSettings {
fmt.Fprintf(w, "%s settings\n", verb(c))
continue
}
fmt.Fprintf(w, "%s %s %s\n", verb(c), c.Kind, c.Label)
}
}
switch {
case resp.DryRun:
fmt.Fprintln(w, "dry run: nothing was changed")
case resp.Applied && !flagQuiet:
fmt.Fprintf(w, "saved (history %d; undo with 'steward history undo %d')\n", resp.HistoryID, resp.HistoryID)
case !resp.Applied && len(resp.Changes) == 0 && !flagQuiet:
fmt.Fprintln(w, "nothing to change")
}
if flagQuiet && len(resp.Object) > 0 && resp.Applied {
outputIDs(resp.Object)
}
printProblems(os.Stderr, resp.Warnings)
return nil
}
func q(kv ...string) url.Values {
v := url.Values{}
for i := 0; i+1 < len(kv); i += 2 {
if kv[i+1] != "" {
v.Set(kv[i], kv[i+1])
}
}
return v
}
func confirm(prompt string, yes bool) bool {
if yes || flagDryRun {
return true
}
fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt)
var answer string
fmt.Scanln(&answer)
return strings.EqualFold(answer, "y") || strings.EqualFold(answer, "yes")
}
// ---- durations
// parseSeconds reads a lease time: "12h", "30m", "7d", "1d12h" or plain
// seconds.
func parseSeconds(s string) (int, error) {
s = strings.TrimSpace(s)
if n, err := strconv.Atoi(s); err == nil {
return n, nil
}
days := 0
if i := strings.Index(s, "d"); i > 0 {
n, err := strconv.Atoi(s[:i])
if err != nil {
return 0, fmt.Errorf("invalid duration %q (use e.g. 12h, 30m or 7d)", s)
}
days, s = n, s[i+1:]
}
var d time.Duration
if s != "" {
var err error
if d, err = time.ParseDuration(s); err != nil {
return 0, fmt.Errorf("invalid duration %q (use e.g. 12h, 30m or 7d)", s)
}
}
return days*86400 + int(d/time.Second), nil
}
// fmtSeconds writes seconds the short way: 43200 is "12h", 90 is "1m30s".
func fmtSeconds(n int) string {
if n <= 0 {
return "-"
}
var parts []string
for _, u := range []struct {
s int
name string
}{{86400, "d"}, {3600, "h"}, {60, "m"}, {1, "s"}} {
if n >= u.s {
parts = append(parts, strconv.Itoa(n/u.s)+u.name)
n %= u.s
}
}
return strings.Join(parts, "")
}
// fmtWhen writes a time relative to now: "in 3h", "2d ago".
func fmtWhen(t time.Time) string {
if t.IsZero() {
return "-"
}
d := time.Until(t)
ago := d < 0
if ago {
d = -d
}
var s string
switch {
case d < time.Minute:
return "now"
case d < time.Hour:
s = fmt.Sprintf("%dm", int(d.Minutes()))
case d < 48*time.Hour:
s = fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60)
default:
s = fmt.Sprintf("%dd", int(d.Hours()/24))
}
if ago {
return s + " ago"
}
return "in " + s
}
func orDash(s string) string {
if s == "" {
return "-"
}
return s
}
// optionsArg turns --option NAME=VALUE flags into option inputs.
func optionsArg(flags []string) ([]api.OptionInput, error) {
var out []api.OptionInput
for _, f := range flags {
name, value, ok := strings.Cut(f, "=")
if !ok {
return nil, fmt.Errorf("--option %q: write it as NAME=VALUE, e.g. routers=10.0.10.1", f)
}
in := api.OptionInput{Option: strings.TrimSpace(name), Value: strings.TrimSpace(value)}
// A custom option's type: 224:string=hello.
if n, typ, ok := strings.Cut(in.Option, ":"); ok {
in.Option, in.Type = n, typ
}
out = append(out, in)
}
return out, nil
}
// mergeOptions sets and unsets options on a current list (by code).
func mergeOptions(cur []model.OptionValue, set []api.OptionInput, unset []string) ([]api.OptionInput, error) {
out := []api.OptionInput{}
drop := map[string]bool{}
for _, u := range unset {
drop[strings.ToLower(strings.TrimSpace(u))] = true
}
setNames := map[string]bool{}
for _, s := range set {
setNames[strings.ToLower(s.Option)] = true
}
for _, o := range cur {
code := strconv.Itoa(o.Code)
if drop[o.Name] || drop[code] || setNames[o.Name] || setNames[code] {
continue
}
out = append(out, api.OptionInput{Option: code, Value: o.Value, Type: o.Type})
}
for name := range drop {
found := false
for _, o := range cur {
if o.Name == name || strconv.Itoa(o.Code) == name {
found = true
}
}
if !found {
return nil, fmt.Errorf("option %s isn't set here", name)
}
}
return append(out, set...), nil
}
func optionsText(opts []model.OptionValue) string {
var parts []string
for _, o := range opts {
parts = append(parts, o.Name+"="+o.Value)
}
if len(parts) == 0 {
return "-"
}
return strings.Join(parts, "; ")
}
+44
View File
@@ -0,0 +1,44 @@
package main
import (
"testing"
"steward/internal/api"
"steward/internal/model"
)
func TestDurations(t *testing.T) {
for in, want := range map[string]int{"3600": 3600, "12h": 43200, "30m": 1800, "1d": 86400, "1d12h": 129600, "1h30m": 5400} {
if got, err := parseSeconds(in); err != nil || got != want {
t.Errorf("parseSeconds(%q) = %d, %v; want %d", in, got, err, want)
}
}
for _, bad := range []string{"12x", "d", "1d2x"} {
if _, err := parseSeconds(bad); err == nil {
t.Errorf("parseSeconds(%q) accepted", bad)
}
}
for n, want := range map[int]string{43200: "12h", 5400: "1h30m", 90061: "1d1h1m1s", 0: "-"} {
if got := fmtSeconds(n); got != want {
t.Errorf("fmtSeconds(%d) = %q, want %q", n, got, want)
}
}
}
func TestOptions(t *testing.T) {
in, err := optionsArg([]string{"routers=10.0.10.1", "224:string=hi", " ntp-servers = 10.0.10.2 "})
if err != nil || len(in) != 3 || in[1].Option != "224" || in[1].Type != "string" || in[2].Option != "ntp-servers" || in[2].Value != "10.0.10.2" {
t.Fatalf("optionsArg: %+v %v", in, err)
}
if _, err := optionsArg([]string{"routers"}); err == nil {
t.Error("an option without a value accepted")
}
cur := []model.OptionValue{{Code: 3, Name: "routers", Value: "10.0.10.1"}, {Code: 6, Name: "domain-name-servers", Value: "10.0.10.2"}}
got, err := mergeOptions(cur, []api.OptionInput{{Option: "routers", Value: "10.0.10.254"}}, []string{"domain-name-servers"})
if err != nil || len(got) != 1 || got[0].Option != "routers" || got[0].Value != "10.0.10.254" {
t.Errorf("merge: %+v %v", got, err)
}
if _, err := mergeOptions(cur, nil, []string{"ntp-servers"}); err == nil {
t.Error("unsetting an option that isn't set accepted")
}
}
+507
View File
@@ -0,0 +1,507 @@
package main
import (
"fmt"
"io"
"net/url"
"strings"
"github.com/spf13/cobra"
"steward/internal/api"
"steward/internal/model"
)
// subnetPath is the API path of a subnet given by ID, name or prefix.
func subnetPath(ref string) string {
return "/subnets/" + url.PathEscape(strings.Replace(ref, "/", "_", 1))
}
func poolsText(ps []model.Pool) string {
var out []string
for _, p := range ps {
out = append(out, p.String())
}
if len(out) == 0 {
return "-"
}
return strings.Join(out, ", ")
}
func usageText(u api.Usage) string {
if u.PoolSize == 0 {
return "-"
}
return fmt.Sprintf("%d/%d (%d%%)", u.Leased, u.PoolSize, u.Leased*100/u.PoolSize)
}
func subnetCmd() *cobra.Command {
cmd := &cobra.Command{Use: "subnet", Aliases: []string{"subnets"}, Short: "Subnets: the networks Steward serves, each on its interface"}
cmd.AddCommand(&cobra.Command{
Use: "list", Short: "List the subnets with their interface, pools and use", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var subs []api.Subnet
if err := c.Get(ctx(), "/subnets", nil, &subs); err != nil {
return err
}
return output(subs, func(w io.Writer) {
fmt.Fprintln(w, "ID\tNAME\tINTERFACE\tPREFIX\tPOOLS\tLEASED\tRESERVED\tLEASE TIME\tSTATE")
for _, s := range subs {
state := "on"
if s.Disabled {
state = "disabled"
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%d\t%s\t%s\n", s.ID, orDash(s.Name), s.Interface, s.Prefix, poolsText(s.Pools), usageText(s.Usage),
s.Usage.Reservations, fmtSeconds(s.LeaseTime), state)
}
})
},
})
cmd.AddCommand(&cobra.Command{
Use: "show SUBNET", Short: "Show a subnet (by ID, name or prefix)", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var s api.Subnet
if err := c.Get(ctx(), subnetPath(args[0]), nil, &s); err != nil {
return err
}
return output(s, func(w io.Writer) {
fmt.Fprintf(w, "ID:\t%d\n", s.ID)
fmt.Fprintf(w, "Name:\t%s\n", orDash(s.Name))
fmt.Fprintf(w, "Interface:\t%s\n", s.Interface)
fmt.Fprintf(w, "Prefix:\t%s (mask %s)\n", s.Prefix, model.Mask(s.Prefix))
fmt.Fprintf(w, "Pools:\t%s\n", poolsText(s.Pools))
fmt.Fprintf(w, "Leased:\t%s, %d declined\n", usageText(s.Usage), s.Usage.Declined)
fmt.Fprintf(w, "Reservations:\t%d (%d with a lease now)\n", s.Usage.Reservations, s.Usage.ReservationsLeased)
lt := "the default"
if s.LeaseTime > 0 {
lt = fmtSeconds(s.LeaseTime)
}
fmt.Fprintf(w, "Lease time:\t%s\n", lt)
fmt.Fprintln(w, "Options:\t"+optionsText(s.Options))
if s.Disabled {
fmt.Fprintln(w, "State:\tdisabled (no answers)")
}
if s.Comment != "" {
fmt.Fprintf(w, "Comment:\t%s\n", s.Comment)
}
})
},
})
var name, iface, leaseTime, comment, prefix string
var pools, opts, addPools, removePools, unsetOpts []string
add := &cobra.Command{
Use: "add PREFIX --interface IFACE",
Short: "Add a subnet",
Long: `Adds a subnet, served on one interface: only clients on that interface
get its addresses.
steward subnet add 10.0.10.0/24 --interface eth0 --name office \
--pool 10.0.10.100-10.0.10.199 \
--option routers=10.0.10.1 --option domain-name-servers=10.0.10.2
Pools are FIRST-LAST or a prefix; give --pool once per pool. Options are
NAME=VALUE ('steward option list' has the names); a number works for any
other option, with its type for ones Steward doesn't know: 224:string=hi.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
in := api.SubnetInput{Prefix: &args[0], Interface: &iface, Pools: &pools}
if pools == nil {
in.Pools = &[]string{}
}
if name != "" {
in.Name = &name
}
if comment != "" {
in.Comment = &comment
}
if leaseTime != "" {
n, err := parseSeconds(leaseTime)
if err != nil {
return err
}
in.LeaseTime = &n
}
if len(opts) > 0 {
o, err := optionsArg(opts)
if err != nil {
return err
}
in.Options = &o
}
var resp api.MutationResponse
if err := c.Do(ctx(), "POST", "/subnets", nil, in, &resp); err != nil {
return err
}
return report(&resp)
},
}
f := add.Flags()
f.StringVar(&iface, "interface", "", "the network interface it is served on (required)")
f.StringVar(&name, "name", "", "a name for it")
f.StringArrayVar(&pools, "pool", nil, "a pool: FIRST-LAST or a prefix (repeat for several)")
f.StringVar(&leaseTime, "lease-time", "", "lease time (12h, 1d; default: the settings')")
f.StringArrayVar(&opts, "option", nil, "an option NAME=VALUE (repeat for several)")
f.StringVar(&comment, "comment", "", "a comment")
add.MarkFlagRequired("interface")
cmd.AddCommand(add)
update := &cobra.Command{
Use: "update SUBNET",
Short: "Change a subnet",
Long: `Changes a subnet. Only what is given changes. --pool replaces all pools;
--add-pool and --remove-pool change one. --option sets an option (keeping
the others), --unset-option removes one.
steward subnet update office --add-pool 10.0.10.200-10.0.10.220
steward subnet update office --option ntp-servers=10.0.10.2 --unset-option time-offset`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var cur api.Subnet
if err := c.Get(ctx(), subnetPath(args[0]), nil, &cur); err != nil {
return err
}
in := api.SubnetInput{}
fl := cmd.Flags()
if fl.Changed("name") {
in.Name = &name
}
if fl.Changed("interface") {
in.Interface = &iface
}
if fl.Changed("prefix") {
in.Prefix = &prefix
}
if fl.Changed("comment") {
in.Comment = &comment
}
if fl.Changed("lease-time") {
n := 0
if leaseTime != "default" {
if n, err = parseSeconds(leaseTime); err != nil {
return err
}
}
in.LeaseTime = &n
}
if fl.Changed("pool") || len(addPools) > 0 || len(removePools) > 0 {
list := []string{}
if !fl.Changed("pool") {
for _, p := range cur.Pools {
list = append(list, p.String())
}
} else {
list = append(list, pools...)
}
for _, r := range removePools {
rp, err := model.ParsePool(r)
if err != nil {
return err
}
found := false
for i, p := range list {
if pp, _ := model.ParsePool(p); pp == rp {
list = append(list[:i], list[i+1:]...)
found = true
break
}
}
if !found {
return fmt.Errorf("the subnet has no pool %s (it has %s)", rp, poolsText(cur.Pools))
}
}
list = append(list, addPools...)
in.Pools = &list
}
if len(opts) > 0 || len(unsetOpts) > 0 {
set, err := optionsArg(opts)
if err != nil {
return err
}
merged, err := mergeOptions(cur.Options, set, unsetOpts)
if err != nil {
return err
}
in.Options = &merged
}
var resp api.MutationResponse
if err := c.Do(ctx(), "PATCH", subnetPath(args[0]), nil, in, &resp); err != nil {
return err
}
return report(&resp)
},
}
f = update.Flags()
f.StringVar(&name, "name", "", "a new name (\"\" for none)")
f.StringVar(&iface, "interface", "", "a new interface")
f.StringVar(&prefix, "prefix", "", "a new prefix")
f.StringArrayVar(&pools, "pool", nil, "replace the pools (repeat for several)")
f.StringArrayVar(&addPools, "add-pool", nil, "add a pool")
f.StringArrayVar(&removePools, "remove-pool", nil, "remove a pool")
f.StringVar(&leaseTime, "lease-time", "", "lease time (12h, 1d, or \"default\")")
f.StringArrayVar(&opts, "option", nil, "set an option NAME=VALUE")
f.StringArrayVar(&unsetOpts, "unset-option", nil, "remove an option (by name or number)")
f.StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(update)
for _, on := range []bool{true, false} {
on := on
verb := "disable"
short := "Stop answering a subnet's clients (its configuration and leases stay)"
if on {
verb, short = "enable", "Answer a disabled subnet's clients again"
}
cmd.AddCommand(&cobra.Command{
Use: verb + " SUBNET", Short: short, Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
disabled := !on
var resp api.MutationResponse
if err := c.Do(ctx(), "PATCH", subnetPath(args[0]), nil, api.SubnetInput{Disabled: &disabled}, &resp); err != nil {
return err
}
return report(&resp)
},
})
}
var yes bool
del := &cobra.Command{
Use: "delete SUBNET", Short: "Delete a subnet and its reservations", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var cur api.Subnet
if err := c.Get(ctx(), subnetPath(args[0]), nil, &cur); err != nil {
return err
}
msg := fmt.Sprintf("Delete subnet %s", cur.Label())
if cur.Usage.Reservations > 0 {
msg += fmt.Sprintf(" and its %d reservations", cur.Usage.Reservations)
}
if !confirm(msg+"?", yes) {
return fmt.Errorf("not deleted")
}
var resp api.MutationResponse
if err := c.Do(ctx(), "DELETE", subnetPath(args[0]), nil, nil, &resp); err != nil {
return err
}
return report(&resp)
},
}
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
cmd.AddCommand(del)
return cmd
}
func reservationCmd() *cobra.Command {
cmd := &cobra.Command{Use: "reservation", Aliases: []string{"reservations", "res"}, Short: "Reservations: fixed addresses for clients"}
var subnet string
list := &cobra.Command{
Use: "list", Short: "List reservations", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var rs []api.Reservation
if err := c.Get(ctx(), "/reservations", q("subnet", subnet), &rs); err != nil {
return err
}
return output(rs, func(w io.Writer) {
fmt.Fprintln(w, "ID\tIP\tMAC / CLIENT ID\tHOSTNAME\tSUBNET\tLEASE\tCOMMENT")
for _, r := range rs {
who := r.MAC
if r.ClientID != "" {
who = "id " + r.ClientID
}
lease := "-"
if r.Lease != nil {
lease = "until " + r.Lease.End.Local().Format("Jan 2 15:04")
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\n", r.ID, r.IP, who, orDash(r.Hostname), orDash(r.SubnetName), lease, r.Comment)
}
})
},
}
list.Flags().StringVar(&subnet, "subnet", "", "only this subnet's (ID, name or prefix)")
cmd.AddCommand(list)
cmd.AddCommand(&cobra.Command{
Use: "show RESERVATION", Short: "Show a reservation (by ID, address or MAC address)", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var r api.Reservation
if err := c.Get(ctx(), "/reservations/"+url.PathEscape(args[0]), nil, &r); err != nil {
return err
}
return output(r, func(w io.Writer) {
fmt.Fprintf(w, "ID:\t%d\n", r.ID)
fmt.Fprintf(w, "Address:\t%s (subnet %s)\n", r.IP, orDash(r.SubnetName))
if r.MAC != "" {
fmt.Fprintf(w, "MAC address:\t%s\n", r.MAC)
}
if r.ClientID != "" {
fmt.Fprintf(w, "Client ID:\t%s\n", r.ClientID)
}
fmt.Fprintf(w, "Host name:\t%s\n", orDash(r.Hostname))
fmt.Fprintln(w, "Options:\t"+optionsText(r.Options))
if r.Lease != nil {
fmt.Fprintf(w, "Lease:\tuntil %s, last seen on %s\n", r.Lease.End.Local().Format("2006-01-02 15:04"), r.Lease.Interface)
} else {
fmt.Fprintln(w, "Lease:\tnone now")
}
if r.Comment != "" {
fmt.Fprintf(w, "Comment:\t%s\n", r.Comment)
}
})
},
})
var hostname, clientID, comment, ip, mac string
var opts, unsetOpts []string
add := &cobra.Command{
Use: "add IP MAC",
Short: "Reserve an address for a client",
Long: `Reserves an address for a client, known by its MAC address (or by its
client identifier: give "-" for the MAC and --client-id). The address picks
the subnet. An address inside a pool is fine: it is never handed out to
anyone else.
steward reservation add 10.0.10.20 aa:bb:cc:dd:ee:01 --hostname nas`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
in := api.ReservationInput{IP: &args[0]}
if args[1] != "-" {
in.MAC = &args[1]
}
if clientID != "" {
in.ClientID = &clientID
}
if hostname != "" {
in.Hostname = &hostname
}
if comment != "" {
in.Comment = &comment
}
if len(opts) > 0 {
o, err := optionsArg(opts)
if err != nil {
return err
}
in.Options = &o
}
var resp api.MutationResponse
if err := c.Do(ctx(), "POST", "/reservations", nil, in, &resp); err != nil {
return err
}
return report(&resp)
},
}
f := add.Flags()
f.StringVar(&hostname, "hostname", "", "the host name to send it")
f.StringVar(&clientID, "client-id", "", "match the client identifier (hex) instead of the MAC address")
f.StringArrayVar(&opts, "option", nil, "an option NAME=VALUE for this client only")
f.StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(add)
update := &cobra.Command{
Use: "update RESERVATION", Short: "Change a reservation (only what is given changes)", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
path := "/reservations/" + url.PathEscape(args[0])
var cur api.Reservation
if err := c.Get(ctx(), path, nil, &cur); err != nil {
return err
}
in := api.ReservationInput{}
fl := cmd.Flags()
if fl.Changed("ip") {
in.IP = &ip
}
if fl.Changed("mac") {
in.MAC = &mac
}
if fl.Changed("client-id") {
in.ClientID = &clientID
}
if fl.Changed("hostname") {
in.Hostname = &hostname
}
if fl.Changed("comment") {
in.Comment = &comment
}
if len(opts) > 0 || len(unsetOpts) > 0 {
set, err := optionsArg(opts)
if err != nil {
return err
}
merged, err := mergeOptions(cur.Options, set, unsetOpts)
if err != nil {
return err
}
in.Options = &merged
}
var resp api.MutationResponse
if err := c.Do(ctx(), "PATCH", path, nil, in, &resp); err != nil {
return err
}
return report(&resp)
},
}
f = update.Flags()
f.StringVar(&ip, "ip", "", "a new address")
f.StringVar(&mac, "mac", "", "a new MAC address (\"\" for none)")
f.StringVar(&clientID, "client-id", "", "a client identifier (\"\" for none)")
f.StringVar(&hostname, "hostname", "", "a new host name")
f.StringArrayVar(&opts, "option", nil, "set an option NAME=VALUE")
f.StringArrayVar(&unsetOpts, "unset-option", nil, "remove an option")
f.StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(update)
cmd.AddCommand(&cobra.Command{
Use: "delete RESERVATION", Short: "Delete a reservation", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var resp api.MutationResponse
if err := c.Do(ctx(), "DELETE", "/reservations/"+url.PathEscape(args[0]), nil, nil, &resp); err != nil {
return err
}
return report(&resp)
},
})
return cmd
}
+40
View File
@@ -0,0 +1,40 @@
package main
import (
"errors"
"fmt"
"os"
"path/filepath"
"syscall"
)
// errInUse means another process holds the database lock.
var errInUse = errors.New("the database is in use by a running stewardd or by 'stewardd restore'")
// lockDatabase takes the exclusive lock (on DATABASE.lock) that marks the
// database as in use by a running stewardd or by 'stewardd restore', so a
// restore can never replace the database under a running stewardd, and two
// stewardds can't serve the same database. Other commands (backup, user, ...)
// don't take it: they work alongside a running stewardd.
func lockDatabase(db string) (release func(), err error) {
if err := os.MkdirAll(filepath.Dir(db), 0o750); err != nil {
return nil, err
}
path := db + ".lock"
// Read-only is enough for flock, and works whoever created the file.
f, err := os.OpenFile(path, os.O_RDONLY|os.O_CREATE, 0o640)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
f.Close()
if errors.Is(err, syscall.EWOULDBLOCK) {
return nil, errInUse
}
return nil, fmt.Errorf("locking %s: %w", path, err)
}
return func() {
syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
f.Close()
}, nil
}
+614
View File
@@ -0,0 +1,614 @@
// 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()) },
}
}