engine: resources: Add a dhcp range resource

This adds the ability to offer a dhcp lease to someone when we don't
know their mac address in advance.

This also uses the extended autogrouping API to keep the internal API
simpler.
This commit is contained in:
James Shubin
2024-03-20 17:35:52 -04:00
parent 8a78907977
commit 388d08e245
4 changed files with 880 additions and 15 deletions

View File

@@ -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
}

31
examples/lang/dhcp1.mcl Normal file
View File

@@ -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
}

6
go.mod
View File

@@ -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

15
go.sum
View File

@@ -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=