initial commit: standalone SoftEther VPN client in Go

Built-in DHCP (raw Ethernet frames through tunnel), automatic reconnection,
host route management, classless static routes (option 121/249), DNS config.
Single static binary, Linux only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Git Sagar 2026-06-06 16:13:51 +05:30
commit 829ca73b1b
340 changed files with 199140 additions and 0 deletions

448
pkg/dhcp/dhcp.go Normal file
View file

@ -0,0 +1,448 @@
// 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
}
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)
}