Network Address Translation changes addressing information as packets cross a boundary. The familiar many-clients-to-one-address arrangement uses network address and port translation: the router keeps mappings that distinguish simultaneous flows. A remote TCP server may see one source IP while seeing different source ports. That shared address identifies a translation boundary, not necessarily one computer, account, or person.

When it breaks

Translation does not replace routing. A client still needs a route toward the gateway, the gateway must forward packets, and the translated address must support a return path. Conversely, private addressing does not inherently require NAT inside a network that routes those prefixes. A missing return route and a missing translation rule can produce similar timeouts but require different configuration decisions.

Stateful NAT also makes existing and new connections behave differently during rule changes. nftables consults a NAT chain to establish a flow’s mapping; later packets use the stored binding. Removing an SNAT rule therefore does not necessarily revoke established translated connections. This depends on connection tracking remaining active and the binding remaining valid, so treating a ruleset edit as an unconditional session purge is unsafe.

Port mappings and connection-tracking capacity are finite resources. Exhaustion, expiry, or a failover device lacking the relevant state can interrupt communication even when the public address remains unchanged. A usable translated source port is considered within the relevant protocol and connection tuple; avoid estimating capacity as one universal port count for every destination and workload. NAT is also separate from the firewall policy deciding permitted traffic.

Two client paths remain distinct through a shared translated address; return routing and mapping lifetime are separate.
Distinct flows can share one translated address. Rule removal differs from mapping and dependency lifetime; the final exchange is an observation, not a required failure.

Check it quickly

Historical article reports lack recovered raw integration logs. No fixture was rerun; privileged operations remain held, with no alternate route.

Save this as check-nat.py and run python3 check-nat.py with Docker. It builds a disposable environment with Python 3.11.16 and nftables 1.0.6. NET_ADMIN and SYS_ADMIN are prerequisites for four nested namespaces and their proc mount. The article reports Linux 6.12.76; Docker shares the host kernel. Assertions must be enabled; cleanup is best-effort. Use only a separately authorized disposable VM: those capabilities and the shared kernel mean a container alone is not an adequate isolation assurance. The live package repository also does not guarantee future availability of the pinned packages:

from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,uuid
recipe='FROM python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84\nRUN apt-get update && apt-get install -y --no-install-recommends iproute2=6.1.0-3 iputils-ping=3:20221126-1+deb12u1 nftables=1.0.6-2+deb12u2 tcpdump=4.99.3-1 traceroute=1:2.1.2-1 procps=2:4.0.2-3 && rm -rf /var/lib/apt/lists/*\nCMD ["python3"]\n'
program=r"""
from contextlib import ExitStack,contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import json,os,select,signal,subprocess,time

def run(*args,check=True,input=None,timeout=8):
    result=subprocess.run(args,input=input,capture_output=True,text=True,timeout=timeout)
    if check:assert result.returncode==0,(args,result.returncode,result.stdout,result.stderr)
    return result
def ns(name,*args,**kwargs):return run('ip','netns','exec',name,*args,**kwargs)
def ip(name,*args,**kwargs):return ns(name,'ip',*args,**kwargs)
def stop(process):
    if process.poll() is None:
        process.terminate()
        try:process.wait(timeout=2)
        except subprocess.TimeoutExpired:process.kill();process.wait(timeout=2)
def line(process,timeout=3):
    assert select.select([process.stdout],[],[],timeout)[0],'child response deadline exceeded'
    value=process.stdout.readline();assert value,'child exited without a response'
    return value
def spawn(stack,command):
    process=subprocess.Popen(command,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
    stack.callback(stop,process);return process
@contextmanager
def lab(names):
    with TemporaryDirectory(prefix='net-path-') as directory,ExitStack() as stack:
        root=Path(directory);proc=root/'proc';proc.mkdir();run('mount','-t','proc','proc',str(proc))
        stack.callback(run,'umount',str(proc))
        for name in names:
            run('ip','netns','add',name);stack.callback(run,'ip','netns','delete',name,check=False)
            ip(name,'link','set','lo','up')
        def setting(name,key,value):
            assert name in names
            path=proc/'sys'/key.replace('.','/')
            return ns(name,'python3','-c',f'from pathlib import Path; p=Path({str(path)!r}); p.write_text({str(value)!r}); print(p.read_text().strip())').stdout.strip()
        yield root,stack,setting
def wire(left,left_if,right,right_if,left_addresses,right_addresses):
    run('ip','link','add',left_if,'type','veth','peer','name',right_if)
    for name,interface,addresses in [(left,left_if,left_addresses),(right,right_if,right_addresses)]:
        run('ip','link','set',interface,'netns',name)
        for address in addresses:ip(name,'address','add',address,'dev',interface,*(['nodad'] if ':' in address else []))
        ip(name,'link','set',interface,'up')
def nft(name,script):return ns(name,'nft','-f','-',input=script)
def counters(name):
    data=json.loads(ns(name,'nft','-j','list','ruleset').stdout);found={}
    for entry in data['nftables']:
        rule=entry.get('rule',{})
        if 'comment' in rule:
            for expression in rule['expr']:
                if 'counter' in expression:found[rule['comment']]=expression['counter']['packets']
    return found

with lab(['a','b','router','target']) as (root,stack,setting):
    wire('a','a0','router','ra',['10.203.1.2/30'],['10.203.1.1/30'])
    wire('b','b0','router','rb',['10.203.2.2/30'],['10.203.2.1/30'])
    wire('router','wan','target','t0',['192.0.2.1/30'],['192.0.2.2/30'])
    setting('router','net.ipv4.ip_forward',1)
    ip('a','route','add','default','via','10.203.1.1');ip('b','route','add','default','via','10.203.2.1')
    server_code=r'''
import json,socketserver
class Handler(socketserver.StreamRequestHandler):
    def handle(self):
        for raw in self.rfile:
            data=json.loads(raw);data['peer']=self.client_address
            self.wfile.write(json.dumps(data).encode()+b'\n');self.wfile.flush()
class Server(socketserver.ThreadingTCPServer):allow_reuse_address=True;daemon_threads=True
server=Server(('192.0.2.2',18080),Handler);print('ready',flush=True);server.serve_forever()
'''
    daemon=spawn(stack,['ip','netns','exec','target','python3','-u','-c',server_code]);assert line(daemon).strip()=='ready'
    client_code=r'''
import json,socket,sys
label,port=sys.argv[1],int(sys.argv[2])
try:
    with socket.socket() as stream:
        stream.settimeout(.7);stream.bind(('0.0.0.0',port));stream.connect(('192.0.2.2',18080))
        with stream.makefile('rb') as incoming:
            def exchange(phase):
                stream.sendall(json.dumps({'label':label,'phase':phase}).encode()+b'\n')
                print(json.dumps({'ok':True,'reply':json.loads(incoming.readline())}),flush=True)
            exchange('initial')
            for command in sys.stdin:exchange(command.strip())
except OSError as error:print(json.dumps({'ok':False,'type':type(error).__name__,'errno':error.errno}),flush=True)
'''
    def once(name,port):return json.loads(ns(name,'python3','-u','-c',client_code,name,str(port),input='').stdout)
    before=once('a',39000);assert not before['ok'] and before['type']=='TimeoutError'
    nft('router','''table ip translation {
 chain postrouting { type nat hook postrouting priority srcnat; policy accept;
  ip saddr 10.203.0.0/16 oifname "wan" counter snat to 192.0.2.1 comment "new_mapping"
 }
}
table inet tracking {
 chain forward { type filter hook forward priority filter; policy accept;
  ct state established,related counter accept comment "established"
 }
}
''')
    clients={};initial={}
    for name in ['a','b']:
        clients[name]=spawn(stack,['ip','netns','exec',name,'python3','-u','-c',client_code,name,'40000'])
        initial[name]=json.loads(line(clients[name]));assert initial[name]['ok'] and initial[name]['reply']['label']==name
    peers=[value['reply']['peer'] for value in initial.values()]
    assert all(peer[0]=='192.0.2.1' for peer in peers) and peers[0][1]!=peers[1][1]
    mapped=counters('router');assert mapped['new_mapping']==2
    nft('router','flush chain ip translation postrouting')
    retained={}
    for name,process in clients.items():
        process.stdin.write('after-rule-removal\n');process.stdin.flush();retained[name]=json.loads(line(process))
        assert retained[name]['ok'] and retained[name]['reply']['peer']==initial[name]['reply']['peer'],(name,retained[name],initial[name])
    new_flow=once('a',40001);assert not new_flow['ok'] and new_flow['type']=='TimeoutError'
    ip('target','route','add','10.203.1.0/30','via','192.0.2.1')
    routed_without_nat=once('a',40003);assert routed_without_nat['ok'] and routed_without_nat['reply']['peer'][0]=='10.203.1.2'
    nft('router','delete table inet tracking')
    clients['a'].stdin.write('after-table-delete\n');clients['a'].stdin.flush()
    after_table_delete=json.loads(line(clients['a']))
    assert isinstance(after_table_delete.get('ok'),bool),after_table_delete
    if after_table_delete['ok']:
        reply=after_table_delete['reply']
        assert reply['label']=='a' and reply['phase']=='after-table-delete',reply
        assert reply['peer']==initial['a']['reply']['peer'],reply
    else:
        # Exact envelope emitted by this client's built-in OSError catch family.
        assert set(after_table_delete)=={'ok','type','errno'},after_table_delete
        assert type(after_table_delete['type']) is str and after_table_delete['type'] in {
            'OSError','BlockingIOError','ChildProcessError','ConnectionError',
            'BrokenPipeError','ConnectionAbortedError','ConnectionRefusedError','ConnectionResetError',
            'FileExistsError','FileNotFoundError','InterruptedError','IsADirectoryError',
            'NotADirectoryError','PermissionError','ProcessLookupError','TimeoutError'},after_table_delete
        assert after_table_delete['errno'] is None or type(after_table_delete['errno']) is int,after_table_delete
    print(json.dumps({'without_nat_or_return_route':before,'both_clients_bind_port':40000,'translated_connections':initial,
        'nat_first_packet_counter':mapped,'existing_after_rule_removal':retained,'new_after_rule_removal':new_flow,
        'explicit_return_route_without_nat':routed_without_nat,'after_tracking_table_delete':after_table_delete,
        'last_tracking_reference':'not_measured'}))
"""
name='xbcatm-netpath-'+uuid.uuid4().hex[:10]
def docker(*args,**kwargs):return subprocess.run(['docker',*args],capture_output=True,text=True,timeout=300,**kwargs)
try:
    with TemporaryDirectory(prefix=name+'-') as directory:
        root=Path(directory);(root/'Dockerfile').write_text(recipe)
        build=docker('build','-t',name,str(root));assert build.returncode==0,build.stderr
        result=docker('run','--rm','-i','--name',name,'--network','none','--cap-add','NET_ADMIN','--cap-add','SYS_ADMIN',
            '--memory','256m','--pids-limit','128',name,'python3','-',input=program)
        assert result.returncode==0,result.stderr
        print(json.dumps(json.loads(result.stdout),sort_keys=True))
finally:
    docker('rm','-f',name);docker('image','rm',name)

Clients a and b occupy separate private subnets behind a router. The target has only its connected route to the router’s outward interface, 192.0.2.1. These documentation addresses remain inside the laboratory. Without translation or an explicit return route to the client subnet, the first TCP exchange times out even though the router has forwarding enabled.

The script installs an SNAT rule and an established-state tracking rule. Both clients bind source port 40000 and contact the same target address and port. The target reports source address 192.0.2.1 for both, with different translated ports. The script checks that replies retain the correct client label; merely observing two accepted sockets would not establish that responses were delivered to the intended clients.

The NAT rule counter records two newly mapped connections. The script then flushes that rule while leaving the NAT base chain and separate tracking rule in place. Both existing sockets exchange another message using their original mappings, while a new connection times out. This directly demonstrates why checking only a previously opened connection can miss a broken configuration for new clients.

Next, a target return route permits an untranslated connection from 10.203.1.2. After deleting the tracking table, the revised script records either a successful exchange or the client’s reported error; success still requires the correct label, phase and peer. It does not demand failure or label the deletion as the last reference. Linux 6.12.76 supports conditional reference release: destroying a ct state expression releases a tracking reference, and hooks unregister only when that family’s user count reaches zero. The fixture retains the empty NAT base chain without measuring other holders. The historical ConnectionResetError lacks recovered raw logs. Neither the source nor this observation proves that error, a last holder, or complete state erasure. Missing or malformed responses fail the harness without establishing a NAT cause.

Diagnose the mapping and the return path together

Record original and translated source addresses and ports, destination tuple, protocol, and timestamps when correlating traffic across a gateway. Server logs containing only the shared IP cannot distinguish the clients in this example. Authentication and application identities remain necessary where attribution matters; a translation log provides network correlation rather than proof of a user’s identity.

For a change or incident, test both an established connection and a newly opened one. Check routing in both directions and inspect the applicable translation and filtering rules. Preserve state intentionally during maintenance when continuity is required. If sessions must be terminated, use an explicit, scoped mechanism whose behavior is verified for the deployed system; do not infer termination from a removed rule alone.

Sources