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

How often do you type telnet some-host 443 just to see if a port is open? That little reachability test — can I reach this TCP port on this host? — is something you can do natively in Python with the socket module, and once you can, you can sweep hundreds of ports across dozens of hosts in seconds. Today we build a real port reachability checker and, along the way, demystify what a socket actually is.

You do not need deep network-programming theory here. As a network engineer you already know what TCP, ports, and the three-way handshake are. We are just 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. To test a TCP port, you create a socket, try to connect it to (host, port), and see 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) so you can branch cleanly 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) will let your connect attempt sit until the OS default timeout — which can be 30+ seconds. settimeout(2) caps that. Tune it to your network: 1–2 seconds for a LAN, more across a WAN.

The Cleaner Pattern: Context Managers

Remember 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 is built 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 THIS box think it is?
print(socket.gethostname())

Cisco Context: A Service Health Checker

Put it together: scan the management-plane ports you care about 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 your 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. We are keeping today single-threaded to stay focused, but here is the shape of the speedup using the standard library’s thread pool — tuck it away for when you need it:

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 port you know is open and one you know is closed.
  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 you have an answer.


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

Leave a Reply