Gần như mọi thiết bị bạn dùng đều ngồi sau một lớp NAT (Network Address Translation): máy tính trong nhà, điện thoại trên mạng di động, container trong Docker. NAT cho nhiều máy dùng IP riêng chia chung một IP công khai — nhưng để làm được thế, nó phải viết lại cổng nguồn của từng kết nối và nhớ ánh xạ để trả gói về đúng máy. Cách nó chọn cổng ngoài quyết định rất nhiều thứ, từ việc gọi video P2P có kết nối được không tới việc gỡ lỗi mạng. Bài này dựng một lớp NAT thật bằng netns trong container, đọc thẳng bảng ánh xạ, và đo chính xác NAT chọn cổng thế nào — rồi lật đổ cái mô hình sai tôi mang theo trong đầu.

Ánh xạ cổng NAT

NAT và bảng ánh xạ

Khi một máy riêng (ví dụ 10.0.0.2) mở kết nối ra ngoài, gói của nó mang địa chỉ nguồn riêng mà Internet không định tuyến về được. Router NAT chặn gói đó lại, viết lại địa chỉ nguồn thành IP công khai của router (203.0.113.1) kèm một cổng nào đó, rồi ghi một dòng vào bảng theo dõi kết nối (conntrack): "kết nối này, bên trong là 10.0.0.2:cổng-A, bên ngoài tôi phát đi bằng 203.0.113.1:cổng-B". Khi gói trả lời về tới 203.0.113.1:cổng-B, router tra bảng, viết lại địa chỉ đích ngược về 10.0.0.2:cổng-A, và chuyển vào trong. Cả quá trình trong suốt với hai đầu.

Câu hỏi mấu chốt là cổng-B được chọn thế nào. Ngẫu nhiên? Giữ nguyên bằng cổng-A? Đổi theo từng đích? Câu trả lời không chỉ là chuyện học thuật — nó quyết định hai máy sau hai lớp NAT có tự tìm được nhau để nói chuyện trực tiếp (P2P) hay không.

Đo: NAT giữ cổng, chỉ đổi khi trùng

Tôi dựng ba vùng mạng (netns) trong container — một client, một router chạy MASQUERADE (kiểu SNAT phổ biến nhất của Linux), và server — rồi cho client bind một cổng nguồn cụ thể để nối ra, và đọc thẳng bảng conntrack ở router để thấy cổng ngoài NAT gán.

Phép một — ánh xạ cơ bản. Client 10.0.0.2 bind cổng 40000, nối tới server. conntrack ở router cho thấy:

src=10.0.0.2 sport=40000 dport=80   (bên trong)
src=203.0.113.2 sport=80 dport=40000 (chiều về: cổng ngoài = 40000)

Cổng ngoài NAT chọn là 40000đúng bằng cổng nguồn bên trong. NAT giữ nguyên cổng khi cổng đó còn rảnh, không hề đổi.

Phép hai — xung đột cổng. Giờ thêm một client thứ hai 10.0.0.3, cũng bind đúng cổng 40000, cũng nối tới cùng server. Hai máy nội bộ khác nhau nhưng cùng số cổng — NAT không thể cho cả hai cùng ra bằng 203.0.113.1:40000 (thì lúc gói về biết trả cho ai?). Kết quả đo:

Máy nội bộ Cổng nội bộ Cổng ngoài
10.0.0.2 40000 40000 (giữ)
10.0.0.3 40000 42478 (đổi)

Máy đầu giữ được 40000, máy sau bị đổi sang 42478. NAT chỉ đổi cổng khi bắt buộc — khi có xung đột thật.

Phép ba — cùng nguồn, hai đích. Client 10.0.0.2:41000 nối tới server thứ nhất, rồi cũng từ 41000 nối tới server thứ hai (một IP đích khác). conntrack cho thấy cả hai kết nối dùng cùng cổng ngoài 41000. Cùng một cổng nguồn nội bộ, đi tới hai đích khác nhau, vẫn nhận cùng một cổng ngoài.

Một lần tôi đo hớ: NAT không "đối xứng" như tôi tưởng

Mô hình trong đầu tôi trước khi đo là NAT đối xứng (symmetric NAT): mỗi kết nối mới, NAT ném cho bạn một cổng ngoài ngẫu nhiên; và đi tới đích khác nhau thì cổng ngoài cũng khác nhau. Đó là mô hình "tệ nhất" mà người ta hay mặc định khi nghĩ về NAT. Cả ba phép đo trên bác bỏ nó.

Thứ nhất, cổng ngoài không ngẫu nhiên: NAT giữ nguyên cổng nguồn (40000 → 40000) bất cứ khi nào cổng đó còn trống. Thứ hai — và quan trọng hơn cho việc xuyên NAT — cùng một cổng nguồn nội bộ đi tới hai đích khác nhau lại nhận cùng một cổng ngoài (41000 cho cả hai server). Đây gọi là ánh xạ độc lập điểm cuối (endpoint-independent mapping): cổng ngoài chỉ phụ thuộc vào (IP:cổng) bên trong, không phụ thuộc vào việc bạn đang nói chuyện với ai. NAT chỉ đổi cổng khi có xung đột thật giữa hai máy nội bộ.

Vì sao sự nhầm này đáng kể? Vì kiểu ánh xạ quyết định P2P có chạy được không. Với ánh xạ độc lập điểm cuối, một máy có thể hỏi một server công khai (STUN) "cổng ngoài của tôi là bao nhiêu?", nhận về 203.0.113.1:41000, rồi chia sẻ cổng đó cho một máy khác để nó gửi thẳng vào — vì NAT dùng đúng cổng 41000 đó cho mọi đích. Với NAT đối xứng thật (cổng đổi theo đích), mẹo này sập, vì cổng mà STUN thấy khác cổng mà máy kia sẽ dùng. Nếu tôi tin mô hình đối xứng và viết "NAT luôn đổi cổng ngẫu nhiên mỗi kết nối", tôi đã tuyên bố sai rằng P2P không thể xuyên NAT trực tiếp — trong khi phần lớn NAT thực tế (như MASQUERADE của Linux đây) là độc lập điểm cuối và cho phép xuyên.

Bài học đo lường: đừng mặc định trường hợp tệ nhất là trường hợp phổ biến. "NAT đối xứng, cổng ngẫu nhiên" là một kiểu NAT có tồn tại, nhưng không phải kiểu mà cái router trước mặt tôi đang chạy. Chỉ một lần đọc bảng conntrack đã tách được mô hình lý thuyết khỏi hành vi thật của chính hệ thống mình đang đứng trên đó.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên là hiểu vì sao kết nối đi-ra dễ mà đi-vào khó. NAT tạo ánh xạ khi máy trong chủ động mở kết nối ra; không có kết nối ra thì không có dòng conntrack nào, nên một gói lạ từ ngoài gõ vào 203.0.113.1:40000 sẽ bị vứt — router không biết trả cho ai. Đây là lý do máy sau NAT không thể làm server công khai nếu không cấu hình chuyển tiếp cổng (port forwarding), và là "tường lửa miễn phí" mà NAT vô tình đem lại. Muốn nhận kết nối vào, hoặc bạn mở cổng thủ công, hoặc dùng một máy trung gian mà cả hai đầu cùng chủ động nối ra.

Hệ quả thứ hai là giữ ánh xạ sống bằng keepalive. Mỗi dòng conntrack có thời gian sống; nếu kết nối im lặng đủ lâu, router NAT xóa ánh xạ để thu hồi cổng, và gói sau đó rơi vào hư không. Đây chính là lý do sâu xa của keepalive: một kết nối để yên quá lâu sau NAT sẽ chết không phải vì hai đầu bỏ cuộc, mà vì cái ánh xạ ở giữa bị dọn. Ứng dụng giữ kết nối dài (WebSocket, kết nối cơ sở dữ liệu, MQTT) phải gửi gói giữ nhịp trước khi hết hạn ánh xạ, thường vài chục giây tới vài phút.

Hệ quả thứ ba là biết loại NAT khi làm ứng dụng thời gian thực. Nếu bạn viết gọi video, game, hay bất cứ thứ gì cần P2P, kiểu ánh xạ NAT của hai đầu quyết định có nối thẳng được không hay phải qua máy tiếp sức (TURN) tốn băng thông. Các thư viện như WebRTC dùng ICE để dò đúng chuyện này. Con số mang theo: NAT Linux (MASQUERADE) giữ nguyên cổng nguồn khi rảnh (40000 → 40000), dùng cùng cổng ngoài cho mọi đích (độc lập điểm cuối), và chỉ đổi cổng khi hai máy nội bộ xung đột (→ 42478) — một hành vi dễ đoán và thân thiện với xuyên NAT, không phải trò tung cổng ngẫu nhiên mà tôi đã tưởng. Đọc bảng conntrack một lần đáng giá hơn mười lần đoán.

Thử ba mươi giây

Trên máy Linux sau NAT (hoặc một router mở được shell), mở một kết nối ra ngoài rồi soi ánh xạ. Đơn giản nhất: vào một trang như whatismyip để thấy IP công khai của bạn — đó chính là IP mà NAT viết lên gói của bạn, khác hẳn IP riêng 192.168.x.x hay 10.x.x.xip addr trên máy bạn hiển thị. Muốn thấy cả cổng, chạy curl -s https://api.ipify.org từ nhiều thiết bị trong nhà: tất cả trả về cùng một IP công khai — đó là bản chất NAT, nhiều máy một địa chỉ. Còn nếu có quyền trên router chạy Linux, conntrack -L | grep <ip-máy-bạn> sẽ cho bạn xem trực tiếp từng dòng ánh xạ trong-ra-ngoài đang sống, đúng cái bảng mà bài này đọc để đo.