Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

11.1 Network Programming Introduction

Socket Programming for Distributed Applications

Section titled “Socket Programming for Distributed Applications”

The landscape of computing is increasingly shifting towards the periphery of the network, giving rise to what is known as edge computing. This paradigm involves processing data closer to its source, rather than sending it to a centralised cloud. Edge devices, such as microcontrollers like the ESP32, are at the forefront of this movement. These devices are often resource-constrained, operating with limited memory, processing power, and battery life, yet they are increasingly tasked with sophisticated network connectivity for data exchange, remote command and control, and over-the-air updates. The demand for robust, efficient, and secure software on these constrained platforms is therefore vital.

Rust is emerging as a compelling language for this domain, offering a unique blend of features that directly address the challenges of embedded and networked systems. In networked embedded systems, where reliability and security are critical, Rust’s compile-time guarantees significantly reduce the likelihood of memory-related bugs, leading to more stable and secure deployments.

Beyond safety, Rust delivers exceptional computational performance. It is a systems programming language capable of generating highly optimised machine code with fine-grained control over memory use, rivalling the raw speed and efficiency of C and C++. This characteristic is vital for resource-constrained edge devices where every byte of memory and every CPU cycle must be utilised effectively. Furthermore, Rust provides strong and safe support for concurrency, which is essential for modern network applications. Whether handling multiple incoming connections on a server, processing concurrent data streams, or managing background tasks, Rust’s compile-time checks for data races simplify concurrent code development, making it less prone to errors than traditional approaches. This capability is particularly beneficial for devices that need to perform multiple network operations simultaneously without compromising stability.

When considering Rust versus C and C++ for networking, it becomes clear that while C and C++ have long been the bedrock of systems programming due to their low-level control and performance, Rust offers a powerful modern alternative. Rust provides memory safety guarantees without sacrificing performance. This significantly reduces the risk of security vulnerabilities, crashes, and prolonged debugging cycles in complex network applications. This positions Rust not just as another language for embedded systems, but as a safer, more reliable, and ultimately more cost-effective choice for mission-critical networked embedded applications, making it a strong and compelling contender against established languages. The inherent prevention of memory corruption, buffer overflows, and data races at compile time dramatically reduces the time typically spent on debugging elusive memory-related issues. This enhances overall system reliability and lowers the total cost of ownership for networked edge devices, which often operate autonomously in remote or critical environments.

Effective network programming begins with a solid understanding of fundamental principles that govern how devices communicate across a network. These principles form the base upon which all network applications are built.

At the most basic level, every device connected to a computer network that uses the Internet Protocol (IP) for communication is assigned a unique numerical label known as an IP address. This address serves as the host identifier, enabling two network hosts to locate each other and exchange messages. IP addresses are fundamental to successful network communication and operate at the network layer of the TCP/IP model. Examples include IPv4 addresses, typically represented as a dotted decimal string (e.g., 192.168.1.1), and the more complex, hexadecimal-based IPv6 addresses (e.g., 2400:cb00:2048:1::c629:d7a2).

As an early example, this is what network programming looks like in Rust. This code will be explained throughout the chapter.

use std::net::{Ipv4Addr, Ipv6Addr};
fn main() {
// An arbitrary private IPv4 address
let home_v4 = Ipv4Addr::new(192, 168, 0, 42);
// The special loop-back IPv6 address ::1
let loopback_v6 = Ipv6Addr::LOCALHOST;
println!("IPv4 example: {}", home_v4);
println!("IPv6 example: {}", loopback_v6);
}

Run this and you will see something like:

IPv4 example: 192.168.0.42
IPv6 example: ::1

While IP addresses are essential for machine-to-machine communication, they are difficult for humans to remember (what are the actual telephone numbers of your friends?). To address this, domain names (e.g., dcu.ie, derekmolloy.ie) were introduced as human-readable aliases for these numerical addresses (which may even be dynamically allocated and change over time). Domain names make it significantly easier for users to access internet resources without the need to memorise complex numerical sequences. Despite the convenience of domain names,** actual network communication packets still rely on IP addresses for routing**. This necessitates a translation mechanism to convert domain names into their corresponding IP addresses before any network communication can commence.

The Domain Name System (DNS) is the decentralised naming system that performs this crucial translation. It is often referred to as the “phonebook of the Internet” because it translates human-readable domain names into machine-friendly IP addresses.

The process of DNS resolution is hierarchical and distributed, typically involving a series of interactions between four key types of DNS servers:

  1. DNS Recursor[^1]: This server, often managed by an Internet Service Provider (ISP) or a public DNS provider (like Google DNS), receives queries from client machines (e.g., web browsers). Its role is to make additional requests to other DNS servers to resolve the query, much like a librarian who goes to find a specific book.
  2. Root Name Server: These are the first step in the hostname-to-IP address translation process. There are 13 sets of root servers globally (named A through M[^2]). When a DNS recursor cannot resolve a query from its cache, it first queries a root name server, which then responds with the address of the appropriate Top-Level Domain (TLD) nameserver.
  3. TLD Nameserver (Top-Level Domain): These servers manage specific top-level domains such as .com, .org, or .ie. Upon receiving a query from the recursor, the TLD name server responds with the IP address of the authoritative nameserver for the specific domain being sought (e.g., dcu.ie). There are approximately 1,600 TLDs (including country codes ccTLDs like .ie, legacy TLDs like .com or .org, and new generic gTLDs, like .shop, .tech), but there are more than 360 million domain name registrations (like dcu.ie).
  4. Authoritative Nameserver: This is the final and most specific server in the DNS lookup chain. It holds the actual DNS resource records (such as A records[^3] for IPv4 addresses) for a particular domain. If it possesses the requested record, it returns the IP address to the DNS recursor, which then relays it back to the client. This multi-step process, which can involve up to eight distinct steps when no information is cached, is crucial for internet navigation. The interaction between these various layers, where an application-layer request (domain name lookup) triggers a process involving transport-layer protocols (DNS queries often use UDP) and network-layer addresses (IP addresses for routing), highlights the deep interdependence of abstraction layers in network communication. Understanding how these layers interact and depend on each other is useful for designing robust, efficient, and debuggable network applications, especially on resource-constrained embedded systems where optimising these interactions can significantly impact performance and resource usage.

DNS Caching plays an important role in optimising this process. By temporarily storing DNS data closer to the requesting client, subsequent queries for the same domain can be resolved much faster. Caching occurs at various levels, including within web browsers, at the operating system (OS) level, and within recursive resolvers (e.g., at the ISP level). This mechanism significantly reduces load times, minimises bandwidth consumption, and improves the overall responsiveness of network applications.

While IP addresses handle host-to-host communication, the transport layer protocols are responsible for ensuring that messages are delivered to the correct application running on a host. This is achieved by allocating different port numbers to local network applications, allowing a host to direct incoming data to the appropriate service. The two primary transport layer protocols are UDP and TCP.

UDP (User Datagram Protocol) is a connectionless protocol, meaning it does not establish a dedicated connection or perform a handshake before sending data. It is inherently unreliable, offering no guarantees of delivery, order, or protection against duplicate packets. Despite its unreliability, UDP is highly efficient due to its minimal overhead. It is ideal for scenarios where low latency and high throughput are paramount, and occasional packet loss is acceptable or can be managed by a higher application layer. Common applications include streaming media, online gaming, Voice over IP (VoIP), and DNS queries (which typically use UDP port 53). Other well-known UDP ports include TFTP (69) and NTP (123).

In contrast, TCP (Transmission Control Protocol) is a connection-oriented protocol. It requires a three-way handshake to establish a dedicated, one-to-one connection before data transmission. TCP provides reliable data delivery through mechanisms like retransmissions and acknowledgements, ensures data is delivered in the correct order, supports full-duplex communication, and handles data as a byte stream. TCP is suitable for applications demanding high reliability, ordered data delivery, and robust error checking. Examples include web browsing (HTTP/HTTPS), email (SMTP/IMAP), file transfer (FTP), and secure shell (SSH).

Table 1 The following table provides a concise comparison of TCP and UDP characteristics:

FeatureTCP (Transmission Control Protocol)UDP (User Datagram Protocol)
ConnectionConnection-oriented (requires handshake)Connectionless (no handshake)
ReliabilityReliable (guaranteed delivery, retransmissions)Unreliable (no delivery guarantee)
OrderOrdered (data delivered in sequence)Unordered (packets may arrive out of sequence)
OverheadHigher (more headers, acknowledgements, state management)Lower (minimal headers, no connection state)
SpeedSlower (due to reliability mechanisms)Faster (less overhead, no retransmissions)
Flow ControlYes (manages sender/receiver buffer to prevent overflow)No (application must handle flow control if needed)
Congestion ControlYes (adjusts transmission rate to avoid network congestion)No (sends data regardless of network conditions)
Error CheckingExtensive (checksums, sequence numbers, acknowledgements)Minimal (checksums for header/data integrity, no retrans.)
DuplicationNo (handles duplicate packets)Possible (application must handle if required)
Use CasesWeb browsing (HTTP/HTTPS), Email (SMTP/IMAP), File Transfer (FTP), SSH, Database connectionsDNS, Streaming media, Online gaming, VoIP, NTP, IoT sensor data

Understanding these fundamental differences is crucial for choosing the appropriate protocol for a given application. On edge devices, where resource usage (overhead) and latency are critical design considerations, selecting the right transport protocol can significantly impact system performance and efficiency.

Rust’s approach to networking is built upon a robust standard library and ecosystem of community-developed crates. This combination provides developers with both low-level control and high-level abstractions, enabling the creation of efficient and reliable network applications.

Rust’s standard library provides fundamental networking functionality through the std::net module. This module offers core primitives for both TCP and UDP communication, mirroring the socket programming concepts familiar to C/C++ developers. It serves as the foundational layer for direct network interactions in Rust.

The key components for blocking network I/O within std::net include:

  • TcpListener: Used for binding to a local address and listening for incoming TCP connections. We use this in Assignment 2.
  • TcpStream: Represents an established TCP connection, providing methods for reading from and writing to the network stream for both client and server sides. We use this in Assignment 2.
  • UdpSocket: Used for connectionless UDP communication, allowing datagrams to be sent to and received from specific addresses.

While std::net provides functionality analogous to C’s BSD sockets API, Rust’s strong type system, robust error handling (via the Result type), and ownership model make these operations significantly safer and more ergonomic. This design reduces the likelihood of common socket programming pitfalls, such as resource leaks or incorrect buffer handling, which can be a major issue for C/C++ network applications.

For many common networking tasks, Rust’s std::net::ToSocketAddrs trait provides a convenient way to resolve hostnames to IP addresses implicitly. This trait is implemented by various types (e.g., &str, (IpAddr, u16)) and is often used directly with connection functions like TcpStream::connect("dcu.ie:80"), where the resolution happens automatically as part of the connection process.

However, for scenarios requiring more explicit control over DNS resolution, such as querying specific record types, or for performing reverse DNS lookups (translating an IP address back to a hostname), external crates are typically employed. The dns-lookup crate is a straightforward option that provides wrappers around system-level DNS resolution functions (like getaddrinfo and getnameinfo from libc).

To include dns-lookup in a project, the following dependency should be added to Cargo.toml. You can do this manually as below, or by using cargo add dns_lookup

[package]
name = "hello_een1097"
version = "0.1.0"
edition = "2024"
[dependencies]
dns-lookup = "2.0.4"

This line adds the dns-lookup crate to the project, making its functions available for use. When you build the project you will experience a delay while this crate is installed.

A practical example of a forward DNS lookup using lookup_host is as follows:

Rust — e.g., main.rs file:

use dns_lookup::lookup_host;
use std::net::IpAddr;
// This main function will either return Ok with no value, or it
// will return an Err containing any kind of standard error.
fn main() -> Result<(), Box<dyn std::error::Error>> {
let hostname = "google.com";
println!("Looking up IP addresses for: {}", hostname);
// The lookup_host function returns a Vec<IpAddr> for the given hostname
// This call can fail if the network is disconnected, WiFi is
// down or the hostname doesn't exist. Hence the ? and the fact it
// either returns Ok(Vec<IpAddr>) or Error (std::io::Error)
// if it returns an Error, main stops executing, and returns the error
// as indicated in fn main() signature.
let ips: Vec<IpAddr> = lookup_host(hostname)?;
if ips.is_empty() {
println!(" No IP addresses found for {}", hostname);
} else {
for ip in ips {
println!(" Resolved to: {}", ip);
}
}
Ok(())
}

Running this should give the output:

Looking up IP addresses for: google.com
Resolved to: 209.85.203.102
Resolved to: 209.85.203.113
Resolved to: 209.85.203.139
Resolved to: 209.85.203.138
Resolved to: 209.85.203.101
Resolved to: 209.85.203.100

This example demonstrates how to use the lookup_host function from the dns-lookup crate to resolve a domain name (e.g., google.com) to a list of associated IP addresses. This is a fundamental operation for any network application that needs to connect to services using their hostnames rather than raw IP addresses.

For reverse DNS lookups, the lookup_addr function can be used:

use dns_lookup::lookup_addr;
use std::net::IpAddr;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let ip: IpAddr = "8.8.8.8".parse()?; // Example: Google's public DNS IP
println!("Attempting reverse DNS lookup for IP: {}", ip);
// lookup_addr attempts to translate an IP address back to a hostname
match lookup_addr(&ip) {
Ok(host) => println!(" Resolved to hostname: {}", host),
Err(e) => eprintln!(" Failed to resolve hostname for {}: {}", ip, e),
}
Ok(())
}

This code should give the output:

Attempting reverse DNS lookup for IP: 8.8.8.8
Resolved to hostname: dns.google

This code snippet illustrates how lookup_addr can be used to perform a reverse DNS lookup, translating an IP address (e.g., 8.8.8.8) back into its corresponding hostname. This functionality can be valuable for network diagnostics, logging, or security analysis to identify the origin of network traffic.

This reliance on external crates for explicit DNS functionalities, while std::net provides basic socket primitives, highlights a significant aspect of Rust’s ecosystem. The standard library provides a minimal, robust, and stable set of foundational primitives, and more specialised or complex functionalities are then built upon this foundation by community-driven crates. This modular approach allows developers to pull in only the specific dependencies they need, which helps in keeping binary sizes smaller and reducing the overall attack surface of an application. For resource-constrained edge devices, this lean dependency management is particularly beneficial. It suggests that while std provides the essential building blocks, a practical Rust developer working on networking applications will frequently leverage and integrate external crates to achieve richer functionalities efficiently.

Concept Match

Match the Network Fundamentals Concepts

Drag each definition into its matching concept slot, then click Submit. Tap × to return a placed card to the pool.

IPv4
drag a definition here…
IPv6
drag a definition here…
DNS Recursive Resolver
drag a definition here…
TcpListener
drag a definition here…
UdpSocket
drag a definition here…

Definition Pool

A 32-bit addressing scheme providing approximately 4.3 billion unique addresses, written in dotted-decimal notation (e.g., 192.168.1.1).
A Rust standard library type for connectionless communication; there is no handshake, and each datagram specifies its own destination address.
A Rust standard library type that binds to a local address and port and waits for incoming TCP connection requests.
A 128-bit addressing scheme providing a practically unlimited address space, written in eight colon-separated hexadecimal groups.
The first DNS server queried by your device; it handles the full lookup process on your behalf, contacting root, TLD, and authoritative servers as needed.
Quiz
Select 0/1

Which characteristic best describes TCP compared to UDP?

Quiz
Select 0/1

In the DNS resolution hierarchy, which server does the recursive resolver contact first when resolving a hostname it has not previously cached?

Quiz
Select 0/1

Why was IPv6 introduced given that IPv4 was already widely deployed?