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

+337
View File
@@ -0,0 +1,337 @@
package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"steward/internal/api"
"steward/internal/auth"
"steward/internal/dhcp4"
"steward/internal/dhcpd"
"steward/internal/engine"
"steward/internal/model"
"steward/internal/store"
)
type hostIfaces map[string][]netip.Prefix
func (h hostIfaces) Addrs(name string) ([]netip.Prefix, error) {
a, ok := h[name]
if !ok {
return nil, errors.New("no interface " + name)
}
return a, nil
}
func (h hostIfaces) All() (map[string][]netip.Prefix, error) { return h, nil }
type env struct {
t *testing.T
ts *httptest.Server
srv *Server
dhcp *dhcpd.Server
eng *engine.Engine
token string // the admin's API token
}
func newEnv(t *testing.T) *env {
t.Helper()
ctx := context.Background()
st, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
hash, _ := auth.HashPassword("secret-pass-1")
admin := &model.User{Username: "admin", PasswordHash: hash, Admin: true}
if err := st.CreateUser(ctx, admin); err != nil {
t.Fatal(err)
}
tok, pid, sh := auth.NewToken()
st.CreateToken(ctx, &model.Token{UserID: admin.ID, Name: "test", PublicID: pid, SecretHash: sh})
eng, err := engine.New(ctx, st, nil)
if err != nil {
t.Fatal(err)
}
ifs := hostIfaces{"eth0": {netip.MustParsePrefix("10.0.10.2/24")}, "eth0.20": {netip.MustParsePrefix("10.0.20.1/24")}}
d := dhcpd.New(nil, st, ifs, nil)
eng.OnChange = d.SetConfig
srv := New(Config{}, eng, d, ifs, nil, nil)
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return &env{t: t, ts: ts, srv: srv, dhcp: d, eng: eng, token: tok}
}
// call makes a request with a token; it returns the status and decodes
// the body into out (if given).
func (e *env) call(token, method, path string, body any, out any) int {
e.t.Helper()
var rd io.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, e.ts.URL+api.Prefix+path, rd)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
e.t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if out != nil && len(b) > 0 {
if err := json.Unmarshal(b, out); err != nil {
e.t.Fatalf("%s %s: %v: %s", method, path, err, b)
}
}
return resp.StatusCode
}
func ptr[T any](v T) *T { return &v }
func TestLoginSessionAndCSRF(t *testing.T) {
e := newEnv(t)
jar, _ := cookiejar.New(nil)
c := &http.Client{Jar: jar}
post := func(path, body, csrf string) *http.Response {
req, _ := http.NewRequest("POST", e.ts.URL+api.Prefix+path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if csrf != "" {
req.Header.Set("X-CSRF-Token", csrf)
}
resp, err := c.Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
if r := post("/auth/login", `{"username":"admin","password":"wrong"}`, ""); r.StatusCode != 401 {
t.Errorf("wrong password: %d", r.StatusCode)
}
r := post("/auth/login", `{"username":"admin","password":"secret-pass-1"}`, "")
var me api.Whoami
json.NewDecoder(r.Body).Decode(&me)
if r.StatusCode != 200 || me.CSRFToken == "" || !me.Admin {
t.Fatalf("login: %d %+v", r.StatusCode, me)
}
body := `{"prefix":"10.0.10.0/24","interface":"eth0","pools":["10.0.10.100-10.0.10.199"]}`
if r := post("/subnets", body, ""); r.StatusCode != 403 {
t.Errorf("change without CSRF token: %d", r.StatusCode)
}
if r := post("/subnets", body, me.CSRFToken); r.StatusCode != 201 {
b, _ := io.ReadAll(r.Body)
t.Errorf("change with CSRF token: %d %s", r.StatusCode, b)
}
h, _ := e.srv.store.ListHistory(context.Background(), 1, 0)
if len(h) != 1 || h[0].Via != "web" || h[0].Username != "admin" {
t.Errorf("history %+v", h)
}
}
func TestConfigFlow(t *testing.T) {
e := newEnv(t)
var mr api.MutationResponse
// A dry run changes nothing.
sub := api.SubnetInput{Name: ptr("office"), Prefix: ptr("10.0.10.0/24"), Interface: ptr("eth0"), Pools: &[]string{"10.0.10.100-10.0.10.199"},
Options: &[]api.OptionInput{{Option: "routers", Value: "10.0.10.1"}, {Option: "domain-name-servers", Value: "10.0.10.2,10.0.10.3"}}}
if code := e.call(e.token, "POST", "/subnets?dry_run=1", sub, &mr); code != 200 || mr.Applied || len(mr.Changes) != 1 {
t.Fatalf("dry run: %d %+v", code, mr)
}
if code := e.call(e.token, "POST", "/subnets", sub, &mr); code != 201 || !mr.Applied {
t.Fatalf("create: %d %+v", code, mr)
}
var sn model.Subnet
json.Unmarshal(mr.Object, &sn)
if sn.ID == 0 || sn.Options[1].Value != "10.0.10.2, 10.0.10.3" {
t.Errorf("created %+v", sn)
}
// An invalid change is refused with the problem.
var eb api.ErrorBody
bad := api.SubnetInput{Prefix: ptr("10.0.0.0/16"), Interface: ptr("eth1")}
if code := e.call(e.token, "POST", "/subnets", bad, &eb); code != 422 || eb.Error.Problems[0].Code != "subnet_overlap" {
t.Errorf("overlap: %d %+v", code, eb)
}
if code := e.call(e.token, "POST", "/subnets", api.SubnetInput{Prefix: ptr("10.0.30.0/24"), Interface: ptr("eth0"), Options: &[]api.OptionInput{{Option: "dhcp-lease-time", Value: "1"}}}, &eb); code != 400 || !strings.Contains(eb.Error.Message, "unknown option") {
t.Errorf("unknown option: %d %+v", code, eb)
}
if code := e.call(e.token, "POST", "/subnets", api.SubnetInput{Prefix: ptr("10.0.30.0/24"), Interface: ptr("eth0"), Options: &[]api.OptionInput{{Option: "51", Value: "1"}}}, &eb); code != 400 || !strings.Contains(eb.Error.Message, "set the lease time instead") {
t.Errorf("reserved option: %d %+v", code, eb)
}
// Reservations; by name, address or MAC.
res := api.ReservationInput{MAC: ptr("AA-BB-CC-00-00-01"), IP: ptr("10.0.10.20"), Hostname: ptr("nas")}
if code := e.call(e.token, "POST", "/reservations", res, &mr); code != 201 {
t.Fatalf("reservation: %d %+v", code, mr)
}
var rv api.Reservation
if code := e.call(e.token, "GET", "/reservations/aa:bb:cc:00:00:01", nil, &rv); code != 200 || rv.IP.String() != "10.0.10.20" || rv.SubnetName != "office" {
t.Errorf("get reservation: %d %+v", code, rv)
}
if code := e.call(e.token, "PATCH", "/subnets/office", api.SubnetInput{LeaseTime: ptr(3600)}, &mr); code != 200 || !mr.Applied {
t.Errorf("update by name: %d %+v", code, mr)
}
var subs []api.Subnet
e.call(e.token, "GET", "/subnets", nil, &subs)
if len(subs) != 1 || subs[0].LeaseTime != 3600 || subs[0].Usage.PoolSize != 100 || subs[0].Usage.Reservations != 1 {
t.Errorf("subnets %+v", subs[0])
}
// Undo the lease time change.
var hs []model.HistoryEntry
e.call(e.token, "GET", "/history", nil, &hs)
var rr api.MutationResponse
if code := e.call(e.token, "POST", "/history/"+itoa(hs[0].ID)+"/revert", nil, &rr); code != 200 || !rr.Applied || rr.DryRun {
t.Fatalf("revert: %d %+v", code, rr)
}
if e.eng.State().Subnets[0].LeaseTime != 0 {
t.Error("revert didn't undo the lease time")
}
// Deleting the subnet takes its reservation along.
var dr api.MutationResponse
if code := e.call(e.token, "DELETE", "/subnets/10.0.10.0_24", nil, &dr); code != 200 || len(dr.Changes) != 2 {
t.Errorf("delete: %d %+v", code, dr)
}
}
func itoa(n int64) string { b, _ := json.Marshal(n); return string(b) }
func TestLeasesThroughDHCP(t *testing.T) {
e := newEnv(t)
var mr api.MutationResponse
e.call(e.token, "POST", "/subnets", api.SubnetInput{Name: ptr("iot"), Prefix: ptr("10.0.20.0/24"), Interface: ptr("eth0.20"), Pools: &[]string{"10.0.20.100-10.0.20.110"}}, &mr)
// A client gets an address.
mac := net.HardwareAddr{0xaa, 0, 0, 0, 0, 1}
disc := dhcp4.NewRequest(dhcp4.Discover, mac, 1)
off := e.dhcp.Handle(context.Background(), "eth0.20", disc)
if off == nil {
t.Fatal("no offer")
}
req := dhcp4.NewRequest(dhcp4.Request, mac, 2)
req.Set(dhcp4.OptServerID, dhcp4.IP(netip.MustParseAddr("10.0.20.1")))
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(off.Packet.YIAddr))
req.Set(dhcp4.OptHostName, []byte("sensor"))
if ack := e.dhcp.Handle(context.Background(), "eth0.20", req); ack == nil || ack.Packet.Type() != dhcp4.Ack {
t.Fatal("no ack")
}
var leases []api.Lease
if code := e.call(e.token, "GET", "/leases?q=sensor", nil, &leases); code != 200 || len(leases) != 1 || leases[0].SubnetName != "iot" || leases[0].State != "active" {
t.Fatalf("leases: %d %+v", code, leases)
}
ip := leases[0].IP.String()
// Make it a reservation.
if code := e.call(e.token, "POST", "/leases/"+ip+"/reserve", nil, &mr); code != 201 || !mr.Applied {
t.Fatalf("reserve: %d %+v", code, mr)
}
var rv api.Reservation
json.Unmarshal(mr.Object, &rv)
if rv.MAC != mac.String() || rv.Hostname != "sensor" || rv.Lease == nil {
t.Errorf("reservation from lease %+v", rv)
}
var st api.Status
if code := e.call(e.token, "GET", "/status", nil, &st); code != 200 || st.Leases["active"] != 1 || st.Subnets[0].Usage.ReservationsLeased != 1 || st.Messages["DHCPACK"] != 1 {
t.Errorf("status: %d %+v", code, st)
}
var evs []api.Event
e.call(e.token, "GET", "/events?mac="+mac.String(), nil, &evs)
if len(evs) != 4 || evs[0].Type != "DHCPACK" {
t.Errorf("events %+v", evs)
}
if code := e.call(e.token, "DELETE", "/leases/"+ip, nil, nil); code != 204 {
t.Errorf("delete lease: %d", code)
}
if code := e.call(e.token, "DELETE", "/leases/"+ip, nil, nil); code != 404 {
t.Errorf("delete again: %d", code)
}
var audit []model.AuditEvent
e.call(e.token, "GET", "/audit?action=lease.", nil, &audit)
if len(audit) != 1 || audit[0].Object != "lease "+ip {
t.Errorf("audit %+v", audit)
}
}
func TestPermissions(t *testing.T) {
e := newEnv(t)
var tok api.Token
if code := e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "monitor", Role: "leases"}, &tok); code != 201 || tok.Secret == "" {
t.Fatalf("token: %d %+v", code, tok)
}
if code := e.call(tok.Secret, "GET", "/leases", nil, nil); code != 200 {
t.Errorf("leases with a leases:read token: %d", code)
}
var eb api.ErrorBody
if code := e.call(tok.Secret, "POST", "/subnets", api.SubnetInput{Prefix: ptr("10.0.10.0/24"), Interface: ptr("eth0")}, &eb); code != 403 || !strings.Contains(eb.Error.Message, "config:write") {
t.Errorf("change with a read token: %d %+v", code, eb)
}
if code := e.call(tok.Secret, "GET", "/users", nil, nil); code != 403 {
t.Errorf("users with a restricted token: %d", code)
}
// A restricted token can't make a wider one.
if code := e.call(tok.Secret, "POST", "/me/tokens", api.TokenInput{Name: "wider", Role: "operator"}, nil); code != 403 {
t.Errorf("wider token: %d", code)
}
if code := e.call("", "GET", "/status", nil, nil); code != 401 {
t.Errorf("no token: %d", code)
}
if code := e.call("steward_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "GET", "/status", nil, nil); code != 401 {
t.Errorf("bad token: %d", code)
}
// The last administrator stays.
var users []api.User
e.call(e.token, "GET", "/users", nil, &users)
if code := e.call(e.token, "PATCH", "/users/"+itoa(users[0].ID), api.UserInput{Admin: ptr(false)}, &eb); code != 409 {
t.Errorf("demoting the last admin: %d", code)
}
}
func TestImportKea(t *testing.T) {
e := newEnv(t)
cfg := `{"Dhcp4": {
"interfaces-config": {"interfaces": ["eth0", "eth0.20"]},
"subnet4": [{"subnet": "10.0.10.0/24", "pools": [{"pool": "10.0.10.100 - 10.0.10.199"}],
"option-data": [{"name": "routers", "data": "10.0.10.1"}],
"reservations": [{"hw-address": "aa:bb:cc:00:00:01", "ip-address": "10.0.10.20", "hostname": "nas"}]},
{"subnet": "172.16.0.0/24", "pools": [{"pool": "172.16.0.10-172.16.0.20"}]}],
"hooks-libraries": []}}`
leases := "address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context\n" +
"10.0.10.150,aa:bb:cc:00:00:09,,43200,4000000000,1,0,0,tv,0,\n10.9.9.9,aa:bb:cc:00:00:0a,,43200,4000000000,9,0,0,x,0,\n"
var eb api.ErrorBody
if code := e.call(e.token, "POST", "/import/kea", KeaImport{Config: cfg, Leases: []string{leases}}, &eb); code != 400 || !strings.Contains(eb.Error.Message, "172.16.0.0/24") {
t.Fatalf("subnet without an interface: %d %+v", code, eb)
}
in := KeaImport{Config: cfg, Leases: []string{leases}, Interfaces: map[string]string{"172.16.0.0/24": "eth0.20"}}
var ir api.ImportResult
if code := e.call(e.token, "POST", "/import/kea", in, &ir); code != 200 || !ir.Applied || ir.Leases != 1 || len(ir.Changes) != 3 {
t.Fatalf("import: %d %+v", code, ir)
}
if !strings.Contains(strings.Join(ir.Skipped, " "), "hooks-libraries") || !strings.Contains(strings.Join(ir.Notes, " "), "outside the imported subnets") {
t.Errorf("notes %v skipped %v", ir.Notes, ir.Skipped)
}
if l := e.dhcp.Lease(netip.MustParseAddr("10.0.10.150")); l == nil || l.Hostname != "tv" || l.Interface != "eth0" {
t.Errorf("imported lease %+v", l)
}
// Importing again needs replace.
if code := e.call(e.token, "POST", "/import/kea", in, &eb); code != 409 {
t.Errorf("second import: %d", code)
}
in.Replace = true
var again api.ImportResult
if code := e.call(e.token, "POST", "/import/kea?dry_run=1", in, &again); code != 200 || again.Applied || len(again.Changes) != 0 {
t.Errorf("the same import again changes something: %d %+v", code, again)
}
// A name given in Steward survives a new import.
e.call(e.token, "PATCH", "/subnets/10.0.10.0_24", api.SubnetInput{Name: ptr("office")}, nil)
var third api.ImportResult
if code := e.call(e.token, "POST", "/import/kea", in, &third); code != 200 || len(third.Changes) != 0 || e.eng.State().FindSubnet("office") == nil {
t.Errorf("import over a named subnet: %d %+v", code, third)
}
}