教程四:远程执行运维命令
shell 模块:三道闸门,从零到可随时收回
场景与前置条件
你手上有几台开发板或测试机,它们跑着 anet daemon。你想从网络的另一端触发 "重启业务进程"、"读最近 200 行日志"、"烧一版固件"这类操作,并拿回回显, 而且这份权限要能随时收回,不需要重启被控端。
这就是 shell 模块的用途。它是套件里唯一一个在宿主机上执行命令的模块,
因此它的默认状态与其它模块相反:不装、不配、不列名单,三者缺一都执行不了。
开始之前需要:
- 两台机器(或同一台机器上的两个身份,见本页末尾的"本机排练")。 一台是被控端,要装 shell 变体;一台是调用端,装默认版即可。
- 两台都能出站访问同一个 hub。本页用官方 hub
https://hub.agentnetwork.org.cn。 - 被控端的
~/.anet/config.json你能编辑,并且有 sudo(名单文件放在/etc/anet下)。
最小镜像(debian:12 这类)要先补三样,否则安装脚本本身就跑不起来,
或者 HTTPS 直接失败:
procps 是因为 uptime、ps 这类顺手会写进命令表的东西不在最小镜像里。
三道闸门
shell 模块的授权被拆成三个互相独立的决定。任何一道没过,命令都不会执行。
| 闸门 | 位于哪里 | 默认值 | 谁决定 | 怎么核实 |
|---|---|---|---|---|
| 1 · 编译期 | 二进制里有没有链接 module/shell | 不在(加法 tag,-tags shell 才编进去) | 谁装/构建了这台机器 | anet version 的 modules: 行 |
| 2 · 配置块 | ~/.anet/config.json 的 modules.shell | 无(不注册任何能力,不执行任何命令) | 被控端的运营者 | anet status 的 caps 字段(下一次 hub-register 之后才更新) |
| 3 · 名单 | modules.shell 里的 allow 或 allow_file | 空(空名单拒绝所有,不是放行所有) | 被控端的运营者 | 发起一次真实调用 |
三道之外还有两个默认关闭的开关,后面会分别说明:
allow_arbitrary—— 是否开放shell.exec(执行调用方给的任意命令)。默认false。allow_local—— 是否放行不带调用方身份的调用。默认false。
第一道之所以做成加法 tag(默认不在),而其余九个由构建标签管辖的子系统都是减法 tag
(默认在、-tags no_<name> 去掉),原因是两个方向出错的代价不对称:减法 tag 写错,后果是
某人想去掉的模块没去掉;加法 tag 写错,后果是命令执行能力进了所有没要过它的构建。
需要靠"记得去掉"来保护的人,恰好是没听说过这个 tag 的人。
判据是符号数,方向与减法 tag 相反,由 CI 的 optin job 两个方向都验:
动手:从零到一次远程执行
在被控端装 shell 变体,并自己核对装到的是它
--shell 换的是另一个二进制,不是同一个二进制上的一个选项:
装完立刻核对。这不是一句承诺,而是一条可以自己查的输出:
默认版这一行里没有 shell:
modules: 那行读的是二进制里实际链接进来的模块注册表(module.Compiled()),
不是构建时刻进去的一个字符串 —— 一个模块出现在这行里,是因为它的 init() 跑过了,
而 init() 跑过是因为链接器把它留下了。不信任这个输出的话,直接查二进制:
发布的二进制保留符号表,就是为了让第二条能跑。
装成默认版而配了 modules.shell 的话,daemon 会在启动时报错,不会静默忽略:
在被控端写 modules.shell 配置块
编辑 ~/.anet/config.json,加一个 modules.shell。只有 commands 里逐条列出的
命令能被调用:
对应的能力 id 是 shell.run@uptime、shell.run@flash 这种形式。
全部字段见下面的"配置字段全表"。
两条会让 daemon 启动失败的写法,先说在前面:
commands为空且allow_arbitrary不为 true —— 这个模块什么都不提供,加载期直接报错。allow与allow_file同时给出 —— "谁能调用"有两个来源,比能正确的数量多一个,加载期报错。
建一个空的名单文件
先留空,不要急着往里写。 空名单拒绝所有远程调用,这是正确的起点。
那条 chown 不是可选的:sudo touch 建出来的是 root:root,配上 600 之后
以你自己身份跑的 daemon 读不了它。读不了不等于空 ——
module/shell/shell.go 的 Start 把"文件在、但打不开"当作启动错误,
daemon 直接起不来,报 shell: allow_file: … permission denied。
(只有"文件不存在"才等同空名单。)daemon 以 root 跑时(见下面"开机自启"一节),
root:root 600 本来就对,这条 chown 不用做。
重启节点
重启让新配置生效,注册不用另做:daemon 每次启动都会跑一次 refreshRegistration,
用 withServedCapabilities 把本节点实际提供的能力 id 合进去重新注册。
所以重启之后别人就能 anet find --cap shell.run@uptime 找到这台机器。
反过来撤掉一条命令时要多做一步:显式注册会把这份并集写回 config.json 的 caps,
删掉 modules.shell 再重启并不会让旧的 shell.run@… 从目录里消失,
还要把 config.json 的 caps 数组里那几条一起删掉(或直接 anet hub-leave)。
要顺便改展示名,再显式注册一次:
启动时名单为空,daemon 的 stderr 会打一行提醒,写进 ~/.anet/daemon.log:
这一行现在是预期的。
先验证空名单确实拒绝所有
到调用端,拿被控端的 AID(在被控端跑 anet status,读 aid 字段),然后:
anet results 打印的是一个 results 数组;下面这段是其中一条的 result 字段,
它本身是一段 JSON 文本。回来的应该是 UNAVAILABLE,而不是 uptime 的输出:
拒绝的措辞是明说的:被拒的调用方应该知道自己不在名单上,而不是被留着猜这个能力 存不存在 —— hub 目录已经告诉过它这个能力存在了。
名单文件不存在和名单文件为空在这里是同一件事:都是拒绝所有。 删掉整个文件是"一次性全撤"的正当做法,所以它必须表现得像一个空文件, 而不是像一个坏掉的节点。
拿到调用方的 AID,写进名单 —— 不重启
在调用端这台机器上取它自己的 AID:
读 aid 字段;或者直接提取:
回到被控端,把这个 AID 追加进名单文件。文件是一行一个 AID,空行与 # 起的注释被忽略,
所以可以顺手写下每一行是谁:
不需要重启,不需要重新注册。 allow_file 在每一次调用前重读,不缓存。
写进去,下一次调用就生效。
再从调用端调一次:
这次应该拿到 OK 和回显。字段名取自实现(internal/daemon/capability.go 的
capabilityResult 与 provider.Provenance),metrics 与 evidence 是 map,
所以键按字母序输出;下面的具体数值是示意,不是某一次实测:
回显在 evidence.observed_state 里。带参数的命令这样调(仅当该命令声明了 "args": true):
收权:删一行,或删整个文件
下一次调用即被拒,不用重启。 加回去同样是下一次调用即恢复 —— 收权不是一趟 需要重启才能撤销的单程票。
allow(内联列表)没有这个性质:它在配置里,改完要 anet stop && anet up。
生产上用 allow_file。想收权的那一刻,通常不是想重启"正要收权的那个东西"的那一刻。
彻底不干了:
hub-leave 只是让这个节点在 hub 上不再可投递,已经产生的证据留着。
配置字段全表
以下字段全部来自 ANet/module/shell/shell.go 的 Config 与 Command 结构。
| 字段 | 类型 | 默认 | 含义 |
|---|---|---|---|
commands.<name>.run | string | 必填 | 命令行,经解释器执行(见 shell 字段) |
commands.<name>.description | string | 空 | 能力卡上给对方看的说明;为空时 shell.list 用 run 顶替 |
commands.<name>.args | bool | false | 是否允许调用方追加参数(args.argv) |
commands.<name>.dir | string | daemon 的工作目录 | 这条命令的工作目录 |
commands.<name>.timeout_s | int | 取全局值 | 这条命令的超时,覆盖全局 timeout_s |
allow | string 列表 | 空 | 允许调用的 AID 内联列表。空 = 拒绝所有;改动需重启 |
allow_file | string | 空 | 允许调用的 AID 文件,一行一个,# 起注释;每次调用重读 |
allow_local | bool | false | 是否放行不带调用方身份(CallerAID 为空)的调用 |
allow_arbitrary | bool | false | 是否开放 shell.exec(任意命令) |
timeout_s | int | 30 | 全局超时秒数;上限 86400(24 小时),超出在加载期报错。0.1.9 之前是 1800,而且 daemon 还会在 60 秒无条件截断 —— 见长任务 |
max_output_bytes | int | 65536 | 回显字节上限;上限 4194304(4 MiB),超出在加载期报错 |
shell | string | /bin/sh | 解释器 |
命令名的限制:不能为空,不能含空格、制表符、@ 或 / —— 因为它会成为能力 id
shell.run@<name> 的一部分。run 不能是空白。
关于 allow_local:CallerAID 的零值就是空字符串。把"空"当作"本机、可信"意味着
将来任何一处新调用点漏填这个字段,都会静默绕过整份名单。默认拒绝让同一个疏忽
表现为一次看得见的拒绝,而不是一次看不见的放行。它需要自己的开关,这个开关默认关。
这个模块暴露哪些能力
| 能力 id | 行为 |
|---|---|
shell.list | 报告本节点定义了哪些命令、名单上有几条、全局超时是多少 |
shell.run@<name> | 执行一条已定义的命令 |
shell.exec | 执行调用方给的任意命令(args.command,可选 args.dir);仅在 allow_arbitrary 为 true 时存在 |
能力列表是排序后通告的,所以卡片上的顺序跨重启稳定 —— 一份自己会重排的列表会让每张卡 看起来都变过。
allow_arbitrary 关着时,shell.exec 既不出现在能力卡上,也不接受按名调用。开着时,
daemon 启动会打一行明确的警告到 ~/.anet/daemon.log:
行为约定
这一节的每一条都是实现行为,不是建议;标注了 daemon 的那条不在模块里。
-
不提权。 命令以 daemon 自己的用户身份运行。本模块不调
sudo、不 setuid。 要 root 就要让 daemon 以 root 运行 —— 那是安装期的决定,写在 unit 文件里、看得见, 不是这个模块能授予的东西。以非 root 身份跑systemctl restart这类命令,能不能做成 完全取决于该用户的权限;做不成时退出码与 stderr 原样回给调用方。(systemctl、journalctl在非 root 下的具体失败形态与目标机器的 polkit 配置有关,本页没有实测。) -
参数不会变成命令。
args: true时,调用方给的argv每一个元素被单引号包裹 (内嵌单引号转义为'\'')后追加到命令行末尾。命令行归运营者,参数归网络, 两者不能混成一个东西。argv不是字符串列表时,这次调用直接报错,不执行。 -
没声明
args却带了argv,是错误而不是忽略。 静默丢掉参数意味着执行了 一件与调用方所要求的不同的事,然后报成功。 -
args里出现别的键,同样是错误。shell.run@<name>只认argv这一个键,shell.exec只认command与dir;别的键一律拒绝并点名:这条补的是同一个漏洞的一般情形:把
argv写成args或arguments的调用方, 参数被丢掉、命令照跑、结果报OK—— 一次没按要求做却自称成功的执行。 -
超时杀整个进程组。 命令跑在自己的进程组里,超时时
SIGKILL发给整个组。 只杀 shell 会把它启动的子进程(一次构建、一次烧录、一个循环)留在机器上, 而没有任何句柄能再找到它们。 -
逃出进程组的子进程最多再等 3 秒。 有些命令会自己
setsid,它们持有的输出管道 会让这次等待永远不结束。WaitDelay把这个等待封顶,而不是让一次调用永久挂起。 -
回显有上限,截断时写明。 超过
max_output_bytes的部分被截断,并在输出末尾追加 一行[output truncated at <N> bytes],不静默截。超限之后的写入仍然被接收(丢弃), 这样命令不会因为管道关闭而被EPIPE打死 —— 否则"输出太多"会伪装成"命令崩了"。 -
诚实效果状态。 退出码 0 →
OK;非 0 →FAILED,message是exit <码>, stderr 在evidence.observed_state里;超时被杀 →FAILED,message是killed after <超时>。不会出现"被杀了但报 OK、输出为空"这种组合。 命令根本没能启动(exec失败)走的是另一条路径:模块不构造效果,而是返回错误shell: start: …;daemon 把 provider 返回的错误统一记成FAILED,message就是 这句错误文本。所以调用方两种情况都看到FAILED,区别在于没能启动的那次不带metrics.exit_code、也不带evidence—— 本节点没有退出状态可报。 -
verify_trust是 2(V2),不是 4。 本节点读回了退出码和这条命令打印的内容, 这是它观察到的;命令声称的那件事在世界上是否真的发生了,它没有观察到。 -
每次执行、以及名单判定产生的每次拒绝,都在应答前上证据链。
| 事件 | 何时写 | 载荷字段 |
|---|---|---|
anet.shell.command | 每次执行后、把结果回给调用方之前,无论成败 | caller capability command exit_code killed duration_ms output_bytes |
anet.shell.refused | 调用方不在名单上,或调用没带身份而 allow_local 关着 | caller capability reason |
有一种拒绝不写事件:名单文件存在但读不了。此时本节点连"谁在名单上"都答不出来,
它回 UNAVAILABLE 并且不落记录(module/shell/provider.go 的 Invoke),
所以"谁在敲门"的复查看不到这一类。
被拒的记录是更值得留的那一条:名单外的 AID 只会出现在这里,而拒绝如果不留痕, 想复查"谁在敲门"的运营者就没有东西可查。查看方式:
开机自启:User= 决定命令以谁的身份执行
anet up 起的进程不跨重启。真机上用 systemd:
User= 决定命令以谁的身份执行。User=你自己 的 daemon 只能跑你自己能跑的东西;
要跑 root 级运维命令,就要把 User= 设成 root,并把 ANET_DATA_DIR 改到
/root/.anet。
把 User= 设成 root 之后,名单里的每一个 AID 都能在这台机器上以 root 跑你在
commands 里列出的那几条命令。
模块不提权,也拦不住这件事 —— 提权与否是你在 unit 文件里做的决定,模块只是照做。
名单要按这个前提来写:allow_file 里的每一行,等于一次 root 权限的授予,
范围是 commands 的那几条,期限是这一行还在文件里的时间。
已知取舍
四条,都是有意选的,写在这里是因为它们各自有代价。
一 · run 经 shell 执行,运营者写进去的内容不受任何限制。
这样运营者可以直接写平时敲的命令(管道、重定向、&&),代价是 run 里的内容
没有任何校验。这条线划在"运营者写的"和"网络传来的"之间,只有后者是被引号隔离的那一侧。
不接受这个取舍的话,把命令包成脚本、run 只写脚本路径 —— 代价是配置文件不再显示
这台机器实际会做什么。
二 · 命令名会进 hub 目录。
节点向 hub 通告的是它实际会应答的能力 id,所以 shell.run@restart-app 这类名字对
任何能浏览目录的人可见。这不给出访问权(名单仍然拦着),但确实公开了这台机器
可以被要求做什么。不希望公开的命令,名字就不要带信息。
三 · shell.list 只把名单条数回给远程调用方,不回 AID 列表。
一个被接受的调用方不该顺带拿到本节点信任的其余机器名单 —— 那会把"一个被接受的调用方"
变成"一份其他运营者的通讯录"。本机调用(CallerAID 为空,需要 allow_local)拿到
完整列表,那是运营者在读自己的配置。
四 · 回显会落到本节点的证据链上,尽管 shell 模块自己没写它。
- 位置:
ANet/internal/daemon/capability.go的deliverCapabilityResult, 写入anet.capability.effect事件时带上了evidence,其中包含observed_state。 - 行为:
ANet/module/shell/provider.go写的anet.shell.command事件不含回显, 但 daemon 在交付能力结果时写的那条事件含。 - 影响:命令打印的任何内容(可能包括日志里的敏感串)会留在被控端的
~/.anet/evidence.ael.jsonl里。ANet/docs/SHELL-zh.md里"回显本身不入链" 这句只对模块自己写的那条事件成立,对整条链不成立。 - 发现方式:为本页核对源码时,对照两处
RecordEvidence/ledger.Append调用点。
排查
| 现象 | 原因与处理 |
|---|---|
daemon 启动即报 module "shell" is configured but not compiled into this build (it needs -tags shell…) | 装的是默认变体。重跑安装带 --shell |
daemon 启动即报 shell: no commands declared and allow_arbitrary is off | commands 为空且没开 allow_arbitrary,这个配置块什么都不提供 |
daemon 启动即报 shell: set either allow or allow_file, not both | 两个名单来源同时给了,删掉一个 |
daemon 启动即报 shell: allow_file: … permission denied | 名单文件在,但 daemon 的运行身份打不开它。chown 给该用户,或让 daemon 以读得了它的身份运行 |
调用回 UNAVAILABLE,消息 does not accept commands from … | 调用方 AID 不在名单里。检查 allow_file 的路径、内容与 daemon 用户的读权限 |
调用回 UNAVAILABLE,消息 carry no caller identity | 这次调用没有带调用方身份。要允许这种调用需显式配 "allow_local": true |
调用回 UNAVAILABLE,消息 cannot read its own allowlist | 名单文件存在但读不了(权限/IO)。注意"文件不存在"不走这条,它等同空名单 |
调用回 FAILED,消息 exit <码> | 命令真的失败了。stderr 在 evidence.observed_state 里 |
调用回 FAILED,消息 killed after … | 超时,整个进程组已被杀。调 commands.<name>.timeout_s |
调用回 FAILED,消息 command "x" does not take arguments | 该命令没声明 "args": true,却传了 argv |
anet find --cap shell.run@… 找不到这台机器 | 配置改了没重启(模块只在启动时构建),或 daemon 起来时 hub 不可达 —— 启动时那次注册刷新失败只写一行日志。看 anet logs 里有没有 could not refresh this node's registration,修好之后重启,或跑一次 anet hub-register … |
调用回 UNAVAILABLE,消息 this node does not serve <cap> | 这台机器上没有任何 provider 服务这个 id:命令名拼错,或 allow_arbitrary 关着而你按名请求了 shell.exec。命令没有执行。用 shell.list 核对能力 id |
这条应答是 0.1.7 补上的。 此前能力解析不出时 tryCapabilityPaid 只返回 false,
委派落到 auto-reply;没配 auto-reply 的节点对该次委派永不作答,请求方拿到的是超时,
与"节点宕了"无法区分。现在没配 auto-reply 的节点直接答 UNAVAILABLE;
配了 auto-reply 的节点仍按原样把它交给 agent 处理 —— 那个 id 可能确实是 agent 能做的事。
发现方式是 ANet/scripts/joint-shell.sh 第 7 组:在 allow_arbitrary 关闭的情况下
按名请求 shell.exec。
节点自己的日志在 ~/.anet/daemon.log,也可以 anet logs 100。
这一页背后验过什么
ANet/scripts/joint-shell.sh 起一个 hub 加两个 daemon,provider 用 -tags shell
构建,跨进程走真实委派路径。脚本里有 19 处 ok 断言点,最后一处就是"未服务的能力
是否作答";这条修好之后一次完整运行打印的是 19 passed。它验的是单元测试验不了的那些:
- 名单文件不存在时拒绝,走的是真实网络路径而不是构造出来的
provider.Call。 - 名单上的调用方能在另一台机器上执行命令,且以 daemon 的用户身份执行(不是 root)。
- 到达模块的
CallerAID是验签后的身份,不是调用方自称的值。 这是关键的一条:整套授权都压在这个字段上,而单元测试断言的是测试文件自己写进去的值。 只有真委派能证明这件事,脚本靠"provider 的证据链里记着 requester 的 AID"来验。 - 参数里的 shell 元字符(
hi; id、&& id、$(id))原样作为文本回来,没有被执行。 - 非零退出回
FAILED且 stderr 完整。 - 删掉名单行后下一次调用即被拒(不重启),加回后即恢复,拒绝记录上了链。
shell.exec在allow_arbitrary关闭时不出现在 hub 目录里,按名调用也不执行。
本机排练(可选)
不想动两台机器的话,可以在一台机器上用两个身份排练,但要注意: 两个身份共用同一个二进制,所以被控端那个身份要生效,这个二进制必须是 shell 变体。
anet id new 会立刻把这个身份起起来,而它此时还没有 modules.shell。所以顺序是:
把 modules.shell 写进 ~/.anet/ids/board/config.json(名单里填默认身份的 AID),
再 anet --id board stop && anet --id board up 让配置生效,然后
anet --id board hub-register <hub-url> --name board 把能力清单折进注册。
之后用 anet --id board <cmd> 操作被控端、用裸 anet <cmd> 操作调用端。
委派仍然走 hub 中继,两个身份都要 hub-register。