Advertisement
Open Source Projects by Phil Schwartz

Building a Python TCP port forwarder with logging and filtering

A TCP port forwarder accepts connections on one socket and relays traffic to another host and port. This simple pattern supports development proxies, internal service gateways, SSH jump points, test harnesses, and small network utilities. Python is well suited to the task because its standard library provides sockets, selectors, threading, logging, and IP address handling without requiring a large dependency stack.

A useful forwarder needs more than two connected sockets. It should preserve binary data, handle half-closed connections, avoid blocking the entire process, record useful events, and reject traffic that does not meet an explicit policy. The design below uses a selector-based event loop, structured log messages, and allow-list filtering that can be adapted for Linux servers or a local development machine in Sydney, Melbourne, or elsewhere in Australia.

Choosing the forwarding model

A TCP forwarder usually has a listening socket and an outbound socket. When a client connects, the program opens a connection to the target service, then copies bytes in both directions until either side closes. TCP has no message boundaries, so the code must treat received data as an arbitrary stream rather than assuming that one recv() call equals one request.

For a small tool, Python’s selectors module offers a practical balance between clarity and concurrency. One event loop can monitor many sockets, while each connection stores its client socket, upstream socket, buffers, and metadata. This avoids creating a thread for every connection, which can become wasteful when a public-facing host receives repeated scans or bursts of short-lived sessions.

The forwarder should bind only to the interface it needs. Binding to 127.0.0.1 is appropriate for a local debugging proxy, while an internal server might use a private address. Exposing 0.0.0.0 should be deliberate and protected by firewall rules, because a port relay can become an unintended open proxy.

Handling sockets and connection state

A compact implementation can begin with a listening socket configured for reuse and non-blocking operation. Each accepted client is checked against an allow list before the program attempts an upstream connection. The upstream socket is also non-blocking, allowing the event loop to continue serving existing connections while a remote service responds slowly.

import selectors
import socket

selector = selectors.DefaultSelector()

listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind(("127.0.0.1", 9000))
listener.listen()
listener.setblocking(False)

selector.register(listener, selectors.EVENT_READ, data=None)

In production code, a connection object should maintain separate buffers for client-to-server and server-to-client traffic. When a socket is readable, append the received bytes to the opposite direction’s buffer. When a socket is writable, send as much buffered data as the operating system accepts, then retain the remainder. This handles partial writes correctly and prevents large payloads from blocking the relay.

A clean shutdown also matters. A zero-length recv() indicates that the peer has performed an orderly shutdown. The forwarder can stop reading from that side, optionally call shutdown(socket.SHUT_WR) on the opposite socket, and close both sockets after pending data has drained. A hard timeout should close connections that remain idle or half-open indefinitely.

Adding useful operational logging

Logs should describe connection lifecycle events without recording sensitive payloads. A typical record includes a timestamp, client address, target address, bytes transferred, duration, close reason, and filtering result. Python’s logging package supports rotating files, syslog integration, and different verbosity levels for development and production.

import logging
from logging.handlers import RotatingFileHandler

handler = RotatingFileHandler(
    "forwarder.log", maxBytes=10_000_000, backupCount=5
)
logging.basicConfig(
    level=logging.INFO,
    handlers=[handler],
    format="%(asctime)s %(levelname)s %(message)s",
)

Avoid logging every chunk of application data. Payload logging can expose passwords, tokens, personal information, or customer records, and it can quickly fill a disk on a busy NBN-connected office network. A connection identifier and byte counters usually provide enough diagnostic value. For a larger monitoring workflow, a streaming log aggregator can collect structured events from several forwarding hosts while keeping the relay focused on network traffic.

Use consistent fields so that command-line tools, dashboards, or a central database can parse records reliably. JSON logging is useful when the forwarder runs beside services in an Australian cloud region, while plain text may be easier for a small Linux host administered over SSH.

Designing safe traffic filters

Filtering should be explicit and conservative. The simplest policy checks the client’s source IP against a set of permitted addresses or networks before creating an upstream connection. Python’s ipaddress module handles both IPv4 and IPv6 CIDR ranges and avoids fragile string comparisons.

from ipaddress import ip_address, ip_network

allowed = [
    ip_network("127.0.0.1/32"),
    ip_network("192.168.1.0/24"),
]

def permitted(address):
    host = ip_address(address[0])
    return any(host in network for network in allowed)

A production filter can also restrict destination ports, apply connection limits, reject private-to-public forwarding combinations, and enforce an authentication layer before opening the tunnel. DNS names should be resolved carefully: resolving a permitted name once and then connecting to an unexpected address can create a security gap. For sensitive environments, use fixed addresses and firewall rules alongside the application-level policy.

Australian businesses often need to account for data residency, especially when a relay handles health, financial, or government-related information. Keeping logs in an approved Sydney or Melbourne region may simplify operational governance, but location does not replace access controls, encryption, retention limits, or proper incident procedures.

Testing, deployment, and maintenance

Test the forwarder with a deliberately simple upstream service before placing it near a real application. python -m http.server can provide a basic target, while nc or socat can confirm raw bidirectional transfer. Exercise denied clients, abrupt disconnects, slow readers, large files, IPv6 addresses, and simultaneous connections. Confirm that the log reports the right byte counts and closes sockets after timeouts.

Run the process under a dedicated unprivileged account with a systemd unit, a restrictive working directory, and a clear restart policy. Keep the listening port above 1024 unless a capability or front-end proxy is required. On a VPS serving users in Perth or Brisbane, measure latency to the upstream service rather than assuming that local geography guarantees a fast path; routing, peering, and the chosen cloud zone all affect performance.

Before deployment, place host-level firewall rules around the listener, cap open file descriptors, and rotate logs. Monitor connection counts, rejection rates, event-loop delays, and upstream failures. A small Python relay can remain dependable for years when its scope is narrow, its filtering policy is visible, and its operational records contain enough detail to explain what happened without capturing the traffic itself.