TL;DR
- 複数のインターフェースを持つマシンに、「自分のIP」という1つの答えはありません。役に立つ問いは、その宛先に行くとき、どのローカルアドレスを使うかです。
- Linuxでは、UDPソケットに
connect()してからgetsockname()を呼べば答えが分かります。UDPにはハンドシェイクがないので、connect()は経路の検索を行って相手を覚えるだけで、僕のテストではIPv4もARPのフレームも外に出ませんでした。 - Wi-Fi相当、VPN相当、コンテナのブリッジ相当の3つのインターフェースで、5つの宛先から経路表と一致する5つの答えが得られました。VPNがデフォルトルートを取ると、公開アドレスにはVPNのアドレスが返り、Wi-Fiのサブネット内のアドレスにはWi-Fiのアドレスが返りました。
- デフォルトルートがないと、公開アドレスの宛先は
ENETUNREACH(「Network is unreachable」)になります。経路表に外へ出る道がない、ということは分かります。逆に、このエラーが出ないからといって、インターネットに届くとは限りません。 - マニュアルページに書かれているのはデータグラムソケットの
connect()についてで、送信元アドレスがそのとき決まるとは書かれていません。そこはLinuxで観察した挙動で、試したのもLinuxだけです。
問題
「スマホでこのURLを開いてください」と表示したい、あるいはローカルネットワークにサービスを広告したい。誘惑されるワンライナーは、マシンのホスト名を解決してその結果を使うものです。Wi-Fi、VPN、コンテナのブリッジがあるノートPCでは、ホスト名の設定次第で、ループバック、コンテナのアドレス、VPNのアドレスのどれかが返りえます。問いは宛先について立てる必要があります。
小技
import socket
def source_ip_for(dest: str) -> str:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect((dest, 9)) # 経路の検索と相手の記憶だけ。何も送らない
return s.getsockname()[0]
finally:
s.close()
SOCK_DGRAM ソケットについて、connect(2) は、そのアドレスが「データグラムがデフォルトで送られる宛先であり、データグラムを受け取る唯一のアドレス」だと書いています(connect(2))。udp(7) も、デフォルトの宛先について同じことを書いています。そのために送る必要があるものは、UDPにはありません。例のポート(9)は一度も使われず、宛先が存在する必要もありません。マニュアルページが書いていないのは、このときカーネルが送信元アドレスも固定する、ということです。それがLinuxで観察したことであり、この小技が動く理由です。
インターフェース3つのホスト
確かめるために、ネットワーク名前空間の中に、ノートPCのように見える3つのインターフェースを持つホストを作りました。wlan0(192.168.1.5/24、デフォルトルートは192.168.1.1経由)、br0(172.17.0.1/16、コンテナのブリッジ)、tun0(10.8.0.2/24、VPN)です。実在のものには何も送られません。インターフェースはvethのペアです。
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")
1回の実行の出力です(rootが必要で、スクリプトが名前空間と3つのvethペアを作って消します)。
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
分かることです。
- 各宛先には、経路表が使うはずのインターフェースのアドレスが返ります。デフォルトルート経由の公開アドレスも、サブネット内のアドレスも同じです。
- IPv4のものは、何もマシンの外に出ませんでした。各インターフェースの反対側で、5回の検索の最中に2秒間動かしたスニファー(rawパケットソケット)が見たのは、ICMPv6のタイプ133、つまりルーター要請だけでした。新しく作ったインターフェース上での、カーネル自身のIPv6の後始末です。検索をまったくしないで同じスニファーを2秒動かして確かめたところ、ルーター要請は同じように出ます。ラボをさらに3回動かしても、検索の有無によらず現れました。どの回でも、IPv4(
0x0800)もARP(0x0806)のフレームも見ていません。(インターフェースの送信カウンターは、きれいな検証にはなりません。ある回では1つ増えましたが、それも同じルーター要請です。) - VPNがデフォルトルートを置き換えると、公開アドレスの宛先にはVPNのアドレスが返りましたが、Wi-Fiのサブネットの宛先にはWi-Fiのアドレスが返りました。**どの宛先について尋ねるかが、答えを決めます。**到達したい相手を代表する宛先を選んでください。「LAN上の自分のアドレス」なら、そのLANのサブネット内のアドレス、「外に出るときのアドレス」なら公開アドレスです。
- デフォルトルートがないと、公開アドレスの宛先は、
connect(2)のエラーの1つとして挙げられているENETUNREACHになりました。その宛先への経路が経路表にない、ということをプログラムに伝えますが、接続性のテストではありません。
宛先の選び方
| 知りたいこと | connectする宛先 |
|---|---|
| 同じLAN上の他のマシンが到達できるアドレス | そのLANのサブネット内のアドレス(たとえばゲートウェイ)。VPNがデフォルトルートを持っていると、公開アドレスにはVPNのアドレスが返る |
| インターネットに出るときのアドレス | 公開アドレス(実験では 8.8.8.8 を使いました。経路のあるアドレスなら何でもよく、実際には何も送られません) |
| 経路表に外へ出る道があるか | 公開アドレスにして、OSError を捕まえる(ここではerrno 101)。パケットが通るかどうかは分かりません |
| 特定のインターフェース | この小技ではありません。そのインターフェースのアドレスにbindするか、インターフェースを列挙します |
よくある失敗
gethostbyname(gethostname())を使う。 症状:127.0.1.1やコンテナのアドレスが返ることがある。ホストの設定に依存し、経路とは無関係です。直し方: 宛先について尋ねる。名前空間では試していません。- 「LANアドレス」を知りたいのに、公開アドレスを宛先にする。 症状: VPNがつながっていると、VPNのアドレスが返る(再現しました)。直し方: LANのサブネット内の宛先を選ぶ。
OSErrorを扱わない。 症状: オフラインのときにプログラムが落ちる(再現しました。errno 101)。直し方: 捕まえて、「オフライン」の意味を決める。- 答えが安定していると思う。 ネットワークが変わると結果も変わります(Wi-Fiから有線、VPNの接続)。起動時に1回ではなく、必要な瞬間に聞き直します。
- どこでも同じように動くと思う。 試したのはLinuxだけです。他のOSでは違うかもしれません。確認してください。
- 誰も到達できないアドレスを広告する。 得られるアドレスは、カーネルが送信元として使うものです。相手がそこへ到達できる(ファイアウォール、NAT、Wi-Fiのクライアント分離)ことは教えません。また、到達できるようにサーバーを全インターフェースにbindすると、すべてのネットワークに公開されます。既定はループバックにして、LANへの公開は明示的な選択にしてください。
試してみる
lan_ip.pyを保存し、自分のマシンでpython3 lan_ip.py 8.8.8.8 192.168.1.1を実行します。成功なら、各行が、その宛先への経路を持つインターフェースのアドレスを示します。ネットワークを切ってもう一度実行すると、エラーを見られます。- 実験全体は、Linuxで
ipが使える環境で、lan_ip.pyとsniff.pyを隣に置いてsudo bash lab.shを実行します。名前空間b1とvethペアwlan0、br0、tun0(と対の…p)を作り、最後に消します。 - VPNをつないで、手順1を、公開アドレスとLANのゲートウェイで実行し直します。先に予想してから、結果と比べてください。
確認の限界
確認できたこと: 1台のLinuxマシン(カーネル6.12、Python 3.13)で、vethペアを使ったネットワーク名前空間の中で、上の出力を1回の実行で、表示されたとおりに確認しました(スニファーの対照実験と検索は、さらに3回繰り返しました)。connect(2) と udp(7) のマニュアルページは、書く際にman7.orgで読みました。
確認できていないこと: macOS、Windows、BSD、IPv6(スクリプトはIPv4専用です)、実際のWi-Fi、VPNソフトウェア、コンテナ(インターフェースはvethペアで作った模造品です)、ポリシールーティングや複数の経路表があるマシン、1つのインターフェースに複数のアドレスがあるホスト、そして「何も送られない」という主張のうち、上のスニファー以外の部分(スニファーが見たのは、検索の有無によらず出るカーネル自身のルーター要請だけです。システムコールのトレースは取っていません)。
宛先について尋ねる
マシンに「あなたは誰か」と聞かない。「あの相手と話すなら、どのアドレスを使うか」と聞いて、経路表に答えさせます。