Atheos v601 JSON API 与插件开发详解
实查确认 v601 无运维 CLI 后的接口层全景:controller.php 的 action/target 分发与恒 200 响应契约、11 类错误码实测、会话 Cookie 鉴权与缺失 CSRF/HttpOnly 的风险、会话指纹 || 缺陷实测、模块动作地图、建文件/写入/上传/下载端到端回显、路径三关与绝对路径泄露,以及插件 init.js/screen.css/controller.php/dialog.php 契约与最小可运行插件实测。
Atheos v601 JSON API 与插件开发详解
目标:把 Atheos v601 的接口层彻底讲透——一个
controller.php入口、action+target两个参数、始终 200 的 HTTP 状态码藏在响应体里的status、以及插件的真实加载契约。文中每条结论都有实测回显或源码行号支撑,实测环境:Ubuntu 26.04.1 LTS、Apache/2.4.65、PHP 8.1.34、站点根/var/www/html、Atheos v601,日期 2026-10-27,账号admin/q1w2e3r4。前置阅读:《Atheos v601 介绍与选型指南》、《Ubuntu 22.04 安装 Atheos v601 指南》、《Atheos v601 配置参数详解》。
一、先回答一个常被问的问题:有没有运维 CLI
动手写之前先做了实查,结论是没有:
ls /var/www/html # 无 bin/、无 console、无 scripts/
grep -n '"bin"' composer.json # 无匹配
grep -iE 'cli|console|command' README.md # 无运维命令行说明
站点根目录只有 Web 入口(index.php / controller.php / dialog.php)、components/、traits/、vendor/ 等,composer.json 也没有声明可执行入口;components/install/ 是 Web 安装向导,不是迁移工具。
因此 v601 的运维动作只有三条路:
- Web 界面:设置、用户管理、项目管理;
- 直接改文件:
data/users.json、data/projects.db.json(改完必须校验 JSON,见配置参数详解第六节); - 调 JSON 接口:
POST controller.php,这也是脚本化、批量化的唯一正道,本文主体。
二、接口形态:一个入口 + 两个参数
三个入口的分工
| 入口 | 用途 | 返回 |
|---|---|---|
index.php | 主页面骨架 | HTML |
controller.php | 全部业务动作 | JSON |
dialog.php | 弹窗内容片段 | HTML / JSON |
controller.php 的完整分发逻辑只有 20 行(源码实证):
$action = POST("action");
$target = POST("target");
if ($action !== "authenticate") { Common::checkSession(); }
if (!$action || !$target) { Common::send(415, "Missing target or action."); }
if ($target === "i18n" && $action === "init") { Common::send(200, ["cache" => $i18n->getCache()]); }
if (file_exists("components/$target/controller.php")) require("components/$target/controller.php");
elseif (file_exists("plugins/$target/controller.php")) require("plugins/$target/controller.php");
else Common::send(404, "Bad target destination");
要点:
- 先内置后插件:同名
target时components/优先,插件无法覆盖内置模块的后端接口(前端可以,见第七节)。 - 只有
authenticate免会话,其余动作在进入业务代码前就要求有效会话。 - 参数一律走
POST,没有路由段、没有 REST 语义;同一个 URL 靠action区分动作。
响应契约:HTTP 永远 200,答案在 body 里
traits/exchange.php 第 41–53 行:
public static function send($status, $data = array()) {
http_response_code(200);
header("X-Frame-Options: SAMEORIGIN");
header('X-Content-Type-Options: nosniff');
...
$data["status"] = $status;
由此得到三条必须记住的行为:
- HTTP 状态码恒为 200,业务成败看响应体的
status;用curl -f或curl --fail判断成功会全部误判为成功。 Content-Type是text/html; charset=UTF-8(不是application/json),前端用JSON.parse而非依赖 MIME。- 响应头只有
X-Frame-Options、X-Content-Type-Options两条,config.php里那 8 条安全头不会出现在接口响应上(详见配置篇第六节)。
错误码字典(实测 + 源码汇总)
| status | 含义 | 实测触发方式 |
|---|---|---|
200 | 成功 | 正常调用 |
403 | 无权限 / 路径越界 | paths=/etc/passwd 读取 |
404 | target 不存在 / 无活动项目 | target=nosuch;未 load 项目时查 current |
409 | 目标已存在 | 重复上传同名文件 |
415 | 缺 action 或 target | 只传 target |
416 | 非法 action | target=filemanager&action=nosuch |
417 | 缺必填参数 | 登录缺 password |
418 | 路径/类型非法 | demo/../../config.php |
419 | 缺 modifyTime(patch 模式) | save 带 patch 无 modifyTime |
451 | 密码错误 | 错误密码登录 |
159 | 文件已被改动(out of sync) | patch 的 modifyTime 与文件 mtime 不一致 |
"error"(字符串) | 会话失效 | 不带 Cookie 调用 |
注意最后一行的 status 类型不一致:多数是整数,会话类错误是字符串 "error"。判等时用宽松比较或先转字符串,否则 {"status": 200} == 200 与 {"status": "error"} == 0 这类陷阱会让人排查半天。
三、鉴权:会话 Cookie,无 CSRF Token
登录与会话
# 1) 登录,保存 Cookie
curl -s -c jar.txt -X POST http://127.0.0.1:8130/controller.php \
-d 'action=authenticate&target=user&username=admin&password=q1w2e3r4&language=en'
# {"username":"admin","lastLogin":"2026-10-27 19:57:13","text":"Successfully authenticated.","status":200}
# 2) 后续调用带 Cookie
curl -s -b jar.txt -X POST http://127.0.0.1:8130/controller.php -d 'action=current&target=project'
# {"name":"demo","path":"demo","status":200}
实测 Set-Cookie 只有 path=/:
Set-Cookie: <sid>=<hash>; path=/
没有 HttpOnly、没有 Secure、没有 SameSite。这意味着 Cookie 可被前端 JS 读取(XSS 场景下会话即失守),跨站 POST 的防护依赖浏览器默认策略。生产环境如果前面有反向代理,建议在代理层重写 Cookie 属性补 HttpOnly; Secure; SameSite=Lax。
会话指纹:设计存在,实测未生效
traits/checks.php 第 63–93 行给会话绑定了四项指纹:/16 网段的宽松 IP、User-Agent、Accept-Encoding、Accept-Language;任一不符就 session_destroy() 并返回 Security violation.。
但实测换 UA 仍能继续调用:
curl -s -A 'AtheosProbe/1.0' -c a4.txt -X POST .../controller.php -d 'action=authenticate&...'
curl -s -A 'AtheosProbe/2.0' -b a4.txt -X POST .../controller.php -d 'action=current&target=project'
# {"text":"No Active Projects","status":404} ← 仍通过鉴权,未触发 Security violation
根因就在第 66–68 行:
$userAgent = SERVER("HTTP_USER_AGENT") || md5("userAgent" . $loose_ip);
|| 是逻辑或,SERVER() 有值就是 true,所以四个指纹变量实际都被压成布尔 true 再存入会话,之后比较 true !== true 恒为假——指纹校验形同虚设。这是 v601 的真实缺陷,二次开发时不要指望它做会话绑定;需要更强绑定请在 AUTH_PATH 外部认证里自行实现。修复方式(不在本文改动范围内):把四处 || 改为 ?:。
没有 CSRF Token
对 traits/、common.php、controller.php 全量搜索 csrf、xsrf、token,零匹配。所有写操作仅靠会话 Cookie 鉴权。安全建议:在反向代理层校验 Origin/Referer,或对接口路径加自定义请求头校验(同源策略下攻击者无法伪造自定义头),并把 Cookie 补上 SameSite=Lax。
四、接口地图:各模块支持的动作
实测从各 components/*/controller.php 提取的 case 分支:
| target | 动作 | 关键参数 | 权限 |
|---|---|---|---|
user | authenticate changePassword create delete keepAlive logout saveActiveProject updateACL | username password language | create/delete 需 configure |
project | create current delete load open rename | projectName projectPath gitRepo gitBranch | 写操作需 configure |
filemanager | create delete duplicate extract index loadURL move open rename save | path dest name type content patch modifyTime | read/write |
transfer | download upload | path type、$_FILES["upload"][] | read/write |
active | add check list rename remove removeall setFocus | — | configure |
settings | load save | — | configure |
analytics | init changeOpt update saveDuration | — | — |
update | init saveCache | — | configure |
codegit | 由 class.codegit.php 内部路由,controller.php 无 case 分支 | — | — |
i18n | init(在 controller.php 里特判,不走组件) | — | 免登录 |
dialog.php 走另一套:components/<target>/dialog.php 或 plugins/<target>/dialog.php,实测 filemanager 支持 create duplicate rename 三个弹窗;缺参数时返回 {"status":"error","text":"Missing target or action."}(注意这里 status 也是字符串)。
五、端到端调用实录
以「拿到项目 → 建文件 → 写入 → 读回 → 上传 → 下载」为主线,全部为实测回显(命令省略了公共前缀 http://127.0.0.1:8130/controller.php)。
1)未加载项目时查当前项目
curl -s -b jar.txt -X POST .../controller.php -d 'action=current&target=project'
# {"text":"No Active Projects","status":404}
「当前项目」不是全局状态,而是会话级的:登录后必须显式 load 一次。
2)加载项目
curl -s -b jar.txt -X POST .../controller.php -d 'action=load&target=project&projectName=demo'
# {"name":"demo","path":"demo","repo":false,"text":"demo Loaded.","lastLogin":"2026-10-27 19:57:13","status":200}
curl -s -b jar.txt -X POST .../controller.php -d 'action=current&target=project'
# {"name":"demo","path":"demo","status":200}
repo:false 表示该目录不是 Git 仓库(codegit 组件据此决定是否显示 Git 面板)。
3)列目录
curl -s -b jar.txt -X POST .../controller.php -d 'action=index&target=filemanager&path=demo'
# {"index":[{"path":"/var/www/html/workspace/demo/hello.html","link":false,"type":"file","size":23},
# {"path":"/var/www/html/workspace/demo/notes.txt","link":false,"type":"file","size":12}],"status":200}
注意返回的是服务端绝对路径,这会把安装目录结构暴露给前端(见第六节)。
4)建文件 → 写入 → 读回
# 建空文件
curl -s -b jar.txt -X POST .../controller.php -d 'action=create&target=filemanager&path=demo/api-test.txt&type=file'
# {"modifyTime":1793102245,"status":200}
# 写入(modifyTime=0 表示不做并发校验)
curl -s -b jar.txt -X POST .../controller.php \
--data-urlencode 'action=save' --data-urlencode 'target=filemanager' \
--data-urlencode 'path=demo/api-test.txt' --data-urlencode 'content=hello from json api' \
--data-urlencode 'modifyTime=0' --data-urlencode 'patch='
# {"modifyTime":1793102245,"status":200}
# 读回
curl -s -b jar.txt -X POST .../controller.php -d 'action=open&target=filemanager&path=demo/api-test.txt'
# {"content":"hello from json api","modifyTime":1793102245,"status":200}
modifyTime 是乐观锁:save 后服务端返回新 mtime,下次带 patch 时必须带上它,否则 159 out of sync。脚本化写文件时用「整段 content + modifyTime=0」最稳,避免 patch 冲突。
5)上传(multipart,字段名固定为 upload[])
curl -s -b jar.txt -F 'action=upload' -F 'target=transfer' -F 'path=demo' \
-F 'upload[]=@/tmp/up.txt' .../controller.php
# {"filename":"up.txt","filepath":"/var/www/html/workspace/demo/up.txt","parent":".../demo","size":17,"text":"Uploaded successfully","status":200}
# 重复上传同名文件
# {"filename":"up.txt",...,"text":"File already exists.","status":409}
单文件大小上限由 PHP 的 upload_max_filesize 决定(本站 64M),超出时 status 转成上传错误码,文本取自 getUploadErrorText()。
6)下载
curl -s -b jar.txt -X POST .../controller.php -d 'action=download&target=transfer&path=demo/notes.txt&type=file'
# {"download":"/var/www/html/workspace/notes.txt","status":200}
download 不是流式响应:它把目标文件 copy() 到工作区根目录,然后返回该副本的绝对路径(源码 class.transfer.php 第 71–74 行)。两点后果:①根目录会留下同名副本,需要外部清理;②实测站点对 /workspace/... 的直接访问返回 403(.htaccess 做了限制),所以这个返回路径不能直接当下载 URL 用,前端另有下载通道。脚本里要取文件内容,用 filemanager 的 open 比 transfer download 合理。
7)鉴权失败对照
# 不带 Cookie
curl -s -X POST .../controller.php -d 'action=index&target=filemanager&path=demo'
# {"text":"Authentication error","status":"error"}
# 密码错误
curl -s -X POST .../controller.php -d 'action=authenticate&target=user&username=admin&password=wrong&language=en'
# {"text":"Invalid password.","status":451}
六、路径边界:三道路径关卡与一个信息泄露
traits/path.php 的 getWorkspacePath() 是所有文件类接口的必经关卡:
$path = Common::cleanPath($path); // ① 清洗:去 NUL、循环删除 "../"、字符白名单、realpath 归一
if (!$path) return false;
if (!Common::checkPath($path)) Common::send(403, "Client does not have access."); // ② 越界检查
if (Common::isAbsPath($path)) return $path; // ③ 绝对路径:原样放行
else return WORKSPACE . "/" . $path; // ④ 相对路径:拼到工作区
实测三组:
请求 path | 结果 | 说明 |
|---|---|---|
demo/../../config.php | {"text":"Invalid path.","status":418} | cleanPath 反复删除 ../ 后剩余路径不存在,realpath 失败 |
/etc/passwd | {"text":"Client does not have access.","status":403} | 绝对路径越界被 checkPath 拦下 |
demo | 200,返回绝对路径列表 | 工作区内正常 |
注意第 ③ 条:绝对路径只要落在工作区内就直接放行,这带来两个现象:filemanager index 回显 /var/www/html/workspace/demo/...(泄露安装路径),以及脚本里可以直接用绝对路径操作工作区内的文件(方便,但也意味着前端拿到的路径可直接回灌)。
cleanPath 是「删除」而不是「解析」../,遇到 demo/../../config.php 这类会变成不存在的路径而失败,方向安全但不精确;如果你的业务需要「移出到工作区外」,正确做法是改 WORKSPACE 常量,而不是构造 ../。
七、插件开发:前端合并加载 + 后端同名分发
目录契约
plugins/<name>/
├── init.js # 前端入口(必需,否则不会被加载)
├── screen.css # 插件样式(可选,文件名固定)
├── controller.php # 后端接口:target=<name> 时被分发(可选)
└── dialog.php # 弹窗片段:dialog.php?target=<name>(可选)
加载逻辑在 traits/cls.source.php 第 56–63 行:扫描 PLUGINS 目录,只有同时满足文件名 init.js / screen.css 才收录;controller.php / dialog.php 由后端按需 require。
前端加载:只有登录后才注入
index.php 的三处调用:
$SourceManager->linkResource("css", "plugins", DEVELOPMENT); // 56 行,页面顶部
if (登录态) {
$SourceManager->linkResource("js", "components", DEVELOPMENT); // 117 行
$SourceManager->linkResource("js", "plugins", DEVELOPMENT); // 120 行
}
实测差异(新增一个 plugins/apiprobe/init.js 后):
curl -s http://127.0.0.1:8130/ | grep -c 'plugins.min' # 未登录:0
curl -s -b jar.txt http://127.0.0.1:8130/ | grep -c 'plugins.min' # 已登录:2(css + js 各一)
这解释了一个高频困惑:「插件写好了,刷新页面没反应」——先确认自己是不是在未登录页看效果。
合并产物在 public/:plugins.min.js、plugins.min.css。linkResource() 用 mtime 判断是否需要重建(cls.source.php 第 103–116 行);实测新增插件后:
-rw-r--r-- 1 www-data www-data 747 Oct 27 19:58 /var/www/html/public/plugins.min.js # 含 apiprobe 2 处
-rw-r--r-- 1 www-data www-data 0 Oct 27 19:53 /var/www/html/public/plugins.min.css # 无 screen.css 时为 0 字节
DEVELOPMENT=true 时不走合并,逐文件输出,调试插件时建议临时开启(见配置篇第八节)。
后端契约:与内置模块同构
最小可用插件(本次实测用):
<?php
// plugins/apiprobe/controller.php
switch ($action) {
case "ping":
Common::send(200, array("plugin" => "apiprobe", "echo" => POST("msg")));
break;
default:
Common::send(416, "Invalid action.");
}
curl -s -b jar.txt -X POST .../controller.php -d 'action=ping&target=apiprobe&msg=hi'
# {"plugin":"apiprobe","echo":"hi","status":200}
curl -s -b jar.txt -X POST .../dialog.php -d 'action=show&target=apiprobe'
# {"dialog":"apiprobe","action":"show","status":200}
要点与限制:
- 插件后端无条件信任
Common::checkSession()已完成——controller.php在require插件前就校验过会话,所以插件内不用重复写鉴权;但如需按权限细分(只有configure能改配置),必须自己调Common::checkAccess("configure"),否则任何登录用户都能调用。 target重名时components/优先,插件只能在「新名字」上扩展后端;前端则相反,插件最后加载,可以重写现有模块对象(官方 README 明确写「a module can be completely rewritten as plugins are loaded last」)。- 跨插件协作通过
plugins/<a>/init.js里对全局 Atheos 对象的挂载实现,加载顺序按目录名排序,不要依赖另一个插件先加载。
前端约定:carbon 事件与 localStorage
官方 plugins/README.md 给出的三条契约(原文要点,实测环境为同一份文件):
- 设置自动保存:任何带
class="setting"的输入项,会自动写入 localStorage,键名由data-setting指定;复杂逻辑监听settings.dialog.save。 - 同步约定:配置键必须以
atheos.plugin.前缀开头,才进入用户的同步范围;同步前后可监听settings.save。 - 变更响应:监听
settings.changed:
carbon.subscribe('settings.changed', function () {
// 配置变化后的重绘、重新请求等
});
内置模块在关键动作上发布 carbon 事件,插件订阅即可介入,不必改内置代码。这也解释了为什么「重写模块」可行:插件加载在最后,可以覆盖对象方法,而事件层保证时机。
八、排障实录
| 现象 | 根因 | 处置 |
|---|---|---|
脚本里 curl -f 判定接口成功,但业务其实失败 | Common::send() 强制 http_response_code(200),错误只在 body 的 status | 解析 body 的 status 字段,别信 HTTP 码;注意会话错误是字符串 "error" |
接口「登录成功」后下一步全返回 Authentication error | 登录响应的 Set-Cookie 没被保存,或后续请求换了 UA/编码导致指纹判定(理论上) | 用 -c/-b 同一 Cookie 文件;先 authenticate 再调业务 |
插件 init.js 写了但浏览器没加载 | 插件 JS 只在登录后的页面注入(index.php 118–120 行) | 登录后再看;确认文件名是 init.js |
| 改了插件 JS 但页面仍旧 | public/plugins.min.js 是合并缓存,按 mtime 增量重建,同秒修改可能不触发 | rm public/plugins.min.js public/plugins.min.css 后刷新,或开 DEVELOPMENT=true |
project current 返回 No Active Projects(404) | 「当前项目」存于会话,登录后未 load | 先 action=load&target=project&projectName=demo |
| 上传同名文件总是失败 | 服务端不做覆盖,status 409 File already exists. | 先 filemanager delete 或换名;批量导入前做存在性检查 |
save 带 patch 报 159 out of sync | 文件已被其他会话改动,mtime 与提交的 modifyTime 不一致 | 重新 open 取最新 modifyTime 再提交,或改用整段 content 覆盖 |
| 换 UA 后会话没按预期失效,误以为「指纹绑定可用」 | checks.php 第 66–68 行 || 把指纹压成 true,比较恒等 | 不要依赖该机制;需要绑定请在 AUTH_PATH 外部认证实现 |
九、小结
- 接口层只有
controller.php+action/target,HTTP 恒 200、状态在 body,这是所有脚本判断的第一原则。 - 鉴权是纯会话 Cookie,无 CSRF Token、Cookie 无
HttpOnly/SameSite,指纹校验因||缺陷实际失效——部署时要靠代理层补齐。 - 文件类接口的路径关卡是
cleanPath → checkPath → isAbsPath,../会被删掉、绝对路径在工作区内放行(会回显绝对路径)。 - 插件是「前端合并加载 + 后端同名分发」:
init.js/screen.css必须叫这个名,后端controller.php自带会话校验但不自带权限校验。 - v601 没有运维 CLI,批量化只能走 JSON 接口或直接改
data/*.json。