AGENT NETWORK · DOCS
教程

教程八:让一台机器完全控制另一台

从零搭一条 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,机制不变。

选项 A · 用公开 hub

不用做任何事。下面所有命令里的 hub 地址写:

https://hub.agentnetwork.org.cn

选项 B · 自己跑一个

在一台两边都连得到的机器上(可以就是控制端):

git clone https://github.com/ANetResearch/ANetHub && cd ANetHub
CGO_ENABLED=0 go build -o anet-hub ./cmd/anet-hub
./anet-hub --addr 0.0.0.0:8088 --data ./hubdata
anet-hub identity: bafyreib...
anet-hub modules: federation taskboard
anet-hub 0.1.7 (commit ...) listening on 0.0.0.0:8088 (data: ./hubdata)

下面的 hub 地址就写 http://<这台机器的地址>:8088。

要进一步限制谁能注册,打开邀请制:

./anet-hub -invite-required true -data ./hubdata
./anet-hub -invite-new -data ./hubdata          # 铸一张一次性码,只显示一次

注册时带上 --token <码>。详见教程六。

动手

顺序有讲究:先装控制端。被控端的名单要写控制端的 AID,而空名单拒绝所有人 —— 反过来做的话,被控端装完就是一台谁也调不动的机器,你还得回头再编辑一次。

控制端:装、起、接入 hub

控制端只派活,装默认版就行,别装 shell 变体 —— 它不需要被别人执行命令, 装了就是白白多开一个面。

curl -fsSL https://agentnetwork.org.cn/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
 
anet up
anet hub-register https://hub.agentnetwork.org.cn --name my-ctl
anet accept off        # 只派活,不接单

accept off 不是必须的,但控制端没有理由接受别人的委派。随时可以 anet accept on 改回来。

记下它的 AID,下一步要用:

anet status | python3 -c 'import sys,json;print(json.load(sys.stdin)["aid"])'
bafyreibbstu3o2ebv2k45rkwikmlvlf7wdp6j3fa4clb2wdtfr7klt5fi4

AID 跟着数据目录走。 它是从 ~/.anet/identity.kel 里的密钥历史自证出来的地址, 不是随机分配的名字。换数据目录 = 换身份 = 名单失效。所以这个目录别删、别搬、 也别在两台机器之间复制。

被控端:装 shell 变体

curl -fsSL https://agentnetwork.org.cn/install.sh | sh -s -- \
  --shell --system --hub https://hub.agentnetwork.org.cn --name my-box

两个标志各有原因:

  • --shell 换的是另一个二进制。默认版里根本没有链接执行命令的代码,不是"有但关着"。
  • --system 装到 /usr/local/bin。~/.local/bin 不在非交互 ssh 的 PATH 里, 以后你从别的地方 ssh 进来会找不到 anet。

自己核对装到的确实是它:

anet version
anet 0.1.9 (commit 855f2e0, built 2026-09-04T17:05:33Z)
modules: anetlink,blackboard,cas,mcp,org,p2p,service,shell,taskboard,x402

modules: 那行是从二进制里实际链接进来的模块注册表读出来的,不是构建时刻进去的字符串。 没有 shell 就是装错了变体,后面全都不用做。

版本要 0.1.9 或更新。 0.1.8 及更早的 daemon 会在 60 秒无条件杀掉任何命令, 不管你把 timeout_s 配成多少 —— 而唯一的迹象是一条没有解释的 FAILED。

被控端:写名单

名单是这台机器唯一的授权边界。里面只放控制端那一个 AID。

sudo mkdir -p /etc/anet
printf '%s  # 我的控制端\n' \
  'bafyreibbstu3o2ebv2k45rkwikmlvlf7wdp6j3fa4clb2wdtfr7klt5fi4' \
  | sudo tee /etc/anet/shell-allow
sudo chmod 600 /etc/anet/shell-allow
sudo chown root:root /etc/anet/shell-allow

用 allow_file 而不是配置里的 allow 数组:名单每次调用前重读, 所以撤权不用重启。删掉这个文件等于一次性撤销所有人,而且按空名单处理,不是报错。

文件属主要和 daemon 跑的用户一致。 root 建的 0600 文件对非 root 的 daemon 读不了,而这不表现为"调用被拒",表现为 daemon 起不来:模块启动时先读一遍名单, 读不了就退出,报 shell: allow_file: … permission denied。

被控端:写配置

sudo python3 - <<'PY'
import json, os
p = "/root/.anet/config.json"
c = json.load(open(p)) if os.path.exists(p) else {}
c["modules"] = {"shell": {
    "allow_arbitrary": True,
    "allow_file": "/etc/anet/shell-allow",
    "timeout_s": 7200,
    "max_output_bytes": 4194304,
}}
json.dump(c, open(p, "w"), indent=1)
print("written", p)
PY

四个字段:

字段这里的值说明
allow_arbitrarytrue开放 shell.exec,执行调用方送来的任意命令
allow_file路径名单,每次调用前重读
timeout_s7200单条命令上限两小时,可到 86400(24 小时)
max_output_bytes4194304回显上限 4 MB,超出截断

不写 commands。校验只要求"要么有具名命令,要么开了 allow_arbitrary",两者不必兼有。

路径取决于你用哪个用户装的。 安装脚本在跑它的那个用户家目录下建 ~/.anet。 上面按 root 写;不是 root 装的就改成那个用户的路径,并且让 daemon 用同一个用户跑 —— 不一致会建出第二个身份,AID 就不是你写进名单的那个了。

被控端:重启,读那行警告

anet stop && anet up
anet logs | tail -5
anet shell: allow_arbitrary is ON — an allowed caller can run any command as the user this daemon runs as

这行是有意留的,别当噪声过滤掉。它是这台机器现在处于什么状态的唯一提示。

把它的 AID 记下来:

anet status | python3 -c 'import sys,json;print(json.load(sys.stdin)["aid"])'
bafyreialf53nmohswqylnnvwbzvkirlbjo2up6uo7zqywv3ybgqnazdbb4

验证:四条,缺一不可

回到控制端。前三条验"能用",第四条验"授权对" —— 只跑前三条,你证明的是网络通, 不是配置对。

RK=bafyreialf53nmohswqylnnvwbzvkirlbjo2up6uo7zqywv3ybgqnazdbb4

一 · 目录里找得到

anet find --cap shell.exec
{"agents": [{"aid": "bafyreialf53nmohswqylnnvwb…", "name": "my-box",
             "caps": ["shell.exec", "shell.list"], "listed": true, …}]}

--cap 和 --capability 都收(0.1.9 起;之前 find 只认前者、delegate 只认后者)。

二 · 能跑,而且是 root

anet delegate $RK --cap shell.exec --args '{"command":"id; uname -a"}'
anet results
status:  OK
输出:
uid=0(root) gid=0(root) groups=0(root)
Linux firefly 6.1.118 #83 SMP Mon Jul 13 13:36:02 CST 2026 aarch64 GNU/Linux

uid=0(root) 说明 daemon 以 root 跑,所以命令也是 root。如果这里不是 0, 说明 daemon 跑在普通用户下 —— 见以谁的身份跑。

三 · 长任务

这一条要单独验,因为失败模式是"它回来了,只是不对":被超时杀掉的命令也会返回, 带 FAILED,只看状态区分不出来。所以要计时。

time anet delegate $RK --cap shell.exec \
  --args '{"command":"echo start-$(date +%T); sleep 90; echo done-$(date +%T)"}'
# 然后轮询 anet results
用时 92s
status: OK
输出:
start-17:19:25
done-17:20:55

首尾时间戳相差正好 90 秒 —— 命令真的跑完了,不是被截断后凑出来的。 同一条命令在 0.1.8 上会在 60 秒被杀。

长任务不会把节点堵住。 声明了长时长的调用在自己的 goroutine 里跑, 被控端在那 90 秒里照常收信、照常应答别的调用。同时在跑的长任务上限是 4 个, 第 5 个会立刻收到 UNAVAILABLE 并说明原因,不排队 —— 排在看不见的队里, 派活方分不清"忙"和"死"。

四 · 名单外的必须被拒

这条最重要。用一个真的另起的身份、真的注册到同一个 hub 的调用方去打, 而不是读代码确认。

anet id new stranger
anet --id stranger hub-register https://hub.agentnetwork.org.cn --name stranger-probe
anet --id stranger delegate $RK --cap shell.exec --args '{"command":"id; echo THIS-MUST-NOT-RUN"}'
anet --id stranger results
status:  UNAVAILABLE
message: this node does not accept commands from bafyreiec6sjvbtgh722y56zic6hsgtshvpusbm7wcm4gkvy5srfp3bjxvq
输出:   (无)

两件事都要看:状态是 UNAVAILABLE,并且回显是空的 —— 命令根本没有执行。 只看状态不够,一个"跑了但报错"的实现也会给你一个非 OK 的状态。

拒绝时点名了调用方的 AID,而不是含糊其辞。理由是 hub 目录已经告诉过他这个能力存在, 再装作不存在没有意义 —— 他该知道的是"你不在名单上",而不是"这里好像没这个东西"。

验完清理掉:

anet --id stranger hub-leave
anet stop stranger
anet id rm stranger --purge

日常用法

RK=<被控端AID>
 
anet delegate $RK --cap shell.exec --args '{"command":"df -h; free -m"}'
anet delegate $RK --cap shell.exec --args '{"command":"./build.sh 2>&1","dir":"/opt/work"}'
anet results

参数只认 command 和 dir 两个键,给别的会被拒绝而不是忽略 —— 一个被静默忽略的参数,是调用方以为生效了而实际没有的那种错误。

以谁的身份跑

命令以 daemon 进程的用户执行,没有别的机制。三种做法:

做法得到什么代价
Adaemon 跑普通用户那个用户的权限要 root 的操作做不了
Bdaemon 跑 root完全控制这台机器上所有模块都是 root
C普通用户 + 窄 sudoers只有列出的操作能提权配置最多,但 shell.exec 下意义有限

本页用的是 B。用 shell.exec 时 C 的意义不大 —— 调用方能送任意命令, 窄 sudoers 只是让他多打几个字。要收窄就该回到具名命令表,而不是在这里做半套。

想让 daemon 跑 root:用 root 装、用 root 起。ANET_DATA_DIR 与安装时一致。

撤权

三种粒度,都不需要重启被控端:

# 一 · 收回某一个调用方
sudo sed -i '/bafyrei<那个AID>/d' /etc/anet/shell-allow
 
# 二 · 收回所有人(名单不存在按空名单处理)
sudo rm /etc/anet/shell-allow
 
# 三 · 这台机器暂时不接任何委派
anet accept off

前两种下一次调用即生效。想彻底关掉这个模块,把 modules.shell 从 config.json 删掉再重启; 想连代码都不留,重装默认版二进制。

从"完全控制"退回"一张清单"

allow_arbitrary 和具名命令表可以同时开。摸清楚常用的是哪几条之后:

{"modules": {"shell": {
  "commands": {
    "health": {"run": "uptime; df -h /", "timeout_s": 30},
    "build":  {"run": "/opt/build.sh", "timeout_s": 7200, "dir": "/opt/work"}
  },
  "allow_arbitrary": false,
  "allow_file": "/etc/anet/shell-allow",
  "timeout_s": 60
}}}

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.execallow_arbitrary 没开,或改了配置没重启
调用回 FAILED,消息含 killed after超时。调 timeout_s,上限 86400
长命令在 60 秒被杀被控端是 0.1.9 之前的版本
anet find --cap 报 unknown flag0.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 的跨机命令。