- Add Renew() to dhcp.Client: sends REQUEST with ciaddr (RENEWING state) - Start renewal goroutine in session at lease_time/2 - On IP change: flush TAP, reconfigure address/routes/DNS/policy routes - On renewal failure: retry at T/4 (min 60s) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
491 lines
13 KiB
Go
491 lines
13 KiB
Go
// Package dhcp implements a minimal DHCP client operating on raw Ethernet frames.
|
|
//
|
|
// It constructs DHCP DISCOVER/REQUEST as complete Ethernet frames (including
|
|
// IP and UDP headers) and parses DHCP OFFER/ACK responses. This allows the
|
|
// VPN client to obtain an IP address without an external dhcpcd dependency.
|
|
//
|
|
// The DHCP exchange is performed over the VPN tunnel: frames are sent via
|
|
// the tunnel's WriteFrames and responses are identified from incoming tunnel frames.
|
|
package dhcp
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"fmt"
|
|
"math/rand"
|
|
"net"
|
|
"time"
|
|
)
|
|
|
|
// Route is a classless static route from DHCP option 121/249.
|
|
type Route struct {
|
|
Dest net.IPNet
|
|
Gateway net.IP
|
|
}
|
|
|
|
// Lease holds the result of a successful DHCP exchange.
|
|
type Lease struct {
|
|
ClientIP net.IP
|
|
SubnetMask net.IPMask
|
|
Gateway net.IP
|
|
DNS []net.IP
|
|
Routes []Route // Classless static routes (option 121/249)
|
|
LeaseTime time.Duration
|
|
ServerIP net.IP // DHCP server identifier
|
|
}
|
|
|
|
const (
|
|
bootRequest = 1
|
|
bootReply = 2
|
|
hwEthernet = 1
|
|
hwAddrLen = 6
|
|
dhcpMagic = 0x63825363
|
|
clientPort = 68
|
|
serverPort = 67
|
|
maxDHCPSize = 576
|
|
|
|
// DHCP message types (option 53)
|
|
dhcpDiscover = 1
|
|
dhcpOffer = 2
|
|
dhcpRequest = 3
|
|
dhcpAck = 5
|
|
dhcpNak = 6
|
|
|
|
// DHCP options
|
|
optSubnetMask = 1
|
|
optRouter = 3
|
|
optDNS = 6
|
|
optLeaseTime = 51
|
|
optMessageType = 53
|
|
optServerID = 54
|
|
optRequestedIP = 50
|
|
optParamRequest = 55
|
|
optClasslessRoutes = 121
|
|
optMSClasslessRoutes = 249
|
|
optEnd = 255
|
|
)
|
|
|
|
// Client performs DHCP over raw Ethernet frames sent through a VPN tunnel.
|
|
type Client struct {
|
|
MAC net.HardwareAddr // Client MAC address (from TAP device)
|
|
xid uint32 // Transaction ID
|
|
frameCh chan []byte // Channel to receive candidate DHCP response frames
|
|
}
|
|
|
|
// NewClient creates a DHCP client for the given MAC address.
|
|
func NewClient(mac net.HardwareAddr) *Client {
|
|
return &Client{
|
|
MAC: mac,
|
|
xid: rand.Uint32(),
|
|
frameCh: make(chan []byte, 16),
|
|
}
|
|
}
|
|
|
|
// FeedFrame should be called with every Ethernet frame received from the tunnel.
|
|
// It checks if the frame is a DHCP response and queues it for processing.
|
|
func (c *Client) FeedFrame(frame []byte) {
|
|
if !isDHCPResponse(frame, c.xid) {
|
|
return
|
|
}
|
|
select {
|
|
case c.frameCh <- append([]byte(nil), frame...):
|
|
default:
|
|
}
|
|
}
|
|
|
|
// Run performs a full DHCP exchange (DISCOVER→OFFER→REQUEST→ACK).
|
|
// sendFrame is called to transmit each Ethernet frame through the tunnel.
|
|
// Returns the lease on success.
|
|
func (c *Client) Run(sendFrame func([]byte) error, timeout time.Duration) (*Lease, error) {
|
|
// Send DISCOVER
|
|
disc := c.buildDiscover()
|
|
if err := sendFrame(disc); err != nil {
|
|
return nil, fmt.Errorf("send discover: %w", err)
|
|
}
|
|
|
|
// Wait for OFFER
|
|
offer, err := c.waitForType(dhcpOffer, timeout)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("waiting for offer: %w", err)
|
|
}
|
|
|
|
offeredIP := net.IP(offer.yiaddr[:])
|
|
serverID := offer.getOptionIP(optServerID)
|
|
if serverID == nil {
|
|
return nil, fmt.Errorf("offer missing server identifier")
|
|
}
|
|
|
|
// Send REQUEST
|
|
req := c.buildRequest(offeredIP, serverID)
|
|
if err := sendFrame(req); err != nil {
|
|
return nil, fmt.Errorf("send request: %w", err)
|
|
}
|
|
|
|
// Wait for ACK
|
|
ack, err := c.waitForType(dhcpAck, timeout)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("waiting for ack: %w", err)
|
|
}
|
|
|
|
lease := &Lease{
|
|
ClientIP: net.IP(ack.yiaddr[:]).To4(),
|
|
ServerIP: ack.getOptionIP(optServerID),
|
|
SubnetMask: net.IPMask(ack.getOptionRaw(optSubnetMask)),
|
|
Gateway: ack.getOptionIP(optRouter),
|
|
DNS: ack.getOptionIPs(optDNS),
|
|
}
|
|
if lt := ack.getOptionUint32(optLeaseTime); lt > 0 {
|
|
lease.LeaseTime = time.Duration(lt) * time.Second
|
|
}
|
|
if lease.SubnetMask == nil {
|
|
lease.SubnetMask = net.CIDRMask(24, 32)
|
|
}
|
|
|
|
// Parse classless static routes (option 121, falls back to 249)
|
|
if routes := parseClasslessRoutes(ack.getOptionRaw(optClasslessRoutes)); len(routes) > 0 {
|
|
lease.Routes = routes
|
|
} else if routes := parseClasslessRoutes(ack.getOptionRaw(optMSClasslessRoutes)); len(routes) > 0 {
|
|
lease.Routes = routes
|
|
}
|
|
|
|
return lease, nil
|
|
}
|
|
|
|
// Renew sends a DHCP REQUEST to renew the current lease.
|
|
// In RENEWING state: ciaddr = current IP, no requested-IP or server-ID options.
|
|
// Returns the renewed lease on ACK, or error on NAK/timeout.
|
|
func (c *Client) Renew(currentIP net.IP, sendFrame func([]byte) error, timeout time.Duration) (*Lease, error) {
|
|
var ciaddr [4]byte
|
|
copy(ciaddr[:], currentIP.To4())
|
|
|
|
opts := []dhcpOption{
|
|
{optMessageType, []byte{dhcpRequest}},
|
|
{optParamRequest, []byte{optSubnetMask, optRouter, optDNS, optLeaseTime, optClasslessRoutes, optMSClasslessRoutes}},
|
|
}
|
|
frame := c.buildFrame(opts, ciaddr, nil)
|
|
if err := sendFrame(frame); err != nil {
|
|
return nil, fmt.Errorf("send renew: %w", err)
|
|
}
|
|
|
|
ack, err := c.waitForType(dhcpAck, timeout)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("waiting for renew ack: %w", err)
|
|
}
|
|
|
|
lease := &Lease{
|
|
ClientIP: net.IP(ack.yiaddr[:]).To4(),
|
|
ServerIP: ack.getOptionIP(optServerID),
|
|
SubnetMask: net.IPMask(ack.getOptionRaw(optSubnetMask)),
|
|
Gateway: ack.getOptionIP(optRouter),
|
|
DNS: ack.getOptionIPs(optDNS),
|
|
}
|
|
if lt := ack.getOptionUint32(optLeaseTime); lt > 0 {
|
|
lease.LeaseTime = time.Duration(lt) * time.Second
|
|
}
|
|
if lease.SubnetMask == nil {
|
|
lease.SubnetMask = net.CIDRMask(24, 32)
|
|
}
|
|
if routes := parseClasslessRoutes(ack.getOptionRaw(optClasslessRoutes)); len(routes) > 0 {
|
|
lease.Routes = routes
|
|
} else if routes := parseClasslessRoutes(ack.getOptionRaw(optMSClasslessRoutes)); len(routes) > 0 {
|
|
lease.Routes = routes
|
|
}
|
|
|
|
return lease, nil
|
|
}
|
|
|
|
func (c *Client) waitForType(msgType byte, timeout time.Duration) (*dhcpMsg, error) {
|
|
deadline := time.After(timeout)
|
|
for {
|
|
select {
|
|
case <-deadline:
|
|
return nil, fmt.Errorf("timeout waiting for DHCP message type %d", msgType)
|
|
case frame := <-c.frameCh:
|
|
msg, err := parseDHCPFrame(frame)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if msg.getOptionByte(optMessageType) == msgType {
|
|
return msg, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// dhcpMsg is a parsed DHCP message.
|
|
type dhcpMsg struct {
|
|
op byte
|
|
xid uint32
|
|
yiaddr [4]byte
|
|
siaddr [4]byte
|
|
options []dhcpOption
|
|
}
|
|
|
|
type dhcpOption struct {
|
|
code byte
|
|
data []byte
|
|
}
|
|
|
|
func (m *dhcpMsg) getOption(code byte) *dhcpOption {
|
|
for i := range m.options {
|
|
if m.options[i].code == code {
|
|
return &m.options[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *dhcpMsg) getOptionByte(code byte) byte {
|
|
if o := m.getOption(code); o != nil && len(o.data) >= 1 {
|
|
return o.data[0]
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (m *dhcpMsg) getOptionIP(code byte) net.IP {
|
|
if o := m.getOption(code); o != nil && len(o.data) >= 4 {
|
|
return net.IP(o.data[:4]).To4()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *dhcpMsg) getOptionIPs(code byte) []net.IP {
|
|
o := m.getOption(code)
|
|
if o == nil {
|
|
return nil
|
|
}
|
|
var ips []net.IP
|
|
for i := 0; i+3 < len(o.data); i += 4 {
|
|
ips = append(ips, net.IP(o.data[i:i+4]).To4())
|
|
}
|
|
return ips
|
|
}
|
|
|
|
func (m *dhcpMsg) getOptionRaw(code byte) []byte {
|
|
if o := m.getOption(code); o != nil {
|
|
return o.data
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *dhcpMsg) getOptionUint32(code byte) uint32 {
|
|
if o := m.getOption(code); o != nil && len(o.data) >= 4 {
|
|
return binary.BigEndian.Uint32(o.data)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// buildDiscover creates a full Ethernet frame containing a DHCP DISCOVER.
|
|
func (c *Client) buildDiscover() []byte {
|
|
opts := []dhcpOption{
|
|
{optMessageType, []byte{dhcpDiscover}},
|
|
{optParamRequest, []byte{optSubnetMask, optRouter, optDNS, optLeaseTime, optClasslessRoutes, optMSClasslessRoutes}},
|
|
}
|
|
return c.buildFrame(opts, [4]byte{}, nil)
|
|
}
|
|
|
|
// buildRequest creates a full Ethernet frame containing a DHCP REQUEST.
|
|
func (c *Client) buildRequest(requestedIP, serverID net.IP) []byte {
|
|
opts := []dhcpOption{
|
|
{optMessageType, []byte{dhcpRequest}},
|
|
{optRequestedIP, requestedIP.To4()},
|
|
{optServerID, serverID.To4()},
|
|
{optParamRequest, []byte{optSubnetMask, optRouter, optDNS, optLeaseTime, optClasslessRoutes, optMSClasslessRoutes}},
|
|
}
|
|
return c.buildFrame(opts, [4]byte{}, serverID.To4())
|
|
}
|
|
|
|
// buildFrame constructs a complete Ethernet/IP/UDP/DHCP frame.
|
|
func (c *Client) buildFrame(opts []dhcpOption, ciaddr [4]byte, _ net.IP) []byte {
|
|
// Build DHCP payload
|
|
dhcp := make([]byte, 240) // fixed DHCP header
|
|
dhcp[0] = bootRequest
|
|
dhcp[1] = hwEthernet
|
|
dhcp[2] = hwAddrLen
|
|
binary.BigEndian.PutUint32(dhcp[4:8], c.xid)
|
|
copy(dhcp[12:16], ciaddr[:])
|
|
copy(dhcp[28:34], c.MAC)
|
|
binary.BigEndian.PutUint32(dhcp[236:240], dhcpMagic)
|
|
|
|
// Append options
|
|
for _, o := range opts {
|
|
dhcp = append(dhcp, o.code, byte(len(o.data)))
|
|
dhcp = append(dhcp, o.data...)
|
|
}
|
|
dhcp = append(dhcp, optEnd)
|
|
|
|
// Pad to minimum size
|
|
for len(dhcp) < 300 {
|
|
dhcp = append(dhcp, 0)
|
|
}
|
|
|
|
// Build UDP header (8 bytes)
|
|
udpLen := uint16(8 + len(dhcp))
|
|
udp := make([]byte, 8)
|
|
binary.BigEndian.PutUint16(udp[0:2], clientPort)
|
|
binary.BigEndian.PutUint16(udp[2:4], serverPort)
|
|
binary.BigEndian.PutUint16(udp[4:6], udpLen)
|
|
// checksum = 0 (optional for IPv4 UDP)
|
|
|
|
// Build IPv4 header (20 bytes, no options)
|
|
ipLen := uint16(20 + len(udp) + len(dhcp))
|
|
ip := make([]byte, 20)
|
|
ip[0] = 0x45 // version=4, IHL=5
|
|
binary.BigEndian.PutUint16(ip[2:4], ipLen)
|
|
binary.BigEndian.PutUint16(ip[6:8], 0) // flags=0, fragment=0
|
|
ip[8] = 64 // TTL
|
|
ip[9] = 17 // protocol = UDP
|
|
copy(ip[12:16], net.IPv4zero.To4()) // src = 0.0.0.0
|
|
copy(ip[16:20], net.IPv4bcast.To4()) // dst = 255.255.255.255
|
|
// IP header checksum
|
|
binary.BigEndian.PutUint16(ip[10:12], ipChecksum(ip))
|
|
|
|
// Build Ethernet header (14 bytes)
|
|
eth := make([]byte, 14)
|
|
copy(eth[0:6], net.HardwareAddr{0xff, 0xff, 0xff, 0xff, 0xff, 0xff}) // dst = broadcast
|
|
copy(eth[6:12], c.MAC) // src
|
|
binary.BigEndian.PutUint16(eth[12:14], 0x0800) // EtherType = IPv4
|
|
|
|
frame := make([]byte, 0, len(eth)+len(ip)+len(udp)+len(dhcp))
|
|
frame = append(frame, eth...)
|
|
frame = append(frame, ip...)
|
|
frame = append(frame, udp...)
|
|
frame = append(frame, dhcp...)
|
|
return frame
|
|
}
|
|
|
|
// isDHCPResponse checks if an Ethernet frame is a DHCP response (UDP from port 67 to 68)
|
|
// matching our transaction ID.
|
|
func isDHCPResponse(frame []byte, xid uint32) bool {
|
|
if len(frame) < 14+20+8+240 { // eth + ip + udp + dhcp minimum
|
|
return false
|
|
}
|
|
// EtherType must be IPv4
|
|
if binary.BigEndian.Uint16(frame[12:14]) != 0x0800 {
|
|
return false
|
|
}
|
|
ipStart := 14
|
|
// IP protocol must be UDP (17)
|
|
if frame[ipStart+9] != 17 {
|
|
return false
|
|
}
|
|
ihl := int(frame[ipStart]&0x0f) * 4
|
|
udpStart := ipStart + ihl
|
|
if len(frame) < udpStart+8 {
|
|
return false
|
|
}
|
|
srcPort := binary.BigEndian.Uint16(frame[udpStart : udpStart+2])
|
|
dstPort := binary.BigEndian.Uint16(frame[udpStart+2 : udpStart+4])
|
|
if srcPort != serverPort || dstPort != clientPort {
|
|
return false
|
|
}
|
|
dhcpStart := udpStart + 8
|
|
if len(frame) < dhcpStart+240 {
|
|
return false
|
|
}
|
|
// Check xid
|
|
pktXid := binary.BigEndian.Uint32(frame[dhcpStart+4 : dhcpStart+8])
|
|
return pktXid == xid
|
|
}
|
|
|
|
// parseDHCPFrame extracts a DHCP message from a full Ethernet frame.
|
|
func parseDHCPFrame(frame []byte) (*dhcpMsg, error) {
|
|
ipStart := 14
|
|
ihl := int(frame[ipStart]&0x0f) * 4
|
|
dhcpStart := ipStart + ihl + 8 // skip IP + UDP headers
|
|
data := frame[dhcpStart:]
|
|
|
|
if len(data) < 240 {
|
|
return nil, fmt.Errorf("dhcp packet too short")
|
|
}
|
|
|
|
msg := &dhcpMsg{
|
|
op: data[0],
|
|
xid: binary.BigEndian.Uint32(data[4:8]),
|
|
}
|
|
copy(msg.yiaddr[:], data[16:20])
|
|
copy(msg.siaddr[:], data[20:24])
|
|
|
|
// Parse options (after magic cookie at offset 236)
|
|
magic := binary.BigEndian.Uint32(data[236:240])
|
|
if magic != dhcpMagic {
|
|
return nil, fmt.Errorf("bad DHCP magic: %x", magic)
|
|
}
|
|
|
|
opts := data[240:]
|
|
for len(opts) > 0 {
|
|
code := opts[0]
|
|
if code == optEnd {
|
|
break
|
|
}
|
|
if code == 0 { // padding
|
|
opts = opts[1:]
|
|
continue
|
|
}
|
|
if len(opts) < 2 {
|
|
break
|
|
}
|
|
length := int(opts[1])
|
|
if len(opts) < 2+length {
|
|
break
|
|
}
|
|
msg.options = append(msg.options, dhcpOption{
|
|
code: code,
|
|
data: append([]byte(nil), opts[2:2+length]...),
|
|
})
|
|
opts = opts[2+length:]
|
|
}
|
|
return msg, nil
|
|
}
|
|
|
|
// parseClasslessRoutes decodes RFC 3442 classless static routes.
|
|
// Encoding: [mask_width] [significant dest octets...] [gateway 4 bytes]
|
|
// e.g. /0 = 1+4 bytes (default route), /25 = 1+4+4 bytes
|
|
func parseClasslessRoutes(data []byte) []Route {
|
|
if len(data) == 0 {
|
|
return nil
|
|
}
|
|
var routes []Route
|
|
for len(data) > 0 {
|
|
if len(data) < 1 {
|
|
break
|
|
}
|
|
maskWidth := int(data[0])
|
|
data = data[1:]
|
|
if maskWidth > 32 {
|
|
break
|
|
}
|
|
// Number of significant destination octets
|
|
nOctets := (maskWidth + 7) / 8
|
|
if len(data) < nOctets+4 {
|
|
break
|
|
}
|
|
var dest [4]byte
|
|
copy(dest[:], data[:nOctets])
|
|
data = data[nOctets:]
|
|
gw := net.IP(append([]byte(nil), data[:4]...)).To4()
|
|
data = data[4:]
|
|
|
|
routes = append(routes, Route{
|
|
Dest: net.IPNet{
|
|
IP: net.IP(dest[:]).To4(),
|
|
Mask: net.CIDRMask(maskWidth, 32),
|
|
},
|
|
Gateway: gw,
|
|
})
|
|
}
|
|
return routes
|
|
}
|
|
|
|
func ipChecksum(header []byte) uint16 {
|
|
var sum uint32
|
|
for i := 0; i < len(header)-1; i += 2 {
|
|
sum += uint32(binary.BigEndian.Uint16(header[i : i+2]))
|
|
}
|
|
if len(header)%2 == 1 {
|
|
sum += uint32(header[len(header)-1]) << 8
|
|
}
|
|
for sum > 0xffff {
|
|
sum = (sum & 0xffff) + (sum >> 16)
|
|
}
|
|
return ^uint16(sum)
|
|
}
|