diff --git a/.gitignore b/.gitignore
index 1091f8b..d5dbfd8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,6 +25,8 @@ vendor/
/xcurl
/xnet
/httpclient
+/httpserver
+**/local*
/stack
**__debug_bin*
# `__debug_bin` Debug binary generated in VSCode when using the built-in debugger.
@@ -33,6 +35,7 @@ vendor/
/bridge
# IDE
.vscode/
+agents.md
# For local development and testing create `local` directories.
local
diff --git a/dhcpv4/dhcp_test.go b/dhcpv4/dhcp_test.go
index cb59901..ebcb51d 100644
--- a/dhcpv4/dhcp_test.go
+++ b/dhcpv4/dhcp_test.go
@@ -2,6 +2,7 @@ package dhcpv4
import (
"bytes"
+ "net/netip"
"testing"
)
@@ -25,7 +26,10 @@ func TestClientServer(t *testing.T) {
t.Errorf("want client state %s, got %s", state.String(), cl.State().String())
}
}
- sv.Reset(svAddr, DefaultServerPort)
+ sv.Configure(ServerConfig{
+ ServerAddr: svAddr,
+ Subnet: netip.PrefixFrom(netip.AddrFrom4(svAddr), 24),
+ })
// CLIENT DISCOVER.
assertClState(StateInit)
var buf [1024]byte
diff --git a/dhcpv4/server.go b/dhcpv4/server.go
index 88bceb0..d82067c 100644
--- a/dhcpv4/server.go
+++ b/dhcpv4/server.go
@@ -11,15 +11,33 @@ import (
)
type Server struct {
- connID uint64
- nextAddr netip.Addr
- prefix netip.Prefix
- hosts map[[36]byte]serverEntry
- vld lneto.Validator
- pending int
- port uint16
- siaddr [4]byte
- gwaddr [4]byte
+ connID uint64
+ nextAddr netip.Addr
+ prefix netip.Prefix
+ hosts map[[36]byte]serverEntry
+ vld lneto.Validator
+ pending int
+ leaseSeconds uint32
+ port uint16
+ siaddr [4]byte
+ gwaddr [4]byte
+ dns [4]byte
+}
+
+// ServerConfig contains configuration parameters for [Server.Configure].
+type ServerConfig struct {
+ // ServerAddr is the DHCP server's own IPv4 address.
+ ServerAddr [4]byte
+ // Gateway advertised to clients as default router. Zero value omits the option.
+ Gateway [4]byte
+ // DNS server address advertised to clients. Zero value omits the option.
+ DNS [4]byte
+ // Subnet defines the network prefix for address allocation and subnet mask responses.
+ Subnet netip.Prefix
+ // LeaseSeconds is the lease duration. Zero defaults to 3600.
+ LeaseSeconds uint32
+ // Port is the server listening port. Zero defaults to DefaultServerPort.
+ Port uint16
}
type serverEntry struct {
@@ -35,30 +53,53 @@ type serverEntry struct {
// - Init: Server received discover, pending Offer sent out.
// - Selecting: Server sent out offer, request not received.
// - Requesting: Request received, pending Ack sent out.
- // - Bound: Request sent out, no more pending data to be sent.
+ // - Bound: Ack sent out, no more pending data to be sent.
state ClientState
}
-func (sv *Server) Reset(serverAddr [4]byte, port uint16) {
- *sv = Server{
- connID: sv.connID + 1,
- siaddr: serverAddr,
- port: port,
- hosts: sv.hosts,
- nextAddr: netip.AddrFrom4(serverAddr),
+// Configure resets and configures the server with the given configuration.
+// The connection ID is incremented on each call to invalidate existing connections.
+// The hosts map is reused across calls to avoid reallocation.
+func (sv *Server) Configure(cfg ServerConfig) error {
+ svAddr := netip.AddrFrom4(cfg.ServerAddr)
+ if !cfg.Subnet.IsValid() {
+ return errors.New("dhcpv4 server: invalid subnet")
+ } else if !cfg.Subnet.Contains(svAddr) {
+ return errors.New("dhcpv4 server: server address outside subnet")
}
- if sv.hosts == nil {
- sv.hosts = make(map[[36]byte]serverEntry)
+ port := cfg.Port
+ if port == 0 {
+ port = DefaultServerPort
+ }
+ lease := cfg.LeaseSeconds
+ if lease == 0 {
+ lease = 3600
+ }
+ hosts := sv.hosts
+ if hosts == nil {
+ hosts = make(map[[36]byte]serverEntry)
} else {
- for k := range sv.hosts {
- delete(sv.hosts, k)
+ for k := range hosts {
+ delete(hosts, k)
}
}
+ *sv = Server{
+ connID: sv.connID + 1,
+ siaddr: cfg.ServerAddr,
+ gwaddr: cfg.Gateway,
+ dns: cfg.DNS,
+ prefix: cfg.Subnet,
+ port: port,
+ leaseSeconds: lease,
+ nextAddr: svAddr,
+ hosts: hosts,
+ }
+ return nil
}
func (sv *Server) ConnectionID() *uint64 { return &sv.connID }
func (sv *Server) Protocol() uint64 { return uint64(lneto.IPProtoUDP) }
-func (sv *Server) Port() uint16 { return sv.port }
+func (sv *Server) LocalPort() uint16 { return sv.port }
func (sv *Server) Demux(carrierData []byte, frameOffset int) error {
isIPLayer := frameOffset >= 28
@@ -103,6 +144,9 @@ func (sv *Server) Demux(carrierData []byte, frameOffset int) error {
}
return nil
})
+ if err != nil {
+ return err
+ }
var clientIDRaw [36]byte
var client serverEntry
var clientExists bool
@@ -115,16 +159,17 @@ func (sv *Server) Demux(carrierData []byte, frameOffset int) error {
switch msgType {
case MsgDiscover:
- if clientExists {
- err = errors.New("DHCP Discover on initialized client")
- break
+ if clientExists && (client.state == StateInit || client.state == StateRequesting) {
+ sv.pending-- // Cancel unfulfilled pending response.
}
- if len(reqAddr) == 4 {
- println("requested", reqAddr[0], reqAddr[1], reqAddr[2], reqAddr[3])
+ if !clientExists {
+ addr, ok := sv.allocAddr(reqAddr)
+ if !ok {
+ return errors.New("dhcpv4 server: address pool exhausted")
+ }
+ client.addr = addr
}
- sv.nextAddr = sv.nextAddr.Next()
copy(client.requestlist[:], reqlist)
- client.addr = sv.nextAddr.As4()
client.state = StateInit
client.hostname = string(hostname)
client.xid = dfrm.XID()
@@ -137,7 +182,7 @@ func (sv *Server) Demux(carrierData []byte, frameOffset int) error {
case MsgRequest:
if !clientExists {
- err = errors.New("request for non existing client?")
+ err = errors.New("request for non existing client")
} else if dfrm.XID() != client.xid {
err = errors.New("unexpected XID for client")
} else if client.state != StateSelecting && client.state != StateRequesting {
@@ -146,11 +191,22 @@ func (sv *Server) Demux(carrierData []byte, frameOffset int) error {
if err != nil {
break
}
- client.state = StateRequesting
- sv.pending++
+ if client.state == StateSelecting {
+ client.state = StateRequesting
+ sv.pending++
+ }
+
+ case MsgRelease:
+ if clientExists {
+ if client.state == StateInit || client.state == StateRequesting {
+ sv.pending--
+ }
+ delete(sv.hosts, clientIDRaw)
+ return nil
+ }
default:
- err = errors.New("unhandled message type")
+ err = fmt.Errorf("unhandled message type %s", msgType.String())
}
if err != nil {
return fmt.Errorf("msgtype=%s client=%+v: %w", msgType.String(), client, err)
@@ -169,7 +225,7 @@ func (sv *Server) Encapsulate(carrierData []byte, offsetToIP, offsetToFrame int)
return 0, errOptionNotFit
}
if sv.pending == 0 {
- return 0, nil // No pending outgoing frames.a
+ return 0, nil // No pending outgoing frames.
}
var client serverEntry
@@ -205,6 +261,26 @@ func (sv *Server) Encapsulate(carrierData []byte, offsetToIP, offsetToFrame int)
n, _ = EncodeOption(optBuf[nopt:], OptRouter, sv.gwaddr[:]...)
nopt += n
}
+ if sv.prefix.IsValid() {
+ bits := uint(sv.prefix.Bits())
+ mask := ^uint32(0) << (32 - bits)
+ var maskBuf [4]byte
+ binary.BigEndian.PutUint32(maskBuf[:], mask)
+ n, _ = EncodeOption(optBuf[nopt:], OptSubnetMask, maskBuf[:]...)
+ nopt += n
+ }
+ if sv.dns != [4]byte{} {
+ n, _ = EncodeOption(optBuf[nopt:], OptDNSServers, sv.dns[:]...)
+ nopt += n
+ }
+ if sv.leaseSeconds > 0 {
+ n, _ = EncodeOption32(optBuf[nopt:], OptIPAddressLeaseTime, sv.leaseSeconds)
+ nopt += n
+ n, _ = EncodeOption32(optBuf[nopt:], OptRenewTimeValue, sv.leaseSeconds/2)
+ nopt += n
+ n, _ = EncodeOption32(optBuf[nopt:], OptRebindingTimeValue, sv.leaseSeconds*7/8)
+ nopt += n
+ }
optBuf[nopt] = byte(OptEnd)
nopt++
@@ -234,6 +310,40 @@ func (sv *Server) Encapsulate(carrierData []byte, offsetToIP, offsetToFrame int)
return OptionsOffset + nopt, nil
}
+// allocAddr allocates the next available address from the pool.
+// If reqAddr is a valid 4-byte address within the subnet and not already assigned,
+// it is preferred. Returns false if the pool is exhausted.
+func (sv *Server) allocAddr(reqAddr []byte) ([4]byte, bool) {
+ if len(reqAddr) == 4 {
+ candidate := netip.AddrFrom4([4]byte(reqAddr))
+ if sv.prefix.Contains(candidate) && candidate.As4() != sv.siaddr && !sv.isAddrAssigned(candidate) {
+ return candidate.As4(), true
+ }
+ }
+ sv.nextAddr = sv.nextAddr.Next()
+ if !sv.prefix.Contains(sv.nextAddr) {
+ return [4]byte{}, false
+ }
+ // Reject broadcast address (all host bits set).
+ a := sv.nextAddr.As4()
+ hostBits := uint(32 - sv.prefix.Bits())
+ hostMask := ^uint32(0) >> (32 - hostBits)
+ if binary.BigEndian.Uint32(a[:])&hostMask == hostMask {
+ return [4]byte{}, false
+ }
+ return a, true
+}
+
+func (sv *Server) isAddrAssigned(addr netip.Addr) bool {
+ a4 := addr.As4()
+ for _, v := range sv.hosts {
+ if v.addr == a4 {
+ return true
+ }
+ }
+ return false
+}
+
func (sv *Server) getClient(clientID [36]byte) (serverEntry, bool) {
entry, ok := sv.hosts[clientID]
return entry, ok
@@ -257,4 +367,4 @@ func getSrcIPPort(ipCarrier []byte) (srcaddr []byte, port uint16, err error) {
}
port = binary.BigEndian.Uint16(ipCarrier[off:]) // TCP and UDP share same port offsets.
return srcaddr, port, nil
-}
+}
\ No newline at end of file
diff --git a/dhcpv4/server_test.go b/dhcpv4/server_test.go
new file mode 100644
index 0000000..cf85bac
--- /dev/null
+++ b/dhcpv4/server_test.go
@@ -0,0 +1,358 @@
+package dhcpv4
+
+import (
+ "net/netip"
+ "testing"
+)
+
+func testServerConfig(svAddr [4]byte) ServerConfig {
+ return ServerConfig{
+ ServerAddr: svAddr,
+ Subnet: netip.PrefixFrom(netip.AddrFrom4(svAddr), 24),
+ }
+}
+
+// TestServerMultipleClients verifies the server can handle multiple clients
+// going through the full DORA flow independently.
+func TestServerMultipleClients(t *testing.T) {
+ svAddr := [4]byte{192, 168, 1, 1}
+ var sv Server
+ sv.Configure(testServerConfig(svAddr))
+
+ const nClients = 3
+ var clients [nClients]Client
+ var bufs [nClients][1024]byte
+
+ for i := range clients {
+ err := clients[i].BeginRequest(uint32(100+i), RequestConfig{
+ ClientHardwareAddr: [6]byte{0, 0, 0, 0, 0, byte(i + 1)},
+ Hostname: "host",
+ ClientID: string([]byte{byte(i + 1)}),
+ })
+ if err != nil {
+ t.Fatalf("client %d BeginRequest: %v", i, err)
+ }
+ }
+
+ // Phase 1: All clients send DISCOVER.
+ for i := range clients {
+ n, err := clients[i].Encapsulate(bufs[i][:], -1, 0)
+ if err != nil {
+ t.Fatalf("client %d discover encapsulate: %v", i, err)
+ }
+ err = sv.Demux(bufs[i][:n], 0)
+ if err != nil {
+ t.Fatalf("client %d discover demux: %v", i, err)
+ }
+ }
+
+ // Route server responses to the correct client by XID (map iteration is non-deterministic).
+ clientByXID := make(map[uint32]int)
+ for i := range clients {
+ clientByXID[uint32(100+i)] = i
+ }
+
+ // Phase 2: Server sends all OFFERs, clients receive.
+ var assignedAddrs [nClients][4]byte
+ for range clients {
+ var buf [1024]byte
+ n, err := sv.Encapsulate(buf[:], -1, 0)
+ if err != nil {
+ t.Fatalf("offer encapsulate: %v", err)
+ } else if n == 0 {
+ t.Fatal("no offer from server")
+ }
+ frm, _ := NewFrame(buf[:n])
+ ci := clientByXID[frm.XID()]
+ assignedAddrs[ci] = *frm.YIAddr()
+ err = clients[ci].Demux(buf[:n], 0)
+ if err != nil {
+ t.Fatalf("client %d offer demux: %v", ci, err)
+ }
+ }
+
+ // Phase 3: All clients send REQUEST.
+ for i := range clients {
+ n, err := clients[i].Encapsulate(bufs[i][:], -1, 0)
+ if err != nil {
+ t.Fatalf("client %d request encapsulate: %v", i, err)
+ } else if n == 0 {
+ t.Fatalf("client %d: no request data", i)
+ }
+ err = sv.Demux(bufs[i][:n], 0)
+ if err != nil {
+ t.Fatalf("client %d request demux: %v", i, err)
+ }
+ }
+
+ // Phase 4: Server sends all ACKs, clients receive.
+ for range clients {
+ var buf [1024]byte
+ n, err := sv.Encapsulate(buf[:], -1, 0)
+ if err != nil {
+ t.Fatalf("ack encapsulate: %v", err)
+ } else if n == 0 {
+ t.Fatal("no ack from server")
+ }
+ frm, _ := NewFrame(buf[:n])
+ ci := clientByXID[frm.XID()]
+ err = clients[ci].Demux(buf[:n], 0)
+ if err != nil {
+ t.Fatalf("client %d ack demux: %v", ci, err)
+ }
+ if clients[ci].State() != StateBound {
+ t.Errorf("client %d: want StateBound, got %s", ci, clients[ci].State())
+ }
+ }
+
+ // All assigned addresses must be unique.
+ for i := 0; i < nClients; i++ {
+ for j := i + 1; j < nClients; j++ {
+ if assignedAddrs[i] == assignedAddrs[j] {
+ t.Errorf("clients %d and %d got same address %v", i, j, assignedAddrs[i])
+ }
+ }
+ }
+}
+
+// TestServerSequentialAddressAllocation verifies that the server allocates
+// addresses sequentially starting from serverAddr+1.
+func TestServerSequentialAddressAllocation(t *testing.T) {
+ svAddr := [4]byte{192, 168, 1, 1}
+ var sv Server
+ sv.Configure(testServerConfig(svAddr))
+
+ // Build raw DISCOVER frames for two clients.
+ for i := byte(0); i < 2; i++ {
+ var buf [512]byte
+ frm, _ := NewFrame(buf[:])
+ frm.ClearHeader()
+ frm.SetOp(OpRequest)
+ frm.SetHardware(1, 6, 0)
+ frm.SetXID(uint32(200 + i))
+ frm.SetSecs(1)
+ copy(frm.CHAddrAs6()[:], []byte{0, 0, 0, 0, 0, 10 + i})
+ frm.SetMagicCookie(MagicCookie)
+ opts := buf[OptionsOffset:]
+ n := writeOption(opts, OptMessageType, byte(MsgDiscover))
+ n += writeOption(opts[n:], OptClientIdentifier, 10+i)
+ opts[n] = byte(OptEnd)
+ n++
+
+ err := sv.Demux(buf[:OptionsOffset+n], 0)
+ if err != nil {
+ t.Fatalf("discover %d: %v", i, err)
+ }
+ }
+
+ // Encapsulate both OFFERs and verify addresses are in expected range.
+ var seen [2][4]byte
+ for i := byte(0); i < 2; i++ {
+ var buf [512]byte
+ n, err := sv.Encapsulate(buf[:], -1, 0)
+ if err != nil {
+ t.Fatalf("offer %d encapsulate: %v", i, err)
+ } else if n == 0 {
+ t.Fatalf("offer %d: no data", i)
+ }
+ frm, _ := NewFrame(buf[:n])
+ seen[i] = *frm.YIAddr()
+ if seen[i][0] != 192 || seen[i][1] != 168 || seen[i][2] != 1 {
+ t.Errorf("offer %d: unexpected subnet in %v", i, seen[i])
+ }
+ if seen[i][3] != 2 && seen[i][3] != 3 {
+ t.Errorf("offer %d: expected .2 or .3, got .%d", i, seen[i][3])
+ }
+ }
+ if seen[0] == seen[1] {
+ t.Errorf("both offers got same address %v", seen[0])
+ }
+}
+
+// TestServerOfferContainsOptions verifies that server OFFER responses
+// contain the expected DHCP options from the ServerConfig.
+func TestServerOfferContainsOptions(t *testing.T) {
+ svAddr := [4]byte{192, 168, 1, 1}
+ gwAddr := [4]byte{192, 168, 1, 254}
+ dnsAddr := [4]byte{8, 8, 8, 8}
+ var sv Server
+ sv.Configure(ServerConfig{
+ ServerAddr: svAddr,
+ Gateway: gwAddr,
+ DNS: dnsAddr,
+ Subnet: netip.PrefixFrom(netip.AddrFrom4(svAddr), 24),
+ LeaseSeconds: 7200,
+ })
+
+ var cl Client
+ err := cl.BeginRequest(500, RequestConfig{
+ ClientHardwareAddr: [6]byte{0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var buf [1024]byte
+ n, err := cl.Encapsulate(buf[:], -1, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = sv.Demux(buf[:n], 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ n, err = sv.Encapsulate(buf[:], -1, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ frm, _ := NewFrame(buf[:n])
+
+ var gotServerID, gotRouter, gotSubnet, gotDNS [4]byte
+ var gotLease, gotRenew, gotRebind uint32
+ var foundServerID, foundRouter, foundSubnet, foundDNS, foundLease bool
+ frm.ForEachOption(func(_ int, opt OptNum, data []byte) error {
+ switch opt {
+ case OptServerIdentification:
+ if len(data) == 4 {
+ foundServerID = true
+ copy(gotServerID[:], data)
+ }
+ case OptRouter:
+ if len(data) == 4 {
+ foundRouter = true
+ copy(gotRouter[:], data)
+ }
+ case OptSubnetMask:
+ if len(data) == 4 {
+ foundSubnet = true
+ copy(gotSubnet[:], data)
+ }
+ case OptDNSServers:
+ if len(data) == 4 {
+ foundDNS = true
+ copy(gotDNS[:], data)
+ }
+ case OptIPAddressLeaseTime:
+ if len(data) == 4 {
+ foundLease = true
+ gotLease = maybeU32(data)
+ }
+ case OptRenewTimeValue:
+ gotRenew = maybeU32(data)
+ case OptRebindingTimeValue:
+ gotRebind = maybeU32(data)
+ }
+ return nil
+ })
+ if !foundServerID || gotServerID != svAddr {
+ t.Errorf("server ID: found=%v got=%v want=%v", foundServerID, gotServerID, svAddr)
+ }
+ if !foundRouter || gotRouter != gwAddr {
+ t.Errorf("router: found=%v got=%v want=%v", foundRouter, gotRouter, gwAddr)
+ }
+ if !foundSubnet || gotSubnet != [4]byte{255, 255, 255, 0} {
+ t.Errorf("subnet: found=%v got=%v want=255.255.255.0", foundSubnet, gotSubnet)
+ }
+ if !foundDNS || gotDNS != dnsAddr {
+ t.Errorf("DNS: found=%v got=%v want=%v", foundDNS, gotDNS, dnsAddr)
+ }
+ if !foundLease || gotLease != 7200 {
+ t.Errorf("lease: found=%v got=%v want=7200", foundLease, gotLease)
+ }
+ if gotRenew != 3600 {
+ t.Errorf("renew T1: got %d want 3600", gotRenew)
+ }
+ if gotRebind != 6300 {
+ t.Errorf("rebind T2: got %d want 6300", gotRebind)
+ }
+}
+
+// TestServerEncapsulateNoPending verifies Encapsulate returns 0 bytes
+// when there are no pending responses.
+func TestServerEncapsulateNoPending(t *testing.T) {
+ var sv Server
+ sv.Configure(testServerConfig([4]byte{192, 168, 1, 1}))
+
+ var buf [512]byte
+ n, err := sv.Encapsulate(buf[:], -1, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n != 0 {
+ t.Errorf("expected 0 bytes from empty server, got %d", n)
+ }
+}
+
+// TestServerConfigValidation verifies that Configure rejects invalid configurations.
+func TestServerConfigValidation(t *testing.T) {
+ var sv Server
+ err := sv.Configure(ServerConfig{
+ ServerAddr: [4]byte{192, 168, 1, 1},
+ })
+ if err == nil {
+ t.Error("expected error for zero subnet")
+ }
+ err = sv.Configure(ServerConfig{
+ ServerAddr: [4]byte{10, 0, 0, 1},
+ Subnet: netip.PrefixFrom(netip.AddrFrom4([4]byte{192, 168, 1, 0}), 24),
+ })
+ if err == nil {
+ t.Error("expected error for server address outside subnet")
+ }
+}
+
+// TestServerRediscover verifies that a client that was previously bound
+// can send a fresh DISCOVER and get re-served.
+func TestServerRediscover(t *testing.T) {
+ svAddr := [4]byte{192, 168, 1, 1}
+ var sv Server
+ sv.Configure(testServerConfig(svAddr))
+
+ // First DORA cycle.
+ var cl Client
+ cl.BeginRequest(1, RequestConfig{
+ ClientHardwareAddr: [6]byte{1, 2, 3, 4, 5, 6},
+ ClientID: "rediscover-client",
+ })
+ var buf [1024]byte
+ n, _ := cl.Encapsulate(buf[:], -1, 0)
+ sv.Demux(buf[:n], 0)
+ n, _ = sv.Encapsulate(buf[:], -1, 0)
+ cl.Demux(buf[:n], 0)
+ n, _ = cl.Encapsulate(buf[:], -1, 0)
+ sv.Demux(buf[:n], 0)
+ n, _ = sv.Encapsulate(buf[:], -1, 0)
+ cl.Demux(buf[:n], 0)
+ if cl.State() != StateBound {
+ t.Fatalf("first DORA: want StateBound, got %s", cl.State())
+ }
+
+ // Client reboots and sends fresh DISCOVER.
+ cl.Reset()
+ cl.BeginRequest(2, RequestConfig{
+ ClientHardwareAddr: [6]byte{1, 2, 3, 4, 5, 6},
+ ClientID: "rediscover-client",
+ })
+ n, _ = cl.Encapsulate(buf[:], -1, 0)
+ err := sv.Demux(buf[:n], 0)
+ if err != nil {
+ t.Fatalf("rediscover demux: %v", err)
+ }
+ n, _ = sv.Encapsulate(buf[:], -1, 0)
+ if n == 0 {
+ t.Fatal("no offer after rediscover")
+ }
+ err = cl.Demux(buf[:n], 0)
+ if err != nil {
+ t.Fatalf("rediscover offer demux: %v", err)
+ }
+ // Complete the second DORA.
+ n, _ = cl.Encapsulate(buf[:], -1, 0)
+ sv.Demux(buf[:n], 0)
+ n, _ = sv.Encapsulate(buf[:], -1, 0)
+ cl.Demux(buf[:n], 0)
+ if cl.State() != StateBound {
+ t.Errorf("second DORA: want StateBound, got %s", cl.State())
+ }
+}
\ No newline at end of file
diff --git a/ethernet/frame.go b/ethernet/frame.go
index 22e6bb0..392855e 100644
--- a/ethernet/frame.go
+++ b/ethernet/frame.go
@@ -79,6 +79,24 @@ func (efrm Frame) SetEtherType(v Type) {
// VLANTag returns the VLAN tag field following the TPID=0x8100. See [VLANTag]. Call [Frame.ValidateSize] to ensure this function does not panic.
func (efrm Frame) VLANTag() VLANTag { return VLANTag(binary.BigEndian.Uint16(efrm.buf[14:16])) }
+// SetVLAN sets following 3 fields:
+// - 12:14 ethernet frame type set to constant [TypeVLAN].
+// - 14:16 set to VLANTag argument value vt
+// - 16:18 set to the VLAN ether type vlanType.
+func (efrm Frame) SetVLAN(tag VLANTag, vlanType Type) {
+ efrm.SetEtherType(TypeVLAN)
+ binary.BigEndian.PutUint16(efrm.buf[14:16], uint16(tag))
+ binary.BigEndian.PutUint16(efrm.buf[16:18], uint16(vlanType))
+}
+
+// VLAN returns fields 14:16 and 16:18. Does not check field 12:14 for correctness.
+// VLAN panics if length is insufficient.
+func (efrm Frame) VLAN() (VLANTag, Type) {
+ vt := binary.BigEndian.Uint16(efrm.buf[14:16])
+ et := binary.BigEndian.Uint16(efrm.buf[16:18])
+ return VLANTag(vt), Type(et)
+}
+
// SetVLANTag sets the VLAN tag field of the Ethernet Header. See [VLANTag]. Call [Frame.ValidateSize] to ensure this function does not panic.
func (efrm Frame) SetVLANTag(vt VLANTag) { binary.BigEndian.PutUint16(efrm.buf[14:16], uint16(vt)) }
diff --git a/examples/httpserver/index.html b/examples/httpserver/index.html
new file mode 100644
index 0000000..9d5cfd3
--- /dev/null
+++ b/examples/httpserver/index.html
@@ -0,0 +1,92 @@
+
+
+
+ Arbitrary Waveform Generator Control
+
+
+
+
+ AD9850 DDS Control
+
+
+
+ Whose woods these are I think I know.
+ His house is in the village though;
+ He will not see me stopping here
+ To watch his woods fill up with snow.
+
+ My little horse must think it queer
+ To stop without a farmhouse near
+ Between the woods and frozen lake
+ The darkest evening of the year.
+
+ He gives his harness bells a shake
+ To ask if there is some mistake.
+ The only other sound’s the sweep
+ Of easy wind and downy flake.
+
+ The woods are lovely, dark and deep,
+ But I have promises to keep,
+ And miles to go before I sleep,
+
+
\ No newline at end of file
diff --git a/examples/httpserver/main.go b/examples/httpserver/main.go
new file mode 100644
index 0000000..3ad6fe0
--- /dev/null
+++ b/examples/httpserver/main.go
@@ -0,0 +1,356 @@
+//go:build !tinygo && linux
+
+package main
+
+import (
+ "bytes"
+ _ "embed"
+ "encoding/hex"
+ "errors"
+ "flag"
+ "fmt"
+ "log"
+ "log/slog"
+ "math"
+ "net"
+ "net/netip"
+ "os"
+ "runtime"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/soypat/lneto"
+ "github.com/soypat/lneto/ethernet"
+ "github.com/soypat/lneto/http/httpraw"
+ "github.com/soypat/lneto/internal"
+ "github.com/soypat/lneto/internal/ltesto"
+ "github.com/soypat/lneto/internet/pcap"
+ "github.com/soypat/lneto/tcp"
+ "github.com/soypat/lneto/x/xnet"
+)
+
+//go:embed index.html
+var indexhtml string
+
+var softRand = time.Now().Unix()
+
+func main() {
+ err := run()
+ if err != nil {
+ fmt.Println(err)
+ os.Exit(1)
+ }
+ fmt.Println("success")
+}
+
+func run() (err error) {
+ var (
+ flagInterface = "tap0"
+ flagUseHTTP = false
+ flagNoPcap = false
+ flagPort = 80
+ )
+ flag.StringVar(&flagInterface, "i", flagInterface, "Interface to use. Either tap* or the name of an existing interface to bridge to.")
+ flag.BoolVar(&flagUseHTTP, "ihttp", flagUseHTTP, "Use HTTP tap interface.")
+ flag.BoolVar(&flagNoPcap, "nopcap", flagNoPcap, "Disable pcap logging.")
+ flag.IntVar(&flagPort, "port", flagPort, "Port to listen on.")
+ flag.Usage = func() {
+ fmt.Fprintf(os.Stderr, "httpserver is a minimal HTTP server using the lneto networking stack.\n")
+ flag.PrintDefaults()
+ }
+ flag.Parse()
+ fmt.Println("softrand", softRand)
+ var iface ltesto.Interface
+ if flagUseHTTP {
+ iface = ltesto.NewHTTPTapClient("http://127.0.0.1:7070")
+ } else {
+ if strings.HasPrefix(flagInterface, "tap") {
+ tap, err := internal.NewTap(flagInterface, netip.MustParsePrefix("192.168.1.1/24"))
+ if err != nil {
+ return err
+ }
+ iface = tap
+ } else {
+ bridge, err := internal.NewBridge(flagInterface)
+ if err != nil {
+ return err
+ }
+ err = bridge.SetReadTimeout(5 * time.Millisecond)
+ if err != nil {
+ return err
+ }
+ iface = bridge
+ }
+ }
+ defer iface.Close()
+
+ nicHW, err := iface.HardwareAddress6()
+ if err != nil {
+ return err
+ }
+ mtu, err := iface.MTU()
+ if err != nil {
+ return err
+ }
+ nicAddr, err := iface.IPMask()
+ if err != nil {
+ return err
+ }
+ fmt.Println("NIC hardware address:", net.HardwareAddr(nicHW[:]).String(), "mtu:", mtu, "addr:", nicAddr.String())
+
+ var stack xnet.StackAsync
+ err = stack.Reset(xnet.StackConfig{
+ Hostname: "httpserver",
+ RandSeed: softRand,
+ HardwareAddress: nicHW,
+ MTU: uint16(mtu),
+ MaxTCPConns: 1000,
+ })
+ if err != nil {
+ return err
+ }
+
+ // Loop goroutine handles packet encapsulation/decapsulation.
+ go func() {
+ lastAction := time.Now()
+ buf := make([]byte, math.MaxUint16)
+ var cap pcap.PacketBreakdown
+ var frames []pcap.Frame
+ pf := pcap.Formatter{
+ FilterClasses: []pcap.FieldClass{pcap.FieldClassFlags, pcap.FieldClassOperation, pcap.FieldClassDst, pcap.FieldClassSrc, pcap.FieldClassAddress, pcap.FieldClassTimestamp},
+ }
+ var pfbuf []byte
+ logFrames := func(context string, pkt []byte) error {
+ if flagNoPcap {
+ return nil
+ }
+ frames, err = cap.CaptureEthernet(frames[:0], pkt, 0)
+ if err != nil {
+ pkt := hex.EncodeToString(pkt)
+ slog.Error(err.Error(), slog.Any("pkt", pkt))
+ return err
+ }
+ pfbuf = fmt.Appendf(pfbuf[:0], "%-3s %3d", context, len(pkt))
+ pfbuf = append(pfbuf, ' ', '[')
+ pfbuf, err = pf.FormatFrames(pfbuf, frames, pkt)
+ pfbuf = bytes.ReplaceAll(pfbuf, stack.Addr().AppendTo(nil), []byte("us"))
+ pfbuf = bytes.ReplaceAll(pfbuf, ethernet.AppendAddr(nil, stack.HardwareAddress()), []byte("us"))
+ pfbuf = append(pfbuf, ']', '\n')
+ if err != nil {
+ return err
+ }
+ _, err = os.Stdout.Write(pfbuf)
+ return err
+ }
+ for {
+ nwrite, err := stack.Encapsulate(buf[:], -1, 0)
+ if err != nil {
+ log.Println("ERR:ENCAPSULATE", err)
+ } else if nwrite > 0 {
+ err = logFrames("OUT", buf[:nwrite])
+ if err != nil {
+ log.Println("ERR:OUTLOG", err)
+ }
+ n, err := iface.Write(buf[:nwrite])
+ if err != nil {
+ log.Fatal("goroutine encapsulate:", err)
+ } else if n != nwrite {
+ log.Fatalf("mismatch written bytes %d!=%d", nwrite, n)
+ }
+ }
+
+ clear(buf[:nwrite])
+ ready, err := tryPoll(iface, 5*time.Millisecond)
+ if err != nil {
+ log.Fatal("goroutine poll:", err)
+ }
+ if !ready {
+ continue
+ }
+ nread, err := iface.Read(buf)
+ if err != nil {
+ log.Fatal("goroutine read:", err)
+ } else if nread > 0 {
+ err = stack.Demux(buf[:nread], 0)
+ if !errors.Is(err, lneto.ErrPacketDrop) {
+ err = logFrames("IN", buf[:nread])
+ if err != nil {
+ log.Println("ERR:INLOG", err)
+ }
+ }
+ }
+ clear(buf[:nread])
+ if nread == 0 && nwrite == 0 && time.Since(lastAction) > 4*time.Second {
+ time.Sleep(5 * time.Millisecond)
+ } else {
+ lastAction = time.Now()
+ runtime.Gosched()
+ }
+ }
+ }()
+
+ rstack := stack.StackRetrying(5 * time.Millisecond)
+
+ const (
+ dhcpTimeout = 6 * time.Second
+ dhcpRetries = 2
+ )
+ timeDHCP := timer("DHCP request completed")
+ results, err := rstack.DoDHCPv4([4]byte{192, 168, 1, 96}, dhcpTimeout, dhcpRetries)
+ if err != nil {
+ return fmt.Errorf("DHCP failed: %w", err)
+ }
+ timeDHCP()
+ err = stack.AssimilateDHCPResults(results)
+ if err != nil {
+ return fmt.Errorf("assimilating DHCP results: %w", err)
+ }
+ slog.Info("dhcp-complete", slog.String("assignedIP", results.AssignedAddr.String()), slog.String("routerIP", results.Router.String()))
+
+ const (
+ arpTimeout = 2 * time.Second
+ arpRetries = 2
+ )
+ timeResolveRouterHW := timer("Router ARP resolution")
+ routerHw, err := rstack.DoResolveHardwareAddress6(results.Router, arpTimeout, arpRetries)
+ if err != nil {
+ return fmt.Errorf("ARP resolution of router failed: %w", err)
+ }
+ timeResolveRouterHW()
+ stack.SetGateway6(routerHw)
+
+ svPort := uint16(flagPort)
+ fmt.Printf("Listening on %s:%d\n", stack.Addr().String(), svPort)
+
+ // Serve connections in a loop.
+ for {
+ var conn tcp.Conn
+ conn.Configure(tcp.ConnConfig{
+ RxBuf: make([]byte, mtu),
+ TxBuf: make([]byte, mtu),
+ TxPacketQueueSize: 3,
+ })
+ err = stack.ListenTCP(&conn, svPort)
+ if err != nil {
+ return fmt.Errorf("listen TCP: %w", err)
+ }
+ fmt.Println("waiting for connection...")
+
+ // Wait for TCP handshake to complete.
+ deadline := time.Now().Add(60 * time.Second)
+ for conn.State() != tcp.StateEstablished {
+ if time.Now().After(deadline) {
+ conn.Abort()
+ fmt.Println("listen timeout, retrying...")
+ break
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ if conn.State() != tcp.StateEstablished {
+ continue
+ }
+ fmt.Println("connection established from", net.IP(conn.RemoteAddr()).String())
+ go func() {
+ err = handleConnection(&conn)
+ if err != nil {
+ fmt.Println("handle error:", err)
+ }
+ }()
+ }
+}
+
+func handleConnection(conn *tcp.Conn) error {
+ conn.SetDeadline(time.Now().Add(10 * time.Second))
+
+ // Read HTTP request.
+ var hdr httpraw.Header
+ var needMore bool = true
+ for needMore {
+ _, err := hdr.ReadFromLimited(conn, 1024)
+ if err != nil {
+ return fmt.Errorf("reading request: %w", err)
+ }
+ const asResponse = false
+ needMore, err = hdr.TryParse(asResponse)
+ if err != nil && !needMore {
+ return fmt.Errorf("parsing request: %w", err)
+ }
+ }
+
+ method := string(hdr.Method())
+ uri := string(hdr.RequestURI())
+ fmt.Printf("< %s %s\n", method, uri)
+
+ // Build response body.
+
+ // Build HTTP response.
+ var resp httpraw.Header
+ resp.SetProtocol("HTTP/1.1")
+ resp.SetStatus("200", "OK")
+ resp.Set("Content-Type", "text/html")
+ resp.Set("Content-Length", strconv.Itoa(len(indexhtml)))
+ resp.Set("Connection", "close")
+ response, err := resp.AppendResponse(nil)
+ if err != nil {
+ return fmt.Errorf("building response: %w", err)
+ }
+ response = append(response, indexhtml...)
+
+ // Send response.
+ _, err = conn.Write(response)
+ if err != nil {
+ return fmt.Errorf("writing response: %w", err)
+ }
+ err = conn.Flush()
+ if err != nil {
+ return fmt.Errorf("flushing response: %w", err)
+ }
+ fmt.Printf("> %d bytes sent\n", len(response))
+
+ conn.Close()
+ return nil
+}
+
+func clear(buf []byte) {
+ for i := range buf {
+ buf[i] = 0
+ }
+}
+
+func timer(context string) func() {
+ start := time.Now()
+ return func() {
+ elapsed := time.Since(start)
+ fmt.Printf("[%s] %s\n", prettyDuration(elapsed), context)
+ }
+}
+
+func prettyDuration(d time.Duration) string {
+ switch {
+ case d < time.Microsecond:
+ // Print as is.
+ case d < time.Millisecond:
+ d = d.Round(time.Microsecond)
+ case d < time.Second:
+ d = d.Round(time.Millisecond)
+ case d < 10*time.Second:
+ d = d.Round(100 * time.Millisecond)
+ case d < 10*time.Minute:
+ d = d.Round(1000 * time.Millisecond)
+ case d < time.Hour:
+ d = d.Round(time.Minute)
+ }
+ return d.String()
+}
+
+func tryPoll(iface ltesto.Interface, poll time.Duration) (dataMayBeReady bool, _ error) {
+ if poller, ok := iface.(interface {
+ Poll(time.Duration) (bool, error)
+ }); ok {
+ ready, err := poller.Poll(poll)
+ return ready, err
+ }
+ dataMayBeReady = true
+ return dataMayBeReady, nil
+}
diff --git a/examples/httptap/dhcp.go b/examples/httptap/dhcp.go
new file mode 100644
index 0000000..b1da3a5
--- /dev/null
+++ b/examples/httptap/dhcp.go
@@ -0,0 +1,350 @@
+//go:build !tinygo && linux
+
+package main
+
+import (
+ "encoding/binary"
+ "fmt"
+ "net/netip"
+ "sync"
+
+ "github.com/soypat/lneto"
+ "github.com/soypat/lneto/arp"
+ "github.com/soypat/lneto/dhcpv4"
+ "github.com/soypat/lneto/ethernet"
+ "github.com/soypat/lneto/internal/ltesto"
+ "github.com/soypat/lneto/ipv4"
+ "github.com/soypat/lneto/udp"
+)
+
+const (
+ sizeEthernet = 14
+ sizeIPv4 = 20
+ sizeUDP = 8
+ sizeARPv4 = 28
+ sizeDHCPMin = dhcpv4.OptionsOffset + 256 // Minimum space for DHCP frame + options.
+)
+
+// dhcpInterceptor wraps an ltesto.Interface and intercepts DHCP traffic.
+// DHCP packets from the client are handled by an embedded dhcpv4.Server
+// and never forwarded to the real interface. DHCP responses are returned
+// on subsequent Read calls. All non-DHCP traffic passes through unchanged.
+type dhcpInterceptor struct {
+ mu sync.Mutex
+ inner ltesto.Interface
+ sv dhcpv4.Server
+
+ // Server network identity.
+ svMAC [6]byte
+ svIP [4]byte
+
+ // Pending ARP reply.
+ arpReply [sizeEthernet + sizeARPv4]byte
+ arpReady bool
+
+ // ARP cache for gateway forwarding: maps IP→MAC from snooped traffic.
+ arpCache [8]arpEntry
+}
+
+type arpEntry struct {
+ mac [6]byte
+ ip [4]byte
+}
+
+// newDHCPInterceptor creates a dhcpInterceptor that wraps iface and serves
+// DHCP from the given server address and subnet.
+func newDHCPInterceptor(iface ltesto.Interface, svIP [4]byte, svMAC [6]byte, subnet netip.Prefix) (*dhcpInterceptor, error) {
+ d := &dhcpInterceptor{
+ inner: iface,
+ svMAC: svMAC,
+ svIP: svIP,
+ }
+ err := d.sv.Configure(dhcpv4.ServerConfig{
+ ServerAddr: svIP,
+ Gateway: svIP,
+ DNS: [4]byte{8, 8, 8, 8},
+ Subnet: subnet,
+ })
+ return d, err
+}
+
+func (d *dhcpInterceptor) Write(b []byte) (int, error) {
+ if d.isARPRequestForUs(b) {
+ d.mu.Lock()
+ d.buildARPReply(b)
+ d.mu.Unlock()
+ return len(b), nil
+ }
+ if isDHCPRequest(b) {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ dhcpOff := dhcpOffset(b)
+ if dhcpOff < 0 {
+ return d.inner.Write(b) // Malformed, pass through.
+ }
+ err := d.sv.Demux(b, dhcpOff)
+ if err != nil {
+ return 0, fmt.Errorf("dhcp server demux: %w", err)
+ }
+ return len(b), nil // Consumed by DHCP server, don't forward.
+ }
+ d.rewriteEthernetDst(b)
+ return d.inner.Write(b)
+}
+
+func (d *dhcpInterceptor) Read(b []byte) (int, error) {
+ d.mu.Lock()
+ if d.arpReady {
+ n := copy(b, d.arpReply[:])
+ d.arpReady = false
+ d.mu.Unlock()
+ return n, nil
+ }
+ n, err := d.buildDHCPResponse(b)
+ d.mu.Unlock()
+ if n > 0 {
+ return n, nil
+ }
+ if err != nil {
+ return 0, err
+ }
+ n, err = d.inner.Read(b)
+ if n >= sizeEthernet+sizeARPv4 && binary.BigEndian.Uint16(b[12:14]) == uint16(ethernet.TypeARP) {
+ d.snoopARP(b[:n])
+ }
+ return n, err
+}
+
+// buildDHCPResponse tries to get a pending DHCP response from the server and
+// wraps it in Ethernet + IPv4 + UDP headers. Returns 0 if no response pending.
+// Caller must hold d.mu.
+func (d *dhcpInterceptor) buildDHCPResponse(buf []byte) (int, error) {
+ if len(buf) < sizeEthernet+sizeIPv4+sizeUDP+sizeDHCPMin {
+ return 0, nil
+ }
+ // Build Ethernet+IPv4 headers since DHCP server may use hardware/ip addr.
+ efrm, _ := ethernet.NewFrame(buf)
+ *efrm.DestinationHardwareAddr() = [6]byte{}
+ *efrm.SourceHardwareAddr() = d.svMAC
+ efrm.SetEtherType(ethernet.TypeIPv4)
+
+ ifrm, _ := ipv4.NewFrame(buf[sizeEthernet:])
+ ifrm.SetVersionAndIHL(4, 5)
+ ifrm.SetToS(0)
+ ifrm.SetFlags(ipv4.FlagDontFragment)
+ ifrm.SetTTL(64)
+ ifrm.SetProtocol(lneto.IPProtoUDP)
+ *ifrm.SourceAddr() = d.svIP
+ *ifrm.DestinationAddr() = [4]byte{}
+
+ // Build UDP header.
+ ufrm, _ := udp.NewFrame(buf[sizeEthernet+sizeIPv4:])
+ ufrm.SetSourcePort(dhcpv4.DefaultServerPort)
+ ufrm.SetDestinationPort(dhcpv4.DefaultClientPort)
+
+ dhcpStart := sizeEthernet + sizeIPv4 + sizeUDP
+ // Ask DHCP server to fill in the payload. offsetToIP=sizeEthernet so
+ // the server can set IP src/dst via internal.SetIPAddrs.
+ dhcpLen, err := d.sv.Encapsulate(buf, sizeEthernet, dhcpStart)
+ if err != nil {
+ return 0, fmt.Errorf("dhcp server encapsulate: %w", err)
+ }
+ if dhcpLen == 0 {
+ return 0, nil // No pending response.
+ }
+
+ totalIPLen := uint16(sizeIPv4 + sizeUDP + dhcpLen)
+ udpLen := uint16(sizeUDP + dhcpLen)
+ totalFrameLen := sizeEthernet + int(totalIPLen)
+
+ // DHCP responses must be broadcast since the client doesn't have
+ // an IP configured yet and the stack would drop unicast packets.
+ *efrm.DestinationHardwareAddr() = ethernet.BroadcastAddr()
+ *ifrm.DestinationAddr() = [4]byte{255, 255, 255, 255}
+ ifrm.SetTotalLength(totalIPLen)
+ ufrm.SetLength(udpLen)
+ // Source and destination IPs already set by dhcpv4.Server.Encapsulate.
+ ifrm.SetCRC(0)
+ prelimCRC := ifrm.CalculateHeaderCRC()
+ ifrm.SetID(^(^prelimCRC * 37))
+ ifrm.SetCRC(0)
+ ifrm.SetCRC(ifrm.CalculateHeaderCRC())
+ // Compute UDP checksum (required, the lneto stack validates it on Demux).
+ ufrm.SetCRC(0)
+ var udpCRC lneto.CRC791
+ ifrm.CRCWriteUDPPseudo(&udpCRC, udpLen)
+ ufrm.SetCRC(lneto.NeverZeroSum(udpCRC.PayloadSum16(ufrm.RawData()[:udpLen])))
+ return totalFrameLen, nil
+}
+
+// isDHCPRequest checks if a raw Ethernet frame is a DHCP request (client → server).
+// Checks: EtherType=IPv4, IP proto=UDP, UDP dst port=67, DHCP magic cookie.
+func isDHCPRequest(b []byte) bool {
+ if len(b) < sizeEthernet+sizeIPv4+sizeUDP+dhcpv4.OptionsOffset {
+ return false
+ }
+ // EtherType must be IPv4.
+ if binary.BigEndian.Uint16(b[12:14]) != uint16(ethernet.TypeIPv4) {
+ return false
+ }
+ // IP header length (IHL) to find UDP header.
+ ihl := int(b[sizeEthernet]&0xf) * 4
+ if ihl < sizeIPv4 {
+ return false
+ }
+ ipStart := sizeEthernet
+ // IP protocol must be UDP.
+ if b[ipStart+9] != uint8(lneto.IPProtoUDP) {
+ return false
+ }
+ udpStart := ipStart + ihl
+ if len(b) < udpStart+sizeUDP {
+ return false
+ }
+ // UDP destination port must be DHCP server port (67).
+ dstPort := binary.BigEndian.Uint16(b[udpStart+2 : udpStart+4])
+ if dstPort != dhcpv4.DefaultServerPort {
+ return false
+ }
+ // Verify DHCP magic cookie.
+ dhcpStart := udpStart + sizeUDP
+ return dhcpv4.PayloadIsDHCPv4(b[dhcpStart:])
+}
+
+// dhcpOffset returns the byte offset where the DHCP payload begins
+// within a raw Ethernet frame. Returns -1 if the frame is too short.
+func dhcpOffset(b []byte) int {
+ if len(b) < sizeEthernet+sizeIPv4+sizeUDP {
+ return -1
+ }
+ ihl := int(b[sizeEthernet]&0xf) * 4
+ off := sizeEthernet + ihl + sizeUDP
+ if off > len(b) {
+ return -1
+ }
+ return off
+}
+
+// isARPRequestForUs checks if b is an ARP request targeting d.svIP.
+func (d *dhcpInterceptor) isARPRequestForUs(b []byte) bool {
+ if len(b) < sizeEthernet+sizeARPv4 {
+ return false
+ }
+ if binary.BigEndian.Uint16(b[12:14]) != uint16(ethernet.TypeARP) {
+ return false
+ }
+ afrm, err := arp.NewFrame(b[sizeEthernet:])
+ if err != nil {
+ return false
+ }
+ if afrm.Operation() != arp.OpRequest {
+ return false
+ }
+ _, targetIP := afrm.Target4()
+ return *targetIP == d.svIP
+}
+
+// buildARPReply constructs an ARP reply in d.arpReply from the given ARP request.
+// Caller must hold d.mu.
+func (d *dhcpInterceptor) buildARPReply(request []byte) {
+ reqARP, _ := arp.NewFrame(request[sizeEthernet:])
+ senderHW, senderIP := reqARP.Sender4()
+
+ buf := d.arpReply[:]
+ // Ethernet header: reply to requester.
+ efrm, _ := ethernet.NewFrame(buf)
+ *efrm.DestinationHardwareAddr() = *senderHW
+ *efrm.SourceHardwareAddr() = d.svMAC
+ efrm.SetEtherType(ethernet.TypeARP)
+
+ // ARP reply.
+ afrm, _ := arp.NewFrame(buf[sizeEthernet:])
+ afrm.SetHardware(1, 6) // Ethernet, 6-byte addresses
+ afrm.SetProtocol(ethernet.TypeIPv4, 4) // IPv4, 4-byte addresses
+ afrm.SetOperation(arp.OpReply)
+ replySndrHW, replySndrIP := afrm.Sender4()
+ *replySndrHW = d.svMAC
+ *replySndrIP = d.svIP
+ replyTgtHW, replyTgtIP := afrm.Target4()
+ *replyTgtHW = *senderHW
+ *replyTgtIP = *senderIP
+
+ d.arpReady = true
+}
+
+// snoopARP records the sender's IP→MAC mapping from an ARP packet.
+func (d *dhcpInterceptor) snoopARP(b []byte) {
+ afrm, err := arp.NewFrame(b[sizeEthernet:])
+ if err != nil {
+ return
+ }
+ senderHW, senderIP := afrm.Sender4()
+ if *senderIP == ([4]byte{}) {
+ return
+ }
+ d.mu.Lock()
+ d.arpCacheStore(*senderHW, *senderIP)
+ d.mu.Unlock()
+}
+
+// rewriteEthernetDst rewrites the Ethernet destination MAC for frames
+// addressed to the gateway (svMAC). Acts as a basic IP forwarder by
+// looking up the destination IP in the ARP cache.
+func (d *dhcpInterceptor) rewriteEthernetDst(b []byte) {
+ if len(b) < sizeEthernet+sizeIPv4 {
+ return
+ }
+ // Only rewrite frames addressed to the gateway.
+ if *(*[6]byte)(b[0:6]) != d.svMAC {
+ return
+ }
+ // Only rewrite IPv4 frames.
+ if binary.BigEndian.Uint16(b[12:14]) != uint16(ethernet.TypeIPv4) {
+ return
+ }
+ dstIP := *(*[4]byte)(b[sizeEthernet+16 : sizeEthernet+20])
+ d.mu.Lock()
+ mac, ok := d.arpCacheLookup(dstIP)
+ d.mu.Unlock()
+ if ok {
+ copy(b[0:6], mac[:])
+ }
+}
+
+// arpCacheLookup finds a MAC for the given IP. Caller must hold d.mu.
+func (d *dhcpInterceptor) arpCacheLookup(ip [4]byte) ([6]byte, bool) {
+ for i := range d.arpCache {
+ if d.arpCache[i].ip == ip {
+ return d.arpCache[i].mac, true
+ }
+ }
+ return [6]byte{}, false
+}
+
+// arpCacheStore adds or updates an IP→MAC entry. Caller must hold d.mu.
+func (d *dhcpInterceptor) arpCacheStore(mac [6]byte, ip [4]byte) {
+ // Update existing entry.
+ for i := range d.arpCache {
+ if d.arpCache[i].ip == ip {
+ d.arpCache[i].mac = mac
+ return
+ }
+ }
+ // Find empty slot.
+ for i := range d.arpCache {
+ if d.arpCache[i].ip == ([4]byte{}) {
+ d.arpCache[i] = arpEntry{mac: mac, ip: ip}
+ return
+ }
+ }
+ // Evict first entry.
+ copy(d.arpCache[:], d.arpCache[1:])
+ d.arpCache[len(d.arpCache)-1] = arpEntry{mac: mac, ip: ip}
+}
+
+// Delegate remaining ltesto.Interface methods to inner.
+
+func (d *dhcpInterceptor) Close() error { return d.inner.Close() }
+func (d *dhcpInterceptor) HardwareAddress6() ([6]byte, error) { return d.inner.HardwareAddress6() }
+func (d *dhcpInterceptor) MTU() (int, error) { return d.inner.MTU() }
+func (d *dhcpInterceptor) IPMask() (netip.Prefix, error) { return d.inner.IPMask() }
diff --git a/examples/httptap/main.go b/examples/httptap/main.go
index 5c38af2..f3c59ae 100644
--- a/examples/httptap/main.go
+++ b/examples/httptap/main.go
@@ -9,6 +9,7 @@ import (
"net"
"net/http"
"net/netip"
+ "strconv"
"strings"
"time"
@@ -61,6 +62,21 @@ func run() error {
iface = br
}
+ // Wrap interface with DHCP server interceptor.
+ hwaddr, err := iface.HardwareAddress6()
+ if err != nil {
+ return err
+ }
+ ipMask, err := iface.IPMask()
+ if err != nil {
+ return err
+ }
+ svIP := ipMask.Addr().As4()
+ iface, err = newDHCPInterceptor(iface, svIP, hwaddr, ipMask.Masked())
+ if err != nil {
+ return fmt.Errorf("DHCP interceptor: %w", err)
+ }
+
sv, err := ltesto.NewHTTPTapServer(iface, flagMinMTU, flagPacketQueueSize, flagPacketQueueSize)
if err != nil {
return err
@@ -72,6 +88,10 @@ func run() error {
}
var pfbuf []byte
sv.OnTransfer(func(channel int, pkt []byte) {
+ channelstr := "OS"
+ if channel != 0 {
+ channelstr = strconv.Itoa(channel) // Will not allocate for values 99 and under (stdlib).
+ }
captime := time.Now()
frames, err := cap.CaptureEthernet(nil, pkt, 0)
if err == nil {
@@ -79,15 +99,15 @@ func run() error {
pfbuf, err = pf.FormatFrames(pfbuf, frames, pkt)
pfbuf = append(pfbuf, ']')
if err != nil {
- fmt.Printf("%d %s !err:%s\n", channel, captime.Format("15:04:05.000"), err)
+ fmt.Printf("%-2s %s !err:%s\n", channelstr, captime.Format("15:04:05.000"), err)
} else {
- fmt.Printf("%d %s %s\n", channel, captime.Format("15:04:05.000"), pfbuf)
+ fmt.Printf("%-2s %s %s\n", channelstr, captime.Format("15:04:05.000"), pfbuf)
}
} else {
- fmt.Println(channel, captime.Format("15:04:05.000"), "cap ERR", frames, err.Error())
+ fmt.Printf("%-2s %s %s %v %s\n", channelstr, captime.Format("15:04:05.000"), "cap ERR", frames, err.Error())
}
})
- hwaddr, err := sv.HardwareAddress6()
+ hwaddr, err = sv.HardwareAddress6()
if err != nil {
return err
}
diff --git a/internet/pcap/capture.go b/internet/pcap/capture.go
index a7e0c6b..185f197 100644
--- a/internet/pcap/capture.go
+++ b/internet/pcap/capture.go
@@ -244,10 +244,12 @@ func (pc *PacketBreakdown) CaptureIPv4(dst []Frame, pkt []byte, bitOffset int) (
println("BAD UDP")
return dst, pc.vld.ErrPop()
}
- frameLen := ufrm.Length()
- ifrm4.CRCWriteUDPPseudo(&crc, frameLen)
- if crc.PayloadSum16(ufrm.RawData()[:frameLen]) != 0 {
- protoErrs = append(protoErrs, lneto.ErrBadCRC)
+ if ufrm.CRC() != 0 {
+ frameLen := ufrm.Length()
+ ifrm4.CRCWriteUDPPseudo(&crc, frameLen)
+ if crc.PayloadSum16(ufrm.RawData()[:frameLen]) != 0 {
+ protoErrs = append(protoErrs, lneto.ErrBadCRC)
+ }
}
}
case lneto.IPProtoICMP:
diff --git a/internet/pcap/format.go b/internet/pcap/format.go
index 35e036b..ea0d5c2 100644
--- a/internet/pcap/format.go
+++ b/internet/pcap/format.go
@@ -80,6 +80,16 @@ func (f *Formatter) FormatFrame(dst []byte, frm Frame, pkt []byte) (_ []byte, er
return dst, err
}
}
+ if len(frm.Errors) > 0 {
+ dst = append(dst, " errs=("...)
+ for i, err := range frm.Errors {
+ if i != 0 {
+ dst = append(dst, ';')
+ }
+ dst = append(dst, err.Error()...)
+ }
+ dst = append(dst, ')')
+ }
return dst, nil
}
diff --git a/ipv4/definitions.go b/ipv4/definitions.go
index 4097e58..d0bf309 100644
--- a/ipv4/definitions.go
+++ b/ipv4/definitions.go
@@ -7,6 +7,14 @@ const (
// ToS represents the Traffic Class (a.k.a Type of Service). It is 8 bits long. 6 MSB are Differentiated Services; 2 LSB are Explicit Congenstion Notification.
type ToS uint8
+// NewToS returns a [ToS] from an Explicit Congestion Notification value and a Differentiated Services Field value.
+func NewToS(ECN, DS uint8) ToS {
+ if ECN > 0b11 || DS > 0b11_1111 {
+ panic("invalid ECN/DS value")
+ }
+ return ToS(ECN | (DS << 2))
+}
+
// DS returns the top 6 bits of the IPv4 ToS holding the Differentiated Services field
// which is used to classify packets.
func (tos ToS) DS() uint8 { return uint8(tos) >> 2 }
@@ -17,22 +25,46 @@ func (tos ToS) ECN() uint8 { return uint8(tos & 0b11) }
// Flags holds fragmentation field data of an IPv4 header. It is 16 bits long.
type Flags uint16
+const (
+ flagIsEvilPos = 13
+ flagDontFragPos = 14
+ flagMoreFragPos = 15
+ FlagOffsetMask = (1 << flagIsEvilPos) - 1
+ flagIsEvil Flags = 1 << flagIsEvilPos
+ FlagDontFragment Flags = 1 << flagDontFragPos
+ FlagMoreFragments Flags = 1 << flagMoreFragPos
+)
+
+func NewFlags(fragOffset uint16, dontFrag, moreFrag bool) Flags {
+ if fragOffset > FlagOffsetMask {
+ panic("invalid NewFlags arg")
+ }
+ return Flags(fragOffset) | Flags(b2u8(dontFrag))<