π¦Tutorial: Rust Networking
This content is a draft and will not be included in production builds.

Rust Networking Tutorial: Building an Edge Sensor Network
Section titled βRust Networking Tutorial: Building an Edge Sensor NetworkβWelcome! This tutorial guides you through Rustβs networking stack, from resolving hostnames to building a multi-client asynchronous TCP server that exchanges structured sensor data. Each question introduces one new concept and builds on the previous one.
This tutorial assumes a working understanding of Rust basics (structs, enums, Result, the ? operator) and that cargo is installed. The running theme is an edge computing scenario: IoT sensor nodes communicating with a central data collection server.
Good luck!
Question 0. Preparation and Setup
Section titled βQuestion 0. Preparation and SetupβCreate a new project for this tutorial:
cargo new een1097_tutorial5cd een1097_tutorial5Add the first two dependencies for DNS resolution and HTTP client functionality:
cargo add dns-lookupcargo add reqwest --features blockingOpen the folder in VSCode. Your Cargo.toml should now include:
[package]name = "een1097_tutorial5"version = "0.1.0"edition = "2021"
[dependencies]dns-lookup = "2.0.4"reqwest = { version = "0.12", features = ["blocking"] }(Note: if cargo add writes edition = "2024", leave it β this requires Rust 1.85 or newer. Run rustup update stable if your build fails with an edition error.)
Question 1. IP Addresses and DNS Resolution
Section titled βQuestion 1. IP Addresses and DNS ResolutionβEvery networked edge device must translate a hostname such as "dcu.ie" into an IP address before it can open a connection. Rust provides built-in IP address types in std::net, and explicit DNS resolution through the dns-lookup crate.
Begin by replacing the contents of src/main.rs with the following starting code:
use std::net::{Ipv4Addr, Ipv6Addr};
fn main() { // IPv4 address constructed from four octets let sensor_gw: Ipv4Addr = Ipv4Addr::new(192, 168, 1, 100);
// The IPv6 loopback constant ::1 let loopback_v6: Ipv6Addr = Ipv6Addr::LOCALHOST;
println!("Sensor gateway (IPv4): {}", sensor_gw); println!("Loopback (IPv6): {}", loopback_v6);}Running cargo run should produce:
Sensor gateway (IPv4): 192.168.1.100Loopback (IPv6): ::1Tasks:
-
Ipv4Addrprovides a constant for the loopback address. ReplaceIpv4Addr::new(192, 168, 1, 100)withIpv4Addr::LOCALHOSTand verify that the output changes to127.0.0.1. -
Extend
mainwith a DNS forward lookup using thedns-lookupcrate. Because the lookup can fail (no network connection, unknown hostname),mainmust returnResult<(), Box<dyn std::error::Error>>. Fill in the missing call below:
use dns_lookup::lookup_host;use std::net::IpAddr;
fn main() -> Result<(), Box<dyn std::error::Error>> { // ... your Ipv4Addr / Ipv6Addr code from Task 1 above ...
let hostname = "dcu.ie"; println!("\nDNS lookup for: {}", hostname); let ips: Vec<IpAddr> = /* call lookup_host(hostname) here */ ; for ip in &ips { println!(" {}", ip); } Ok(())}- Add a second lookup for
"google.com"immediately after the first. The output will vary by network and time, but should resemble Figure Q1:
Sensor gateway (IPv4): 127.0.0.1Loopback (IPv6): ::1
DNS lookup for: dcu.ie 136.206.1.2
DNS lookup for: google.com 142.250.200.46 142.250.200.49 ...Figure Q1. Expected output for Question 1: IP address constants followed by DNS lookups for two hostnames. Your IP addresses will differ from those shown.
Question 2. Blocking HTTP Client
Section titled βQuestion 2. Blocking HTTP ClientβHTTP is the most common protocol for edge devices calling cloud APIs or retrieving remote configuration. The reqwest crate abstracts the underlying TCP connection, HTTP headers, and response parsing into a single function call.
Replace the contents of main.rs with the following starting code:
fn main() -> Result<(), Box<dyn std::error::Error>> { let url = "http://example.com"; println!("Fetching: {}", url); let body = reqwest::blocking::get(url)?.text()?; println!("{}", body); Ok(())}Tasks:
-
Run the code and observe the raw HTML response from
example.com. -
Printing the entire page is verbose. Limit the output to the first 300 characters by replacing
println!("{}", body)with the following:
let preview: String = body.chars().take(300).collect();println!("{}β¦", preview);Using .chars().take(300) is safer than &body[..300]: a byte-index slice panics if the boundary falls inside a multi-byte UTF-8 character, which is common in HTML containing accented characters.
- Change the URL to
"http://dcu.ie"and run again. The output should resemble Figure Q2:
Fetching: http://dcu.ie<!DOCTYPE html><html lang="en" dir="ltr" prefix="content: http://purl.org/rss/1.0/modules/content/dc: http://purl.org/dc/terms/ foaf: http://xmlns.com/foaf/0.1/ og: http://ogp.me/ns#β¦Figure Q2. Expected output for Question 2: the first 300 characters of the DCU home page HTML.
Question 3. Raw TCP Client
Section titled βQuestion 3. Raw TCP ClientβHTTP is built on TCP. For custom sensor protocols where you control the exact byte format, std::net::TcpStream gives you direct access to the TCP layer without any additional crate.
This question uses tcpbin.com:4242, a public TCP echo server that returns exactly the bytes you send, making it ideal for testing a client without running your own server.
Replace main.rs with the following skeleton:
use std::io::{Read, Write};use std::net::TcpStream;
fn main() -> std::io::Result<()> { println!("Connecting to tcpbin.com:4242 β¦"); let mut stream = TcpStream::connect("tcpbin.com:4242")?; println!("Connected.");
// Task 1: send a message to the echo server
// Task 2: read and print the echo response
Ok(())}Tasks:
-
Send the message
b"Edge sensor check-in\n"usingstream.write_all(...). Callstream.flush()immediately after to ensure all bytes are transmitted. -
Allocate a 512-byte buffer (
let mut buf = [0u8; 512];), callstream.read(&mut buf)?to receive the response, and print the received bytes usingString::from_utf8_lossy(&buf[..n])wherenis the number of bytes returned byread. -
The expected output is shown in Figure Q3:
Connecting to tcpbin.com:4242 β¦Connected.Sent: Edge sensor check-inReceived echo: Edge sensor check-inFigure Q3. A successful TCP echo round trip: the server returns exactly what was sent.
Question 4. Connection Timeouts
Section titled βQuestion 4. Connection TimeoutsβOn edge networks, a target host may be unreachable or slow to respond. Without timeouts, TcpStream::connect blocks indefinitely, freezing the programme. This question replaces the bare connect call with connect_timeout and adds read/write timeouts to the established stream.
connect_timeout requires a pre-resolved SocketAddr rather than a hostname string, because it applies its deadline only to the TCP handshake, not to DNS resolution. You must therefore resolve the address yourself first.
Replace main.rs with the skeleton below and complete the three tasks:
use std::io::{Read, Write};use std::net::{TcpStream, ToSocketAddrs};use std::time::Duration;
fn main() -> std::io::Result<()> { // Resolve the hostname first β connect_timeout needs a SocketAddr, not a string. let addr = "tcpbin.com:4242" .to_socket_addrs()? .next() .ok_or_else(|| std::io::Error::new( std::io::ErrorKind::NotFound, "hostname resolved to no addresses", ))?;
// Task 1: replace this line with connect_timeout using a 5-second deadline let mut stream = TcpStream::connect(addr)?;
// Task 2: set a 10-second read timeout on the stream
// Task 3: set a 10-second write timeout on the stream
stream.write_all(b"Timeout-aware ping\n")?; stream.flush()?; let mut buf = [0u8; 512]; let n = stream.read(&mut buf)?; println!("Received: {}", String::from_utf8_lossy(&buf[..n])); Ok(())}Tasks:
-
Replace
TcpStream::connect(addr)?withTcpStream::connect_timeout(&addr, Duration::from_secs(5))?. -
Call
stream.set_read_timeout(Some(Duration::from_secs(10)))?after the connection is established. -
Call
stream.set_write_timeout(Some(Duration::from_secs(10)))?on the next line. -
Test the timeout by changing the port in the hostname string from
4242to9999(an unreachable port). Confirm that the programme exits with a timeout error after approximately 5 seconds rather than blocking indefinitely, as shown in Figure Q4:
Error: Os { code: 110, kind: TimedOut, message: "Connection timed out" }Figure Q4. With connect_timeout, an unreachable host produces an error after the deadline instead of blocking forever.
Question 5. TCP Echo Server
Section titled βQuestion 5. TCP Echo ServerβUp to this point you have only written clients. Now you will build both sides of a TCP connection. This requires a project with two separate executables. Cargo supports this through a src/bin/ subdirectory: each .rs file in that directory becomes its own binary.
Project structure:
een1097_tutorial5/βββ Cargo.tomlβββ src/ βββ bin/ βββ server.rs βββ client.rsCreate the src/bin/ directory and add src/bin/server.rs with the complete server below:
use std::io::{Read, Write};use std::net::{TcpListener, TcpStream};
fn handle_client(mut stream: TcpStream) { let peer = stream.peer_addr() .map(|a| a.to_string()) .unwrap_or_else(|_| "unknown".to_string()); println!("Connection from: {}", peer);
let mut buf = [0u8; 512]; loop { match stream.read(&mut buf) { Ok(0) => { println!("Client {} disconnected.", peer); break; } Ok(n) => { println!("Received: {}", String::from_utf8_lossy(&buf[..n])); if stream.write_all(&buf[..n]).is_err() { break; } } Err(e) => { eprintln!("Read error: {}", e); break; } } }}
fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:7878")?; println!("TCP Echo Server listening on 127.0.0.1:7878"); for stream in listener.incoming() { match stream { Ok(s) => handle_client(s), Err(e) => eprintln!("Accept error: {}", e), } } Ok(())}Now add src/bin/client.rs with the following skeleton:
use std::io::{Read, Write};use std::net::TcpStream;
fn main() -> std::io::Result<()> { // Task 1: connect to 127.0.0.1:7878
// Task 2: send "Hello from the edge!\n"
// Task 3: read and print the echo response
Ok(())}Tasks:
-
Complete
client.rsso that it connects to"127.0.0.1:7878", sends a message, reads the echo, and prints it. Use the patterns from Questions 3 and 4 as a reference. -
Run the server in VSCode using the play button on
server.rs. Open a split terminal and run the client:
cargo run --bin client- The expected output from both terminals is shown in Figure Q5. Remember to stop the server when done β port 7878 is reserved for the entire lifetime of the server process and cannot be reused until the process exits.
# server terminalTCP Echo Server listening on 127.0.0.1:7878Connection from: 127.0.0.1:54321Received: Hello from the edge!Client 127.0.0.1:54321 disconnected.
# client terminalConnected.Sent: Hello from the edge!Received echo: Hello from the edge!Figure Q5. The TCP echo server (left) and client (right) communicating over the loopback interface. The clientβs ephemeral port changes on every run.
Question 6. UDP Echo Client and Server
Section titled βQuestion 6. UDP Echo Client and ServerβUDP sends each message as an independent datagram without a persistent connection. This makes it well-suited to low-latency sensor broadcasts where occasional loss is acceptable. The API is simpler than TCP: there is no connect/accept cycle, and each send and receive call specifies the remote address explicitly.
Add src/bin/udp_server.rs with the following complete server:
use std::net::UdpSocket;
fn main() -> std::io::Result<()> { let socket = UdpSocket::bind("127.0.0.1:8080")?; println!("UDP Echo Server listening on 127.0.0.1:8080"); let mut buf = [0u8; 512]; loop { let (n, src) = socket.recv_from(&mut buf)?; println!("Datagram from {}: {}", src, String::from_utf8_lossy(&buf[..n])); socket.send_to(&buf[..n], src)?; }}Now add src/bin/udp_client.rs with the following skeleton:
use std::net::UdpSocket;
fn main() -> std::io::Result<()> { // Binding to port 0 asks the OS to assign a free ephemeral port automatically. let socket = UdpSocket::bind("127.0.0.1:0")?;
let server_addr = "127.0.0.1:8080"; let message = b"Temperature: 22.1 C";
// Task 1: send message to server_addr using socket.send_to
// Task 2: receive the echo into a 512-byte buffer and print it
Ok(())}Tasks:
-
Complete
udp_client.rs: send the message withsocket.send_to(message, server_addr)?, then receive withlet (n, _src) = socket.recv_from(&mut buf)?and print the echoed bytes. -
Start the UDP server and client in two terminals:
cargo run --bin udp_server # first terminalcargo run --bin udp_client # second terminal- Run the client three times without restarting the server. Observe that the ephemeral source port number printed by the server changes on each run, as shown in Figure Q6:
# udp_server terminalUDP Echo Server listening on 127.0.0.1:8080Datagram from 127.0.0.1:52341: Temperature: 22.1 CDatagram from 127.0.0.1:49127: Temperature: 22.1 CDatagram from 127.0.0.1:61082: Temperature: 22.1 C
# udp_client terminal (each run)Received echo: Temperature: 22.1 CFigure Q6. Three UDP client runs. The OS assigns a different ephemeral port each time (52341, 49127, 61082). Compare this with the TCP server in Q5, where the server holds port 7878 for its entire lifetime.
Question 7. Structured JSON Data (Advanced)
Section titled βQuestion 7. Structured JSON Data (Advanced)βSending plain text messages works for simple strings, but a real edge network exchanges structured data: sensor type, value, device ID, and timestamp. The serde crate with serde_json handles this automatically through Rustβs derive macros, eliminating all manual JSON parsing.
This question also introduces a shared library: a SensorReading struct that both the client and server binaries import from one location, ensuring they always agree on the data format.
Project structure:
een1097_tutorial5/βββ Cargo.tomlβββ src/β βββ lib.rs β declares shared_data as a public moduleβ βββ shared_data.rs β SensorReading structβ βββ bin/β βββ json_server.rsβ βββ json_client.rsAdd the serde dependencies:
cargo add serde --features derivecargo add serde_jsonCreate src/shared_data.rs:
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]pub struct SensorReading { pub device_id: String, pub temperature_c: f32, pub humidity_pct: f32, pub timestamp_s: u64,}Create src/lib.rs to expose the struct to both binaries:
pub mod shared_data;The server skeleton (src/bin/json_server.rs):
use std::io::{Read, Write};use std::net::{TcpListener, TcpStream};use een1097_tutorial5::shared_data::SensorReading;
fn handle_client(mut stream: TcpStream) { let peer = stream.peer_addr() .map(|a| a.to_string()) .unwrap_or_else(|_| "unknown".to_string()); println!("Connection from: {}", peer);
let mut buf = [0u8; 512]; loop { match stream.read(&mut buf) { Ok(0) => { println!("Client {} disconnected.", peer); break; } Ok(n) => { let text = String::from_utf8_lossy(&buf[..n]);
// Task 1: call serde_json::from_str::<SensorReading>(text.trim()) // On Ok(data): print data using {:?} and write an ACK line // On Err(_): write b"ERROR: invalid JSON\n" to the stream } Err(e) => { eprintln!("Read error: {}", e); break; } } }}
fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:1234")?; println!("JSON Server listening on 127.0.0.1:1234"); for stream in listener.incoming().flatten() { handle_client(stream); } Ok(())}The client skeleton (src/bin/json_client.rs):
use std::io::{Read, Write};use std::net::TcpStream;use een1097_tutorial5::shared_data::SensorReading;
fn main() -> Result<(), Box<dyn std::error::Error>> { let mut stream = TcpStream::connect("127.0.0.1:1234")?; println!("Connected to JSON server.");
let reading = SensorReading { device_id: "ESP32-A1".to_string(), temperature_c: 24.7, humidity_pct: 58.3, timestamp_s: 1748000000, };
// Task 2: serialise `reading` to a JSON string using serde_json::to_string // then write the string bytes + b"\n" to the stream and flush
let mut buf = [0u8; 256]; let n = stream.read(&mut buf)?; println!("Server response: {}", String::from_utf8_lossy(&buf[..n])); Ok(())}Tasks:
-
In
json_server.rs, replace the// Task 1comment with amatchonserde_json::from_str::<SensorReading>(text.trim()):- On
Ok(data): print the struct with{:?}and writeformat!("ACK: {} @ {:.1}C\n", data.device_id, data.temperature_c).as_bytes()to the stream. - On
Err(_): writeb"ERROR: invalid JSON\n"to the stream.
- On
-
In
json_client.rs, replace the// Task 2comment with a call toserde_json::to_string(&reading)?, write the resulting string as bytes, appendb"\n", and callflush(). -
Run the server and then the client. The expected output is shown in Figure Q7:
# json_server terminalJSON Server listening on 127.0.0.1:1234Connection from: 127.0.0.1:XXXXXSensorReading { device_id: "ESP32-A1", temperature_c: 24.7, humidity_pct: 58.3, timestamp_s: 1748000000 }Client 127.0.0.1:XXXXX disconnected.
# json_client terminalConnected to JSON server.Sent: {"device_id":"ESP32-A1","temperature_c":24.7,"humidity_pct":58.3,"timestamp_s":1748000000}Server response: ACK: ESP32-A1 @ 24.7CFigure Q7. JSON sensor data exchange: the SensorReading struct is automatically serialised to a compact JSON string by the client and deserialised by the server without any manual parsing code.
Question 8. Asynchronous TCP Server with Tokio (Advanced)
Section titled βQuestion 8. Asynchronous TCP Server with Tokio (Advanced)βThe serial server in Q5 handles one client at a time. While it is reading from one sensor node, all others must wait for it to disconnect. For an edge gateway managing tens or hundreds of sensor nodes simultaneously this is impractical. Tokioβs asynchronous runtime solves this by running many lightweight concurrent tasks on a small, fixed thread pool.
Add Tokio as a dependency:
cargo add tokio --features fullAdd src/bin/async_server.rs with the skeleton below. The handle_async_client function is complete; your task is to fill in main:
use tokio::io::{AsyncReadExt, AsyncWriteExt};use tokio::net::TcpListener;
async fn handle_async_client(mut stream: tokio::net::TcpStream) { let peer = stream.peer_addr() .map(|a| a.to_string()) .unwrap_or_else(|_| "unknown".to_string()); println!("Async connection from: {}", peer);
let mut buf = [0u8; 512]; loop { let n = stream.read(&mut buf).await.unwrap_or(0); if n == 0 { break; } println!("Received: {}", String::from_utf8_lossy(&buf[..n])); if stream.write_all(&buf[..n]).await.is_err() { break; } } println!("Client {} disconnected.", peer);}
// Task 1: add #[tokio::main] above this line and change fn to async fnfn main() -> Result<(), Box<dyn std::error::Error>> {
// Task 2: bind the listener β TcpListener::bind("127.0.0.1:7879").await?
// Task 3: add a loop that accepts connections and spawns each one
Ok(())}Tasks:
-
Add
#[tokio::main]on the line directly abovefn main(), and changefn main()toasync fn main(). -
Inside
main, bind the listener:
let listener = TcpListener::bind("127.0.0.1:7879").await?;println!("Async TCP Echo Server listening on 127.0.0.1:7879");- Add an accept loop. Each accepted connection is handed off to a spawned task, allowing the main loop to return immediately and accept the next client:
loop { let (stream, _) = listener.accept().await?; tokio::spawn(async move { handle_async_client(stream).await; });}- Test the async server using the blocking client from Q5: change the connection address in
client.rsfrom"127.0.0.1:7878"to"127.0.0.1:7879"and rebuild. Open two client terminal windows and run them at the same time. Both should be served concurrently, as shown in Figure Q8:
# async_server terminalAsync TCP Echo Server listening on 127.0.0.1:7879Async connection from: 127.0.0.1:54100Async connection from: 127.0.0.1:54101Received: Hello from the edge!Received: Hello from the edge!Client 127.0.0.1:54100 disconnected.Client 127.0.0.1:54101 disconnected.Figure Q8. Two clients handled concurrently by the async server. Both connections appear in the log before either disconnects, confirming that neither client waited for the other.
The video solutions for these questions are available in the next section.
© 2026 Derek Molloy, Dublin City University. All rights reserved.