From 583344138a8d1b7155976c860a75ace19e611cc7 Mon Sep 17 00:00:00 2001 From: James Shubin Date: Sun, 29 Mar 2020 03:02:56 -0400 Subject: [PATCH] engine: resources: Add dhcp:server and dhcp:host resources This adds a new dhcp server resource, as well as a dhcp host resource used to specify the static mapping between mac address and ip address. It also adds a simple, pure-golang example dhcp client which might make testing easier. The dhcp resource is not meant to be a full-featured dhcp server replacement, and it might still be useful to use the venerable dhcpd, however for integrated, pure-golang bootstrapping environments, this might prove to be very useful. It can be combined with the tftp resource to build PXE setups with mgmt. This resource can be extended further to support a dhcp:range directive, automatic edges, and more! --- engine/resources/dhcp.go | 1177 +++++++++++++++++++++++++++ examples/dhcp_client/.gitignore | 1 + examples/dhcp_client/Makefile | 29 + examples/dhcp_client/dhcp_client.go | 107 +++ examples/lang/dhcp0.mcl | 26 + 5 files changed, 1340 insertions(+) create mode 100644 engine/resources/dhcp.go create mode 100644 examples/dhcp_client/.gitignore create mode 100644 examples/dhcp_client/Makefile create mode 100644 examples/dhcp_client/dhcp_client.go create mode 100644 examples/lang/dhcp0.mcl diff --git a/engine/resources/dhcp.go b/engine/resources/dhcp.go new file mode 100644 index 00000000..ec6dfecc --- /dev/null +++ b/engine/resources/dhcp.go @@ -0,0 +1,1177 @@ +// Mgmt +// Copyright (C) 2013-2020+ 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 . + +package resources + +import ( + "fmt" + "net" + "net/url" + "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/insomniacslk/dhcp/dhcpv4" + "github.com/insomniacslk/dhcp/dhcpv4/server4" +) + +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"` + + // 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 + + // 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() + + return nil +} + +// Close is run by the engine to clean up after the resource is done. +func (obj *DHCPServerRes) Close() 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() 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("dhcpv4: "+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! + if err == nil { + 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 <-obj.init.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(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(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.Done: // closed by the engine to signal shutdown + //} + + // Cheap runtime validation! + checkOK := true + if c, err := obj.sidCheckApply(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) + } + } + + 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, + } +} + +// 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) + 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)) + 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: + h := res.handler4() + hostHandlers = append(hostHandlers, h) + + //case *DhcpRangeRes: + // h := res.handler4() + // 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 { + 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 + 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 + + result, err := url.Parse(obj.NBP) + if err != nil { + // this should have been checked in Validate :/ + return 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) + obj.opt67 = &obfn + + return nil +} + +// Close is run by the engine to clean up after the resource is done. +func (obj *DHCPHostRes) Close() 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() error { + obj.init.Running() // when started, notify engine that we're running + + select { + case <-obj.init.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(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() func(*dhcpv4.DHCPv4, *dhcpv4.DHCPv4) (*dhcpv4.DHCPv4, bool) { + 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: 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.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 + } +} + +// 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) +} diff --git a/examples/dhcp_client/.gitignore b/examples/dhcp_client/.gitignore new file mode 100644 index 00000000..91b3907f --- /dev/null +++ b/examples/dhcp_client/.gitignore @@ -0,0 +1 @@ +dhcp_client diff --git a/examples/dhcp_client/Makefile b/examples/dhcp_client/Makefile new file mode 100644 index 00000000..223efeaf --- /dev/null +++ b/examples/dhcp_client/Makefile @@ -0,0 +1,29 @@ +# Mgmt +# Copyright (C) 2013-2020+ 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 . + +SHELL = /usr/bin/env bash + +.PHONY: clean +.SILENT: clean + +all: dhcp_client + +dhcp_client: dhcp_client.go + go build -o dhcp_client dhcp_client.go + +clean: + rm -f dhcp_client diff --git a/examples/dhcp_client/dhcp_client.go b/examples/dhcp_client/dhcp_client.go new file mode 100644 index 00000000..790ea8dc --- /dev/null +++ b/examples/dhcp_client/dhcp_client.go @@ -0,0 +1,107 @@ +// Mgmt +// Copyright (C) 2013-2020+ 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 . + +package main + +import ( + "context" + "fmt" + "log" + "net" + "os" + + "github.com/insomniacslk/dhcp/dhcpv4" + "github.com/insomniacslk/dhcp/dhcpv4/nclient4" +) + +const ( + iface = "lo" // loopback for local testing + address = "127.0.0.1" +) + +func main() { + if len(os.Args) < 2 || len(os.Args) > 3 { + log.Printf("Usage: %s [port] ", os.Args[0]) + return + } + + port := string(nclient4.ServerPort) // the default is 67 + if len(os.Args) >= 3 { + port = os.Args[1] + } + hwAddr := os.Args[len(os.Args)-1] // argv[1] + + hw, err := net.ParseMAC(hwAddr) + if err != nil { + log.Printf("Invalid mac address: %v", err) + return + } + + addr := fmt.Sprintf("%s:%s", address, port) + log.Printf("Connecting to: %s", addr) + + opts := []nclient4.ClientOpt{} + { + opt := nclient4.WithHWAddr(hw) + opts = append(opts, opt) + } + { + opt := nclient4.WithSummaryLogger() + opts = append(opts, opt) + } + //{ + // opt := nclient4.WithDebugLogger() + // opts = append(opts, opt) + //} + + //c, err := nclient4.NewWithConn(conn net.PacketConn, ifaceHWAddr net.HardwareAddr, opts...) + c, err := nclient4.New(iface, opts...) + if err != nil { + log.Printf("Error connecting to server: %v", err) + return + } + defer func() { + if err := c.Close(); err != nil { + log.Printf("Error closing client: %v", err) + } + }() + + modifiers := []dhcpv4.Modifier{} + //{ + // mod := dhcpv4.WithYourIP(net.ParseIP(?)) + // modifiers = append(modifiers, mod) + //} + //{ + // mod := dhcpv4.WithClientIP(net.ParseIP(?)) + // modifiers = append(modifiers, mod) + //} + // TODO: add modifiers + + log.Printf("Requesting...") + ctx := context.Background() // TODO: add to ^C handler + offer, ack, err := c.Request(ctx, modifiers...) // (offer, ack *dhcpv4.DHCPv4, err error) + if err != nil { + log.Printf("Error requesting from server: %v", err) + return + } + + // Show the results of the D-O-R-A exchange. + log.Printf("Offer: %+v", offer) + log.Printf("Ack: %+v", ack) + + log.Printf("Done!") +} diff --git a/examples/lang/dhcp0.mcl b/examples/lang/dhcp0.mcl new file mode 100644 index 00000000..b063bf77 --- /dev/null +++ b/examples/lang/dhcp0.mcl @@ -0,0 +1,26 @@ +$iface = "lo" # replace with your desired interface like eth0 + +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: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 +}