// 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("; 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, ",", ",") }