Socket Programming Basics: A Tiny TCP Port Reachability Checker in Python

telnet some-host 443 just to see if a port is open is a common reflex. That little reachability test — can this TCP port on this host be reached? — can be done natively in Python with the socket module, and from there it scales: sweep hundreds of ports across dozens of hosts in seconds. Today builds a real port reachability checker and, along the way, demystifies what a socket actually is.

No deep network-programming theory is needed here. TCP, ports, and the three-way handshake are already familiar territory for a network engineer — this is just about driving them from Python.

What a Socket Is (in 30 Seconds)

A socket is an endpoint for network communication — the programmatic equivalent of plugging a cable into a jack. Testing a TCP port means creating a socket, trying to connect it to (host, port), and seeing whether the connection succeeds. A successful connect means the port is open and something accepted the handshake. That is the whole idea.

import socket

# AF_INET = IPv4, SOCK_STREAM = TCP
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(2)                      # don't wait forever on a filtered port
result = s.connect_ex(("1.1.1.1", 443))   # returns 0 on success
s.close()
print("OPEN" if result == 0 else "CLOSED/FILTERED")

The key choice: connect_ex instead of connect. The plain connect raises an exception on failure; connect_ex returns an error code (0 = success), which allows clean branching without a try/except for the common case. For a scanner, connect_ex is exactly right.

Always Set a Timeout

This is the rule that separates a usable scanner from one that hangs. A closed port usually refuses fast, but a filtered port (firewall silently dropping) lets the connect attempt sit until the OS default timeout — which can be 30+ seconds. settimeout(2) caps that. Tune it to the network: 1–2 seconds for a LAN, more across a WAN.

The Cleaner Pattern: Context Managers

Recall with from the files lesson in Week 1. Sockets support it too, so they always get closed even if something goes wrong:

import socket

def check_port(host, port, timeout=2):
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(timeout)
        return s.connect_ex((host, port)) == 0

print(check_port("1.1.1.1", 443))   # True
print(check_port("1.1.1.1", 8443))  # False (likely)

That is a complete, reusable port checker in five lines. Everything else today builds on it.

Resolving Names and Grabbing the Local View

The socket module also wraps DNS and local identity, handy for inventory scripts:

import socket

# Forward lookup: name -> IP
print(socket.gethostbyname("one.one.one.one"))   # 1.1.1.1

# Reverse lookup: IP -> name (may raise socket.herror if no PTR)
try:
    print(socket.gethostbyaddr("1.1.1.1")[0])
except socket.herror:
    print("no PTR record")

# What does the local box think it is?
print(socket.gethostname())

Cisco Context: A Service Health Checker

Put it together: scan the relevant management-plane ports across a device list and produce a matrix. Is SSH up on every switch? Did someone leave Telnet (23) open where they should not have? This answers both.

import socket

SERVICES = {22: "SSH", 23: "Telnet", 443: "HTTPS", 830: "NETCONF"}

def check_port(host, port, timeout=1.5):
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(timeout)
        return s.connect_ex((host, port)) == 0

devices = ["1.1.1.1", "8.8.8.8"]

# header
print(f"{'Host':16}" + "".join(f"{name:9}" for name in SERVICES.values()))
for host in devices:
    row = f"{host:16}"
    for port, name in SERVICES.items():
        row += f"{'open' if check_port(host, port) else '-':9}"
    print(row)

Run this and a row where Telnet shows open is an instant audit finding. The same structure, pointed at a real management subnet, becomes a daily exposure check.

Going Faster: A Note on Concurrency

Checking ports one at a time is fine for a handful of hosts but slow for hundreds, because each check spends most of its time waiting. That waiting is the perfect case for concurrency. Today’s examples stay single-threaded to keep the focus clear, but here is the shape of the speedup using the standard library’s thread pool — worth filing away for later:

from concurrent.futures import ThreadPoolExecutor

targets = [("1.1.1.1", 443), ("8.8.8.8", 443), ("1.1.1.1", 22)]
with ThreadPoolExecutor(max_workers=50) as pool:
    results = pool.map(lambda t: (t, check_port(*t)), targets)
for (host, port), is_open in results:
    print(host, port, "open" if is_open else "closed")

Exercises

  1. Warm-up. Write is_open(host, port) using connect_ex and a 2-second timeout. Test it against a known-open port and a known-closed one.
  2. Range scan. Scan ports 20–25 on a host and print only the open ones.
  3. Service map. Given the dict {22:"SSH", 80:"HTTP", 443:"HTTPS"}, check one host and print "SSH: open" / "HTTP: closed" style lines using the friendly names.
  4. Resolve then scan. Take a hostname, resolve it to an IP with gethostbyname, then check whether 443 is open on the resolved address. Handle the case where the name does not resolve.
  5. Challenge. Write first_open(host, ports) that returns the first open port from a list (useful for “is this device reachable on SSH or Telnet?”), or None if none are open. Then use it to classify a list of hosts as "ssh", "telnet-only", or "unreachable".

Answers

Show answers

1. Warm-up

import socket
def is_open(host, port):
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(2)
        return s.connect_ex((host, port)) == 0

print(is_open("1.1.1.1", 443))   # True
print(is_open("1.1.1.1", 444))   # False

2. Range scan

host = "1.1.1.1"
for port in range(20, 26):
    if is_open(host, port):
        print(f"{port} open")

3. Service map

services = {22: "SSH", 80: "HTTP", 443: "HTTPS"}
host = "1.1.1.1"
for port, name in services.items():
    print(f"{name}: {'open' if is_open(host, port) else 'closed'}")

4. Resolve then scan

import socket
name = "one.one.one.one"
try:
    ip = socket.gethostbyname(name)
    print(f"{name} -> {ip}: 443 {'open' if is_open(ip, 443) else 'closed'}")
except socket.gaierror:
    print(f"{name} did not resolve")

gaierror is the “get address info” error — the exception DNS resolution raises on failure.

5. Challenge

def first_open(host, ports):
    for p in ports:
        if is_open(host, p):
            return p
    return None

hosts = ["1.1.1.1", "8.8.8.8", "192.0.2.1"]
for h in hosts:
    p = first_open(h, [22, 23])
    if p == 22:
        label = "ssh"
    elif p == 23:
        label = "telnet-only"
    else:
        label = "unreachable"
    print(f"{h:15} {label}")

Returning early from the loop on the first hit is the idiom — no need to scan the rest once an answer is in hand.

This is Day 11 of the 21‑post Python for Network Engineers series.


Previously: subprocess. Coming tomorrow — Paramiko: a first real SSH session to a router, driven entirely from Python.