Writing and testing a TCP echo server: Python
Every server built across this course's socket programming module ran on a single machine, talking to 127.0.0.1. This lab is the one place all of it gets tested for real: the identical echo server code from that module, deployed on one host, reached from a genuinely separate one across a network — with every safety and verification habit this course has built up along the way actually put to use at once.
Goal and end result
By the end of this lab, you'll have the TCP echo server from Module 7 running on one machine, reachable and verified from a second, separate machine — confirming with ss, a live capture, and a Python client that a piece of code written and tested locally actually works the same way once a real network sits between client and server.
Topology
+------------------+ +------------------+
| server host | | client host |
| 192.168.100.10 |<---- TCP :65432 ---| 192.168.100.11 |
+------------------+ +------------------+
Requirements
- Two hosts or containers that can reach each other — the virtual home network lab's Docker setup works directly.
- Python 3 on both.
Safety note
This lab deliberately binds the server to 0.0.0.0 — every interface — rather than 127.0.0.1, because the entire point is reaching it from a separate host. That's safe here specifically because these two hosts sit on an isolated lab network with no public interface at all; the sockets article's own warning about 0.0.0.0 exposing a service to the entire internet applies in full the moment this same code runs on a machine that does have a public interface.
Step 1: deploy the server
# server.py
import socket
HOST = "0.0.0.0"
PORT = 65432
def main():
server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_sock.bind((HOST, PORT))
server_sock.listen(5)
print(f"Listening on {HOST}:{PORT}")
while True:
conn, addr = server_sock.accept()
print(f"Connection from {addr}")
with conn:
while True:
data = conn.recv(1024)
if not data:
break
conn.sendall(data)
if __name__ == "__main__":
main()
The only change from Module 7's version is HOST — 0.0.0.0 here instead of 127.0.0.1 — and wrapping accept() in a loop, the exact fix that article's own closing section named as necessary for handling more than one connection.
Step 2: confirm the server is actually listening, before trying to reach it
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 5 0.0.0.0:65432 0.0.0.0:* users:(("python3",pid=14,fd=3))
That 0.0.0.0:65432 confirms the socket is bound to every interface, exactly as configured — the same ss command the netstat article already covered for reading exactly this kind of listening-socket state. Checking this before attempting a connection from the client separates "the server never started listening" from "the server is listening but unreachable," two different problems with different fixes.
Step 3: connect from the separate client host
# client.py
import socket
SERVER_HOST = "192.168.100.10" # the server's real address, not 127.0.0.1
PORT = 65432
def main():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client_sock:
client_sock.connect((SERVER_HOST, PORT))
message = b"hello across a real network"
client_sock.sendall(message)
data = client_sock.recv(1024)
print(f"Sent: {message!r}")
print(f"Received: {data!r}")
if __name__ == "__main__":
main()
The identical bytes, echoed back, confirm the whole path worked: the client's connect() completed a real three-way handshake across the network rather than the loopback shortcut every earlier test in this course used, the server's accept() picked it up, and the data made the round trip intact.
On the server's own terminal, the connection log line now shows the client's real address instead of 127.0.0.1:
Step 4: watch the exchange as a live capture
14:22:07.001233 IP 192.168.100.11.51230 > 192.168.100.10.65432: Flags [S], seq 1234567890, win 64240, options [mss 1460,sackOK,TS val 991823001 ecr 0,nop,wscale 7], length 0
14:22:07.001301 IP 192.168.100.10.65432 > 192.168.100.11.51230: Flags [S.], seq 987654321, ack 1234567891, win 65160, options [mss 1460,sackOK,TS val 3820019271 ecr 991823001,nop,wscale 7], length 0
14:22:07.001350 IP 192.168.100.11.51230 > 192.168.100.10.65432: Flags [.], ack 987654322, win 64240, length 0
14:22:07.001480 IP 192.168.100.11.51230 > 192.168.100.10.65432: Flags [P.], seq 1234567891:1234567919, ack 987654322, win 64240, length 28
14:22:07.001520 IP 192.168.100.10.65432 > 192.168.100.11.51230: Flags [P.], seq 987654322:987654350, ack 1234567919, win 65160, length 28
This is the identical three-way handshake — [S], [S.], [.] — this course explained from the very first mention of TCP, right down to the same mss and wscale options that article walked through field by field, now captured live for a connection your own two lines of Python actually drove. The two [P.] segments carrying length 28 are the request and its echo, matching the 28-byte message sent in step 3 exactly.
Practical scenario: the connection works locally but times out from the other host
A variant of this exact setup fails: the client's connect() hangs and eventually times out, even though python3 server.py on the server host reports it's listening and curl 127.0.0.1:65432 from the server host itself connects fine.
The server is listening only on 127.0.0.1, not 0.0.0.0 — someone reverted the HOST value from step 1 back to loopback-only, whether by copying the exact code from Module 7 without re-applying this lab's one intentional change, or by editing it back during testing. A connection from the same machine reaches it fine, since 127.0.0.1 genuinely is reachable locally; a connection from any other host is refused at the kernel level before the application ever sees it, for precisely the reason the sockets article explained: a socket bound to one specific address only accepts traffic arriving on that exact interface. The fix is the one-line change this lab's step 1 already made — HOST = "0.0.0.0" — re-applied and the server restarted.
Cleanup
Stop both server.py and client.py with Ctrl-C; nothing else needs to be undone.
Evaluation criterion
You've completed this lab correctly if you can show, from your own terminal output across two separate hosts: the server's ss -tlnp line confirming it's listening on 0.0.0.0, a successful echo from the client showing the sent and received bytes matching, and a live tcpdump capture showing the handshake and the two data segments for that exact exchange.
This lab closes the socket-programming and hands-on modules the same way they opened: with the plain fact that a TCP connection is two ends, a handshake, and a stream of bytes — everything else this course covered, from DNS to TLS to load balancing, is built on exactly that foundation, just with more structure layered on top of it.
Sources
- Python Software Foundation, socket — Low-level networking interface