// Mgmt // Copyright (C) James Shubin and the project contributors // Written by James Shubin and the project contributors // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . // // Additional permission under GNU GPL version 3 section 7 // // If you modify this program, or any covered work, by linking or combining it // with embedded mcl code and modules (and that the embedded mcl code and // modules which link with this program, contain a copy of their source code in // the authoritative form) containing parts covered by the terms of any other // license, the licensors of this program grant you additional permission to // convey the resulting work. Furthermore, the licensors of this program grant // the original author, James Shubin, additional permission to update this // additional permission if he deems it necessary to achieve the goals of this // additional permission. package resources import ( "context" "encoding/binary" "errors" "fmt" "net" "net/netip" "net/url" "sort" "strconv" "strings" "sync" "time" "github.com/purpleidea/mgmt/engine" "github.com/purpleidea/mgmt/engine/traits" "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{} }) if _, err := time.ParseDuration(DHCPDefaultLeaseTime); err != nil { panic("invalid duration for DHCPDefaultLeaseTime constant") } } const ( // DHCPDefaultLeaseTime is the default lease time used when one was not // specified explicitly. DHCPDefaultLeaseTime = "10m" // common default from dhcpd ) // DHCPServerRes is a simple dhcp server resource. It responds to dhcp client // requests, but does not actually apply any state. The name is used as the // address to listen on, unless the Address field is specified, and in that case // it is used instead. The resource can offer up dhcp client leases from any // number of dhcp:host resources which will get autogrouped into this resource // at runtime. // // This server is not meant as a featureful replacement for the venerable dhcpd, // but rather as a simple, dynamic, integrated alternative for bootstrapping new // machines and clusters in an elegant way. // // TODO: Add autoedges between the Interface and any identically named NetRes. type DHCPServerRes struct { traits.Base // add the base methods without re-implementation traits.Edgeable // TODO: add autoedge support traits.Groupable // can have DHCPHostRes and more, grouped into it init *engine.Init // Address is the listen address to use for the dhcp server. It is // common to use `:67` (the standard) to listen on UDP port 67 on all // addresses. Address string `lang:"address" yaml:"address"` // Interface is interface to bind to. For example `eth0` for the common // case. You may leave this field blank to not run any specific binding. // XXX: You need to actually specify an interface here at the moment. :( // BUG: https://github.com/insomniacslk/dhcp/issues/372 Interface string `lang:"interface" yaml:"interface"` // ServerID is a unique IPv4 identifier for this server as specified in // the DHCPv4 protocol. It is almost always the IP address of the DHCP // server. If you don't specify this, then we will attempt to determine // it from the specified interface. If it is set to the empty string, // then this won't be set in the DHCP protocol, and your DHCP server // might not work as you intend. Otherwise, if a valid value is // specified, then this will be used as long as it validates correctly. // Please note that if you attempt to automatically determine this from // the specified interface, then this only happens at runtime when the // first DHCP request needs this or during CheckApply, either of which // could fail if for some reason it is not available. ServerID *string `lang:"serverid" yaml:"serverid"` // LeaseTime is the default lease duration in a format that is parseable // by the golang time.ParseDuration function, for example "60s" or "10m" // or "1h42m13s". If it is unspecified, then a default will be used. If // the empty string is specified, then no lease time will be set in the // DHCP protocol, and your DHCP server might not work as you intend. LeaseTime *string `lang:"leasetime" yaml:"leasetime"` // DNS represents a list of DNS servers to offer to the DHCP client. // XXX: Is it mandatory? https://github.com/insomniacslk/dhcp/issues/359 DNS []string `lang:"dns" yaml:"dns"` // Routers represents a list of routers to offer to the DHCP client. It // is most common to only specify one unless you know what you're doing. Routers []string `lang:"routers" yaml:"routers"` // 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". This values is used as the // default for all dhcp:host resources. You can specify this here, and // the NBPPath per-resource and they will successfully combine. NBP string `lang:"nbp" yaml:"nbp"` // These private fields are ordered in the handler order, the above // public fields are ordered in the human logical order. leaseTime time.Duration sidMutex *sync.Mutex // guards the serverID field serverID net.IP // can be nil dnsServers4 []net.IP routers4 []net.IP //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? } // Default returns some sensible defaults for this resource. func (obj *DHCPServerRes) Default() engine.Res { return &DHCPServerRes{} } // getAddress returns the actual address to use. When Address is not specified, // we use the Name. func (obj *DHCPServerRes) getAddress() string { if obj.Address != "" { return obj.Address } return obj.Name() } // getServerID returns the expected server identifier that we should use, based // on our obj.ServerID, obj.Interface and Address value. This function should // only return (nil, nil) if the user requested we skip the server id process. func (obj *DHCPServerRes) getServerID() (net.IP, error) { // First see if the server id was specified explicitly... if obj.ServerID != nil { id := *obj.ServerID if id == "" { return nil, nil // skip! } ip := net.ParseIP(id) if ip == nil { // We're allowed to fail here, because you can either // specify a valid server id, or omit it, but you can // never pass in invalid data for no reason at all... return nil, fmt.Errorf("the ServerID is not a valid IP: %s", id) } if ip.To4() == nil { return nil, fmt.Errorf("the ServerID is not a valid v4 address: %s", id) } return ip, nil } // Next use the host part of the address if it was specified... if host, _, err := net.SplitHostPort(obj.getAddress()); err == nil && host != "" { ip := net.ParseIP(host) if ip == nil { // We're allowed to fail here, because you can either // specify a valid server id, or omit it, but you can // never pass in invalid data for no reason at all... return nil, fmt.Errorf("the Address is not a valid IP: %s", host) } if ip.To4() == nil { return nil, fmt.Errorf("the Address is not a valid v4 address: %s", host) } return ip, nil } // Try and lookup the ServerID automatically if we can, otherwise fail. // TODO: is there a way to determine this without Interface being set? // NOTE: we could look on the first interface if there is only one, but // what if an earlier graph operation created that interface in our run? if obj.Interface == "" { return nil, fmt.Errorf("can't get ServerID with an empty Interface") } // From `man dhcpd.conf`: // The default value is the first IP address associated with the // physical network interface on which the request arrived. The usual // case where the server-identifier statement needs to be sent is when a // physical interface has more than one IP address... iface, err := net.InterfaceByName(obj.Interface) // *net.Interface if err != nil { return nil, errwrap.Wrapf(err, "error finding interface: %s", obj.Interface) } if iface == nil { return nil, fmt.Errorf("unexpected nil iface") } a, err := iface.Addrs() if err != nil { return nil, errwrap.Wrapf(err, "error getting addrs from interface: %s", iface.Name) } if len(a) == 0 { // add a better error message in this scenario return nil, fmt.Errorf("no addrs were found on %s", iface.Name) } if obj.init.Debug { obj.init.Logf("got %d addrs from %s", len(a), iface.Name) for _, addr := range a { obj.init.Logf("addr: %s", addr.String()) } } var ip net.IP for _, addr := range a { // we're only interested in the strings (not the network) // NOTE: it's weird you can't get net.IP directly :/ Probably // because "the exact form and meaning of the strings is up to // the implementation". s := addr.String() // Parse by two different methods, since we don't know if it // contains a CIDR suffix or not... ip = net.ParseIP(s) if ip == nil { var err error ip, _, err = net.ParseCIDR(s) if err != nil || ip == nil { continue // nothing found } } // If we reached here, we have a potential IP... if ip.To4() == nil { ip = nil continue // take the first ipv4 address, or nothing... } break // we only care about the first one } if ip == nil { return nil, fmt.Errorf("no valid IPv4 addrs were found") } return ip, nil } // Validate checks if the resource data structure was populated correctly. func (obj *DHCPServerRes) Validate() error { // FIXME: https://github.com/insomniacslk/dhcp/issues/372 if obj.Interface == "" { return fmt.Errorf("the Interface is empty") } if obj.getAddress() == "" { return fmt.Errorf("the Address is empty") } // Ensure this format is valid for when we parse it later. host, _, err := net.SplitHostPort(obj.getAddress()) if err != nil { return errwrap.Wrapf(err, "the Address is in an invalid format: %s", obj.getAddress()) } if host != "" { ip := net.ParseIP(host) if ip == nil { return fmt.Errorf("the Address is not a valid IP: %s", host) } if ip.To4() == nil { return fmt.Errorf("the Address is not a valid v4 address: %s", host) } } // TODO: is there a way to determine this without Interface being set? // NOTE: we could look on the first interface if there is only one, but // what if an earlier graph operation created that interface in our run? if obj.ServerID == nil && (obj.Interface == "" && host == "") { return fmt.Errorf("can't determine ServerID automatically without Interface or host Address") } // We only validate the ServerID if it's specified and not empty. // We can't call getServerID because it does run-time dependent checks. if obj.ServerID != nil && *obj.ServerID != "" { // modified from: https://github.com/coredhcp/coredhcp/blob/b4aa45e6f7268cc4c52f863b130bd8eb388647b2/plugins/serverid/plugin.go#L101 serverID := net.ParseIP(*obj.ServerID) if serverID == nil { return fmt.Errorf("%s is an invalid or empty IP address", *obj.ServerID) } if serverID.To4() == nil { return fmt.Errorf("%s is not a valid IPv4 address", *obj.ServerID) } } // We only validate the LeaseTime if it's specified and not empty. if obj.LeaseTime != nil && *obj.LeaseTime != "" { // modified from: https://github.com/coredhcp/coredhcp/blob/b4aa45e6f7268cc4c52f863b130bd8eb388647b2/plugins/leasetime/plugin.go#L49 _, err := time.ParseDuration(*obj.LeaseTime) if err != nil { return errwrap.Wrapf(err, "invalid duration: %s", *obj.LeaseTime) } } // Validate the DNS servers. // modified from: https://github.com/coredhcp/coredhcp/blob/b4aa45e6f7268cc4c52f863b130bd8eb388647b2/plugins/dns/plugin.go#L52 for _, ip := range obj.DNS { if dns := net.ParseIP(ip); dns.To4() == nil { return fmt.Errorf("expected a DNS server address, got: %s", ip) } } // Validate the routers. // modified from: https://github.com/coredhcp/coredhcp/blob/b4aa45e6f7268cc4c52f863b130bd8eb388647b2/plugins/router/plugin.go#L42 for _, ip := range obj.Routers { if router := net.ParseIP(ip); router.To4() == nil { return fmt.Errorf("expected a router address, got: %s", ip) } } return nil } // Init runs some startup code for this resource. func (obj *DHCPServerRes) Init(init *engine.Init) error { obj.init = init // save for later // NOTE: If we don't Init anything that's autogrouped, then it won't // even get an Init call on it. // TODO: should we do this in the engine? Do we want to decide it here? for _, res := range obj.GetGroup() { // grouped elements if err := res.Init(init); err != nil { return errwrap.Wrapf(err, "autogrouped Init failed") } } // Ensure the lease time is valid before we try and use it. if obj.LeaseTime == nil || *obj.LeaseTime != "" { leaseTime := DHCPDefaultLeaseTime if obj.LeaseTime != nil { leaseTime = *obj.LeaseTime } var err error if obj.leaseTime, err = time.ParseDuration(leaseTime); err != nil { return errwrap.Wrapf(err, "unexpected invalid duration: %s", leaseTime) } } obj.sidMutex = &sync.Mutex{} // We can't do this here, because our network might not be up yet, and // if this happens in Init, that's before a Net resource might do it! //var err error //if obj.serverID, err = obj.getServerID(); err != nil { // return errwrap.Wrapf(err, "could not determine the ServerID") //} obj.dnsServers4 = []net.IP{} for _, ip := range obj.DNS { dns := net.ParseIP(ip).To4() if dns == nil { return fmt.Errorf("unexpected invalid DNS server address, got: %s", ip) } obj.dnsServers4 = append(obj.dnsServers4, dns) } obj.routers4 = []net.IP{} for _, ip := range obj.Routers { router := net.ParseIP(ip).To4() if router == nil { return fmt.Errorf("unexpected invalid router address, got: %s", ip) } obj.routers4 = append(obj.routers4, router) } //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 } // Cleanup is run by the engine to clean up after the resource is done. func (obj *DHCPServerRes) Cleanup() error { // NOTE: if this ever panics, it might mean the engine is running Close // before Watch finishes exiting, which is an engine bug in that code... //obj.mutex.RUnlock() return nil } // Watch is the primary listener for this resource and it outputs events. func (obj *DHCPServerRes) Watch(ctx context.Context) error { addr, err := net.ResolveUDPAddr("udp", obj.getAddress()) // *net.UDPAddr if err != nil { return errwrap.Wrapf(err, "could not resolve address") } //conn, err := net.ListenUDP("udp", addr) //if err != nil { // return errwrap.Wrapf(err, "could not start listener") //} //defer conn.Close() opts := []server4.ServerOpt{} // This is the variant for the simple interface as seen in: // https://github.com/insomniacslk/dhcp/pull/373 //logf := func(format string, v ...interface{}) { // obj.init.Logf("dhcpv4: "+format, v...) //} //logfOpt := server4.WithLogf(logf) // wrap the server logging... //opts = append(opts, logfOpt) newLogger := &overEngineeredLogger{ logf: func(format string, v ...interface{}) { obj.init.Logf(format, v...) }, } logOpt := server4.WithLogger(newLogger) opts = append(opts, logOpt) server, err := server4.NewServer(obj.Interface, addr, obj.handler4(), opts...) if err != nil { return errwrap.Wrapf(err, "could not start listener") // it's inside } obj.init.Running() // when started, notify engine that we're running //defer obj.mutex.RLock() //obj.mutex.RUnlock() // it's safe to let CheckApply proceed var closeError error closeSignal := make(chan struct{}) wg := &sync.WaitGroup{} defer wg.Wait() wg.Add(1) go func() { defer wg.Done() defer close(closeSignal) err := server.Serve() // blocks until Close() is called I hope! // TODO: getting this error is probably a bug, please see: // https://github.com/insomniacslk/dhcp/issues/376 isClosing := errors.Is(err, net.ErrClosed) if err == nil || isClosing { return } // if this returned on its own, then closeSignal can be used... closeError = errwrap.Wrapf(err, "the server errored") }() defer server.Close() startupChan := make(chan struct{}) close(startupChan) // send one initial signal var send = false // send event? for { if obj.init.Debug { obj.init.Logf("Looping...") } select { case <-startupChan: startupChan = nil send = true case <-closeSignal: // something shut us down early return closeError case <-ctx.Done(): // closed by the engine to signal shutdown return nil } // do all our event sending all together to avoid duplicate msgs if send { send = false obj.init.Event() // notify engine of an event (this can block) } } } // sidCheckApply runs the server ID cache operation in CheckApply, which can // help CheckApply fail before the handler runs, so at least we see an error. func (obj *DHCPServerRes) sidCheckApply(ctx context.Context, apply bool) (bool, error) { // Mutex guards the cached obj.serverID value. defer obj.sidMutex.Unlock() obj.sidMutex.Lock() // We've been explicitly asked to skip this handler. if obj.ServerID != nil && *obj.ServerID == "" { return true, nil } if obj.serverID == nil { // lookup the server ID and cache it here... var err error if obj.serverID, err = obj.getServerID(); err != nil { obj.init.Logf("could not determine the ServerID during CheckApply") return false, err } } return true, nil } // CheckApply never has anything to do for this resource, so it always succeeds. // It does however check that certain runtime requirements (such as the Root dir // existing if one was specified) are fulfilled. func (obj *DHCPServerRes) CheckApply(ctx context.Context, apply bool) (bool, error) { if obj.init.Debug { obj.init.Logf("CheckApply") } //// We don't want the initial CheckApply to return true until the Watch //// has started up, so we must block here until that's the case... //ch := make(chan struct{}) //go func() { // // XXX: this goroutine leaks if CheckApply runs right before the // // Watch method exits on error. And this could deadlock it too! // defer close(ch) // defer obj.mutex.Unlock() // obj.mutex.Lock() // can't acquire this lock until Watch startup RUnlock // //}() //select { //case <-ch: ////case <-obj.interruptChan: // TODO: if we ever support InterruptableRes //case <-obj.init.DoneCtx.Done(): // closed by the engine to signal shutdown //} // Cheap runtime validation! checkOK := true if c, err := obj.sidCheckApply(ctx, apply); err != nil { return false, err } else if !c { checkOK = false } return checkOK, nil // almost always succeeds, with nothing to do! } // Cmp compares two resources and returns an error if they are not equivalent. func (obj *DHCPServerRes) Cmp(r engine.Res) error { // we can only compare DHCPServerRes to others of the same resource kind res, ok := r.(*DHCPServerRes) if !ok { return fmt.Errorf("res is not the same kind") } if obj.Address != res.Address { return fmt.Errorf("the Address differs") } if obj.Interface != res.Interface { return fmt.Errorf("the Interface differs") } if (obj.ServerID == nil) != (res.ServerID == nil) { // xor return fmt.Errorf("the ServerID differs") } if obj.ServerID != nil && res.ServerID != nil { if *obj.ServerID != *res.ServerID { // compare the strings return fmt.Errorf("the contents of ServerID differ") } } // Be sneaky and compare the actual values. Eg: 60s vs 1m are the same! objIsEmptyLeaseTime := obj.LeaseTime != nil && *obj.LeaseTime == "" resIsEmptyLeaseTime := res.LeaseTime != nil && *res.LeaseTime == "" // if objIsEmptyLeaseTime && !resIsEmptyLeaseTime : FAIL // if !objIsEmptyLeaseTime && resIsEmptyLeaseTime : FAIL if objIsEmptyLeaseTime != resIsEmptyLeaseTime { return fmt.Errorf("the LeaseTime differs") } // if objIsEmptyLeaseTime && resIsEmptyLeaseTime : SKIP // if !objIsEmptyLeaseTime && !resIsEmptyLeaseTime : COMPARE if !objIsEmptyLeaseTime && !resIsEmptyLeaseTime { objLeaseTime := DHCPDefaultLeaseTime resLeaseTime := DHCPDefaultLeaseTime if obj.LeaseTime != nil { objLeaseTime = *obj.LeaseTime } if res.LeaseTime != nil { resLeaseTime = *res.LeaseTime } d1, err1 := time.ParseDuration(objLeaseTime) d2, err2 := time.ParseDuration(resLeaseTime) // we can only compare this way if they both parse correctly... if err1 == nil && err2 == nil { // TODO: should we use Nanoseconds or Seconds instead? // NOTE: Seconds are the sane unit for DHCP, and // Nanoseconds are the internal representation of the // Duration value. LeaseTime option precision is in sec. //if d1.Milliseconds() != d2.Milliseconds() { if d1.Seconds() != d2.Seconds() { return fmt.Errorf("the duration of LeaseTime differs") } } else if objLeaseTime != resLeaseTime { // plain string cmp return fmt.Errorf("the contents of LeaseTime differs") } } if len(obj.DNS) != len(res.DNS) { return fmt.Errorf("the number of DNS servers differs") } for i, x := range obj.DNS { if dns := res.DNS[i]; x != dns { return fmt.Errorf("the DNS server at index %d differs", i) } } if len(obj.Routers) != len(res.Routers) { return fmt.Errorf("the number of routers differs") } for i, x := range obj.Routers { if router := res.Routers[i]; x != router { return fmt.Errorf("the router at index %d differs", i) } } if len(obj.NBP) != len(res.NBP) { return fmt.Errorf("the NBP field differs") } return nil } // Copy copies the resource. Don't call it directly, use engine.ResCopy instead. // TODO: should this copy internal state? func (obj *DHCPServerRes) Copy() engine.CopyableRes { var serverID *string if obj.ServerID != nil { s := *obj.ServerID // copy serverID = &s } var leaseTime *string if obj.LeaseTime != nil { x := *obj.LeaseTime // copy leaseTime = &x } dns := []string{} for _, x := range obj.DNS { dns = append(dns, x) } routers := []string{} for _, x := range obj.Routers { routers = append(routers, x) } return &DHCPServerRes{ Address: obj.Address, Interface: obj.Interface, ServerID: serverID, LeaseTime: leaseTime, DNS: dns, Routers: routers, NBP: obj.NBP, } } // UnmarshalYAML is the custom unmarshal handler for this struct. It is // primarily useful for setting the defaults. func (obj *DHCPServerRes) UnmarshalYAML(unmarshal func(interface{}) error) error { type rawRes DHCPServerRes // indirection to avoid infinite recursion def := obj.Default() // get the default res, ok := def.(*DHCPServerRes) // put in the right format if !ok { return fmt.Errorf("could not convert to DHCPServerRes") } raw := rawRes(*res) // convert; the defaults go here if err := unmarshal(&raw); err != nil { return err } *obj = DHCPServerRes(raw) // restore from indirection with type conversion! return nil } // GroupCmp returns whether two resources can be grouped together or not. Can // these two resources be merged, aka, does this resource support doing so? Will // resource allow itself to be grouped _into_ this obj? func (obj *DHCPServerRes) GroupCmp(r engine.GroupableRes) error { res1, ok1 := r.(*DHCPHostRes) // different from what we usually do! if ok1 { // If the dhcp host resource has the Server field specified, // then it must match against our name field if we want it to // group with us. if res1.Server != "" && res1.Server != obj.Name() { return fmt.Errorf("resource groups with a different server name") } 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") } return nil } return fmt.Errorf("resource is not the right kind") } // leasetimeHandler4 handles DHCPv4 packets for the leasetime component. // Modified from: https://github.com/coredhcp/coredhcp/blob/b4aa45e6f7268cc4c52f863b130bd8eb388647b2/plugins/leasetime/plugin.go#L32 func (obj *DHCPServerRes) leasetimeHandler4(req, resp *dhcpv4.DHCPv4) (*dhcpv4.DHCPv4, bool) { // We've been explicitly asked to skip this handler. if obj.LeaseTime != nil && *obj.LeaseTime == "" { return resp, false } if req.OpCode != dhcpv4.OpcodeBootRequest { return resp, false } // Set lease time unless it has already been set. if !resp.Options.Has(dhcpv4.OptionIPAddressLeaseTime) { resp.Options.Update(dhcpv4.OptIPAddressLeaseTime(obj.leaseTime)) } return resp, false } // serverIDHandler4 handles DHCPv4 packets for the serverid component. Modified // from: https://github.com/coredhcp/coredhcp/blob/b4aa45e6f7268cc4c52f863b130bd8eb388647b2/plugins/serverid/plugin.go#L78 func (obj *DHCPServerRes) serverIDHandler4(req, resp *dhcpv4.DHCPv4) (*dhcpv4.DHCPv4, bool) { // We've been explicitly asked to skip this handler. if obj.ServerID != nil && *obj.ServerID == "" { return resp, false } // Mutex guards the cached obj.serverID value. defer obj.sidMutex.Unlock() obj.sidMutex.Lock() if obj.serverID == nil { // lookup the server ID and cache it here... var err error if obj.serverID, err = obj.getServerID(); err != nil { obj.init.Logf("could not determine the ServerID during runtime") return resp, false } } v4ServerID := obj.serverID.To4() if v4ServerID == nil { // We already checked this in Validate! panic("unexpected nil serverID") } if req.OpCode != dhcpv4.OpcodeBootRequest { if obj.init.Debug { obj.init.Logf("not a BootRequest, ignoring") } return resp, false } if req.ServerIPAddr != nil && !req.ServerIPAddr.Equal(net.IPv4zero) && !req.ServerIPAddr.Equal(v4ServerID) { // This request is not for us, drop it. if obj.init.Debug { obj.init.Logf("requested server ID does not match this server's ID. Got %v, want %v", req.ServerIPAddr, v4ServerID) } return nil, true } resp.ServerIPAddr = make(net.IP, net.IPv4len) copy(resp.ServerIPAddr[:], v4ServerID) resp.UpdateOption(dhcpv4.OptServerIdentifier(v4ServerID)) return resp, false } // dnsHandler4 handles DHCPv4 packets for the DNS component. Modified from: // https://github.com/coredhcp/coredhcp/blob/b4aa45e6f7268cc4c52f863b130bd8eb388647b2/plugins/dns/plugin.go#L79 // XXX: Is it mandatory? https://github.com/insomniacslk/dhcp/issues/359 func (obj *DHCPServerRes) dnsHandler4(req, resp *dhcpv4.DHCPv4) (*dhcpv4.DHCPv4, bool) { if len(obj.dnsServers4) == 0 { return resp, false // skip it } if req.IsOptionRequested(dhcpv4.OptionDomainNameServer) { resp.Options.Update(dhcpv4.OptDNS(obj.dnsServers4...)) } return resp, false } // routerHandler4 handles DHCPv4 packets for the router component. Modified // from: https://github.com/coredhcp/coredhcp/blob/b4aa45e6f7268cc4c52f863b130bd8eb388647b2/plugins/router/plugin.go#L61 func (obj *DHCPServerRes) routerHandler4(req, resp *dhcpv4.DHCPv4) (*dhcpv4.DHCPv4, bool) { if len(obj.routers4) == 0 { return resp, false // skip it } resp.Options.Update(dhcpv4.OptRouter(obj.routers4...)) return resp, false } // handler4 handles all the incoming requests from IPv4 clients. func (obj *DHCPServerRes) handler4() func(net.PacketConn, net.Addr, *dhcpv4.DHCPv4) { // NOTE: this is similar to MainHandler4 in coredhcp. Keep it in sync... return func(conn net.PacketConn, peer net.Addr, req *dhcpv4.DHCPv4) { // req is the incoming message from the dhcp client // peer is who we're replying to (often a broadcast address) obj.init.Logf("received a DHCPv4 packet from: %s", req.ClientHWAddr.String()) if obj.init.Debug { obj.init.Logf("received from DHCPv4 peer: %s", peer) obj.init.Logf("received a DHCPv4 packet: %s", req.Summary()) } var ( resp, tmp *dhcpv4.DHCPv4 err error stop bool ) if req.OpCode != dhcpv4.OpcodeBootRequest { obj.init.Logf("handler4: unsupported opcode %d. Only BootRequest (%d) is supported", req.OpCode, dhcpv4.OpcodeBootRequest) return } tmp, err = dhcpv4.NewReplyFromRequest(req) if err != nil { obj.init.Logf("handler4: failed to build reply: %v", err) return } // D-O-R-A switch mt := req.MessageType(); mt { case dhcpv4.MessageTypeDiscover: tmp.UpdateOption(dhcpv4.OptMessageType(dhcpv4.MessageTypeOffer)) case dhcpv4.MessageTypeRequest: tmp.UpdateOption(dhcpv4.OptMessageType(dhcpv4.MessageTypeAck)) case dhcpv4.MessageTypeDecline: // If mask is not set, some DHCP clients will DECLINE. obj.init.Logf("handler4: Unhandled decline message: %+v", req) return default: obj.init.Logf("handler4: Unhandled message type: %v", mt) return } handlers := []handler.Handler4{} // These are the core handlers from our own server struct. The // order of these matters in theory, and possibly in practice. handlers = append(handlers, obj.leasetimeHandler4) handlers = append(handlers, obj.serverIDHandler4) handlers = append(handlers, obj.dnsHandler4) handlers = append(handlers, obj.routerHandler4) //handlers = append(handlers, obj.netmaskHandler4) // in host // These handlers arrive from autogrouping other resources in. hostHandlers := []handler.Handler4{} rangeHandlers := []handler.Handler4{} // Look through the autogrouped resources! // TODO: can we improve performance by only searching here once? for _, x := range obj.GetGroup() { // grouped elements if obj.init.Debug { obj.init.Logf("Got grouped resource: %s", x.String()) } // TODO: any kind of filtering could go here... switch res := x.(type) { // convert from Res case *DHCPHostRes: // NOTE: this is not changed if we do send/recv. // If we want to support that, we need to patch! 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 } hostHandlers = append(hostHandlers, 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 } } // NOTE: the order of these plugins matter, but we'll just // hardcode something for now. It could be configurable later. for _, h := range hostHandlers { handlers = append(handlers, h) } for _, h := range rangeHandlers { handlers = append(handlers, h) } resp = tmp for _, handler := range handlers { resp, stop = handler(req, resp) if stop { break } } if resp != nil { if obj.init.Debug { // NOTE: This is very useful for debugging! obj.init.Logf("sending a DHCPv4 packet: %s", resp.Summary()) } var peer net.Addr if !req.GatewayIPAddr.IsUnspecified() { // TODO: make RFC8357 compliant peer = &net.UDPAddr{IP: req.GatewayIPAddr, Port: dhcpv4.ServerPort} } else if resp.MessageType() == dhcpv4.MessageTypeNak { peer = &net.UDPAddr{IP: net.IPv4bcast, Port: dhcpv4.ClientPort} } else if !req.ClientIPAddr.IsUnspecified() { peer = &net.UDPAddr{IP: req.ClientIPAddr, Port: dhcpv4.ClientPort} } else if req.IsBroadcast() { peer = &net.UDPAddr{IP: net.IPv4bcast, Port: dhcpv4.ClientPort} } else { // FIXME: we're supposed to unicast to a specific *L2* address, and an L3 // address that's not yet assigned. // I don't know how to do that with this API... //peer = &net.UDPAddr{IP: resp.YourIPAddr, Port: dhcpv4.ClientPort} obj.init.Logf("handler4: Cannot handle non-broadcast-capable unspecified peers in an RFC-compliant way. Response will be broadcast") peer = &net.UDPAddr{IP: net.IPv4bcast, Port: dhcpv4.ClientPort} } if _, err := conn.WriteTo(resp.ToBytes(), peer); err != nil { obj.init.Logf("handler4: conn.Write to %v failed: %v", peer, err) } } else { obj.init.Logf("handler4: dropping request because response is nil") } return // department of redundancy department } } // DHCPHostRes is a representation of a static host assignment in DHCP. type DHCPHostRes 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"` // Mac is the mac address of the host in lower case and separated with // colons. Mac string `lang:"mac" yaml:"mac"` // IP is the IPv4 address with the CIDR suffix. The suffix is required // because it specifies the netmask to be used in the DHCPv4 protocol. // For example, you might specify 192.0.2.42/24 which represents a mask // of 255.255.255.0 that will be sent. IP string `lang:"ip" yaml:"ip"` // 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"` ipv4Addr net.IP // XXX: port to netip.Addr ipv4Mask net.IPMask opt66 *dhcpv4.Option opt67 *dhcpv4.Option } // Default returns some sensible defaults for this resource. func (obj *DHCPHostRes) Default() engine.Res { return &DHCPHostRes{} } // Validate checks if the resource data structure was populated correctly. func (obj *DHCPHostRes) Validate() error { if hw, err := net.ParseMAC(obj.Mac); err != nil { return errwrap.Wrapf(err, "invalid mac address") } else if s := hw.String(); s != obj.Mac { // should be all lowercase, for example return fmt.Errorf("the mac address is not in the canonical format of: %s", s) } ipv4Addr, _, err := net.ParseCIDR(obj.IP) if err != nil { return errwrap.Wrapf(err, "invalid IP/CIDR address") } if ipv4Addr.To4() == nil { return fmt.Errorf("only IPv4 is currently supported") } // If we didn't require the CIDR, then we could do this... //if obj.IP != "" && net.ParseIP(obj.IP) == nil { // return fmt.Errorf("the IP was not a valid address") //} //if obj.IP != "" && net.ParseIP(obj.IP).To4() == nil { // return fmt.Errorf("only IPv4 is currently supported") //} // 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 *DHCPHostRes) Init(init *engine.Init) error { obj.init = init // save for later ipv4Addr, ipv4Net, err := net.ParseCIDR(obj.IP) if err != nil { return errwrap.Wrapf(err, "unexpected invalid IP/CIDR address") } if ipv4Addr.To4() == nil { return fmt.Errorf("unexpectedly missing an IPv4 address") } obj.ipv4Addr = ipv4Addr obj.ipv4Mask = ipv4Net.Mask return nil } // Cleanup is run by the engine to clean up after the resource is done. func (obj *DHCPHostRes) 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 *DHCPHostRes) 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 *DHCPHostRes) 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 *DHCPHostRes) Cmp(r engine.Res) error { // we can only compare DHCPHostRes to others of the same resource kind res, ok := r.(*DHCPHostRes) 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.Mac != res.Mac { return fmt.Errorf("the Mac differs") } if obj.IP != res.IP { return fmt.Errorf("the IP 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 *DHCPHostRes) UnmarshalYAML(unmarshal func(interface{}) error) error { type rawRes DHCPHostRes // indirection to avoid infinite recursion def := obj.Default() // get the default res, ok := def.(*DHCPHostRes) // put in the right format if !ok { return fmt.Errorf("could not convert to DHCPHostRes") } raw := rawRes(*res) // convert; the defaults go here if err := unmarshal(&raw); err != nil { return err } *obj = DHCPHostRes(raw) // restore from indirection with type conversion! return nil } // handler4 returns the handler for the host 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 *DHCPHostRes) 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 } return func(req, resp *dhcpv4.DHCPv4) (*dhcpv4.DHCPv4, bool) { // Might not be *this* reservation, but another one... if obj.init.Debug { obj.init.Logf("comparing mac %s to %s", req.ClientHWAddr.String(), obj.Mac) } if req.ClientHWAddr.String() != obj.Mac { //obj.init.Logf("MAC address %s is unknown", req.ClientHWAddr.String()) return resp, false } if obj.init.Debug || true { // TODO: should we silence this? obj.init.Logf("found IP address %s for MAC %s", obj.IP, req.ClientHWAddr.String()) } 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. // If mask is not set, some DHCP clients will DECLINE. resp.Options.Update(dhcpv4.OptSubnetMask(obj.ipv4Mask)) // 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 } // 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", netmaskAsQuadString(obj.mask)) 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 // persisted 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. // If mask is not set, some DHCP clients will DECLINE. 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. NBP string } // overEngineeredLogger is a helper struct that fulfills the over-engineered // logging interface that was introduced in: // https://github.com/insomniacslk/dhcp/pull/371/ type overEngineeredLogger struct { logf func(format string, v ...interface{}) } func (obj *overEngineeredLogger) Printf(format string, v ...interface{}) { obj.logf(format, v...) } 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 } // netmaskAsQuadString returns a dotted-quad string giving you something like: // 255.255.255.0 instead of ffffff00 which is what's seen when you print it now. func netmaskAsQuadString(netmask net.IPMask) string { return fmt.Sprintf("%d.%d.%d.%d", netmask[0], netmask[1], netmask[2], netmask[3]) }