Core Question
Core Question
What does a STUN server actually tell you, and how do you turn that answer into knowledge that you are behind a NAT?
Outcome
Outcome
By the end of this session, the learner should be able to:
- state the one thing a STUN server does, in one sentence
- explain what
nat_mapping=Nonemeans and why it is the identity function - explain why
behind_nat()is a single tuple comparison, not a protocol - explain why STUN alone cannot tell a client whether a NAT is in the path — only the comparison can
Read Order
Read Order
- Read the module comment at the top of the file
- Read
BindingRequest - Read
BindingResponse - Read
stun_query() - Read
behind_nat() - Run
examples/ice/session_01_walkthrough.py
Read It Like Code
Read It Like Code
stun_query(
local_ip,
local_port,
nat_mapping,
server,
)Fields That Matter
Fields That Matter
| Field | Why it matters |
|---|---|
from_ip / from_port (BindingRequest) | The client's own claim about itself. The server does not need to believe it — it is only ever the starting point for comparison. |
mapped_ip / mapped_port (BindingResponse) | Not an echo of the request. It is the return address the server actually observed on the packet's envelope. |
nat_mapping (stun_query) | A callable standing in for whatever sits on the path. None means nothing rewrites the envelope; passing a function means something does. |
Decision Flow
Decision Flow
nat_mapping is None -> seen_ip, seen_port = local_ip, local_port (identity) nat_mapping is a function -> seen_ip, seen_port = nat_mapping(local_ip, local_port, server) mapped != local -> behind_nat() is True mapped == local -> behind_nat() is False
Reading Lens
Reading Lens
The important move in this session is to stop thinking of STUN as "a protocol that detects NATs" and start asking:
- what did the server actually see arrive, versus what the client believes it sent?
- is
nat_mappingstanding in for "no NAT" or "some NAT," and how does the function's return value encode that? - where does the comparison happen — inside
stun_query(), or somewhere else?
Toy Model Boundary
Toy Model Boundary
Real STUN (RFC 5389 / RFC 8489) carries a transaction ID, message integrity via a message-integrity attribute, and a XOR-MAPPED-ADDRESS attribute so the mapped address survives NATs that rewrite payloads that look like addresses. This lesson keeps BindingRequest and BindingResponse to the two fields that carry the actual discovery — the address and port — so the one fact that matters (the server reports what it saw, not what you claimed) stays the whole story.
nat_mapping is a plain Callable, not a simulated network stack. It exists so the walkthrough can express "no NAT" and "some NAT" as two different arguments to the same function, without building a real UDP path.
Code Landmarks
Code Landmarks
Module comment
The file opens with the thesis of the whole track: a STUN server reads the envelope of the packet that arrived and reports the source address and port it saw — never the payload's claimed origin. nat_behavior.py and candidates.py both build on this one fact.
stun_query()
The main reading target. When nat_mapping is None, seen_ip, seen_port are set directly from the request — this is the identity function, spelled out in code rather than left implicit. When nat_mapping is a callable, it is handed the request's claimed address plus the server, and whatever it returns becomes the response. stun_query() never compares anything; it only reports.
behind_nat()
One line: a tuple inequality. (response.mapped_ip, response.mapped_port) != local. This is the headline of the session — the discovery IS the comparison. Nothing about stun_query() or BindingResponse "detects" a NAT; detection is this one equality check living in the caller.
Failure Questions
Failure Questions
Use the source file to answer these:
- What does it mean, in code, for
nat_mappingto beNone? Which two variables get set, and from what? - If
nat_mappingis a function that always returns the same(ip, port)regardless of its arguments, doesstun_query()care? Why not? behind_nat()takeslocalas a tuple, not as two separate arguments. Why does that matter for how the comparison is written?- Can
stun_query()alone — withoutbehind_nat()— ever tell you whether you are behind a NAT? What is missing? - Why does
BindingResponsenot include a copy ofBindingRequest? What would comparing them accomplish thatbehind_nat()does not already do withlocal?
Walkthrough
Walkthrough
Run this:
PYTHONPATH=src python3 examples/ice/session_01_walkthrough.py
The walkthrough queries with no NAT (response echoes local, behind_nat is False), then with a NAT mapping in the path (response differs, behind_nat is True), and shows that the response carries exactly what the server saw — the same local address through the same NAT always maps to the same observed address.
Done When
Done When
The learner can say all of the following without looking at notes:
- "A STUN server does one thing: it tells you the return address it saw on your packet."
- "
nat_mapping=Noneis not a special case — it is the identity function, spelled out." - "You don't detect a NAT by asking STUN a question with a yes/no answer; you detect it by comparing the response to what you already knew about yourself."
References
References
- RFC 5389 (Session Traversal Utilities for NAT — STUN)
- RFC 8489 (STUN, obsoletes RFC 5389)
Continue