A server’s bind address selects which local destination addresses its socket can accept. Binding an IPv4 listener to 127.0.0.1 differs from binding it to 0.0.0.0, the wildcard address. Neither setting alone describes the complete access policy. Routing, network namespaces, packet filtering, port publication, proxies, and application authentication determine how a request reaches the listener and what it may do afterward.

When it breaks

A wildcard listener is not proof that the internet can reach a service. It may run inside an isolated namespace without an external interface or have a restrictive firewall. Conversely, a loopback listener can be reached indirectly through a reverse proxy or tunnel that accepts remote requests. Investigate forwarding paths before defining its audience.

The IPv6 wildcard :: adds another choice. On Linux, IPV6_V6ONLY controls whether an IPv6 socket also accepts IPv4 connections as mapped addresses. Defaults depend on the environment and application. Seeing one IPv6 entry in a listener listing therefore does not establish that IPv4 is unavailable. Set the option deliberately when the service contract requires a particular family boundary, then test each family.

Binding to a specific address can also fail during startup. An address already occupied by a conflicting socket produces a different error from an address not assigned locally. Reuse options and nonlocal-binding facilities alter some cases, but they are not enabled here. Treating every bind failure as a busy port can send an operator toward killing an unrelated process when the actual fault is missing address configuration.

Intended IPv4 exercise: exact and wildcard binds match different destinations, with separate bind errors. IPv6-only and mapped IPv4 behavior remains a separate conditional model, outside this script. Technical and execution acceptance await operational verification; no reproduced result is claimed.
Bind scope selects local addresses. The script is IPv4-only; the IPv6 panel is a separate conditional model. No reproduced output or technical acceptance is claimed.

Check it quickly

Source review, 2026-10-19: this revised exercise uses IPv4 only. Docker’s none driver omits ::1; IPv6 needs separate, verified setup. Technical and execution acceptance remain on hold pending an authorized complete run. Everything below describes expected results.

Save as check-bind-address.py and run python3 check-bind-address.py on a permitted Linux Docker host with the pinned image already present. Its claimed Python version has not been reverified. The script disables image pulls, publishes no ports, and changes no host firewall:

import json,re,subprocess,sys,tempfile
from pathlib import Path

image='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
program=r'''
from contextlib import ExitStack
import errno,json,socket

def read_exact(stream,length):
    result=b''
    while len(result)<length:
        part=stream.recv(length-len(result));assert part,'unexpected end of stream'
        result+=part
    return result
def listener(stack,address):
    stream=stack.enter_context(socket.socket(socket.AF_INET,socket.SOCK_STREAM))
    stream.settimeout(1);stream.bind((address,0));stream.listen(4)
    return stream
def exchange(server,address):
    with socket.create_connection((address,server.getsockname()[1]),timeout=1) as client:
        connection,peer=server.accept()
        with connection:
            connection.settimeout(1);client.sendall(b'ping');assert read_exact(connection,4)==b'ping'
            connection.sendall(b'pong');assert read_exact(client,4)==b'pong'
            assert connection.getsockname()[0]==address
            return {'listener':server.getsockname(),'accepted_local':connection.getsockname(),'peer':peer}
def refused(server,address):
    try:
        with socket.create_connection((address,server.getsockname()[1]),timeout=1):pass
    except ConnectionRefusedError:return 'ECONNREFUSED'
    raise AssertionError('unexpectedly connected')
with ExitStack() as stack:
    narrow=listener(stack,'127.0.0.1')
    one=exchange(narrow,'127.0.0.1');other=refused(narrow,'127.0.0.2')
    duplicate=stack.enter_context(socket.socket(socket.AF_INET,socket.SOCK_STREAM))
    try:duplicate.bind(narrow.getsockname());raise AssertionError('duplicate bind succeeded')
    except OSError as error:assert error.errno==errno.EADDRINUSE
    unassigned=stack.enter_context(socket.socket(socket.AF_INET,socket.SOCK_STREAM))
    try:unassigned.bind(('192.0.2.99',0));raise AssertionError('unassigned bind succeeded')
    except OSError as error:assert error.errno==errno.EADDRNOTAVAIL
    wildcard=listener(stack,'0.0.0.0')
    both=[exchange(wildcard,a) for a in ['127.0.0.1','127.0.0.2']]
    assert one['listener'][0]=='127.0.0.1'
    assert all(item['listener'][0]=='0.0.0.0' for item in both)
    print(json.dumps({'narrow':one,'other_loopback':other,'duplicate':'EADDRINUSE',
        'unassigned':'EADDRNOTAVAIL','wildcard':both}))
'''
def docker(*args,input=None,timeout=30):
    result=subprocess.run(['docker',*args],input=input,capture_output=True,text=True,timeout=timeout)
    assert result.returncode==0,result.stderr
    return result.stdout
def owned_id(path):
    value=path.read_text().strip()
    assert re.fullmatch('[0-9a-f]{64}',value),'missing or invalid owned container ID'
    return value
def remove_owned(paths):
    errors=[]
    for path in paths:
        try:
            cid=owned_id(path)
            print('Cleanup target: '+cid,file=sys.stderr)
            docker('rm','-f',cid,timeout=10)
        except Exception as error:
            errors.append(f'{path.name}: {error}')
    if errors:raise RuntimeError('Cleanup not confirmed; inspect the recorded IDs: '+'; '.join(errors))
with tempfile.TemporaryDirectory(prefix='xbcatm-bind-') as work:
    cidfile=Path(work)/'container.cid'
    try:
        docker('create','-i','--cidfile',str(cidfile),'--pull','never','--network','none',
            '--memory','128m','--pids-limit','64',image,'python3','-')
        cid=owned_id(cidfile)
        print('Owned container: '+cid,file=sys.stderr)
        output=docker('start','-a','-i',cid,input=program,timeout=60)
        assert docker('inspect','--format','{{.State.ExitCode}}',cid).strip()=='0'
        report=json.loads(output)
    finally:
        remove_owned([cidfile])
print(json.dumps(report,ensure_ascii=False,sort_keys=True))

The exact listener should accept a request to 127.0.0.1 and return pong. Connecting to 127.0.0.2 on the same port is refused. Both are loopback destinations, so this comparison isolates address matching without claiming to test external filtering. Each listener binds port zero, allowing the kernel to choose an available port; the program reads that actual port before connecting.

The wildcard listener should serve both destinations. Its listening socket still reports 0.0.0.0, while each accepted connection reports the concrete local address used by that client. This difference matters when interpreting diagnostics: a wildcard describes the listener’s matching rule, whereas an accepted connection has a specific local endpoint. The example reads the complete four-byte response instead of assuming that stream reads preserve message boundaries.

Two deliberate setup errors are separately asserted. A second ordinary socket cannot bind the exact listener’s occupied address and port, yielding EADDRINUSE. Binding 192.0.2.99, which is not assigned in this namespace, yields EADDRNOTAVAIL. These negative controls target the expected causes; an arbitrary exception or timeout would fail the exercise instead of being counted as equivalent evidence.

IPv6 is a separate experiment, excluded from this script and JSON. After independently configuring and verifying IPv6 loopback, a Linux :: listener with IPV6_V6ONLY set to one should accept ::1 and reject IPv4; zero permits mapped IPv4, such as ::ffff:127.0.0.1. Those conditional semantics establish neither this fixture’s dual-stack readiness nor another system’s defaults.

Docker calls and socket waits have timeouts. Cleanup targets only full IDs in this run’s private CID files, tries every recorded target, and must succeed before final JSON is printed. Interrupted creation or daemon failure can leave cleanup uncertain: retain diagnostics and investigate; never remove guessed names. Run ordinary Python, without optimization disabling assertions.

Verify the listener and the permitted caller

Record address family, bind address, selected port, process, and network namespace together. A listener inside a container and one on its host can use identical address text without referring to the same socket. Check the application’s effective configuration after startup, because a deployment variable or library default may differ from the configuration file an operator first inspected.

Choose the smallest address scope compatible with legitimate callers, then enforce the required source and application policy. A proxy may need to reach a backend through a container interface rather than its own loopback. Binding the backend narrowly should follow that actual topology. Authentication still matters for local callers and for remote requests forwarded by an authorized intermediary.

Finally, test both permitted and rejected paths from the namespaces that represent real clients. This exercise targets IPv4 binding semantics and distinct bind errors. It does not prove a production firewall, tunnel, or load balancer is configured correctly. Those components must be checked along the real request path before publishing an access guarantee.

Further reading

Sources