TT Lab
Get started
Learn Learning paths Courses

Addresses, Subnets and Gateways

Reproduce the Hairpin and Solve It Two Ways

Continue in TT Lab

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

  1. Write the address and job of each of the three roles to /root/hairpin/01-map.txt. They are laptop 127.0.0.10, router 127.0.0.99 and server 127.0.0.20, and also write one line on why all three addresses can be used on one machine.
  2. In /root/hairpin/site/index.html, put one line, hairpin-lab-server, and serve that directory at 127.0.0.20:9000. Put the result of connecting to the private address and the result of connecting to 127.0.0.99:9000 together in /root/hairpin/02-direct.txt.
  3. 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 are request-out, after-dnat, reply, expected and mismatch=.
  4. Create /root/hairpin/nat.py. python3 nat.py dnat-only must reproduce that situation with three sockets and print result=dropped on the last line. Save the output to /root/hairpin/04-dnat-only.txt.
  5. Make python3 nat.py dnat-snat of the same tool change the source as well so that it prints result=delivered, and save the output to /root/hairpin/05-dnat-snat.txt.
  6. Write the rules you would apply on a real router to /root/hairpin/06-rules.nft. The server is 192.168.0.20, the home range is 192.168.0.0/24 and 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.
  7. Solve the same problem on the name side. In /root/hairpin/hosts.internal, write the internal answer for home.example.test, and use curl --resolve to imitate the internal answer and the outside answer, and put the results in /root/hairpin/07-split.txt.
  8. Compare the two solutions in /root/hairpin/08-compare.md. It needs three sections: ## 헤어핀, ## 스플릿 and ## 무엇을 고르나.

Notes

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.

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.

The grader looks for the section titles ## 헤어핀, ## 스플릿 and ## 무엇을 고르나 exactly as written.