教程八:让一台机器完全控制另一台
从零搭一条 agent → 远程 root shell 的通路,并证明它挡得住不该进来的人
这一页做什么
两台机器:一台你坐在前面(下称控制端),一台在别处(下称被控端)。 做完这一页,你可以从控制端在被控端上跑任意命令,以 root 身份,单条最长 24 小时, 而且只有你那一个身份能这么做 —— 别人拿到它的地址也进不来。
这与教程四是两种用法。那一页配的是一张
具名命令表:shell.run@restart-app、shell.run@flash,名字进 hub 目录,参数受限,
适合交给别人或交给一个自动化流程。这一页开的是 shell.exec —— 一个远程 root shell,
适合"这是我自己的机器,我要完全控制它"。两者可以同时开,后面会说怎么从后者退回前者。
先说清楚这一页的终点是什么。 做完之后,控制端的私钥就是被控端的 root 密码。
~/.anet/identity.kel 泄漏 = 那台机器沦陷。这不是配置得当就能规避的性质,是这个用法
本身的定义 —— 你把一台机器的完全控制权,绑定到了另一台机器上的一份密钥。
如果你要的是"让同事/CI 触发几个固定操作",别用这一页,用教程四。
本页所有回显都是在一块真实的 RK3588 板子(Firefly AIBOX-PRO-3588JD4,Debian,aarch64) 上跑出来的,不是示意。
先选 hub
两台机器要能互相找到,得有一个 hub 居中中继。二选一:
| 用公开 hub | 自己跑一个 | |
|---|---|---|
| 要做什么 | 什么都不用做 | 一条命令起一个进程 |
| 被控端会被谁看见 | 目录公开,任何人可见它提供 shell.exec | 只有能连到你这个 hub 的人 |
| 谁能往它信箱投递 | 任何人(会被名单拒绝) | 能连到你 hub 的人 |
| 适合 | 先跑通、机器本来就在公网 | 内网、不希望公开这台机器的存在 |
hub 不决定谁能执行命令。 它只决定谁能找到你、谁能把消息送到你门口。
执行授权完全由被控端的名单决定 —— 这一点两种选择完全一样。所以先用公开 hub 跑通,
之后想换只是改一个 hub_url,机制不变。
选项 B · 自己跑一个
在一台两边都连得到的机器上(可以就是控制端):
下面的 hub 地址就写 http://<这台机器的地址>:8088。
要进一步限制谁能注册,打开邀请制:
注册时带上 --token <码>。详见教程六。
动手
顺序有讲究:先装控制端。被控端的名单要写控制端的 AID,而空名单拒绝所有人 —— 反过来做的话,被控端装完就是一台谁也调不动的机器,你还得回头再编辑一次。
控制端:装、起、接入 hub
控制端只派活,装默认版就行,别装 shell 变体 —— 它不需要被别人执行命令, 装了就是白白多开一个面。
accept off 不是必须的,但控制端没有理由接受别人的委派。随时可以 anet accept on 改回来。
记下它的 AID,下一步要用:
AID 跟着数据目录走。 它是从 ~/.anet/identity.kel 里的密钥历史自证出来的地址,
不是随机分配的名字。换数据目录 = 换身份 = 名单失效。所以这个目录别删、别搬、
也别在两台机器之间复制。
被控端:装 shell 变体
两个标志各有原因:
--shell换的是另一个二进制。默认版里根本没有链接执行命令的代码,不是"有但关着"。--system装到/usr/local/bin。~/.local/bin不在非交互 ssh 的 PATH 里, 以后你从别的地方 ssh 进来会找不到anet。
自己核对装到的确实是它:
modules: 那行是从二进制里实际链接进来的模块注册表读出来的,不是构建时刻进去的字符串。
没有 shell 就是装错了变体,后面全都不用做。
版本要 0.1.9 或更新。 0.1.8 及更早的 daemon 会在 60 秒无条件杀掉任何命令,
不管你把 timeout_s 配成多少 —— 而唯一的迹象是一条没有解释的 FAILED。
被控端:写名单
名单是这台机器唯一的授权边界。里面只放控制端那一个 AID。
用 allow_file 而不是配置里的 allow 数组:名单每次调用前重读,
所以撤权不用重启。删掉这个文件等于一次性撤销所有人,而且按空名单处理,不是报错。
文件属主要和 daemon 跑的用户一致。 root 建的 0600 文件对非 root 的 daemon
读不了,而这不表现为"调用被拒",表现为 daemon 起不来:模块启动时先读一遍名单,
读不了就退出,报 shell: allow_file: … permission denied。
被控端:写配置
四个字段:
| 字段 | 这里的值 | 说明 |
|---|---|---|
allow_arbitrary | true | 开放 shell.exec,执行调用方送来的任意命令 |
allow_file | 路径 | 名单,每次调用前重读 |
timeout_s | 7200 | 单条命令上限两小时,可到 86400(24 小时) |
max_output_bytes | 4194304 | 回显上限 4 MB,超出截断 |
不写 commands。校验只要求"要么有具名命令,要么开了 allow_arbitrary",两者不必兼有。
路径取决于你用哪个用户装的。 安装脚本在跑它的那个用户家目录下建 ~/.anet。
上面按 root 写;不是 root 装的就改成那个用户的路径,并且让 daemon 用同一个用户跑 ——
不一致会建出第二个身份,AID 就不是你写进名单的那个了。
验证:四条,缺一不可
回到控制端。前三条验"能用",第四条验"授权对" —— 只跑前三条,你证明的是网络通, 不是配置对。
一 · 目录里找得到
--cap 和 --capability 都收(0.1.9 起;之前 find 只认前者、delegate 只认后者)。
二 · 能跑,而且是 root
uid=0(root) 说明 daemon 以 root 跑,所以命令也是 root。如果这里不是 0,
说明 daemon 跑在普通用户下 —— 见以谁的身份跑。
三 · 长任务
这一条要单独验,因为失败模式是"它回来了,只是不对":被超时杀掉的命令也会返回,
带 FAILED,只看状态区分不出来。所以要计时。
首尾时间戳相差正好 90 秒 —— 命令真的跑完了,不是被截断后凑出来的。 同一条命令在 0.1.8 上会在 60 秒被杀。
长任务不会把节点堵住。 声明了长时长的调用在自己的 goroutine 里跑,
被控端在那 90 秒里照常收信、照常应答别的调用。同时在跑的长任务上限是 4 个,
第 5 个会立刻收到 UNAVAILABLE 并说明原因,不排队 —— 排在看不见的队里,
派活方分不清"忙"和"死"。
四 · 名单外的必须被拒
这条最重要。用一个真的另起的身份、真的注册到同一个 hub 的调用方去打, 而不是读代码确认。
两件事都要看:状态是 UNAVAILABLE,并且回显是空的 —— 命令根本没有执行。
只看状态不够,一个"跑了但报错"的实现也会给你一个非 OK 的状态。
拒绝时点名了调用方的 AID,而不是含糊其辞。理由是 hub 目录已经告诉过他这个能力存在, 再装作不存在没有意义 —— 他该知道的是"你不在名单上",而不是"这里好像没这个东西"。
验完清理掉:
日常用法
参数只认 command 和 dir 两个键,给别的会被拒绝而不是忽略 ——
一个被静默忽略的参数,是调用方以为生效了而实际没有的那种错误。
以谁的身份跑
命令以 daemon 进程的用户执行,没有别的机制。三种做法:
| 做法 | 得到什么 | 代价 | |
|---|---|---|---|
| A | daemon 跑普通用户 | 那个用户的权限 | 要 root 的操作做不了 |
| B | daemon 跑 root | 完全控制 | 这台机器上所有模块都是 root |
| C | 普通用户 + 窄 sudoers | 只有列出的操作能提权 | 配置最多,但 shell.exec 下意义有限 |
本页用的是 B。用 shell.exec 时 C 的意义不大 —— 调用方能送任意命令,
窄 sudoers 只是让他多打几个字。要收窄就该回到具名命令表,而不是在这里做半套。
想让 daemon 跑 root:用 root 装、用 root 起。ANET_DATA_DIR 与安装时一致。
撤权
三种粒度,都不需要重启被控端:
前两种下一次调用即生效。想彻底关掉这个模块,把 modules.shell 从 config.json 删掉再重启;
想连代码都不留,重装默认版二进制。
从"完全控制"退回"一张清单"
allow_arbitrary 和具名命令表可以同时开。摸清楚常用的是哪几条之后:
allow_arbitrary 关掉之后,shell.exec 既不出现在能力卡上,也不接受按名调用 ——
不是"隐藏了但还能用"。名单和身份都不用动。
注意 timeout_s 按命令给,而不是全局调大:全局 7200 会让一条卡住的 health
也挂两个小时。
会咬人的地方
一 · 身份跟着数据目录走。 systemd 单元里的 User= 和 ANET_DATA_DIR=
必须和你安装时用的一致,否则 daemon 会在另一个目录里建第二个身份,
AID 不同 —— 你写进名单、发去调用的那个 AID 就不再是正在跑的那个。
二 · 名单文件读不了 = daemon 起不来,不是"调用被拒"。模块启动时先读一遍。
三 · 公开 hub 上,这台机器是公开可见的。 它会列在目录里,标着提供 shell.exec。
/relay/send 按设计不鉴权,所以任何人都能往它信箱投递 —— 会被名单挡下并在链上留一条
anet.shell.refused,但"这台机器接受任意远程命令"这件事是公开的。不想公开就自建 hub。
四 · 回显会落到被控端的证据链上。 命令打印的任何内容(可能包括日志里的敏感串)
会留在 ~/.anet/evidence.ael.jsonl。这不是 shell 模块写的,是 daemon 交付能力结果时写的。
五 · 私钥就是钥匙。 控制端的 ~/.anet/identity.kel 泄漏 = 被控端 root 泄漏。
别让那个目录进备份、进 git、进容器镜像。
排查
| 现象 | 原因 |
|---|---|
anet version 的 modules: 里没有 shell | 装的是默认版,重装时加 --shell |
daemon 起不来,报 allow_file: permission denied | 名单文件属主/权限与 daemon 用户不符 |
调用回 UNAVAILABLE: this node does not accept commands from … | 调用方 AID 不在名单里,或名单里写的是另一个身份的 AID |
调用回 UNAVAILABLE: this node does not serve shell.exec | allow_arbitrary 没开,或改了配置没重启 |
调用回 FAILED,消息含 killed after | 超时。调 timeout_s,上限 86400 |
| 长命令在 60 秒被杀 | 被控端是 0.1.9 之前的版本 |
anet find --cap 报 unknown flag | 0.1.9 之前 find 只认 --cap、delegate 只认 --capability |
| 目录里找不到被控端 | hub-register 之后能力才上报;anet status 看 caps |
这一页背后验过什么
每一条回显都来自一次真实执行,被控端是一块 Firefly AIBOX-PRO-3588JD4(RK3588, Debian,aarch64,内核 6.1.118),控制端与它经公开 hub 通信:
id; uname -a→uid=0(root),确认 root 且跨机可达sleep 90→ 用时 92 秒,首尾时间戳相差 90 秒,确认超时按配置生效- 另起身份调用 →
UNAVAILABLE且回显为空,确认名单是真边界
同一条路径在 scripts/joint-shell.sh 里有 22 项自动化覆盖,每次提交都跑,
其中第 8 节就是一条真的 sleep 75 的跨机命令。