diff --git a/engine/resources/dhcp.go b/engine/resources/dhcp.go index b7ea74b8..b808915d 100644 --- a/engine/resources/dhcp.go +++ b/engine/resources/dhcp.go @@ -31,10 +31,15 @@ package resources import ( "context" + "encoding/binary" "errors" "fmt" "net" + "net/netip" "net/url" + "sort" + "strconv" + "strings" "sync" "time" @@ -43,14 +48,17 @@ import ( "github.com/purpleidea/mgmt/util/errwrap" "github.com/coredhcp/coredhcp/handler" + "github.com/coredhcp/coredhcp/plugins/allocators" + "github.com/coredhcp/coredhcp/plugins/allocators/bitmap" "github.com/insomniacslk/dhcp/dhcpv4" "github.com/insomniacslk/dhcp/dhcpv4/server4" + "go4.org/netipx" // extension of unmerged parts from net/netip ) func init() { engine.RegisterResource("dhcp:server", func() engine.Res { return &DHCPServerRes{} }) engine.RegisterResource("dhcp:host", func() engine.Res { return &DHCPHostRes{} }) - //engine.RegisterResource("dhcp:range", func() engine.Res { return &DhcpRangeRes{} }) + engine.RegisterResource("dhcp:range", func() engine.Res { return &DHCPRangeRes{} }) if _, err := time.ParseDuration(DHCPDefaultLeaseTime); err != nil { panic("invalid duration for DHCPDefaultLeaseTime constant") @@ -143,6 +151,10 @@ type DHCPServerRes struct { //mutex *sync.RWMutex + // Global pool where allocated resources are tracked. + //reservedMacs map[string]engine.Res // net.HardwareAddr is not comparable :( + reservedIPs map[netip.Addr]engine.Res // track which res reserved + // TODO: add in ipv6 support here or in a separate resource? } @@ -399,6 +411,32 @@ func (obj *DHCPServerRes) Init(init *engine.Init) error { //obj.mutex = &sync.RWMutex{} //obj.mutex.RLock() + //obj.reservedMacs = make(map[string]engine.Res) + obj.reservedIPs = make(map[netip.Addr]engine.Res) + + for _, x := range obj.GetGroup() { // grouped elements + switch res := x.(type) { // convert from Res + case *DHCPHostRes: + + // TODO: reserve res.Mac as well + + addr, ok := netip.AddrFromSlice(res.ipv4Addr) // net.IP -> netip.Addr + if !ok { + // programming error + return fmt.Errorf("could not convert ip: %s", res.ipv4Addr) + } + //addr := res.ipv4Addr // TODO: once ported to netip.Addr + if r, exists := obj.reservedIPs[addr]; exists { + // TODO: Could we do all of this in Validate() ? + return fmt.Errorf("res %s already reserved ip: %s", r, addr) + } + + obj.reservedIPs[addr] = res // reserve! + + case *DHCPRangeRes: + } + } + return nil } @@ -717,17 +755,17 @@ func (obj *DHCPServerRes) GroupCmp(r engine.GroupableRes) error { return nil } - //res2, ok2 := r.(*DhcpRangeRes) // different from what we usually do! - //if ok2 { - // // If the dhcp range resource has the Server field specified, - // // then it must match against our name field if we want it to - // // group with us. - // if res2.Server != "" && res2.Server != obj.Name() { - // return fmt.Errorf("resource groups with a different server name") - // } + res2, ok2 := r.(*DHCPRangeRes) // different from what we usually do! + if ok2 { + // If the dhcp range resource has the Server field specified, + // then it must match against our name field if we want it to + // group with us. + if res2.Server != "" && res2.Server != obj.Name() { + return fmt.Errorf("resource groups with a different server name") + } - // return nil - //} + return nil + } return fmt.Errorf("resource is not the right kind") } @@ -897,12 +935,24 @@ func (obj *DHCPServerRes) handler4() func(net.PacketConn, net.Addr, *dhcpv4.DHCP // message and skipping the handler is // good enough here. obj.init.Logf("%s: invalid handler: %v", x.String(), err) + continue } hostHandlers = append(hostHandlers, h) - //case *DhcpRangeRes: - // h := res.handler4() - // rangeHandlers = append(rangeHandlers, h) + case *DHCPRangeRes: + // TODO: should all handlers have the same signature? + data := &HostData{ + NBP: obj.NBP, + } + h, err := res.handler4(data) + if err != nil { + // This should rarely error, so a log + // message and skipping the handler is + // good enough here. + obj.init.Logf("%s: invalid handler: %v", x.String(), err) + continue + } + rangeHandlers = append(rangeHandlers, h) default: continue @@ -1004,7 +1054,7 @@ type DHCPHostRes struct { // slash. This is sometimes desirable for legacy tftp setups. NBPPath string `lang:"nbp_path" yaml:"nbp_path"` - ipv4Addr net.IP + ipv4Addr net.IP // XXX: port to netip.Addr ipv4Mask net.IPMask opt66 *dhcpv4.Option opt67 *dhcpv4.Option @@ -1195,6 +1245,9 @@ func (obj *DHCPHostRes) handler4(data *HostData) (func(*dhcpv4.DHCPv4, *dhcpv4.D resp.YourIPAddr = obj.ipv4Addr // TODO: make a copy for this? + // XXX: This is done in the standalone leasetime handler for now + //resp.Options.Update(dhcpv4.OptIPAddressLeaseTime(obj.LeaseTime.Round(time.Second))) + // XXX: https://tools.ietf.org/html/rfc2132#section-3.3 // If both the subnet mask and the router option are specified // in a DHCP reply, the subnet mask option MUST be first. @@ -1216,6 +1269,751 @@ func (obj *DHCPHostRes) handler4(data *HostData) (func(*dhcpv4.DHCPv4, *dhcpv4.D }, nil } +// DHCPRangeRes is a representation of a range allocator in DHCP. To declare a +// range you must specify either the `network` field or the `from` and `to` +// fields as ip with cidr's, or `from` and `to` fields without cidr's but with +// the `mask` field as either a dotted netmask or a `/number` field. If you +// specify none of these, then the resource name will be interpreted the same +// way that the `network` field os. The last ip in the range (which is often +// used as a broadcast address) is never allocated. +// TODO: Add a setting to determine if we should allocate the last address. +type DHCPRangeRes struct { + traits.Base // add the base methods without re-implementation + //traits.Edgeable // XXX: add autoedge support? + traits.Groupable // can be grouped into DHCPServerRes + + init *engine.Init + + // Server is the name of the dhcp server resource to group this into. If + // it is omitted, and there is only a single dhcp resource, then it will + // be grouped into it automatically. If there is more than one main dhcp + // resource being used, then the grouping behaviour is *undefined* when + // this is not specified, and it is not recommended to leave this blank! + Server string `lang:"server" yaml:"server"` + + // MacMatch only allocates ip addresses if the mac address matches this + // wildcard pattern. The default pattern of the empty string, means any + // mac address is permitted. + // TODO: This is not implemented at the moment. + // TODO: Consider implementing this sort of functionality. + // TODO: Can we use https://pkg.go.dev/path/filepath#Match ? + //MacMatch string `lang:"macmatch" yaml:"macmatch"` + + // Network is the network number and cidr to determine the range. For + // example, the common network range of 192.168.42.1 to 192.168.42.255 + // should have a network field here of 192.168.42.0/24. You can either + // specify this field or `from` and `to`, but not a different + // combination. If you don't specify any of these fields, then the + // resource name will be parsed as if it was used here. + Network string `lang:"network" yaml:"network"` + + // From is the start address in the range inclusive. If it is specified + // in cidr notation, then the `mask` field must not be used. Otherwise + // it must be used. In both situations the cidr or mask must be + // consistent with the `to` field. If this field is used, you must not + // use the `network` field. + From string `lang:"from" yaml:"from"` + + // To is the end address in the range inclusive. If it is specified in + // cidr notation, then the `mask` field must not be used. Otherwise it + // must be used. In both situations the cidr or mask must be consistent + // with the `from` field. If this field is used, you must not use the + // `network` field. + To string `lang:"to" yaml:"to"` + + // Mask is the cidr or netmask of ip addresses in the specified range. + // This field must only be used if both `from` and `to` are specified, + // and if neither of them specify a cidr suffix. If neither do, then the + // mask here can be in either dotted format or, preferably, in cidr + // format by starting with a slash. + Mask string `lang:"mask" yaml:"mask"` + + // Skip is a list ip's in either cidr or standalone representation which + // will be skipped and not allocated. + Skip []string `lang:"skip" yaml:"skip"` + + // Persist should be true if you want to persist the lease information + // to disk so that a new (or changed) invocation of this resource with + // the same name, will regain that existing initial state at startup. + // TODO: Add a new param to persist the data to etcd in the world API so + // that we could have redundant dhcp servers which share the same state. + // This would require having a distributed allocator through etcd too! + // TODO: Consider adding a new param to erase the persisted record + // database if any field param changes, as opposed to just looking at + // the name field alone. + // XXX: This is currently not implemented. + Persist bool `lang:"persist" yaml:"persist"` + + // NBP is the network boot program URL. This is used for the tftp server + // name and the boot file name. For example, you might use: + // tftp://192.0.2.13/pxelinux.0 for a common bios, pxe boot setup. Note + // that the "scheme" prefix is required, and that it's impossible to + // specify a file that doesn't begin with a leading slash. If you wish + // to specify a "root less" file (common for legacy tftp setups) then + // you can use this feature in conjunction with the NBPPath parameter. + // For DHCPv4, the scheme must be "tftp". + NBP string `lang:"nbp" yaml:"nbp"` + + // NBPPath overrides the path that is sent for the nbp protocols. By + // default it is taken from parsing a URL in NBP, but this can override + // that. This is useful if you require a path that doesn't start with a + // slash. This is sometimes desirable for legacy tftp setups. + NBPPath string `lang:"nbp_path" yaml:"nbp_path"` + + // TODO: consider persisting this to disk (with the Local API) + records map[string]*HostRecord // key is mac address + + // TODO: port the allocator to use the net/netip types + // TODO: add a new allocator that can work on multiple hosts over etcd + allocator allocators.Allocator + + // mutex guards access to records and allocator when running. + mutex *sync.Mutex + + from netip.Addr + to netip.Addr + mask net.IPMask + skip []netip.Addr + + opt66 *dhcpv4.Option + opt67 *dhcpv4.Option +} + +// Default returns some sensible defaults for this resource. +func (obj *DHCPRangeRes) Default() engine.Res { + return &DHCPRangeRes{} +} + +// parse handles the permutations of options to parse the fields into our data +// format that we use. This is a helper function because it is used in both +// Validate and also Init. It only stores the result on no error, and if set is +// true. +func (obj *DHCPRangeRes) parse(set bool) error { + if a, b := obj.From == "", obj.To == ""; a != b { + return fmt.Errorf("must specify both from and to or neither") + } + + if obj.From != "" && obj.To != "" { + var from, to netip.Addr + var mask net.IPMask // nil unless set + + if prefix, err := netip.ParsePrefix(obj.From); err == nil { + from = prefix.Addr() + + ones := prefix.Bits() // set portion of the mask, -1 if invalid + bits := 128 // ipv6 + if from.Is4() { // ipv4 + bits = 32 + } + mask = net.CIDRMask(ones, bits) // net.IPMask + + } else if addr, err := netip.ParseAddr(obj.From); err == nil { // without cidr + from = addr + } + // else we error (caught below) + if prefix, err := netip.ParsePrefix(obj.To); err == nil { + to = prefix.Addr() + + ones := prefix.Bits() // set portion of the mask, -1 if invalid + bits := 128 // ipv6 + if to.Is4() { // ipv4 + bits = 32 + } + mask = net.CIDRMask(ones, bits) // net.IPMask + + } else if addr, err := netip.ParseAddr(obj.To); err == nil { // without cidr + to = addr + } + // else we error (caught below) + + if !from.Is4() || !to.Is4() { + // TODO: support ipv6 + return fmt.Errorf("only ipv4 is supported at this time") + } + + if a, b := obj.Mask == "", mask == nil; a == b { + return fmt.Errorf("mask must be specified somehow") + } + + // weird "/cidr" form + // TODO: Do we want to allow this form? + if obj.Mask != "" && strings.HasPrefix(obj.Mask, "/") { + + ones, err := strconv.Atoi(obj.Mask[1:]) + if err != nil { + return fmt.Errorf("invalid cidr suffix: %s", obj.Mask) + } + + // The range is [0,32] for IPv4 or [0,128] for IPv6. + if ones < 0 || ones > 128 { + return fmt.Errorf("invalid cidr: %s", obj.Mask) + } + + bits := 128 // ipv6 + if from.Is4() && to.Is4() { // ipv4 + if ones > 32 { + return fmt.Errorf("invalid ipv4 cidr: %s", obj.Mask) + } + bits = 32 + } + mask = net.CIDRMask(ones, bits) // net.IPMask + + } else if obj.Mask != "" { // standard 255.255.255.0 form + + ip := net.ParseIP(obj.Mask) + if ip == nil || ip.IsUnspecified() { + return fmt.Errorf("invalid mask: %s", obj.Mask) + } + ip4 := ip.To4() + if ip4 == nil { + return fmt.Errorf("invalid ipv4 mask: %s", obj.Mask) + } + mask = net.IPv4Mask(ip4[0], ip4[1], ip4[2], ip4[3]) + } + + if !checkValidNetmask(mask) { // TODO: is this needed? + return fmt.Errorf("invalid mask: %s", mask) + } + + if !set { + return nil + } + + r := netipx.IPRangeFrom(from, to) // netipx.IPRange + obj.from = r.From() // netip.Addr + obj.to = r.To() // netip.Addr + obj.mask = mask + + return nil + } + + if obj.Mask != "" { + return fmt.Errorf("mask must not be set when using network") + } + + if obj.Network != "" { + return obj.parseNetwork(set, obj.Network) + } + + // try to parse based on name if nothing else is possible + return obj.parseNetwork(set, obj.Name()) +} + +// parseNetwork handles the permutations of options to parse the network field +// into the data format that we use. This is a helper function because it is +// used in both Validate and also Init for both the network field and the name +// field. It only stores the result on no error, and if set is true. +func (obj *DHCPRangeRes) parseNetwork(set bool, network string) error { + if network == "" { + return fmt.Errorf("empty network") + } + + prefix, err := netip.ParsePrefix(network) + if err != nil { + return err + } + + ones := prefix.Bits() // set portion of the mask, -1 if invalid + bits := 128 // ipv6 + if prefix.Addr().Is4() { // ipv4 + bits = 32 + } + mask := net.CIDRMask(ones, bits) // net.IPMask + + if !checkValidNetmask(mask) { // TODO: is this needed? + return fmt.Errorf("invalid mask: %s", mask) + } + + // XXX: Are we doing the network math here correctly? (eg with .Next()) + r := netipx.RangeOfPrefix(prefix) // netipx.IPRange + from := r.From() // netip.Addr + next := from.Next() // skip the network addr + if !next.IsValid() { + return fmt.Errorf("not enough addresses") // or a bug? + } + + if !set { + return nil + } + + obj.from = next // netip.Addr + // TODO: should we use .Prev() for to? + obj.to = r.To() // netip.Addr + obj.mask = mask + + return nil +} + +// parseSkip handles the permutations of options to parse the skip field into +// the data format that we use. This is a helper function because it is used in +// both Validate and also Init. It only stores the result on no error, and if +// set is true. +func (obj *DHCPRangeRes) parseSkip(set bool) error { + ips := []netip.Addr{} + for _, s := range obj.Skip { + if prefix, err := netip.ParsePrefix(s); err == nil { + addr := prefix.Addr() + ips = append(ips, addr) + + // TODO: check if mask matches mask from range? + //ones := prefix.Bits() // set portion of the mask, -1 if invalid + //bits := 128 // ipv6 + //if addr.Is4() { // ipv4 + // bits = 32 + //} + //mask = net.CIDRMask(ones, bits) // net.IPMask + continue + + } else if addr, err := netip.ParseAddr(s); err == nil { // without cidr + ips = append(ips, addr) + continue + } + + return fmt.Errorf("invalid ip: %s", s) + } + + if !set { + return nil + } + + obj.skip = ips + + return nil +} + +// Validate checks if the resource data structure was populated correctly. +func (obj *DHCPRangeRes) Validate() error { + + //if obj.MacMatch != "" { + // TODO: validate pattern + //} + + // If input is false, this doesn't modify any private struct fields. + if err := obj.parse(false); err != nil { + return err + } + + if err := obj.parseSkip(false); err != nil { + return err + } + + // validate the network boot program URL + if obj.NBP != "" { + u, err := url.Parse(obj.NBP) + if err != nil { + return errwrap.Wrapf(err, "invalid nbp URL") + } + if u.Scheme == "" { + return fmt.Errorf("missing nbp scheme") + } + // TODO: remove this check when we support DHCPv6 + if u.Scheme != "tftp" { + return fmt.Errorf("the scheme must be `tftp` for DHCPv4") + } + } + + return nil +} + +// Init runs some startup code for this resource. +func (obj *DHCPRangeRes) Init(init *engine.Init) error { + obj.init = init // save for later + + // This sets some private struct fields if it doesn't error. + if err := obj.parse(true); err != nil { + return err + } + + if err := obj.parseSkip(true); err != nil { + return err + } + + obj.records = make(map[string]*HostRecord) + // TODO: consider persisting this to disk + if obj.Persist { + return fmt.Errorf("persist not implemented") + //records, err := obj.load() + //if err != nil { + // return nil + //} + //obj.records = records + } + + from := net.IP(obj.from.AsSlice()) // netip.Addr -> net.IP + to := net.IP(obj.to.AsSlice()) + allocator, err := bitmap.NewIPv4Allocator(from, to) + if err != nil { + return nil + } + obj.allocator = allocator + obj.mutex = &sync.Mutex{} + + res, ok := obj.Parent().(*DHCPServerRes) + if !ok { + // programming error + return fmt.Errorf("unexpected parent resource") + } + // TODO: res.reservedMutex? + for addr := range res.reservedIPs { + ip := net.IP(addr.AsSlice()) // netip.Addr -> net.IP + hint := net.IPNet{IP: ip} + ipNet, err := obj.allocator.Allocate(hint) // (net.IPNet, error) + if err != nil { + return fmt.Errorf("could not reserve ip %s: %v", ip, err) + } + if ip.String() != ipNet.IP.String() { + return fmt.Errorf("requested %s, allocator returned %s", ip, ipNet.IP) + } + + // NOTE: We don't add these to the memory or stateful record + // store, since we have these "stored" by virtue of them being a + // resource as code. If those changed, we'd have an out-of-date + // stateful database! + } + + macs := []string{} + for k := range obj.records { // deterministic for now + macs = append(macs, k) + } + sort.Strings(macs) + for _, mac := range macs { + record, ok := obj.records[mac] + if !ok { + // programming error + return fmt.Errorf("missing record") + } + if !record.IP.IsValid() || record.IP.IsUnspecified() { + // programming error + return fmt.Errorf("bad ip in record") + } + + // Allocate what we already chose to statically in the database. + // NOTE: The API lets us request an ip, but it's not guaranteed. + hint := net.IPNet{IP: net.IP(record.IP.AsSlice())} // netip.Addr -> net.IP + ipNet, err := obj.allocator.Allocate(hint) // (net.IPNet, error) + if err != nil { + return fmt.Errorf("could not re-allocate leased ip %s: %v", record.IP, err) + } + if record.IP.String() != ipNet.IP.String() { + return fmt.Errorf("pre-requested %s, allocator returned %s", record.IP, ipNet.IP) + } + } + + for _, ip := range obj.skip { + // Allocate what the config already chose to skip statically. + // NOTE: The API lets us request an ip, but it's not guaranteed. + hint := net.IPNet{IP: net.IP(ip.AsSlice())} // netip.Addr -> net.IP + ipNet, err := obj.allocator.Allocate(hint) // (net.IPNet, error) + if err != nil { + return fmt.Errorf("could not allocate skip ip %s: %v", ip, err) + } + if ip.String() != ipNet.IP.String() { + return fmt.Errorf("skip-requested %s, allocator returned %s", ip, ipNet.IP) + } + } + + obj.init.Logf("from: %s", obj.from) + obj.init.Logf(" to: %s", obj.to) + obj.init.Logf("mask: %s", obj.mask) // TODO: print as cidr or dotted quad + + return nil +} + +// Cleanup is run by the engine to clean up after the resource is done. +func (obj *DHCPRangeRes) Cleanup() error { + return nil +} + +// Watch is the primary listener for this resource and it outputs events. This +// particular one does absolutely nothing but block until we've received a done +// signal. +func (obj *DHCPRangeRes) Watch(ctx context.Context) error { + obj.init.Running() // when started, notify engine that we're running + + select { + case <-ctx.Done(): // closed by the engine to signal shutdown + } + + //obj.init.Event() // notify engine of an event (this can block) + + return nil +} + +// CheckApply never has anything to do for this resource, so it always succeeds. +func (obj *DHCPRangeRes) CheckApply(ctx context.Context, apply bool) (bool, error) { + if obj.init.Debug { + obj.init.Logf("CheckApply") + } + + return true, nil // always succeeds, with nothing to do! +} + +// Cmp compares two resources and returns an error if they are not equivalent. +func (obj *DHCPRangeRes) Cmp(r engine.Res) error { + // we can only compare DHCPRangeRes to others of the same resource kind + res, ok := r.(*DHCPRangeRes) + if !ok { + return fmt.Errorf("res is not the same kind") + } + + if obj.Server != res.Server { + return fmt.Errorf("the Server field differs") + } + + //if obj.MacMatch != res.MacMatch { + // return fmt.Errorf("the MacMatch field differs") + //} + + if obj.Network != res.Network { + return fmt.Errorf("the Network field differs") + } + if obj.From != res.From { + return fmt.Errorf("the From field differs") + } + if obj.To != res.To { + return fmt.Errorf("the To field differs") + } + if obj.Mask != res.Mask { + return fmt.Errorf("the Mask field differs") + } + + if len(obj.Skip) != len(res.Skip) { + return fmt.Errorf("the size of Skip differs") + } + for i, x := range obj.Skip { + if x != res.Skip[i] { + return fmt.Errorf("the Skip at index %d differs", i) + } + } + + if obj.Persist != res.Persist { + return fmt.Errorf("the Persist field differs") + } + + if obj.NBP != res.NBP { + return fmt.Errorf("the NBP differs") + } + if obj.NBPPath != res.NBPPath { + return fmt.Errorf("the NBPPath differs") + } + + return nil +} + +// UnmarshalYAML is the custom unmarshal handler for this struct. It is +// primarily useful for setting the defaults. +func (obj *DHCPRangeRes) UnmarshalYAML(unmarshal func(interface{}) error) error { + type rawRes DHCPRangeRes // indirection to avoid infinite recursion + + def := obj.Default() // get the default + res, ok := def.(*DHCPRangeRes) // put in the right format + if !ok { + return fmt.Errorf("could not convert to DHCPRangeRes") + } + raw := rawRes(*res) // convert; the defaults go here + + if err := unmarshal(&raw); err != nil { + return err + } + + *obj = DHCPRangeRes(raw) // restore from indirection with type conversion! + return nil +} + +// handler4 returns the handler for the range resource. It gets called from the +// main handler4 function in the dhcp server resource. This combines the concept +// of multiple "plugins" inside of coredhcp. It includes "file" and also "nbp" +// and others. +func (obj *DHCPRangeRes) handler4(data *HostData) (func(*dhcpv4.DHCPv4, *dhcpv4.DHCPv4) (*dhcpv4.DHCPv4, bool), error) { + nbp := "" + if data != nil { + nbp = data.NBP // from server + } + if obj.NBP != "" { // host-specific override + nbp = obj.NBP + } + result, err := url.Parse(nbp) + if err != nil { + // this should have been checked in Validate :/ + return nil, errwrap.Wrapf(err, "unexpected invalid nbp URL") + } + otsn := dhcpv4.OptTFTPServerName(result.Host) + obj.opt66 = &otsn + p := result.Path + if obj.NBPPath != "" { // override the path if this is specified + p = obj.NBPPath + } + obfn := dhcpv4.OptBootFileName(p) + if p != "" { + obj.opt67 = &obfn + } + + res, ok := obj.Parent().(*DHCPServerRes) + if !ok { + // programming error + return nil, fmt.Errorf("unexpected parent resource") + } + + leaseTime := res.leaseTime + + // FIXME: Run this somewhere for now, eventually it should get scheduled + // to run in the returned duration of time. This way, it would clean old + // peristed entries when they're stale, not when a new request comes in. + if _, err := obj.leaseClean(); err != nil { + return nil, errwrap.Wrapf(err, "clean error") + } + + return func(req, resp *dhcpv4.DHCPv4) (*dhcpv4.DHCPv4, bool) { + obj.mutex.Lock() + defer obj.mutex.Unlock() + + mac := req.ClientHWAddr.String() + //hostname := req.HostName() // TODO: is it needed in the record? + + // Incoming allocation request in our range. + if obj.init.Debug { + obj.init.Logf("range lookup for mac %s", mac) + } + + update := false + record, ok := obj.records[mac] + if !ok { + ipNet, err := obj.allocator.Allocate(net.IPNet{}) // (net.IPNet, error) + if err != nil { + obj.init.Logf("could not allocate for mac %s: %v", mac, err) + return nil, true + } + // TODO: Do we need to use ipNet.Mask ? + + // TODO: do we want this complex version instead? + //addr, ok := netipx.FromStdIP(ipNet.IP) // unmap's + addr, ok := netip.AddrFromSlice(ipNet.IP) // net.IP -> netip.Addr + if !ok { + // programming error + obj.init.Logf("could not convert ip: %s", ipNet.IP) + return nil, true + } + + rec := &HostRecord{ + IP: addr, + Expires: int(time.Now().Add(leaseTime).Unix()), + //Hostname: hostname, + } + + obj.records[mac] = rec + record = rec // set it + update = true + } else { + // extend lease + expiry := time.Unix(int64(record.Expires), 0) // 0 is nsec + if expiry.Before(time.Now().Add(leaseTime)) { + record.Expires = int(time.Now().Add(leaseTime).Round(time.Second).Unix()) + //record.Hostname = hostname + update = true + } + } + + // TODO: consider persisting this to disk + if obj.Persist && update { + //if err = obj.store(req.ClientHWAddr, record); err != nil { + // obj.init.Logf("could not store mac %s: %v", mac, err) + //} + } + + if obj.init.Debug || true { // TODO: should we silence this? + obj.init.Logf("allocated ip %s for MAC %s", record.IP, mac) + } + + resp.YourIPAddr = net.IP(record.IP.AsSlice()) // netip.Addr -> net.IP + + // XXX: This is done in the standalone leasetime handler for now + //resp.Options.Update(dhcpv4.OptIPAddressLeaseTime(record.Expires.Round(time.Second))) + + // XXX: https://tools.ietf.org/html/rfc2132#section-3.3 + // If both the subnet mask and the router option are specified + // in a DHCP reply, the subnet mask option MUST be first. + // XXX: Should we do this? Does it matter? Does the lib do it? + //resp.Options.Update(dhcpv4.OptSubnetMask(obj.mask)) // net.IPMask + + // nbp section + if obj.opt66 != nil && req.IsOptionRequested(dhcpv4.OptionTFTPServerName) { + resp.Options.Update(*obj.opt66) + } + if obj.opt67 != nil && req.IsOptionRequested(dhcpv4.OptionBootfileName) { + resp.Options.Update(*obj.opt67) + } + if obj.init.Debug { + obj.init.Logf("Added NBP %s / %s to request", obj.opt66, obj.opt67) + } + + return resp, true + }, nil +} + +// leaseClean frees any expired leases. It also returns the duration till the +// next expected cleaning. +func (obj *DHCPRangeRes) leaseClean() (time.Duration, error) { + obj.mutex.Lock() + defer obj.mutex.Unlock() + + min := time.Duration(-1) + now := time.Now() + expire := []string{} + for mac, record := range obj.records { // see who is expired... + expiry := time.Unix(int64(record.Expires), 0) // 0 is nsec + //if !expiry.After(now) // same + if delta := expiry.Sub(now); delta > 0 { // positive if expired + if min == -1 { // initialize + min = delta + } + min = minDuration(min, delta) // soonest time to wakeup + continue + } + + // it's expired + expire = append(expire, mac) + } + + for _, mac := range expire { + record, exists := obj.records[mac] + if !exists { + // programming error + return 0, fmt.Errorf("missing record") + } + free := net.IPNet{IP: net.IP(record.IP.AsSlice())} // netip.Addr -> net.IP + err := obj.allocator.Free(free) + delete(obj.records, mac) + obj.init.Logf("unallocated (free) ip %s for MAC %s", record.IP, mac) + // TODO: run the persist somewhere... + // if obj.Persist { + // + //} + if err == nil { + continue + } + if dblErr, ok := err.(*allocators.ErrDoubleFree); ok { + // programming error + obj.init.Logf("double free programming error on: %v", dblErr.Loc) + continue + } + return 0, err // actual unknown error + } + + //if min >= 0 // schedule in `min * time.Second` seconds + return min, nil +} + +// HostRecord is some information that we store about each reservation that we +// allocated. This struct is stored as a value which is mapped to by a mac +// address key, which is why the mac address is not stored in this record. +type HostRecord struct { + IP netip.Addr + + // Expires represents the number of seconds since the epoch that this + // lease expires at. + Expires int +} + // HostData is some data that each host will get made available to its handler. type HostData struct { // NBP is the network boot program URL. See the resources for more docs. @@ -1236,3 +2034,18 @@ func (obj *overEngineeredLogger) Printf(format string, v ...interface{}) { func (obj *overEngineeredLogger) PrintMessage(prefix string, message *dhcpv4.DHCPv4) { obj.logf("%s: %s", prefix, message) } + +func minDuration(d1, d2 time.Duration) time.Duration { + if d1 < d2 { + return d1 + } + return d2 +} + +// from: https://github.com/coredhcp/coredhcp/blob/master/plugins/netmask/plugin.go +func checkValidNetmask(netmask net.IPMask) bool { + netmaskInt := binary.BigEndian.Uint32(netmask) + x := ^netmaskInt + y := x + 1 + return (y & x) == 0 +} diff --git a/examples/lang/dhcp1.mcl b/examples/lang/dhcp1.mcl new file mode 100644 index 00000000..95168166 --- /dev/null +++ b/examples/lang/dhcp1.mcl @@ -0,0 +1,31 @@ +#$iface = "lo" # replace with your desired interface like eth0 +$iface = "enp0s31f6" + +net $iface { + state => "up", + addrs => ["192.168.42.1/24",], +} + +dhcp:server ":67" { + interface => $iface, # required for now + leasetime => "60s", # increase this for normal production + dns => ["8.8.8.8", "1.1.1.1",], # pick your own better ones! + routers => ["192.168.42.1",], + + Depend => Net[$iface], # TODO: add autoedges +} + +dhcp:range "192.168.42.0/24" { + skip => ["192.168.42.1/24",], # skip this host +} + +dhcp:host "hostname1" { + mac => "00:11:22:33:44:55", # replace with your own! + ip => "192.168.42.101/24", # cidr notation is required +} + +dhcp:host "hostname2" { + mac => "ba:98:76:54:32:11", # replace with your own! + ip => "192.168.42.102/24", + nbp => "tftp://192.168.42.1/pxelinux.0", # for bios clients +} diff --git a/go.mod b/go.mod index aff6f88f..30d18fe3 100644 --- a/go.mod +++ b/go.mod @@ -52,8 +52,10 @@ require ( github.com/armon/go-metrics v0.4.1 // indirect github.com/benbjohnson/clock v1.3.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/bits-and-blooms/bitset v1.13.0 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/chappjc/logrus-prefix v0.0.0-20180227015900-3a1d64819adb // indirect github.com/containerd/log v0.1.0 // indirect github.com/coreos/go-semver v0.3.0 // indirect github.com/distribution/reference v0.5.0 // indirect @@ -96,6 +98,7 @@ require ( github.com/mdlayher/packet v1.0.0 // indirect github.com/mdlayher/raw v0.1.0 // indirect github.com/mdlayher/socket v0.5.0 // indirect + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect @@ -109,6 +112,7 @@ require ( github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/common v0.48.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect + github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5 // indirect github.com/sergi/go-diff v1.2.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/soheilhy/cmux v0.1.5 // indirect @@ -137,10 +141,12 @@ require ( go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.23.0 // indirect + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 // indirect golang.org/x/mod v0.16.0 // indirect golang.org/x/net v0.22.0 // indirect golang.org/x/sync v0.6.0 // indirect + golang.org/x/term v0.18.0 // indirect golang.org/x/text v0.14.0 // indirect google.golang.org/genproto v0.0.0-20231212172506-995d672761c0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917 // indirect diff --git a/go.sum b/go.sum index 2940fbdf..32e99f9a 100644 --- a/go.sum +++ b/go.sum @@ -54,6 +54,8 @@ github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+Ce github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE= +github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= @@ -62,6 +64,8 @@ github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghf github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chappjc/logrus-prefix v0.0.0-20180227015900-3a1d64819adb h1:aZTKxMminKeQWHtzJBbV8TttfTxzdJ+7iEJFE6FmUzg= +github.com/chappjc/logrus-prefix v0.0.0-20180227015900-3a1d64819adb/go.mod h1:xzXc1S/L+64uglB3pw54o8kqyM6KFYpTeC9Q6+qZIu8= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= @@ -131,6 +135,7 @@ github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2 github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -275,6 +280,7 @@ github.com/josharian/native v1.0.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2C github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/jsimonetti/rtnetlink v0.0.0-20190606172950-9527aa82566a/go.mod h1:Oz+70psSo5OFh8DBl0Zv2ACw7Esh6pPUphlvZG9x7uw= github.com/jsimonetti/rtnetlink v0.0.0-20200117123717-f846d4f6c1f4/go.mod h1:WGuG/smIU4J/54PblvSbh+xvCZmpJnFgr3ds6Z55XMQ= github.com/jsimonetti/rtnetlink v0.0.0-20201009170750-9c6f07d100c1/go.mod h1:hqoO/u39cqLeBLebZ8fWdE96O7FxrAsRYhnVOdgHxok= @@ -286,6 +292,7 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= @@ -341,6 +348,8 @@ github.com/mdlayher/raw v0.1.0/go.mod h1:yXnxvs6c0XoF/aK52/H5PjsVHmWBCFfZUfoh/Y5 github.com/mdlayher/socket v0.2.1/go.mod h1:QLlNPkFR88mRUNQIzRBMfXxwKal8H7u1h3bL1CV+f0E= github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.41 h1:WMszZWJG0XmzbK9FEmzH2TVcqYzFesusSIB41b8KHxY= @@ -371,6 +380,7 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= @@ -421,6 +431,8 @@ github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+Gx github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5 h1:mZHayPoR0lNmnHyvtYjDeq0zlVHn9K/ZXoy17ylucdo= +github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5/go.mod h1:GEXHk5HgEKCvEIIrSpFI3ozzG5xOKA2DVlEX/gGnewM= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= @@ -546,6 +558,8 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8 go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY= go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -688,6 +702,7 @@ golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=