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>
581 lines
16 KiB
Go
581 lines
16 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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}
|
|
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)
|
|
}
|
|
}
|
|
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
|
|
}
|
|
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, ",", ",") }
|