Addresses, Subnets and Gateways
Reproduce the Hairpin and Solve It Two Ways
Goal
You build three roles — laptop, router and server in a home network — inside one Pod, and actually reproduce how the reply cannot come back when only the destination is changed. Then you change the source as well to make it work, and compare that with solving the same problem on the name resolution side.
Why it matters
A report that "it works from outside but not from inside the house" is hard to diagnose. The connection is not failing and the name is not failing to resolve; a reply does arrive, but the receiving side drops it. Drops leave nothing in the log.
This lab lets you see that moment of dropping. A UDP socket that has called connect() accepts only what comes from the peer it connected to, and the kernel silently drops everything else. That is exactly the situation in which a laptop waits for a reply from the router's address and drops the one that comes from the server's private address.
What works and what does not in this Pod
The lab Pod has no kernel privileges, so ip netns, nft and tcpdump do not work. Instead, 127.0.0.0/8 is entirely the machine itself, so you can use 127.0.0.10, 127.0.0.20 and 127.0.0.99 together on one machine without privileges. You divide the three roles among those three addresses. The nftables rules in step 6 are only written, not applied.
Steps
- Write the address and job of each of the three roles to
/root/hairpin/01-map.txt. They arelaptop 127.0.0.10,router 127.0.0.99andserver 127.0.0.20, and also write one line on why all three addresses can be used on one machine. - In
/root/hairpin/site/index.html, put one line,hairpin-lab-server, and serve that directory at127.0.0.20:9000. Put the result of connecting to the private address and the result of connecting to127.0.0.99:9000together in/root/hairpin/02-direct.txt. - Write how the source and destination of the packet change when only the destination is changed (DNAT only) in five lines in
/root/hairpin/03-tuple.txt. The line heads arerequest-out,after-dnat,reply,expectedandmismatch=. - Create
/root/hairpin/nat.py.python3 nat.py dnat-onlymust reproduce that situation with three sockets and printresult=droppedon the last line. Save the output to/root/hairpin/04-dnat-only.txt. - Make
python3 nat.py dnat-snatof the same tool change the source as well so that it printsresult=delivered, and save the output to/root/hairpin/05-dnat-snat.txt. - Write the rules you would apply on a real router to
/root/hairpin/06-rules.nft. The server is192.168.0.20, the home range is192.168.0.0/24and the port is 80. You need two chains, prerouting and postrouting, and the rule that changes the source must apply only to traffic coming from inside the house. - Solve the same problem on the name side. In
/root/hairpin/hosts.internal, write the internal answer forhome.example.test, and usecurl --resolveto imitate the internal answer and the outside answer, and put the results in/root/hairpin/07-split.txt. - Compare the two solutions in
/root/hairpin/08-compare.md. It needs three sections:## 헤어핀,## 스플릿and## 무엇을 고르나.
Notes
- Start the server with
python3 -m http.server 9000 --bind 127.0.0.20 --directory /root/hairpin/site. When you run it in the background, cut off standard output withsetsid nohup … >/tmp/서버로그 2>&1 </dev/null &. If you leave it attached, the grader waits until that process ends. - A failed connection is evidence too. Capture the error message in the file with
curl -sS -m 3 … 2>&1. - The reason for using three sockets in step 4 is as follows. The laptop socket calls
connect()to the router's address, the router socket binds to127.0.0.99:9500, and the server socket binds to127.0.0.20:9500. A socket cannot forge the source, so in the DNAT-only situation you carry the original source in the payload to tell the server, and have the server reply directly to that address. - Catch
socket.timeoutand printresult=dropped. It failed because time ran out, not because an error occurred. - In step 7,
curl --resolve 이름:포트:주소(name:port:address) treats that name as if it resolved to that address. You can imitate split horizon without setting up DNS. - The grader runs your
nat.pydirectly in both modes. If you hard-code the results, it fails in one of the modes for certain.
Draw a map of the three roles
Everything in 127.0.0.0/8 whose first octet is 127 is the machine itself. So you can bind the server to 127.0.0.20 and use 127.0.0.99 as the router's role. The fact that no kernel privileges are needed is why this lab can run in a Pod.
On each line, write the name, address and job, and on the last line write why all three addresses can be used together on one machine.
The private address works and the outside address does not
First create the file that the server will serve. For the grader to recognize the response, it must contain the text hairpin-lab-server.
python3 -m http.server 9000 --bind 127.0.0.20 --directory /root/hairpin/site
Then connect twice. 127.0.0.20:9000 works and 127.0.0.99:9000 does not. Right now nothing is in the router's role, so the connection is refused immediately; this is the position of a "router with no port forwarding configured."
You also have to put the failure message in the file. Capture standard error as well, as in curl -sS -m 3 http://127.0.0.99:9000/ 2>&1.
Trace the four tuples
DNAT changes only the destination. So even after passing through the router, the source is still the laptop, and the server replies to the laptop directly.
Write four lines in order.
request-out— when the laptop first sends. The destination is the router's address.after-dnat— after the router has changed the destination to the server. The source is unchanged.reply— when the server replies. The source is the server's private address.expected— the reply the laptop is waiting for. The source must be the router's address.
On the last line, mismatch=, write where reply and expected differ. For port numbers, pick any values and use them consistently.
If only the destination changes, the reply is dropped
The key is connect(). When a UDP socket calls connect(), the kernel registers "whose reply am I waiting for," and datagrams from other addresses are dropped without reaching the socket.
Set up the three sockets like this.
laptop bind(('127.0.0.10', 0)) connect(('127.0.0.99', 9500))
router bind(('127.0.0.99', 9500))
server bind(('127.0.0.20', 9500))
When the laptop sends, the router receives. Since only DNAT is applied, the source the server sees should be the laptop, but a socket cannot forge the source. So carry the original source in the payload, and have the server reply directly to that address.
At the end, try laptop.recv() with a 2-second limit, and if socket.timeout is raised, print result=dropped.
Change the source too and it works
There is only one thing to fix. When the router passes the request to the server, if it sends it from its own socket, the source the server sees becomes the router and the reply also comes to the router. The router then just passes that reply back to the laptop.
At that point the source of the reply the laptop receives is 127.0.0.99:9500, which is the same as the peer the laptop called connect() on, so the kernel lets it through.
What matters is that the same tool runs with only the mode changed. The grader runs both modes and checks that the results really differ.
Write the rules for the router
This Pod has no kernel privileges, so you cannot apply them with nft. The goal is to write the rules correctly.
You need two chains.
chain prerouting { type nat hook prerouting priority dstnat; … dnat to … }
chain postrouting { type nat hook postrouting priority srcnat; … masquerade }
Do not leave out the condition on the rule that changes the source. If you apply it without a condition, requests coming from outside also get their source changed to the router, and the connection records in the server log all collapse into one address. Use ip saddr to select only traffic coming from the home range.
Add a # comment to each rule saying what the line does.
Solve the same problem on the name side
Split-horizon DNS makes the same name return a private address when asked from inside and a public address when asked from outside. Then devices in the house never go to the router in the first place.
You imitate both cases with curl --resolve, without setting up a DNS server.
curl -sS -m 3 --resolve home.example.test:9000:127.0.0.20 http://home.example.test:9000/
curl -sS -m 3 --resolve home.example.test:9000:127.0.0.99 http://home.example.test:9000/ 2>&1
The point is that you connect by name, not by address. If you make people connect directly to the private address, the name in the certificate differs from the address you connected to, and a warning appears.
In hosts.internal, put only the value that the internal side answers. If you write the outside address there as well, it is already not a split.
Compare the two solutions
The two solutions fix different places. Hairpin requires touching only one router but sends traffic back and forth to the router, while split DNS ends inside the switch but leaves two places to manage.
Three things must be in your comparison.
- Certificates: why you must connect by name, not by address
- Path: how far the traffic goes and comes back
- Kubernetes: a Pod that calls itself through its own Service's external address ends up in the same situation — how do you avoid it
The grader looks for the section titles ## 헤어핀, ## 스플릿 and ## 무엇을 고르나 exactly as written.