A concurrent TCP echo/handler server built on socket and threading, used as a safe local target for the client, scanner, and banner-grabber projects.
A TCP server binds a socket to an address, listens for incoming connections, and handles each accepted client. This project implements a threaded echo server: it accepts many clients at once, echoes back whatever they send, and cleanly handles disconnects. It doubles as a controlled lab target so you can test [[TCP-Client]], [[Port-Scanner]], and [[Banner-Grabber]] without touching any third-party system.
[!warning] Authorized use only Bind test servers to
127.0.0.1(loopback) unless you intend to expose them, and never run experimental listeners on networks you do not control.
- Bind, listen, and accept TCP connections.
- Serve multiple clients concurrently without blocking.
- Guard shared state across threads.
- Frame messages over a byte-stream protocol.
- Shut down cleanly, releasing the port immediately.
| Item | Detail |
|---|---|
| Python | 3.9 or newer |
| Dependencies | socket, threading, argparse (standard library) |
| Privileges | None for ports above 1023; root required below that |
| Bind address | 127.0.0.1 for lab use |
| Pairs with | The [[TCP-Client]] project |
- CLI —
argparsesets bind address, port, and a custom banner. - Listener — one socket with
SO_REUSEADDR,bind(), andlisten(). - Accept loop — the main thread blocks on
accept(), spawning a handler thread per client. - Handler — sends a banner, then echoes each received chunk until the client closes.
- Shutdown —
KeyboardInterruptbreaks the loop and thewithblock closes the socket.
bind + listen ─▶ accept loop ─▶ Thread(handle_client, conn)
│ recv ─▶ sendall(echo)
└─ loop until client closes
tcp-server/
├── tcp_server.py # threaded echo server (CLI)
└── tcp_client.py # test client (see TCP-Client note)
- Bind and listen. Create a socket, set
SO_REUSEADDR, bind to127.0.0.1, and listen with a backlog. - Serve one client. Accept a connection, echo what it sends, and close.
- Serve many. Spawn a thread per accepted connection.
- Protect shared state. Hold the client registry behind a
threading.Lock. - Frame messages. Add newline delimiting and buffer partial reads —
recv()boundaries do not matchsend()boundaries. - Bound input. Cap the per-client buffer and drop clients that exceed it.
- Shut down. Handle
KeyboardInterrupt, close all client sockets, and join the threads.
#!/usr/bin/env python3
"""tcp_server.py - Threaded TCP echo server for lab use (educational).
Bind to loopback unless you deliberately expose it.
"""
import argparse
import socket
import threading
def handle_client(conn: socket.socket, addr: tuple[str, int], banner: str) -> None:
"""Serve one client: send a banner, then echo everything it sends."""
print(f"[+] Connection from {addr[0]}:{addr[1]}")
try:
conn.sendall(banner.encode() + b"\n")
while True:
data = conn.recv(4096)
if not data: # empty read == client closed
break
conn.sendall(b"echo: " + data)
except OSError as exc:
print(f"[!] {addr}: {exc}")
finally:
conn.close()
print(f"[-] Disconnected {addr[0]}:{addr[1]}")
def serve(host: str, port: int, banner: str) -> None:
"""Bind, listen, and dispatch each client to its own thread."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv:
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind((host, port))
srv.listen(5)
print(f"[*] Listening on {host}:{port} (Ctrl-C to stop)")
try:
while True:
conn, addr = srv.accept()
thread = threading.Thread(
target=handle_client, args=(conn, addr, banner), daemon=True
)
thread.start()
except KeyboardInterrupt:
print("\n[*] Shutting down.")
def main() -> int:
parser = argparse.ArgumentParser(description="Threaded TCP echo server.")
parser.add_argument("-b", "--bind", default="127.0.0.1",
help="bind address (default: 127.0.0.1)")
parser.add_argument("-p", "--port", type=int, default=9000,
help="listen port (default: 9000)")
parser.add_argument("--banner", default="Welcome to the echo server",
help="banner sent on connect")
args = parser.parse_args()
serve(args.bind, args.port, args.banner)
return 0
if __name__ == "__main__":
raise SystemExit(main())python tcp_server.py --bind 127.0.0.1 --port 9000[*] Listening on 127.0.0.1:9000 (Ctrl-C to stop)
[+] Connection from 127.0.0.1:52344
[-] Disconnected 127.0.0.1:52344
# Start the server on a lab port
python3 tcp_server.py --host 127.0.0.1 --port 9009
# Larger backlog and verbose logging
python3 tcp_server.py --host 127.0.0.1 --port 9009 --backlog 32 --verbose
# In another terminal
python3 tcp_client.py 127.0.0.1 9009 --message "hello"$ python3 tcp_server.py --host 127.0.0.1 --port 9009
[*] Listening on 127.0.0.1:9009 (backlog 8)
[+] Client connected: 127.0.0.1:52114
[>] 127.0.0.1:52114 sent 'hello'
[-] Client disconnected: 127.0.0.1:52114
^C
[*] Shutting down, closing 0 active connections
| Condition | Exception | Response |
|---|---|---|
| Port already bound | OSError (EADDRINUSE) |
Report clearly and suggest SO_REUSEADDR or another port; exit 1 |
| Binding a privileged port as a normal user | PermissionError |
Report that ports below 1024 need root; exit 1 |
| Client disconnects abruptly | ConnectionResetError |
Remove that client only; the server keeps running |
| Client sends non-UTF-8 data | UnicodeDecodeError |
Decode with errors="ignore" or handle as bytes |
| Client exceeds the buffer cap | custom | Log and disconnect that client |
| Operator stops the server | KeyboardInterrupt |
Close every socket, join threads, exit 0 |
Each client is handled in its own try/except so one misbehaving peer cannot take the server down.
[!warning] Authorized use only Bind to
127.0.0.1unless you deliberately intend to expose the service. Run this only on systems you own.
- Binding
0.0.0.0publishes the service to every host that can reach the interface. On an untrusted network that is an unauthenticated service anyone can talk to. - There is no authentication or encryption here. Anyone who connects is trusted. Adding TLS via the [[SSL-Module|ssl]] module and an auth step is the first thing a real service needs.
- Bound every read. An unbounded
recv()loop is a memory-exhaustion denial of service. - Thread-per-client does not scale and is itself a resource-exhaustion vector — a real service would use
asyncioor a bounded worker pool. - Never
eval()orpickle.loads()network input. Both are direct remote-code-execution paths. - Log connections with timestamps so the service is auditable.
# Start the server, connect, and assert the echo
python3 tcp_server.py --host 127.0.0.1 --port 9009 &
sleep 0.5
echo "hello" | nc -q1 127.0.0.1 9009 | grep -qi hello && echo PASS
kill %1import socket
import threading
import tcp_server
def test_server_echoes():
server = tcp_server.EchoServer("127.0.0.1", 0) # port 0 = pick a free port
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
with socket.create_connection(server.address, timeout=2) as sock:
sock.sendall(b"hello\n")
assert b"hello" in sock.recv(1024).lower()
server.shutdown()Cover: a single client, two concurrent clients, an abrupt disconnect, and an oversized message.
socketserver/asyncio— replace manual threading withsocketserver.ThreadingTCPServeror anasyncioserver for cleaner lifecycle handling.- Graceful shutdown — use a
threading.Eventand join handler threads instead ofdaemon=True. - TLS — wrap accepted sockets with
sslto offer an encrypted listener. - Protocol — replace echo with a tiny request/response protocol to practice parsing.
- Connection limits — cap concurrent clients with a
Semaphoreto avoid resource exhaustion.
| Symptom | Likely cause | Fix |
|---|---|---|
OSError: [Errno 98] Address already in use |
Previous socket in TIME_WAIT |
Set SO_REUSEADDR before bind() |
PermissionError on bind |
Port below 1024 without root | Use a port above 1023 |
| Server exits when a client drops | Unhandled ConnectionResetError |
Catch it inside the per-client handler |
| Messages concatenated | No framing over a byte stream | Delimit with newlines and buffer partial reads |
| Client list corrupted | Shared state without a lock | Guard all access with threading.Lock |
| Server will not stop on Ctrl-C | Threads blocked in recv() |
Mark threads daemon, or close their sockets to unblock |
- [[TCP-Client]]
- [[Port-Scanner]]
- [[Banner-Grabber]]
- [[Socket-Programming-for-Networking]]
- [[Argparse-Module]]
- [[Mini-Projects/Readme|Mini-Projects]] — module index
- [[Readme|Python for Security Professionals]] — course home