运维 | Tailscale 的默认全放行:一次把家里的门关上

有些默认值是善意:让你第一分钟就能把两台机器连起来。代价总要有人付,只是账单晚一点到。

写在前面

夜里刷到一条推文,讲的是用 Tailscale 同时连 VPS 和家里的设备。作者的提醒很直白:默认的 * -> * 全放行,会让名册上所有设备彻底互通——某天 VPS 被拿下,家里的 NAS 也就跟着开门。

看完的第一反应不该是转发,是登进后台看一眼自己那份策略。

结果不出意外:我从没动过它。

一、先把默认值翻译成人话

后台 Access controls 里那段 JSON,如果从没手动改过,核心就是一行:

{
  "grants": [
    { "src": ["*"], "dst": ["*"], "ip": ["*"] }
  ]
}

文档里有一句值得抄下来:In the absence of an acls section, Tailscale applies the default allow all policy. —— "我从没配过"这件事本身,就是一份允许一切的策略。 名册上几台机器,一台 tag 都没有,它们以"同一个用户的设备"的身份彼此全通。

二、威胁模型:哪一边才是暴露面

组网工具容易制造一种错觉:既然都叫"内网",彼此就都可信。实际上不是。

有公网 IP、跑着反向代理、对外提供服务的机器,才是唯一"可能先被别人拿下"的那一类;家里的 NAS、台式机、智能家居网关没有入站口,它们唯一的风险,是有人从内部把门打开。

于是威胁模型很清楚:

角色定位
公网 VPS暴露面,随时假设"已不属于你"
家里的设备高价值资产:面板、下载器、媒体库、一台开着远程桌面的 Windows
自己的手机 / 笔记本需要全权,日常还要用

一台 VPS 被拿下之后,从隧道里能摸到的东西是这样的:

路径后果
→ 家里 NAS 的管理面板与下载器面板被接管,媒体库被拖走
→ 家里的台式机(3389 / 445)RDP 与 SMB,等于整机钥匙
→ 智能家居网关家庭设备被控

三、方案:只给暴露面打 tag

核心只有一句:打了 tag 的节点不再属于"你的设备"这个 autogroup,于是它自动掉出那条全放行规则;而没打 tag 的家内设备什么都不用动,靠"默认拒绝"被保护。

最终策略只有三条规则:

{
  "tagOwners": { "tag:vps": ["autogroup:admin"] },
  "grants": [
    { "src": ["autogroup:member"], "dst": ["autogroup:self"], "ip": ["*"] },
    { "src": ["autogroup:member"], "dst": ["tag:vps"],        "ip": ["*"] },
    { "src": ["tag:vps"],          "dst": ["tag:vps"],        "ip": ["tcp:22", "icmp:*"] }
  ]
}

逐条看:

  1. 你的设备之间互通 —— 手机看面板、笔记本远程桌面,全在这个 autogroup 里,日常体验零变化;
  2. 你 → VPS 全端口 —— 从 VPS 拉中转下载的文件也走这条;
  3. VPS ↔ VPS —— 只留 SSH 互救和 ICMP。

刻意没有的那一条才是重点:tag:vps 到任何家内设备。默认拒绝。

两个细节值得单独记:

  • ICMP 必须留。只放 TCP 会让 tailscale ping 全线失败,之后的排障就只剩猜。
  • tailscale ssh 也吃 ACL。VPS 打了 tag 就会掉出 member → self,得在 ssh 段把 tag:vps 补进 dst —— 否则某天你会忽然发现 SSH 进不去,还以为是密钥坏了。

四、用 API 改,而不是在后台点

在网页上点保存当然更快,但改之前你要有一份原文、改完要能一条命令回去。后台的保存按钮不提供这两样。

先备份。Accept 头决定拿回来的是 huJSON 原文,而不是被规范化过的 JSON:

TOK=$(cat /path/to/token)     # 后台 Keys → API access tokens
curl -sS -u "$TOK:" -H 'Accept: application/hujson' \
  https://api.tailscale.com/api/v2/tailnet/-/acl > policy.backup.hujson

然后是四步:只验证 → 正式写入 → 给设备打 tag → 带上自检再写一次。

# 1. 只验证,不生效(返回 {} 即通过)
curl -sS -u "$TOK:" -H 'Content-Type: application/hujson' -X POST \
  --data-binary @policy.hujson \
  https://api.tailscale.com/api/v2/tailnet/-/acl/validate

# 2. 正式写入
curl -sS -u "$TOK:" -H 'Content-Type: application/hujson' -X POST \
  --data-binary @policy.hujson \
  https://api.tailscale.com/api/v2/tailnet/-/acl

# 3. 给某台设备打 tag(设备 ID 从 /tailnet/-/devices 拿)
curl -sS -u "$TOK:" -H 'Content-Type: application/json' -X POST \
  -d '{"tags":["tag:vps"]}' \
  https://api.tailscale.com/api/v2/device/<设备ID>/tags

五、四个坑,一个没落

1. 状态码 200 不代表成功

/acl 和 /acl/validate 的 body 是策略文件原文(application/hujson),不是 {"acl": "..."}。包一层会拿到 {"message":"json: unknown field \"acl\""} —— HTTP 状态码依然是 200。排错时先看 body,别信状态码。

2. tag 和自检互相依赖

策略里还没有 tagOwners 时给设备打 tag,返回的是 requested tags [...] are invalid or not permitted;而自检里引用 tag:vps,又要求名册上已经有节点带着这个 tag,否则判定为 want: Accept, got: Drop,保存直接被拒。

顺序只能是:先发"有 tagOwners、没有自检"的版本 → 打 tag → 再发带自检的版本。想一步到位,会被这两道闸门夹在中间。

3. 自检里的 src 不能写 autogroup

"src": "autogroup:member" 会报 unknown user or host,得换成真实账号;accept / deny 里用 IP:端口 最稳。

4. 第一版方案其实是多余的

我最早想给家里的设备也打一组 tag:home,写完才发现完全没必要:未打 tag 的设备本来就落在你自己的 autogroup 里,默认拒绝已经保护它们。多打一组 tag,只是多一组未来会漂移、会在重建容器时丢失的状态。

能靠"没有规则"解决的问题,别用规则解决。

六、验证:让策略引擎自己回答

自检(tests)不是注释。保存时它会真的跑一遍断言,不通过就不允许入库。

所以最好的验证不是"我去 ping 一下试试",而是让控制面替我回答:

"tests": [
  {
    "src": "<你的账号>",
    "accept": ["<自用设备>:22", "<台式机>:3389"]
  },
  {
    "src": "tag:vps",
    "accept": ["<另一台VPS>:22"],
    "deny": [
      "<家内NAS>:22",
      "<家内NAS>:<面板端口>",
      "<台式机>:3389",
      "<台式机>:445"
    ]
  }
]

保存返回 {} 的那一刻,引擎已经确认了两件事:你自己的设备还能到家里;而 tag 里的节点到 NAS 面板、到台式机 RDP 与 SMB,判定全是 Drop。

这比端到端探测更可靠——探测会受网络、防火墙、服务是否在听的影响,而这里是策略引擎对规则本身的判决。

七、两个顺带的发现

  • 名册上没有任何节点宣告 0.0.0.0/0,也就是说"别把 VPS 当 exit node"这条天然满足。不是因为我谨慎,是因为从没配过——这两件事的结局常常一样,性质完全不同。
  • 名册上还挂着一台两年前建的节点,最后一次上线停在当年秋天。僵尸身份不会自己消失,它只是安静地躺在全放行名单里。

八、留在最后

安全这件事,最贵的从来不是配置,是"我以为配过"。

默认值是延后支付的账单:平时看不见,出事那天一次性结清。而"没配过"这三个字,在权限系统里从来不是空白的含义——它是一份策略,只是写的人不是你。

2026 年 11 月,于一间深夜还亮着灯的屋子。全部操作由 Hermes Agent 辅助完成。