Networking in C · beginner · ~6 min
- By the end you can explain what the loopback interface (127.0.0.1 / ::1) is and why traffic to it never leaves your machine. - By the end you can bind a server to INADDR_LOOPBACK instead of INADDR_ANY, and articulate the security difference between the two. - By the end you can write a complete, hermetic client+server test that runs entirely over loopback with no external dependencies. - By the end you can recognize and diagnose the failure modes (ENETUNREACH, EADDRINUSE, EACCES) that show up in a locked-down, localhost-only sandbox. - By the end you can justify the localhost-only rule on technical, legal, and reproducibility grounds.
In the previous lesson you built a simple TCP server: you called socket(), bind(), listen(), and accept() and watched a client connect. This lesson is about where that server is allowed to listen and why every exercise in this course keeps that scope pinned to your own machine. You already know how to fill in a struct sockaddr_in; here we zoom in on exactly one field of it — sin_addr — because that single field decides whether your server is a private lab or a door onto the network.
The short version: every networking exercise here is localhost-only. You bind to the loopback address 127.0.0.1, you connect to 127.0.0.1, and the sandbox runs your code with networking disabled as a backstop. None of that limits what you can learn — the TCP handshake, framing, error handling, and server design are all identical over loopback — but it guarantees your practice code can never touch a stranger's machine.
The difference between INADDR_LOOPBACK and INADDR_ANY is a one-line change that decides whether a service is private or exposed to everyone on your WiFi, your office LAN, or (behind a misconfigured firewall) the public internet. Real breaches have started with a debug server, database, or admin port that someone bound to 0.0.0.0 "just for testing" and forgot about — think of the many unauthenticated Redis, Elasticsearch, and Docker daemons that have been mass-scanned and wiped. Learning the loopback-first habit now means that when you write production services later, exposing a port is a deliberate, reviewed decision rather than an accident. It is also the ethical and legal boundary of this course: we teach how servers work defensively, and defensive understanding never requires sending a packet to a host you do not own.
localhost is a hostname that resolves to the loopback address: 127.0.0.1 for IPv4 (and ::1 for IPv6). Loopback is a virtual network interface implemented entirely inside the kernel. When you send bytes to 127.0.0.1, the kernel copies them from the sending socket's buffer straight to the receiving socket's buffer. No network card is involved, no Ethernet frame is built, no packet is put on a wire, and nothing is visible to any other machine.
Normal network path Loopback path (127.0.0.1)
------------------- --------------------------
app -> socket buffer app -> socket buffer
-> TCP/IP stack -> TCP/IP stack (lo)
-> NIC driver -> kernel copies back up
-> physical wire <== leaves -> receiving socket
-> other machine the host! (never leaves the host)
The entire 127.0.0.0/8 block (127.0.0.1 through 127.255.255.255) is reserved for loopback, so any address starting with 127. stays on the machine.
sin_addrWhen a server binds, it chooses which local address to accept connections on. That choice is the sin_addr.s_addr field of the struct sockaddr_in you pass to bind(). Two constants dominate:
| Constant | Numeric value | Server listens on | Reachable from |
|---|---|---|---|
INADDR_LOOPBACK |
0x7f000001 (127.0.0.1) |
only the loopback interface | this machine only |
INADDR_ANY |
0x00000000 (0.0.0.0) |
every interface | anything that can route to any of your IPs |
Both constants are in host byte order, so you must wrap them with htonl() before storing them: addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);. Binding to INADDR_ANY is the classic footgun: it feels convenient ("listen everywhere") but it means the moment your laptop joins a network, your practice server is reachable by every other device on that network.
Knowledge check: You wrote addr.sin_addr.s_addr = INADDR_LOOPBACK; (no htonl). On a little-endian x86 machine, what address did you actually bind to?
INADDR_LOOPBACKis0x7f000001in host order. On little-endian hardware the bytes are stored01 00 00 7f, which is the IPv4 address1.0.0.127— not loopback at all.bind()will likely fail withEADDRNOTAVAILbecause your machine has no such address. Always wrap address and port constants (htonl,htons) so the value is in network byte order regardless of your CPU's endianness.
INADDR_ANY — the security picture bind(INADDR_LOOPBACK) bind(INADDR_ANY)
-------------------- ----------------
[ your machine ] [ your machine ]
lo: 127.0.0.1 <-- only you lo: 127.0.0.1 <-- you
eth0: 192.168.1.20 (closed) eth0: 192.168.1.20 <-- neighbor
wlan: 10.0.0.5 <-- coffee shop
Binding to loopback is fail-closed: even a server with no authentication and a nasty buffer bug is only exploitable by processes already on your machine. Binding to INADDR_ANY is fail-open: the same buggy server is now exposed to whoever shares your network segment. In this course we always choose fail-closed.
--network=noneYour code is not the only line of defense. Submissions run in a container started with --network=none, meaning the process has a network namespace with only a loopback device — no real interfaces at all. This is defense in depth:
ENETUNREACH ("network is unreachable") on connect().So even if a submission tried to connect() to a public IP, it would get an error instead of a packet on the wire. Your correct code and the sandbox agree; the sandbox just guarantees the outcome.
Every concept in networking is observable over loopback: the three-way handshake, send/recv semantics, partial reads and message framing, blocking vs. non-blocking sockets, select/poll, connection resets, timeouts, and graceful shutdown all behave the same. The only things loopback hides are physical-network effects (real latency, packet loss, MTU/fragmentation), which are not what these lessons teach. For reproducibility this is a feature: loopback behaves identically on your laptop, on CI, and inside Docker.
uint32_t htonl(uint32_t hostlong) / uint16_t htons(uint16_t hostshort) — convert a 32-bit (address) or 16-bit (port) value from host byte order to network byte order (big-endian). Their inverses are ntohl/ntohs. Always wrap address and port constants with these; they are no-ops on big-endian hosts and byte-swaps on little-endian ones, so your code stays portable. From <arpa/inet.h>.
INADDR_LOOPBACK (== 0x7f000001) and INADDR_ANY (== 0) — integer constants in host byte order representing 127.0.0.1 and 0.0.0.0 respectively. Assign as addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);. From <netinet/in.h>.
int bind(int fd, const struct sockaddr *addr, socklen_t len) — associate socket fd with the local address in addr. Returns 0 on success, -1 with errno set on failure. Common errors: EADDRINUSE (port already taken), EADDRNOTAVAIL (asked for an address this host doesn't have — often the un-htonl'd bug), EACCES (binding a port < 1024 without privilege).
int getsockname(int fd, struct sockaddr *addr, socklen_t *len) — fill addr with the local address fd is actually bound to. Essential when you bind to port 0 ("give me any free port") and need to learn which one the kernel chose. len is in/out: set it to the buffer size before the call, read the used size after.
struct sockaddr_in — the IPv4 address struct. Fields: sin_family (set to AF_INET), sin_port (16-bit port, network order — use htons), sin_addr.s_addr (32-bit address, network order — use htonl). Always memset it to zero first so padding/sin_zero is clean.
Resource rules: every socket() and every fd returned by accept() must be close()d. There is no memory to free() here, but leaked file descriptors are a real bug — a server that never closes accepted sockets will eventually hit EMFILE (too many open files).
Every networking exercise in this course is localhost-only. Localhost means your own machine, reachable at the address 127.0.0.1 (also called the loopback address). Traffic to this address never leaves your computer.
In practice, that means:
INADDR_LOOPBACK (127.0.0.1), never INADDR_ANY.INADDR_ANY would listen on every network interface, including ones reachable from outside.127.0.0.1, never to an external host.--network=none, so even an exercise that tried to reach the public internet would simply fail with ENETUNREACH ("network unreachable").You lose nothing technically. You can practice every concept — the handshake, message framing, server design, error handling — without ever generating a packet that leaves your machine. The kernel's loopback interface handles all of it internally.
Legal and ethical reasons. Scanning, connecting to, or sending traffic to other people's hosts is not something this course teaches or condones. A defensive understanding of how servers work is enough.
Reproducibility. Localhost behaves the same everywhere: every machine, every CI runner, every Docker container.
If your own work ever genuinely requires testing against a remote service, do it on a machine you own, against a service you own. That is outside the scope of this curriculum.
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
/* The port the server actually got, published to the client thread. */
static int g_port = -1;
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t g_ready = PTHREAD_COND_INITIALIZER;
static void *server_thread(void *arg) {
(void)arg;
int lsock = socket(AF_INET, SOCK_STREAM, 0);
if (lsock < 0) { perror("socket"); return NULL; }
int yes = 1;
setsockopt(lsock, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes);
struct sockaddr_in addr;
memset(&addr, 0, sizeof addr);
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); /* 127.0.0.1 ONLY */
addr.sin_port = 0; /* 0 = kernel picks a free port */
if (bind(lsock, (struct sockaddr *)&addr, sizeof addr) < 0) {
perror("bind"); close(lsock); return NULL;
}
if (listen(lsock, 1) < 0) { perror("listen"); close(lsock); return NULL; }
/* Discover which ephemeral port we were assigned. */
struct sockaddr_in bound;
socklen_t blen = sizeof bound;
getsockname(lsock, (struct sockaddr *)&bound, &blen);
pthread_mutex_lock(&g_lock);
g_port = ntohs(bound.sin_port);
pthread_cond_signal(&g_ready);
pthread_mutex_unlock(&g_lock);
printf("[server] listening on 127.0.0.1:%d\n", g_port);
int csock = accept(lsock, NULL, NULL);
if (csock < 0) { perror("accept"); close(lsock); return NULL; }
char buf[128];
ssize_t n = recv(csock, buf, sizeof buf - 1, 0);
if (n > 0) {
buf[n] = '\0';
printf("[server] received: %s\n", buf);
send(csock, buf, (size_t)n, 0); /* echo it back */
}
close(csock);
close(lsock);
return NULL;
}
int main(void) {
pthread_t tid;
if (pthread_create(&tid, NULL, server_thread, NULL) != 0) {
perror("pthread_create"); return 1;
}
/* Wait until the server has published its port. */
pthread_mutex_lock(&g_lock);
while (g_port < 0) pthread_cond_wait(&g_ready, &g_lock);
int port = g_port;
pthread_mutex_unlock(&g_lock);
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0) { perror("socket"); return 1; }
struct sockaddr_in srv;
memset(&srv, 0, sizeof srv);
srv.sin_family = AF_INET;
srv.sin_port = htons((uint16_t)port);
srv.sin_addr.s_addr = htonl(INADDR_LOOPBACK); /* connect to 127.0.0.1 ONLY */
if (connect(sock, (struct sockaddr *)&srv, sizeof srv) < 0) {
perror("connect"); close(sock); return 1;
}
const char *msg = "hello over loopback";
send(sock, msg, strlen(msg), 0);
char reply[128];
ssize_t n = recv(sock, reply, sizeof reply - 1, 0);
if (n > 0) {
reply[n] = '\0';
printf("[client] echo: %s\n", reply);
}
close(sock);
pthread_join(tid, NULL);
puts("[main] done -- no packet ever left this machine");
return 0;
}
g_port, g_lock, g_ready — a tiny hand-off channel. The server picks an ephemeral port at runtime; the client needs to know it. The mutex + condition variable let the client block until the port is published, avoiding a race and avoiding a sleep() hack.
socket(AF_INET, SOCK_STREAM, 0) — create an IPv4 TCP socket, exactly as in the previous lesson.
setsockopt(..., SO_REUSEADDR, ...) — lets the port be re-bound quickly after the program exits (a socket lingering in TIME_WAIT otherwise causes EADDRINUSE). Handy when re-running tests.
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); — the heart of the lesson. This pins the listener to 127.0.0.1. Swapping in INADDR_ANY here is the single change that would expose the server to the network.
addr.sin_port = 0; — asking the kernel for any free ephemeral port. This keeps the demo hermetic: no hard-coded port to clash with something already running.
bind() / listen() — associate the socket with 127.0.0.1:
getsockname(...) + ntohs(bound.sin_port) — read back the port the kernel actually assigned and convert it from network order to host order for printing and for the client.
pthread_mutex_lock ... pthread_cond_signal — publish the port and wake the client. The client side uses a while (g_port < 0) pthread_cond_wait(...) loop — a while, not an if, to guard against spurious wakeups.
accept() / recv() / send() — accept the one connection, read the message, echo the same bytes back. recv returns the byte count; we NUL-terminate before printing since the bytes are not guaranteed to be.
Client half in main — mirror of the server: build a sockaddr_in pointing at htonl(INADDR_LOOPBACK) and the discovered port, connect(), send() the message, recv() the echo.
close(...) everywhere + pthread_join — every socket fd is closed; joining the thread ensures a clean, leak-free shutdown before main returns.
1. Binding to INADDR_ANY "for convenience."
addr.sin_addr.s_addr = htonl(INADDR_ANY); /* WRONG for a lab server */
Why it breaks: the server now listens on every interface. On a shared network anyone can reach your unauthenticated, possibly-buggy practice server. Fix:
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); /* 127.0.0.1 only */
2. Forgetting htonl/htons.
addr.sin_addr.s_addr = INADDR_LOOPBACK; /* WRONG */
addr.sin_port = 8080; /* WRONG */
Why it breaks: on little-endian CPUs the bytes are stored reversed, so you bind to a garbage address (leading to EADDRNOTAVAIL) and the wrong port. Fix:
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
addr.sin_port = htons(8080);
3. Not zeroing the address struct.
struct sockaddr_in addr; /* WRONG: uninitialized padding */
addr.sin_family = AF_INET;
addr.sin_port = htons(0);
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
bind(fd, (struct sockaddr*)&addr, sizeof addr);
Why it breaks: leftover garbage in sin_zero/padding can cause subtle, non-reproducible failures. Fix: memset(&addr, 0, sizeof addr); before setting any field.
4. Ignoring the bind() return value.
bind(fd, (struct sockaddr*)&addr, sizeof addr); /* WRONG: return ignored */
listen(fd, 1);
Why it breaks: if bind failed (EADDRINUSE, etc.) you listen/accept on an unbound socket and get confusing behavior. Fix: if (bind(...) < 0) { perror("bind"); return; } — always check and perror.
lsof -iTCP -sTCP:LISTEN -P -n (macOS/Linux) or ss -ltnp (Linux). You want to see 127.0.0.1:<port>; if you see 0.0.0.0:<port> or *:<port> you accidentally bound to INADDR_ANY.perror/strerror(errno) on every syscall. The errno value is the fastest diagnosis: EADDRINUSE = port taken (use SO_REUSEADDR or a different/ephemeral port), EADDRNOTAVAIL = you likely forgot htonl, ENETUNREACH = you tried to leave the machine (expected under --network=none), EACCES = binding a privileged port (<1024).strace -f -e trace=network ./m (Linux) or dtruss/ktrace (macOS) shows every socket/bind/connect call and its result — invaluable for seeing the exact address bytes passed to bind.gdb: break on bind, then p/x ((struct sockaddr_in*)addr)->sin_addr.s_addr — you should see 0x0100007f (network-order 127.0.0.1). Seeing 0x7f000001 means you forgot htonl.while loop and that the server signals after setting g_port.recv returns a length, not a terminated string. Always read into buf[size-1] max and set buf[n] = '\0' before printf("%s"), or you read past the buffer.recv to fill your buffer or deliver a whole message. TCP is a byte stream; a single send may arrive as several recvs (and vice versa). Sizing reads to sizeof buf - 1 and checking the return value avoids overflow; real protocols need a framing loop.socket()/accept() fd must be close()d. A server that leaks fds eventually fails with EMFILE. This demo closes all four (listen, accepted, client, and joins the thread).g_port is shared between two threads, so it is written and read only under g_lock, and the wait uses a while loop to survive spurious wakeups. Reading a shared variable without the lock is a data race (undefined behavior). Compile with -fsanitize=thread to catch such races.htonl/htons produces a valid-but-incorrect address; the sanitizers won't flag it, only inspection (gdb / lsof) will.bind 0.0.0.0 with no auth.pprof/profiling ports, and language debuggers (e.g. a Python pdb remote port) should listen on loopback and be reached via an SSH tunnel or reverse proxy, never bound to all interfaces.127.0.0.1:0 (ephemeral port), talk to it over loopback, and tear it down — fast, parallelizable, and identical on every runner. Best practice: default to loopback, make external exposure an explicit, reviewed, authenticated decision, and bind to port 0 in tests to avoid clashes.Modify the demo to print the raw 32-bit value of srv.sin_addr.s_addr in hex both before and after htonl(INADDR_LOOPBACK), and confirm it becomes 0x0100007f in network order.
Change the server to bind to a fixed port (e.g. 5555) instead of 0. Run two copies and observe the second one's bind failing; identify the errno and fix it with SO_REUSEADDR or by choosing another port.
Deliberately drop the htonl around INADDR_LOOPBACK, run the program, and record the exact error bind produces. Explain in a comment why that address is invalid on your machine.
Add a helper int is_loopback(uint32_t netaddr) that returns true only for addresses in 127.0.0.0/8, and call it right before connect() to refuse any non-loopback destination, printing a clear message if it would leave the machine.
Extend the server into a length-prefixed echo: send a 4-byte big-endian length followed by that many bytes, and have the server loop on recv until it has the full message before echoing. Verify it still runs entirely over 127.0.0.1.
sin_addr.s_addr = htonl(INADDR_LOOPBACK) is fail-closed (this machine only); htonl(INADDR_ANY) is fail-open (every interface). Choose loopback.htonl/htons; forgetting it silently binds to a wrong address (often EADDRNOTAVAIL).--network=none as defense in depth: loopback works, off-machine connect() fails with ENETUNREACH.lsof/ss (what am I bound to?), perror/errno, and gdb/strace; stay safe by NUL-terminating received bytes, closing every fd, and guarding shared state with a mutex.