tailscale 为什么不能像 OpenVPN 那样用 via 路由访问对端局域网

九月 04, 2026 更新: 十月 07, 2026 [网络与安全] #tailscale #Linux #routing #wireguard #tun

背景:想在 tailscale 网络里"借用"对端访问其局域网

本机(tailscale 100.64.0.1)想临时访问对端节点(100.64.0.2)所在站点的局域网设备 192.168.2.1。 直觉做法是加一条经对端转发的路由,就像 OpenVPN 里那样:

sudo ip r replace 192.168.2.1/32 via 100.64.0.2

结果:

Error: Nexthop has invalid gateway.

换成写进 tailscale 自己的路由表也报一样的错。而另一台 OpenVPN 机器上同样的姿势却完全正常:

# OpenVPN 机器上实测可行
ip r add 192.168.2.1 via 10.8.0.5   # 成功,且能正常访问 192.168.2.1

同样的命令形态,一个报错一个成功,差在哪?本文完整记录排查过程与背后的架构差异。

排查过程

本机 tailscale 的路由布局

$ ip route show
default via 192.168.4.1 dev bridge0 proto static metric 425
192.168.4.0/22 dev bridge0 proto kernel scope link src 192.168.5.244 metric 425
...   # main 表里没有任何 100.64.0.0/10 相关路由

$ ip route show table 52
100.64.0.2 dev tailscale0
100.64.0.3 dev tailscale0
...
100.100.100.100 dev tailscale0

$ ip rule show
5270: from all lookup 52
...

关键事实:所有 tailscale peer 的 /32 路由都在独立的 table 52 里,main 表一条都没有; ip rule 5270: from all lookup 52 让 peer IP 的查表先落到 52。

$ ip route get 100.64.0.2
100.64.0.2 dev tailscale0 table 52 src 100.64.0.1 uid 1000

但 ip route get 能查到,不代表 add 能通过——实测 main 表、table 52 两个目标表都报同样的错 (2026-09-04 于 7.0.0-28 内核复核,同结果)。

一个被推翻的假设:网关校验不是"查目标表"

最初以为是"网关必须在目标表内可达",于是试 table 52(网关 100.64.0.2 明明就在 52 里):

$ sudo ip r add 192.168.2.1/32 via 100.64.0.2 table 52
Error: Nexthop has invalid gateway.   # 依然失败

勘误:早先"只查 main 表、不跟 ip rule"的归因是错的

初稿曾把报错归因于"内核校验网关时 fib_lookup() 固定只查 main 表(RT_TABLE_MAIN)、 路由添加时的 nexthop 校验不查规则链"。该解释被下面的实测推翻——真正的判官是 scope(on-link)过滤,与查哪张表无关:

实测 A(证伪"只查 main"):往 table 52 放一条专门当网关的路由、scope 写成 link (main 表里没有它,只有经 ip rule 5270 能到):

$ sudo ip r add 100.64.0.254/32 dev tailscale0 scope link table 52
$ sudo ip r add 10.99.99.99/32 via 100.64.0.254    # 加进 main,成功!
10.99.99.99 via 100.64.0.254 dev tailscale0

网关只存在于 table 52、目标却是 main 表,却能加上——说明添加路由时校验 gateway 会走完整 ip rule 链(fib_lookup → fib_rules_lookup → table 52),并非只查 main。

实测 B(定位到 scope):关键线索在路由属性——tailscaled 写进 table 52 的 peer 路由 全部是 scope global:

$ ip -d route show table 52 | head -2
unicast 100.64.0.2 dev tailscale0 proto boot scope global
unicast 100.64.0.3 dev tailscale0 proto boot scope global

内核在添加路由时(fib_check_nh_v4_gw(),net/ipv4/fib_semantics.c)解析 gateway, 查询 scope 被钳制为 ≥ RT_SCOPE_LINK——语义是**"网关必须由一条 on-link(直连、 scope ≥ link)的路由覆盖"**;查表时 scope 不足的路由会被丢弃(fib_trie.c: fib_scope < flowi4_scope 即跳过,global=0 < link=253)。于是 100.64.0.2 无论 main、table 52、带不带 dev,全部 invalid gateway;只有显式 dev + onlink 能跳过这套校验:

$ sudo ip r add 10.99.99.99/32 via 100.64.0.2 dev tailscale0 onlink table 52   # 成功

(反向对照:把实测 A 那条 100.64.0.254 的 scope 改成 global,via 100.64.0.254 立刻回到 invalid gateway——同一条路由只差 scope。)

那 ip route get 为什么能查到?它走独立的查询路径(inet_rtm_getroute),查询 scope 是 universe、不过滤 global 路由——所以"查得到"与"能当网关"是两个不同的问题。

就算校验过了,也到不了对端

把报错消掉很容易(不带 via 的 dev 路由不触发网关校验,带 via 的加 dev + onlink 也行,见上节实测):

sudo ip r add 192.168.2.1/32 dev tailscale0   # 能装上
ping -c2 192.168.2.1                          # 预期全丢:隧道内无对端认领该目标

这就触及真正的架构差异了。

根因:两级转发模型 vs OpenVPN 的单链路模型

包进隧道后,谁决定发给谁?

tailscale0 是普通 TUN(本机实测 type tun,wireguard-go 用户态模式)。 内核路由表只负责"把包交给 tailscale0"这一级——TUN 设备不检查目标 IP 是否被允许, 任何路由进来的包都原样写进 tun fd。真正判决在第二级:

tailscaled 用户态进程从 tun 读走包后,wireguard-go 拿 dst 去匹配各 peer 的 AllowedIPs:

所以这不是路由表技巧能绕过的:手工路由改的是第一级,判决在第二级,而第二级只认宣告。

OpenVPN 为什么能通

OpenVPN 的 tun0 是单对端 ptp 链路:包进隧道后没有"选谁"这回事——固定发往唯一的 server。 而 server 是显式开启转发的真实路由器(内核 ip_forward + 它自己的 LAN 路由), 所以任何你路由过去的目标,只要 server 能到达就通。

# OpenVPN 机器实测路由
10.8.0.0/24 via 10.8.0.5 dev tun0          # config 里 route 指令,程序经 netlink 写入
10.8.0.5 dev tun0 proto kernel scope link src 10.8.0.6   # 内核自动生成

顺带解答一个疑问:ip route 为什么能看到 10.8.0.5 这个"peer"?它不可能读 openvpn 配置。 看 proto kernel 标记——这条路由是内核在配置 ptp 地址时自动生成的: openvpn 配地址用的是 10.8.0.6 peer 10.8.0.5(点对点形式),对端地址成为接口地址属性, 内核自动为链路对端建 /32 路由(与给网桥配 IP 自动生成链路路由同理)。 对比 tailscale0 的地址 100.64.0.1/32——纯 /32、没有 peer 字段(多对端不存在单一链路对端), 所以内核不会生成任何 peer 路由,对端们是 tailscaled 手工写进 table 52 的。

这同时也解释了开头那个"一个报错一个成功"的表层差异:OpenVPN 机上 via 10.8.0.5 能装上, 除了隧道路由在 main 表里,更关键的是内核自动生成的 peer 路由是 scope link (proto kernel scope link)——正好满足上文"网关必须由 on-link 路由覆盖"的校验; 而 tailscaled 手写的 peer 路由是 proto boot scope global,在同样的校验处就被拦下。 报错与否取决于覆盖网关那条路由的 scope,与规则链、目标表都无关。

两条"peer"路由的来历:scope 与 proto 为什么不同

顺着上面两个输出再问一层:同样是"对端可达",为什么 OpenVPN 的是 proto kernel scope link、 tailscale 的是 proto boot scope global?因为两者根本不是同一种东西——生成方、生成时机都不同。

OpenVPN 的 peer 路由是"配地址"的副作用,由内核自己生成。 openvpn 配 10.8.0.6 peer 10.8.0.5 时,对端 IP 作为接口地址属性(IFA_ADDRESS)随地址一起下发;内核 fib_add_ifaddr() (net/ipv4/fib_frontend.c)除了把本端 10.8.0.6 收进 local 表,还会为对端地址自动建一条 /32 路由,且 scope/proto 是写死的(fib_magic()):proto = RTPROT_KERNEL(标记"内核创建")、 scope = RT_SCOPE_LINK——ptp 对端在语义上就是直连对端(on-link、不经过任何中间网关), 天然满足上文网关校验对 scope 的要求。这也是 OpenVPN 的 via 10.8.0.5 能装上的直接原因。

tailscale 的"peer 路由"是 tailscaled 经 netlink 手工写入的普通 dev 路由(wgengine/router/ osrouter/router_linux.go 的 addRoute()):只填 LinkIndex(tailscale0)、Dst、Table=52 三个字段, 不带 via、也不指定 scope/proto——于是落在其 netlink 库的默认值上:scope=universe、proto=boot (nl.NewRtMsg() 的缺省)。所以 proto boot scope global 不是设计选择,是"没设置 + 库默认": tailscaled 自己从不用 via 指 peer(包进隧道后的归属由 wg 引擎按 AllowedIPs 判决,路由只负责 把包送进 tun),scope 写多少对它毫无影响。

侧面印证:tailscaled 在 netlink 不可用的兜底路径会退化执行 ip route add <网段> dev tailscale0 (iproute2),而 iproute2 对 dev-only 路由默认给 scope link——同一条路由若由这条路径写入, via peer IP 就能装上(对照上节实测 A:scope link 的网关路由只放 table 52,main 表加 via 照样成功)。

两个"peer"路由的性质也不同:OpenVPN 那条描述"链路另一端是谁"(内核地址模型的一部分); tailscale 那条描述"哪些目标 IP 属于隧道"——本质是 AllowedIPs 的内核投影,随 netmap 增删、 与点对点链路毫无关系。

核心差异一图流

OpenVPN(tun 模式)Tailscale(本机)
隧道形态每客户端 1 条到 server 的链路1 个 TUN 设备对 N 个 peer
包进隧道后的判决无判决:固定发唯一 server用户态 wg-go 按 dst 匹配 AllowedIPs
未宣告的目标无此概念(server 来者不拒)直接丢弃
隧道→LAN 转发server 内核 ip_forward仅 subnet router 节点(内核 ip_forward + nftables)
peer 表在哪openvpn daemon 用户态(config 单点)tailscaled 用户态(netmap 动态下发)
"peer"路由的来历配地址时内核自动生成(proto kernel scope link)AllowedIPs 的内核镜像,tailscaled netlink 写入(proto boot scope global)
"用 via 路由借道"天然成立(server 就是路由器)不成立(peer 默认是叶子,不承担转发)

一句话:转发执行者两边都是内核,判决者都是用户态程序;区别是 OpenVPN 用配置把"谁当路由器" 写死成唯一的 server(无条件信任),tailscale 用宣告机制动态决定谁当路由器(默认谁都不当,最小暴露)。

解决方案

不改 tailscale 配置:以对端做跳板

peer 的 tailscale IP(100.64.0.2)现在就能通(table 52 有路由),所以:

# TCP 服务透传
ssh -L 8080:192.168.2.1:80 user@100.64.0.2
# 通用 SOCKS 代理(任意协议)
ssh -D 1080 user@100.64.0.2
# 直接登录对端操作
ssh user@100.64.0.2

要 IP 层透明直达:subnet router(唯一的正规解)

tailscale 版"让对端扮演 OpenVPN server"= 在对端宣告子网路由,本机接受:

# 对端(能路由到 192.168.2.0/24 的节点)
tailscale set --advertise-routes=192.168.2.0/24
# 本机
tailscale set --accept-routes

tailscale set 全程不掉线,撤销只需把值清空。生效过程不只是用户态配置:

  1. 用户态:两端 prefs 持久化;
  2. 控制面:对端宣告上传协调服务器,审核后把新 netmap 推给所有 peer;
  3. 本机内核改动:192.168.2.0/24 加入对端 AllowedIPs(本机是 wg-go 用户态持有; 内核 wireguard 模式则写入内核 wg 对象),同时 tailscaled 经 netlink 新增一条内核路由;
  4. 对端内核改动:nftables 加 forward/SNAT 规则,开启 ip_forward sysctl——这就是 "OpenVPN server 转发角色"的等价物。

实战坑:accept-routes 全量接受——本网段被自身通告劫持

上面"正规解"要求本机开 accept-routes,但它有一个设计限制:接受是全量的,没有按前缀过滤。 假设本机所在网段(192.168.4.0/22)里有两台机器同时向 tailnet 宣告了本网段(常见的冗余 subnet router 配置:两台都 advertise 192.168.4.0/22,供远端 192.168.2.0/24 站点访问), 本机一开 accept-routes 就会把"自己所在的网段"也收下来,后果:

现状核验:tailscale debug prefs 的 AdvertiseRoutes、tailscale status --json 的 Self.AllowedIPs(本机自己也在宣告时可见本网段)。三种解法(按推荐序):

A. 选择性手工路由——不开 accept-routes(实测:不成立)

只加目标网段、不开 accept 的思路看起来干净:

sudo ip r add 192.168.2.1/32 dev tailscale0

但实测包进隧道即被本机 wg 引擎丢弃(广告节点侧 tcpdump 全空)。原因是一个容易踩的视角陷阱: tailscale status --json 里对端 AllowedIPs 显示包含通告(如 "192.168.2.1/32"), 那是 netmap 视角;而 accept-routes=false 时,wireguard 引擎实际使用的 allowedIPs 会把 对端子网通告过滤掉,只留各 peer 的 tailscale IP。用 status json 判断"wg 是否放行"会误判。 所以想走 subnet router,accept-routes 这一侧绕不开。

B. accept-routes + 高优先级 ip rule 把本网段拨回 main(实测:有效,推荐)

保留 accept 的自动全量能力,用内核规则修正冲突(rule 插在 tailscale 的 5270 之前):

sudo ip rule add pref 100 to 192.168.4.0/22 lookup main   # 先加保护,再开 accept
sudo tailscale set --accept-routes

rule 100 先于 rule 5270 命中:本网段查表直接落到 main 表的本地直连,不进隧道; 远端 192.168.2.1/32 不在规则内,照走 tailscale(accept 后自动装入 table 52)。 实测结果:table 52 出现 192.168.2.1 dev tailscale0,远端 ping 通(~11ms), 本地网关 ping 仍直连(0.5ms)。等于把"全量接受"改成"按需屏蔽"——以后新增的其他远端 通告仍会自动收,需要时再补规则精确屏蔽。该规则需自行持久化,tailscaled 不会清理它管理 的规则号段之外的条目:

# /etc/systemd/system/tailscale-protect-lan.service
[Unit]
Description=Protect local LAN subnet from tailscale route shadowing
After=network-online.target tailscaled.service
Wants=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/sh -c 'ip rule add pref 100 to 192.168.4.0/22 lookup main 2>/dev/null || true'
ExecStop=/bin/sh -c 'ip rule del pref 100 to 192.168.4.0/22 lookup main 2>/dev/null || true'

[Install]
WantedBy=multi-user.target
sudo systemctl enable --now tailscale-protect-lan.service

C. 先测新版 tailscaled 是否自带冲突跳过(5 秒实验,可回滚)

tailscale set --accept-routes
ip route show table 52              # 192.168.4.0/22 是否被装上?
journalctl -u tailscaled --since "1 min ago" | grep -i conflict   # 有 skip 日志 = 被保护
ping -c1 192.168.4.1                # 本网段网关仍直连即安全
tailscale set --accept-routes=false # 不对就立即回滚

新版若对与本地直连冲突的 accepted 路由自动跳过(日志可见 skip),直接 accept 即可无事。

2026-10-07 复核(tailscale 1.102.4):不会跳过。 重开 accept-routes,table 52 立即装回本地网段路由、无任何 skip 日志——ip rule 豁免在当前版本仍是必配项。

排障实录:从"宣告成功"到"真正连通"的完整链路

本机与对端网络(headscale v0.29.3 + tailscale 1.102)实测踩坑全过程,按检查顺序:

  1. 广告节点宣告:tailscale set --advertise-routes=192.168.2.1/32(对端能直达该目标为前提)。

  2. headscale 批准:v0.29 CLI 重构后是 headscale nodes list-routes / headscale nodes approve-routes <node-id> <prefix>,不再有顶层 routes 命令。批准前 Self.AllowedIPs 不会出现该路由。

    勘误(2026-09-12):approve-routes 不接受位置参数,正确写法是 flag 形式 headscale nodes approve-routes -i <node-id> -r <prefix>(0.29.3 --help 实测:只有 -i/--identifier 与 -r/--routes)。list-routes 本身不带参数,写法无误。

  3. 广告节点进入 serving 状态(本次最大暗坑):headscale 侧已显示 Approved + Serving、 其他节点的 netmap 也已收到通告,但广告节点自己的 netmap 迟迟不更新——force-netmap-update 无效,Self.AllowedIPs 始终缺路由、PrimaryRoutes 为空,必须重启 tailscaled 才进入 serving 状态并应用 netfilter 规则。

  4. 广告节点 netfilter 后端判定:tailscaled 日志显示 netfilter running in iptables mode—— 规则在 iptables 而非 nftables!nft list ruleset 为空不代表没规则,误判会带偏排查。 正确查法:iptables -S | grep ts- 与 iptables -t nat -S,可见 ts-forward/ts-input/ ts-postrouting 链 + mark 0x40000 MASQUERADE,规则本身逻辑正确。

  5. 访问端包是否真的进隧道(定位到本机的关键证据):在广告节点 tcpdump -ni any icmp 抓包全空 → 包根本没到广告节点,被访问端本地丢弃。结合第 3 节视角陷阱定位到: accept-routes=false 时 wg 引擎过滤对端通告,本机装 dev 路由也白搭。

  6. 解法落地:先加 ip rule pref 100 to 192.168.4.0/22 lookup main 保护本网段, 再 tailscale set --accept-routes → table 52 自动出现 192.168.2.1 dev tailscale0, 远端与本地 ping 同时验证通过。

关键教训:"宣告成功"与"真正连通"之间隔着 5 个独立环节(宣告→批准→节点 serving→ 广告节点 netfilter→访问端 accept),任一环节静默失效都不通;其中 status json 的 AllowedIPs 是 netmap 视角、accept-routes 才是 wg 引擎的开关、netfilter 后端要按日志判断——三个陷阱 分别浪费一轮排查。

2026-10 复发实录:跨站点同网段——宣告网段里的每台 Linux 都会中招

架构换了个形态,坑还是同一个:家庭站 192.168.2.0/24(KVM 宿主机 + 桥接 VM,网段被 tailnet 里的节点宣告供公司站远程访问),VM 为了走公司侧 192.168.4.0/22 的隧道开了 accept-routes,结果把自己所在的 192.168.2.0/24 也收进了 table 52——局域网流量 被劫进隧道 hairpin 到 subnet router 再绕回,宿主机 SSH/ping VM 全部超时。

症状极具迷惑性,识别特征:

"/23 短前缀宣告"为什么救不了 Linux:直觉方案是把宣告改成 192.168.2.0/23, 指望"本地 /24 更长前缀获胜"。这在 Windows/macOS 上成立(子网路由进系统常规表, 遵守最长前缀),tailscale 官方文档也正是这么建议的——但同一页随即说明 Linux 用 policy routing 引入了额外一层优先级,需配套执行:

sudo ip rule add to 192.168.2.0/24 priority 2500 lookup main

原因:策略路由是逐表顺序查找、单表内才做最长前缀——查 192.168.2.x 时 rule 5270 先命中 table 52 的 /23 即走隧道,main 表的 /24 直连根本轮不到被查。tailscale 开发者在 issue #1227 的原话: "the 'more-specific route' trick won't work on Linux. On that platform Tailscale uses policy routing and puts its route into table 52."(2026-10-07 于 1.102.4 复核: 重开 accept-routes,table 52 立即装回本地网段路由,无冲突跳过日志。)另外 /23 会 把 192.168.3.0/24 一并拉进隧道:家里没有该网段时,公司侧访问 3.x 直接黑洞。

ip rule 的持久化(运行时状态重启即丢,tailscaled 只管理自己 5210–5270 的 pref 段、不会恢复外部规则,必须自行落地)。首选 systemd oneshot(与方案 B 同构,pref 2500 取自官方文档示例,任何 < 5270 皆可):

# /etc/systemd/system/ts-lan-prefer.service
[Unit]
Description=Prefer direct LAN routing for 192.168.2.0/24 over tailscale policy routing
Documentation=https://tailscale.com/docs/reference/troubleshooting/network-configuration/lan-traffic-overlapping-subnets
After=tailscaled.service network-online.target
Wants=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
# 幂等:ip rule add 不查重,重复执行会叠加,先 grep 判断再 add
ExecStart=/bin/sh -c "ip rule show | grep -q 'to 192.168.2.0/24 lookup main' || ip rule add to 192.168.2.0/24 priority 2500 lookup main"

[Install]
WantedBy=multi-user.target
sudo systemctl enable --now ts-lan-prefer.service

两个实现细节:① rule 的匹配顺序由 pref 数字决定、与添加时间无关,该单元先于 tailscaled 执行也没问题;② NetworkManager 管理的机器可用原生方式 nmcli con mod <连接名> +ipv4.route-rules "priority 2500 to 192.168.2.0/24 table main" (ipv4.route-rules 属性),随连接生命周期自动增删,无需自建单元。

最终效果(VM 实测):accept-routes=true 保持开启——公司网段照走隧道 (ip route get 192.168.4.1 → dev tailscale0 table 52),本网段豁免直连 (ip route get 192.168.2.100 → dev enp1s0)。管理员侧宣告无需改动:纯 Linux tailnet 保留 /24 最简洁;有 Windows/macOS 设备时才值得改 /23,两种手段可叠加。

结论泛化:只要节点"住在某个被 tailnet 宣告的网段里",开 accept-routes 前必配本网段 的 ip rule 豁免——与上文"本站冗余宣告"是同一机制的两个触发面。

关键认知:tailscale 接受侧没有 OpenVPN 那样的"要不要这条路由"细粒度控制,但内核 ip rule 可以补齐这个缺口——方案 B 就是"选择性接受"的内核等价物。

附:TUN 还是 TAP,怎么判定

排查中顺手验证 tailscale0 是 TUN(三层)不是 TAP(二层):

$ ip -d link show dev tailscale0 | head -1
tailscale0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> ... tun type tun ...   # type tun = TUN
$ cat /sys/class/net/tailscale0/type       # 65534 = ARPHRD_NONE(TUN);TAP 会是 1
$ cat /sys/class/net/tailscale0/tun_flags  # 0x5001:含 0x1(TUN_TUN_DEV) 无 0x2(TUN_TAP_DEV)
$ cat /sys/class/net/tailscale0/address    # 空:TUN 无 MAC;TAP 会有

若用内核 wireguard(非 wg-go),ip -d link 会显示 type wireguard,丢弃判决会移交给 内核 wg 模块执行——但 AllowedIPs 的内容仍由 tailscaled 按 netmap 下发,规则始终是 tailscale 定的。

总结