Directory: src/net/ (13 files, ~2700 lines). Modular protocol stack with socket abstraction, NIC drivers, and ARP cache.
| File | Lines | Responsibility |
|---|---|---|
types.rs |
~80 | MacAddr, Ipv4Addr, SocketAddrV4, TcpState, SocketType, SocketDirection |
ethernet.rs |
~120 | Ethernet frame header (14 bytes: dst MAC, src MAC, ethertype), FCS computation, build_ethernet_frame() |
arp.rs |
~250 | ARP protocol, 64-entry cache with 300s timeout, static entries, request/reply, arp_resolve() |
ipv4.rs |
~300 | IPv4 header (20 bytes), header checksum, packet building, classification (loopback/multicast/link-local) |
icmp.rs |
~100 | ICMP echo request/reply, checksum, build_port_unreachable() |
udp.rs |
~150 | UDP header (8 bytes), pseudo-header checksum, build_udp_datagram() |
tcp.rs |
~800 | TCP state machine (11 states), connection lifecycle, send/recv buffers (16 KB sliding window), segment building |
socket.rs |
~700 | SocketManager, bind/connect/listen/send/recv/close, KWait wake, udp_dispatch(), tcp_dispatch() |
nic.rs |
~240 | NetworkInterface trait (9 methods), NicRegistry (4 slots), IP/next-hop/gateway, Route local-routing decision, vendor/device/description per NIC. NICs registered via NEM bridge |
loopback.rs |
~120 | Loopback interface (127.0.0.0/8, #484): TX queue, LoopbackInterface, loopback_send/pump, \Device\Loopback |
net_bridge.rs |
~100 | NEM network bridge: hst_register_network_device, wraps NEM callbacks as NetworkInterface |
counters.rs |
~150 | Global + per-interface counters (RX/TX packets/bytes/errors per NIC slot + loopback), exposed via NetStats (28); periodic dump every 1000 ticks |
tests.rs |
~300 | 18+ integration tests |
11 states defined in TcpState:
pub enum TcpState {
Closed, // Initial state, no connection
Listen, // Waiting for SYN
SynSent, // SYN sent, awaiting SYN+ACK
SynRcvd, // SYN received, SYN+ACK sent
Established, // Connection established, data transfer
FinWait1, // FIN sent, awaiting ACK
FinWait2, // FIN ACKed, awaiting peer's FIN
CloseWait, // FIN received, waiting for app to close
Closing, // Both FINs sent, awaiting final ACK
LastAck, // FIN received, FIN sent, awaiting FIN ACK
TimeWait, // All done, waiting for delayed segments
}Connection lifecycle: build_tcp_segment(), send_tcp_segment(), tcp_send_syn_ack(), tcp_handle_ack(). Send/recv buffers use a 16 KB sliding window.
Data path (#486): tcp_send() buffers; tcp_tick() (from net_tick) flushes
up to MSS per connection, tracks send_base/send_next, advances the window
on ACKs, retransmits on a 200 ms TSC-based RTO (8 retries max), and drives
FIN (fin_pending when closing with unsent data). SynReceived connections
also retransmit their SYN-ACK and time out with the same bound. Incoming data
is stored in the connection buffer, ACKed, and wakes SocketRead waiters;
incoming FINs park in CloseWait. tcp_dispatch() never holds SOCKET_MANAGER
across protocol actions (nested locks deadlocked SYN handling).
network_poll_all() drains a bounded batch of frames while holding
NIC_REGISTRY, then dispatches them after releasing it. The dispatcher
must not hold the registry: the TCP send path re-locks it (nic_next_hop,
nic_default_id, nic_send_packet), and spin::Mutex is non-reentrant, so
holding it deadlocked netpump on the first TCP segment received over a real
NIC. net_handle_incoming_packet(nic_id, frame) takes the interface id (not a
borrowed interface) and emits replies through net_send_frame(), which routes
loopback frames through the loopback queue.
Received IPv4 datagrams are bounded by the header's total length (so Ethernet
padding never enters a transport checksum), the IPv4 header checksum is
verified, and TCP/UDP checksums are verified (tcp_checksum_valid,
udp_checksum_valid; a zero UDP checksum means "not computed" and is
accepted). Invalid frames are counted as RX errors and dropped.
socket_free calls tcp_release_connection: a connection that still has
buffered/unacknowledged data is not freed immediately — it is marked
reap_pending and tcp_tick transmits the remaining payload and FIN before
reaping it once Closed. Freed immediately only when already drained. This
prevents losing the last write when an application closes right after send.
There is no TIME_WAIT / delayed-ACK handling yet.
ObSetInfoClass::SocketListen/SocketClose carry no payload and are accepted
with an empty buffer by the generic ob_set_info validation. ob_wait on a
socket blocks on WaitReason::SocketConnect / SocketAccept / SocketRead
(socket_wait_reason), and the handshake, inbound data, FIN and connection
failure all wake the corresponding waiters. Before this, ob_wait returned
NoSys for sockets, so Ring 3 callers had to busy-poll socket_recv.
Covered by the kernel tests net_tcp_loopback_e2e (handshake + both
directions + orderly close over loopback), net_tcp_nic_path_syn_synack_no_deadlock
(real dispatch path through a scripted NIC), net_tcp_synack_retransmit_bounded,
net_tcp_nic_bad_checksum_dropped, net_tcp_close_flushes_buffered_data, and
the checksum-validation tests. Off-subnet TCP uses nic_next_hop (gateway-
aware); #315 is resolved. The Ring 3 utilities netsrv/netcli (real client
and echo server: connect/accept, finite timeouts, clean close) and the
tcptest self-test validate the public socket ABI. In VirtualBox NAT (e1000,
guest 10.0.2.15) netcli 10.0.2.2 5555 reached a host echo server and a host
client reached netsrv 6666 through a NAT port-forward.
ObType::Socket = 18. Sockets are Ob objects managed through the standard handle/namespace system.
| Class ID | Name | Returns |
|---|---|---|
| 17 | SocketInfo |
Socket state, type, local/remote addresses |
| 18 | SocketAddr |
Bound address and port |
| 19 | TcpStatus |
TCP connection state |
| 20 | NicInfo |
NIC metadata: MAC, IP, link status, vendor/device PCI IDs, driver name, description |
| 23 | SocketRecv |
Received data (non-blocking read) |
| 43 | SocketAccept |
Accept a pending connection from a listener (4-byte fd out) |
| Class ID | Name | Effect |
|---|---|---|
| 18 | SocketConnect |
Initiate TCP connection or set UDP remote |
| 19 | SocketBind |
Bind socket to local address/port |
| 20 | SocketListen |
Start listening for TCP connections |
| 21 | SocketSend |
Send data on connected socket |
| 22 | SocketClose |
Close socket (FIN or RST) |
| 27 | SetNicIp |
Set NIC IP address from userspace |
| 28 | SetNicGateway |
Set NIC default gateway (0.0.0.0 = unset) |
Socket creation via ob_create with attrs encoding: bits 0-7 = socket type (1=TCP, 2=UDP, 3=Raw), bits 8-23 = port for well-known bindings.
Socket objects support KWait (kernel wait) for blocking I/O operations:
| Wait Reason | Triggers On |
|---|---|
SocketRead |
Data arrives in socket receive buffer |
SocketConnect |
TCP handshake completes (SYN+ACK received) |
SocketAccept |
New connection arrives on listening socket |
Blocking syscalls suspend the calling thread via KWait and wake when the corresponding event fires.
A listening socket stays in Listen; each incoming pure SYN (SYN without
ACK) creates a distinct socket + TCP connection, sends the SYN-ACK and
queues the connection on the listener (MAX_ACCEPT_BACKLOG = 8; excess SYNs
are dropped, the listener stays usable). ob_query_info(SocketAccept) pops an
established connection, wraps it in a new Socket Ob object/handle and
returns the new fd (EAGAIN when none is ready). This allows sequential — and,
up to the backlog and MAX_SOCKETS, concurrent — connections without
cross-talk. A SYN-ACK is never routed to a listener; it reaches the client
connection.
ob_wait on a socket blocks on SocketAccept (listener), SocketConnect
(handshake in progress) or SocketRead (data/EOF). A non-zero ob_wait
timeout arms a TSC deadline serviced by the RX pump (net_tick →
kwait::deadline_tick), which wakes the waiter when it expires. The timeout
wake carries no distinct status (ob_wait returns 0); the caller must
re-check readiness. Indefinite waits (timeout = 0) are unchanged.
SocketSend returns the number of bytes buffered; a full send buffer returns
EIO (retry after draining). SocketRecv returns bytes copied, or EAGAIN
when none are buffered. There is no distinct EOF: SocketRecv returns
EAGAIN both when no data is available and when the peer has closed; the
connection state (TcpStatus) distinguishes CloseWait/Closed. Zero-length
sends/recvs are rejected as EINVAL.
NIC (e1000)
-> poll_packet() -> 2048 byte buffer
-> ethernet::EthernetHeader parse (dst MAC, src MAC, ethertype)
|
+-> ARP (ethertype 0x0806):
| -> arp_resolve() -> cache lookup (broadcast IP -> broadcast MAC directly)
| -> if request for our IP: build reply
| -> if reply: update cache
|
+-> IPv4 (ethertype 0x0800):
-> Ipv4Header parse (version, IHL, total_len, protocol)
|
+-> ICMP (proto 1):
| -> echo request -> build echo reply -> send
| -> port unreachable -> build and send
|
+-> TCP (proto 6):
| -> tcp_dispatch() -> socket lookup by (src IP, src port, dst IP, dst port)
| -> segment processing (SYN, ACK, FIN, data)
| -> data delivery to socket recv buffer
| -> KWait wake for SocketRead/SocketConnect
|
+-> UDP (proto 17):
-> udp_dispatch() -> socket lookup by (dst port)
-> data delivery to socket recv buffer
-> KWait wake for SocketRead
Virtual interface outside NicRegistry (registering it would change
default_nic_id() and consume a NIC slot). Frames to 127/8 are queued in
loopback.rs and drained through the single dispatch path
net_handle_incoming_packet(), both from network_poll_all() and
synchronously on the send path (so back-to-back send+recv never sees EAGAIN).
Rules: no NIC required (works with 0 NICs), no ARP, no gateway lookup, no new
syscalls (sockets keep using ObType::Socket), \Device\Loopback in the Ob
namespace. Synthetic MAC 02:00:00:00:00:01 (MacAddr::loopback()), never on
the wire. nic_route() classifies destinations as
Route::{Loopback, OnLink, ViaGateway, Unreachable}. ping 127.0.0.1 answers
through the real ICMP dispatch (request → echo reply → notify); RAX 36 reports
a nominal 1 µs since 0 means failure. The interface is also enumerated by the
NicInfo query (sentinel nic_id, read-only) and shown by ipconfig as the
Loopback adapter. Validated in VirtualBox: 5 kernel tests
(net_loopback_*) green plus a Ring-3 ping 127.0.0.1 check in cmdtest.nxe.
- Create
\Device\Tcpand\Device\Udpnamespace entries in Ob - Probe PCI bus for Intel e1000 devices (vendor 0x8086, devices 0x100E/0x1004/0x100F/0x10D3)
- Initialize found NIC: map MMIO BAR, allocate RX/TX ring buffers, configure descriptors
- Set initial IP configuration (default: 0.0.0.0 — no kernel DHCP)
- Userspace DHCP service (
dhcpd.nxe) configures IP asynchronously - ARP cache begins accepting entries
NIC drivers run as NEM modules (drivers/e1000). The kernel NEM bridge
(src/drivers/nem/loader/net_bridge.rs) registers a bootstrap NetworkInterface at
driver-load time, so it cannot call back into a driver that is still loading.
Instead:
- The e1000 driver exports
driver_link_up(), which readsSTATUS.LU(with a link-ready flag cached duringinit_e1000_hw) and publishes the state throughhst_set_network_link_state. Since the NEM bridge has no link-query slot, the driver also re-publishes from its RXpollentry (throttled: 1 MMIO read per 512 polls, #529); unload publishes down viadriver_fini. - The Ring-0
netpumpkernel thread callsnic::nic_poll_link_state()once pernetwork_poll_all()and stores the result inNicSlot::link_up; theNicInfoquery and thenetapplierlink-up edge detection read that cached value. - A NIC is never advertised as link-up before its driver reports a real link.
The e1000 is polled, not interrupt-driven: RX is drained by network_poll_all()
from the Ring-0 netpump worker and from the sys_yield syscall path. (netd
is now the Ring 3 network service — see userland.md — not the RX pump.)
Issue #339 established that this
polling (not driver link/ring bring-up) is the relevant variable for the first
DHCP DISCOVER.
Manages up to 4 NIC slots. Each slot holds a Box<dyn NetworkInterface>.
pub struct NicRegistry {
pub nics: [Option<NicSlot>; 4],
}DHCP is NOT handled by the kernel. The protocol runs entirely in userspace as the dhcpd.nxe service, following the NT-like design principle where protocol stacks live in user mode and the kernel only provides transport primitives.
dhcpd.nxe (Ring 3 user service)
│
├─ Creates UDP socket (ObType::Socket = 18)
│ ├─ bind(0.0.0.0:68) ← DHCP client port
│ └─ connect(255.255.255.255:67) ← DHCP server port (broadcast)
│
├─ Performs DORA sequence:
│ ├─ DISCOVER → wait → OFFER
│ ├─ REQUEST → wait → ACK
│ └─ On ACK: publish IP/mask/gw/DNS to the Registry
│ (the netapplier service applies it to the NIC)
│
├─ Supervises the lease (#316): unicast renew at server T1 (default 50%),
│ broadcast rebind at server T2 (default 87.5%), DORA restart on NAK/expiry
├─ Falls back to APIPA (169.254.1.1) if DHCP fails
└─ Publishes IP configuration to the Registry (never applies it directly)
Kernel (Ring 0) provides:
├─ NIC access (e1000 kernel stub or NEM driver)
├─ Ethernet / ARP / IPv4 / UDP protocol processing
├─ Socket abstraction with recv/send via Ob syscalls
└─ No DHCP protocol logic — removed in v0.48.8+
- DISCOVER: Broadcast UDP packet with DHCPDISCOVER message type
- OFFER: Received on bound UDP socket (port 68), parsed for offered IP and options
- REQUEST: Unicast or broadcast DHCPREQUEST with offered server ID
- ACK: Final acknowledgment; the lease is published to the Registry and the
netapplierservice applies it to the NIC (SetNicIp) —dhcpdnever configures the NIC directly
After binding, dhcpd.nxe enters a loop that:
- Tracks elapsed time via
sys_yield()batches (approximating seconds) - At 50% of lease time, sends unicast DHCPREQUEST to the serving DHCP server
- On ACK: lease is extended, timer resets
- On failure: retries up to
MAX_RETRIES, then restarts DORA
The previous kernel-based DHCP implementation had fundamental architectural problems:
build_dhcp_packet()usedVec(heap allocation) from timer IRQ contextnic_send_packet()acquiredNIC_REGISTRY.lock()(spinlock) from IRQ context, risking deadlockdhcp_tick()in the idle loop never ran because user threads were alwaysReady- Result: DHCP never progressed, and the DHCP client always fell back to APIPA
Moving DHCP to userspace resolves all these issues:
- All memory allocation happens in process context (safe)
- No spinlocks held in IRQ context
- Socket operations are fully preemptible
- Clean separation of concerns: kernel transports packets, user mode runs protocols
dhcpd.nxe stores network configuration in:
\Registry\Machine\System\CurrentControlSet\Services\Network\Interfaces\0
DHCPEnabled = 1 (DWORD)
IPAddress = <assigned IP> (DWORD, big-endian byte order)
SubnetMask = <subnet mask> (DWORD)
DHCPBound = 1/0 (DWORD, 1 when DHCP lease is active)
DHCPServer = <server IP> (DWORD)
dhcpd only publishes here; the resident netapplier service reads this key
and applies IP/mask/gateway to the runtime NIC (single applier; see #365).
Change notification (#364). The applier no longer spin-polls the key. A
config writer that applies a NIC address (ObSetInfoClass::SetNicIp/SetNicGateway,
i.e. libnet::config::apply) makes the kernel signal EVENT_NET_CONFIG_CHANGED
(18) via kwait; the applier blocks on an Event object
(ob_create(Event, attrs = 18) + ob_wait) and re-reads the Registry on wake.
ob_wait carries a finite timeout (2 s) so polling remains a fallback if the
notification object is unavailable (degradation).
DNS resolution is not a kernel subsystem: like DHCP, it runs in user mode on
top of the kernel's UDP sockets. The protocol is implemented once in the
libdns crate and used by libnet (and therefore by nslookup and ping).
| Layer | Path | Responsibility |
|---|---|---|
| Wire format + resolver core | libdns/src/lib.rs |
Query/response encoding, A/CNAME parsing, hostname validation, DnsError, retries/server selection |
| Userland transport + config | libnet/src/dns.rs |
UDP via net.nxl, Registry servers (DnsServer, DnsServer2/3), bounded cache, resolve() / resolve_with_server() |
| Tools | userbin/nslookup, userbin/ping |
Consume the shared resolver API |
The kernel-side DNS parser/cache (src/net/dns.rs) has been retired (#312):
it was only reachable from dns_tick()/tests and duplicated libdns. DNS now
has a single implementation — libdns (wire format) + libnet::dns (transport)
— and the kernel only polls the NIC and runs ARP/TCP ticks.
See docs/networking/userland.md §3.4–3.5 for the API, configuration and error
taxonomy.
Making DNS work on a real network required three fixes in the kernel/userland send path (both were latent because DHCP only uses broadcast):
- Source address. A UDP socket bound to
0.0.0.0(the DNS resolver binds port 0) had an unspecified source address in the IP header.socket_send_udp_rawnow fills it from the NIC, except for broadcast datagrams (DHCP DISCOVER keeps0.0.0.0). - Next-hop routing. IPv4 next-hop is centralized in
nic_next_hop(dest)(NicRegistry::next_hop_ip): on-link → the destination; off-link → the NIC's configured gateway; off-link without a gateway (0.0.0.0, unset) → no valid next hop and the send fails cleanly (it never ARPs the remote destination). The gateway is a real per-NIC property (NicSlot::gateway), set byObSetInfoClass::SetNicGateway(28) from DHCP Option 3 / static config, and persisted inNetwork\Interfaces\0\Gateway.socket_send_udp_rawandicmp_pingboth usenic_next_hop. There is no implicit10.0.1.1fallback. Bound-interface selection (#317): UDP (socket_send_udp_raw_on) and TCP (tcp_nic_for→resolve_src_ip/tcp_mac_for/send_tcp_segment) now select the source address/MAC, the next hop (nic_next_hop_for(nic_id, dst)) and the ARP resolution (arp_resolve_on(nic_id, target)) from the socket's boundnic_id(ObSetInfoClass::SocketBindNic= 29), falling back to the default NIC when unbound.icmp_pinghas no socket and keeps using the default NIC. - Datagram dispatch.
udp_dispatchmatched connected sockets only byremote.port; it now matches the socket's local port (dst_port) and, when set, the remote port, so a previous socket cannot capture replies addressed to a different local port.
Because netpump (not the syscall) drives RX polling and there is no blocking
socket wait syscall, the userland resolver waits for the reply with an RDTSC time
budget rather than a bare sys_yield loop (which returns immediately when no
other thread is runnable).
NeoDOS uses QEMU's user-mode networking (SLiRP) by default, which requires no root/sudo privileges.
-netdev user,id=net0,net=10.0.1.0/24,dhcpstart=10.0.1.80,host=10.0.1.1 \
-device e1000,netdev=net0QEMU's built-in DHCP server assigns IPs in the 10.0.1.x range starting at 10.0.1.80. The host is accessible at 10.0.1.1 and provides NAT to the outside world.
neodev run --net tapTAP networking requires:
/dev/net/tunaccess (requiresCAP_NET_ADMINor root)- A preconfigured
tap0interface on the host
Useful when the guest needs direct network access (e.g., DHCP from a real LAN server).
| File | Path |
|---|---|
| types.rs | src/net/types.rs |
| ethernet.rs | src/net/ethernet.rs |
| arp.rs | src/net/arp.rs |
| ipv4.rs | src/net/ipv4.rs |
| icmp.rs | src/net/icmp.rs |
| udp.rs | src/net/udp.rs |
| tcp.rs | src/net/tcp.rs |
| socket.rs | src/net/socket.rs |
| nic.rs | src/net/nic.rs |
| e1000 | drivers/e1000/ (NEM) |
| tests/ | src/net/tests/ |
User-mode DHCP service: userbin/dhcpd/src/main.rs
When a packet needs to be sent to an IP address (e.g., ICMP ping), the ARP resolution follows this sequence:
-
Cache lookup:
arp_lookup(target_ip)checks the 64-entry ARP cache.- Broadcast IP → broadcast MAC (no ARP needed).
- Cache hit → returns MAC immediately.
- Cache miss → proceeds to send ARP request.
-
ARP Request: An Ethernet frame is built with:
- Destination MAC:
FF:FF:FF:FF:FF:FF(broadcast) - Ethertype:
ETH_TYPE_ARP (0x0806) - ARP operation:
ARP_OP_REQUEST (1) - Sender MAC/IP: NIC's MAC and IP
- Target IP: the IP to resolve
- Destination MAC:
-
Wait for Reply: The sender polls
network_poll_all()in a tight loop with RDTSC-based timeout (500ms). Each poll checks for received packets via the e1000 RX ring. -
ARP Reply Reception:
net_handle_incoming_packet()processes incoming Ethernet frames:- If ethertype is
ETH_TYPE_ARP (0x0806)and operation isARP_OP_REPLY (2):- Extracts sender IP and MAC from the ARP payload
- Calls
arp_insert()to add/update the cache entry
arp_insert()updates existing entries or inserts new ones (LRU eviction at 64 entries)
- If ethertype is
-
Resolution Complete: The polling loop in
icmp_ping()detects the cache entry and returns the resolved MAC address. -
ICMP Echo Request: The resolved MAC is used as the destination in the Ethernet frame, and the ICMP echo request is sent.
-
ICMP Echo Reply Wait: Polls
LAST_PING_REPLYatomic with RDTSC-based timeout (configurable, default 1s).
- The
icmp_ping()function inicmp.rscontains its own ARP resolution logic (inline inor_elseclosure) rather than callingarp_resolve(). This is becauseicmp_ping()needs to block waiting for the reply, whilearp_resolve()is fire-and-forget. - The ARP request Ethernet frame must use
ETH_TYPE_ARP (0x0806)as the ethertype. UsingETH_TYPE_IPV4 (0x0800)will cause the receiver to reject the frame. - The ARP cache is protected by
spin::Mutex. Entries expire after 300 seconds (checked every 10 ticks). - QEMU user-mode (SLiRP) networking supports ARP. VirtualBox Bridge Mode forwards ARP to the physical network.
- Only one ARP resolution can be in-flight at a time (no pending queue for concurrent requests).
- The e1000 RX descriptor status is read without explicit
read_volatile, relying on the compiler generating fresh memory reads via&mut selfreference. - Gratuitous ARP sent automatically on IP address change via
nic_set_ip().
17+ tests in src/net/tests/ covering: MAC address formatting, IPv4 header checksum, ARP cache operations, TCP state machine transitions, TCP full lifecycle (listen -> connect -> established -> close), ICMP echo request/reply, socket creation/lookup/bind/connect, UDP header construction, NIC registry add/remove.
Kernel DHCP tests removed (DHCP is now a userspace service).