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

+580
View File
@@ -0,0 +1,580 @@
// 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, "&#x2c", ",") }
+170
View File
@@ -0,0 +1,170 @@
package kea
import (
"net/netip"
"strings"
"testing"
"time"
"steward/internal/model"
)
const sample = `
# Kea for the house
{
"Dhcp4": {
"interfaces-config": { "interfaces": [ "eth0", "eth0.20/10.0.20.1" ] },
"lease-database": { "type": "memfile", "persist": true, "name": "/var/lib/kea/kea-leases4.csv" },
"valid-lifetime": 43200,
"renew-timer": 21600,
"authoritative": true,
"option-data": [
{ "name": "domain-name-servers", "data": "10.0.10.2, 10.0.10.3" },
{ "name": "domain-name", "data": "lan" }, // our domain
{ "name": "domain-search", "data": "lan, home.arpa" }
],
"hooks-libraries": [ { "library": "/usr/lib/kea/hooks/libdhcp_lease_cmds.so" } ],
/* the office */
"subnet4": [
{
"id": 1,
"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" },
{ "code": 42, "data": "0a000a02", "csv-format": false }
],
"reservations": [
{ "hw-address": "AA-BB-CC-00-00-01", "ip-address": "10.0.10.20", "hostname": "nas" },
{ "client-id": "01:aa:bb:cc:00:00:02", "ip-address": "10.0.10.21", "hostname": "printer",
"option-data": [ { "name": "domain-name-servers", "data": "1.1.1.1" } ] },
{ "duid": "00:01:02", "ip-address": "10.0.10.22" }
]
}
],
"shared-networks": [
{
"name": "iot",
"interface": "eth0.20",
"valid-lifetime": 3600,
"option-data": [ { "name": "routers", "data": "10.0.20.1" } ],
"subnet4": [
{ "id": 2, "subnet": "10.0.20.0/24", "pools": [ { "pool": "10.0.20.0/25" } ], "relay": { "ip-addresses": [ "10.0.20.254" ] } },
{ "id": 3, "subnet": "10.0.21.0/24", "pools": [ { "pool": "10.0.21.10-10.0.21.20" } ],
"option-data": [ { "name": "routers", "data": "10.0.21.1" } ] }
]
}
]
},
"Logging": { "loggers": [] }
}
`
func TestParseConfig(t *testing.T) {
res, err := ParseConfig([]byte(sample), Options{HostInterfaces: map[string][]netip.Prefix{"eth0": {netip.MustParsePrefix("10.0.10.2/24")}}})
if err != nil {
t.Fatal(err)
}
st := res.State
if st.Settings.LeaseTime != 43200 || len(st.Settings.Options) != 3 {
t.Errorf("settings %+v", st.Settings)
}
if len(st.Subnets) != 3 {
t.Fatalf("subnets %+v", st.Subnets)
}
office := st.Subnets[0]
if office.Interface != "eth0" || office.Pools[0].String() != "10.0.10.100-10.0.10.199" || office.LeaseTime != 0 {
t.Errorf("office %+v", office)
}
if len(office.Options) != 2 || office.Options[1].Code != 42 || office.Options[1].Value != "10.0.10.2" {
t.Errorf("office options %+v", office.Options)
}
iot, iot2 := st.Subnets[1], st.Subnets[2]
if iot.Interface != "eth0.20" || iot.LeaseTime != 3600 || iot.Pools[0].String() != "10.0.20.0-10.0.20.127" || iot.Options[0].Value != "10.0.20.1" {
t.Errorf("iot %+v", iot)
}
if iot2.Interface != "eth0.20" || len(iot2.Options) != 1 || iot2.Options[0].Value != "10.0.21.1" {
t.Errorf("iot2 %+v %+v", iot2, iot2.Options)
}
if len(st.Reservations) != 2 || st.Reservations[0].MAC != "aa:bb:cc:00:00:01" || st.Reservations[1].ClientID != "01aabbcc000002" ||
st.Reservations[1].Options[0].Value != "1.1.1.1" {
t.Errorf("reservations %+v %+v", st.Reservations[0], st.Reservations[1])
}
skipped := strings.Join(res.Skipped, "\n")
for _, want := range []string{"hooks-libraries", "Logging", "duid", "relay"} {
if !strings.Contains(skipped, want) {
t.Errorf("skipped list lacks %s:\n%s", want, skipped)
}
}
notes := strings.Join(res.Notes, "\n")
for _, want := range []string{"renew-timer", "shared network iot"} {
if !strings.Contains(notes, want) {
t.Errorf("notes lack %s:\n%s", want, notes)
}
}
}
func TestInterfaceOverride(t *testing.T) {
cfg := `{"Dhcp4": {"interfaces-config": {"interfaces": ["*"]}, "subnet4": [{"subnet": "192.168.5.0/24", "pools": [{"pool": "192.168.5.10-192.168.5.20"}]}]}}`
res, err := ParseConfig([]byte(cfg), Options{})
if err != nil {
t.Fatal(err)
}
if res.State.Subnets[0].Interface != "" {
t.Errorf("guessed %q", res.State.Subnets[0].Interface)
}
res, _ = ParseConfig([]byte(cfg), Options{Interfaces: map[string]string{"192.168.5.0/24": "br0"}})
if res.State.Subnets[0].Interface != "br0" {
t.Errorf("override: %q", res.State.Subnets[0].Interface)
}
}
func TestConfigErrors(t *testing.T) {
for in, want := range map[string]string{
`{"Dhcp6": {}}`: "no Dhcp4",
"{\n\"Dhcp4\": {\n\"subnet4\": [\n}}": "line 4",
`{"Dhcp4": {}} <?include "/etc/kea/more.json"?>`: "include",
`{"Dhcp4": {"subnet4": [{"subnet": "2001:db8::/64"}]}}`: "IPv4",
`{"Dhcp4": {"option-data": [{"name": "routers", "data": "x"}]}}`: "routers",
} {
if _, err := ParseConfig([]byte(in), Options{}); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%q: %v", in, err)
}
}
}
func TestStripComments(t *testing.T) {
in := "{\"a\": \"x // not a comment # nor this\", # comment\n \"b\": /* gone */ 1 // tail\n}"
got := string(StripComments([]byte(in)))
if !strings.Contains(got, `"x // not a comment # nor this"`) || strings.Contains(got, "gone") || strings.Contains(got, "tail") {
t.Errorf("got %q", got)
}
}
func TestParseLeases(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
csv := `address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context,pool_id
10.0.10.100,aa:bb:cc:00:00:05,01:aa:bb:cc:00:00:05,43200,1800010000,1,0,0,laptop,0,,0
10.0.10.101,aa:bb:cc:00:00:06,,43200,1799990000,1,0,0,old,0,,0
10.0.10.100,aa:bb:cc:00:00:05,01:aa:bb:cc:00:00:05,43200,1800040000,1,0,0,laptop&#x2cwork,0,,0
10.0.10.102,,,86400,1800050000,1,0,0,,1,,0
10.0.10.103,aa:bb:cc:00:00:07,,43200,1799000000,1,0,0,gone,3,,0
`
ls, err := ParseLeases(strings.NewReader(csv), now)
if err != nil {
t.Fatal(err)
}
if len(ls) != 3 {
t.Fatalf("leases %+v", ls)
}
l := ls[0]
if l.IP.String() != "10.0.10.100" || l.State != model.LeaseActive || l.End.Unix() != 1800040000 || l.Hostname != "laptop,work" || l.ClientID != "01aabbcc000005" {
t.Errorf("lease %+v", l)
}
if ls[1].State != model.LeaseDeclined || ls[2].State != model.LeaseReleased {
t.Errorf("states %s %s", ls[1].State, ls[2].State)
}
if _, err := ParseLeases(strings.NewReader("a,b\n1,2\n"), now); err == nil {
t.Error("a file without Kea's columns was accepted")
}
}