# 教程八:让一台机器完全控制另一台 > 从零搭一条 agent → 远程 root shell 的通路,并证明它挡得住不该进来的人 ## 这一页做什么 两台机器:一台你坐在前面(下称**控制端**),一台在别处(下称**被控端**)。 做完这一页,你可以从控制端在被控端上跑**任意命令**,以 root 身份,单条最长 24 小时, 而且只有你那一个身份能这么做 —— 别人拿到它的地址也进不来。 这与[教程四](https://docs.agentnetwork.org.cn/raw/tutorials/04-remote-commands.md)是两种用法。那一页配的是一张 **具名命令表**:`shell.run@restart-app`、`shell.run@flash`,名字进 hub 目录,参数受限, 适合交给别人或交给一个自动化流程。这一页开的是 `shell.exec` —— 一个远程 root shell, 适合"这是我自己的机器,我要完全控制它"。两者可以同时开,后面会说怎么从后者退回前者。 > **注意** > > **先说清楚这一页的终点是什么。** 做完之后,控制端的私钥就是被控端的 root 密码。 > `~/.anet/identity.kel` 泄漏 = 那台机器沦陷。这不是配置得当就能规避的性质,是这个用法 > 本身的定义 —— 你把一台机器的完全控制权,绑定到了另一台机器上的一份密钥。 > > 如果你要的是"让同事/CI 触发几个固定操作",别用这一页,用[教程四](https://docs.agentnetwork.org.cn/raw/tutorials/04-remote-commands.md)。 本页所有回显都是在一块真实的 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 · 自己跑一个 在一台两边都连得到的机器上(可以就是控制端): ```sh 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`。 要进一步限制谁能注册,打开邀请制: ```sh ./anet-hub -invite-required true -data ./hubdata ./anet-hub -invite-new -data ./hubdata # 铸一张一次性码,只显示一次 ``` 注册时带上 `--token <码>`。详见[教程六](https://docs.agentnetwork.org.cn/raw/tutorials/06-run-your-own-hub.md)。 ## 动手 顺序有讲究:**先装控制端**。被控端的名单要写控制端的 AID,而空名单拒绝所有人 —— 反过来做的话,被控端装完就是一台谁也调不动的机器,你还得回头再编辑一次。 ### 控制端:装、起、接入 hub 控制端只派活,**装默认版就行**,别装 shell 变体 —— 它不需要被别人执行命令, 装了就是白白多开一个面。 ```sh 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,下一步要用: ```sh anet status | python3 -c 'import sys,json;print(json.load(sys.stdin)["aid"])' ``` ``` bafyreibbstu3o2ebv2k45rkwikmlvlf7wdp6j3fa4clb2wdtfr7klt5fi4 ``` > **说明** > > **AID 跟着数据目录走。** 它是从 `~/.anet/identity.kel` 里的密钥历史自证出来的地址, > 不是随机分配的名字。换数据目录 = 换身份 = 名单失效。所以这个目录别删、别搬、 > 也别在两台机器之间复制。 ### 被控端:装 shell 变体 ```sh 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`。 自己核对装到的确实是它: ```sh 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。 ```sh 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`。 ### 被控端:写配置 ```sh 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_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 就不是你写进名单的那个了。 ### 被控端:重启,读那行警告 ```sh 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 记下来: ```sh anet status | python3 -c 'import sys,json;print(json.load(sys.stdin)["aid"])' ``` ``` bafyreialf53nmohswqylnnvwbzvkirlbjo2up6uo7zqywv3ybgqnazdbb4 ``` ## 验证:四条,缺一不可 回到控制端。前三条验"能用",第四条验"授权对" —— 只跑前三条,你证明的是网络通, 不是配置对。 ```sh RK=bafyreialf53nmohswqylnnvwbzvkirlbjo2up6uo7zqywv3ybgqnazdbb4 ``` ### 一 · 目录里找得到 ```sh anet find --cap shell.exec ``` ```json {"agents": [{"aid": "bafyreialf53nmohswqylnnvwb…", "name": "my-box", "caps": ["shell.exec", "shell.list"], "listed": true, …}]} ``` `--cap` 和 `--capability` 都收(0.1.9 起;之前 `find` 只认前者、`delegate` 只认后者)。 ### 二 · 能跑,而且是 root ```sh 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`,只看状态区分不出来。所以要计时。 ```sh 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** 的调用方去打, 而不是读代码确认。 ```sh 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 目录已经告诉过他这个能力存在, 再装作不存在没有意义 —— 他该知道的是"你不在名单上",而不是"这里好像没这个东西"。 验完清理掉: ```sh anet --id stranger hub-leave anet stop stranger anet id rm stranger --purge ``` ## 日常用法 ```sh 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 进程的用户**执行,没有别的机制。三种做法: | | 做法 | 得到什么 | 代价 | | --- | --- | --- | --- | | A | daemon 跑普通用户 | 那个用户的权限 | 要 root 的操作做不了 | | B | daemon 跑 root | 完全控制 | 这台机器上所有模块都是 root | | C | 普通用户 + 窄 sudoers | 只有列出的操作能提权 | 配置最多,但 `shell.exec` 下意义有限 | 本页用的是 B。用 `shell.exec` 时 C 的意义不大 —— 调用方能送任意命令, 窄 sudoers 只是让他多打几个字。要收窄就该回到具名命令表,而不是在这里做半套。 想让 daemon 跑 root:用 root 装、用 root 起。`ANET_DATA_DIR` 与安装时一致。 ## 撤权 三种粒度,都不需要重启被控端: ```sh # 一 · 收回某一个调用方 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` 和具名命令表可以同时开。摸清楚常用的是哪几条之后: ```json {"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.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` 的跨机命令。