🥲 為什麼我需要這個架構?
我是自架服務玩家(VPS、BGP 也有自己的 ASN)。
我常常需要:
- 測我自己的 IPv6 Anycast
- 對外做 IPv6 API 服務測試
- 測 DNS、DoQ、DoH 的 IPv6 traceflow
- 測 Bird2 + RPKI 的 route propagation
但我家裡的網路: Kbro(IPv4 only)根本無法使用 IPv6 上游,也不能去測我自己廣播出的 IPv6 路由。
🎯 最終目標
Mac (只有 IPv4+Wireguard) → Kbro → 中華電信 VPS + WireGuard VPN(IPv4) → Sing-Box → VPS(台灣 Dual-Stack) 讓我所有 IPv6 全部走 VLESS 出口

架構圖
Client (Mac)
fd42:42:44::2
⬇︎ (WireGuard IPv6)
wg0 on 中華電信 VPS
⬇︎ (mangle → fwmark 0x1)
ip -6 rule fwmark 0x1 → table 100
⬇︎
table 100: default dev sb-tun0
⬇︎
sb-tun0 (sing-box tun inbound)
⬇︎ (VLESS over TLS)
DualStack VPS: 1.1.1.1 / 2a12:xxxx:123:xxx::
⬇︎
IPv6 Internet
Mac 上的 Wireguard 設定
[Interface]
PrivateKey = yEHMdZBaxxxxxwZ2U4=
Address = 10.8.3.2/24, fd42:42:44::2/128
DNS = 10.8.3.1
MTU = 1320
[Peer]
PublicKey = BB6wTT+ZMKxxxZI6vXUw8=
PresharedKey = d4tQpuxamExxxFN1bqGGM=
AllowedIPs = 0.0.0.0/0, ::/0, 10.8.3.0/24, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
Endpoint = xxx.com:1234
PersistentKeepalive = 15
流量路由流程(Network Flow)
- ip6tables (mangle) 做標記
ip6tables -t mangle -A PREROUTING -i wg0 -p tcp -j MARK --set-mark 0x1
ip6tables -t mangle -A PREROUTING -i wg0 -p udp -j MARK --set-mark 0x1
2. fwmark=0x1 -> 走 table 100
ip -6 rule add fwmark 0x1 table 100
ip -6 route add default dev sb-tun0 table 100
Note: IPv6 只要是從 wg0 進來,就會被送進 sb-tun0(Sing-Box)。
3. Sing-box (sb-tun0)
- TUN Inbound 收到 IPv6 馮總
- outbound=VLESS
- 派送到 DualStack VPS IPv6 上游
4. DualStack VPS
- IPv6/64 prefix
5. IPv6 回程 -> 原路回 Mac
Sing-box 安裝
https://github.com/SagerNet/sing-box/releases/download/v1.13.0-alpha.27/sing-box_1.13.0-alpha.27_linux_amd64.deb
dpkg -i sing-box_1.13.0-alpha.27_linux_amd64.deb
systemctl restart sing-box
systemctl status sing-box
iptables / routing Diagram(詳細流程)
(wg0 IPv6 traffic)
│
▼
+-------------------+
| ip6tables mangle | mark: 0x1
+-------------------+
│
fwmark=0x1
│
▼
+-----------------------+
| ip -6 rule table 100 |
+-----------------------+
│
▼
+-----------------------+
| table 100 → default → sb-tun0 |
+-----------------------+
│
▼
+----------------------+
| Sing-Box tun-in |
+----------------------+
│
▼
+----------------------+
| VLESS → DualStack VPS|
+----------------------+
Wireguard 設定 (中華電信)
[Interface]
Address = 10.8.3.1/24, fd42:42:44::1/128
# 允許 wg0 進來
PostUp = iptables -A INPUT -i %i -j ACCEPT
PostUp = ip6tables -A INPUT -i %i -j ACCEPT
PostUp = iptables -A FORWARD -i %i -o eth0 -j ACCEPT
PostUp = iptables -A FORWARD -i eth0 -o %i -j ACCEPT
# IPv4 NAT:wg0 出 eth0 時做 MASQUERADE
PostUp = iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
# 允許 IPv6 轉發
PostUp = ip6tables -A FORWARD -i %i -j ACCEPT
PostUp = ip6tables -A FORWARD -o %i -j ACCEPT
# 還原規則
PostDown = iptables -D INPUT -i %i -j ACCEPT
PostDown = ip6tables -D INPUT -i %i -j ACCEPT
PostDown = iptables -D FORWARD -i %i -o eth0 -j ACCEPT
PostDown = iptables -D FORWARD -i eth0 -o %i -j ACCEPT
PostDown = iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
PostDown = ip6tables -D FORWARD -i %i -j ACCEPT
PostDown = ip6tables -D FORWARD -o %i -j ACCEPT
# IPv6:全部交給腳本處理 → fwmark + table 100 + sb-tun0
PostUp = /root/wg0-ipv6.sh up %i
PostDown = /root/wg0-ipv6.sh down %i
中華電信 VPS 上 Sing-Box 設定(最小可用版)
{
"inbounds": [
{
"type": "tun",
"tag": "tun-in",
"interface_name": "sb-tun0",
"address": [
"172.19.0.1/30",
"fd00:19::1/126"
],
"auto_route": false,
"auto_redirect": false
}
],
"outbounds": [
{
"type": "vless",
"tag": "to-xxx",
"server": "1.1.1.1",
"server_port": 4443,
"uuid": "5f2c2f07-zzz-zz6-zzz-9fe5c6zzzzzz",
"tls": {
"enabled": true,
"server_name": "xxxxxx"
}
},
{
"type": "direct",
"tag": "direct-out"
}
],
"route": {
"rules": [
{
"ip_version": 6,
"outbound": "to-xxx"
},
{
"ip_version": 4,
"outbound": "direct-out"
}
],
"final": "direct-out"
}
}
🧨 重點:IPv6 不能 NAT
- 沒有 ip6tables -t nat -A POSTROUTING -j MASQUERADE
- 因為 Debian 6.17 kernel 根本沒有 nat table
- 而且 IPv6 NAT 本來就不該存在(會破壞端到端原則)
只能fwmark + policy routing → 強制走 sb-tun0
中華電信 VPS 上的 Script
#!/bin/bash
set -e
ACTION="$1" # up / down
IFACE="$2" # wg0
MARK=0x1
TABLE=100
TUN_IF="sb-tun0"
case "$ACTION" in
up)
# 開 IPv6 forward(只要開一次,重複設也沒差)
sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1 || true
# mangle:標記從 wg0 進來的 IPv6 TCP/UDP
ip6tables -t mangle -C PREROUTING -i "$IFACE" -p tcp -j MARK --set-mark $MARK 2>/dev/null || \
ip6tables -t mangle -A PREROUTING -i "$IFACE" -p tcp -j MARK --set-mark $MARK
ip6tables -t mangle -C PREROUTING -i "$IFACE" -p udp -j MARK --set-mark $MARK 2>/dev/null || \
ip6tables -t mangle -A PREROUTING -i "$IFACE" -p udp -j MARK --set-mark $MARK
# FORWARD:允許 wg0 ↔ sb-tun0
ip6tables -C FORWARD -i "$IFACE" -o "$TUN_IF" -j ACCEPT 2>/dev/null || \
ip6tables -A FORWARD -i "$IFACE" -o "$TUN_IF" -j ACCEPT
ip6tables -C FORWARD -i "$TUN_IF" -o "$IFACE" -j ACCEPT 2>/dev/null || \
ip6tables -A FORWARD -i "$TUN_IF" -o "$IFACE" -j ACCEPT
# policy routing:fwmark=1 → table 100
ip -6 rule | grep -q "fwmark $MARK lookup $TABLE" || \
ip -6 rule add fwmark $MARK table $TABLE
# table 100:default 走 sb-tun0
ip -6 route replace default dev "$TUN_IF" table $TABLE
;;
down)
# 刪掉 mangle 規則
ip6tables -t mangle -D PREROUTING -i "$IFACE" -p tcp -j MARK --set-mark $MARK 2>/dev/null || true
ip6tables -t mangle -D PREROUTING -i "$IFACE" -p udp -j MARK --set-mark $MARK 2>/dev/null || true
# 刪掉 FORWARD 規則
ip6tables -D FORWARD -i "$IFACE" -o "$TUN_IF" -j ACCEPT 2>/dev/null || true
ip6tables -D FORWARD -i "$TUN_IF" -o "$IFACE" -j ACCEPT 2>/dev/null || true
# 清掉 policy routing & table 100
ip -6 rule del fwmark $MARK table $TABLE 2>/dev/null || true
ip -6 route flush table $TABLE 2>/dev/null || true
;;
*)
echo "Usage: $0 {up|down} <iface>" >&2
exit 1
;;
esac
DualStack VPS 的 Singbox 設定
# /etc/sing-box/config.json
{
"log": {
"level": "info"
},
"inbounds": [
{
"type": "vless",
"tag": "vless-in",
"listen": "0.0.0.0",
"listen_port": 4443,
"users": [
{
"uuid": "5f2c2f07-xxx-xxx-xxx-9fe5c6xxxxxx",
"flow": ""
}
],
"tls": {
"enabled": true,
"certificate_path": "/root/.acme.sh/xxxxxxx/fullchain.cer",
"key_path": "/root/.acme.sh/xxxxxx/xxx.key"
}
}
],
"outbounds": [
{
"type": "direct",
"tag": "direct-out"
}
]
}
純手動設定 + 測試
# 第一步
兩台 VPS 都安裝 Sing-box 和設定好相對應 /etc/sing-box/config.json
# 第二步
wg-quick up wg0
# 第三步
#(只清 IPv6 nat table,不動 filter / mangle 其他規則)
ip6tables -t nat -F
ip6tables -t nat -X
第四步 用 mangle 標記「從 wg0 來的 IPv6」
ip6tables -t mangle -F PREROUTING
# 從 wg0 進來的 IPv6 TCP/UDP,全部打上 fwmark = 1
ip6tables -t mangle -A PREROUTING -i wg0 -p tcp -j MARK --set-mark 0x1
ip6tables -t mangle -A PREROUTING -i wg0 -p udp -j MARK --set-mark 0x1
# 第五步 建立一個 routing table 100,所有 mark=1 的封包走這張表
# 新增一條規則:fwmark=1 → 用 table 100
ip -6 rule add fwmark 0x1 table 100
ip -6 rule del fwmark 0x1 table 100 2>/dev/null
ip -6 rule add fwmark 0x1 table 100
ip -6 route flush table 100
ip -6 route add default dev sb-tun0 table 100
第六步 在 table 100 裡面,預設路由改成丟到 sb-tun0
ip -6 route add default dev sb-tun0 table 100
####
這樣
• 任何從 wg0 進來的 IPv6:
• 被 mangle 標記 fwmark=1
• 根據 ip -6 rule 被送去查 table 100
• table 100 的 default route 是 dev sb-tun0
• 封包原本 dst = 2606:4700:xxxx::xxxx 不變,但路由改成「交給 sb-tun0」
這時 sing-box 的 tun inbound 就能吃到完整 L3 封包。
###
# 記得 interface 名稱要和 ip a 顯示完全一致:
ip a
10: sb-tun0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 65535 qdisc fq_codel state UNKNOWN group default qlen 500
link/none
inet 172.19.0.1/30 brd 172.19.0.3 scope global sb-tun0
valid_lft forever preferred_lft forever
inet6 fd00:19::1/126 scope global
valid_lft forever preferred_lft forever
inet6 fe80::627e:7f30:b47c:5a3a/64 scope link stable-privacy proto kernel_ll
valid_lft forever preferred_lft forever
# 檢查 table 100
ip -6 route show table 100
預期結果:
default dev sb-tun0 metric 1024 pref medium
第七步 允許 FORWARD(必要)
ip6tables -A FORWARD -i wg0 -o sb-tun0 -j ACCEPT
ip6tables -A FORWARD -i sb-tun0 -o wg0 -j ACCEPT
第八步 確認 IPv6 forwarding 開啟
grep -q "net.ipv6.conf.all.forwarding" /etc/sysctl.conf || \
echo "net.ipv6.conf.all.forwarding=1" >> /etc/sysctl.conf
sysctl -p
測試
# 1. 確認 sing-box 正常
systemctl restart sing-box
systemctl status sing-box
看到:
• inbound/tun[tun-in]: started at sb-tun0
• sing-box started
# 中華電信 監聽
## sb-tun0 會跳一堆 IPv6 SYN 封包(你會看到)
tcpdump -ni sb-tun0 ip6
# 在 Mac 運行
curl -6 -k https://ifconfig.io
curl -6 -k https://ipapi.co/ip
curl -6 -k https://ipv6.ident.me
# 注意:為什麼「DNAT 到 fd00:19::1」一定會失敗?
DNAT 把目標 IP 改成本機 IP(fd00:19::1) → Linux 會當成本機服務 → 因為沒人聽 443 → RST → Connection refused。
TUN 要吃的是:
• 保持原目標 IP
• 但「路由」送去 TUN interface,而不是 DNAT 把 dst 改成本機。
Photo by Josiah Rock on Unsplash