给 nl2sh 接上一条“猫尾巴”Tailcat:Android 端侧 Agent 终于会自己“打洞”了
nl2sh + Tailcat 实战:跨网暴露端口、临时传文件、把远端服务映射成本机
127.0.0.1。
有些 Android 调试问题,难点根本不在 Android。
难点在一句很朴素的话:
“你能不能先连到这台设备?”
设备在公司内网、实验室 Wi-Fi、客户现场、路由器后面、电视机后面,甚至压根没有一个你能直接访问的公网 IP。
你明明已经知道:
- Web 调试页开在
9999 - 某个本地服务开在
8080 - ADB TCP 在
5555 - 日志文件就在
/data/local/tmp/xxx.log
但你人在另一张网。
于是调试流程突然从“查问题”,变成了“网络工程师模拟考试”:
配 VPN?
开端口?
做 NAT?
让对方装 Tailscale?
申请账号?
配 ACL?
SSH 反向隧道?-L、-R到底谁在前谁在后?
为什么昨天还能连今天又不行?
一个本来五分钟能定位的问题,先花半小时证明“我们俩确实不在一个局域网”。
现在,nl2sh 新加入了 Tailcat 工具组。
这件事乍看只是“多了几个网络工具”。
实际体验更像是:
原来 nl2sh 只是 Android 设备上的一双手。
现在这双手突然能从 NAT 后面伸出来了。
先说 Tailcat 是什么:可以把它理解成“会打洞的 netcat”
Tailcat 是 Tailscale 开源的一个项目。
它自己的定位很直接:
像 netcat,但跑在 Tailscale 的数据平面上,不依赖 Tailscale 的控制平面。
翻译成人话:
普通 nc 的世界观大概是:
你知道我的 IP
+
你能访问我的端口
=
我们可以通信
现实世界通常是:
公司 NAT
家庭路由器
移动网络
防火墙
多层内网
↓
“你知道 IP 也没什么用”
Tailcat 换了一种玩法。
服务端启动后,生成一串类似这样的 Tailcat Address:
tcxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...
把这串地址通过微信、IM、工单、邮件,或者别的可信渠道交给另一端。
另一端拿着地址连接。
底层使用的是 Tailscale 数据平面的能力:
Tailcat Address
│
▼
DERP 引导建立连接
│
▼
尝试 NAT Traversal
│
├── 能直连:WireGuard 点对点
│
└── 不能直连:DERP 中继
流量本身使用 WireGuard 端到端加密。
重点是,它不要求你先建立一个完整的 Tailscale 网络,也不要求双方先加入同一个 Tailnet。
所以我更喜欢把它理解成:
一根临时的、加密的、会自动找路的网络延长线。
而不是“又一套 VPN”。
Tailcat v0.7.0 对 Android 也更友好了:官方 Linux 二进制可以直接运行在 Android / adb shell / rooted shell 等环境,并补上了 Android DNS、系统 CA 和网络接口探测方面的适配。
这也是它和 nl2sh 组合起来很有意思的地方。
nl2sh 加的不是一个 tailcat 命令,而是一组 Agent 工具
如果只是让大模型执行:
tailcat ...
其实没什么值得单独写一篇文章。
真正有意思的是,nl2sh 没有把 Tailcat 当成“随便执行的 shell 命令”,而是把常用能力拆成了结构化工具。
当前一共 7 个:
| 工具 | 用途 | 风险级别 |
|---|---|---|
tailcat_check |
检查 Tailcat 是否存在、查看版本 | ReadOnly |
tailcat_receive_stream |
接收一次原始数据流,写入一个新文件 | Mutating |
tailcat_receive |
在指定目录启动文件接收箱 | Mutating |
tailcat_send_file |
把本地文件发送到 Tailcat 地址 | Dangerous |
tailcat_serve |
暴露一个本机 TCP 端口 | Dangerous |
tailcat_status |
查看当前 nl2sh 管理的 Tailcat 监听器 | ReadOnly |
tailcat_stop |
停止当前 Tailcat 监听器 | Mutating |
这背后其实体现了一个很重要的思路:
Agent 不应该只会“执行命令”,还应该知道自己正在做什么。
比如:
“查版本”和“把 root 控制端口暴露到远端”,显然不是一回事。
如果它们最后都只是:
execute_shell_command(...)
那安全系统看到的只是一串字符。
而结构化工具知道:
这是查版本
这是接收文件
这是向外发送文件
这是开放本地服务
于是 nl2sh 可以在执行前采用不同级别的确认策略。
真实使用:我让它共享 9999,它先把我拦下来了
这次测试里,我先问:
检查 tailcat 是否可用,查看版本。
nl2sh 调用了:
tailcat_check
返回:
binary=/data/local/tmp/tailcat
v0.7.0
到这里平平无奇。
真正有意思的是下一段。
我说:
请帮我共享本地端口;先说明需要我提供哪些具体信息。
它先确认:
tailcat_status
当前没有监听器。
然后问我需要共享哪个端口。
我回复:
9999
按照“工具包装器”的思路,这时候最简单的实现就是:
tailcat_serve(port=9999)
完事。
但 nl2sh 没这么干。
它先查了一遍本机监听:
0.0.0.0:9999 LISTEN 23208/nl2sh
继续检查进程:
/data/local/tmp/nl2sh
UID 0
然后告诉我:
9999 不是普通业务端口。
它正是当前 nl2sh 自己的控制通道,而且以 root 身份运行。
如果共享这个端口,本质上就是把这台设备的 Agent 控制入口暴露出去。
所以它没有直接执行。
而是停下来要求我明确确认。
我回复:
确认暴露 9999
这时候才真正执行:
tailcat_serve
并返回一串 Tailcat Address。
这里我就不把真实地址放出来了。
原因后面讲。
这一小段,比“成功执行 tailcat”重要得多
这正好解释了我为什么越来越喜欢 端侧 Agent + 结构化 Tool 这种组合。
传统脚本的逻辑是:
你让我共享 9999
→ 我共享 9999
更理想的 Agent 应该是:
你让我共享 9999
│
▼
先确认 9999 是什么
│
▼
发现这是 root nl2sh 控制通道
│
▼
风险语义发生变化
│
▼
要求用户再次明确确认
│
▼
才执行
注意,这不是“AI 替你做决定”。
恰恰相反。
它是先把原本隐藏在命令背后的事实翻出来:
你共享的不是一个数字 9999,你共享的是 9999 后面的那个东西。
然后把决定重新交给你。
这才是 Agent 比一堆 shell alias 更有价值的地方。
对端怎么用?一条命令把远端 9999 变成本机 127.0.0.1:9999
共享完成后,我又问:
我想让对端访问
127.0.0.1:9999,就相当于访问这台设备的 9999,怎么做?
nl2sh 没有凭印象回答。
它现场查看了设备上 Tailcat v0.7.0 的帮助信息,确认 forward 的语义后,给出了:
tailcat forward <tc-addr> 9999
Tailcat 的 forward 默认绑定:
127.0.0.1
所以最终链路就是:
对端程序
│
▼
127.0.0.1:9999
│
▼
tailcat forward
│
▼
WireGuard / NAT Traversal / DERP
│
▼
Android 设备
│
▼
localhost:9999
于是对端的软件根本不需要知道:
- Android 设备真实 IP 是多少
- 两边是不是同一个局域网
- 中间有几层 NAT
- 服务到底在哪个 Wi-Fi
- 有没有公网 IPv4
- 有没有 IPv6
对它来说,服务就在:
127.0.0.1:9999
这就很舒服了。
如果本地 9999 已经被占用:
tailcat forward <tc-addr> 18099:9999
那么:
对端 127.0.0.1:18099
↓
Android 设备 localhost:9999
你甚至可以把一个“远在天边”的 Android 本地调试页,伪装成电脑上的本地服务。
这对 nl2sh 意味着什么?
以前 nl2sh 解决的是:
我已经在 Android 设备上了,接下来怎么查?
例如:
- 看应用进程
- 查前台 Activity
- 查网络
- 看 logcat
- 分析 ANR
- 看 tombstone
- 检查 APK
- 分析音频
- 看 UI 树
- 截图
- 执行受控 shell
加入 Tailcat 以后,它开始能解决另一类问题:
设备上的东西,怎么安全、临时地伸到另一台机器?
两者一结合,使用方式一下就多了。
场景一:把 Android 上的 Web 调试页临时给远端访问
比如某个设备上跑着:
127.0.0.1:8080
以前常见做法:
adb forward
ssh -L
ssh -R
VPN
frp
公网反代
路由器端口映射
现在可以直接告诉 nl2sh:
帮我共享本机 8080 端口,先检查监听进程和风险,再执行。
它完成检查后启动 tailcat_serve。
对端:
tailcat forward <tc-addr> 8080
然后浏览器打开:
http://127.0.0.1:8080
结束后再告诉 nl2sh:
停止 Tailcat 共享。
对应:
tailcat_stop
整个过程非常适合:
- 临时调试
- 客户现场问题
- 测试机 Web 页面
- 内部诊断接口
- 一次性的远程协作
场景二:把文件直接塞进 Android,不搭 HTTP Server
假设电脑上有:
bugreport.zip
想传进 Android。
你可以在 nl2sh 里说:
用 Tailcat 接收一次原始数据流,保存到
/sdcard/Download/bugreport.zip。
nl2sh 会调用:
tailcat_receive_stream
它只允许写入一个不存在的新文件。
然后返回一个 Tailcat Address。
电脑上:
cat bugreport.zip | tailcat <tc-addr>
文件就过去了。
不需要:
python -m http.server
不需要临时找一台 HTTP Server。
不需要问:
“你这台设备能访问我电脑的 192.168.x.x 吗?”
更不需要经历:
“等等,你的 192.168.x.x 是哪个网卡?”
场景三:做一个临时“文件投递箱”
如果不是只传一个原始流,而是希望连续投递文件,可以让 nl2sh 在一个已经存在的目录启动接收箱:
在
/sdcard/Download/inbox启动 Tailcat 文件接收。
对应:
tailcat_receive
另一端使用:
tailcat cp report.pdf <tc-addr>:
这种模式底层走 Tailcat 的文件服务。
nl2sh 当前的 copy 发送模式依赖发送端系统存在 scp。
这点对 Android 尤其要注意:
很多
adb shell环境并没有scp。
因此在 Android 设备上,单文件临时传输时我反而更推荐 stream 模式。
少一层依赖,简单粗暴。
有时候,简单粗暴就是工程美学。
场景四:把 Android 本地服务“搬”到电脑 localhost
这是我认为日常最实用的一种。
假设电视、车机、盒子、手机里跑了一个服务:
localhost:9999
你在设备上:
共享 9999。
nl2sh 返回:
<tc-addr>
电脑:
tailcat forward <tc-addr> 9999
以后你电脑上的任何软件都继续访问:
127.0.0.1:9999
甚至原来的工具都不用改配置。
例如:
浏览器
curl
Postman
调试器
自研客户端
它们根本不知道中间多了一条 WireGuard 隧道。
场景五:远程 ADB——能做,但先想清楚你在暴露什么
Tailcat 官方自己的例子里就有 ADB:
服务端把 5555 暴露出来后,对端:
tailcat forward <tc-addr> 5555
adb connect 127.0.0.1:5555
从网络技术角度完全成立。
但放到 nl2sh 的场景里,我反而建议更谨慎。
因为:
“暴露 5555”
并不是一个单纯的网络动作。
它实际意味着:
暴露 Android ADB 能力
是否安全,取决于:
- ADB 当前认证状态
- 设备构建类型
adbd权限- 远端是谁
- 这条地址是否泄漏
- 是否只在临时调试时使用
这和前面 9999 的例子其实是同一件事。
不要只看端口号,要看端口后面是什么。
Tailcat Address 不是普通 URL,最好把它当“临时钥匙”
Tailcat Address 长得很像一串“奇怪 URL”。
但更合理的理解是:
它是连接信息,也是能力凭据的一部分。
Tailcat 当前默认会在地址中带入 WireGuard 预共享密钥信息。
所以文章截图、日志、群聊、Issue 里不要随手贴完整地址。
这次真实会话里生成的地址,我在本文全部写成:
<tc-addr>
如果把它原样放进公众号文章,然后再配一句:
“大家可以试试。”
那文章可能会突然从技术分享升级成线上事故复盘。
nl2sh 为 Tailcat 做了哪些“看不见,但很重要”的限制?
这部分我觉得比命令列表更值得看。
1. Tailcat 工具组默认关闭
最新实现中:
[tool_groups]
tailcat = false
默认不会把 Tailcat 工具直接暴露给模型。
在 Web 的“能做什么”页面里,可以按组打开,也可以只启用某几个工具。
例如你只想:
检查
状态
停止
完全没必要把“发送文件”和“开放端口”一起打开。
也可以在配置里做单工具覆盖:
[tool_overrides]
tailcat_check = true
tailcat_status = true
tailcat_serve = false
这是我很认可的一点:
能力不是“代码里有了,模型就全能用”。
工具存在和工具授权,是两回事。
2. 发送文件、暴露端口都属于高风险操作
nl2sh 对 Tailcat 做了单独的风险分类。
类似:
version / help / ping / ls
→ ReadOnly
recv
→ Mutating
发送数据 / 开放端口
→ Dangerous
因此即使模型退回去直接调用 shell:
tailcat ...
也不会因为“没有走结构化工具”就绕过风险等级。
换句话说:
关掉 Tailcat Tool,不等于 shell 里的 Tailcat 突然变成无风险命令。
这是正确的安全边界。
3. 接收文件不会随便覆盖已有文件
tailcat_receive_stream 要求目标:
- 有合法父目录
- 是一个新文件
- 文件已存在就拒绝
也就是不会因为一句:
“接到
/data/important.conf”
直接把已有文件冲掉。
4. nl2sh 进程退出,监听器跟着结束
Tailcat 监听不是“启动以后就扔在后台不管”。
nl2sh 会管理当前进程启动的 listener。
实现里使用临时 key:
--key=new
并且在 Linux / Android 上给子进程设置父进程退出信号。
效果可以简单理解为:
nl2sh 活着
│
└── Tailcat listener 活着
nl2sh 退出
│
└── listener 一起结束
这非常适合临时调试。
否则最怕的情况就是:
三个月后有人问:
“这台电视上为什么有个不知道谁开的隧道?”
然后所有人开始考古。
5. 当前进程只管理一个 Tailcat listener
如果已经有一个接收器或端口共享在跑,再启动新的,会要求先停止旧的。
这看起来“不够强大”。
但对 Agent 来说,我觉得反而是好事。
因为:
一个任务
一个明确的地址
一个生命周期
一个停止动作
比同时在后台挂七八个隧道,更容易让人理解当前到底发生了什么。
Agent 系统里,可理解性也是安全能力的一部分。
为什么没有把 Tailcat 所有能力全做成 Tool?
Tailcat 本身的能力远不止这些。
它还支持:
sshsocksexit-node- 多端口
- 文件服务
pingforward- 自定义 DERP
- 更复杂的端口映射
但 nl2sh 当前结构化工具故意只选了几种非常明确的场景:
检查
收文件
发文件
共享一个 localhost TCP 端口
看状态
停止
这其实是一个很好的 Agent Tool 设计原则:
不是底层 CLI 有多少参数,就给模型暴露多少参数。
CLI 是给人用的。
Tool 是给 Agent 用的。
Agent Tool 更应该:
- 参数少
- 语义窄
- 风险明确
- 容易预览
- 容易确认
- 容易回收
真需要 Tailcat 高级功能时,nl2sh 仍然可以通过 shell 使用。
只是这时候会继续经过自己的命令安全分类。
怎么开启?
首先,设备上需要有可信的 Tailcat 二进制。
Android 默认路径:
/data/local/tmp/tailcat
配置:
tailcat_binary_path = "/data/local/tmp/tailcat"
然后开启工具组:
[tool_groups]
tailcat = true
也可以直接在 Web 的“能做什么”页面打开 Tailcat。
建议第一句话先问:
检查 Tailcat 是否可用并告诉我版本。
理想结果:
binary=/data/local/tmp/tailcat
v0.7.0
然后再开始真正的网络动作。
我日常会怎么用?
如果是我,我不会记七个 Tool 名字。
我会直接跟 nl2sh 说人话。
临时开放 Web 页面
检查 8080 当前是谁在监听。如果是我预期的服务,就通过 Tailcat 暴露,执行前把风险告诉我。
给 Android 传一个文件
用 Tailcat 接收一次原始文件流,保存到
/sdcard/Download/test.apk,不要覆盖已有文件。
从 Android 发日志
把
/data/local/tmp/error.log发到这个 Tailcat 地址,优先用不依赖 scp 的方式。
查看当前有没有忘记关的隧道
看一下 Tailcat 当前状态。
用完关掉
停止 Tailcat listener。
这就是 Agent 的意义:
不是把:
tailcat serve
tailcat forward
tailcat recv
tailcat cp
全背下来。
而是告诉它:
我想实现什么。
它再根据设备当前状态选择工具。
nl2sh + Tailcat 最适合的,并不是“长期组网”
如果你有十几台机器,需要:
- 长期在线
- 稳定设备身份
- ACL
- DNS
- 子网路由
- 设备管理
- 长期访问控制
那完整的 Tailscale、WireGuard、企业 VPN 等方案更适合。
Tailcat 的优势反而是:
临时、点对点、低配置成本。
例如:
“这个问题就今天查一下”
“我只想临时看一个 8080”
“给我传一个 200MB 的 bugreport”
“让远端工具暂时认为设备服务就在 localhost”
这种场景里,如果先搭一套完整网络,多少有点像:
为了借邻居一把螺丝刀,先成立一个物业公司。
Tailcat 就没那么大阵仗。
更重要的是:端侧 Agent 开始获得“网络行动能力”
我之前一直把 nl2sh 定义成:
Android 端侧运行的 Agent Harness。
它最大的特点不是聊天。
而是模型就在设备旁边:
LLM
│
▼
nl2sh Agent
│
├── Android 状态
├── shell
├── 文件
├── UI
├── APK
├── 音频
└── 网络诊断
Tailcat 加进来以后,这张图变成:
┌───────────────┐
│ 远端电脑 │
│ browser/curl │
│ adb/debugger │
└───────┬───────┘
│
Tailcat Address
│
WireGuard / DERP / P2P
│
┌───────────────────────────▼───────────────────────────┐
│ Android Device │
│ │
│ LLM → nl2sh Agent → Tool → 本地文件 / 端口 / 系统能力 │
│ │
└───────────────────────────────────────────────────────┘
以前 Agent 的“手脚”只在本机。
现在多了一条可以按需伸出去的“猫尾巴”。
这才是这次更新真正让我觉得有意思的地方。
最后
nl2sh 还在持续迭代。
项目地址:
如果你平时也经常在 Android、电视、车机、盒子、IoT 设备上:
adb shell- 查日志
- 查网络
- 看进程
- 分析系统状态
- 临时远程调试
可以试试 nl2sh。
觉得项目有点意思,欢迎点个 Star。
如果你也在折腾端侧 Agent、Android 系统调试、远程设备诊断,欢迎关注公众号,也欢迎私信交流你实际遇到的奇怪问题。
毕竟工程师最不缺的,就是:
“理论上应该可以,实际上就是不通。”
而这类问题,刚好最适合拿来喂 Agent。
参考资料
- nl2sh:https://github.com/nl2sh/nl2sh
- Tailcat:https://github.com/tailscale/tailcat
- Tailcat README:https://github.com/tailscale/tailcat/blob/main/README.md
- Tailcat Changelog:https://github.com/tailscale/tailcat/blob/main/CHANGELOG.md
