diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-25 12:40:53 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-25 12:46:51 -0300 |
| commit | f5f8068fac59b4f16046c2022c2fc7c7e447ef4c (patch) | |
| tree | 2731a3ed4e51cae09e184e25778eded5fc37d1f5 /src/net/ip.zig | |
| download | esp32p4-f5f8068fac59b4f16046c2022c2fc7c7e447ef4c.tar.gz esp32p4-f5f8068fac59b4f16046c2022c2fc7c7e447ef4c.zip | |
zig-p4: pure-Zig ESP32-P4 toolchain
build.zig generates the linker script and drives Zig's own LLD; tools/image.zig
turns the ELF into a flashable image and tools/{rom,serial}.zig speak the mask
ROM loader over the UART. No CMake, ninja, idf.py, esptool, or external linker.
src/soc.zig is a comptime register model over ESP-IDF's own *_reg.h headers;
src/hal/ adds peripheral sequences; src/io/ implements std.Io for the chip;
src/oracle/ diffs this HAL against ESP-IDF's on the die.
Diffstat (limited to 'src/net/ip.zig')
| -rw-r--r-- | src/net/ip.zig | 2903 |
1 files changed, 2903 insertions, 0 deletions
diff --git a/src/net/ip.zig b/src/net/ip.zig new file mode 100644 index 0000000..2cc4301 --- /dev/null +++ b/src/net/ip.zig @@ -0,0 +1,2903 @@ +//! A minimal IPv4 stack: Ethernet, ARP, IPv4, ICMP echo, UDP, a DHCP client, one TCP client and +//! HTTP GET. This is what replaces lwIP. +//! +//! Two functions drive everything and nothing else touches the outside world: +//! +//! stack.onFrame(frame) a received Ethernet frame, headers and all +//! stack.tick(now_ms) time passing, in milliseconds, from anywhere the caller likes +//! +//! and one callback carries frames out (`send`, supplied to `init`). There is no `std.Io`, no +//! allocator, no clock read and no hidden thread. That is not minimalism for its own sake: it is +//! what makes the whole stack testable on the host, where a "network" is a test function that hands +//! `onFrame` bytes it wrote by hand and reads back whatever `send` was given. Every protocol +//! behaviour in this file is exercised that way in `ip_test.zig`, including retransmission - which +//! on a real timer would be a flaky test and here is two calls to `tick`. +//! +//! **Everything is statically sized.** `Stack` is one struct with fixed buffers inside it; there is +//! no allocator, not even a `FixedBufferAllocator`, because nothing here has a lifetime that an +//! arena would model better than a field does. `@sizeOf(Stack)` is asserted at compile time below +//! (`footprint`) so the number cannot drift silently against the ~128 KB of L2MEM the image has. +//! +//! Wire formats are matched field by field against the lwIP this replaces, and every one is cited: +//! ESP-IDF v6.0.2 carries lwIP at `components/lwip/lwip/src/`, and the packed structs in +//! `include/lwip/prot/*.h` are the reference for offsets, and its `.c` files for behaviour. Where +//! this stack deliberately differs from lwIP, the comment says so and why. +//! +//! ## What this is not +//! +//! * No IPv6, no TCP listen/accept, no IP fragmentation or reassembly, no TLS. Out of scope. +//! * No congestion control. TCP sends at most one unacknowledged segment at a time (see `Tcp`), +//! which is a fixed window of one and therefore needs no congestion window, no slow start and +//! no fast recovery. It is also slow. For an HTTP GET of a few kilobytes over Wi-Fi that is the +//! right trade; for bulk transfer it is not, and nothing here pretends otherwise. +//! * No VLAN tags, no 802.1Q. A tagged frame is dropped as an unknown ethertype. +//! * No transfer coding but `identity` and `chunked`. Anything else - `gzip`, `deflate`, a +//! stack of them - is rejected with `error.UnsupportedTransferEncoding` rather than handed +//! back with its framing bytes still in it. +//! * DNS resolves A records only, one query at a time, over the UDP already here, with no +//! cache. `resolve` follows `httpGet`'s protocol exactly: start, `error.WouldBlock`, the +//! caller drives `tick`/`onFrame`, call again with the same name. + +const std = @import("std"); +const assert = std.debug.assert; + +// =============================================================================== sizing +// +// The whole static footprint, in one place. Every buffer in `Stack` is one of these. + +/// Ethernet MTU: the largest IP datagram that fits in one frame. +pub const mtu: usize = 1500; +/// Ethernet header: 6 destination + 6 source + 2 ethertype. lwIP `prot/ethernet.h:89` +/// (`SIZEOF_ETH_HDR`, with its optional `ETH_PAD_SIZE` at zero). +pub const eth_hlen: usize = 14; +/// The largest frame this stack will build or accept, excluding the FCS the MAC appends. +pub const frame_max: usize = eth_hlen + mtu; + +/// ARP cache entries. Four is enough for the gateway, one peer, and two strangers, which is the +/// whole population a single-connection HTTP client on a home /24 ever needs to address. +pub const arp_cache_len: usize = 4; + +/// Bytes of HTTP response head (status line plus headers) that may be buffered while waiting for +/// the blank line. Exceeding this fails the request rather than truncating silently. +/// +/// 2048, raised from 1024 against a measurement rather than a guess. A real response from the site +/// this stack was pointed at - Cloudflare in front of GitHub Pages - carries **1043 bytes** of head: +/// 26 header lines, of which `Report-To` alone is 254 bytes and `Nel`, `X-Fastly-Request-ID`, +/// `X-GitHub-Request-Id` and `alt-svc` are another 200 between them. At 1024 the request failed with +/// `HttpHeadersTooLong` after the body had already been negotiated, 19 bytes short. +/// +/// Modern CDN responses simply have large heads, and 1 KB is not a realistic ceiling for one. 2 KB +/// leaves about a kilobyte of margin over the measured case; the failure remains a named error +/// rather than truncation, so a head that exceeds even this is still diagnosable rather than silently +/// wrong. +pub const http_head_max: usize = 2048; + +/// Bytes of chunked *framing* - one chunk's extension parameters, or the whole trailer section - +/// tolerated before the response is failed. Framing is skipped rather than stored, so this bounds +/// work and not memory: without it a peer that streams `;a=b` forever, or trailer lines forever, +/// is a request that never ends and never errors. 512 is generous; a real trailer section is one +/// or two short lines. +pub const http_framing_max: usize = 512; + +/// The longest host name `resolve` will encode into a DNS question, in dotted text. RFC 1035 2.3.4 +/// allows 255; this stack holds the encoded question in `Stack` for the duration of the query, and +/// 64 covers every name a device that fetches one URL will ever ask for. A longer one is +/// `error.NameTooLong`, never a silently truncated question. +pub const dns_name_max: usize = 64; + +/// The encoded question that `dns_name_max` produces. Encoding turns `a.b` into `1a1b0`: one +/// length byte per label plus the root label, which for a name with no trailing dot is exactly +/// two bytes more than the text. RFC 1035 4.1.2. +pub const dns_qname_max: usize = dns_name_max + 2; + +/// Bytes of outbound TCP payload held for retransmission. This is sized for one HTTP request line +/// plus headers; there is no streaming send, so it is also the hard limit on request size. +pub const tcp_tx_max: usize = 512; + +/// The receive window this stack advertises, in bytes, when it has that much room to consume into. +/// One MSS: a peer that fills the window gets a segment acknowledged before it may send another. +pub const tcp_window: u16 = 1460; + +/// TCP MSS offered in the SYN. 1500 - 20 (IP) - 20 (TCP). +pub const tcp_mss: u16 = 1460; + +/// RFC 1122 4.2.2.6: a peer that sends no MSS option is assumed to accept 536. +pub const tcp_default_mss: u16 = 536; + +/// Initial retransmission timeout. RFC 6298 2.1 specifies 1 s for a connection with no RTT sample, +/// and this stack never takes an RTT sample - see `Tcp.rto_ms`. +pub const tcp_rto_initial_ms: u32 = 1000; +/// Retransmission timeout ceiling. RFC 6298 5.7 allows any value at or above 60 s; 16 s is chosen +/// against a device whose whole reason to exist is one short request. +pub const tcp_rto_max_ms: u32 = 16_000; +/// Retransmissions of the same segment before the connection is abandoned with `error.TimedOut`. +/// With the backoff above that is 1+2+4+8+16+16 = 47 s of trying. +pub const tcp_max_retries: u8 = 6; +/// TIME_WAIT duration. RFC 793 says 2*MSL, conventionally 240 s. Two seconds is what this uses: +/// holding a connection block for four minutes on a part with 128 KB of RAM to protect a +/// port number that this stack increments on every connect is the wrong trade. The risk it drops is +/// a late duplicate segment from the *previous* incarnation of the same 4-tuple being accepted into +/// a new one, and incrementing the local port already makes a repeat 4-tuple require 16,384 +/// connections first. +pub const tcp_time_wait_ms: u32 = 2000; +/// How long a half-closed connection waits for the peer's FIN before the block is released. RFC +/// 793 has no such timer and a connection may legitimately sit in FIN-WAIT-2 forever; Linux uses +/// 60 s for the same reason this uses 10 s - a peer that has our FIN and never answers is a peer +/// that is gone, and the one connection block here is not worth holding for it. +pub const tcp_fin_wait2_ms: u32 = 10_000; + +/// DHCP retransmission backoff, in milliseconds, indexed by attempt. RFC 2131 4.1 asks for +/// randomised exponential backoff starting at 4 s; this starts at 2 s because the first DHCP +/// exchange is on the critical path of every boot, and does not randomise because there is one +/// client on this board and the collision RFC 2131 is avoiding is between many. +const dhcp_backoff_ms = [_]u32{ 2_000, 4_000, 8_000, 16_000, 32_000, 64_000 }; + +/// Minimum length of the BOOTP/DHCP message this stack transmits, in UDP payload bytes. RFC 951 +/// fixed BOOTP messages at 300 bytes and relay agents in the field still expect at least that +/// much; lwIP pads the same way through its fixed-size `struct dhcp_msg` +/// (`prot/dhcp.h:63-91`: 236 + 4 cookie + `DHCP_OPTIONS_LEN` 68 = 308). +const dhcp_min_msg_len: usize = 300; + +/// DNS retransmission backoff, in milliseconds, indexed by attempt. RFC 1035 4.2.1 leaves the +/// timer to the implementation; this is BIND's classic 1 s doubling, and the array length is the +/// try count, so the whole exchange is bounded at 1+2+4 = 7 s and then `error.TimedOut`. The +/// transaction id is *not* redrawn between tries: a slow first answer must still be accepted. +const dns_backoff_ms = [_]u32{ 1_000, 2_000, 4_000 }; + +/// Compression pointers followed while skipping one name (RFC 1035 4.1.4). This is the bound that +/// makes a hostile message terminate: see `dnsSkipName`, where the argument is written out. +const dns_max_jumps: u8 = 16; + +// =============================================================== addresses and enumerations + +pub const Mac = [6]u8; +pub const Ip4 = [4]u8; + +pub const mac_broadcast: Mac = @splat(0xff); +pub const ip_any: Ip4 = @splat(0x00); +pub const ip_broadcast: Ip4 = @splat(0xff); + +/// Ethernet type field values. lwIP `prot/ieee.h:52-85` (`enum lwip_ieee_eth_type`). +pub const EtherType = enum(u16) { + ip4 = 0x0800, + arp = 0x0806, + vlan = 0x8100, + ip6 = 0x86dd, + _, +}; + +/// IP header protocol numbers. lwIP `prot/ip.h:46-50`. +pub const Protocol = enum(u8) { + icmp = 1, + tcp = 6, + udp = 17, + _, +}; + +// =============================================================================== checksum +// +// One implementation for IPv4, ICMP, UDP and TCP. The last two prepend a pseudo-header, which is +// the only difference between them: the arithmetic is identical, so it is written once. + +/// The Internet checksum of RFC 1071: the one's complement of the one's complement sum of the +/// data taken as 16-bit big-endian words, with a zero byte appended if the length is odd. +/// +/// Incremental, because TCP and UDP checksum a pseudo-header, a header and a payload that are +/// three separate buffers and never adjacent in memory. Feeding them in sequence must give the +/// same answer as checksumming the concatenation, which is why `half` exists: a chunk of odd +/// length leaves the high byte of a word owed, and the next chunk's first byte completes it. +/// Getting that wrong is invisible until a payload happens to have odd length, which for HTTP is +/// most of the time. +pub const Checksum = struct { + /// Accumulated 16-bit words. Deferring the end-around carry is safe for any length this + /// stack can produce: 32 bits absorbs 65,536 words, and the largest thing checksummed here is + /// 1,500 bytes. + sum: u32 = 0, + /// High byte of a 16-bit word whose low byte has not arrived yet. + half: ?u8 = null, + + pub fn update(self: *Checksum, bytes: []const u8) void { + var b = bytes; + if (self.half) |hi| { + if (b.len == 0) return; + self.sum += (@as(u32, hi) << 8) | b[0]; + self.half = null; + b = b[1..]; + } + var i: usize = 0; + while (i + 1 < b.len) : (i += 2) self.sum += std.mem.readInt(u16, b[i..][0..2], .big); + if (i < b.len) self.half = b[i]; + } + + /// Feed a big-endian 16-bit value, for the pseudo-header fields that are not in any buffer. + pub fn update16(self: *Checksum, v: u16) void { + var tmp: [2]u8 = undefined; + std.mem.writeInt(u16, &tmp, v, .big); + self.update(&tmp); + } + + /// The checksum as it goes on the wire. RFC 1071 1: an odd-length buffer is padded with a + /// zero byte, which the fold below does implicitly by shifting the owed byte up. + pub fn final(self: Checksum) u16 { + var s = self.sum; + if (self.half) |hi| s += @as(u32, hi) << 8; + while (s >> 16 != 0) s = (s & 0xffff) + (s >> 16); + return ~@as(u16, @truncate(s)); + } +}; + +/// The Internet checksum of one contiguous buffer. +pub fn checksum(bytes: []const u8) u16 { + var c: Checksum = .{}; + c.update(bytes); + return c.final(); +} + +/// The TCP/UDP pseudo-header of RFC 793 3.1: source address, destination address, a zero byte, the +/// protocol number and the transport length. Not transmitted; only checksummed. +fn pseudoHeader(c: *Checksum, src: Ip4, dst: Ip4, proto: Protocol, len: u16) void { + c.update(&src); + c.update(&dst); + c.update16(@intFromEnum(proto)); // the zero byte and the protocol byte, as one word + c.update16(len); +} + +/// A checksum for a UDP or TCP segment: pseudo-header, then the segment with its own checksum +/// field already zeroed. +fn transportChecksum(src: Ip4, dst: Ip4, proto: Protocol, segment: []const u8) u16 { + var c: Checksum = .{}; + pseudoHeader(&c, src, dst, proto, @intCast(segment.len)); + c.update(segment); + return c.final(); +} + +/// Verify a received transport checksum. A UDP datagram may carry zero, meaning "not computed" +/// (RFC 768); TCP may not. +fn transportChecksumOk(src: Ip4, dst: Ip4, proto: Protocol, segment: []const u8, field: u16) bool { + if (proto == .udp and field == 0) return true; + // Summing a segment that already contains its own checksum yields 0 (or, equivalently, the + // sum before complementing is 0xffff). RFC 1071 1. + return transportChecksum(src, dst, proto, segment) == 0; +} + +/// A transmitted UDP checksum of zero would be read as "not computed", so RFC 768 requires it be +/// sent as the equivalent 0xffff instead. TCP has no such rule and no such ambiguity. +pub fn udpChecksumOnWire(c: u16) u16 { + return if (c == 0) 0xffff else c; +} + +// ============================================================== unaligned big-endian access +// +// `std.mem.readInt`/`writeInt` with an explicit endianness at every single field. Never a shift and +// an or: a network header written by hand is where byte order goes wrong, and it goes wrong +// silently, on one field, in a way that looks like a hardware problem. + +inline fn rd16(b: []const u8, off: usize) u16 { + return std.mem.readInt(u16, b[off..][0..2], .big); +} +inline fn rd32(b: []const u8, off: usize) u32 { + return std.mem.readInt(u32, b[off..][0..4], .big); +} +inline fn wr16(b: []u8, off: usize, v: u16) void { + std.mem.writeInt(u16, b[off..][0..2], v, .big); +} +inline fn wr32(b: []u8, off: usize, v: u32) void { + std.mem.writeInt(u32, b[off..][0..4], v, .big); +} +inline fn rdIp(b: []const u8, off: usize) Ip4 { + return b[off..][0..4].*; +} +inline fn wrIp(b: []u8, off: usize, v: Ip4) void { + b[off..][0..4].* = v; +} +inline fn rdMac(b: []const u8, off: usize) Mac { + return b[off..][0..6].*; +} +inline fn wrMac(b: []u8, off: usize, v: Mac) void { + b[off..][0..6].* = v; +} + +// ============================================================================ header offsets +// +// Byte offsets rather than packed structs. `extern struct` would need `align(1)` on every field and +// a byte-swap on every access on this little-endian part, and the offsets are what the RFCs and +// lwIP's headers actually state, so this is the form that can be checked against them by eye. + +/// lwIP `prot/ethernet.h:76-83` (`struct eth_hdr`). +const eth = struct { + const dst = 0; + const src = 6; + const ethertype = 12; +}; + +/// lwIP `prot/etharp.h:86-96` (`struct etharp_hdr`), `SIZEOF_ETHARP_HDR` 28 at `:102`. +const arp = struct { + const hwtype = 0; + const proto = 2; + const hwlen = 4; + const protolen = 5; + const opcode = 6; + const sha = 8; // sender hardware address + const spa = 14; // sender protocol address + const tha = 18; // target hardware address + const tpa = 24; // target protocol address + const len = 28; + + /// lwIP `prot/iana.h:54` (`LWIP_IANA_HWTYPE_ETHERNET`). + const hwtype_ethernet: u16 = 1; + /// lwIP `prot/etharp.h:105-108` (`enum etharp_opcode`). + const op_request: u16 = 1; + const op_reply: u16 = 2; +}; + +/// lwIP `prot/ip4.h:73-97` (`struct ip_hdr`), `IP_HLEN` 20 at `:64`. +const ip4 = struct { + const v_hl = 0; + const tos = 1; + const total_len = 2; + const id = 4; + const frag = 6; + const ttl = 8; + const proto = 9; + const chksum = 10; + const src = 12; + const dst = 16; + const hlen = 20; + + /// lwIP `prot/ip4.h:84-87`. + const flag_df: u16 = 0x4000; + const flag_mf: u16 = 0x2000; + const offset_mask: u16 = 0x1fff; +}; + +/// lwIP `prot/icmp.h:89-95` (`struct icmp_echo_hdr`). +const icmp = struct { + const type_ = 0; + const code = 1; + const chksum = 2; + const id = 4; + const seq = 6; + const hlen = 8; + + /// lwIP `prot/icmp.h:46,50`. + const echo_reply: u8 = 0; + const echo_request: u8 = 8; +}; + +/// lwIP `prot/udp.h:53-58` (`struct udp_hdr`), `UDP_HLEN` 8 at `:46`. +const udp = struct { + const src_port = 0; + const dst_port = 2; + const len = 4; + const chksum = 6; + const hlen = 8; +}; + +/// lwIP `prot/tcp.h:56-65` (`struct tcp_hdr`), `TCP_HLEN` 20 at `:47`. +const tcp = struct { + const src_port = 0; + const dst_port = 2; + const seq = 4; + const ack = 8; + /// Top four bits are the header length in 32-bit words; the low six are the flags. + /// lwIP `prot/tcp.h:85-87`. + const hdrlen_flags = 12; + const window = 14; + const chksum = 16; + const urgent = 18; + const hlen = 20; + + /// lwIP `prot/tcp.h:72-81`. + const fin: u8 = 0x01; + const syn: u8 = 0x02; + const rst: u8 = 0x04; + const psh: u8 = 0x08; + const ack_f: u8 = 0x10; + const urg: u8 = 0x20; + + /// RFC 793 3.1: kind 2, length 4, then the 16-bit MSS. + const opt_end: u8 = 0; + const opt_nop: u8 = 1; + const opt_mss: u8 = 2; +}; + +/// lwIP `prot/dhcp.h:50-91` (`struct dhcp_msg`) and `:51-56` for the offsets named there. +const dhcp = struct { + const op = 0; + const htype = 1; + const hlen = 2; + const hops = 3; + const xid = 4; + const secs = 8; + const flags = 10; + const ciaddr = 12; + const yiaddr = 16; + const siaddr = 20; + const giaddr = 24; + const chaddr = 28; + const sname = 44; // DHCP_SNAME_OFS + const file = 108; // DHCP_FILE_OFS + const cookie = 236; // DHCP_MSG_LEN + const options = 240; // DHCP_OPTIONS_OFS = DHCP_MSG_LEN + 4 + + /// lwIP `prot/dhcp.h:116-117`. + const bootrequest: u8 = 1; + const bootreply: u8 = 2; + /// lwIP `prot/dhcp.h:120-127`. + const discover: u8 = 1; + const offer: u8 = 2; + const request: u8 = 3; + const ack: u8 = 5; + const nak: u8 = 6; + /// lwIP `prot/dhcp.h:129`. + const magic_cookie: u32 = 0x63825363; + /// RFC 2131 figure 2: the top bit of `flags` asks the server to broadcast its reply. + const flag_broadcast: u16 = 0x8000; + + /// lwIP `prot/dhcp.h:134-165`. Only the ones this client uses. + const opt_pad: u8 = 0; + const opt_subnet_mask: u8 = 1; + const opt_router: u8 = 3; + const opt_dns: u8 = 6; + const opt_hostname: u8 = 12; + const opt_requested_ip: u8 = 50; + const opt_lease_time: u8 = 51; + const opt_overload: u8 = 52; + const opt_msg_type: u8 = 53; + const opt_server_id: u8 = 54; + const opt_param_list: u8 = 55; + const opt_max_msg_size: u8 = 57; + const opt_t1: u8 = 58; + const opt_t2: u8 = 59; + const opt_end: u8 = 255; + + /// lwIP `prot/iana.h:66-68`. + const server_port: u16 = 67; + const client_port: u16 = 68; +}; + +/// RFC 1035 4.1. There is no lwIP reference for this one: lwIP's resolver is `core/dns.c`, which +/// builds the same header out of its own `struct dns_hdr` (`core/dns.c:180-190`) - the offsets +/// below are the RFC's, and `dns.c` is only a cross-check. +const dns = struct { + // 4.1.1 header, six 16-bit fields. + const id = 0; + const flags = 2; + const qdcount = 4; + const ancount = 6; + const nscount = 8; + const arcount = 10; + const hlen = 12; + + /// 4.1.1: QR is the top bit of `flags`, RD is bit 8, RCODE the bottom four bits. + const flag_qr: u16 = 0x8000; + const flag_rd: u16 = 0x0100; + const rcode_mask: u16 = 0x000f; + /// RCODE 3, "name error": the name authoritatively does not exist. RFC 1035 4.1.1. + const rcode_name_error: u16 = 3; + + /// 4.1.4: the two top bits of a length byte set means the rest is a 14-bit offset. + const ptr_mask: u8 = 0xc0; + /// 2.3.4: a label is at most 63 bytes, which is also why 0x40 and 0x80 are free to be flags. + const label_max: u8 = 63; + + /// 3.2.2 TYPE and 3.2.4 CLASS. Only the two this stack looks at, plus CNAME, which is not + /// followed but must be stepped over: a name behind a CNAME chain answers with the chain and + /// the A record together, and a resolver that stops at the first record finds the CNAME. + const type_a: u16 = 1; + const type_cname: u16 = 5; + const class_in: u16 = 1; + + /// 3.2.1: TYPE(2) CLASS(2) TTL(4) RDLENGTH(2) after the name. + const rr_fixed = 10; + + /// lwIP `prot/iana.h:64` (`LWIP_IANA_PORT_DNS`). + const port: u16 = 53; +}; + +// ============================================================================== ARP cache + +const ArpEntry = struct { + ip: Ip4 = ip_any, + mac: Mac = @splat(0), + /// `tick`'s clock at the last hit or update. Zero means the entry is empty. + stamp_ms: u64 = 0, + + inline fn valid(self: ArpEntry) bool { + return self.stamp_ms != 0; + } +}; + +/// Entries older than this are treated as absent and re-resolved. lwIP's default is 300 s +/// (`ARP_TMR_INTERVAL` 1000 ms x `ARP_MAXAGE` 300, `core/ipv4/etharp.c`); the same here. +const arp_max_age_ms: u64 = 300_000; +/// How often an unanswered ARP request is repeated while `httpGet` waits for a MAC address. +const arp_retry_ms: u64 = 1000; +/// ARP requests sent for one destination before `httpGet` gives up with `error.HostUnreachable`. +const arp_max_tries: u8 = 5; + +// ================================================================================ DHCP state + +pub const DhcpState = enum { + /// `dhcpStart` has not been called, or `setStatic` has taken over. + off, + /// DISCOVER sent, waiting for an OFFER. + selecting, + /// REQUEST sent, waiting for an ACK. + requesting, + /// Bound, lease held, T1 not yet reached. + bound, + /// Past T1: unicast REQUEST to the server that granted the lease. + renewing, + /// Past T2: broadcast REQUEST to any server. + rebinding, +}; + +const Dhcp = struct { + state: DhcpState = .off, + xid: u32 = 0, + /// The address the server offered, held between OFFER and ACK. + offered: Ip4 = ip_any, + /// Option 54 from the OFFER, echoed in the REQUEST and unicast to when renewing. + server: Ip4 = ip_any, + /// Option 51, seconds. `0xffff_ffff` is an infinite lease (RFC 2131 3.3). + lease_s: u32 = 0, + /// Absolute deadlines derived from the lease at bind time, in `tick`'s milliseconds. + t1_ms: u64 = 0, + t2_ms: u64 = 0, + expire_ms: u64 = 0, + /// When the next DISCOVER/REQUEST retransmission is due, and how many have gone out. + retry_ms: u64 = 0, + tries: u8 = 0, + /// `tick`'s clock when acquisition began, for the `secs` field. + started_ms: u64 = 0, +}; + +// ================================================================================ TCP state + +pub const TcpState = enum { + closed, + /// Waiting for the peer's MAC address before the SYN can be built. + arp_wait, + syn_sent, + established, + /// Our FIN is sent; the peer has not FINed. + fin_wait_1, + fin_wait_2, + /// The peer FINed first and we have replied with our own FIN. + last_ack, + time_wait, +}; + +const Tcp = struct { + state: TcpState = .closed, + + peer_ip: Ip4 = ip_any, + peer_port: u16 = 0, + local_port: u16 = 0, + + /// Initial send sequence number. The SYN occupies `iss`; request data occupies + /// `iss+1 .. iss+1+tx_len`; a FIN occupies `iss+1+tx_len`. Every offset in this struct is + /// derived from that one layout, which is why there is no separate "unacked offset". + iss: u32 = 0, + /// Oldest sequence number not yet acknowledged by the peer. + snd_una: u32 = 0, + /// Next sequence number to send. + snd_nxt: u32 = 0, + /// The peer's advertised window. + snd_wnd: u32 = 0, + /// The peer's MSS, from its SYN's option or RFC 1122's default. + snd_mss: u16 = tcp_default_mss, + /// Set once a FIN has been queued behind the request data. + fin_queued: bool = false, + /// Set once the peer's FIN has been received in order. Receiving it does not by itself move + /// `state`, so that `tcpSendData` stays the only thing that changes it. + peer_fin: bool = false, + + /// Next sequence number expected from the peer. + rcv_nxt: u32 = 0, + + /// Retransmission deadline in `tick`'s milliseconds, and the current timeout. `rto_ms` doubles + /// on every retransmission and is never reduced by an RTT measurement, because this stack + /// takes none: with a single segment in flight and a fixed backoff there is nothing an RTT + /// estimator would change except the first timeout, and 1 s is already RFC 6298's answer for + /// that case. + rto_deadline_ms: u64 = 0, + rto_ms: u32 = tcp_rto_initial_ms, + retries: u8 = 0, + /// When TIME_WAIT ends. + close_deadline_ms: u64 = 0, + + /// The request bytes, held for retransmission until acknowledged. + tx: [tcp_tx_max]u8 = undefined, + tx_len: usize = 0, + + /// Sequence number of the first byte of `tx`. + inline fn dataStart(self: Tcp) u32 { + return self.iss +% 1; + } + /// Sequence number one past the last byte of `tx`. + inline fn dataEnd(self: Tcp) u32 { + return self.iss +% 1 +% @as(u32, @intCast(self.tx_len)); + } +}; + +/// Sequence-number comparison. TCP sequence numbers wrap, so they are compared by the sign of the +/// difference and never by `<`. RFC 1982 serial arithmetic; the classic bug this avoids is a +/// connection that stalls forever once the sequence space crosses 2^32. +inline fn seqLt(a: u32, b: u32) bool { + return @as(i32, @bitCast(a -% b)) < 0; +} +inline fn seqLe(a: u32, b: u32) bool { + return @as(i32, @bitCast(a -% b)) <= 0; +} +inline fn seqGt(a: u32, b: u32) bool { + return seqLt(b, a); +} +inline fn seqGe(a: u32, b: u32) bool { + return seqLe(b, a); +} + +// =============================================================================== HTTP state + +pub const HttpError = error{ + /// The request is in flight. Call `tick`, feed frames to `onFrame`, and call `httpGet` again + /// with the same arguments. This is the only "error" a healthy request returns. + WouldBlock, + /// `httpGet` was called with different arguments while a request was in flight. + Busy, + /// The peer's MAC address could not be resolved. + HostUnreachable, + /// The peer sent RST. + ConnectionReset, + /// The peer FINed or vanished before the body was complete. + ConnectionClosed, + /// Retransmissions exhausted. + TimedOut, + /// The status line, the headers, or a chunked body's trailer section exceeded its budget + /// (`http_head_max`, `http_framing_max`). + HttpHeadersTooLong, + /// The status line was not `HTTP/1.x SSS`. + HttpMalformed, + /// A chunked body's framing was not RFC 7230 4.1: a size with no hex digits, a size that + /// overflows `usize`, or a CRLF that was not where the grammar puts it. Distinct from + /// `HttpMalformed` because the two point at different halves of the response, and on a board + /// with one UART the error name is the whole diagnosis. + HttpChunkMalformed, + /// `Transfer-Encoding` was present and was neither `identity` nor `chunked`. + UnsupportedTransferEncoding, + /// The body did not fit in the caller's `out` buffer. + StreamTooLong, + /// The request line and headers did not fit in `tcp_tx_max`, or `path` is unusable. + RequestTooLong, + /// `httpGet` was called before the stack had an address. + NoAddress, +}; + +const HttpPhase = enum { idle, head, body, complete, failed }; + +const Http = struct { + phase: HttpPhase = .idle, + /// Valid when `phase == .failed`. + err: HttpError = error.WouldBlock, + + /// The caller's output buffer, borrowed for the duration of the request. Recorded rather than + /// copied, so the caller must not move or resize it between `httpGet` calls; the identity check + /// in `httpGet` catches the common way of getting that wrong. + out: []u8 = &.{}, + out_len: usize = 0, + + /// The request being served, kept so a re-entrant `httpGet` can be told apart from a new one. + /// The hash covers the path *and* the `Host:` name, which is what makes two requests to the + /// same address for the same path but different virtual hosts distinguishable - and they must + /// be, or the second silently rides on the first's connection. A hash rather than the strings + /// themselves because `Stack` has a 4 KiB budget and the strings are the caller's, alive for + /// the duration of the call only. + req_host: Ip4 = ip_any, + req_port: u16 = 0, + req_hash: u64 = 0, + + /// Status line and headers, accumulated until the blank line. + head: [http_head_max]u8 = undefined, + head_len: usize = 0, + + status: u16 = 0, + /// `null` means the response had no usable `Content-Length`, so the body ends at the peer's + /// FIN - or, when `chunked`, at the zero-length chunk. + content_length: ?usize = null, + + // ------------------------------------------------------- RFC 7230 4.1 chunked decoding + // + // Four fields hold the whole position in the chunked grammar, because a segment boundary may + // fall between any two bytes of it and the decoder has to resume from exactly here. + + /// `Transfer-Encoding: chunked` was in force on this response. + chunked: bool = false, + chunk: ChunkState = .size, + /// In `.size`, the hexadecimal size accumulated so far; in `.data`, the bytes of this chunk + /// still to come. The two are the same number, which is why one field serves both: the size + /// read is the count remaining the instant the header ends. + chunk_left: usize = 0, + /// At least one hex digit has been seen in the size being read. RFC 7230 4.1 is `1*HEXDIG`, + /// so an empty size is malformed - and without this flag a stray CRLF reads as a chunk of + /// length zero, which is the terminator, which ends the body early and looks like success. + chunk_digit: bool = false, + /// Framing bytes consumed in the extension or trailer section now being skipped, against + /// `http_framing_max`. + chunk_skip: u16 = 0, +}; + +/// Where the chunked decoder is in RFC 7230 4.1's grammar: +/// +/// chunked-body = *chunk last-chunk trailer-part CRLF +/// chunk = chunk-size [ chunk-ext ] CRLF chunk-data CRLF +/// last-chunk = 1*("0") [ chunk-ext ] CRLF +/// +/// Every terminal in that grammar that can be split by a segment boundary is a state, including +/// the two halves of each CRLF. That is not pedantry: a 1,460-byte segment ends wherever the +/// server's writes happen to end, and "the CR arrived and the LF did not" is a case that happens. +const ChunkState = enum { + /// Reading hex digits of `chunk-size`. + size, + /// A `;` was seen: skipping `chunk-ext` to the CR that ends the header. + ext, + /// The CR of the chunk header is in; its LF must follow. + size_lf, + /// Copying `chunk_left` more bytes of `chunk-data` into the caller's `out`. + data, + /// The data is in; the CR of the CRLF that closes the chunk must follow. + data_cr, + /// ...and its LF. + data_lf, + /// At the first byte of a trailer line - or of the CRLF that ends the whole body. + trailer, + /// Inside a trailer line, skipping to its CR. + trailer_line, + /// The LF of a trailer line. + trailer_lf, + /// The LF of the final empty line. The response is complete after it, and not before. + end_lf, +}; + +// ================================================================================ DNS state + +pub const DnsError = error{ + /// The query is in flight. Call `tick`, feed frames to `onFrame`, and call `resolve` again + /// with the same name. This is the only "error" a healthy query returns. + WouldBlock, + /// `resolve` was called with a different name while a query was in flight. One query is + /// outstanding at a time; the caller must finish or abandon the first. + Busy, + /// `resolve` was called before the stack had an address of its own to send from. + NoAddress, + /// No resolver: DHCP supplied none and `setDnsServer` was not called. + NoDnsServer, + /// The name was empty, had an empty label, or had a label over 63 bytes. RFC 1035 2.3.4. + NameInvalid, + /// The name was longer than `dns_name_max`. + NameTooLong, + /// `dns_backoff_ms.len` queries went out and nothing came back. + TimedOut, + /// The server said the name does not exist (RCODE 3), or answered with no A record in it - + /// a CNAME chain leading nowhere, or an AAAA-only name. Both mean the same thing to a stack + /// that speaks IPv4 only. + NameNotFound, + /// The server answered with a non-zero RCODE other than name-error: SERVFAIL, REFUSED. + DnsRefused, + /// A response that matched the id and the question could not be parsed: a name that runs off + /// the end, a compression pointer that goes forward or loops, an RDLENGTH past the message. + /// Responses that do *not* match the id and question are ignored rather than reported, so + /// this is the server or an attacker who already guessed both, never stray traffic. + DnsMalformed, +}; + +const DnsPhase = enum { idle, waiting, done, failed }; + +const DnsQuery = struct { + phase: DnsPhase = .idle, + /// Valid when `phase == .failed`. + err: DnsError = error.WouldBlock, + + /// The question, in RFC 1035 4.1.2 wire form, root label included. Held rather than + /// re-encoded because it is needed in three places: to build each retransmission from `tick`, + /// to compare against the question echoed in a response, and to tell a re-entrant `resolve` + /// from a new one. Comparing the encoded form is what makes the last two exact. + qname: [dns_qname_max]u8 = undefined, + qname_len: u8 = 0, + + /// The transaction id, held across retransmissions so a slow first answer still matches. + id: u16 = 0, + /// The ephemeral source port, redrawn per query. Together with `id` that is 32 bits an + /// off-path spoofer has to guess, which is the whole of what plain DNS offers. + local_port: u16 = 0, + + tries: u8 = 0, + retry_ms: u64 = 0, + + /// Valid when `phase == .done`. + result: Ip4 = ip_any, +}; + +// ================================================================================= counters +// +// Not statistics for their own sake: the first hardware bring-up of this stack will be a board that +// either answers a ping or does not, with no debugger and one UART. These are what turns "nothing +// happens" into "1,204 frames arrived, 1,204 were dropped, and the checksum counter is zero", which +// says the frames are not for us rather than that the checksum code is broken. + +pub const Counters = struct { + rx_frames: u32 = 0, + rx_dropped: u32 = 0, + tx_frames: u32 = 0, + tx_dropped: u32 = 0, + arp_rx: u32 = 0, + arp_tx: u32 = 0, + icmp_echo: u32 = 0, + udp_rx: u32 = 0, + dhcp_rx: u32 = 0, + dhcp_tx: u32 = 0, + tcp_rx: u32 = 0, + tcp_tx: u32 = 0, + tcp_retx: u32 = 0, + tcp_rst_rx: u32 = 0, + /// Queries sent, retransmissions among them, and responses that matched the outstanding + /// query's id and question. `dns_tx > dns_rx` with `dns_retx` climbing is a resolver that is + /// not answering; `dns_rx == 0` with `udp_rx` climbing is an answer arriving and being + /// rejected, which is a different bug in a different place. + dns_tx: u32 = 0, + dns_retx: u32 = 0, + dns_rx: u32 = 0, + /// Frames discarded because a checksum did not verify. A non-zero value here with a working + /// link means a bug in this file or a broken SDIO transfer, not a network problem. + checksum_bad: u32 = 0, +}; + +// ==================================================================================== Stack + +pub const Stack = struct { + // ------------------------------------------------------------------ identity and route + mac: Mac, + /// `null` until DHCP binds or `setStatic` is called. + addr: ?Ip4 = null, + mask: Ip4 = ip_any, + gw: Ip4 = ip_any, + /// The resolver, from DHCP option 6 or `setDnsServer`. `null` means nothing to ask. + dns: ?Ip4 = null, + + /// Where frames go. Called synchronously from `onFrame`, `tick` and `httpGet`; the slice is + /// borrowed for the duration of the call and must be copied if the transport needs it later. + /// + /// Note the absence of a context pointer: the interface this slice implements specifies + /// `*const fn ([]const u8) void`, so a callee needing state has to reach it some other way. + send: *const fn (frame: []const u8) void, + + // ------------------------------------------------------------------------------ clock + /// The last value handed to `tick`. `onFrame` needs a timestamp for the ARP cache and takes it + /// from here rather than reading a clock, which is what keeps this file free of any hardware + /// dependency at all. + now_ms: u64 = 0, + + // ------------------------------------------------------------------------------ state + arp_cache: [arp_cache_len]ArpEntry = @splat(.{}), + /// Pending ARP resolution for the TCP peer: deadline, tries. + arp_retry_ms: u64 = 0, + arp_tries: u8 = 0, + + dhcp: Dhcp = .{}, + tcp: Tcp = .{}, + http: Http = .{}, + /// The one outstanding DNS query. Named `query` and not `dns`, which is the server's address. + query: DnsQuery = .{}, + counters: Counters = .{}, + + /// IPv4 identification field. Incremented per datagram. Nothing here fragments, so this only + /// has to be non-constant for the benefit of middleboxes and packet captures. + ip_id: u16 = 0, + /// Mixed into transaction ids, initial sequence numbers and ephemeral ports. There is no + /// hardware RNG in this file's reach, so this is seeded from the MAC and stirred by every + /// `tick` value observed - which for the two uses here (not colliding with a previous + /// incarnation of the same connection, and not matching a stale DHCP reply) is sufficient. + /// It is emphatically *not* a source of security-relevant randomness. + entropy: u64, + + /// The single transmit staging buffer. Every frame this stack sends is built here and handed to + /// `send` before the next one starts, so one is enough - and `send` is documented as borrowing. + tx: [frame_max]u8 = undefined, + + /// The total static footprint of one `Stack`, asserted so the number in the report cannot rot. + pub const footprint = @sizeOf(Stack); + + // =========================================================================== lifecycle + + /// A single struct-literal return, deliberately: result-location semantics then construct the + /// buffers in the caller's storage instead of memcpy-ing several kilobytes off a stack that is + /// 8 KB by default on this target. + pub fn init(mac: [6]u8, send: *const fn (frame: []const u8) void) Stack { + return .{ + .mac = mac, + .send = send, + .entropy = std.hash.Wyhash.hash(0x4200_cafe, &mac), + }; + } + + /// Stir and draw. Not random; see `entropy`. + fn draw(self: *Stack) u32 { + self.entropy = self.entropy *% 6364136223846793005 +% 1442695040888963407; + return @truncate(self.entropy >> 32); + } + + // ============================================================================= address + + /// The configured address, or `null` if there is none yet. + pub fn ip(self: *Stack) ?[4]u8 { + return self.addr; + } + + pub fn netmask(self: *Stack) Ip4 { + return self.mask; + } + + pub fn gateway(self: *Stack) Ip4 { + return self.gw; + } + + /// The resolver `resolve` will ask: the first server DHCP offered (option 6), or whatever + /// `setDnsServer` last set. `null` means `resolve` will answer `error.NoDnsServer`. + pub fn dnsServer(self: *Stack) ?Ip4 { + return self.dns; + } + + /// Override the resolver. Only needed on a network whose DHCP server offers none, or when + /// configuring statically: the ordinary path is a lease that carries option 6, which + /// `dhcpBind` already stores, and a caller that does nothing gets that. + /// + /// Any query in flight is abandoned: it was addressed to the old server and its answer would + /// now be rejected as coming from the wrong source. + pub fn setDnsServer(self: *Stack, addr: Ip4) void { + self.dns = addr; + self.query.phase = .idle; + } + + pub fn dhcpState(self: *Stack) DhcpState { + return self.dhcp.state; + } + + pub fn tcpState(self: *Stack) TcpState { + return self.tcp.state; + } + + /// The status code of the last response whose head was parsed. Zero before that. + pub fn httpStatus(self: *Stack) u16 { + return self.http.status; + } + + /// Configure statically and stop any DHCP activity. This is the path the first hardware test + /// takes: it makes the board reachable without a working DHCP client, so an ARP or ping + /// failure means the SDIO transport or the association is wrong rather than this file. + pub fn setStatic(self: *Stack, addr: [4]u8, mask: [4]u8, gw: [4]u8) void { + self.dhcp = .{}; + self.addr = addr; + self.mask = mask; + self.gw = gw; + // Any query in flight was sent from the old address, so its answer is addressed to a + // station that no longer exists. `dns` itself is left alone: a resolver learnt from a + // previous lease is still the right one to ask on the same wire. + self.query.phase = .idle; + self.announce(); + } + + /// Is this address ours, or one everybody on the wire is meant to hear? + fn forUs(self: *Stack, dst: Ip4) bool { + if (std.mem.eql(u8, &dst, &ip_broadcast)) return true; + const a = self.addr orelse return false; + if (std.mem.eql(u8, &dst, &a)) return true; + // Subnet broadcast: host bits all ones. + var i: usize = 0; + while (i < 4) : (i += 1) { + if (dst[i] | self.mask[i] != 0xff) return false; + if (dst[i] & self.mask[i] != a[i] & self.mask[i]) return false; + } + return true; + } + + fn onLink(self: *Stack, dst: Ip4) bool { + const a = self.addr orelse return true; // unconfigured: everything is a direct neighbour + var i: usize = 0; + while (i < 4) : (i += 1) { + if ((dst[i] ^ a[i]) & self.mask[i] != 0) return false; + } + return true; + } + + // ================================================================== frame construction + + fn emitFrame(self: *Stack, len: usize) void { + if (len > frame_max) { + self.counters.tx_dropped += 1; + return; + } + // Ethernet's 60-byte minimum (64 with FCS) is padded by the MAC, and ESP-Hosted's slave + // hands the frame to the C6's Wi-Fi MAC, which does the same. Nothing is padded here. + self.counters.tx_frames += 1; + self.send(self.tx[0..len]); + } + + fn ethHeader(self: *Stack, dst: Mac, ethertype: EtherType) void { + wrMac(&self.tx, eth.dst, dst); + wrMac(&self.tx, eth.src, self.mac); + wr16(&self.tx, eth.ethertype, @intFromEnum(ethertype)); + } + + /// Build and send an IPv4 datagram whose payload the caller has already written to + /// `self.tx[eth_hlen + ip4.hlen ..]`. Returns false if the destination's MAC is unknown, in + /// which case an ARP request has been sent and the datagram is dropped. + /// + /// Dropping rather than queueing is lwIP's `ETHARP_SUPPORT_STATIC_ENTRIES`-less behaviour minus + /// its one-packet queue (`core/ipv4/etharp.c`, `etharp_query`). Nothing here needs the queue: + /// DHCP is broadcast, ICMP replies go to a peer whose MAC just arrived in the request, and TCP + /// resolves the peer before the SYN is built (`TcpState.arp_wait`). + fn emitIp(self: *Stack, src: Ip4, dst: Ip4, proto: Protocol, payload_len: usize) bool { + assert(payload_len <= mtu - ip4.hlen); + const total: u16 = @intCast(ip4.hlen + payload_len); + + const dst_mac = self.routeMac(dst) orelse { + self.counters.tx_dropped += 1; + return false; + }; + self.ethHeader(dst_mac, .ip4); + + const h = self.tx[eth_hlen..][0..ip4.hlen]; + h[ip4.v_hl] = 0x45; // IPv4, 5 words of header, no options + h[ip4.tos] = 0; + wr16(h, ip4.total_len, total); + wr16(h, ip4.id, self.ip_id); + self.ip_id +%= 1; + // DF set: this stack neither fragments what it sends nor reassembles what it receives, so + // saying so is more useful than letting a router fragment a datagram we cannot rebuild. + wr16(h, ip4.frag, ip4.flag_df); + h[ip4.ttl] = 64; // RFC 1122 3.2.1.7 recommends 64 + h[ip4.proto] = @intFromEnum(proto); + wr16(h, ip4.chksum, 0); + wrIp(h, ip4.src, src); + wrIp(h, ip4.dst, dst); + wr16(h, ip4.chksum, checksum(h)); + + self.emitFrame(eth_hlen + total); + return true; + } + + /// The MAC a datagram for `dst` must be sent to: broadcast for a broadcast address, the peer + /// itself if it is on-link, otherwise the gateway. `null` means unresolved, and an ARP request + /// has been sent. + fn routeMac(self: *Stack, dst: Ip4) ?Mac { + if (std.mem.eql(u8, &dst, &ip_broadcast)) return mac_broadcast; + if (self.addr != null) { + // Subnet broadcast. + var all_ones = true; + var i: usize = 0; + while (i < 4) : (i += 1) { + if (dst[i] | self.mask[i] != 0xff) all_ones = false; + } + if (all_ones and self.onLink(dst)) return mac_broadcast; + } + const next = if (self.onLink(dst)) dst else self.gw; + if (self.arpLookup(next)) |m| return m; + self.arpRequest(next); + return null; + } + + // ================================================================================= ARP + + /// An entry's timestamp is *not* refreshed by a lookup, only by an ARP packet from that host. + /// Refreshing on use looks like a cheap optimisation and is a real bug: an entry kept alive by + /// our own traffic is never re-resolved, so a gateway whose MAC changes - VRRP failover, a + /// replaced router, a roam to a different AP with a different BSSID-derived address - is never + /// noticed, and every frame goes to a MAC that no longer answers. Ageing out after + /// `arp_max_age_ms` of no ARP traffic from that host costs one dropped segment and a + /// retransmission; getting it wrong costs the connection. + fn arpLookup(self: *Stack, target: Ip4) ?Mac { + for (&self.arp_cache) |*e| { + if (!e.valid()) continue; + if (self.now_ms -% e.stamp_ms > arp_max_age_ms) { + e.stamp_ms = 0; + continue; + } + if (std.mem.eql(u8, &e.ip, &target)) return e.mac; + } + return null; + } + + /// Insert or refresh. `insert` false means "update only if already known", which is how a + /// four-entry cache survives a busy /24: every ARP request on the segment is a broadcast, and a + /// cache that admitted all of them would evict the gateway within seconds. lwIP draws the same + /// line with `ETHARP_FLAG_TRY_HARD` (`core/ipv4/etharp.c`, `etharp_update_arp_entry`). + fn arpStore(self: *Stack, target: Ip4, hw: Mac, insert: bool) void { + if (std.mem.eql(u8, &target, &ip_any)) return; + if (std.mem.eql(u8, &target, &ip_broadcast)) return; + for (&self.arp_cache) |*e| { + if (e.valid() and std.mem.eql(u8, &e.ip, &target)) { + e.mac = hw; + e.stamp_ms = self.now_ms; + return; + } + } + if (!insert) return; + // Free slot, else the least recently used. + var victim: *ArpEntry = &self.arp_cache[0]; + for (&self.arp_cache) |*e| { + if (!e.valid()) { + victim = e; + break; + } + if (e.stamp_ms < victim.stamp_ms) victim = e; + } + victim.* = .{ .ip = target, .mac = hw, .stamp_ms = self.now_ms }; + } + + fn arpEmit(self: *Stack, opcode: u16, target_ip: Ip4, target_mac: Mac, dst_mac: Mac, spa: Ip4) void { + self.ethHeader(dst_mac, .arp); + const h = self.tx[eth_hlen..][0..arp.len]; + wr16(h, arp.hwtype, arp.hwtype_ethernet); + wr16(h, arp.proto, @intFromEnum(EtherType.ip4)); + h[arp.hwlen] = 6; + h[arp.protolen] = 4; + wr16(h, arp.opcode, opcode); + wrMac(h, arp.sha, self.mac); + wrIp(h, arp.spa, spa); + wrMac(h, arp.tha, target_mac); + wrIp(h, arp.tpa, target_ip); + self.counters.arp_tx += 1; + self.emitFrame(eth_hlen + arp.len); + } + + fn arpRequest(self: *Stack, target: Ip4) void { + // RFC 826: the target hardware address of a request is "don't care"; zero is conventional. + self.arpEmit(arp.op_request, target, @splat(0), mac_broadcast, self.addr orelse ip_any); + } + + /// Gratuitous ARP: a broadcast request for our own address, which every listener treats as + /// "this MAC now owns this IP". Sent when an address is acquired, so the gateway and the AP + /// learn us without waiting to need us. RFC 5227 2.3. + fn announce(self: *Stack) void { + const a = self.addr orelse return; + self.arpEmit(arp.op_request, a, @splat(0), mac_broadcast, a); + } + + fn arpInput(self: *Stack, body: []const u8) void { + if (body.len < arp.len) { + self.counters.rx_dropped += 1; + return; + } + // RFC 826 "Packet Reception", exactly the four checks lwIP makes at + // `core/ipv4/etharp.c:656-659`. + if (rd16(body, arp.hwtype) != arp.hwtype_ethernet or + rd16(body, arp.proto) != @intFromEnum(EtherType.ip4) or + body[arp.hwlen] != 6 or body[arp.protolen] != 4) + { + self.counters.rx_dropped += 1; + return; + } + self.counters.arp_rx += 1; + + const spa = rdIp(body, arp.spa); + const sha = rdMac(body, arp.sha); + const tpa = rdIp(body, arp.tpa); + const for_us = if (self.addr) |a| std.mem.eql(u8, &tpa, &a) else false; + + // Learn the sender. Admitted to a free slot only when the packet was addressed to us - + // either a request we must answer or the reply to a request we sent. + self.arpStore(spa, sha, for_us); + + if (rd16(body, arp.opcode) == arp.op_request and for_us) { + // A reply goes back to the requester, not to the broadcast address. + self.arpEmit(arp.op_reply, spa, sha, sha, self.addr.?); + } + } + + // =============================================================================== input + + /// A received Ethernet frame. Everything this stack does in response happens before this + /// returns, including any frame it sends. + pub fn onFrame(self: *Stack, frame: []const u8) void { + self.counters.rx_frames += 1; + if (frame.len < eth_hlen or frame.len > frame_max) { + self.counters.rx_dropped += 1; + return; + } + const dst = rdMac(frame, eth.dst); + // The C6's MAC filter should already have done this, but a promiscuous or misconfigured + // transport would otherwise have this stack answering ARP for other stations. + if (!std.mem.eql(u8, &dst, &self.mac) and !std.mem.eql(u8, &dst, &mac_broadcast)) { + self.counters.rx_dropped += 1; + return; + } + const body = frame[eth_hlen..]; + switch (@as(EtherType, @enumFromInt(rd16(frame, eth.ethertype)))) { + .arp => self.arpInput(body), + .ip4 => self.ip4Input(body), + // .vlan lands here: an 802.1Q tag would need the 4-byte shim skipped and the real + // ethertype read from behind it. Nothing on this board tags frames, so it is dropped + // rather than half-handled. + else => self.counters.rx_dropped += 1, + } + } + + fn ip4Input(self: *Stack, body: []const u8) void { + if (body.len < ip4.hlen) { + self.counters.rx_dropped += 1; + return; + } + if (body[ip4.v_hl] >> 4 != 4) { + self.counters.rx_dropped += 1; + return; + } + const hlen = @as(usize, body[ip4.v_hl] & 0x0f) * 4; + if (hlen < ip4.hlen or hlen > body.len) { + self.counters.rx_dropped += 1; + return; + } + if (checksum(body[0..hlen]) != 0) { + self.counters.checksum_bad += 1; + return; + } + const total = rd16(body, ip4.total_len); + if (total < hlen or total > body.len) { + // Shorter than claimed: truncated. Longer than claimed happens legitimately - a + // 60-byte minimum-length Ethernet frame padding a 28-byte datagram - and is handled by + // trusting `total` below, but a frame shorter than its own IP header claims is junk. + self.counters.rx_dropped += 1; + return; + } + const frag = rd16(body, ip4.frag); + if (frag & (ip4.flag_mf | ip4.offset_mask) != 0) { + // A fragment. Reassembly is out of scope, and accepting the first fragment as a whole + // datagram would be worse than dropping it. + self.counters.rx_dropped += 1; + return; + } + + const src = rdIp(body, ip4.src); + const dst = rdIp(body, ip4.dst); + const proto: Protocol = @enumFromInt(body[ip4.proto]); + const payload = body[hlen..total]; + + if (!self.forUs(dst)) { + // One exception, and it is the reason DHCP works at all: a server may unicast its + // OFFER or ACK to the address it is about to grant, which is not yet ours, at a MAC + // that is. RFC 2131 4.1 permits exactly this. So while unbound, UDP is let through to + // the demultiplexer, which will only match the DHCP client port. + const dhcp_pending = self.addr == null and self.dhcp.state != .off; + if (!(dhcp_pending and proto == .udp)) { + self.counters.rx_dropped += 1; + return; + } + } + + switch (proto) { + .icmp => self.icmpInput(src, dst, payload), + .udp => self.udpInput(src, dst, payload), + .tcp => self.tcpInput(src, dst, payload), + else => self.counters.rx_dropped += 1, + } + } + + // ================================================================================ ICMP + + fn icmpInput(self: *Stack, src: Ip4, dst: Ip4, payload: []const u8) void { + if (payload.len < icmp.hlen) { + self.counters.rx_dropped += 1; + return; + } + // ICMP has no pseudo-header (RFC 792): the checksum covers the message alone. + if (checksum(payload) != 0) { + self.counters.checksum_bad += 1; + return; + } + if (payload[icmp.type_] != icmp.echo_request) { + // Destination-unreachable and time-exceeded carry useful information that nothing here + // consumes; a stack with no routing decisions to revise has nothing to do with them. + self.counters.rx_dropped += 1; + return; + } + // A request addressed to the broadcast address is answered from our own address only; a + // reply sourced from a broadcast address is malformed and some hosts treat it as an attack. + if (self.addr == null) return; + if (payload.len > mtu - ip4.hlen) { + // Would need fragmenting to answer. `ping -s 1473` from the development host lands + // here as a fragmented request and is already dropped above; this covers the rest. + self.counters.rx_dropped += 1; + return; + } + _ = dst; + + const out = self.tx[eth_hlen + ip4.hlen ..][0..payload.len]; + @memcpy(out, payload); + out[icmp.type_] = icmp.echo_reply; + out[icmp.code] = 0; + wr16(out, icmp.chksum, 0); + wr16(out, icmp.chksum, checksum(out)); + self.counters.icmp_echo += 1; + _ = self.emitIp(self.addr.?, src, .icmp, payload.len); + } + + // ================================================================================= UDP + + fn udpInput(self: *Stack, src: Ip4, dst: Ip4, payload: []const u8) void { + if (payload.len < udp.hlen) { + self.counters.rx_dropped += 1; + return; + } + const ulen = rd16(payload, udp.len); + if (ulen < udp.hlen or ulen > payload.len) { + self.counters.rx_dropped += 1; + return; + } + const datagram = payload[0..ulen]; + if (!transportChecksumOk(src, dst, .udp, datagram, rd16(datagram, udp.chksum))) { + self.counters.checksum_bad += 1; + return; + } + self.counters.udp_rx += 1; + + const sport = rd16(datagram, udp.src_port); + const dport = rd16(datagram, udp.dst_port); + const data = datagram[udp.hlen..]; + if (dport == dhcp.client_port) { + self.dhcpInput(src, data); + } else if (self.query.phase == .waiting and dport == self.query.local_port) { + self.dnsInput(src, sport, data); + } else { + // No sockets, so nothing else has a port. A real stack would answer with ICMP port + // unreachable; announcing which ports are closed is of no use to this device. + self.counters.rx_dropped += 1; + } + } + + /// Send a UDP datagram. `src` may be `0.0.0.0`, which DHCP needs before it has an address. + fn emitUdp(self: *Stack, src: Ip4, sport: u16, dst: Ip4, dport: u16, data_len: usize) bool { + const seg_len = udp.hlen + data_len; + assert(seg_len <= mtu - ip4.hlen); + const seg = self.tx[eth_hlen + ip4.hlen ..][0..seg_len]; + wr16(seg, udp.src_port, sport); + wr16(seg, udp.dst_port, dport); + wr16(seg, udp.len, @intCast(seg_len)); + wr16(seg, udp.chksum, 0); + wr16(seg, udp.chksum, udpChecksumOnWire(transportChecksum(src, dst, .udp, seg))); + return self.emitIp(src, dst, .udp, seg_len); + } + + // ================================================================================ DHCP + + /// Begin acquiring an address. Idempotent while an acquisition is in progress; a call while + /// bound restarts from DISCOVER. + pub fn dhcpStart(self: *Stack) void { + self.addr = null; + self.mask = ip_any; + self.gw = ip_any; + self.dns = null; + // The resolver is gone with the lease, so anything in flight to it is abandoned rather + // than left to time out against a server this stack no longer believes in. + self.query.phase = .idle; + self.dhcp = .{ + .state = .selecting, + .xid = self.draw(), + .started_ms = self.now_ms, + }; + self.dhcpSend(dhcp.discover); + self.dhcp.tries = 1; + self.dhcp.retry_ms = self.now_ms + dhcp_backoff_ms[0]; + } + + /// Options are appended through this so a length byte can never be written by hand. + const OptWriter = struct { + buf: []u8, + i: usize = 0, + + fn raw(self: *OptWriter, code: u8, value: []const u8) void { + assert(value.len <= 255); + assert(self.i + 2 + value.len <= self.buf.len); + self.buf[self.i] = code; + self.buf[self.i + 1] = @intCast(value.len); + @memcpy(self.buf[self.i + 2 ..][0..value.len], value); + self.i += 2 + value.len; + } + fn byte(self: *OptWriter, code: u8, v: u8) void { + self.raw(code, &[_]u8{v}); + } + fn word(self: *OptWriter, code: u8, v: u16) void { + var t: [2]u8 = undefined; + std.mem.writeInt(u16, &t, v, .big); + self.raw(code, &t); + } + fn address(self: *OptWriter, code: u8, v: Ip4) void { + self.raw(code, &v); + } + fn end(self: *OptWriter) void { + assert(self.i < self.buf.len); + self.buf[self.i] = dhcp.opt_end; + self.i += 1; + } + }; + + /// Build and send one DHCP message. The RFC 2131 4.3.6 table is what decides which fields are + /// set: it is the part of DHCP that servers actually enforce, and getting `ciaddr` or the + /// server identifier wrong produces a NAK rather than an error message. + fn dhcpSend(self: *Stack, kind: u8) void { + const msg = self.tx[eth_hlen + ip4.hlen + udp.hlen ..][0..dhcp_min_msg_len]; + @memset(msg, 0); + + const renewing = self.dhcp.state == .renewing; + const rebinding = self.dhcp.state == .rebinding; + // RENEWING and REBINDING carry the bound address in `ciaddr` and no requested-IP option; + // SELECTING and REQUESTING carry zero and use option 50. lwIP makes the same distinction + // at `core/ipv4/dhcp.c:2026-2030`. + const use_ciaddr = renewing or rebinding; + + msg[dhcp.op] = dhcp.bootrequest; + msg[dhcp.htype] = @intCast(arp.hwtype_ethernet); + msg[dhcp.hlen] = 6; + msg[dhcp.hops] = 0; + wr32(msg, dhcp.xid, self.dhcp.xid); + wr16(msg, dhcp.secs, @intCast(@min(0xffff, (self.now_ms -% self.dhcp.started_ms) / 1000))); + // Ask the server to broadcast its reply. lwIP clears this flag + // (`core/ipv4/dhcp.c:2024-2025`: "we don't need the broadcast flag since we can receive + // unicast traffic before being fully configured"), and so can this stack - `ip4Input` has + // the explicit exemption for it. The flag is set anyway because a broadcast reply is the + // path with the fewest ways to fail on first bring-up: it needs no ARP entry at the server, + // no unicast-to-unconfigured-host handling in the AP, and no exemption in this file. + wr16(msg, dhcp.flags, dhcp.flag_broadcast); + if (use_ciaddr) wrIp(msg, dhcp.ciaddr, self.addr orelse ip_any); + @memcpy(msg[dhcp.chaddr..][0..6], &self.mac); + wr32(msg, dhcp.cookie, dhcp.magic_cookie); + + var o: OptWriter = .{ .buf = msg[dhcp.options..] }; + o.byte(dhcp.opt_msg_type, kind); + // RFC 2131 3.5: the maximum message size we can reassemble. One MTU minus the headers, + // which for this stack is also the largest datagram it can receive at all. + o.word(dhcp.opt_max_msg_size, @intCast(mtu - ip4.hlen - udp.hlen)); + if (kind == dhcp.request and !use_ciaddr) { + o.address(dhcp.opt_requested_ip, self.dhcp.offered); + o.address(dhcp.opt_server_id, self.dhcp.server); + } + // RFC 2131 4.3.6: a REQUEST in RENEWING/REBINDING must not carry a server identifier. + o.raw(dhcp.opt_param_list, &[_]u8{ + dhcp.opt_subnet_mask, + dhcp.opt_router, + dhcp.opt_dns, + dhcp.opt_lease_time, + dhcp.opt_t1, + dhcp.opt_t2, + }); + o.raw(dhcp.opt_hostname, "esp32p4"); + o.end(); + // Everything past the END option stays zero: RFC 2131 4.1 pads with option 0. + + const src = if (use_ciaddr) (self.addr orelse ip_any) else ip_any; + // RENEWING unicasts to the server that granted the lease; every other message is broadcast + // (RFC 2131 4.3.6, 4.4.5). + const dst = if (renewing) self.dhcp.server else ip_broadcast; + self.counters.dhcp_tx += 1; + _ = self.emitUdp(src, dhcp.client_port, dst, dhcp.server_port, dhcp_min_msg_len); + } + + /// One parsed option, or the end of the list. + const Opt = struct { code: u8, value: []const u8 }; + + /// Walk a DHCP option list. Stops at END, at a truncated option, or at the end of the buffer - + /// a malformed length must not walk off the datagram, which is the classic DHCP parser bug. + fn dhcpOption(body: []const u8, want: u8) ?[]const u8 { + if (body.len <= dhcp.options) return null; + var i: usize = dhcp.options; + while (i < body.len) { + const code = body[i]; + if (code == dhcp.opt_end) return null; + if (code == dhcp.opt_pad) { + i += 1; + continue; + } + if (i + 2 > body.len) return null; + const len = body[i + 1]; + if (i + 2 + len > body.len) return null; + if (code == want) return body[i + 2 ..][0..len]; + i += 2 + len; + } + return null; + } + + fn dhcpOptionIp(body: []const u8, want: u8) ?Ip4 { + const v = dhcpOption(body, want) orelse return null; + if (v.len < 4) return null; + return v[0..4].*; + } + + fn dhcpOptionU32(body: []const u8, want: u8) ?u32 { + const v = dhcpOption(body, want) orelse return null; + if (v.len != 4) return null; + return std.mem.readInt(u32, v[0..4], .big); + } + + fn dhcpInput(self: *Stack, src: Ip4, body: []const u8) void { + if (self.dhcp.state == .off) return; + if (body.len < dhcp.options) { + self.counters.rx_dropped += 1; + return; + } + if (body[dhcp.op] != dhcp.bootreply) return; + if (rd32(body, dhcp.cookie) != dhcp.magic_cookie) return; + if (rd32(body, dhcp.xid) != self.dhcp.xid) return; + // The reply must be about our hardware address, not a relayed one for someone else. + if (body[dhcp.hlen] != 6 or !std.mem.eql(u8, body[dhcp.chaddr..][0..6], &self.mac)) return; + + const kind_opt = dhcpOption(body, dhcp.opt_msg_type) orelse return; + if (kind_opt.len != 1) return; + self.counters.dhcp_rx += 1; + + switch (kind_opt[0]) { + dhcp.offer => { + if (self.dhcp.state != .selecting) return; + self.dhcp.offered = rdIp(body, dhcp.yiaddr); + if (std.mem.eql(u8, &self.dhcp.offered, &ip_any)) return; + // Option 54 is how the REQUEST names which offer it accepts. A server that omits + // it is out of spec; `siaddr` is the best fallback, and the sender is the last. + self.dhcp.server = dhcpOptionIp(body, dhcp.opt_server_id) orelse blk: { + const s = rdIp(body, dhcp.siaddr); + break :blk if (std.mem.eql(u8, &s, &ip_any)) src else s; + }; + self.dhcp.state = .requesting; + self.dhcpSend(dhcp.request); + self.dhcp.tries = 1; + self.dhcp.retry_ms = self.now_ms + dhcp_backoff_ms[0]; + }, + dhcp.ack => { + switch (self.dhcp.state) { + .requesting, .renewing, .rebinding => {}, + else => return, + } + const granted = rdIp(body, dhcp.yiaddr); + if (std.mem.eql(u8, &granted, &ip_any)) return; + self.dhcpBind(body, granted, src); + }, + dhcp.nak => { + switch (self.dhcp.state) { + .requesting, .renewing, .rebinding => {}, + else => return, + } + // RFC 2131 4.4.5: a NAK sends the client back to INIT. The lease is gone, so the + // address goes with it - continuing to use it would be squatting. + self.dhcpStart(); + }, + else => {}, + } + } + + fn dhcpBind(self: *Stack, body: []const u8, granted: Ip4, src: Ip4) void { + self.addr = granted; + self.mask = dhcpOptionIp(body, dhcp.opt_subnet_mask) orelse .{ 255, 255, 255, 0 }; + self.gw = dhcpOptionIp(body, dhcp.opt_router) orelse ip_any; + self.dns = dhcpOptionIp(body, dhcp.opt_dns); + if (dhcpOptionIp(body, dhcp.opt_server_id)) |s| self.dhcp.server = s else if (std.mem.eql(u8, &self.dhcp.server, &ip_any)) { + self.dhcp.server = src; + } + + // RFC 2131 3.3. A server that sends no lease time is out of spec; an hour is a safe + // assumption, being short enough that a wrong guess self-corrects. + const lease = dhcpOptionU32(body, dhcp.opt_lease_time) orelse 3600; + self.dhcp.lease_s = lease; + if (lease == 0xffff_ffff) { + // Infinite lease: never renew. + self.dhcp.t1_ms = std.math.maxInt(u64); + self.dhcp.t2_ms = std.math.maxInt(u64); + self.dhcp.expire_ms = std.math.maxInt(u64); + } else { + // The server may state T1 and T2 itself; otherwise lwIP's derivation, which is RFC + // 2131 4.4.5's: half the lease, and seven eighths of it + // (`core/ipv4/dhcp.c:757` and `:766`). + const t1 = dhcpOptionU32(body, dhcp.opt_t1) orelse lease / 2; + const t2 = dhcpOptionU32(body, dhcp.opt_t2) orelse (lease / 8) * 7; + const base = self.now_ms; + self.dhcp.t1_ms = base + @as(u64, @min(t1, lease)) * 1000; + self.dhcp.t2_ms = base + @as(u64, @min(t2, lease)) * 1000; + self.dhcp.expire_ms = base + @as(u64, lease) * 1000; + } + self.dhcp.state = .bound; + self.dhcp.tries = 0; + self.dhcp.retry_ms = 0; + self.announce(); + } + + fn dhcpTick(self: *Stack) void { + // T1 while bound: start renewing. RFC 2131 4.4.5 requires a fresh transaction id, and the + // first REQUEST goes out on this same tick rather than one backoff later - a state change + // that transmits nothing is how a lease quietly expires while the client thinks it is + // renewing. + if (self.dhcp.state == .bound and self.now_ms >= self.dhcp.t1_ms) { + self.dhcp.state = .renewing; + self.dhcp.xid = self.draw(); + self.dhcp.started_ms = self.now_ms; + self.dhcp.tries = 0; + self.dhcp.retry_ms = 0; + } + switch (self.dhcp.state) { + .off, .bound => return, + .selecting, .requesting, .renewing, .rebinding => {}, + } + if (self.dhcp.state == .renewing and self.now_ms >= self.dhcp.t2_ms) { + // T2: the granting server is not answering. Ask anyone. + self.dhcp.state = .rebinding; + self.dhcp.tries = 0; + self.dhcp.retry_ms = 0; + } + if ((self.dhcp.state == .renewing or self.dhcp.state == .rebinding) and + self.now_ms >= self.dhcp.expire_ms) + { + // The lease is over. Give up the address before asking again: keeping it would mean + // using an address the server may already have given away. + self.dhcpStart(); + return; + } + if (self.dhcp.retry_ms != 0 and self.now_ms < self.dhcp.retry_ms) return; + const kind: u8 = if (self.dhcp.state == .selecting) dhcp.discover else dhcp.request; + self.dhcpSend(kind); + const idx = @min(self.dhcp.tries, dhcp_backoff_ms.len - 1); + self.dhcp.retry_ms = self.now_ms + dhcp_backoff_ms[idx]; + if (self.dhcp.tries < 255) self.dhcp.tries += 1; + } + + // ================================================================================= DNS + // + // One question, QTYPE=A, QCLASS=IN, recursion desired, over the UDP above. No cache, no + // search list, no NS or SOA handling, no TCP fallback on a truncated answer: this resolves + // the one name a device that fetches one URL has to resolve, and says so with a named error + // when it cannot. + // + // The hard part of DNS parsing is not the header, it is that a name in a resource record may + // be a compression pointer into anywhere earlier in the message (RFC 1035 4.1.4). A parser + // that follows those without a bound hangs on a message that points at itself, and such a + // message costs an attacker two bytes. `dnsSkipName` is where that is dealt with. + + /// Encode a dotted name into RFC 1035 4.1.2 wire form: each label prefixed with its length, + /// terminated by the zero-length root label. Returns the encoded length. + /// + /// `out` must be at least `dns_qname_max`, which the length check below makes sufficient: a + /// name of `n` text bytes with no trailing dot encodes to exactly `n + 2`. + fn dnsEncodeName(name: []const u8, out: []u8) DnsError!usize { + assert(out.len >= dns_qname_max); + if (name.len > dns_name_max) return error.NameTooLong; + // A trailing dot is the root label written out, and `example.com.` names the same node as + // `example.com`. Everything after it - an empty final label - is not. + var rest = name; + if (rest.len != 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1]; + if (rest.len == 0) return error.NameInvalid; + + var o: usize = 0; + var labels = std.mem.splitScalar(u8, rest, '.'); + while (labels.next()) |label| { + // An empty label inside a name (`a..b`, or a leading dot) is not a name. + if (label.len == 0 or label.len > dns.label_max) return error.NameInvalid; + out[o] = @intCast(label.len); + @memcpy(out[o + 1 ..][0..label.len], label); + o += 1 + label.len; + } + out[o] = 0; + return o + 1; + } + + /// Compare an encoded name against the question we asked, ASCII-case-insensitively. RFC 4343: + /// label comparison ignores case, and a resolver is entitled to answer `0X4200.CAFE` to a + /// question about `0x4200.cafe`. Length bytes are 0-63 and so are never touched by the fold. + fn dnsQNameEql(a: []const u8, b: []const u8) bool { + if (a.len != b.len) return false; + for (a, b) |x, y| if (std.ascii.toLower(x) != std.ascii.toLower(y)) return false; + return true; + } + + /// Step over the name at `start` and return the offset of the byte after it - which for a + /// name that ends in a compression pointer is two bytes after the pointer, *not* wherever the + /// pointer led. `null` means the name is unparseable and the message is to be rejected. + /// + /// **Why this terminates.** Two independent bounds, because one of them is not enough: + /// + /// * A pointer must point strictly backwards (`target < here`). That alone is the check + /// most implementations stop at, and it is *not* sufficient: after jumping back the walk + /// moves forward again over labels, so a pointer at offset 12 to offset 10 and a label at + /// 10 that is two bytes long lands back at 12, and the pair loops forever with every + /// individual jump going backwards. + /// * So the jumps themselves are counted, and `dns_max_jumps` of them ends the name. That + /// is the bound that actually holds: the loop below does at most `dns_max_jumps` jumps + /// and, between them, walks labels whose lengths are positive, so it visits at most + /// `dns_max_jumps * msg.len` bytes and stops. A legitimate answer uses one jump per name. + fn dnsSkipName(msg: []const u8, start: usize) ?usize { + var i = start; + var jumps: u8 = 0; + // The offset after the name in the *message*, fixed by the first pointer taken. + var after: ?usize = null; + while (true) { + if (i >= msg.len) return null; + const len = msg[i]; + if (len & dns.ptr_mask == dns.ptr_mask) { + if (i + 1 >= msg.len) return null; + const target = (@as(usize, len & 0x3f) << 8) | msg[i + 1]; + if (after == null) after = i + 2; + if (target >= i) return null; + jumps += 1; + if (jumps > dns_max_jumps) return null; + i = target; + continue; + } + // 0x40 and 0x80 are the reserved label types of RFC 1035 4.1.4 / RFC 6891; neither is + // something this stack can skip a known number of bytes past, so neither is accepted. + if (len & dns.ptr_mask != 0) return null; + if (len == 0) return after orelse i + 1; + i += 1 + @as(usize, len); + if (i > msg.len) return null; + } + } + + /// Build and send the query held in `self.query`. Called for the first transmission and for + /// every retransmission, from the same fields, so the two cannot drift apart. + fn dnsSend(self: *Stack) void { + const src = self.addr orelse return; + const server = self.dns orelse return; + const qn_len: usize = self.query.qname_len; + const msg_len = dns.hlen + qn_len + 4; + const msg = self.tx[eth_hlen + ip4.hlen + udp.hlen ..][0..msg_len]; + wr16(msg, dns.id, self.query.id); + // RD only. Not AD, not CD, not EDNS0: this asks a recursive resolver for one A record and + // has nothing to validate with. + wr16(msg, dns.flags, dns.flag_rd); + wr16(msg, dns.qdcount, 1); + wr16(msg, dns.ancount, 0); + wr16(msg, dns.nscount, 0); + wr16(msg, dns.arcount, 0); + @memcpy(msg[dns.hlen..][0..qn_len], self.query.qname[0..qn_len]); + wr16(msg, dns.hlen + qn_len, dns.type_a); + wr16(msg, dns.hlen + qn_len + 2, dns.class_in); + self.counters.dns_tx += 1; + _ = self.emitUdp(src, self.query.local_port, server, dns.port, msg_len); + } + + fn dnsFail(self: *Stack, e: DnsError) void { + self.query.phase = .failed; + self.query.err = e; + } + + /// A datagram to the port the outstanding query was sent from. Called from inside `onFrame`. + /// + /// Everything that does not match the query is *ignored*, not failed: on a real network the + /// port this query owns will collect late answers to previous queries, scans, and whatever + /// else is loose on the segment, and any of those failing the query would be a denial of + /// service that costs one packet. Only a response that matches the source, the id and the + /// question can decide the query - and then it decides it either way. + fn dnsInput(self: *Stack, src: Ip4, sport: u16, msg: []const u8) void { + const server = self.dns orelse return; + if (!std.mem.eql(u8, &src, &server)) return; + if (sport != dns.port) return; + if (msg.len < dns.hlen) return; + if (rd16(msg, dns.id) != self.query.id) return; + + const flags = rd16(msg, dns.flags); + if (flags & dns.flag_qr == 0) return; // a query, not a response + if (rd16(msg, dns.qdcount) != 1) return; + + // The question, echoed. A server that answers a different question - or an attacker who + // guessed the id and the port but not the name - is not answering this. + const qn = self.query.qname[0..self.query.qname_len]; + var off = dns.hlen + qn.len + 4; + if (msg.len < off) return; + if (!dnsQNameEql(msg[dns.hlen..][0..qn.len], qn)) return; + if (rd16(msg, dns.hlen + qn.len) != dns.type_a) return; + if (rd16(msg, dns.hlen + qn.len + 2) != dns.class_in) return; + + self.counters.dns_rx += 1; + + const rcode = flags & dns.rcode_mask; + if (rcode != 0) { + self.dnsFail(if (rcode == dns.rcode_name_error) error.NameNotFound else error.DnsRefused); + return; + } + + // Walk the answer section and take the first A record. Walking rather than reading the + // first record is what makes a CNAME chain work: `0x4200.cafe` may answer with the CNAME + // and the A together, in that order, and a resolver that reads answer[0] gets a name. + var left = rd16(msg, dns.ancount); + while (left != 0) : (left -= 1) { + off = dnsSkipName(msg, off) orelse { + self.dnsFail(error.DnsMalformed); + return; + }; + if (off + dns.rr_fixed > msg.len) { + self.dnsFail(error.DnsMalformed); + return; + } + const rtype = rd16(msg, off); + const rclass = rd16(msg, off + 2); + const rdlen: usize = rd16(msg, off + 8); + off += dns.rr_fixed; + if (off + rdlen > msg.len) { + self.dnsFail(error.DnsMalformed); + return; + } + if (rtype == dns.type_a and rclass == dns.class_in and rdlen == 4) { + self.query.result = rdIp(msg, off); + self.query.phase = .done; + return; + } + off += rdlen; + } + // A well-formed answer with no A record in it: NODATA, or a CNAME chain this stack will + // not chase a second query down. + self.dnsFail(error.NameNotFound); + } + + fn dnsTick(self: *Stack) void { + if (self.query.phase != .waiting) return; + if (self.now_ms < self.query.retry_ms) return; + if (self.query.tries >= dns_backoff_ms.len) { + self.dnsFail(error.TimedOut); + return; + } + self.query.retry_ms = self.now_ms + dns_backoff_ms[self.query.tries]; + self.query.tries += 1; + self.counters.dns_retx += 1; + self.dnsSend(); + } + + /// Resolve `name` to an IPv4 address. + /// + /// **The protocol is `httpGet`'s, deliberately.** There is no clock and no `std.Io` here, so + /// there is nothing for a blocking call to block on: the first call sends the query and + /// returns `error.WouldBlock`, and the caller drives `tick` and `onFrame` and calls again + /// with the same name until an address or a real error comes back. + /// + /// const addr = while (true) { + /// stack.tick(hal.systimer.millis()); + /// while (transport.next()) |frame| stack.onFrame(frame); + /// if (stack.resolve("0x4200.cafe")) |a| break a + /// else |e| if (e != error.WouldBlock) return e; + /// }; + /// + /// The wait is bounded whether or not the caller bounds it: `dns_backoff_ms` retransmits + /// three times over 7 s and then answers `error.TimedOut`. Nothing here waits forever, and + /// the retransmissions happen in `tick`, so a caller that ticks and polls rarely still gets + /// them on time. + /// + /// One query is outstanding at a time. A call naming something else while one is in flight is + /// `error.Busy`; a call naming something else after one has finished starts a new query, + /// which is what makes the loop above safe to write for two names in a row. + pub fn resolve(self: *Stack, name: []const u8) DnsError!Ip4 { + // Encoded first, and compared in encoded form: `0x4200.cafe`, `0x4200.cafe.` and + // `0X4200.CAFE` are one name, and a caller that spells it differently between two polls + // of the same loop must not get `error.Busy` for it. + var wire: [dns_qname_max]u8 = undefined; + const wire_len = try dnsEncodeName(name, &wire); + const same = self.query.qname_len == wire_len and + dnsQNameEql(self.query.qname[0..wire_len], wire[0..wire_len]); + + switch (self.query.phase) { + .idle => {}, + .waiting => { + if (!same) return error.Busy; + return error.WouldBlock; + }, + // A finished query for this name is collected and the slot released. A finished query + // for a different name falls through and is replaced. + .done => if (same) { + self.query.phase = .idle; + return self.query.result; + }, + .failed => if (same) { + self.query.phase = .idle; + return self.query.err; + }, + } + + if (self.addr == null) return error.NoAddress; + if (self.dns == null) return error.NoDnsServer; + + @memcpy(self.query.qname[0..wire_len], wire[0..wire_len]); + self.query.qname_len = @intCast(wire_len); + self.query.id = @truncate(self.draw()); + // RFC 6335's dynamic range, as `tcpConnect` uses. Redrawn per query so a late answer to + // the previous one cannot be mistaken for this one even if the id happens to repeat. + self.query.local_port = @intCast(49152 + self.draw() % (65535 - 49152 + 1)); + self.query.result = ip_any; + self.query.err = error.WouldBlock; + self.query.phase = .waiting; + self.query.tries = 1; + self.query.retry_ms = self.now_ms + dns_backoff_ms[0]; + self.dnsSend(); + return error.WouldBlock; + } + + // ================================================================================= TCP + // + // One connection, client side only, one unacknowledged segment at a time. The send side is a + // single static buffer holding the whole request, so "retransmission" is always "send from + // `snd_una` again" and there is no retransmission queue. The receive side has no reassembly + // buffer at all: a segment that is not the next one expected is answered with a duplicate ACK + // and dropped. Three of those is a fast-retransmit signal to any modern peer, so the common + // case of one lost segment costs a round trip rather than an RTO - but a reordered segment + // costs a retransmission that a reassembly buffer would have avoided. That is the price of not + // having one, and on a Wi-Fi link where reordering is rare it is the right price. + + fn emitTcp(self: *Stack, flags: u8, seq: u32, data: []const u8, mss_opt: bool) bool { + const src = self.addr orelse return false; + const opt_len: usize = if (mss_opt) 4 else 0; + const seg_len = tcp.hlen + opt_len + data.len; + assert(seg_len <= mtu - ip4.hlen); + const seg = self.tx[eth_hlen + ip4.hlen ..][0..seg_len]; + + wr16(seg, tcp.src_port, self.tcp.local_port); + wr16(seg, tcp.dst_port, self.tcp.peer_port); + wr32(seg, tcp.seq, seq); + wr32(seg, tcp.ack, self.tcp.rcv_nxt); + const words: u16 = @intCast((tcp.hlen + opt_len) / 4); + wr16(seg, tcp.hdrlen_flags, (words << 12) | flags); + wr16(seg, tcp.window, self.rcvWindow()); + wr16(seg, tcp.chksum, 0); + wr16(seg, tcp.urgent, 0); + if (mss_opt) { + seg[tcp.hlen] = tcp.opt_mss; + seg[tcp.hlen + 1] = 4; + wr16(seg, tcp.hlen + 2, tcp_mss); + } + if (data.len != 0) @memcpy(seg[tcp.hlen + opt_len ..], data); + wr16(seg, tcp.chksum, transportChecksum(src, self.tcp.peer_ip, .tcp, seg)); + + self.counters.tcp_tx += 1; + return self.emitIp(src, self.tcp.peer_ip, .tcp, seg_len); + } + + /// The window to advertise: real back-pressure, not a constant. Everything accepted is consumed + /// synchronously into the HTTP head buffer or the caller's `out`, so the window is whatever + /// room is left there, capped at one MSS. Advertising a fixed window while the consumer had no + /// room left would turn "the response is bigger than your buffer" into a silently dropped + /// segment and an RTO storm. + fn rcvWindow(self: *Stack) u16 { + const room: usize = switch (self.http.phase) { + .head => (http_head_max - self.http.head_len) + self.http.out.len, + // Chunked framing - the CRLF closing each chunk, the zero-length chunk, the trailer + // section and the final CRLF - is consumed and discarded rather than delivered, so it + // needs window that `out` does not account for. Without this a body that exactly + // fills `out` closes the window before its own terminator can arrive, and the request + // stalls until the RTO gives up on a peer that is behaving perfectly. + .body => (self.http.out.len - self.http.out_len) + + @as(usize, if (self.http.chunked) http_framing_max + 16 else 0), + else => tcp_window, + }; + return @intCast(@min(room, tcp_window)); + } + + /// Reset the connection and fail the request. RST is sent unless the peer sent one. + fn tcpAbort(self: *Stack, err: HttpError, send_rst: bool) void { + if (send_rst and self.tcp.state != .closed and self.tcp.state != .arp_wait) { + _ = self.emitTcp(tcp.rst | tcp.ack_f, self.tcp.snd_nxt, &.{}, false); + } + self.tcp.state = .closed; + if (self.http.phase == .head or self.http.phase == .body) { + self.http.phase = .failed; + self.http.err = err; + } + } + + /// Set up the connection block for a fresh connect. Written field by field on purpose: the + /// obvious `self.tcp = .{ ... }` would assign `tx` from the struct's `undefined` default, which + /// in a safe build overwrites the request bytes with 0xAA, and in a release build memsets half + /// a kilobyte for nothing. + fn tcpConnect(self: *Stack, peer: Ip4, port: u16) void { + // A fresh ephemeral port every time. RFC 6335's dynamic range is 49152-65535, and moving + // through it is what makes the short TIME_WAIT above safe. + const span: u32 = 65535 - 49152 + 1; + const iss = self.draw(); + self.tcp.state = .arp_wait; + self.tcp.peer_ip = peer; + self.tcp.peer_port = port; + self.tcp.local_port = @intCast(49152 + self.draw() % span); + self.tcp.iss = iss; + self.tcp.snd_una = iss; + self.tcp.snd_nxt = iss; + self.tcp.snd_wnd = 0; + self.tcp.snd_mss = tcp_default_mss; + self.tcp.fin_queued = false; + self.tcp.peer_fin = false; + self.tcp.rcv_nxt = 0; + self.tcp.rto_deadline_ms = 0; + self.tcp.rto_ms = tcp_rto_initial_ms; + self.tcp.retries = 0; + self.tcp.close_deadline_ms = 0; + self.tcp.tx_len = 0; + self.arp_tries = 0; + self.arp_retry_ms = 0; + } + + fn tcpSendSyn(self: *Stack) void { + self.tcp.state = .syn_sent; + self.tcp.snd_nxt = self.tcp.iss +% 1; + _ = self.emitTcp(tcp.syn, self.tcp.iss, &.{}, true); + self.armRto(); + } + + fn armRto(self: *Stack) void { + self.tcp.rto_deadline_ms = self.now_ms + self.tcp.rto_ms; + } + + /// Send as much of the request as the peer's window and MSS allow, then the FIN if the whole + /// request has gone out. One segment in flight, so this sends at most one segment per call. + /// + /// Only `established` sends: receiving the peer's FIN does not move the state, it sets + /// `peer_fin`, so this stays the single place that decides what goes on the wire and the state + /// only ever changes when a FIN of ours actually leaves. + fn tcpSendData(self: *Stack) void { + if (self.tcp.state != .established) return; + // Nothing outstanding is the precondition for sending: this is the fixed window of one. + if (seqLt(self.tcp.snd_una, self.tcp.snd_nxt)) return; + + const end = self.tcp.dataEnd(); + if (seqLt(self.tcp.snd_nxt, end)) { + const off: usize = self.tcp.snd_nxt -% self.tcp.dataStart(); + const remaining = self.tcp.tx_len - off; + const window: usize = self.tcp.snd_una +% self.tcp.snd_wnd -% self.tcp.snd_nxt; + const n = @min(@min(remaining, self.tcp.snd_mss), @max(window, 1)); + // PSH on the last segment of the request: the peer's application should see it without + // waiting for more. RFC 793 has no requirement here; every HTTP server expects it. + const last = off + n == self.tcp.tx_len; + const flags: u8 = tcp.ack_f | (if (last) tcp.psh else 0); + const seq = self.tcp.snd_nxt; + self.tcp.snd_nxt = seq +% @as(u32, @intCast(n)); + _ = self.emitTcp(flags, seq, self.tcp.tx[off..][0..n], false); + self.armRto(); + return; + } + if (self.tcp.fin_queued and self.tcp.snd_nxt == end) { + const seq = self.tcp.snd_nxt; + self.tcp.snd_nxt = seq +% 1; + _ = self.emitTcp(tcp.fin | tcp.ack_f, seq, &.{}, false); + self.armRto(); + // RFC 793's FIN-WAIT-1 if we closed first, its CLOSING/LAST-ACK if the peer did. Both + // of the latter are `last_ack` here: they differ only in which ACK is still owed, and + // `closeCheck` settles that from the sequence numbers. + self.tcp.state = if (self.tcp.peer_fin) .last_ack else .fin_wait_1; + } + } + + /// Half-close: everything we mean to send has been sent, so send FIN once the data is out. + fn tcpFinish(self: *Stack) void { + if (self.tcp.fin_queued) return; + self.tcp.fin_queued = true; + self.tcpSendData(); + } + + /// Both directions closed and our FIN acknowledged: nothing is left in flight, so the + /// connection block can be released after TIME_WAIT. Called once at the end of every segment, + /// which covers both orders of arrival - the peer's FIN then its ACK, or the reverse. + fn closeCheck(self: *Stack) void { + switch (self.tcp.state) { + .fin_wait_1, .fin_wait_2, .last_ack => {}, + else => return, + } + if (!self.tcp.peer_fin) return; + if (self.tcp.snd_una != self.tcp.snd_nxt) return; + self.tcp.state = .time_wait; + self.tcp.rto_deadline_ms = 0; + self.tcp.close_deadline_ms = self.now_ms + tcp_time_wait_ms; + } + + fn tcpInput(self: *Stack, src: Ip4, dst: Ip4, seg: []const u8) void { + if (seg.len < tcp.hlen) { + self.counters.rx_dropped += 1; + return; + } + const hf = rd16(seg, tcp.hdrlen_flags); + const hlen = @as(usize, hf >> 12) * 4; + if (hlen < tcp.hlen or hlen > seg.len) { + self.counters.rx_dropped += 1; + return; + } + if (!transportChecksumOk(src, dst, .tcp, seg, rd16(seg, tcp.chksum))) { + self.counters.checksum_bad += 1; + return; + } + const flags: u8 = @truncate(hf & 0x3f); + const sport = rd16(seg, tcp.src_port); + const dport = rd16(seg, tcp.dst_port); + + if (self.tcp.state == .closed or + dport != self.tcp.local_port or + sport != self.tcp.peer_port or + !std.mem.eql(u8, &src, &self.tcp.peer_ip)) + { + // Not for our one connection. A real stack would RST; a client with no listening port + // gains nothing by telling a scanner it is there. + self.counters.rx_dropped += 1; + return; + } + self.counters.tcp_rx += 1; + + const seq = rd32(seg, tcp.seq); + const ackno = rd32(seg, tcp.ack); + const data = seg[hlen..]; + + if (flags & tcp.rst != 0) { + self.counters.tcp_rst_rx += 1; + // RFC 5961 3: only a RST whose sequence number is the next one expected may tear the + // connection down. Anything else gets a challenge ACK, which is also what stops a + // blind off-path reset. + if (self.tcp.state == .syn_sent) { + // In SYN-SENT the RST is validated by its ACK instead: there is no rcv_nxt yet. + if (flags & tcp.ack_f != 0 and ackno == self.tcp.snd_nxt) self.tcpAbort(error.ConnectionReset, false); + return; + } + if (seq == self.tcp.rcv_nxt) { + self.tcpAbort(error.ConnectionReset, false); + } else { + _ = self.emitTcp(tcp.ack_f, self.tcp.snd_nxt, &.{}, false); + } + return; + } + + if (self.tcp.state == .syn_sent) { + if (flags & tcp.syn == 0) { + self.counters.rx_dropped += 1; + return; + } + if (flags & tcp.ack_f == 0) { + // A simultaneous open. Nothing on the other end of an HTTP GET does this. + self.counters.rx_dropped += 1; + return; + } + if (ackno != self.tcp.iss +% 1) { + // Not acknowledging our SYN: an old duplicate. RFC 793 says reset it. + _ = self.emitTcp(tcp.rst, ackno, &.{}, false); + return; + } + self.tcp.rcv_nxt = seq +% 1; + self.tcp.snd_una = ackno; + self.tcp.snd_wnd = rd16(seg, tcp.window); + self.tcp.snd_mss = parseMss(seg[tcp.hlen..hlen]) orelse tcp_default_mss; + self.tcp.state = .established; + self.tcp.rto_ms = tcp_rto_initial_ms; + self.tcp.retries = 0; + // The ACK completing the handshake carries the first data segment, which is one frame + // saved and what every other stack does. + self.tcp.rto_deadline_ms = 0; + self.tcpSendData(); + if (self.tcp.snd_nxt == self.tcp.snd_una) { + // Nothing to send yet; the handshake still needs acknowledging. + _ = self.emitTcp(tcp.ack_f, self.tcp.snd_nxt, &.{}, false); + } + return; + } + + // A duplicate SYN in an established connection is either a retransmitted SYN whose ACK was + // lost - answer with an ACK - or an attack. Never a reason to re-open. + if (flags & tcp.syn != 0 and seqLt(seq, self.tcp.rcv_nxt)) { + _ = self.emitTcp(tcp.ack_f, self.tcp.snd_nxt, &.{}, false); + return; + } + + if (flags & tcp.ack_f != 0) self.tcpAck(ackno, rd16(seg, tcp.window)); + + // ---- receive side + var payload = data; + var accept = false; + if (payload.len != 0) { + if (seqLe(seq, self.tcp.rcv_nxt) and seqGt(seq +% @as(u32, @intCast(payload.len)), self.tcp.rcv_nxt)) { + // Overlaps what we already have: trim the duplicate prefix. A retransmission after + // a lost ACK arrives exactly like this, and rejecting it would deadlock. + const skip: usize = self.tcp.rcv_nxt -% seq; + payload = payload[skip..]; + accept = true; + } else if (seqLe(seq +% @as(u32, @intCast(payload.len)), self.tcp.rcv_nxt)) { + // Wholly old. Re-acknowledge so the peer stops. + _ = self.emitTcp(tcp.ack_f, self.tcp.snd_nxt, &.{}, false); + return; + } else { + // Out of order, and there is nowhere to keep it. The duplicate ACK below is the + // signal that makes the peer resend. + _ = self.emitTcp(tcp.ack_f, self.tcp.snd_nxt, &.{}, false); + return; + } + } + + if (accept) { + // Never accept more than the window we advertised. + const room = self.rcvWindow(); + if (payload.len > room) payload = payload[0..room]; + self.tcp.rcv_nxt +%= @intCast(payload.len); + // Consuming the data may itself put a segment on the wire - completing the body sends + // our FIN - and every segment carries `rcv_nxt`, so a separate ACK would be a wasted + // frame. Counting is the honest way to know: anything emitted has already acknowledged + // this data, and nothing emitted means we still owe an ACK. + const tx_before = self.counters.tcp_tx; + self.httpOnData(payload); + if (self.tcp.state == .closed) return; // httpOnData failed and aborted + if (self.counters.tcp_tx == tx_before) { + _ = self.emitTcp(tcp.ack_f, self.tcp.snd_nxt, &.{}, false); + } + } + + // ---- FIN, in order only. An out-of-order FIN names a sequence number beyond data we have + // not seen, and honouring it would close the connection over a hole. + if (flags & tcp.fin != 0) { + const fin_seq = seq +% @as(u32, @intCast(data.len)); + const in_order = fin_seq == self.tcp.rcv_nxt; + // A FIN we have already consumed, arriving again because our ACK was lost. It must be + // re-acknowledged or the peer retransmits until it gives up and resets. + const duplicate = self.tcp.peer_fin and fin_seq +% 1 == self.tcp.rcv_nxt; + if (in_order and !self.tcp.peer_fin) { + self.tcp.rcv_nxt +%= 1; + self.tcp.peer_fin = true; + self.httpOnEof(); + } + if (in_order or duplicate) { + // Our own FIN, if it has not gone yet, acknowledges the peer's on the way out. + const tx_before = self.counters.tcp_tx; + self.tcpFinish(); + if (self.counters.tcp_tx == tx_before) { + _ = self.emitTcp(tcp.ack_f, self.tcp.snd_nxt, &.{}, false); + } + } + } + + self.closeCheck(); + } + + fn tcpAck(self: *Stack, ackno: u32, window: u16) void { + // An ACK ahead of what we sent is invalid; an old one is a duplicate. + if (seqGt(ackno, self.tcp.snd_nxt)) return; + self.tcp.snd_wnd = window; + if (seqLe(ackno, self.tcp.snd_una)) { + // Duplicate ACK. With one segment in flight there is nothing to fast-retransmit. + return; + } + self.tcp.snd_una = ackno; + self.tcp.retries = 0; + self.tcp.rto_ms = tcp_rto_initial_ms; + if (self.tcp.snd_una == self.tcp.snd_nxt) { + self.tcp.rto_deadline_ms = 0; // nothing outstanding + } else { + self.armRto(); + } + if (self.tcp.state == .fin_wait_1 and self.tcp.snd_una == self.tcp.snd_nxt) { + self.tcp.state = .fin_wait_2; + self.tcp.close_deadline_ms = self.now_ms + tcp_fin_wait2_ms; + } + // Window opened or data acknowledged: there may be more to send. + self.tcpSendData(); + } + + /// RFC 793 3.1 option format: kind, then for kinds above 1 a length byte covering both. + fn parseMss(opts: []const u8) ?u16 { + var i: usize = 0; + while (i < opts.len) { + const kind = opts[i]; + if (kind == tcp.opt_end) return null; + if (kind == tcp.opt_nop) { + i += 1; + continue; + } + if (i + 2 > opts.len) return null; + const len = opts[i + 1]; + if (len < 2 or i + len > opts.len) return null; + if (kind == tcp.opt_mss and len == 4) { + const v = rd16(opts, i + 2); + // Below RFC 1122's floor a peer's MSS is not believable; above our MTU it cannot + // be honoured anyway. + return @min(@max(v, 64), tcp_mss); + } + i += len; + } + return null; + } + + fn tcpTick(self: *Stack) void { + switch (self.tcp.state) { + .closed => {}, + .arp_wait => { + if (self.arpLookup(self.tcpNextHop())) |_| { + self.tcpSendSyn(); + return; + } + if (self.arp_retry_ms != 0 and self.now_ms < self.arp_retry_ms) return; + if (self.arp_tries >= arp_max_tries) { + self.tcpAbort(error.HostUnreachable, false); + return; + } + self.arpRequest(self.tcpNextHop()); + self.arp_tries += 1; + self.arp_retry_ms = self.now_ms + arp_retry_ms; + }, + .fin_wait_2 => { + // Our FIN is acknowledged and nothing is outstanding, so there is no RTO to run: + // the only thing left is the peer's FIN, and this is how long we wait for it. + if (self.now_ms >= self.tcp.close_deadline_ms) self.tcp.state = .closed; + }, + .time_wait => { + if (self.now_ms >= self.tcp.close_deadline_ms) self.tcp.state = .closed; + }, + else => { + if (self.tcp.rto_deadline_ms == 0) return; + if (self.now_ms < self.tcp.rto_deadline_ms) return; + if (self.tcp.retries >= tcp_max_retries) { + self.tcpAbort(error.TimedOut, true); + return; + } + self.tcp.retries += 1; + self.counters.tcp_retx += 1; + // Exponential backoff, RFC 6298 5.5. + self.tcp.rto_ms = @min(self.tcp.rto_ms * 2, tcp_rto_max_ms); + self.tcpRetransmit(); + }, + } + } + + fn tcpNextHop(self: *Stack) Ip4 { + return if (self.onLink(self.tcp.peer_ip)) self.tcp.peer_ip else self.gw; + } + + /// Go back to `snd_una` and send again. With one segment in flight this is the whole of + /// retransmission: there is no queue to walk and no partial-ACK case to handle. + fn tcpRetransmit(self: *Stack) void { + const una = self.tcp.snd_una; + if (una == self.tcp.iss) { + // The SYN. Its MSS option must be repeated: a peer that only ever sees the + // retransmission would otherwise assume 536. + self.tcp.snd_nxt = self.tcp.iss; + self.tcpSendSyn(); + return; + } + const end = self.tcp.dataEnd(); + if (seqLt(una, end)) { + self.tcp.snd_nxt = una; + self.tcpSendData(); + return; + } + if (self.tcp.fin_queued and una == end) { + self.tcp.snd_nxt = una; + // `tcpSendData` re-sends the FIN and re-arms, but it refuses to run in FIN_WAIT_1 + // (which is where a lost FIN leaves us), so the segment is emitted directly. + _ = self.emitTcp(tcp.fin | tcp.ack_f, una, &.{}, false); + self.tcp.snd_nxt = una +% 1; + self.armRto(); + return; + } + // Nothing identifiable outstanding: a bare ACK, which costs one frame and cannot hurt. + _ = self.emitTcp(tcp.ack_f, self.tcp.snd_nxt, &.{}, false); + self.armRto(); + } + + // ================================================================================ HTTP + + /// Fetch `path` from `host:port` over HTTP/1.1 and write the body to `out`, sending the + /// address literal as the `Host:` header. Exactly `httpGetHost(host, null, ...)`; see there + /// for the protocol, which is the whole of how this is used. + pub fn httpGet(self: *Stack, host: [4]u8, port: u16, path: []const u8, out: []u8) HttpError!usize { + return self.httpGetHost(host, null, port, path, out); + } + + /// Fetch `path` from `host:port` over HTTP/1.1 and write the body to `out`. + /// + /// `name` is the `Host:` header. `null` sends the address literal - `Host: 192.168.1.90` - + /// which is right for a bare address and is what `httpGet` does. A name is what a + /// name-based virtual host requires: one address behind a CDN serves thousands of sites and + /// picks between them on this header alone, so `Host: 104.21.46.8` gets the CDN's own error + /// page and never the site. The address is still where the connection goes; the name only + /// ever appears in the header, and nothing here resolves it - `resolve` does that, and the + /// two are separate because a caller may have the address already. + /// + /// The port is appended as `:port` only when it is not 80, name or no name. RFC 7230 5.4. + /// + /// **This does not block, and it is not a one-shot call.** There is no `std.Io` here and no + /// clock, so there is nothing for a blocking call to block on: the frames that carry the + /// response arrive through `onFrame` and time advances through `tick`, both of which are the + /// caller's to drive. So the first call starts the request and returns `error.WouldBlock`, and + /// the caller keeps driving and keeps calling with the same arguments until it returns a length: + /// + /// while (true) { + /// stack.tick(hal.systimer.millis()); + /// while (transport.next()) |frame| stack.onFrame(frame); + /// if (stack.httpGetHost(addr, "0x4200.cafe", 80, "/", &buf)) |n| break :done buf[0..n] + /// else |e| if (e != error.WouldBlock) return e; + /// } + /// + /// `out` is borrowed until the request completes: it is written to from inside `onFrame` as the + /// body arrives, so it must not move or be reused meanwhile. Calling with different arguments + /// while a request is in flight returns `error.Busy` rather than quietly abandoning the first, + /// and `name` is one of those arguments: two requests to one address for one path but + /// different virtual hosts are different requests. + /// + /// `Content-Length` is honoured, and so is `Transfer-Encoding: chunked` - the body handed back + /// is decoded, with no framing bytes in it. A response with neither ends at the peer's FIN, + /// which is why the request says `Connection: close`. + pub fn httpGetHost( + self: *Stack, + host: [4]u8, + name: ?[]const u8, + port: u16, + path: []const u8, + out: []u8, + ) HttpError!usize { + // The name is folded into the path hash rather than given a field of its own: `Stack` has + // a 4 KiB budget, and what this has to distinguish is "the same call again" from "a + // different call", which a hash does exactly. Seeding with the name's hash rather than + // concatenating keeps `null` (seed 0) distinct from any name, including the empty one. + const req_hash = std.hash.Wyhash.hash( + if (name) |nm| std.hash.Wyhash.hash(0x486f_7374, nm) else 0, + path, + ); + switch (self.http.phase) { + .idle => {}, + .head, .body => { + if (!std.mem.eql(u8, &self.http.req_host, &host) or + self.http.req_port != port or + self.http.req_hash != req_hash or + self.http.out.ptr != out.ptr or + self.http.out.len != out.len) return error.Busy; + return error.WouldBlock; + }, + .complete => { + const n = self.http.out_len; + self.http.phase = .idle; + return n; + }, + .failed => { + const e = self.http.err; + self.http.phase = .idle; + return e; + }, + } + + if (self.addr == null) return error.NoAddress; + + // The request, built once into the TCP send buffer where it stays until acknowledged. + var w: RequestWriter = .{ .buf = &self.tcp.tx }; + w.str("GET "); + w.str(if (path.len == 0) "/" else path); + w.str(" HTTP/1.1\r\nHost: "); + if (name) |nm| w.str(nm) else w.ipv4(host); + if (port != 80) { + w.str(":"); + w.dec(port); + } + // Connection: close is not politeness, it is the framing: it is what makes a response with + // no Content-Length terminable, and it is what makes the peer's FIN the end of the body. + w.str("\r\nUser-Agent: zig-p4/0.1\r\nAccept: */*\r\nConnection: close\r\n\r\n"); + if (w.overflow) return error.RequestTooLong; + + self.http = .{ + .phase = .head, + .out = out, + .req_host = host, + .req_port = port, + .req_hash = req_hash, + }; + self.tcpConnect(host, port); + self.tcp.tx_len = w.i; + self.tcp.fin_queued = false; + // A MAC address may already be known, in which case the SYN goes out now rather than one + // `tick` later. + if (self.arpLookup(self.tcpNextHop()) != null) { + self.tcpSendSyn(); + } else { + self.arpRequest(self.tcpNextHop()); + self.arp_tries = 1; + self.arp_retry_ms = self.now_ms + arp_retry_ms; + } + return error.WouldBlock; + } + + /// A bounds-checked append into a fixed buffer. Overflow is recorded, not asserted: a caller's + /// long path is a request error, not a bug in this file. + const RequestWriter = struct { + buf: []u8, + i: usize = 0, + overflow: bool = false, + + fn str(self: *RequestWriter, s: []const u8) void { + if (self.overflow or self.i + s.len > self.buf.len) { + self.overflow = true; + return; + } + @memcpy(self.buf[self.i..][0..s.len], s); + self.i += s.len; + } + fn dec(self: *RequestWriter, v: u32) void { + var tmp: [10]u8 = undefined; + var n: usize = 0; + var x = v; + while (true) { + tmp[n] = '0' + @as(u8, @intCast(x % 10)); + n += 1; + x /= 10; + if (x == 0) break; + } + while (n > 0) { + n -= 1; + self.str(tmp[n .. n + 1]); + } + } + fn ipv4(self: *RequestWriter, a: Ip4) void { + for (a, 0..) |b, k| { + if (k != 0) self.str("."); + self.dec(b); + } + } + }; + + /// Fail the request and reset the connection. The phase is set before `tcpAbort`, which would + /// otherwise overwrite `err` with its own argument on the way past. + fn httpFail(self: *Stack, e: HttpError) void { + self.http.phase = .failed; + self.http.err = e; + self.tcpAbort(e, true); + } + + /// In-order TCP payload. Called from inside `onFrame`. + fn httpOnData(self: *Stack, bytes: []const u8) void { + var rest = bytes; + if (self.http.phase == .head) { + const room = http_head_max - self.http.head_len; + const n = @min(room, rest.len); + @memcpy(self.http.head[self.http.head_len..][0..n], rest[0..n]); + const scan_from = self.http.head_len -| 3; + self.http.head_len += n; + rest = rest[n..]; + + const blank = std.mem.indexOfPos(u8, self.http.head[0..self.http.head_len], scan_from, "\r\n\r\n") orelse { + if (self.http.head_len == http_head_max) self.httpFail(error.HttpHeadersTooLong); + return; + }; + const head_end = blank + 4; + // Anything the head buffer swallowed past the blank line is body. This is the case a + // test has to cover deliberately, because it only happens when a segment boundary does + // not coincide with the end of the headers - which on a real server is most of the time. + const spill = self.http.head[head_end..self.http.head_len]; + self.parseHead(self.http.head[0..blank]) catch |e| { + self.httpFail(e); + return; + }; + self.http.phase = .body; + // `spill` aliases `self.http.head`, and `httpBody` only ever writes to `self.http.out`, + // so passing it through is safe. Copy first if that ever stops being true. + // + // It is called unconditionally, even when `spill` is empty: that is what completes a + // `Content-Length: 0` response, whose body is over the moment its headers are. + self.httpBody(spill); + if (self.http.phase != .body) return; + } + if (rest.len != 0) self.httpBody(rest); + } + + /// Status line and headers, without the terminating blank line. + fn parseHead(self: *Stack, head: []const u8) HttpError!void { + var lines = std.mem.splitSequence(u8, head, "\r\n"); + const status_line = lines.next() orelse return error.HttpMalformed; + // "HTTP/1.1 200 OK": version, space, three digits. + if (status_line.len < 12) return error.HttpMalformed; + if (!std.mem.startsWith(u8, status_line, "HTTP/1.")) return error.HttpMalformed; + if (status_line[8] != ' ') return error.HttpMalformed; + var code: u16 = 0; + for (status_line[9..12]) |c| { + if (c < '0' or c > '9') return error.HttpMalformed; + code = code * 10 + (c - '0'); + } + self.http.status = code; + self.http.content_length = null; + self.http.chunked = false; + + while (lines.next()) |line| { + if (line.len == 0) continue; + const colon = std.mem.indexOfScalar(u8, line, ':') orelse continue; + const name = line[0..colon]; + const value = std.mem.trim(u8, line[colon + 1 ..], " \t"); + // RFC 7230 3.2: field names are case-insensitive. Servers vary, and a stack that + // compares them exactly works against nginx and fails against something else. + if (std.ascii.eqlIgnoreCase(name, "content-length")) { + self.http.content_length = std.fmt.parseInt(usize, value, 10) catch + return error.HttpMalformed; + } else if (std.ascii.eqlIgnoreCase(name, "transfer-encoding")) { + // RFC 7230 3.3.1: the final coding decides the framing. Exactly two are + // understood - `chunked`, which frames the body, and `identity`, which does not - + // and a list, or a coding that transforms the bytes, is refused. Guessing at + // `gzip` would hand the caller compressed data and call it a body. + if (std.ascii.eqlIgnoreCase(value, "chunked")) { + self.http.chunked = true; + } else if (!std.ascii.eqlIgnoreCase(value, "identity")) { + return error.UnsupportedTransferEncoding; + } + } + } + if (self.http.chunked) { + // RFC 7230 3.3.3 case 3: when both are present the chunked framing wins and + // `Content-Length` must be ignored - it is the classic request-smuggling + // disagreement, and a response that carries both is not to be believed twice. + self.http.content_length = null; + self.http.chunk = .size; + self.http.chunk_left = 0; + self.http.chunk_digit = false; + self.http.chunk_skip = 0; + } + // A response whose body cannot possibly fit is refused now rather than after copying most + // of it: the caller gets a clean error instead of a truncated buffer. A chunked response + // announces no total, so its equivalent check is per chunk, in `httpChunkedBody`. + if (self.http.content_length) |len| { + if (len > self.http.out.len) return error.StreamTooLong; + } + } + + fn httpBody(self: *Stack, bytes: []const u8) void { + if (self.http.chunked) return self.httpChunkedBody(bytes); + var b = bytes; + if (self.http.content_length) |len| { + const want = len - self.http.out_len; + if (b.len > want) b = b[0..want]; + } + if (self.http.out_len + b.len > self.http.out.len) { + self.httpFail(error.StreamTooLong); + return; + } + @memcpy(self.http.out[self.http.out_len..][0..b.len], b); + self.http.out_len += b.len; + if (self.http.content_length) |len| { + if (self.http.out_len >= len) self.httpComplete(); + } + } + + /// Charge `n` bytes against the framing budget. False means the request has been failed and + /// the decoder must stop. + fn chunkSkip(self: *Stack, n: usize) bool { + const total = @as(usize, self.http.chunk_skip) + n; + if (total > http_framing_max) { + self.httpFail(error.HttpHeadersTooLong); + return false; + } + self.http.chunk_skip = @intCast(total); + return true; + } + + /// RFC 7230 4.1 chunked decoding, resumable between any two bytes. + /// + /// The decoder's whole position lives in `http.chunk`, `chunk_left`, `chunk_digit` and + /// `chunk_skip`, and `bytes` is whatever the last segment happened to carry. Nothing is + /// buffered and nothing is looked ahead at: a size split across two segments accumulates a + /// digit at a time, a CRLF split across two segments is two states, and a chunk's data is + /// copied out as it arrives however it is cut up. That is not a hypothetical - a 1,460-byte + /// segment ends where the server's writes ended, which is nowhere in particular. + /// + /// `out` receives decoded data only. No size, no extension, no CRLF and no trailer byte is + /// ever copied into it, and every failure is a named error rather than a short body. + fn httpChunkedBody(self: *Stack, bytes: []const u8) void { + var b = bytes; + while (b.len != 0) { + switch (self.http.chunk) { + .size => { + const c = b[0]; + const digit: ?u8 = switch (c) { + '0'...'9' => c - '0', + 'a'...'f' => c - 'a' + 10, + 'A'...'F' => c - 'A' + 10, + else => null, + }; + b = b[1..]; + if (digit) |d| { + // Checked, not truncated: a size that does not fit `usize` is a malformed + // message, and wrapping it would turn a hostile header into a short read + // that looks like a complete body. + if (self.http.chunk_left > (std.math.maxInt(usize) - @as(usize, d)) / 16) { + self.httpFail(error.HttpChunkMalformed); + return; + } + self.http.chunk_left = self.http.chunk_left * 16 + d; + self.http.chunk_digit = true; + continue; + } + // RFC 7230 4.1 is `1*HEXDIG`. Without this an empty line reads as a chunk of + // size zero, which is the terminator, which ends the body early. + if (!self.http.chunk_digit) { + self.httpFail(error.HttpChunkMalformed); + return; + } + self.http.chunk_skip = 0; + switch (c) { + ';' => self.http.chunk = .ext, + '\r' => self.http.chunk = .size_lf, + else => { + self.httpFail(error.HttpChunkMalformed); + return; + }, + } + }, + .ext => { + // chunk-ext is skipped whole: nothing here depends on one, so the only thing + // that matters is finding the CR that ends the header - possibly not in this + // segment at all. + const cr = std.mem.indexOfScalar(u8, b, '\r'); + const n = cr orelse b.len; + if (!self.chunkSkip(n)) return; + b = b[n..]; + if (cr != null) { + b = b[1..]; + self.http.chunk = .size_lf; + } + }, + .size_lf => { + if (b[0] != '\n') { + self.httpFail(error.HttpChunkMalformed); + return; + } + b = b[1..]; + if (self.http.chunk_left == 0) { + // The zero-length chunk. What follows is the trailer section, and the + // body is not complete until its final CRLF. + self.http.chunk_skip = 0; + self.http.chunk = .trailer; + } else { + // Refused on the header rather than part-way through the copy, which is + // what `Content-Length` gets: the caller sees the error before the buffer + // has been half filled with a body it will never be given. + if (self.http.chunk_left > self.http.out.len - self.http.out_len) { + self.httpFail(error.StreamTooLong); + return; + } + self.http.chunk = .data; + } + }, + .data => { + // In bounds by construction: `.size_lf` refused any chunk larger than the room + // left, and this only ever takes `chunk_left` of it. + const n = @min(self.http.chunk_left, b.len); + @memcpy(self.http.out[self.http.out_len..][0..n], b[0..n]); + self.http.out_len += n; + self.http.chunk_left -= n; + b = b[n..]; + if (self.http.chunk_left == 0) self.http.chunk = .data_cr; + }, + .data_cr => { + if (b[0] != '\r') { + self.httpFail(error.HttpChunkMalformed); + return; + } + b = b[1..]; + self.http.chunk = .data_lf; + }, + .data_lf => { + if (b[0] != '\n') { + self.httpFail(error.HttpChunkMalformed); + return; + } + b = b[1..]; + // `.data` is only ever left with the chunk exhausted, so the accumulator the + // next size builds in already reads zero and is not re-zeroed here. Asserted + // rather than assumed: re-zeroing would be dead code that hides the day the + // invariant stops holding, and a stale count would be silent. + assert(self.http.chunk_left == 0); + self.http.chunk_digit = false; + self.http.chunk = .size; + }, + .trailer => { + if (!self.chunkSkip(1)) return; + const cr = b[0] == '\r'; + b = b[1..]; + self.http.chunk = if (cr) .end_lf else .trailer_line; + }, + .trailer_line => { + const cr = std.mem.indexOfScalar(u8, b, '\r'); + const n = cr orelse b.len; + if (!self.chunkSkip(n)) return; + b = b[n..]; + if (cr != null) { + b = b[1..]; + self.http.chunk = .trailer_lf; + } + }, + .trailer_lf => { + if (b[0] != '\n') { + self.httpFail(error.HttpChunkMalformed); + return; + } + b = b[1..]; + self.http.chunk = .trailer; + }, + .end_lf => { + if (b[0] != '\n') { + self.httpFail(error.HttpChunkMalformed); + return; + } + self.httpComplete(); + // Anything after the final CRLF belongs to a response this connection will + // never ask for: `Connection: close` was sent, and the FIN follows. + return; + }, + } + } + } + + fn httpComplete(self: *Stack) void { + self.http.phase = .complete; + // The body is in hand; close our half. Reading further would only cost frames. + self.tcpFinish(); + } + + /// The peer closed. Whether that completes the response depends on the framing. + fn httpOnEof(self: *Stack) void { + switch (self.http.phase) { + .body => { + if (self.http.chunked) { + // The zero-length chunk and its trailer never arrived. RFC 7230 4.1 makes + // them the framing, so a close before them is a truncated body, however many + // whole chunks came first - reporting what did arrive would be reporting a + // prefix as the whole. + self.http.phase = .failed; + self.http.err = error.ConnectionClosed; + } else if (self.http.content_length) |len| { + if (self.http.out_len >= len) { + self.http.phase = .complete; + } else { + // Fewer body bytes than Content-Length promised. + self.http.phase = .failed; + self.http.err = error.ConnectionClosed; + } + } else { + // No Content-Length: the FIN *is* the framing (RFC 7230 3.3.3 case 7). + self.http.phase = .complete; + } + }, + .head => { + self.http.phase = .failed; + self.http.err = error.ConnectionClosed; + }, + else => {}, + } + } + + // ================================================================================ tick + + /// Advance time. Drives DHCP retransmission and renewal, ARP resolution and TCP + /// retransmission. `now_ms` must be monotonic; it need not start at zero and it need not be + /// called at any particular rate, but nothing times out between calls, so a 47-second TCP + /// deadline needs ticks more often than every 47 seconds to be observed on time. + pub fn tick(self: *Stack, now_ms: u64) void { + self.now_ms = now_ms; + // Stir. The MAC alone would make every boot draw the same transaction ids, initial sequence + // numbers and ephemeral ports, which is how two runs of the same firmware end up accepting + // each other's stale DHCP replies. `now_ms` is the only outside input this file has, and a + // caller that ticks a real timer before starting DHCP therefore gets a different sequence + // every boot. Still not a source of security-relevant randomness - see `entropy`. + self.entropy ^= now_ms *% 0x9e37_79b9_7f4a_7c15; + self.dhcpTick(); + self.dnsTick(); + self.tcpTick(); + } +}; + +// The footprint claim, enforced at compile time, so a buffer that grows fails the build rather than +// the board. +// +// 4 KiB is the budget and it is measured, not guessed: the image has ~128 KB of L2MEM, nothing +// initialises the 32 MB of PSRAM, and ESP-Hosted's queues and its seven task stacks are competing +// for the same space. `Stack` is 3,576 bytes today. The failure this prevents is a stack overflow +// on a part with no debugger, which is indistinguishable from the SDIO bus not coming up. +comptime { + // 6 KiB, raised from 4 KiB when `http_head_max` went from 1024 to 2048 to fit a real CDN + // response head (1043 bytes measured). This is a regression alarm, not a budget: it exists so a + // buffer cannot grow unnoticed, and moving it is a decision to be justified at the buffer that + // caused it - which the comment on `http_head_max` does. The image's real constraint is the + // ~128 KB of L2MEM, and the heap in examples/http.zig was reduced by the same amount to pay for + // this. + if (Stack.footprint > 6 * 1024) @compileError(std.fmt.comptimePrint( + "ip.Stack is {d} bytes, over the 4 KiB budget", + .{Stack.footprint}, + )); +} + +// The host tests live in `ip_test.zig` - 117 cases, and they are the correctness argument for this +// slice, since it is the one part of the P4 bring-up that can be proven without the board. They are +// in their own file because they are longer than the stack, and because the tests deliberately +// re-derive every header offset from the RFCs rather than importing the tables above: a test that +// shares the constant it is checking passes on a consistent misreading. +// +// This reference is what makes `zig build test` find them: build.zig runs `src/net/ip.zig` as a +// test root, and Zig only collects tests from files the root actually references. +test { + _ = @import("ip_test.zig"); +} |
