Không cần đưa cả Ubuntu vào VPN: chạy riêng một Chrome qua WireGuard bằng Network Namespace

Có một việc tưởng nhỏ nhưng làm tôi thấy Linux network namespace thực sự thú vị.

Tôi cần vào một Redmine chỉ truy cập được khi đi qua WireGuard VPN. Cách quen thuộc nhất là bật VPN trên máy. Nhưng WireGuard config của tôi có:

AllowedIPs = 0.0.0.0/0

Nghĩa là toàn bộ traffic Internet của máy sẽ đi qua VPN server. Redmine thì vào được, nhưng Slack, Terminal, Chrome bình thường, update package… cũng phải đi một vòng qua VPN. Nếu VPN server ở xa hoặc băng thông không tốt, mọi thứ đều chậm theo.

Điều tôi thực sự muốn đơn giản hơn nhiều:

Chrome thường / Slack / Terminal  ──> Internet trực tiếp

Chrome Redmine                    ──> WireGuard VPN ──> Redmine

Không phải tách thêm dây mạng. Không cần thêm card Wi-Fi. Chỉ cần tách network stack mà Chrome Redmine nhìn thấy.


Network namespace là gì?

Một process bình thường trên Linux dùng network stack của host: cùng interface, route table, DNS và firewall với mọi ứng dụng khác.

Network namespace tạo ra một network stack riêng. Bên trong namespace có thể có:

  • interface riêng;
  • IP address riêng;
  • route table riêng;
  • DNS configuration riêng;
  • firewall rules riêng;
  • socket riêng.

Vì vậy, cùng trên một Ubuntu host, ta có thể có hai “góc nhìn về mạng” khác nhau:

Ubuntu host
├── Chrome thường
│   └── default route của host ──> Internet bình thường
└── namespace redmine-vpn
    └── Chrome Redmine
        └── default route trong namespace ──> wg0 ──> VPN

Điểm quan trọng là: default route của host không đổi. Chỉ default route bên trong namespace đổi sang WireGuard.


Vẫn chỉ có một đường Internet, vậy traffic tách kiểu gì?

Đây là phần dễ gây nhầm lẫn nhất.

Máy vẫn chỉ dùng một Wi-Fi hoặc một cổng Ethernet vật lý. Các packet từ mọi ứng dụng vẫn chia sẻ đường truyền đó. Namespace không tạo thêm băng thông.

Điều nó tách là quyết định “packet này sẽ được gửi theo luật nào”.

Chrome thường
  → host route table
  → Wi-Fi/Ethernet
  → Internet

Chrome Redmine
  → route table của redmine-vpn
  → wg0 (mã hóa packet)
  → veth
  → host route table
  → Wi-Fi/Ethernet
  → WireGuard endpoint
  → VPN → Redmine

Chrome Redmine tạo socket bên trong namespace nên kernel tra route table của namespace và chọn wg0. Chrome thường tạo socket ở host, nên hoàn toàn không biết wg0 này tồn tại.

Nói cách khác: đây là split tunnel theo ứng dụng, không phải split tunnel theo một dải IP mong manh.


Kiến trúc tôi dùng

Namespace cần một đường để ra host trước khi WireGuard hoạt động. Tôi tạo một cặp veth — có thể hình dung là dây Ethernet ảo — nối host với namespace:

Host                                      namespace redmine-vpn
──────────────────────────────────────────────────────────────────
rvpn-host0: 10.42.0.1/24  ←── veth ──→  rvpn-ns0: 10.42.0.2/24
                                               ├── wg0: 10.254.0.2/24
                                               └── Chrome Redmine

Host bật forwarding và NAT cho đúng subnet 10.42.0.0/24. Do đó namespace có thể gửi packet ra Internet qua host, nhưng không cần động vào default route của host.

Sau khi WireGuard lên, routing bên trong namespace có dạng:

default dev wg0
203.0.113.42/32 via 10.42.0.1 dev rvpn-ns0
10.42.0.0/24 dev rvpn-ns0
10.254.0.0/24 dev wg0

203.0.113.42 ở trên là IP minh họa theo dải tài liệu; trong script thực tế, IP endpoint được đọc từ WireGuard config. Route /32 cho WireGuard endpoint là chi tiết quan trọng nhất.

Nếu chỉ đặt default dev wg0, packet dùng để thiết lập tunnel cũng sẽ bị gửi vào chính tunnel đó. Tunnel chưa hoạt động thì packet không thể thoát ra ngoài: một routing loop.

Route /32 bảo kernel rằng: “riêng endpoint WireGuard thì đi qua rvpn-ns0 → host → Internet bình thường”. Còn mọi đích khác trong namespace đi vào wg0.


Vì sao không chỉ route IP của Redmine qua VPN?

Có thể nghĩ đến cách resolve redmine.company.com, lấy IP rồi thêm route riêng qua VPN. Nhưng Redmine nằm sau Cloudflare. IP có thể thay đổi, nhiều website có thể dùng chung một IP, và DNS/CDN có thể trả kết quả khác theo thời điểm hoặc vị trí.

Với VPN config yêu cầu AllowedIPs = 0.0.0.0/0, route toàn bộ traffic của namespace nhỏ qua VPN đơn giản và ổn định hơn. Vì chỉ Chrome Redmine ở trong namespace nên phạm vi ảnh hưởng vẫn rất hẹp.


DNS cũng phải thuộc namespace

Một lỗi khá khó chịu khi làm kiểu này là DNS.

Host Ubuntu của tôi dùng systemd-resolved, nên /etc/resolv.conf thường trỏ đến 127.0.0.53. Địa chỉ loopback đó chỉ có ý nghĩa trong network namespace của host; namespace mới không có DNS resolver đang lắng nghe ở đó.

Thay vì sửa DNS của Ubuntu, tôi tạo file riêng:

/etc/netns/redmine-vpn/resolv.conf

Ví dụ:

nameserver 8.8.8.8
options timeout:2 attempts:2

Khi chạy ip netns exec redmine-vpn ..., Linux dùng resolv.conf riêng này. DNS request đi theo default route trong namespace, tức là qua WireGuard sau khi tunnel đã lên. DNS của host và các ứng dụng khác không thay đổi.


Không chạy Chrome bằng root

Tạo namespace và interface cần sudo, nhưng Chrome không nên chạy bằng root. Tôi khởi động Chrome qua namespace rồi hạ quyền về desktop user:

sudo ip netns exec redmine-vpn \
  runuser -u "$USER" -- \
  google-chrome \
    --user-data-dir="$HOME/.config/google-chrome-redmine" \
    --no-first-run \
    --new-window \
    https://redmine.company.com/redmine/

--user-data-dir là bắt buộc trong trường hợp này.

Nếu dùng profile Chrome bình thường, Chrome có thể phát hiện process đang chạy và gửi yêu cầu mở tab sang process đó. Process cũ đang ở host namespace, nên traffic không còn đi qua VPN. Profile riêng vừa đảm bảo Chrome thực sự nằm trong namespace, vừa giữ lại lịch sử, cookie, bookmark và phiên đăng nhập Redmine cho những lần sau.


Biến nó thành vài lệnh make

Tôi gói phần setup/cleanup thành Makefile và shell script để tránh gõ nhầm những lệnh mạng cần quyền root:

# Chép WireGuard config có private key vào đây trước
cp /path/to/my-wireguard.conf wireguard/redmine.conf
chmod 600 wireguard/redmine.conf

make setup    # kiểm tra tool, config và môi trường
make start    # tạo namespace, veth, NAT, wg0 và DNS riêng
make test     # test từng tầng mạng
make chrome   # mở Chrome Redmine với profile riêng

# Khi xong
make stop

make start phải idempotent: chạy lần hai không tạo veth hay firewall rule trùng. make stop cũng phải chạy được khi một phần resource đã mất. Đây không chỉ là chuyện tiện; networking script mà cleanup không chuẩn rất dễ để lại NAT rule khó lần ra sau vài tuần.

Với nftables, cách an toàn là tạo table mang tên riêng cho project, ví dụ redmine_vpnredmine_vpn_nat; không flush ruleset chung của máy. Với iptables, dùng chain riêng như RVPN_FORWARDRVPN_NAT, rồi chỉ thêm/xóa hook trỏ tới các chain đó.

Private key không đưa vào source code hay Git. File WireGuard được .gitignore và script kiểm tra permission 600 trước khi chạy.


Thứ tự test giúp debug đỡ mù mờ

Đừng mở Chrome ngay rồi đoán tại sao Redmine timeout. Tôi test từ dưới lên:

namespace tồn tại
veth hoạt động
namespace reach được WireGuard endpoint qua Internet thường
wg0 tồn tại và có handshake
DNS trong namespace resolve được Redmine
HTTPS tới Redmine thành công
route tới IP Redmine đi qua wg0
Chrome chạy trong namespace

Ví dụ, handshake không có nghĩa DNS đã đúng; DNS đúng không có nghĩa Chrome đang dùng đúng process/profile. Tách các tầng ra giúp biết chính xác nơi hỏng.


Khi nào cách này đáng dùng?

Cách này hợp khi:

  • chỉ một hoặc vài ứng dụng cần VPN;
  • VPN full tunnel làm các ứng dụng khác chậm;
  • cần tách DNS, route hoặc firewall của một ứng dụng;
  • muốn thử nghiệm traffic mà không ảnh hưởng host.

Nó không phải cách để tăng băng thông. Chrome Redmine vẫn đi qua VPN server và vẫn chịu latency/băng thông của VPN. Lợi ích là chỉ Chrome Redmine chịu điều đó; phần còn lại của Ubuntu đi Internet trực tiếp như cũ.

Docker và Kubernetes dùng network namespace rất nhiều, nhưng trường hợp “mở riêng một Chrome công việc qua VPN” làm tôi thấy đây là một primitive rất gần gũi với desktop. Không cần container phức tạp, không cần đổi route cả máy — chỉ cần đặt đúng process vào đúng network namespace.

Đó là một ứng dụng nhỏ, nhưng khá thuyết phục, của một tính năng rất mạnh trong Linux.