Learning Goals
3 minBy the end of this lesson you can:
- Build a TCP server:
bind → listen → accept → handle → close. - Serve multiple clients concurrently with threads.
- Apply server security defaults: bind to localhost, validate input, cap resources.
- Pair it with the Lesson 7 client over your length-prefixed protocol.
Warm-Up · A Server Is the Other Half
5 minLast lesson's client initiated connections. A server does the opposite: it claims a port, then waits. When a client connects, the server gets a fresh socket dedicated to that one client, talks to it, and goes back to waiting for the next.
The server lifecycle is create → bind → listen → accept (loop) → handle → close. The subtle part is concurrency: accept blocks until a client arrives and gives you a per-client socket, so to serve more than one client at a time you hand each connection to a thread. And because a server accepts untrusted input from anyone who can reach it, it's a prime attack target — security defaults matter from line one.
New Concept · The Server Lifecycle & Safety
14 minA minimal echo server
import socket with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # reuse port on restart server.bind(("127.0.0.1", 9000)) # claim host:port — LOCALHOST server.listen() # start accepting print("listening on 127.0.0.1:9000") while True: conn, addr = server.accept() # blocks until a client connects with conn: print("client:", addr) data = conn.recv(1024) conn.sendall(data) # echo it back
bind((host, port))claims the address. Bind to127.0.0.1so only your machine can reach it (not0.0.0.0, which exposes it to the whole network).listen()turns the socket into a listening server.accept()blocks and returns(conn, addr)— a new socket just for that client, plus their address.SO_REUSEADDRavoids "address already in use" when you restart during development.
Serving many clients with threads
The minimal server handles one client at a time (a second client waits). Hand each connection to a thread to serve them concurrently:
import socket, threading def handle_client(conn, addr): with conn: conn.settimeout(30) # don't let a client tie up a thread forever while True: data = conn.recv(1024) if not data: # client disconnected break conn.sendall(data) # echo print("closed:", addr) def serve(): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(("127.0.0.1", 9000)) server.listen() while True: conn, addr = server.accept() # one thread per client (daemon = dies when main exits) threading.Thread(target=handle_client, args=(conn, addr), daemon=True).start()
Server security defaults — the must-dos
- Bind to localhost in development; only expose externally when you mean to, behind a firewall.
- Set a recv timeout so a silent client can't hold a thread forever (a cheap DoS).
- Cap message size — never read unbounded input into memory (another DoS).
- Validate everything a client sends before acting on it — it's untrusted (the boundary from Lesson 2).
- Never
eval/execclient data, and never pass it to a shell (Lesson 43). - Limit connections if needed, and log who connects (Lesson 45).
For real apps, prefer a framework
Raw sockets are perfect for understanding the network and for security tooling. For production services you'd use a framework (Flask, FastAPI, asyncio) that handles concurrency, parsing, and many security details for you. Knowing sockets makes you a better user of those frameworks.
Worked Example · A Secure-by-Default Message Server
12 minGoal: a threaded server speaking the length-prefixed protocol from Lesson 7, with every security default baked in — size caps, timeouts, validation, logging. This is the foundation for the encrypted chat in Lesson 10.
import socket, struct, threading, logging logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S") log = logging.getLogger("server") HOST, PORT = "127.0.0.1", 9000 # localhost only MAX_MSG = 64 * 1024 # 64KB cap — refuse anything larger def recv_exact(sock, n: int) -> bytes: buf = b"" while len(buf) < n: chunk = sock.recv(n - len(buf)) if not chunk: raise ConnectionError("client closed") buf += chunk return buf def recv_msg(sock) -> str: (length,) = struct.unpack("!I", recv_exact(sock, 4)) if length > MAX_MSG: # validate size BEFORE reading raise ValueError(f"message too large: {length} bytes") return recv_exact(sock, length).decode("utf-8", errors="replace") def send_msg(sock, text: str) -> None: data = text.encode("utf-8") sock.sendall(struct.pack("!I", len(data)) + data) def handle(conn, addr): log.info("connect %s", addr) with conn: conn.settimeout(60) # idle clients get dropped try: while True: msg = recv_msg(conn) msg = msg.strip()[:500] # validate/limit before use log.info("%s says: %r", addr, msg) if msg.lower() == "quit": send_msg(conn, "bye"); break send_msg(conn, f"echo: {msg}") # NEVER eval/exec the input except (ConnectionError, ValueError, socket.timeout) as e: log.warning("%s dropped: %s", addr, e) log.info("closed %s", addr) def main(): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((HOST, PORT)) server.listen() log.info("listening on %s:%d (localhost only)", HOST, PORT) while True: conn, addr = server.accept() threading.Thread(target=handle, args=(conn, addr), daemon=True).start() if __name__ == "__main__": main()
14:30:00 listening on 127.0.0.1:9000 (localhost only)
14:30:05 connect ('127.0.0.1', 51422)
14:30:05 ('127.0.0.1', 51422) says: 'hello'
14:30:08 ('127.0.0.1', 51422) says: 'quit'
14:30:08 closed ('127.0.0.1', 51422)Read the code
Every security default from the concept section is here: bound to 127.0.0.1, a 64KB size cap validated before reading (so a malicious 4GB length can't exhaust memory), a 60-second idle timeout (so a stalled client can't hold a thread), input stripped/truncated before use, and the echo handler never evals or shells out the input. The threading lets many clients connect at once. Pair it with the Lesson 7 client's send_msg/recv_msg and you have a complete, safe message system — ready for TLS in Lesson 9.
Try It Yourself
13 minRun the server, then connect with your Lesson 7 client and send a few messages. Confirm the echo comes back and that quit closes cleanly. Watch the server log.
Open two terminals and connect two clients simultaneously. Confirm the threaded server handles both without one blocking the other.
Make the server respond to a few safe commands (time → current time, echo <text>, upper <text>) using a whitelist dispatch — never executing arbitrary input. Reject unknown commands. This is the right way to accept "commands" from a network.
Hint
from datetime import datetime def run_command(line: str) -> str: parts = line.split(maxsplit=1) cmd = parts[0].lower(); arg = parts[1] if len(parts) > 1 else "" if cmd == "time": return datetime.now().isoformat() if cmd == "echo": return arg if cmd == "upper": return arg.upper() return "error: unknown command" # whitelist only — never exec(line)!
Mini-Challenge · Harden Against a Slow-Client DoS
8 minA classic attack opens many connections and sends data one byte at a time, very slowly, to exhaust your threads ("Slowloris"). Add defences: a per-client read timeout, a cap on total concurrent connections (a counting semaphore), and logging of rejected connections. Demonstrate it refusing the 11th connection when the cap is 10.
Show the key additions
import threading MAX_CLIENTS = 10 slots = threading.Semaphore(MAX_CLIENTS) def guarded_handle(conn, addr): if not slots.acquire(blocking=False): log.warning("REJECT %s — at capacity (%d)", addr, MAX_CLIENTS) with conn: send_msg(conn, "server busy, try later") return try: conn.settimeout(30) # slow clients time out handle(conn, addr) finally: slots.release() # in the accept loop: # threading.Thread(target=guarded_handle, args=(conn, addr), # daemon=True).start()
Non-negotiables: per-client timeout, a concurrency cap via semaphore, and a logged rejection when full.
Recap
3 minA TCP server runs create → bind → listen → accept (loop) → handle → close; accept gives a per-client socket you hand to a thread for concurrency. Because a server accepts input from anyone who can reach it, security defaults are non-negotiable: bind to localhost in dev, set timeouts, cap message size before reading, validate all input, and never eval/exec or shell out client data. Add a concurrency cap to resist slow-client DoS. Raw sockets teach the fundamentals; production services use frameworks built on these same ideas — now with TLS next lesson.
Vocabulary Card
- bind / listen / accept
- Claim a port / start accepting / get the next client's socket.
- per-client socket
- The dedicated socket
acceptreturns for one connection. - bind 127.0.0.1 vs 0.0.0.0
- Localhost-only vs. all interfaces (exposed to the network).
- resource cap
- Limits on size/timeouts/connections that prevent DoS.
Homework
4 minBuild a complete client+server pair on localhost that speaks your length-prefixed protocol and implements a safe, whitelisted command set. Apply all the server security defaults plus the connection cap. Write a short note listing each security measure and the specific attack it prevents.
Sample · security measures & the attacks they stop
bind 127.0.0.1 → only my machine can connect (no remote attackers) SO_REUSEADDR → dev convenience, not security 4-byte length + cap → rejects a forged huge length → stops memory-exhaustion DoS recv timeout (30s) → drops stalled clients → stops Slowloris-style DoS connection semaphore → caps concurrent clients → stops connection-flood DoS strip + truncate input→ bounds what the handler processes whitelist dispatch → only known commands run → stops command/code injection NEVER eval/exec/shell → input can never become executed code log every connect → audit trail for forensics (L8-45)
Non-negotiables: working client+server, whitelisted commands, every default applied, each mapped to the attack it prevents.