Added DHCPv4 Client
This adds a bare-bones DHCPv4 client. Currently leases are handled by a single callback which
can then be used to implement option observers and other ways to deal with them.
Test Plan: Some tests already here, more coming.
X-Origin-Diff: phab/D645
GitOrigin-RevId: 76fae7080cdd8ba59cf77368179cae0bc9c9c824
diff --git a/core/internal/network/dhcp/dhcp.go b/core/internal/network/dhcp/dhcp.go
index 6621f1e..9a62c7c 100644
--- a/core/internal/network/dhcp/dhcp.go
+++ b/core/internal/network/dhcp/dhcp.go
@@ -84,13 +84,13 @@
err = supervisor.Run(ctx, "client", func(ctx context.Context) error {
supervisor.Signal(ctx, supervisor.SignalHealthy)
- _, ack, err := client.Request(ctx)
+ lease, err := client.Request(ctx)
if err != nil {
// TODO(q3k): implement retry logic with full state machine
logger.Errorf("DHCP lease request failed: %v", err)
return err
}
- newC <- parseAck(ack)
+ newC <- parseAck(lease.ACK)
supervisor.Signal(ctx, supervisor.SignalDone)
return nil
})
diff --git a/core/pkg/dhcp4c/BUILD.bazel b/core/pkg/dhcp4c/BUILD.bazel
new file mode 100644
index 0000000..6d79268
--- /dev/null
+++ b/core/pkg/dhcp4c/BUILD.bazel
@@ -0,0 +1,32 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
+
+go_library(
+ name = "go_default_library",
+ srcs = [
+ "dhcpc.go",
+ "doc.go",
+ "lease.go",
+ ],
+ importpath = "git.monogon.dev/source/nexantic.git/core/pkg/dhcp4c",
+ visibility = ["//visibility:public"],
+ deps = [
+ "//core/internal/common/supervisor:go_default_library",
+ "//core/pkg/dhcp4c/transport:go_default_library",
+ "@com_github_cenkalti_backoff_v4//:go_default_library",
+ "@com_github_insomniacslk_dhcp//dhcpv4:go_default_library",
+ "@com_github_insomniacslk_dhcp//iana:go_default_library",
+ ],
+)
+
+go_test(
+ name = "go_default_test",
+ srcs = ["dhcpc_test.go"],
+ embed = [":go_default_library"],
+ pure = "on",
+ deps = [
+ "//core/pkg/dhcp4c/transport:go_default_library",
+ "@com_github_cenkalti_backoff_v4//:go_default_library",
+ "@com_github_insomniacslk_dhcp//dhcpv4:go_default_library",
+ "@com_github_stretchr_testify//assert:go_default_library",
+ ],
+)
diff --git a/core/pkg/dhcp4c/dhcpc.go b/core/pkg/dhcp4c/dhcpc.go
new file mode 100644
index 0000000..0811890
--- /dev/null
+++ b/core/pkg/dhcp4c/dhcpc.go
@@ -0,0 +1,677 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package dhcp4c implements a DHCPv4 Client as specified in RFC2131 (with some notable deviations).
+// It implements only the DHCP state machine itself, any configuration other than the interface IP
+// address (which is always assigned in DHCP and necessary for the protocol to work) is exposed
+// as [informers/observables/watchable variables/???] to consumers who then deal with it.
+package dhcp4c
+
+import (
+ "context"
+ "crypto/rand"
+ "errors"
+ "fmt"
+ "io"
+ "math"
+ "net"
+ "time"
+
+ "github.com/cenkalti/backoff/v4"
+ "github.com/insomniacslk/dhcp/dhcpv4"
+ "github.com/insomniacslk/dhcp/iana"
+
+ "git.monogon.dev/source/nexantic.git/core/internal/common/supervisor"
+ "git.monogon.dev/source/nexantic.git/core/pkg/dhcp4c/transport"
+)
+
+type state int
+
+const (
+ // stateDiscovering sends broadcast DHCPDISCOVER messages to the network and waits for either a DHCPOFFER or
+ // (in case of Rapid Commit) DHCPACK.
+ stateDiscovering state = iota
+ // stateRequesting sends broadcast DHCPREQUEST messages containing the server identifier for the selected lease and
+ // waits for a DHCPACK or a DHCPNAK. If it doesn't get either it transitions back into discovering.
+ stateRequesting
+ // stateBound just waits until RenewDeadline (derived from RenewTimeValue, half the lifetime by default) expires.
+ stateBound
+ // stateRenewing sends unicast DHCPREQUEST messages to the currently-selected server and waits for either a DHCPACK
+ // or DHCPNAK message. On DHCPACK it transitions to bound, otherwise to discovering.
+ stateRenewing
+ // stateRebinding sends broadcast DHCPREQUEST messages to the network and waits for either a DHCPACK or DHCPNAK from
+ // any server. Response processing is identical to stateRenewing.
+ stateRebinding
+)
+
+func (s state) String() string {
+ switch s {
+ case stateDiscovering:
+ return "DISCOVERING"
+ case stateRequesting:
+ return "REQUESTING"
+ case stateBound:
+ return "BOUND"
+ case stateRenewing:
+ return "RENEWING"
+ case stateRebinding:
+ return "REBINDING"
+ default:
+ return "INVALID"
+ }
+}
+
+// This only requests SubnetMask and IPAddressLeaseTime as renewal and rebinding times are fine if
+// they are just defaulted. They are respected (if valid, otherwise they are clamped to the nearest
+// valid value) if sent by the server.
+var internalOptions = dhcpv4.OptionCodeList{dhcpv4.OptionSubnetMask, dhcpv4.OptionIPAddressLeaseTime}
+
+// Transport represents a mechanism over which DHCP messages can be exchanged with a server.
+type Transport interface {
+ // Send attempts to send the given DHCP payload message to the transport target once. An empty return value
+ // does not indicate that the message was successfully received.
+ Send(payload *dhcpv4.DHCPv4) error
+ // SetReceiveDeadline sets a deadline for Receive() calls after which they return with DeadlineExceededErr
+ SetReceiveDeadline(time.Time) error
+ // Receive waits for a DHCP message to arrive and returns it. If the deadline expires without a message arriving
+ // it will return DeadlineExceededErr. If the message is completely malformed it will an instance of
+ // InvalidMessageError.
+ Receive() (*dhcpv4.DHCPv4, error)
+ // Close closes the given transport. Calls to any of the above methods will fail if the transport is closed.
+ // Specific transports can be reopened after being closed.
+ Close() error
+}
+
+// UnicastTransport represents a mechanism over which DHCP messages can be exchanged with a single server over an
+// arbitrary IPv4-based network. Implementers need to support servers running outside the local network via a router.
+type UnicastTransport interface {
+ Transport
+ // Open connects the transport to a new unicast target. Can only be called after calling Close() or after creating
+ // a new transport.
+ Open(serverIP, bindIP net.IP) error
+}
+
+// BroadcastTransport represents a mechanism over which DHCP messages can be exchanged with all servers on a Layer 2
+// broadcast domain. Implementers need to support sending and receiving messages without any IP being configured on
+// the interface.
+type BroadcastTransport interface {
+ Transport
+ // Open connects the transport. Can only be called after calling Close() or after creating a new transport.
+ Open() error
+}
+
+type LeaseCallback func(old, new *Lease) error
+
+// Client implements a DHCPv4 client.
+//
+// Note that the size of all data sent to the server (RequestedOptions, ClientIdentifier,
+// VendorClassIdentifier and ExtraRequestOptions) should be kept reasonably small (<500 bytes) in
+// order to maximize the chance that requests can be properly transmitted.
+type Client struct {
+ // RequestedOptions contains a list of extra options this client is interested in
+ RequestedOptions dhcpv4.OptionCodeList
+
+ // ClientIdentifier is used by the DHCP server to identify this client.
+ // If empty, on Ethernet the MAC address is used instead.
+ ClientIdentifier []byte
+
+ // VendorClassIdentifier is used by the DHCP server to identify options specific to this type of
+ // clients and to populate the vendor-specific option (43).
+ VendorClassIdentifier string
+
+ // ExtraRequestOptions are extra options sent to the server.
+ ExtraRequestOptions dhcpv4.Options
+
+ // Backoff strategies for each state. These all have sane defaults, override them only if
+ // necessary.
+ DiscoverBackoff backoff.BackOff
+ AcceptOfferBackoff backoff.BackOff
+ RenewBackoff backoff.BackOff
+ RebindBackoff backoff.BackOff
+
+ state state
+
+ lastBoundTransition time.Time
+
+ iface *net.Interface
+
+ // now can be used to override time for testing
+ now func() time.Time
+
+ // LeaseCallback is called every time a lease is aquired, renewed or lost
+ LeaseCallback LeaseCallback
+
+ // Valid in states Discovering, Requesting, Rebinding
+ broadcastConn BroadcastTransport
+
+ // Valid in states Requesting
+ offer *dhcpv4.DHCPv4
+
+ // Valid in states Bound, Renewing
+ unicastConn UnicastTransport
+
+ // Valid in states Bound, Renewing, Rebinding
+ lease *dhcpv4.DHCPv4
+ leaseDeadline time.Time
+ leaseBoundDeadline time.Time
+ leaseRenewDeadline time.Time
+}
+
+// newDefaultBackoff returns an infinitely-retrying randomized exponential backoff with a
+// DHCP-appropriate InitialInterval
+func newDefaultBackoff() *backoff.ExponentialBackOff {
+ b := backoff.NewExponentialBackOff()
+ b.MaxElapsedTime = 0 // No Timeout
+ // Lots of servers wait 1s for existing users of an IP. Wait at least for that and keep some
+ // slack for randomization, communication and processing overhead.
+ b.InitialInterval = 1400 * time.Millisecond
+ b.MaxInterval = 30 * time.Second
+ b.RandomizationFactor = 0.2
+ return b
+}
+
+// NewClient instantiates (but doesn't start) a new DHCPv4 client.
+// To have a working client it's required to set LeaseCallback to something that is capable of configuring the IP
+// address on the given interface. Unless managed through external means like a routing protocol, setting the default
+// route is also required. A simple example with the callback package thus looks like this:
+// c := dhcp4c.NewClient(yourInterface)
+// c.LeaseCallback = callback.Compose(callback.ManageIP(yourInterface), callback.ManageDefaultRoute(yourInterface))
+// c.Run(ctx)
+func NewClient(iface *net.Interface) (*Client, error) {
+ broadcastConn := transport.NewBroadcastTransport(iface)
+
+ // broadcastConn needs to be open in stateDiscovering
+ if err := broadcastConn.Open(); err != nil {
+ return nil, fmt.Errorf("failed to create DHCP broadcast transport: %w", err)
+ }
+
+ discoverBackoff := newDefaultBackoff()
+
+ acceptOfferBackoff := newDefaultBackoff()
+ // Abort after 30s and go back to discovering
+ acceptOfferBackoff.MaxElapsedTime = 30 * time.Second
+
+ renewBackoff := newDefaultBackoff()
+ // Increase maximum interval to reduce chatter when the server is down
+ renewBackoff.MaxInterval = 5 * time.Minute
+
+ rebindBackoff := newDefaultBackoff()
+ // Increase maximum interval to reduce chatter when the server is down
+ renewBackoff.MaxInterval = 5 * time.Minute
+
+ return &Client{
+ state: stateDiscovering,
+ broadcastConn: broadcastConn,
+ unicastConn: transport.NewUnicastTransport(iface),
+ iface: iface,
+ RequestedOptions: dhcpv4.OptionCodeList{},
+ lastBoundTransition: time.Now(),
+ now: time.Now,
+ DiscoverBackoff: discoverBackoff,
+ AcceptOfferBackoff: acceptOfferBackoff,
+ RenewBackoff: renewBackoff,
+ RebindBackoff: rebindBackoff,
+ }, nil
+}
+
+// acceptableLease checks if the given lease is valid enough to even be processed. This is
+// intentionally not exposed to users because under certain cirumstances it can end up acquiring all
+// available IP addresses from a server.
+func (c *Client) acceptableLease(offer *dhcpv4.DHCPv4) bool {
+ // RFC2131 Section 4.3.1 Table 3
+ if offer.ServerIdentifier() == nil || offer.ServerIdentifier().To4() == nil {
+ return false
+ }
+ // RFC2131 Section 4.3.1 Table 3
+ // Minimum representable lease time is 1s (Section 1.1)
+ if offer.IPAddressLeaseTime(0) < 1*time.Second {
+ return false
+ }
+
+ // Ignore IPs that are in no way valid for an interface (multicast, loopback, ...)
+ if offer.YourIPAddr.To4() == nil || (!offer.YourIPAddr.IsGlobalUnicast() && !offer.YourIPAddr.IsLinkLocalUnicast()) {
+ return false
+ }
+
+ // Technically the options Requested IP address, Parameter request list, Client identifier
+ // and Maximum message size should be refused (MUST NOT), but in the interest of interopatibilty
+ // let's simply remove them if they are present.
+ delete(offer.Options, dhcpv4.OptionRequestedIPAddress.Code())
+ delete(offer.Options, dhcpv4.OptionParameterRequestList.Code())
+ delete(offer.Options, dhcpv4.OptionClientIdentifier.Code())
+ delete(offer.Options, dhcpv4.OptionMaximumDHCPMessageSize.Code())
+
+ // Clamp rebindinding times longer than the lease time. Otherwise the state machine might misbehave.
+ if offer.IPAddressRebindingTime(0) > offer.IPAddressLeaseTime(0) {
+ offer.UpdateOption(dhcpv4.OptGeneric(dhcpv4.OptionRebindingTimeValue, dhcpv4.Duration(offer.IPAddressLeaseTime(0)).ToBytes()))
+ }
+ // Clamp renewal times longer than the rebinding time. Otherwise the state machine might misbehave.
+ if offer.IPAddressRenewalTime(0) > offer.IPAddressRebindingTime(0) {
+ offer.UpdateOption(dhcpv4.OptGeneric(dhcpv4.OptionRenewTimeValue, dhcpv4.Duration(offer.IPAddressRebindingTime(0)).ToBytes()))
+ }
+
+ // Normalize two options that can be represented either inline or as options.
+ if len(offer.ServerHostName) > 0 {
+ offer.Options[uint8(dhcpv4.OptionTFTPServerName)] = []byte(offer.ServerHostName)
+ }
+ if len(offer.BootFileName) > 0 {
+ offer.Options[uint8(dhcpv4.OptionBootfileName)] = []byte(offer.BootFileName)
+ }
+
+ // Normalize siaddr to option 150 (see RFC5859)
+ if len(offer.GetOneOption(dhcpv4.OptionTFTPServerAddress)) == 0 {
+ if offer.ServerIPAddr.To4() != nil && (offer.ServerIPAddr.IsGlobalUnicast() || offer.ServerIPAddr.IsLinkLocalUnicast()) {
+ offer.Options[uint8(dhcpv4.OptionTFTPServerAddress)] = offer.ServerIPAddr.To4()
+ }
+ }
+
+ return true
+}
+
+func earliestDeadline(dl1, dl2 time.Time) time.Time {
+ if dl1.Before(dl2) {
+ return dl1
+ } else {
+ return dl2
+ }
+}
+
+// newXID generates a new transaction ID
+func (c *Client) newXID() (dhcpv4.TransactionID, error) {
+ var xid dhcpv4.TransactionID
+ if _, err := io.ReadFull(rand.Reader, xid[:]); err != nil {
+ return xid, fmt.Errorf("cannot read randomness for transaction ID: %w", err)
+ }
+ return xid, nil
+}
+
+// As most servers out there cannot do reassembly, let's just hope for the best and
+// provide the local interface MTU. If the packet is too big it won't work anyways.
+// Also clamp to the biggest representable MTU in DHCPv4 (2 bytes unsigned int).
+func (c *Client) maxMsgSize() uint16 {
+ if c.iface.MTU < math.MaxUint16 {
+ return uint16(c.iface.MTU)
+ } else {
+ return math.MaxUint16
+ }
+}
+
+// newMsg creates a new DHCP message of a given type and adds common options.
+func (c *Client) newMsg(t dhcpv4.MessageType) (*dhcpv4.DHCPv4, error) {
+ xid, err := c.newXID()
+ if err != nil {
+ return nil, err
+ }
+ opts := make(dhcpv4.Options)
+ opts.Update(dhcpv4.OptMessageType(t))
+ if len(c.ClientIdentifier) > 0 {
+ opts.Update(dhcpv4.OptClientIdentifier(c.ClientIdentifier))
+ }
+ if t == dhcpv4.MessageTypeDiscover || t == dhcpv4.MessageTypeRequest || t == dhcpv4.MessageTypeInform {
+ opts.Update(dhcpv4.OptParameterRequestList(append(c.RequestedOptions, internalOptions...)...))
+ opts.Update(dhcpv4.OptMaxMessageSize(c.maxMsgSize()))
+ if c.VendorClassIdentifier != "" {
+ opts.Update(dhcpv4.OptClassIdentifier(c.VendorClassIdentifier))
+ }
+ for opt, val := range c.ExtraRequestOptions {
+ opts[opt] = val
+ }
+ }
+ return &dhcpv4.DHCPv4{
+ OpCode: dhcpv4.OpcodeBootRequest,
+ HWType: iana.HWTypeEthernet,
+ ClientHWAddr: c.iface.HardwareAddr,
+ HopCount: 0,
+ TransactionID: xid,
+ NumSeconds: 0,
+ Flags: 0,
+ ClientIPAddr: net.IPv4zero,
+ YourIPAddr: net.IPv4zero,
+ ServerIPAddr: net.IPv4zero,
+ GatewayIPAddr: net.IPv4zero,
+ Options: opts,
+ }, nil
+}
+
+// transactionStateSpec describes a state which is driven by a DHCP message transaction (sending a
+// specific message and then transitioning into a different state depending on the received messages)
+type transactionStateSpec struct {
+ // ctx is a context for canceling the process
+ ctx context.Context
+
+ // transport is used to send and receive messages in this state
+ transport Transport
+
+ // stateDeadline is a fixed external deadline for how long the FSM can remain in this state.
+ // If it's exceeded the stateDeadlineExceeded callback is called and responsible for
+ // transitioning out of this state. It can be left empty to signal that there's no external
+ // deadline for the state.
+ stateDeadline time.Time
+
+ // backoff controls how long to wait for answers until handing control back to the FSM.
+ // Since the FSM hasn't advanced until then this means we just get called again and retransmit.
+ backoff backoff.BackOff
+
+ // requestType is the type of DHCP request sent out in this state. This is used to populate
+ // the default options for the message.
+ requestType dhcpv4.MessageType
+
+ // setExtraOptions can modify the request and set extra options before transmitting. Returning
+ // an error here aborts the FSM an can be used to terminate when no valid request can be
+ // constructed.
+ setExtraOptions func(msg *dhcpv4.DHCPv4) error
+
+ // handleMessage gets called for every parseable (not necessarily valid) DHCP message received
+ // by the transport. It should return an error for every message that doesn't advance the
+ // state machine and no error for every one that does. It is responsible for advancing the FSM
+ // if the required information is present.
+ handleMessage func(msg *dhcpv4.DHCPv4, sentTime time.Time) error
+
+ // stateDeadlineExceeded gets called if either the backoff returns backoff.Stop or the
+ // stateDeadline runs out. It is responsible for advancing the FSM into the next state.
+ stateDeadlineExceeded func() error
+}
+
+func (c *Client) runTransactionState(s transactionStateSpec) error {
+ sentTime := c.now()
+ msg, err := c.newMsg(s.requestType)
+ if err != nil {
+ return fmt.Errorf("failed to get new DHCP message: %w", err)
+ }
+ if err := s.setExtraOptions(msg); err != nil {
+ return fmt.Errorf("failed to create DHCP message: %w", err)
+ }
+
+ wait := s.backoff.NextBackOff()
+ if wait == backoff.Stop {
+ return s.stateDeadlineExceeded()
+ }
+
+ receiveDeadline := sentTime.Add(wait)
+ if !s.stateDeadline.IsZero() {
+ receiveDeadline = earliestDeadline(s.stateDeadline, receiveDeadline)
+ }
+
+ // Jump out if deadline expires in less than 10ms. Minimum lease time is 1s and if we have less
+ // than 10ms to wait for an answer before switching state it makes no sense to send out another
+ // request. This nearly eliminates the problem of sending two different requests back-to-back.
+ if receiveDeadline.Add(-10 * time.Millisecond).Before(sentTime) {
+ return s.stateDeadlineExceeded()
+ }
+
+ if err := s.transport.Send(msg); err != nil {
+ return fmt.Errorf("failed to send message: %w", err)
+ }
+
+ if err := s.transport.SetReceiveDeadline(receiveDeadline); err != nil {
+ return fmt.Errorf("failed to set deadline: %w", err)
+ }
+
+ for {
+ offer, err := s.transport.Receive()
+ select {
+ case <-s.ctx.Done():
+ c.cleanup()
+ return s.ctx.Err()
+ default:
+ }
+ if errors.Is(err, transport.DeadlineExceededErr) {
+ return nil
+ }
+ var e transport.InvalidMessageError
+ if errors.As(err, &e) {
+ // Packet couldn't be read. Maybe log at some point in the future.
+ continue
+ }
+ if err != nil {
+ return fmt.Errorf("failed to receive packet: %w", err)
+ }
+ if offer.TransactionID != msg.TransactionID { // Not our transaction
+ continue
+ }
+ err = s.handleMessage(offer, sentTime)
+ if err == nil {
+ return nil
+ } else if !errors.Is(err, InvalidMsgErr) {
+ return err
+ }
+ }
+}
+
+var InvalidMsgErr = errors.New("invalid message")
+
+func (c *Client) runState(ctx context.Context) error {
+ switch c.state {
+ case stateDiscovering:
+ return c.runTransactionState(transactionStateSpec{
+ ctx: ctx,
+ transport: c.broadcastConn,
+ backoff: c.DiscoverBackoff,
+ requestType: dhcpv4.MessageTypeDiscover,
+ setExtraOptions: func(msg *dhcpv4.DHCPv4) error {
+ msg.UpdateOption(dhcpv4.OptGeneric(dhcpv4.OptionRapidCommit, []byte{}))
+ return nil
+ },
+ handleMessage: func(offer *dhcpv4.DHCPv4, sentTime time.Time) error {
+ switch offer.MessageType() {
+ case dhcpv4.MessageTypeOffer:
+ if c.acceptableLease(offer) {
+ c.offer = offer
+ c.AcceptOfferBackoff.Reset()
+ c.state = stateRequesting
+ return nil
+ }
+ case dhcpv4.MessageTypeAck:
+ if c.acceptableLease(offer) {
+ return c.transitionToBound(offer, sentTime)
+ }
+ }
+ return InvalidMsgErr
+ },
+ })
+ case stateRequesting:
+ return c.runTransactionState(transactionStateSpec{
+ ctx: ctx,
+ transport: c.broadcastConn,
+ backoff: c.AcceptOfferBackoff,
+ requestType: dhcpv4.MessageTypeRequest,
+ setExtraOptions: func(msg *dhcpv4.DHCPv4) error {
+ msg.UpdateOption(dhcpv4.OptServerIdentifier(c.offer.ServerIdentifier()))
+ msg.TransactionID = c.offer.TransactionID
+ msg.UpdateOption(dhcpv4.OptRequestedIPAddress(c.offer.YourIPAddr))
+ return nil
+ },
+ handleMessage: func(msg *dhcpv4.DHCPv4, sentTime time.Time) error {
+ switch msg.MessageType() {
+ case dhcpv4.MessageTypeAck:
+ if c.acceptableLease(msg) {
+ return c.transitionToBound(msg, sentTime)
+ }
+ case dhcpv4.MessageTypeNak:
+ c.requestingToDiscovering()
+ return nil
+ }
+ return InvalidMsgErr
+ },
+ stateDeadlineExceeded: func() error {
+ c.requestingToDiscovering()
+ return nil
+ },
+ })
+ case stateBound:
+ select {
+ case <-time.After(c.leaseBoundDeadline.Sub(c.now())):
+ c.state = stateRenewing
+ c.RenewBackoff.Reset()
+ return nil
+ case <-ctx.Done():
+ c.cleanup()
+ return ctx.Err()
+ }
+ case stateRenewing:
+ return c.runTransactionState(transactionStateSpec{
+ ctx: ctx,
+ transport: c.unicastConn,
+ backoff: c.RenewBackoff,
+ requestType: dhcpv4.MessageTypeRequest,
+ stateDeadline: c.leaseRenewDeadline,
+ setExtraOptions: func(msg *dhcpv4.DHCPv4) error {
+ msg.ClientIPAddr = c.lease.YourIPAddr
+ return nil
+ },
+ handleMessage: func(ack *dhcpv4.DHCPv4, sentTime time.Time) error {
+ switch ack.MessageType() {
+ case dhcpv4.MessageTypeAck:
+ if c.acceptableLease(ack) {
+ return c.transitionToBound(ack, sentTime)
+ }
+ case dhcpv4.MessageTypeNak:
+ return c.leaseToDiscovering()
+ }
+ return InvalidMsgErr
+ },
+ stateDeadlineExceeded: func() error {
+ c.state = stateRebinding
+ if err := c.switchToBroadcast(); err != nil {
+ return fmt.Errorf("failed to switch to broadcast: %w", err)
+ }
+ c.RebindBackoff.Reset()
+ return nil
+ },
+ })
+ case stateRebinding:
+ return c.runTransactionState(transactionStateSpec{
+ ctx: ctx,
+ transport: c.broadcastConn,
+ backoff: c.RebindBackoff,
+ stateDeadline: c.leaseDeadline,
+ requestType: dhcpv4.MessageTypeRequest,
+ setExtraOptions: func(msg *dhcpv4.DHCPv4) error {
+ msg.ClientIPAddr = c.lease.YourIPAddr
+ return nil
+ },
+ handleMessage: func(ack *dhcpv4.DHCPv4, sentTime time.Time) error {
+ switch ack.MessageType() {
+ case dhcpv4.MessageTypeAck:
+ if c.acceptableLease(ack) {
+ return c.transitionToBound(ack, sentTime)
+ }
+ case dhcpv4.MessageTypeNak:
+ return c.leaseToDiscovering()
+ }
+ return InvalidMsgErr
+ },
+ stateDeadlineExceeded: func() error {
+ return c.leaseToDiscovering()
+ },
+ })
+ }
+ return errors.New("state machine in invalid state")
+}
+
+func (c *Client) Run(ctx context.Context) error {
+ if c.LeaseCallback == nil {
+ panic("LeaseCallback must be set before calling Run")
+ }
+ logger := supervisor.Logger(ctx)
+ for {
+ oldState := c.state
+ if err := c.runState(ctx); err != nil {
+ return err
+ }
+ if c.state != oldState {
+ logger.Infof("%s => %s", oldState, c.state)
+ }
+ }
+}
+
+func (c *Client) cleanup() {
+ c.unicastConn.Close()
+ if c.lease != nil {
+ c.LeaseCallback(leaseFromAck(c.lease, c.leaseDeadline), nil)
+ }
+ c.broadcastConn.Close()
+}
+
+func (c *Client) requestingToDiscovering() {
+ c.offer = nil
+ c.DiscoverBackoff.Reset()
+ c.state = stateDiscovering
+}
+
+func (c *Client) leaseToDiscovering() error {
+ if c.state == stateRenewing {
+ if err := c.switchToBroadcast(); err != nil {
+ return err
+ }
+ }
+ c.state = stateDiscovering
+ c.DiscoverBackoff.Reset()
+ if err := c.LeaseCallback(leaseFromAck(c.lease, c.leaseDeadline), nil); err != nil {
+ return fmt.Errorf("lease callback failed: %w", err)
+ }
+ c.lease = nil
+ return nil
+}
+
+func leaseFromAck(ack *dhcpv4.DHCPv4, expiresAt time.Time) *Lease {
+ if ack == nil {
+ return nil
+ }
+ return &Lease{Options: ack.Options, AssignedIP: ack.YourIPAddr, ExpiresAt: expiresAt}
+}
+
+func (c *Client) transitionToBound(ack *dhcpv4.DHCPv4, sentTime time.Time) error {
+ // Guaranteed to exist, leases without a lease time are filtered
+ leaseTime := ack.IPAddressLeaseTime(0)
+ origLeaseDeadline := c.leaseDeadline
+ c.leaseDeadline = sentTime.Add(leaseTime)
+ c.leaseBoundDeadline = sentTime.Add(ack.IPAddressRenewalTime(time.Duration(float64(leaseTime) * 0.5)))
+ c.leaseRenewDeadline = sentTime.Add(ack.IPAddressRebindingTime(time.Duration(float64(leaseTime) * 0.85)))
+
+ if err := c.LeaseCallback(leaseFromAck(c.lease, origLeaseDeadline), leaseFromAck(ack, c.leaseDeadline)); err != nil {
+ return fmt.Errorf("lease callback failed: %w", err)
+ }
+
+ if c.state != stateRenewing {
+ if err := c.switchToUnicast(ack.ServerIdentifier(), ack.YourIPAddr); err != nil {
+ return fmt.Errorf("failed to switch transports: %w", err)
+ }
+ }
+ c.state = stateBound
+ c.lease = ack
+ return nil
+}
+
+func (c *Client) switchToUnicast(serverIP, bindIP net.IP) error {
+ if err := c.broadcastConn.Close(); err != nil {
+ return fmt.Errorf("failed to close broadcast transport: %w", err)
+ }
+ if err := c.unicastConn.Open(serverIP, bindIP); err != nil {
+ return fmt.Errorf("failed to open unicast transport: %w", err)
+ }
+ return nil
+}
+
+func (c *Client) switchToBroadcast() error {
+ if err := c.unicastConn.Close(); err != nil {
+ return fmt.Errorf("failed to close unicast transport: %w", err)
+ }
+ if err := c.broadcastConn.Open(); err != nil {
+ return fmt.Errorf("failed to open broadcast transport: %w", err)
+ }
+ return nil
+}
diff --git a/core/pkg/dhcp4c/dhcpc_test.go b/core/pkg/dhcp4c/dhcpc_test.go
new file mode 100644
index 0000000..4c5fb11
--- /dev/null
+++ b/core/pkg/dhcp4c/dhcpc_test.go
@@ -0,0 +1,514 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dhcp4c
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "testing"
+ "time"
+
+ "github.com/cenkalti/backoff/v4"
+ "github.com/insomniacslk/dhcp/dhcpv4"
+ "github.com/stretchr/testify/assert"
+
+ "git.monogon.dev/source/nexantic.git/core/pkg/dhcp4c/transport"
+)
+
+type fakeTime struct {
+ time time.Time
+}
+
+func newFakeTime(t time.Time) *fakeTime {
+ return &fakeTime{
+ time: t,
+ }
+}
+
+func (ft *fakeTime) Now() time.Time {
+ return ft.time
+}
+
+func (ft *fakeTime) Advance(d time.Duration) {
+ ft.time = ft.time.Add(d)
+}
+
+type mockTransport struct {
+ sentPacket *dhcpv4.DHCPv4
+ sendError error
+ setDeadline time.Time
+ receivePackets []*dhcpv4.DHCPv4
+ receiveError error
+ receiveIdx int
+ closed bool
+}
+
+func (mt *mockTransport) sendPackets(pkts ...*dhcpv4.DHCPv4) {
+ mt.receiveIdx = 0
+ mt.receivePackets = pkts
+}
+
+func (mt *mockTransport) Open() error {
+ mt.closed = false
+ return nil
+}
+
+func (mt *mockTransport) Send(payload *dhcpv4.DHCPv4) error {
+ mt.sentPacket = payload
+ return mt.sendError
+}
+
+func (mt *mockTransport) Receive() (*dhcpv4.DHCPv4, error) {
+ if mt.receiveError != nil {
+ return nil, mt.receiveError
+ }
+ if len(mt.receivePackets) > mt.receiveIdx {
+ packet := mt.receivePackets[mt.receiveIdx]
+ packet, err := dhcpv4.FromBytes(packet.ToBytes()) // Clone packet
+ if err != nil {
+ panic("ToBytes => FromBytes failed")
+ }
+ packet.TransactionID = mt.sentPacket.TransactionID
+ mt.receiveIdx++
+ return packet, nil
+ }
+ return nil, transport.DeadlineExceededErr
+}
+
+func (mt *mockTransport) SetReceiveDeadline(t time.Time) error {
+ mt.setDeadline = t
+ return nil
+}
+
+func (mt *mockTransport) Close() error {
+ mt.closed = true
+ return nil
+}
+
+type unicastMockTransport struct {
+ mockTransport
+ serverIP net.IP
+ bindIP net.IP
+}
+
+func (umt *unicastMockTransport) Open(serverIP, bindIP net.IP) error {
+ if umt.serverIP != nil {
+ panic("double-open of unicast transport")
+ }
+ umt.serverIP = serverIP
+ umt.bindIP = bindIP
+ return nil
+}
+
+func (umt *unicastMockTransport) Close() error {
+ umt.serverIP = nil
+ umt.bindIP = nil
+ return umt.mockTransport.Close()
+}
+
+type mockBackoff struct {
+ indefinite bool
+ values []time.Duration
+ idx int
+}
+
+func newMockBackoff(vals []time.Duration, indefinite bool) *mockBackoff {
+ return &mockBackoff{values: vals, indefinite: indefinite}
+}
+
+func (mb *mockBackoff) NextBackOff() time.Duration {
+ if mb.idx < len(mb.values) || mb.indefinite {
+ val := mb.values[mb.idx%len(mb.values)]
+ mb.idx++
+ return val
+ }
+ return backoff.Stop
+}
+
+func (mb *mockBackoff) Reset() {
+ mb.idx = 0
+}
+
+func TestClient_runTransactionState(t *testing.T) {
+ ft := newFakeTime(time.Date(2020, 10, 28, 15, 02, 32, 352, time.UTC))
+ c := Client{
+ now: ft.Now,
+ iface: &net.Interface{MTU: 9324, HardwareAddr: net.HardwareAddr{0x12, 0x23, 0x34, 0x45, 0x56, 0x67}},
+ }
+ mt := &mockTransport{}
+ err := c.runTransactionState(transactionStateSpec{
+ ctx: context.Background(),
+ transport: mt,
+ backoff: newMockBackoff([]time.Duration{1 * time.Second}, true),
+ requestType: dhcpv4.MessageTypeDiscover,
+ setExtraOptions: func(msg *dhcpv4.DHCPv4) error {
+ msg.UpdateOption(dhcpv4.OptDomainName("just.testing.invalid"))
+ return nil
+ },
+ handleMessage: func(msg *dhcpv4.DHCPv4, sentTime time.Time) error {
+ return nil
+ },
+ stateDeadlineExceeded: func() error {
+ panic("shouldn't be called")
+ },
+ })
+ assert.NoError(t, err)
+ assert.Equal(t, "just.testing.invalid", mt.sentPacket.DomainName())
+ assert.Equal(t, dhcpv4.MessageTypeDiscover, mt.sentPacket.MessageType())
+}
+
+// TestAcceptableLease tests if a minimal valid lease is accepted by acceptableLease
+func TestAcceptableLease(t *testing.T) {
+ c := Client{}
+ offer := &dhcpv4.DHCPv4{
+ OpCode: dhcpv4.OpcodeBootReply,
+ }
+ offer.UpdateOption(dhcpv4.OptMessageType(dhcpv4.MessageTypeOffer))
+ offer.UpdateOption(dhcpv4.OptServerIdentifier(net.IP{192, 0, 2, 1}))
+ offer.UpdateOption(dhcpv4.OptIPAddressLeaseTime(10 * time.Second))
+ offer.YourIPAddr = net.IP{192, 0, 2, 2}
+ assert.True(t, c.acceptableLease(offer), "Valid lease is not acceptable")
+}
+
+type dhcpClientPuppet struct {
+ ft *fakeTime
+ bmt *mockTransport
+ umt *unicastMockTransport
+ c *Client
+}
+
+func newPuppetClient(initState state) *dhcpClientPuppet {
+ ft := newFakeTime(time.Date(2020, 10, 28, 15, 02, 32, 352, time.UTC))
+ bmt := &mockTransport{}
+ umt := &unicastMockTransport{}
+ c := &Client{
+ state: initState,
+ now: ft.Now,
+ iface: &net.Interface{MTU: 9324, HardwareAddr: net.HardwareAddr{0x12, 0x23, 0x34, 0x45, 0x56, 0x67}},
+ broadcastConn: bmt,
+ unicastConn: umt,
+ DiscoverBackoff: newMockBackoff([]time.Duration{1 * time.Second}, true),
+ AcceptOfferBackoff: newMockBackoff([]time.Duration{1 * time.Second, 2 * time.Second}, false),
+ RenewBackoff: newMockBackoff([]time.Duration{1 * time.Second}, true),
+ RebindBackoff: newMockBackoff([]time.Duration{1 * time.Second}, true),
+ }
+ return &dhcpClientPuppet{
+ ft: ft,
+ bmt: bmt,
+ umt: umt,
+ c: c,
+ }
+}
+
+func newResponse(m dhcpv4.MessageType) *dhcpv4.DHCPv4 {
+ o := &dhcpv4.DHCPv4{
+ OpCode: dhcpv4.OpcodeBootReply,
+ }
+ o.UpdateOption(dhcpv4.OptMessageType(m))
+ return o
+}
+
+// TestDiscoverOffer tests if the DHCP state machine in discovering state properly selects the first valid lease
+// and transitions to requesting state
+func TestDiscoverRequesting(t *testing.T) {
+ p := newPuppetClient(stateDiscovering)
+
+ // A minimal valid lease
+ offer := newResponse(dhcpv4.MessageTypeOffer)
+ testIP := net.IP{192, 0, 2, 2}
+ offer.UpdateOption(dhcpv4.OptServerIdentifier(net.IP{192, 0, 2, 1}))
+ offer.UpdateOption(dhcpv4.OptIPAddressLeaseTime(10 * time.Second))
+ offer.YourIPAddr = testIP
+
+ // Intentionally bad offer with no lease time.
+ terribleOffer := newResponse(dhcpv4.MessageTypeOffer)
+ terribleOffer.UpdateOption(dhcpv4.OptServerIdentifier(net.IP{192, 0, 2, 2}))
+ terribleOffer.YourIPAddr = net.IPv4(192, 0, 2, 2)
+
+ // Send the bad offer first, then the valid offer
+ p.bmt.sendPackets(terribleOffer, offer)
+
+ if err := p.c.runState(context.Background()); err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, stateRequesting, p.c.state, "DHCP client didn't process offer")
+ assert.Equal(t, testIP, p.c.offer.YourIPAddr, "DHCP client requested invalid offer")
+}
+
+// TestOfferBound tests if the DHCP state machine in requesting state processes a valid DHCPACK and transitions to
+// bound state.
+func TestRequestingBound(t *testing.T) {
+ p := newPuppetClient(stateRequesting)
+
+ offer := newResponse(dhcpv4.MessageTypeAck)
+ testIP := net.IP{192, 0, 2, 2}
+ offer.UpdateOption(dhcpv4.OptServerIdentifier(net.IP{192, 0, 2, 1}))
+ offer.UpdateOption(dhcpv4.OptIPAddressLeaseTime(10 * time.Second))
+ offer.YourIPAddr = testIP
+
+ p.bmt.sendPackets(offer)
+ p.c.offer = offer
+ p.c.LeaseCallback = func(old, new *Lease) error {
+ assert.Nil(t, old, "old lease is not nil for new lease")
+ assert.Equal(t, testIP, new.AssignedIP, "new lease has wrong IP")
+ return nil
+ }
+
+ if err := p.c.runState(context.Background()); err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, stateBound, p.c.state, "DHCP client didn't process offer")
+ assert.Equal(t, testIP, p.c.lease.YourIPAddr, "DHCP client requested invalid offer")
+}
+
+// TestRequestingDiscover tests if the DHCP state machine in requesting state transitions back to discovering if it
+// takes too long to get a valid DHCPACK.
+func TestRequestingDiscover(t *testing.T) {
+ p := newPuppetClient(stateRequesting)
+
+ offer := newResponse(dhcpv4.MessageTypeOffer)
+ testIP := net.IP{192, 0, 2, 2}
+ offer.UpdateOption(dhcpv4.OptServerIdentifier(net.IP{192, 0, 2, 1}))
+ offer.UpdateOption(dhcpv4.OptIPAddressLeaseTime(10 * time.Second))
+ offer.YourIPAddr = testIP
+ p.c.offer = offer
+
+ for i := 0; i < 10; i++ {
+ p.bmt.sendPackets()
+ if err := p.c.runState(context.Background()); err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, dhcpv4.MessageTypeRequest, p.bmt.sentPacket.MessageType(), "Invalid message type for requesting")
+ if p.c.state == stateDiscovering {
+ break
+ }
+ p.ft.time = p.bmt.setDeadline
+
+ if i == 9 {
+ t.Fatal("Too many tries while requesting, backoff likely wrong")
+ }
+ }
+ assert.Equal(t, stateDiscovering, p.c.state, "DHCP client didn't switch back to offer after requesting expired")
+}
+
+// TestDiscoverRapidCommit tests if the DHCP state machine in discovering state transitions directly to bound if a
+// rapid commit response (DHCPACK) is received.
+func TestDiscoverRapidCommit(t *testing.T) {
+ testIP := net.IP{192, 0, 2, 2}
+ offer := newResponse(dhcpv4.MessageTypeAck)
+ offer.UpdateOption(dhcpv4.OptServerIdentifier(net.IP{192, 0, 2, 1}))
+ leaseTime := 10 * time.Second
+ offer.UpdateOption(dhcpv4.OptIPAddressLeaseTime(leaseTime))
+ offer.YourIPAddr = testIP
+
+ p := newPuppetClient(stateDiscovering)
+ p.c.LeaseCallback = func(old, new *Lease) error {
+ assert.Nil(t, old, "old is not nil")
+ assert.Equal(t, testIP, new.AssignedIP, "callback called with wrong IP")
+ assert.Equal(t, p.ft.Now().Add(leaseTime), new.ExpiresAt, "invalid ExpiresAt")
+ return nil
+ }
+ p.bmt.sendPackets(offer)
+ if err := p.c.runState(context.Background()); err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, stateBound, p.c.state, "DHCP client didn't process offer")
+ assert.Equal(t, testIP, p.c.lease.YourIPAddr, "DHCP client requested invalid offer")
+ assert.Equal(t, 5*time.Second, p.c.leaseBoundDeadline.Sub(p.ft.Now()), "Renewal time was incorrectly defaulted")
+}
+
+type TestOption uint8
+
+func (o TestOption) Code() uint8 {
+ return uint8(o) + 224 // Private options
+}
+func (o TestOption) String() string {
+ return fmt.Sprintf("Test Option %d", uint8(o))
+}
+
+// TestBoundRenewingBound tests if the DHCP state machine in bound correctly transitions to renewing after
+// leaseBoundDeadline expires, sends a DHCPREQUEST and after it gets a DHCPACK response calls LeaseCallback and
+// transitions back to bound with correct new deadlines.
+func TestBoundRenewingBound(t *testing.T) {
+ offer := newResponse(dhcpv4.MessageTypeAck)
+ testIP := net.IP{192, 0, 2, 2}
+ serverIP := net.IP{192, 0, 2, 1}
+ offer.UpdateOption(dhcpv4.OptServerIdentifier(serverIP))
+ leaseTime := 10 * time.Second
+ offer.UpdateOption(dhcpv4.OptIPAddressLeaseTime(leaseTime))
+ offer.YourIPAddr = testIP
+
+ p := newPuppetClient(stateBound)
+ p.umt.Open(serverIP, testIP)
+ p.c.lease, _ = dhcpv4.FromBytes(offer.ToBytes())
+ // Other deadlines are intentionally empty to make sure they aren't used
+ p.c.leaseRenewDeadline = p.ft.Now().Add(8500 * time.Millisecond)
+ p.c.leaseBoundDeadline = p.ft.Now().Add(5000 * time.Millisecond)
+
+ p.ft.Advance(5*time.Second - 5*time.Millisecond)
+ if err := p.c.runState(context.Background()); err != nil {
+ t.Error(err)
+ }
+ p.ft.Advance(5 * time.Millisecond) // We cannot intercept time.After so we just advance the clock by the time slept
+ assert.Equal(t, stateRenewing, p.c.state, "DHCP client not renewing")
+ offer.UpdateOption(dhcpv4.OptGeneric(TestOption(1), []byte{0x12}))
+ p.umt.sendPackets(offer)
+ p.c.LeaseCallback = func(old, new *Lease) error {
+ assert.Equal(t, testIP, old.AssignedIP, "callback called with wrong old IP")
+ assert.Equal(t, testIP, new.AssignedIP, "callback called with wrong IP")
+ assert.Equal(t, p.ft.Now().Add(leaseTime), new.ExpiresAt, "invalid ExpiresAt")
+ assert.Empty(t, old.Options.Get(TestOption(1)), "old contains options from new")
+ assert.Equal(t, []byte{0x12}, new.Options.Get(TestOption(1)), "renewal didn't add new option")
+ return nil
+ }
+ if err := p.c.runState(context.Background()); err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, stateBound, p.c.state, "DHCP client didn't renew")
+ assert.Equal(t, p.ft.Now().Add(leaseTime), p.c.leaseDeadline, "lease deadline not updated")
+ assert.Equal(t, dhcpv4.MessageTypeRequest, p.umt.sentPacket.MessageType(), "Invalid message type for renewal")
+}
+
+// TestRenewingRebinding tests if the DHCP state machine in renewing state correctly sends DHCPREQUESTs and transitions
+// to the rebinding state when it hasn't received a valid response until the deadline expires.
+func TestRenewingRebinding(t *testing.T) {
+ offer := newResponse(dhcpv4.MessageTypeAck)
+ testIP := net.IP{192, 0, 2, 2}
+ serverIP := net.IP{192, 0, 2, 1}
+ offer.UpdateOption(dhcpv4.OptServerIdentifier(serverIP))
+ leaseTime := 10 * time.Second
+ offer.UpdateOption(dhcpv4.OptIPAddressLeaseTime(leaseTime))
+ offer.YourIPAddr = testIP
+
+ p := newPuppetClient(stateRenewing)
+ p.umt.Open(serverIP, testIP)
+ p.c.lease, _ = dhcpv4.FromBytes(offer.ToBytes())
+ // Other deadlines are intentionally empty to make sure they aren't used
+ p.c.leaseRenewDeadline = p.ft.Now().Add(8500 * time.Millisecond)
+ p.c.leaseDeadline = p.ft.Now().Add(10000 * time.Millisecond)
+
+ startTime := p.ft.Now()
+ p.ft.Advance(5 * time.Second)
+
+ p.c.LeaseCallback = func(old, new *Lease) error {
+ t.Fatal("Lease callback called without valid offer")
+ return nil
+ }
+
+ for i := 0; i < 10; i++ {
+ p.umt.sendPackets()
+ if err := p.c.runState(context.Background()); err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, dhcpv4.MessageTypeRequest, p.umt.sentPacket.MessageType(), "Invalid message type for renewal")
+ p.ft.time = p.umt.setDeadline
+
+ if p.c.state == stateRebinding {
+ break
+ }
+ if i == 9 {
+ t.Fatal("Too many tries while renewing, backoff likely wrong")
+ }
+ }
+ assert.Equal(t, startTime.Add(8500*time.Millisecond), p.umt.setDeadline, "wrong listen deadline when renewing")
+ assert.Equal(t, stateRebinding, p.c.state, "DHCP client not renewing")
+ assert.False(t, p.bmt.closed)
+ assert.True(t, p.umt.closed)
+}
+
+// TestRebindingBound tests if the DHCP state machine in rebinding state sends DHCPREQUESTs to the network and if
+// it receives a valid DHCPACK correctly transitions back to bound state.
+func TestRebindingBound(t *testing.T) {
+ offer := newResponse(dhcpv4.MessageTypeAck)
+ testIP := net.IP{192, 0, 2, 2}
+ serverIP := net.IP{192, 0, 2, 1}
+ offer.UpdateOption(dhcpv4.OptServerIdentifier(serverIP))
+ leaseTime := 10 * time.Second
+ offer.UpdateOption(dhcpv4.OptIPAddressLeaseTime(leaseTime))
+ offer.YourIPAddr = testIP
+
+ p := newPuppetClient(stateRebinding)
+ p.c.lease, _ = dhcpv4.FromBytes(offer.ToBytes())
+ // Other deadlines are intentionally empty to make sure they aren't used
+ p.c.leaseDeadline = p.ft.Now().Add(10000 * time.Millisecond)
+
+ p.ft.Advance(9 * time.Second)
+ if err := p.c.runState(context.Background()); err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, dhcpv4.MessageTypeRequest, p.bmt.sentPacket.MessageType(), "DHCP rebind sent invalid message type")
+ assert.Equal(t, stateRebinding, p.c.state, "DHCP client transferred out of rebinding state without trigger")
+ offer.UpdateOption(dhcpv4.OptGeneric(TestOption(1), []byte{0x12})) // Mark answer
+ p.bmt.sendPackets(offer)
+ p.bmt.sentPacket = nil
+ p.c.LeaseCallback = func(old, new *Lease) error {
+ assert.Equal(t, testIP, old.AssignedIP, "callback called with wrong old IP")
+ assert.Equal(t, testIP, new.AssignedIP, "callback called with wrong IP")
+ assert.Equal(t, p.ft.Now().Add(leaseTime), new.ExpiresAt, "invalid ExpiresAt")
+ assert.Empty(t, old.Options.Get(TestOption(1)), "old contains options from new")
+ assert.Equal(t, []byte{0x12}, new.Options.Get(TestOption(1)), "renewal didn't add new option")
+ return nil
+ }
+ if err := p.c.runState(context.Background()); err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, dhcpv4.MessageTypeRequest, p.bmt.sentPacket.MessageType())
+ assert.Equal(t, stateBound, p.c.state, "DHCP client didn't go back to bound")
+}
+
+// TestRebindingBound tests if the DHCP state machine in rebinding state transitions to discovering state if
+// leaseDeadline expires and calls LeaseCallback with an empty new lease.
+func TestRebindingDiscovering(t *testing.T) {
+ offer := newResponse(dhcpv4.MessageTypeAck)
+ testIP := net.IP{192, 0, 2, 2}
+ serverIP := net.IP{192, 0, 2, 1}
+ offer.UpdateOption(dhcpv4.OptServerIdentifier(serverIP))
+ leaseTime := 10 * time.Second
+ offer.UpdateOption(dhcpv4.OptIPAddressLeaseTime(leaseTime))
+ offer.YourIPAddr = testIP
+
+ p := newPuppetClient(stateRebinding)
+ p.c.lease, _ = dhcpv4.FromBytes(offer.ToBytes())
+ // Other deadlines are intentionally empty to make sure they aren't used
+ p.c.leaseDeadline = p.ft.Now().Add(10000 * time.Millisecond)
+
+ p.ft.Advance(9 * time.Second)
+ p.c.LeaseCallback = func(old, new *Lease) error {
+ assert.Equal(t, testIP, old.AssignedIP, "callback called with wrong old IP")
+ assert.Nil(t, new, "transition to discovering didn't clear new lease on callback")
+ return nil
+ }
+ for i := 0; i < 10; i++ {
+ p.bmt.sendPackets()
+ p.bmt.sentPacket = nil
+ if err := p.c.runState(context.Background()); err != nil {
+ t.Error(err)
+ }
+ if p.c.state == stateDiscovering {
+ assert.Nil(t, p.bmt.sentPacket)
+ break
+ }
+ assert.Equal(t, dhcpv4.MessageTypeRequest, p.bmt.sentPacket.MessageType(), "Invalid message type for rebind")
+ p.ft.time = p.bmt.setDeadline
+ if i == 9 {
+ t.Fatal("Too many tries while rebinding, backoff likely wrong")
+ }
+ }
+ assert.Nil(t, p.c.lease, "Lease not zeroed on transition to discovering")
+ assert.Equal(t, stateDiscovering, p.c.state, "DHCP client didn't transition to discovering after loosing lease")
+}
diff --git a/core/pkg/dhcp4c/doc.go b/core/pkg/dhcp4c/doc.go
new file mode 100644
index 0000000..b270c7b
--- /dev/null
+++ b/core/pkg/dhcp4c/doc.go
@@ -0,0 +1,53 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package dhcp4c provides a client implementation of the DHCPv4 protocol (RFC2131) and a few extensions for Linux-based
+// systems.
+// The code is split into three main parts:
+// - The core DHCP state machine, which lives in dhcpc.go
+// - Mechanisms to send and receive DHCP messages, which live in transport/
+// - Standard callbacks which implement necessary kernel configuration steps in a simple and standalone way living in
+// callback/
+//
+// Since the DHCP protocol is ugly and underspecified (see https://tools.ietf.org/html/draft-ietf-dhc-implementation-02
+// for a subset of known issues), this client slightly bends the specification in the following cases:
+// - IP fragmentation for DHCP messages is not supported for both sending and receiving messages
+// This is because the major servers (ISC, dnsmasq, ...) do not implement it and just drop fragmented packets, so it
+// would be counterproductive to try to send them. The client just attempts to send the full message and hopes it
+// passes through to the server.
+// - The suggested timeouts and wait periods have been tightened significantly. When the standard was written 10Mbps
+// Ethernet with hubs was a common interconnect. Using these would make the client extremely slow on today's
+// 1Gbps+ networks.
+// - Wrong data in DHCP responses is fixed up if possible. This fixing includes dropping prohibited options, clamping
+// semantically invalid data and defaulting not set options as far as it's possible. Non-recoverable responses
+// (for example because a non-Unicast IP is handed out or lease time is not set or zero) are still ignored.
+// All data which can be stored in both DHCP fields and options is also normalized to the corresponding option.
+// - Duplicate Address Detection is not implemented by default. It's slow, hard to implement correctly and generally
+// not necessary on modern networks as the servers already waste time checking for duplicate addresses. It's possible
+// to hook it in via a LeaseCallback if necessary in a given application.
+//
+// Operationally, there's one known caveat to using this client: If the lease offered during the select phase (in a
+// DHCPOFFER) is not the same as the one sent in the following DHCPACK the first one might be acceptable, but the second
+// one might not be. This can cause pathological behavior where the client constantly switches between discovering and
+// requesting states. Depending on the reuse policies on the DHCP server this can cause the client to consume all
+// available IP addresses. Sadly there's no good way of fixing this within the boundaries of the protocol. A DHCPRELEASE
+// for the adresse would need to be unicasted so the unaccepable address would need to be configured which can be either
+// impossible if it's not valid or not acceptable from a security standpoint (for example because it overlaps with a
+// prefix used internally) and a DHCPDECLINE would cause the server to blacklist the IP thus also depleting the IP pool.
+// This could be potentially avoided by originating DHCPRELEASE packages from a userspace transport, but said transport
+// would need to be routing- and PMTU-aware which would make it even more complicated than the existing
+// BroadcastTransport.
+package dhcp4c
diff --git a/core/pkg/dhcp4c/lease.go b/core/pkg/dhcp4c/lease.go
new file mode 100644
index 0000000..ab6da40
--- /dev/null
+++ b/core/pkg/dhcp4c/lease.go
@@ -0,0 +1,74 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dhcp4c
+
+import (
+ "net"
+ "time"
+
+ "github.com/insomniacslk/dhcp/dhcpv4"
+)
+
+// Lease represents a DHCPv4 lease. It only consists of an IP, an expiration timestamp and options as all other
+// relevant parts of the message have been normalized into their respective options. It also contains some smart
+// getters for commonly-used options which extract only valid information from options.
+type Lease struct {
+ AssignedIP net.IP
+ ExpiresAt time.Time
+ Options dhcpv4.Options
+}
+
+// SubnetMask returns the SubnetMask option or the default mask if not set or invalid.
+// It returns nil if the lease is nil.
+func (l *Lease) SubnetMask() net.IPMask {
+ if l == nil {
+ return nil
+ }
+ mask := net.IPMask(dhcpv4.GetIP(dhcpv4.OptionSubnetMask, l.Options))
+ if _, bits := mask.Size(); bits != 32 { // If given mask is not valid, use the default mask
+ mask = l.AssignedIP.DefaultMask()
+ }
+ return mask
+}
+
+// IPNet returns an IPNet from the assigned IP and subnet mask.
+// It returns nil if the lease is nil.
+func (l *Lease) IPNet() *net.IPNet {
+ if l == nil {
+ return nil
+ }
+ return &net.IPNet{
+ IP: l.AssignedIP,
+ Mask: l.SubnetMask(),
+ }
+}
+
+// Router returns the first valid router from the DHCP router option or nil if none such exists.
+// It returns nil if the lease is nil.
+func (l *Lease) Router() net.IP {
+ if l == nil {
+ return nil
+ }
+ routers := dhcpv4.GetIPs(dhcpv4.OptionRouter, l.Options)
+ for _, r := range routers {
+ if r.IsGlobalUnicast() || r.IsLinkLocalUnicast() {
+ return r
+ }
+ }
+ // No (valid) router found
+ return nil
+}
diff --git a/core/pkg/dhcp4c/transport/BUILD.bazel b/core/pkg/dhcp4c/transport/BUILD.bazel
new file mode 100644
index 0000000..7ee17f6
--- /dev/null
+++ b/core/pkg/dhcp4c/transport/BUILD.bazel
@@ -0,0 +1,20 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+ name = "go_default_library",
+ srcs = [
+ "transport.go",
+ "transport_broadcast.go",
+ "transport_unicast.go",
+ ],
+ importpath = "git.monogon.dev/source/nexantic.git/core/pkg/dhcp4c/transport",
+ visibility = ["//visibility:public"],
+ deps = [
+ "@com_github_google_gopacket//:go_default_library",
+ "@com_github_google_gopacket//layers:go_default_library",
+ "@com_github_insomniacslk_dhcp//dhcpv4:go_default_library",
+ "@com_github_mdlayher_raw//:go_default_library",
+ "@org_golang_x_net//bpf:go_default_library",
+ "@org_golang_x_sys//unix:go_default_library",
+ ],
+)
diff --git a/core/pkg/dhcp4c/transport/transport.go b/core/pkg/dhcp4c/transport/transport.go
new file mode 100644
index 0000000..8f5f791
--- /dev/null
+++ b/core/pkg/dhcp4c/transport/transport.go
@@ -0,0 +1,50 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package transport contains Linux-based transports for the DHCP broadcast and unicast
+// specifications.
+package transport
+
+import (
+ "errors"
+ "fmt"
+ "net"
+)
+
+var DeadlineExceededErr = errors.New("deadline exceeded")
+
+func NewInvalidMessageError(internalErr error) error {
+ return &InvalidMessageError{internalErr: internalErr}
+}
+
+type InvalidMessageError struct {
+ internalErr error
+}
+
+func (i InvalidMessageError) Error() string {
+ return fmt.Sprintf("received invalid packet: %v", i.internalErr.Error())
+}
+
+func (i InvalidMessageError) Unwrap() error {
+ return i.internalErr
+}
+
+func deadlineFromTimeout(err error) error {
+ if timeoutErr, ok := err.(net.Error); ok && timeoutErr.Timeout() {
+ return DeadlineExceededErr
+ }
+ return err
+}
diff --git a/core/pkg/dhcp4c/transport/transport_broadcast.go b/core/pkg/dhcp4c/transport/transport_broadcast.go
new file mode 100644
index 0000000..79fad7d
--- /dev/null
+++ b/core/pkg/dhcp4c/transport/transport_broadcast.go
@@ -0,0 +1,207 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package transport
+
+import (
+ "errors"
+ "fmt"
+ "math"
+ "net"
+ "time"
+
+ "github.com/google/gopacket"
+ "github.com/google/gopacket/layers"
+ "github.com/insomniacslk/dhcp/dhcpv4"
+ "github.com/mdlayher/raw"
+ "golang.org/x/net/bpf"
+)
+
+const (
+ // RFC2474 Section 4.2.2.1 with reference to RFC791 Section 3.1 (Network Control Precedence)
+ dscpCS7 = 0x7 << 3
+
+ // IPv4 MTU
+ maxIPv4MTU = math.MaxUint16 // IPv4 "Total Length" field is an unsigned 16 bit integer
+)
+
+// mustAssemble calls bpf.Assemble and panics if it retuns an error.
+func mustAssemble(insns []bpf.Instruction) []bpf.RawInstruction {
+ rawInsns, err := bpf.Assemble(insns)
+ if err != nil {
+ panic("mustAssemble failed to assemble BPF: " + err.Error())
+ }
+ return rawInsns
+}
+
+// BPF filter for UDP in IPv4 with destination port 68 (DHCP Client)
+//
+// This is used to make the kernel drop non-DHCP traffic for us so that we don't have to handle
+// excessive unrelated traffic flowing on a given link which might overwhelm the single-threaded
+// receiver.
+var bpfFilterInstructions = []bpf.Instruction{
+ // Check IP protocol version equals 4 (first 4 bits of the first byte)
+ // With Ethernet II framing, this is more of a sanity check. We already request the kernel
+ // to only return EtherType 0x0800 (IPv4) frames.
+ bpf.LoadAbsolute{Off: 0, Size: 1},
+ bpf.ALUOpConstant{Op: bpf.ALUOpAnd, Val: 0xf0}, // SubnetMask second 4 bits
+ bpf.JumpIf{Cond: bpf.JumpEqual, Val: 4 << 4, SkipTrue: 1},
+ bpf.RetConstant{Val: 0}, // Discard
+
+ // Check IPv4 Protocol byte (offset 9) equals UDP
+ bpf.LoadAbsolute{Off: 9, Size: 1},
+ bpf.JumpIf{Cond: bpf.JumpEqual, Val: uint32(layers.IPProtocolUDP), SkipTrue: 1},
+ bpf.RetConstant{Val: 0}, // Discard
+
+ // Check if IPv4 fragment offset is all-zero (this is not a fragment)
+ bpf.LoadAbsolute{Off: 6, Size: 2},
+ bpf.JumpIf{Cond: bpf.JumpBitsSet, Val: 0x1fff, SkipFalse: 1},
+ bpf.RetConstant{Val: 0}, // Discard
+
+ // Load IPv4 header size from offset zero and store it into X
+ bpf.LoadMemShift{Off: 0},
+
+ // Check if UDP header destination port equals 68
+ bpf.LoadIndirect{Off: 2, Size: 2}, // Offset relative to header size in register X
+ bpf.JumpIf{Cond: bpf.JumpEqual, Val: 68, SkipTrue: 1},
+ bpf.RetConstant{Val: 0}, // Discard
+
+ // Accept packet and pass through up maximum IP packet size
+ bpf.RetConstant{Val: maxIPv4MTU},
+}
+
+var bpfFilter = mustAssemble(bpfFilterInstructions)
+
+// BroadcastTransport implements a DHCP transport based on a custom IP/UDP stack fulfilling the
+// specific requirements for broadcasting DHCP packets (like all-zero source address, no ARP, ...)
+type BroadcastTransport struct {
+ rawConn *raw.Conn
+ iface *net.Interface
+}
+
+func NewBroadcastTransport(iface *net.Interface) *BroadcastTransport {
+ return &BroadcastTransport{iface: iface}
+}
+
+func (t *BroadcastTransport) Open() error {
+ if t.rawConn != nil {
+ return errors.New("broadcast transport already open")
+ }
+ rawConn, err := raw.ListenPacket(t.iface, uint16(layers.EthernetTypeIPv4), &raw.Config{
+ LinuxSockDGRAM: true,
+ Filter: bpfFilter,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to create raw listener: %w", err)
+ }
+ t.rawConn = rawConn
+ return nil
+}
+
+func (t *BroadcastTransport) Send(payload *dhcpv4.DHCPv4) error {
+ if t.rawConn == nil {
+ return errors.New("broadcast transport closed")
+ }
+ packet := gopacket.NewSerializeBuffer()
+ opts := gopacket.SerializeOptions{
+ ComputeChecksums: true,
+ FixLengths: true,
+ }
+
+ ipLayer := &layers.IPv4{
+ Version: 4,
+ TOS: dscpCS7 << 2, // Shift left of ECN field
+ TTL: 1, // These packets should never be routed (their IP headers contain garbage)
+ Protocol: layers.IPProtocolUDP,
+ Flags: layers.IPv4DontFragment, // Most DHCP servers don't support fragmented packets
+ DstIP: net.IPv4bcast,
+ SrcIP: net.IPv4zero,
+ }
+ udpLayer := &layers.UDP{
+ DstPort: 67,
+ SrcPort: 68,
+ }
+ if err := udpLayer.SetNetworkLayerForChecksum(ipLayer); err != nil {
+ panic("Invalid layer stackup encountered")
+ }
+
+ err := gopacket.SerializeLayers(packet, opts,
+ ipLayer,
+ udpLayer,
+ gopacket.Payload(payload.ToBytes()))
+
+ if err != nil {
+ return fmt.Errorf("failed to assemble packet: %w", err)
+ }
+
+ _, err = t.rawConn.WriteTo(packet.Bytes(), &raw.Addr{HardwareAddr: layers.EthernetBroadcast})
+ if err != nil {
+ return fmt.Errorf("failed to transmit broadcast packet: %w", err)
+ }
+ return nil
+}
+
+func (t *BroadcastTransport) Receive() (*dhcpv4.DHCPv4, error) {
+ if t.rawConn == nil {
+ return nil, errors.New("broadcast transport closed")
+ }
+ buf := make([]byte, math.MaxUint16) // Maximum IP packet size
+ n, _, err := t.rawConn.ReadFrom(buf)
+ if err != nil {
+ return nil, deadlineFromTimeout(err)
+ }
+ respPacket := gopacket.NewPacket(buf[:n], layers.LayerTypeIPv4, gopacket.Default)
+ ipLayer := respPacket.Layer(layers.LayerTypeIPv4)
+ if ipLayer == nil {
+ return nil, NewInvalidMessageError(errors.New("got invalid IP packet"))
+ }
+ ip := ipLayer.(*layers.IPv4)
+ if ip.Flags&layers.IPv4MoreFragments != 0 {
+ return nil, NewInvalidMessageError(errors.New("got fragmented message"))
+ }
+
+ udpLayer := respPacket.Layer(layers.LayerTypeUDP)
+ if udpLayer == nil {
+ return nil, NewInvalidMessageError(errors.New("got non-UDP packet"))
+ }
+ udp := udpLayer.(*layers.UDP)
+ if udp.DstPort != 68 {
+ return nil, NewInvalidMessageError(errors.New("message not for DHCP client port"))
+ }
+ msg, err := dhcpv4.FromBytes(udp.Payload)
+ if err != nil {
+ return nil, NewInvalidMessageError(fmt.Errorf("failed to decode DHCPv4 message: %w", err))
+ }
+ return msg, nil
+}
+
+func (t *BroadcastTransport) Close() error {
+ if t.rawConn == nil {
+ return nil
+ }
+ if err := t.rawConn.Close(); err != nil {
+ return err
+ }
+ t.rawConn = nil
+ return nil
+}
+
+func (t *BroadcastTransport) SetReceiveDeadline(deadline time.Time) error {
+ if t.rawConn == nil {
+ return errors.New("broadcast transport closed")
+ }
+ return t.rawConn.SetReadDeadline(deadline)
+}
diff --git a/core/pkg/dhcp4c/transport/transport_unicast.go b/core/pkg/dhcp4c/transport/transport_unicast.go
new file mode 100644
index 0000000..bf2b3a4
--- /dev/null
+++ b/core/pkg/dhcp4c/transport/transport_unicast.go
@@ -0,0 +1,122 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package transport
+
+import (
+ "errors"
+ "fmt"
+ "math"
+ "net"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/insomniacslk/dhcp/dhcpv4"
+ "golang.org/x/sys/unix"
+)
+
+// UnicastTransport implements a DHCP transport based on a normal Linux UDP socket with some custom
+// socket options to influence DSCP and routing.
+type UnicastTransport struct {
+ udpConn *net.UDPConn
+ targetIP net.IP
+ iface *net.Interface
+}
+
+func NewUnicastTransport(iface *net.Interface) *UnicastTransport {
+ return &UnicastTransport{
+ iface: iface,
+ }
+}
+
+func (t *UnicastTransport) Open(serverIP, bindIP net.IP) error {
+ if t.udpConn != nil {
+ return errors.New("unicast transport already open")
+ }
+ rawFd, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM, 0)
+ if err != nil {
+ return fmt.Errorf("failed to get socket: %w", err)
+ }
+ if err := unix.BindToDevice(rawFd, t.iface.Name); err != nil {
+ return fmt.Errorf("failed to bind UDP interface to device: %w", err)
+ }
+ if err := unix.SetsockoptByte(rawFd, unix.SOL_IP, unix.IP_TOS, dscpCS7<<2); err != nil {
+ return fmt.Errorf("failed to set DSCP CS7: %w", err)
+ }
+ var addr [4]byte
+ copy(addr[:], bindIP.To4())
+ if err := unix.Bind(rawFd, &unix.SockaddrInet4{Addr: addr, Port: 68}); err != nil {
+ return fmt.Errorf("failed to bind UDP unicast interface: %w", err)
+ }
+ filePtr := os.NewFile(uintptr(rawFd), "dhcp-udp")
+ defer filePtr.Close()
+ conn, err := net.FileConn(filePtr)
+ if err != nil {
+ return fmt.Errorf("failed to initialize runtime-supported UDP connection: %w", err)
+ }
+ realConn, ok := conn.(*net.UDPConn)
+ if !ok {
+ panic("UDP socket imported into Go runtime is no longer a UDP socket")
+ }
+ t.udpConn = realConn
+ t.targetIP = serverIP
+ return nil
+}
+
+func (t *UnicastTransport) Send(payload *dhcpv4.DHCPv4) error {
+ if t.udpConn == nil {
+ return errors.New("unicast transport closed")
+ }
+ _, _, err := t.udpConn.WriteMsgUDP(payload.ToBytes(), []byte{}, &net.UDPAddr{
+ IP: t.targetIP,
+ Port: 67,
+ })
+ return err
+}
+
+func (t *UnicastTransport) SetReceiveDeadline(deadline time.Time) error {
+ return t.udpConn.SetReadDeadline(deadline)
+}
+
+func (t *UnicastTransport) Receive() (*dhcpv4.DHCPv4, error) {
+ if t.udpConn == nil {
+ return nil, errors.New("unicast transport closed")
+ }
+ receiveBuf := make([]byte, math.MaxUint16)
+ _, _, err := t.udpConn.ReadFromUDP(receiveBuf)
+ if err != nil {
+ return nil, deadlineFromTimeout(err)
+ }
+ msg, err := dhcpv4.FromBytes(receiveBuf)
+ if err != nil {
+ return nil, NewInvalidMessageError(err)
+ }
+ return msg, nil
+}
+
+func (t *UnicastTransport) Close() error {
+ if t.udpConn == nil {
+ return nil
+ }
+ err := t.udpConn.Close()
+ t.udpConn = nil
+ // TODO(lorenz): Move to net.ErrClosed once Go 1.16 lands
+ if err != nil && strings.Contains(err.Error(), "use of closed network connection") {
+ return nil
+ }
+ return err
+}