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

+149
View File
@@ -0,0 +1,149 @@
package server
import (
"fmt"
"net/http"
"sort"
"strings"
"time"
"steward/internal/api"
"steward/internal/auth"
"steward/internal/engine"
"steward/internal/kea"
"steward/internal/model"
)
// KeaImport is the body of POST /import/kea.
type KeaImport struct {
// Config is the kea-dhcp4.conf text; Leases the lease files' texts
// (kea-leases4.csv, and kea-leases4.csv.2 if there is one, older
// first).
Config string `json:"config"`
Leases []string `json:"leases,omitempty"`
// Interfaces gives subnets' interfaces by prefix, for subnets Kea
// doesn't tie to one.
Interfaces map[string]string `json:"interfaces,omitempty"`
// Replace replaces Steward's configuration when it already has
// subnets or reservations.
Replace bool `json:"replace,omitempty"`
}
// importKea: POST /import/kea makes Steward's configuration Kea's, and
// adds Kea's current leases (?dry_run=1 shows what it would do).
func (s *Server) importKea(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
if err := require(p, auth.ResLeases, true); err != nil {
return err
}
var in KeaImport
if err := decodeJSON(r, &in); err != nil {
return err
}
host, _ := s.ifaces.All()
res, err := kea.ParseConfig([]byte(in.Config), kea.Options{HostInterfaces: host, Interfaces: in.Interfaces})
if err != nil {
return badRequest("%v", err)
}
var missing []string
for _, sn := range res.State.Subnets {
if sn.Interface == "" {
missing = append(missing, sn.Prefix.String())
}
}
if len(missing) > 0 {
return badRequest("Kea doesn't say which interface serves %s, and no interface here has an address in it; give it: --interface %s=IFACE", strings.Join(missing, ", "), missing[0])
}
cur := s.eng.State()
if (len(cur.Subnets) > 0 || len(cur.Reservations) > 0) && !in.Replace {
return conflict("Steward already has %d subnets and %d reservations; import with replace (--replace) to replace them with Kea's", len(cur.Subnets), len(cur.Reservations))
}
// A subnet with the same prefix, or a reservation for the same address,
// keeps its ID, so importing again shows only what differs. Kea has no
// subnet names: Steward's stay.
ids, err := s.store.AllocateIDs(r.Context(), len(res.State.Subnets)+len(res.State.Reservations))
if err != nil {
return err
}
for i, sn := range res.State.Subnets {
sn.ID = ids[i]
for _, old := range cur.Subnets {
if old.Prefix == sn.Prefix {
sn.ID, sn.Name = old.ID, old.Name
if sn.Comment == "" {
sn.Comment = old.Comment
}
}
}
}
for i, rv := range res.State.Reservations {
rv.ID = ids[len(res.State.Subnets)+i]
for _, old := range cur.Reservations {
if old.IP == rv.IP {
rv.ID = old.ID
if rv.Comment == "" {
rv.Comment = old.Comment
}
}
}
}
// Steward's own settings that Kea has no word for stay.
res.State.Settings.PingCheck = cur.Settings.PingCheck
res.State.Settings.PingTimeout = cur.Settings.PingTimeout
res.State.Settings.NotAuthoritative = cur.Settings.NotAuthoritative
var leases []*model.Lease
skippedLeases := 0
now := time.Now()
for i, text := range in.Leases {
ls, err := kea.ParseLeases(strings.NewReader(text), now)
if err != nil {
return badRequest("lease file %d: %v", i+1, err)
}
leases = append(leases, ls...)
}
// A later file's lease for an address replaces an earlier one's.
byIP := map[string]*model.Lease{}
for _, l := range leases {
sn := res.State.SubnetFor(l.IP)
if sn == nil {
skippedLeases++
continue
}
l.SubnetID, l.Interface = sn.ID, sn.Interface
byIP[l.IP.String()] = l
}
leases = leases[:0]
for _, l := range byIP {
leases = append(leases, l)
}
sort.Slice(leases, func(i, j int) bool { return leases[i].IP.Less(leases[j].IP) })
if skippedLeases > 0 {
res.Notes = append(res.Notes, fmt.Sprintf("%d leases are for addresses outside the imported subnets and were left out", skippedLeases))
}
result, err := s.eng.Replace(r.Context(), p, "import from Kea", engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)}, res.State)
if err != nil {
s.writeErr(w, r, err, result)
return nil
}
if result.Applied && len(leases) > 0 {
if err := s.dhcp.ImportLeases(r.Context(), leases); err != nil {
return fmt.Errorf("the configuration was imported, but saving the leases failed: %w", err)
}
s.eng.Audit(r.Context(), p, "lease.import", "kea", "success", fmt.Sprintf("%d leases", len(leases)))
}
out := api.ImportResult{MutationResponse: api.MutationResponse{Applied: result.Applied, DryRun: result.DryRun, HistoryID: result.HistoryID, Changes: result.Changes, Warnings: result.Warnings},
Leases: len(leases), Notes: res.Notes, Skipped: res.Skipped}
if out.Changes == nil {
out.Changes = []model.Change{}
}
if out.Notes == nil {
out.Notes = []string{}
}
if out.Skipped == nil {
out.Skipped = []string{}
}
writeJSON(w, http.StatusOK, out)
return nil
}