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

+335
View File
@@ -0,0 +1,335 @@
// Package validate checks a whole configuration. Every problem has a
// stable code (docs/validation.md); errors stop a change from being saved,
// warnings are reported with it.
package validate
import (
"fmt"
"net/netip"
"sort"
"strings"
"steward/internal/dhcp4"
"steward/internal/model"
)
// Severities.
const (
Error = "error"
Warning = "warning"
)
// Problem is one finding.
type Problem struct {
Severity string `json:"severity"`
Code string `json:"code"`
Message string `json:"message"`
// Object names what it is about: "subnet office (10.0.10.0/24)".
Object string `json:"object,omitempty"`
}
// Env describes the host, for checks against its interfaces. A nil Env
// skips them (tests, imports for another machine).
type Env struct {
// Interfaces maps each interface name to its IPv4 prefixes
// (10.0.10.1/24).
Interfaces map[string][]netip.Prefix
}
// HasErrors reports whether any problem is an error.
func HasErrors(ps []Problem) bool {
for _, p := range ps {
if p.Severity == Error {
return true
}
}
return false
}
// Errors returns only the errors.
func Errors(ps []Problem) []Problem {
var out []Problem
for _, p := range ps {
if p.Severity == Error {
out = append(out, p)
}
}
return out
}
type checker struct {
out []Problem
}
func (c *checker) add(sev, code, object, format string, args ...any) {
c.out = append(c.out, Problem{Severity: sev, Code: code, Object: object, Message: fmt.Sprintf(format, args...)})
}
// Check validates a configuration.
func Check(st *model.State, env *Env) []Problem {
c := &checker{}
c.settings(st.Settings)
c.subnets(st, env)
c.reservations(st, env)
return c.out
}
func (c *checker) settings(s model.Settings) {
if s.LeaseTime != 0 {
c.leaseTime(s.LeaseTime, "settings")
}
if s.PingTimeout != 0 && (s.PingTimeout < 50 || s.PingTimeout > 5000) {
c.add(Error, "ping_timeout_range", "settings", "the ping timeout is %d ms; use 50 to 5000 ms", s.PingTimeout)
}
if s.DeclineHold != 0 && (s.DeclineHold < 60 || s.DeclineHold > 30*24*3600) {
c.add(Error, "decline_hold_range", "settings", "the decline hold is %d seconds; use 1 minute to 30 days", s.DeclineHold)
}
c.options(s.Options, "settings")
}
func (c *checker) leaseTime(sec int, object string) {
if sec < model.MinLeaseTime || sec > model.MaxLeaseTime {
c.add(Error, "lease_time_range", object, "the lease time is %d seconds; use 1 minute to 365 days", sec)
}
}
// options checks option values: known types, valid values, no reserved
// codes, no code twice.
func (c *checker) options(opts []model.OptionValue, object string) {
seen := map[int]bool{}
for _, o := range opts {
if o.Code < 1 || o.Code > 254 {
c.add(Error, "option_code", object, "option code %d is out of range (1 to 254)", o.Code)
continue
}
def := dhcp4.ByCode(byte(o.Code))
if why, ok := dhcp4.Reserved[byte(o.Code)]; ok {
c.add(Error, "option_reserved", object, "option %d can't be set: %s", o.Code, why)
continue
}
if seen[o.Code] {
c.add(Error, "option_duplicate", object, "option %s is set twice", def.Name)
}
seen[o.Code] = true
typ := def.Type
if !dhcp4.Known(byte(o.Code)) && o.Type != "" {
typ = o.Type
}
if _, err := dhcp4.Encode(typ, o.Value); err != nil {
c.add(Error, "option_invalid", object, "option %s: %v", def.Name, err)
}
}
}
func (c *checker) subnets(st *model.State, env *Env) {
names := map[string]*model.Subnet{}
for i, s := range st.Subnets {
obj := "subnet " + s.Label()
p := s.Prefix
switch {
case !p.IsValid() || !p.Addr().Is4():
c.add(Error, "subnet_prefix", obj, "the prefix must be an IPv4 network such as 10.0.10.0/24")
continue
case p.Masked() != p:
c.add(Error, "subnet_prefix", obj, "%s has host bits set; the network is %s", p, p.Masked())
continue
case p.Bits() < 8 || p.Bits() > 30:
c.add(Error, "subnet_size", obj, "a /%d is not a subnet DHCP can serve; use /8 to /30", p.Bits())
continue
}
if s.Name != "" {
if strings.ContainsAny(s.Name, " \t/,") {
c.add(Error, "subnet_name", obj, "the name %q has spaces, slashes or commas; use letters, digits, '-' and '_'", s.Name)
}
key := strings.ToLower(s.Name)
if o := names[key]; o != nil {
c.add(Error, "subnet_name_taken", obj, "the name %s is already used by subnet %s", s.Name, o.Prefix)
}
names[key] = s
}
if s.Interface == "" {
c.add(Error, "subnet_interface_missing", obj, "the subnet needs the network interface it is served on (eth0, eth0.10, br-lan)")
} else if !validIfName(s.Interface) {
c.add(Error, "subnet_interface_name", obj, "%q isn't an interface name", s.Interface)
}
for _, o := range st.Subnets[:i] {
if o.Prefix.IsValid() && o.Prefix.Overlaps(p) {
c.add(Error, "subnet_overlap", obj, "it overlaps subnet %s", o.Label())
}
}
if s.LeaseTime != 0 {
c.leaseTime(s.LeaseTime, obj)
}
c.pools(s, obj)
c.options(s.Options, obj)
c.routers(s, st.Settings.Options, obj)
if env != nil && !s.Disabled && s.Interface != "" {
c.hostInterface(s, env, obj)
}
}
}
func (c *checker) pools(s *model.Subnet, obj string) {
if len(s.Pools) == 0 && !s.Disabled {
c.add(Warning, "subnet_no_pools", obj, "the subnet has no pools: only clients with a reservation get an address")
}
network, broadcast := s.Prefix.Addr(), model.Last(s.Prefix)
for i, p := range s.Pools {
if !p.Start.Is4() || !p.End.Is4() || p.End.Less(p.Start) {
c.add(Error, "pool_invalid", obj, "pool %s is not a range of IPv4 addresses", p)
continue
}
if !s.Prefix.Contains(p.Start) || !s.Prefix.Contains(p.End) {
c.add(Error, "pool_outside_subnet", obj, "pool %s is not inside %s", p, s.Prefix)
continue
}
if p.Contains(network) || p.Contains(broadcast) {
c.add(Error, "pool_network_address", obj, "pool %s includes the network address %s or the broadcast address %s; start it at %s and end it at %s at most", p, network, broadcast, network.Next(), broadcast.Prev())
}
for _, o := range s.Pools[:i] {
if o.Start.Is4() && o.End.Is4() && !(p.End.Less(o.Start) || o.End.Less(p.Start)) {
c.add(Error, "pool_overlap", obj, "pools %s and %s overlap", o, p)
}
}
}
}
// routers warns about a gateway outside the subnet: clients can't reach it.
func (c *checker) routers(s *model.Subnet, global []model.OptionValue, obj string) {
val := ""
for _, o := range global {
if o.Code == dhcp4.OptRouters {
val = o.Value
}
}
for _, o := range s.Options {
if o.Code == dhcp4.OptRouters {
val = o.Value
}
}
if val == "" {
return
}
b, err := dhcp4.Encode(dhcp4.TypeIPs, val)
if err != nil {
return
}
for i := 0; i+4 <= len(b); i += 4 {
a := netip.AddrFrom4([4]byte(b[i : i+4]))
if !s.Prefix.Contains(a) {
c.add(Warning, "router_outside_subnet", obj, "the router %s is outside %s, so clients can't reach it", a, s.Prefix)
}
for _, p := range s.Pools {
if p.Contains(a) {
c.add(Warning, "router_in_pool", obj, "the router %s is inside pool %s; it is never handed out while it answers pings, but take it out of the pool", a, p)
}
}
}
}
func (c *checker) hostInterface(s *model.Subnet, env *Env, obj string) {
addrs, ok := env.Interfaces[s.Interface]
if !ok {
var names []string
for n := range env.Interfaces {
names = append(names, n)
}
sort.Strings(names)
c.add(Warning, "interface_unknown", obj, "this server has no interface %s (it has %s); the subnet is served once it appears", s.Interface, strings.Join(names, ", "))
return
}
for _, a := range addrs {
if s.Prefix.Contains(a.Addr()) {
for _, p := range s.Pools {
if p.Contains(a.Addr()) {
c.add(Warning, "pool_includes_server", obj, "pool %s includes this server's own address %s; it is never handed out, but take it out of the pool", p, a.Addr())
}
}
return
}
}
c.add(Warning, "interface_no_address", obj, "interface %s has no address in %s; give it one (the clients renew their leases with it), or check the interface", s.Interface, s.Prefix)
}
func (c *checker) reservations(st *model.State, env *Env) {
byIP := map[netip.Addr]*model.Reservation{}
type key struct {
subnet int64
id string
}
byClient := map[key]*model.Reservation{}
for _, r := range st.Reservations {
obj := "reservation " + r.Label()
if r.MAC == "" && r.ClientID == "" {
c.add(Error, "reservation_no_client", obj, "a reservation needs the client's MAC address or client identifier")
}
if !r.IP.Is4() {
c.add(Error, "reservation_ip", obj, "the address must be an IPv4 address")
continue
}
s := st.SubnetFor(r.IP)
if s == nil {
c.add(Error, "reservation_no_subnet", obj, "%s is in none of the subnets; add the subnet first", r.IP)
continue
}
if r.IP == s.Prefix.Addr() || r.IP == model.Last(s.Prefix) {
c.add(Error, "reservation_network_address", obj, "%s is the network or broadcast address of %s", r.IP, s.Prefix)
}
if o := byIP[r.IP]; o != nil {
c.add(Error, "reservation_ip_taken", obj, "%s is already reserved for %s", r.IP, o.Label())
}
byIP[r.IP] = r
k := key{s.ID, "mac " + r.MAC}
if r.ClientID != "" {
k.id = "id " + r.ClientID
}
if o := byClient[k]; o != nil {
c.add(Error, "reservation_client_taken", obj, "this client already has a reservation in %s: %s", s.Label(), o.Label())
}
byClient[k] = r
if r.Hostname != "" && !validHostname(r.Hostname) {
c.add(Error, "reservation_hostname", obj, "%q isn't a valid host name (letters, digits and '-', labels up to 63 characters, separated by dots)", r.Hostname)
}
c.options(r.Options, obj)
if env != nil && s.Interface != "" {
for _, a := range env.Interfaces[s.Interface] {
if a.Addr() == r.IP {
c.add(Warning, "reservation_server_address", obj, "%s is this server's own address on %s", r.IP, s.Interface)
}
}
}
}
}
func validIfName(n string) bool {
if n == "" || len(n) > 15 {
return false
}
for _, c := range n {
if c <= ' ' || c == '/' || c == ':' || c > '~' {
return false
}
}
return true
}
func validHostname(n string) bool {
n = strings.TrimSuffix(n, ".")
if n == "" || len(n) > 253 {
return false
}
for _, l := range strings.Split(n, ".") {
if l == "" || len(l) > 63 || l[0] == '-' || l[len(l)-1] == '-' {
return false
}
for _, c := range l {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') {
return false
}
}
}
return true
}
// ValidHostname reports whether n can be sent as a host name.
func ValidHostname(n string) bool { return validHostname(n) }
+129
View File
@@ -0,0 +1,129 @@
package validate
import (
"net/netip"
"strings"
"testing"
"steward/internal/model"
)
func pool(s string) model.Pool {
p, err := model.ParsePool(s)
if err != nil {
panic(err)
}
return p
}
func good() *model.State {
return &model.State{
Settings: model.DefaultSettings(),
Subnets: []*model.Subnet{
{ID: 1, Name: "office", Interface: "eth0", Prefix: netip.MustParsePrefix("10.0.10.0/24"), Pools: []model.Pool{pool("10.0.10.100-10.0.10.199")},
Options: []model.OptionValue{{Code: 3, Value: "10.0.10.1"}}},
{ID: 2, Name: "iot", Interface: "eth0.20", Prefix: netip.MustParsePrefix("10.0.20.0/24")},
},
Reservations: []*model.Reservation{
{ID: 3, MAC: "aa:bb:cc:dd:ee:01", IP: netip.MustParseAddr("10.0.10.20"), Hostname: "nas"},
},
}
}
func codes(ps []Problem) string {
var out []string
for _, p := range ps {
out = append(out, p.Severity+":"+p.Code)
}
return strings.Join(out, " ")
}
func TestGoodConfig(t *testing.T) {
st := good()
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.100-10.0.20.200")}
if ps := Check(st, nil); len(ps) != 0 {
t.Errorf("problems: %v", ps)
}
}
func TestProblems(t *testing.T) {
cases := []struct {
name string
edit func(st *model.State)
want string
}{
{"host bits", func(st *model.State) { st.Subnets[0].Prefix = netip.MustParsePrefix("10.0.10.5/24") }, "error:subnet_prefix"},
{"too small", func(st *model.State) { st.Subnets[0].Prefix = netip.MustParsePrefix("10.0.10.0/31") }, "error:subnet_size"},
{"ipv6", func(st *model.State) { st.Subnets[0].Prefix = netip.MustParsePrefix("2001:db8::/64") }, "error:subnet_prefix"},
{"overlap", func(st *model.State) { st.Subnets[1].Prefix = netip.MustParsePrefix("10.0.0.0/16") }, "error:subnet_overlap"},
{"name taken", func(st *model.State) { st.Subnets[1].Name = "Office" }, "error:subnet_name_taken"},
{"no interface", func(st *model.State) { st.Subnets[0].Interface = "" }, "error:subnet_interface_missing"},
{"bad interface", func(st *model.State) { st.Subnets[0].Interface = "eth 0" }, "error:subnet_interface_name"},
{"no pools", func(st *model.State) {}, "warning:subnet_no_pools"},
{"pool outside", func(st *model.State) { st.Subnets[0].Pools = []model.Pool{pool("10.0.11.1-10.0.11.9")} }, "error:pool_outside_subnet"},
{"pool network", func(st *model.State) { st.Subnets[0].Pools = []model.Pool{pool("10.0.10.0/24")} }, "error:pool_network_address"},
{"pool overlap", func(st *model.State) {
st.Subnets[0].Pools = append(st.Subnets[0].Pools, pool("10.0.10.150-10.0.10.250"))
}, "error:pool_overlap"},
{"lease time", func(st *model.State) { st.Subnets[0].LeaseTime = 10 }, "error:lease_time_range"},
{"reserved option", func(st *model.State) { st.Settings.Options = []model.OptionValue{{Code: 51, Value: "1"}} }, "error:option_reserved"},
{"bad option", func(st *model.State) { st.Subnets[0].Options = []model.OptionValue{{Code: 6, Value: "dns.example"}} }, "error:option_invalid"},
{"duplicate option", func(st *model.State) {
st.Subnets[0].Options = append(st.Subnets[0].Options, model.OptionValue{Code: 3, Value: "10.0.10.2"})
}, "error:option_duplicate"},
{"router outside", func(st *model.State) { st.Subnets[0].Options = []model.OptionValue{{Code: 3, Value: "10.0.11.1"}} }, "warning:router_outside_subnet"},
{"router in pool", func(st *model.State) { st.Subnets[0].Options = []model.OptionValue{{Code: 3, Value: "10.0.10.150"}} }, "warning:router_in_pool"},
{"no client", func(st *model.State) { st.Reservations[0].MAC = "" }, "error:reservation_no_client"},
{"no subnet", func(st *model.State) { st.Reservations[0].IP = netip.MustParseAddr("192.168.1.5") }, "error:reservation_no_subnet"},
{"broadcast", func(st *model.State) { st.Reservations[0].IP = netip.MustParseAddr("10.0.10.255") }, "error:reservation_network_address"},
{"ip taken", func(st *model.State) {
st.Reservations = append(st.Reservations, &model.Reservation{ID: 9, MAC: "aa:bb:cc:dd:ee:02", IP: netip.MustParseAddr("10.0.10.20")})
}, "error:reservation_ip_taken"},
{"client taken", func(st *model.State) {
st.Reservations = append(st.Reservations, &model.Reservation{ID: 9, MAC: "aa:bb:cc:dd:ee:01", IP: netip.MustParseAddr("10.0.10.21")})
}, "error:reservation_client_taken"},
{"hostname", func(st *model.State) { st.Reservations[0].Hostname = "my_nas" }, "error:reservation_hostname"},
}
for _, c := range cases {
st := good()
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.100-10.0.20.200")}
if c.name == "no pools" {
st.Subnets[1].Pools = nil
}
c.edit(st)
got := codes(Check(st, nil))
if !strings.Contains(" "+got+" ", " "+c.want+" ") {
t.Errorf("%s: got %q, want %s", c.name, got, c.want)
}
}
}
func TestSameMACInTwoSubnets(t *testing.T) {
st := good()
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.100-10.0.20.200")}
st.Reservations = append(st.Reservations, &model.Reservation{ID: 9, MAC: "aa:bb:cc:dd:ee:01", IP: netip.MustParseAddr("10.0.20.20")})
if ps := Check(st, nil); len(ps) != 0 {
t.Errorf("a laptop on two VLANs: %v", ps)
}
}
func TestHostInterfaces(t *testing.T) {
st := good()
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.100-10.0.20.200")}
st.Subnets[0].Pools = []model.Pool{pool("10.0.10.1-10.0.10.199")}
st.Subnets[0].Options = nil
env := &Env{Interfaces: map[string][]netip.Prefix{
"eth0": {netip.MustParsePrefix("10.0.10.2/24")},
"wg0": {netip.MustParsePrefix("10.9.0.1/24")},
}}
got := codes(Check(st, env))
for _, want := range []string{"warning:pool_includes_server", "warning:interface_unknown"} {
if !strings.Contains(got, want) {
t.Errorf("got %q, want %s", got, want)
}
}
env.Interfaces["eth0.20"] = []netip.Prefix{netip.MustParsePrefix("192.168.5.1/24")}
if got := codes(Check(st, env)); !strings.Contains(got, "warning:interface_no_address") {
t.Errorf("got %q, want interface_no_address", got)
}
}