TL;DR

  • “What is my IP?” has no single answer on a machine with several interfaces. The useful question is: which local address would I use to reach that destination?
  • On Linux, calling connect() on a UDP socket and then getsockname() answers it. UDP has no handshake, so connect() only does a route lookup and remembers the peer; in my test no IPv4 or ARP frame left the machine.
  • With a Wi-Fi-like interface, a VPN-like interface and a container-like bridge, five destinations gave five answers that match the routing table. When the VPN took over the default route, a public destination returned the VPN address, and an address in the Wi-Fi subnet still returned the Wi-Fi address.
  • With no default route, a public destination raised ENETUNREACH (“Network is unreachable”). That says the routing table has no way out; it does not say the Internet is reachable when it does.
  • The manual pages describe connect() on a datagram socket; they do not say that the source address is chosen at that moment. That part is observed Linux behaviour and I only tested Linux.

The problem

You want to print “open this URL from your phone”, or advertise a service on the local network. The tempting one-liner resolves the machine’s hostname and takes the result. On a laptop with Wi-Fi, a VPN and a container bridge, that can return a loopback address, a container address, or the VPN’s, depending on how the hostname is configured. The question has to be asked about a destination.

The trick

import socket

def source_ip_for(dest: str) -> str:
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        s.connect((dest, 9))      # route lookup + remember the peer; sends nothing
        return s.getsockname()[0]
    finally:
        s.close()

For a SOCK_DGRAM socket, connect(2) says that the address “is the address to which datagrams are sent by default, and the only address from which datagrams are received” (connect(2)); udp(7) says the same about the default destination. Nothing in UDP needs to be sent for that. The port in the example (9) is never used, and the destination does not need to exist. What the manual pages do not say is that the kernel also fixes the source address at this point. That is what I observed on Linux, and it is why the trick works.

A host with three interfaces

To check this, I built a host in a network namespace with three interfaces that look like a laptop’s: wlan0 (192.168.1.5/24, default route via 192.168.1.1), br0 (172.17.0.1/16, a container bridge) and tun0 (10.8.0.2/24, a VPN). No packets are sent to anything real; the interfaces are veth pairs.

import socket, sys

def source_ip_for(dest: str, family=socket.AF_INET) -> str:
    """The local address the kernel would use to reach `dest`. Sends nothing."""
    s = socket.socket(family, socket.SOCK_DGRAM)
    try:
        s.connect((dest, 9))                 # UDP connect = route lookup + remember the peer; no packet leaves
        return s.getsockname()[0]
    finally:
        s.close()

if __name__ == "__main__":
    for dest in sys.argv[1:]:
        try:    print(f"{dest:>14} -> {source_ip_for(dest)}")
        except OSError as e: print(f"{dest:>14} -> OSError: {e.strerror} (errno {e.errno})")
#!/bin/bash
# Needs root (ip netns). Builds a host with a Wi-Fi-like interface, a VPN-like interface and a docker-like bridge.
set -e
HERE=$(cd "$(dirname "$0")" && pwd)
ip netns del b1 2>/dev/null || true
ip netns add b1
mk() { ip link add name $1 type veth peer name $1p; ip link set $1 netns b1; ip netns exec b1 ip addr add $2 dev $1; ip netns exec b1 ip link set $1 up; ip link set $1p up; }
mk wlan0 192.168.1.5/24
mk br0   172.17.0.1/16
mk tun0  10.8.0.2/24
ip netns exec b1 ip link set lo up
ip netns exec b1 ip route add default via 192.168.1.1 dev wlan0
tx() { ip netns exec b1 cat /sys/class/net/{wlan0,br0,tun0}/statistics/tx_packets | tr '\n' ' '; }
echo "interfaces:"; ip netns exec b1 ip -br addr | grep -v '^lo'
echo "routes:";     ip netns exec b1 ip route | sed 's/^/  /'
echo
echo "tx_packets before (wlan0 br0 tun0): $(tx)"
echo "-- default route is the Wi-Fi gateway"
ip netns exec b1 python3 "$HERE/lan_ip.py" 8.8.8.8 192.0.2.1 192.168.1.77 172.17.0.9 10.8.0.50
echo "tx_packets after:                   $(tx)"
echo "-- control: a sniffer on the other end of each interface for 2 s, with NO lookups at all"
sleep 3   # let the interfaces finish their own IPv6 housekeeping first
python3 "$HERE/sniff.py" 2 wlan0p br0p tun0p
echo "-- the same sniffer while the five lookups run"
python3 "$HERE/sniff.py" 2 wlan0p br0p tun0p &
SN=$!; sleep 0.5
ip netns exec b1 python3 "$HERE/lan_ip.py" 8.8.8.8 192.0.2.1 192.168.1.77 172.17.0.9 10.8.0.50 >/dev/null
wait $SN
echo
echo "-- the VPN takes over the default route"
ip netns exec b1 ip route replace default dev tun0
ip netns exec b1 python3 "$HERE/lan_ip.py" 8.8.8.8 192.168.1.77
echo
echo "-- no default route at all"
ip netns exec b1 ip route del default
ip netns exec b1 python3 "$HERE/lan_ip.py" 8.8.8.8 192.168.1.77
echo
echo "-- tx_packets at the end:              $(tx)"
ip netns del b1
for i in wlan0p br0p tun0p; do ip link del $i 2>/dev/null || true; done
import socket, sys, time, collections, select
# usage: sniff.py <seconds> <iface>...   counts every frame arriving on the given (host-side) interfaces, by Ethernet type
secs, ifaces = float(sys.argv[1]), sys.argv[2:]
socks = {}
for i in ifaces:
    s = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(3)); s.bind((i, 0)); socks[s] = i
seen = collections.Counter(); end = time.monotonic() + secs
while time.monotonic() < end:
    r, _, _ = select.select(list(socks), [], [], 0.1)
    for s in r:
        f = s.recv(2048); et = int.from_bytes(f[12:14], 'big')
        what = f"0x{et:04x}"
        if et == 0x86dd:                                   # IPv6: show the next-header byte, and the ICMPv6 type if it is ICMPv6
            what += f" ipv6 next-header={f[20]}" + (f" icmpv6-type={f[54]}" if f[20] == 58 else "")
        seen[(socks[s], what)] += 1
print("frames seen:", dict(seen) if seen else "none")

Output of one run (root required; the script creates and removes the namespace and the three veth pairs):

interfaces:
wlan0@if68       UP             192.168.1.5/24 fe80::1d:81ff:feab:5f0e/64 
br0@if70         UP             172.17.0.1/16 fe80::488:d7ff:fefa:fb7a/64 
tun0@if72        UP             10.8.0.2/24 fe80::5480:f9ff:fe59:a0e7/64 
routes:
  default via 192.168.1.1 dev wlan0 
  10.8.0.0/24 dev tun0 proto kernel scope link src 10.8.0.2 
  172.17.0.0/16 dev br0 proto kernel scope link src 172.17.0.1 
  192.168.1.0/24 dev wlan0 proto kernel scope link src 192.168.1.5 

tx_packets before (wlan0 br0 tun0): 1 1 0 
-- default route is the Wi-Fi gateway
       8.8.8.8 -> 192.168.1.5
     192.0.2.1 -> 192.168.1.5
  192.168.1.77 -> 192.168.1.5
    172.17.0.9 -> 172.17.0.1
     10.8.0.50 -> 10.8.0.2
tx_packets after:                   1 1 1 
-- control: a sniffer on the other end of each interface for 2 s, with NO lookups at all
frames seen: {('wlan0p', '0x86dd ipv6 next-header=58 icmpv6-type=133'): 1}
-- the same sniffer while the five lookups run
frames seen: {('br0p', '0x86dd ipv6 next-header=58 icmpv6-type=133'): 2, ('tun0p', '0x86dd ipv6 next-header=58 icmpv6-type=133'): 2, ('wlan0p', '0x86dd ipv6 next-header=58 icmpv6-type=133'): 1}

-- the VPN takes over the default route
       8.8.8.8 -> 10.8.0.2
  192.168.1.77 -> 192.168.1.5

-- no default route at all
       8.8.8.8 -> OSError: Network is unreachable (errno 101)
  192.168.1.77 -> 192.168.1.5

-- tx_packets at the end:              7 7 7 

What it shows:

  • Each destination gets the address of the interface the routing table would use for it, including the public ones (via the default route) and the subnet ones.
  • Nothing IPv4 left the machine. The sniffer on the far end of each interface (a raw packet socket, 2 seconds, running while the five lookups ran) saw only ICMPv6 type 133, a router solicitation: the kernel’s own IPv6 housekeeping on freshly created interfaces. I checked that by running the same sniffer for 2 seconds with no lookups at all; the router solicitations appear there too, and they showed up in three more runs of the lab, with and without lookups. No IPv4 (0x0800) or ARP (0x0806) frame was seen in any run. (The interfaces’ transmit counters are not a clean test: one counter went up by one in a run, and that is the same router solicitation.)
  • When the VPN replaced the default route, a public destination now returned the VPN address, but a Wi-Fi subnet destination still returned the Wi-Fi address. Which destination you ask about decides the answer. Pick one that represents who you want to reach: an address in the LAN’s subnet for “my LAN address”, a public address for “the address I’d use to go out”.
  • With no default route, the public destination raised ENETUNREACH, which connect(2) lists among its errors. It tells a program that the routing table has no route for that destination; it is not a connectivity test.

Choosing the destination

What you wantDestination to connect to
The address other machines on the same LAN can reachAn address in that LAN’s subnet (for example the gateway). A public address returns the VPN’s address if a VPN holds the default route
The address used to go out to the InternetA public address (the lab used 8.8.8.8; any routable address works, and nothing is sent to it)
Whether the routing table has a way outA public address; catch OSError (errno 101 here). This says nothing about whether packets get through
A specific interfaceNot this trick. Bind to the address of that interface, or enumerate interfaces

Common failures

  • Taking gethostbyname(gethostname()). Symptom: 127.0.1.1 or a container address. It depends on the host configuration, not on routing. Fix: ask about a destination. I did not test it in the namespace.
  • Using a public destination for a “LAN address”. Symptom: with a VPN on, the VPN’s address (reproduced). Fix: pick a destination in the LAN’s subnet.
  • Not handling OSError. Symptom: the program crashes when offline (reproduced: errno 101). Fix: catch it and decide what “offline” means.
  • Treating the answer as stable. The result can change when a network changes (Wi-Fi to cable, VPN up). Ask again at the moment you need it, not once at start.
  • Assuming this works the same everywhere. I tested Linux only. Other systems may differ; check.
  • Advertising on an address nobody can reach. The address tells you which one the kernel would use as a source. It does not tell you that peers can reach you there (firewalls, NAT, client isolation on Wi-Fi). Also, binding a server to all interfaces to be reachable exposes it to every network. Prefer loopback by default, and make LAN exposure an explicit choice.

Try it

  1. Save lan_ip.py and run python3 lan_ip.py 8.8.8.8 192.168.1.1 on your own machine. Success: each line shows the address of the interface that has a route to that destination. Disconnect from the network and run it again to see the error.
  2. For the full lab, run sudo bash lab.sh on Linux with ip available, with lan_ip.py and sniff.py next to it. It creates a namespace b1 and veth pairs wlan0, br0, tun0 (and their …p peers), and removes them at the end.
  3. Turn on a VPN and run step 1 again with a public address and then with your LAN gateway. Predict first, then compare.

Limits of the check

Verified on one Linux machine (kernel 6.12, Python 3.13), in a network namespace with veth pairs: the output above, from one run as printed (the sniffer control and the lookups were repeated in three more runs). The manual pages for connect(2) and udp(7) were read at man7.org while writing.

Not verified: macOS, Windows or BSD; IPv6 (the script is IPv4 only); real Wi-Fi, VPN software, or containers (the interfaces are imitations built with veth pairs); a machine with policy routing or multiple routing tables; hosts with several addresses on one interface; and the “nothing is sent” claim beyond the sniffer described above (it saw only the kernel’s own router solicitations, with or without the lookups; I did not trace the system calls).

Ask about a destination

Don’t ask the machine who it is. Ask it which address it would use to talk to the one you care about, and let the routing table answer.