Files
steward/internal/kea/kea.go
T
abrown38andClaude Opus 5.5 8ee430276c Address order: sequential, reverse or random, per subnet and pool
- Which never-used pool address a new client gets: sequential (the
  lowest, as before), reverse or random, in the settings, a subnet or
  a single pool (FIRST-LAST:ORDER). When no never-used address is left,
  the one free the longest, whatever the order; returning clients keep
  theirs.
- A subnet's pools are used in the order listed, or one at random
  weighted by its never-used addresses. Pools now keep the order they
  are given in (no more sorting by address).
- CLI --address-order and --pool-order; --add-pool of an existing range
  changes its order in place. Web UI: per-pool order and a move-up
  button, pool and address order on the subnet and settings pages.
- Kea's allocator: random is imported. Schema migration 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:13:25 +01:00

608 lines
17 KiB
Go

// Package kea reads a Kea DHCPv4 configuration (kea-dhcp4.conf) and its
// lease file (the memfile CSV), so a Kea setup can move to Steward without
// renumbering anything. What Steward doesn't do is listed, not dropped
// silently.
package kea
import (
"bytes"
"encoding/csv"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/netip"
"sort"
"strconv"
"strings"
"time"
"steward/internal/dhcp4"
"steward/internal/model"
)
// Result is what an import makes of a Kea configuration.
type Result struct {
// State has the settings, subnets and reservations (IDs not set).
State *model.State
// Notes say how things were mapped; Skipped lists what Steward
// doesn't support, so it isn't lost silently.
Notes []string
Skipped []string
}
// Options steer an import.
type Options struct {
// HostInterfaces are this machine's interfaces with their prefixes,
// to find a subnet's interface when Kea doesn't name it.
HostInterfaces map[string][]netip.Prefix
// Interfaces gives the interface of subnets by prefix
// ("10.0.20.0/24" -> "eth0.20"); it wins over the configuration.
Interfaces map[string]string
}
// StripComments removes Kea's comments (#, // and /* */) outside strings.
func StripComments(b []byte) []byte {
var out bytes.Buffer
inStr, esc := false, false
for i := 0; i < len(b); i++ {
c := b[i]
if inStr {
out.WriteByte(c)
switch {
case esc:
esc = false
case c == '\\':
esc = true
case c == '"':
inStr = false
}
continue
}
switch {
case c == '"':
inStr = true
out.WriteByte(c)
case c == '#' || c == '/' && i+1 < len(b) && b[i+1] == '/':
for i < len(b) && b[i] != '\n' {
i++
}
out.WriteByte('\n')
case c == '/' && i+1 < len(b) && b[i+1] == '*':
i += 2
for i+1 < len(b) && !(b[i] == '*' && b[i+1] == '/') {
if b[i] == '\n' {
out.WriteByte('\n')
}
i++
}
i++
default:
out.WriteByte(c)
}
}
return out.Bytes()
}
type obj = map[string]json.RawMessage
type importer struct {
opts Options
res *Result
globals []string // interfaces-config interfaces
// allocator is Dhcp4's allocator (iterative, random or flq).
allocator string
noted map[string]bool
}
// ParseConfig reads a kea-dhcp4.conf.
func ParseConfig(data []byte, opts Options) (*Result, error) {
clean := StripComments(data)
if bytes.Contains(clean, []byte("<?include")) {
return nil, fmt.Errorf("the configuration uses <?include ...?>; put the included files' contents in place first")
}
var top obj
if err := json.Unmarshal(clean, &top); err != nil {
return nil, fmt.Errorf("reading the Kea configuration: %w", jsonError(clean, err))
}
raw, ok := top["Dhcp4"]
if !ok {
return nil, fmt.Errorf("no Dhcp4 section: is this a kea-dhcp4.conf?")
}
var d4 obj
if err := json.Unmarshal(raw, &d4); err != nil {
return nil, fmt.Errorf("Dhcp4: %w", err)
}
im := &importer{opts: opts, res: &Result{State: &model.State{Settings: model.DefaultSettings(), Subnets: []*model.Subnet{}, Reservations: []*model.Reservation{}}}}
for k := range top {
if k != "Dhcp4" {
im.skip("the %s section (Steward imports Dhcp4 only)", k)
}
}
if err := im.dhcp4(d4); err != nil {
return nil, err
}
return im.res, nil
}
// jsonError adds the line number to a JSON syntax error.
func jsonError(b []byte, err error) error {
if se, ok := err.(*json.SyntaxError); ok {
line := 1 + bytes.Count(b[:min(int(se.Offset), len(b))], []byte("\n"))
return fmt.Errorf("line %d: %v", line, err)
}
return err
}
// noteOnce adds a note once per key.
func (im *importer) noteOnce(key, format string, args ...any) {
if im.noted == nil {
im.noted = map[string]bool{}
}
if !im.noted[key] {
im.noted[key] = true
im.note(format, args...)
}
}
func (im *importer) note(format string, args ...any) {
im.res.Notes = append(im.res.Notes, fmt.Sprintf(format, args...))
}
func (im *importer) skip(format string, args ...any) {
im.res.Skipped = append(im.res.Skipped, fmt.Sprintf(format, args...))
}
func get[T any](o obj, key string, into *T) (bool, error) {
raw, ok := o[key]
if !ok {
return false, nil
}
if err := json.Unmarshal(raw, into); err != nil {
return true, fmt.Errorf("%s: %v", key, err)
}
return true, nil
}
// Keys of Dhcp4 that are handled, ignored on purpose, or skipped with a
// note.
var (
handledKeys = map[string]bool{"interfaces-config": true, "valid-lifetime": true, "option-data": true, "subnet4": true, "shared-networks": true,
"reservations": true, "authoritative": true, "decline-probation-period": true, "allocator": true}
ignoredKeys = map[string]string{
"lease-database": "Steward keeps leases in its own database", "control-socket": "Steward has its own API",
"loggers": "Steward logs to the journal", "renew-timer": "Steward sends half the lease time",
"rebind-timer": "Steward sends 7/8 of the lease time", "expired-leases-processing": "Steward expires leases itself",
"sanity-checks": "", "multi-threading": "", "server-tag": "", "option-def": "", "calculate-tee-times": "", "t1-percent": "", "t2-percent": "",
"min-valid-lifetime": "", "max-valid-lifetime": "", "host-reservation-identifiers": "", "echo-client-id": "", "match-client-id": "",
"user-context": "", "comment": "", "cache-threshold": "", "dhcp4o6-port": "", "hostname-char-set": "", "hostname-char-replacement": "",
}
)
func (im *importer) dhcp4(d obj) error {
var keys []string
for k := range d {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if handledKeys[k] {
continue
}
if why, ok := ignoredKeys[k]; ok {
if why != "" {
im.note("%s isn't needed: %s", k, why)
}
continue
}
im.skip("%s (not supported by Steward 0.1)", k)
}
var ic struct {
Interfaces []string `json:"interfaces"`
}
if _, err := get(d, "interfaces-config", &ic); err != nil {
return err
}
for _, i := range ic.Interfaces {
name, _, _ := strings.Cut(i, "/")
if name != "*" && name != "" {
im.globals = append(im.globals, name)
}
}
get(d, "allocator", &im.allocator)
set := &im.res.State.Settings
var vl int
if ok, err := get(d, "valid-lifetime", &vl); err != nil {
return err
} else if ok {
set.LeaseTime = vl
}
var dpp int
if ok, err := get(d, "decline-probation-period", &dpp); err != nil {
return err
} else if ok {
set.DeclineHold = dpp
}
var authoritative bool
if ok, err := get(d, "authoritative", &authoritative); err != nil {
return err
} else if !ok || !authoritative {
im.note("Kea wasn't authoritative; Steward is by default (it answers requests for addresses that don't belong on an interface with a NAK, so clients start over at once); turn it off in the settings if another DHCP server shares a network")
}
var err error
if set.Options, err = im.options(d, "global"); err != nil {
return err
}
var subnets []obj
if _, err := get(d, "subnet4", &subnets); err != nil {
return err
}
for _, s := range subnets {
if err := im.subnet(s, nil); err != nil {
return err
}
}
var shared []obj
if _, err := get(d, "shared-networks", &shared); err != nil {
return err
}
for _, sh := range shared {
var name string
get(sh, "name", &name)
var subs []obj
if _, err := get(sh, "subnet4", &subs); err != nil {
return fmt.Errorf("shared network %s: %w", name, err)
}
if len(subs) > 1 {
im.note("shared network %s: its %d subnets are served together on one interface, as in Kea", name, len(subs))
}
for _, s := range subs {
if err := im.subnet(s, sh); err != nil {
return err
}
}
}
var global []obj
if _, err := get(d, "reservations", &global); err != nil {
return err
}
for _, r := range global {
if err := im.reservation(r, nil); err != nil {
return err
}
}
return nil
}
// options reads option-data.
func (im *importer) options(o obj, where string) ([]model.OptionValue, error) {
var list []obj
if _, err := get(o, "option-data", &list); err != nil {
return nil, fmt.Errorf("%s: %w", where, err)
}
out := []model.OptionValue{}
for _, od := range list {
var name, data, space string
var code int
csvFormat := true
get(od, "name", &name)
get(od, "code", &code)
get(od, "data", &data)
get(od, "space", &space)
get(od, "csv-format", &csvFormat)
if space != "" && space != "dhcp4" {
im.skip("%s: option %s%d in space %s", where, name, code, space)
continue
}
var def dhcp4.Def
ok := false
if name != "" {
def, ok = dhcp4.Lookup(name)
}
if !ok && code > 0 && code < 255 {
def, ok = dhcp4.ByCode(byte(code)), true
}
if !ok {
im.skip("%s: option %s (Steward doesn't know it by name; set it by number)", where, name)
continue
}
if why, reserved := dhcp4.Reserved[def.Code]; reserved {
im.note("%s: option %s left out: %s", where, def.Name, why)
continue
}
v := model.OptionValue{Code: int(def.Code), Value: data}
if !dhcp4.Known(def.Code) {
v.Type = dhcp4.TypeHex
}
if !csvFormat {
b, err := hex.DecodeString(strings.NewReplacer(":", "", " ", "").Replace(strings.TrimPrefix(strings.ToLower(data), "0x")))
if err != nil {
return nil, fmt.Errorf("%s: option %s: data %q isn't hex (csv-format is false)", where, def.Name, data)
}
typ := def.Type
if v.Type != "" {
typ = v.Type
}
v.Value = dhcp4.Decode(typ, b)
}
typ := def.Type
if v.Type != "" {
typ = v.Type
}
if _, err := dhcp4.Encode(typ, v.Value); err != nil {
return nil, fmt.Errorf("%s: option %s: %v", where, def.Name, err)
}
out = append(out, v)
}
return out, nil
}
func (im *importer) subnet(s obj, shared obj) error {
var prefix, iface, comment string
var lifetime int
get(s, "subnet", &prefix)
pfx, err := netip.ParsePrefix(prefix)
if err != nil || !pfx.Addr().Is4() {
return fmt.Errorf("subnet %q isn't an IPv4 prefix", prefix)
}
where := "subnet " + pfx.String()
sn := &model.Subnet{Prefix: pfx.Masked(), Pools: []model.Pool{}}
if shared != nil {
get(shared, "interface", &iface)
var name string
get(shared, "name", &name)
sn.Comment = "shared network " + name
if ok, _ := get(shared, "valid-lifetime", &lifetime); ok {
sn.LeaseTime = lifetime
}
so, err := im.options(shared, "shared network "+name)
if err != nil {
return err
}
sn.Options = so
}
alloc := im.allocator
if shared != nil {
get(shared, "allocator", &alloc)
}
get(s, "allocator", &alloc)
switch alloc {
case "random":
// Kea picks among all the subnet's free addresses.
sn.PoolOrder, sn.AddressOrder = model.PoolsRandom, model.OrderRandom
case "flq":
im.noteOnce("flq", "the allocator flq (free lease queue) became Steward's sequential order: a new client gets the lowest address never handed out, else the one free the longest")
}
if ok, _ := get(s, "interface", &iface); ok && iface == "" {
iface = ""
}
if ok, _ := get(s, "valid-lifetime", &lifetime); ok {
sn.LeaseTime = lifetime
}
if lt := im.res.State.Settings.LeaseTime; sn.LeaseTime == lt {
sn.LeaseTime = 0
}
var uc struct {
Comment string `json:"comment"`
}
if get(s, "user-context", &uc); uc.Comment != "" {
comment = uc.Comment
}
get(s, "comment", &comment)
if comment != "" {
sn.Comment = strings.TrimSpace(sn.Comment + " " + comment)
}
opts, err := im.options(s, where)
if err != nil {
return err
}
// The subnet's options over the shared network's.
sn.Options = mergeOptions(sn.Options, opts)
var pools []obj
if _, err := get(s, "pools", &pools); err != nil {
return fmt.Errorf("%s: %w", where, err)
}
for _, p := range pools {
var ps string
get(p, "pool", &ps)
pool, err := model.ParsePool(strings.ReplaceAll(ps, " ", ""))
if err != nil {
return fmt.Errorf("%s: %v", where, err)
}
sn.Pools = append(sn.Pools, pool)
if _, ok := p["option-data"]; ok {
im.skip("%s: options of pool %s (Steward sets options per subnet)", where, pool)
}
if _, ok := p["client-class"]; ok {
im.skip("%s: the client class of pool %s", where, pool)
}
}
for _, k := range []string{"relay", "client-class", "require-client-classes", "next-server", "boot-file-name", "4o6-interface", "4o6-subnet"} {
if _, ok := s[k]; ok {
im.skip("%s: %s", where, k)
}
}
if forced, ok := im.opts.Interfaces[sn.Prefix.String()]; ok {
iface = forced
}
if iface == "" {
iface = im.findInterface(sn.Prefix)
if iface != "" {
im.note("%s: served on %s (Kea didn't name its interface)", where, iface)
}
}
sn.Interface = iface
im.res.State.Subnets = append(im.res.State.Subnets, sn)
var res []obj
if _, err := get(s, "reservations", &res); err != nil {
return fmt.Errorf("%s: %w", where, err)
}
for _, r := range res {
if err := im.reservation(r, sn); err != nil {
return err
}
}
return nil
}
// findInterface guesses a subnet's interface: the host's interface with an
// address in it, else the only interface Kea listened on.
func (im *importer) findInterface(p netip.Prefix) string {
for name, addrs := range im.opts.HostInterfaces {
for _, a := range addrs {
if p.Contains(a.Addr()) {
return name
}
}
}
if len(im.globals) == 1 {
return im.globals[0]
}
return ""
}
func mergeOptions(base, over []model.OptionValue) []model.OptionValue {
out := []model.OptionValue{}
seen := map[int]bool{}
for _, o := range over {
seen[o.Code] = true
}
for _, o := range base {
if !seen[o.Code] {
out = append(out, o)
}
}
return append(out, over...)
}
func (im *importer) reservation(r obj, sn *model.Subnet) error {
var hw, cid, ip, host string
get(r, "hw-address", &hw)
get(r, "client-id", &cid)
get(r, "ip-address", &ip)
get(r, "hostname", &host)
where := "reservation " + ip
if host != "" {
where = "reservation " + host
}
if ip == "" {
im.skip("%s: no ip-address (Steward reservations always fix the address)", where)
return nil
}
a, err := netip.ParseAddr(ip)
if err != nil {
return fmt.Errorf("%s: %q isn't an address", where, ip)
}
res := &model.Reservation{IP: a, Hostname: host, Options: []model.OptionValue{}}
if hw != "" {
if res.MAC, err = model.NormalizeMAC(hw); err != nil {
return fmt.Errorf("%s: %v", where, err)
}
}
if cid != "" {
if res.ClientID, err = model.NormalizeClientID(cid); err != nil {
return fmt.Errorf("%s: %v", where, err)
}
}
if res.MAC == "" && res.ClientID == "" {
im.skip("%s: identified by %s (Steward knows clients by MAC address or client id)", where, firstKey(r, "duid", "circuit-id", "flex-id"))
return nil
}
if res.Options, err = im.options(r, where); err != nil {
return err
}
for _, k := range []string{"client-classes", "next-server", "boot-file-name", "server-hostname"} {
if _, ok := r[k]; ok {
im.skip("%s: %s", where, k)
}
}
im.res.State.Reservations = append(im.res.State.Reservations, res)
return nil
}
func firstKey(o obj, keys ...string) string {
for _, k := range keys {
if _, ok := o[k]; ok {
return k
}
}
return "something else"
}
// ParseLeases reads a Kea memfile lease file (kea-leases4.csv). The file
// is a log: later lines for an address replace earlier ones. Only leases
// that haven't ended are returned (declined ones too).
func ParseLeases(r io.Reader, now time.Time) ([]*model.Lease, error) {
cr := csv.NewReader(r)
cr.FieldsPerRecord = -1
header, err := cr.Read()
if err != nil {
return nil, fmt.Errorf("reading the lease file: %w", err)
}
col := map[string]int{}
for i, h := range header {
col[strings.TrimSpace(h)] = i
}
for _, need := range []string{"address", "hwaddr", "expire", "valid_lifetime"} {
if _, ok := col[need]; !ok {
return nil, fmt.Errorf("the lease file has no %s column: is it a Kea DHCPv4 memfile (kea-leases4.csv)?", need)
}
}
field := func(rec []string, name string) string {
if i, ok := col[name]; ok && i < len(rec) {
return strings.TrimSpace(rec[i])
}
return ""
}
byIP := map[netip.Addr]*model.Lease{}
line := 1
for {
rec, err := cr.Read()
line++
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("lease file line %d: %w", line, err)
}
a, err := netip.ParseAddr(field(rec, "address"))
if err != nil || !a.Is4() {
continue
}
exp, _ := strconv.ParseInt(field(rec, "expire"), 10, 64)
life, _ := strconv.ParseInt(field(rec, "valid_lifetime"), 10, 64)
end := time.Unix(exp, 0).UTC()
l := &model.Lease{IP: a, State: model.LeaseActive, Start: end.Add(-time.Duration(life) * time.Second), End: end, Updated: now,
Hostname: unescape(field(rec, "hostname"))}
if m, err := model.NormalizeMAC(field(rec, "hwaddr")); err == nil {
l.MAC = m
}
if c := field(rec, "client_id"); c != "" {
if id, err := model.NormalizeClientID(c); err == nil {
l.ClientID = id
}
}
switch field(rec, "state") {
case "1":
l.State = model.LeaseDeclined
case "2", "3":
l.State = model.LeaseReleased
}
byIP[a] = l
}
var out []*model.Lease
for _, l := range byIP {
if l.MAC == "" && l.State != model.LeaseDeclined {
continue
}
if l.End.After(now) || l.State == model.LeaseReleased {
out = append(out, l)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].IP.Less(out[j].IP) })
return out, nil
}
// unescape undoes Kea's escaping of commas in the lease file.
func unescape(s string) string { return strings.ReplaceAll(s, "&#x2c", ",") }