//! TFTP server implementation. //! //! Design: the main socket on :69 accepts RRQ packets. For each RRQ we spawn //! a task that creates a new ephemeral UDP socket and handles the full //! transfer there (per RFC 1350 — each transfer uses its own port pair so //! multiple clients can download concurrently). This matches exactly how //! `tftpd`/`in.tftpd` works and is why TFTP is awkward behind stateful NAT: //! the ephemeral ports must be reachable from the client. //! //! We only serve files from `openpxe_ipxe_assets::asset_slice` — that is, //! the bundled iPXE binaries and wimboot. No filesystem is ever opened, so //! `../` path traversal attempts simply return ENOENT. use openpxe_core::{ClientEvent, ClientRegistry}; use openpxe_ipxe_assets::asset_slice; use socket2::{Domain, Protocol, Socket, Type}; use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; use std::time::Duration; use tokio::net::UdpSocket; // TFTP opcodes. const OP_RRQ: u16 = 1; const OP_WRQ: u16 = 2; const OP_DATA: u16 = 3; const OP_ACK: u16 = 4; const OP_ERROR: u16 = 5; const OP_OACK: u16 = 6; // Error codes (RFC 1350). const ERR_NOT_DEFINED: u16 = 0; const ERR_FILE_NOT_FOUND: u16 = 1; const ERR_ILLEGAL_OP: u16 = 4; /// Server-rendered TFTP content for names that aren't embedded assets — /// e.g. `grub.cfg` for the signed shim+GRUB Secure Boot chain (v0.7.0), /// which is generated from the live boot-entry list per fetch. Kept as a /// closure so this crate stays decoupled from the ISO store; the binary /// wires it up in `main`. pub type DynamicAsset = Arc Option> + Send + Sync>; pub struct TftpServer { bind: IpAddr, port: u16, clients: Arc, metrics: openpxe_core::Metrics, dynamic: Option, } impl TftpServer { pub fn new( bind: IpAddr, port: u16, clients: Arc, metrics: openpxe_core::Metrics, dynamic: Option, ) -> Self { Self { bind, port, clients, metrics, dynamic, } } pub async fn run(self) -> anyhow::Result<()> { let sock = bind_udp(self.bind, self.port)?; tracing::info!(target: "openpxe::tftp", "TFTP listening on {}:{}", self.bind, self.port); let clients = self.clients.clone(); let metrics = self.metrics.clone(); let mut buf = vec![0u8; 2048]; loop { let (n, from) = match sock.recv_from(&mut buf).await { Ok(v) => v, Err(e) => { tracing::warn!(target: "openpxe::tftp", "recv error: {e}"); continue; } }; let data = buf[..n].to_vec(); let clients = clients.clone(); let metrics = metrics.clone(); let bind_ip = self.bind; let dynamic = self.dynamic.clone(); tokio::spawn(async move { if let Err(e) = handle_rrq(data, from, bind_ip, clients, metrics.clone(), dynamic).await { metrics.record_tftp_err(); tracing::warn!(target: "openpxe::tftp", peer=%from, "handler error: {e}"); } }); } } } async fn handle_rrq( packet: Vec, peer: SocketAddr, bind_ip: IpAddr, clients: Arc, metrics: openpxe_core::Metrics, dynamic: Option, ) -> anyhow::Result<()> { let Some(req) = parse_rrq(&packet) else { // Not a well-formed RRQ. A WRQ deserves an explicit refusal — // legacy clients retry a silently-dropped write until they time // out; an ERROR packet fails them fast with a readable reason. if packet.len() >= 2 && u16::from_be_bytes([packet[0], packet[1]]) == OP_WRQ { let sock = bind_udp(bind_ip, 0)?; let _ = send_error(&sock, peer, ERR_ILLEGAL_OP, "writes not supported").await; } return Ok(()); }; let Request { filename, mode, options, } = req; // Per-transfer ephemeral socket. let sock = bind_udp(bind_ip, 0)?; // We serve binary boot artifacts; netascii line-ending translation // would corrupt them. Refuse loudly instead of timing out silently — // matters for legacy clients that default to netascii. if !mode.eq_ignore_ascii_case("octet") { let _ = send_error(&sock, peer, ERR_NOT_DEFINED, "only octet mode is supported").await; tracing::info!(target: "openpxe::tftp", peer=%peer, %mode, "rejected non-octet transfer"); return Ok(()); } // Embedded assets first; otherwise the dynamic renderer (server- // generated content like the Secure Boot chain's grub.cfg, v0.7.0). let resolved = asset_slice(&filename).or_else(|| { dynamic .as_ref() .and_then(|f| f(&filename)) .map(std::borrow::Cow::Owned) }); let Some(file_bytes) = resolved else { let _ = send_error(&sock, peer, ERR_FILE_NOT_FOUND, "no such file").await; tracing::info!(target: "openpxe::tftp", peer=%peer, file=%filename, "404"); clients.record( &peer.ip().to_string(), Some(peer.ip()), None, ClientEvent::TftpRead { file: filename.clone(), }, ); return Ok(()); }; tracing::info!( target: "openpxe::tftp", peer=%peer, file=%filename, size=file_bytes.len(), "serving" ); clients.record( &peer.ip().to_string(), Some(peer.ip()), None, ClientEvent::TftpRead { file: filename.clone(), }, ); // Negotiate options. let mut blksize: usize = 512; let mut window: u16 = 1; let mut accepted_opts: Vec<(String, String)> = Vec::new(); for (k, v) in &options { match k.as_str() { "blksize" => { if let Ok(n) = v.parse::() { blksize = n.clamp(8, 65464); accepted_opts.push(("blksize".into(), blksize.to_string())); } } "tsize" => { accepted_opts.push(("tsize".into(), file_bytes.len().to_string())); } "windowsize" => { if let Ok(n) = v.parse::() { window = n.clamp(1, 64); accepted_opts.push(("windowsize".into(), window.to_string())); } } _ => {} } } if !accepted_opts.is_empty() { let oack = encode_oack(&accepted_opts); // Loop until client ACKs block 0 (the OACK). if !wait_for_ack(&sock, peer, 0, &oack).await? { return Ok(()); } } // DATA transfer. Block numbers are u16 and may wrap at 65535 — we handle // that via `wrapping_add`. For each window we remember the starting // (offset, block_no) explicitly; on retransmit we replay from there // instead of trying to compute it back from `last_block_in_window` which // is wrong when a window short-sends at EOF (previous bug: window=8 but // only 3 blocks sent, then rewind subtracted 7 landing in the wrong id). let total = file_bytes.len(); let mut offset: usize = 0; let mut block_no: u16 = 1; // Per RFC 1350: if the final data block is exactly blksize, the // server must follow up with a zero-length DATA so the client knows // the transfer has ended. The flag is set inside the loop and // tested at end-of-transfer. let needs_zero_final; 'transfer: loop { let window_start_offset = offset; let window_start_block = block_no; let mut last_block_in_window = block_no; let mut window_reached_eof = false; let mut last_chunk_len = 0usize; // Send one window worth of DATA. for _ in 0..window { if offset >= total { break; } let end = (offset + blksize).min(total); let chunk = &file_bytes[offset..end]; let pkt = encode_data(block_no, chunk); sock.send_to(&pkt, peer).await?; last_block_in_window = block_no; last_chunk_len = chunk.len(); offset = end; block_no = block_no.wrapping_add(1); if end == total { window_reached_eof = true; break; } } // Wait for an ACK of the last block we actually sent (not the // nominal last block of a theoretical full window). let mut tries = 0u8; loop { match tokio::time::timeout(Duration::from_secs(3), recv_ack(&sock, peer)).await { Ok(Ok(acked)) if acked == last_block_in_window => break, Ok(Ok(_)) => {} // stale ACK from an earlier block — ignore Ok(Err(e)) => return Err(e), Err(_) => { tries += 1; if tries > 5 { tracing::warn!( target: "openpxe::tftp", peer=%peer, last_block=last_block_in_window, "timeout after {tries} retries, aborting transfer" ); return Ok(()); } // Rewind to the start of this window and resend exactly // the same blocks (same count, same block numbers). This // is cheap and correct even for short final windows. offset = window_start_offset; block_no = window_start_block; let mut resent = 0; while resent < window && offset < total { let end = (offset + blksize).min(total); let pkt = encode_data(block_no, &file_bytes[offset..end]); sock.send_to(&pkt, peer).await?; last_block_in_window = block_no; last_chunk_len = end - offset; offset = end; block_no = block_no.wrapping_add(1); resent += 1; } } } } if window_reached_eof || offset >= total { // If the very last DATA was exactly blksize, RFC 1350 requires a // following zero-length DATA to signal end-of-transfer. If it // was shorter, the short block already signals EOF. needs_zero_final = last_chunk_len == blksize; break 'transfer; } } if needs_zero_final { let pkt = encode_data(block_no, &[]); sock.send_to(&pkt, peer).await?; let _ = tokio::time::timeout(Duration::from_secs(3), recv_ack(&sock, peer)).await; } tracing::debug!(target: "openpxe::tftp", peer=%peer, bytes=total, "transfer complete"); metrics.record_tftp_ok(total as u64); Ok(()) } #[derive(Debug)] struct Request { filename: String, mode: String, options: Vec<(String, String)>, } fn parse_rrq(pkt: &[u8]) -> Option { if pkt.len() < 4 { return None; } let op = u16::from_be_bytes([pkt[0], pkt[1]]); if op != OP_RRQ { return None; } let mut rest = &pkt[2..]; let filename = read_cstr(&mut rest)?; let mode = read_cstr(&mut rest)?; let mut options = Vec::new(); while !rest.is_empty() { let Some(k) = read_cstr(&mut rest) else { break }; if k.is_empty() { break; } let v = read_cstr(&mut rest).unwrap_or_default(); options.push((k.to_ascii_lowercase(), v)); } Some(Request { filename, mode, options, }) } fn read_cstr(buf: &mut &[u8]) -> Option { let pos = buf.iter().position(|b| *b == 0)?; let s = std::str::from_utf8(&buf[..pos]).ok()?.to_string(); *buf = &buf[pos + 1..]; Some(s) } fn encode_data(block: u16, chunk: &[u8]) -> Vec { let mut v = Vec::with_capacity(4 + chunk.len()); v.extend_from_slice(&OP_DATA.to_be_bytes()); v.extend_from_slice(&block.to_be_bytes()); v.extend_from_slice(chunk); v } fn encode_oack(opts: &[(String, String)]) -> Vec { let mut v = Vec::new(); v.extend_from_slice(&OP_OACK.to_be_bytes()); for (k, val) in opts { v.extend_from_slice(k.as_bytes()); v.push(0); v.extend_from_slice(val.as_bytes()); v.push(0); } v } async fn send_error( sock: &UdpSocket, peer: SocketAddr, code: u16, msg: &str, ) -> std::io::Result<()> { let mut v = Vec::with_capacity(5 + msg.len()); v.extend_from_slice(&OP_ERROR.to_be_bytes()); v.extend_from_slice(&code.to_be_bytes()); v.extend_from_slice(msg.as_bytes()); v.push(0); sock.send_to(&v, peer).await.map(|_| ()) } async fn recv_ack(sock: &UdpSocket, peer: SocketAddr) -> anyhow::Result { let mut buf = [0u8; 32]; loop { let (n, from) = sock.recv_from(&mut buf).await?; if from.ip() != peer.ip() { continue; } if n < 4 { continue; } let op = u16::from_be_bytes([buf[0], buf[1]]); match op { OP_ACK => return Ok(u16::from_be_bytes([buf[2], buf[3]])), OP_ERROR => { let code = u16::from_be_bytes([buf[2], buf[3]]); anyhow::bail!("client error {code}"); } _ => {} } } } async fn wait_for_ack( sock: &UdpSocket, peer: SocketAddr, expect_block: u16, to_retx: &[u8], ) -> anyhow::Result { let mut tries = 0; loop { sock.send_to(to_retx, peer).await?; match tokio::time::timeout(Duration::from_secs(3), recv_ack(sock, peer)).await { Ok(Ok(b)) if b == expect_block => return Ok(true), Ok(Ok(_)) => {} Ok(Err(_)) | Err(_) => { tries += 1; if tries > 5 { return Ok(false); } } } } } fn bind_udp(bind: IpAddr, port: u16) -> anyhow::Result { let domain = match bind { IpAddr::V4(_) => Domain::IPV4, IpAddr::V6(_) => Domain::IPV6, }; let sock = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP))?; sock.set_reuse_address(true)?; sock.set_nonblocking(true)?; let addr: SocketAddr = SocketAddr::new(bind, port); sock.bind(&addr.into())?; let std_sock: std::net::UdpSocket = sock.into(); Ok(UdpSocket::from_std(std_sock)?) } /// Pure-logic mirror of `handle_rrq`'s windowing math, exercised by the /// unit tests below. Given a position in the file and the window, return /// the (block_no, chunk_len) list this window will emit — tested against /// edge cases (exact-blksize tail, short tail, single-block window, /// block-number wraparound). #[cfg(test)] fn plan_window( total: usize, offset: usize, blksize: usize, window: u16, starting_block: u16, ) -> Vec<(u16, usize)> { let mut out = Vec::new(); let mut o = offset; let mut b = starting_block; for _ in 0..window { if o >= total { break; } let end = (o + blksize).min(total); out.push((b, end - o)); o = end; b = b.wrapping_add(1); } out } #[cfg(test)] mod tests { use super::*; #[test] fn parses_rrq_with_options() { // RRQ "snponly.efi" mode "octet" blksize=1468 tsize=0 let mut pkt = vec![0, OP_RRQ as u8]; // The single-digit `\0` escapes here are NUL terminators between // TFTP option name/value pairs — using `\x00` to dodge clippy's // "octal-looking escape" lint. pkt.extend_from_slice(b"snponly.efi\x00octet\x00blksize\x001468\x00tsize\x000\x00"); let r = parse_rrq(&pkt).unwrap(); assert_eq!(r.filename, "snponly.efi"); assert_eq!(r.mode, "octet"); assert_eq!(r.options.len(), 2); assert_eq!(r.options[0].0, "blksize"); assert_eq!(r.options[0].1, "1468"); } #[test] fn encode_decode_data() { let p = encode_data(7, b"hello"); assert_eq!(&p[0..2], &OP_DATA.to_be_bytes()); assert_eq!(&p[2..4], &7u16.to_be_bytes()); assert_eq!(&p[4..], b"hello"); } #[test] fn plan_window_full_blocks() { // 4 KB file, 1024 blksize, window 4 → one window of 4 full blocks. let p = plan_window(4096, 0, 1024, 4, 1); assert_eq!(p, vec![(1, 1024), (2, 1024), (3, 1024), (4, 1024)]); } #[test] fn plan_window_short_tail_at_eof() { // 3.5 KB file, 1024 blksize, window 8 starting at offset 0. // Expect 3 full + 1 half, then stop (below 8 blocks). let p = plan_window(3584, 0, 1024, 8, 1); assert_eq!(p, vec![(1, 1024), (2, 1024), (3, 1024), (4, 512)]); } #[test] fn plan_window_exact_boundary_needs_zero_final() { // 2 KB file, 1024 blksize, window 8 — last block is exactly blksize. // `handle_rrq` checks `last_chunk_len == blksize` to decide whether to // emit the terminating zero-length DATA. Assert that condition here. let p = plan_window(2048, 0, 1024, 8, 1); assert_eq!(p, vec![(1, 1024), (2, 1024)]); let last = p.last().unwrap(); assert_eq!(last.1, 1024); // => needs zero final per RFC 1350 } #[test] fn plan_window_wraparound() { // Block number wraps from u16::MAX to 0 on next window — 2 blocks, // starting at 65534. let p = plan_window(2048, 0, 1024, 2, 65534); assert_eq!(p, vec![(65534, 1024), (65535, 1024)]); let p2 = plan_window(2048, 2048, 1024, 2, 0); assert!(p2.is_empty()); // nothing past EOF // And a cross-boundary case: let p3 = plan_window(3072, 0, 1024, 3, 65535); assert_eq!(p3, vec![(65535, 1024), (0, 1024), (1, 1024)]); } }