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/build/fietsje/main.go b/build/fietsje/main.go
index ef291ae..1500623 100644
--- a/build/fietsje/main.go
+++ b/build/fietsje/main.go
@@ -76,16 +76,14 @@
 	p.collect("github.com/google/go-tpm", "ae6dd98980d4")
 	p.collect("github.com/google/go-tpm-tools", "f8c04ff88181")
 	p.collect("github.com/google/certificate-transparency-go", "v1.1.0")
-	p.collect("github.com/insomniacslk/dhcp", "5dd7202f19711228cb4a51aa8b3415421c2edefe")
-	p.collect("github.com/mdlayher/ethernet", "0394541c37b7f86a10e0b49492f6d4f605c34163").use(
+	p.collect("github.com/insomniacslk/dhcp", "67c425063dcad32c5d14ce9a520c8865240dc945").use(
+		"github.com/mdlayher/ethernet",
 		"github.com/mdlayher/raw",
+		"github.com/u-root/u-root",
 	)
 	p.collect("github.com/rekby/gpt", "a930afbc6edcc89c83d39b79e52025698156178d")
 	p.collect("github.com/yalue/native_endian", "51013b03be4fd97b0aabf29a6923e60359294186")
 
-	// used by insomniacslk/dhcp for pkg/uio
-	p.collect("github.com/u-root/u-root", "v6.0.0")
-
 	// used by //core/cmd/mkimage
 	p.collect("github.com/diskfs/go-diskfs", "v1.0.0").use(
 		"gopkg.in/djherbis/times.v1",
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
+}
diff --git a/third_party/go/repositories.bzl b/third_party/go/repositories.bzl
index dd79302..e934373 100644
--- a/third_party/go/repositories.bzl
+++ b/third_party/go/repositories.bzl
@@ -911,8 +911,8 @@
     go_repository(
         name = "com_github_insomniacslk_dhcp",
         importpath = "github.com/insomniacslk/dhcp",
-        version = "v0.0.0-20200402185128-5dd7202f1971",
-        sum = "h1:P1pxzF2xvdnSY12ODSSwjxA4tyEjDEJNn829OXKnqks=",
+        version = "v0.0.0-20200922210017-67c425063dca",
+        sum = "h1:zhwTlFGM8ZkD5J/c43IWkxSJQWzhm20QWou8zajbCck=",
     )
     go_repository(
         name = "com_github_j_keck_arping",
@@ -1079,8 +1079,8 @@
     go_repository(
         name = "com_github_mdlayher_raw",
         importpath = "github.com/mdlayher/raw",
-        version = "v0.0.0-20190606142536-fef19f00fc18",
-        sum = "h1:zwOa3e/13D6veNIz6zzuqrd3eZEMF0dzD0AQWKcYSs4=",
+        version = "v0.0.0-20191009151244-50f2db8cc065",
+        sum = "h1:aFkJ6lx4FPip+S+Uw4aTegFMct9shDvP+79PsSxpm3w=",
     )
     go_repository(
         name = "com_github_miekg_dns",
@@ -1460,8 +1460,8 @@
     go_repository(
         name = "com_github_u_root_u_root",
         importpath = "github.com/u-root/u-root",
-        version = "v6.0.0+incompatible",
-        sum = "h1:YqPGmRoRyYmeg17KIWFRSyVq6LX5T6GSzawyA6wG6EE=",
+        version = "v7.0.0+incompatible",
+        sum = "h1:u+KSS04pSxJGI5E7WE4Bs9+Zd75QjFv+REkjy/aoAc8=",
     )
     go_repository(
         name = "com_github_urfave_cli",
diff --git a/third_party/go/shelf.pb.text b/third_party/go/shelf.pb.text
index 6eb9603..e78522e 100644
--- a/third_party/go/shelf.pb.text
+++ b/third_party/go/shelf.pb.text
@@ -1378,6 +1378,13 @@
   semver: "v0.0.0-20200402185128-5dd7202f1971"
 >
 entry: <
+  import_path: "github.com/insomniacslk/dhcp"
+  version: "67c425063dcad32c5d14ce9a520c8865240dc945"
+  bazel_name: "com_github_insomniacslk_dhcp"
+  sum: "h1:zhwTlFGM8ZkD5J/c43IWkxSJQWzhm20QWou8zajbCck="
+  semver: "v0.0.0-20200922210017-67c425063dca"
+>
+entry: <
   import_path: "github.com/j-keck/arping"
   version: "v0.0.0-20160618110441-2cf9dc699c56"
   bazel_name: "com_github_j_keck_arping"
@@ -1574,6 +1581,13 @@
   semver: "v0.0.0-20190606142754-0394541c37b7"
 >
 entry: <
+  import_path: "github.com/mdlayher/ethernet"
+  version: "v0.0.0-20190606142754-0394541c37b7"
+  bazel_name: "com_github_mdlayher_ethernet"
+  sum: "h1:lez6TS6aAau+8wXUP3G9I3TGlmPFEq2CTxBaRqY6AGE="
+  semver: "v0.0.0-20190606142754-0394541c37b7"
+>
+entry: <
   import_path: "github.com/mdlayher/genetlink"
   version: "v1.0.0"
   bazel_name: "com_github_mdlayher_genetlink"
@@ -1602,6 +1616,13 @@
   semver: "v0.0.0-20190606142536-fef19f00fc18"
 >
 entry: <
+  import_path: "github.com/mdlayher/raw"
+  version: "v0.0.0-20191009151244-50f2db8cc065"
+  bazel_name: "com_github_mdlayher_raw"
+  sum: "h1:aFkJ6lx4FPip+S+Uw4aTegFMct9shDvP+79PsSxpm3w="
+  semver: "v0.0.0-20191009151244-50f2db8cc065"
+>
+entry: <
   import_path: "github.com/miekg/dns"
   version: "v1.0.14"
   bazel_name: "com_github_miekg_dns"
@@ -2232,6 +2253,13 @@
   semver: "v6.0.0+incompatible"
 >
 entry: <
+  import_path: "github.com/u-root/u-root"
+  version: "v7.0.0+incompatible"
+  bazel_name: "com_github_u_root_u_root"
+  sum: "h1:u+KSS04pSxJGI5E7WE4Bs9+Zd75QjFv+REkjy/aoAc8="
+  semver: "v7.0.0+incompatible"
+>
+entry: <
   import_path: "github.com/urfave/cli"
   version: "bfe2e925cfb6d44b40ad3a779165ea7e8aff9212"
   bazel_name: "com_github_urfave_cli"