插件包
插件包是一个 .arcx 文件:一个 zip,里面有清单(manifest.json)、编译成 WebAssembly 的后端、浏览器端的一个 JS 模块,以及建表和删表的 SQL。把它拖进后台「插件」页就装好了;可以启用、禁用、删除;删除是真删——它从列表里消失,存进数据库的包本体被删掉,不需要重启,也不需要重新编译。
这是编译进二进制的插件之外的另一条路。两种插件用的是同一组扩展点(设置、账户字段、注册与登录守卫、路由、控制台命令……),后台和其余代码分不出区别;区别只在代码从哪来:一种编进二进制、随构建而定,一种是数据、随时装卸。
装一个包会发生什么
- 上传即预览。 文件拖进页面,服务器只读它:检查 zip、检查清单、检查它定义的东西会不会和服务器已有的撞车。什么都没有存,什么都没有跑。 对话框列出它是谁、哪个版本、多大、SHA-256、它要哪些权限、有没有在页面里运行的 JS。
- 确认才安装。 操作员看过权限、填好初始设置、选择是否立即启用,才点安装。在开启了「插件操作需要两步验证码」的实例上还要输入验证码。
- 一个事务做完全部。 迁移、初始设置、包本体、状态、挂到运行中服务器的各个扩展点,一起成功或一起回滚。它的迁移在 SQLite 和 PostgreSQL 上都要能跑(和核心的迁移同一条规则,安装时就检查)。
- 更新是原地的。 上传同名的新版本,对话框写「更新 1.0.0 → 1.1.0」,状态和设置原样保留,变的只有代码。守卫在替换的一瞬间也不会出现敞口。
- 删除。 默认保留它的数据(表、列、设置),以后重装接着用;勾选「同时删除数据」则执行包里的
purge/脚本、删掉它的设置行、忘记它的迁移,重装从空表开始。两种都会让它从列表里消失。删除要plugins_remove权限,保留数据的删除可以由持有它的委派管理员做;「同时删除数据」只限超级管理员,理由与上传相同:清理脚本以数据库属主的身份执行。服务器在请求里拒绝这种删除(403),控制台的--purge走的也是同一个接口。
包的结构
manifest.json 必须
plugin.wasm 后端(清单里 backend 指向它,可选)
web/ui.js 浏览器端(清单里 ui.module 指向它,可选),以及它用到的其他静态文件
migrations/*.sql 建表、加列,按文件名顺序执行,各记一条 schema_migrations
purge/*.sql 「同时删除数据」时执行的清理,按文件名顺序
README.md, LICENSE 随便放,不读迁移和清理文件的名字就是迁移的版本号,而所有来源(核心、编进二进制的插件、各个包)的版本号共用一张 schema_migrations 表,所以它们的名字必须归自己:<包名>_NNNN_说明.sql(只用小写字母、数字、下划线)。唯一的例外是从核心迁出来的迁移,它要保留原来的版本号,好让已经跑过它的数据库不再跑第二遍:NNNN_说明.sql,而且 NNNN 必须小于核心当前最新迁移的编号(核心下一个版本要用的编号不能被占)。安装时还会检查:版本号不能和核心、编进二进制的插件、已装的别的包撞车——撞了就拒绝安装;启动时遇到以前放进来的撞车包,会把它留在原地、不加载(日志里写明原因),而不是让整个服务器起不来。
zip 里只允许上面这些;路径里出现 ..、绝对路径、反斜杠、符号链接、隐藏文件都会被拒绝。压缩包不超过 32 MiB,解压后合计不超过 96 MiB,条目不超过 400 个。包永远不会被解压到磁盘上:安装就是把字节存进数据库的 plugin_packages 表,所以备份带得走它,第二个实例也读得到它。
manifest.json
严格解析:不认识的字段是错误。下面每项都是可选的,除非标了「必须」。
| 字段 | 含义 |
|---|---|
arcx | 必须,1 |
name | 必须,2–32 位小写字母和数字,字母开头。它是设置的所有者、路由的命名空间、迁移文件名的前缀 |
version | 必须,1.2.0 这样的版本。预发布版本(如 1.4.0-rc.1)按 SemVer 排在同号正式版之前,+ 之后的构建元数据不参与比较 |
title, description | 必须有 title.en;{ "en": "...", "zh": "..." } |
author, homepage, license | 显示用;homepage 必须是 http:// 或 https:// 地址(后台会把它画成链接,javascript: 之类的会被拒绝) |
requires.api | 必须,插件接口的版本,目前是 5;服务器提供的更低则拒绝安装。用到参数的 rest、sensitive 或标志的 sensitive 的包须写 5 |
permissions | 后端要用的权限,见下 |
backend | 后端文件,plugin.wasm |
hooks | describe(后端告诉浏览器和页面策略「现在该说什么」)、decorate_invitees(给邀请人自己的邀请列表加内容)、sweep(服务器每 10 分钟的例行清理时、以及启动时,给启用的插件一次机会做定期的事,比如复查外部状态;节奏由插件自己记,多实例时各自都会调用,需用行锁认领;需要 API 4) |
settings | 插件的设置:key(risk.base_url 这样带点的小写名)、default、secret(只写,不回显)、permission(哪个后台权限可读写,如 security)、enum 或 pattern(校验;整串必须匹配,安装时按 ^(?:…)$ 的实际用法检查,所以 \Qabc 这种单独能编译、套上锚点就不能的写法会被拒绝),以及安装对话框用的 label、hint、initial(安装时询问) |
captcha_modes | 往「注册验证码模式」的下拉里加的选项 |
fields | 账户上的字段:key、unique、searchable、pattern。列本身由插件自己的迁移创建。key 不能是 users 表里核心已有的任何一列(包括 password_hash、username_lower、email_lower、invite_reward_count):账户自己改资料时会写这些字段 |
field_rules | 哪个设置决定注册时这个字段是 off、optional 还是 required |
oauth_bindings | OIDC 登录的主体号形如 pattern 时,就是这个字段的值(自动绑定,且该连接不可解绑) |
guards | 注册、登录或生图实验室前的检查:action(register/login/images;images 需要 requires.api ≥ 2,管理员也会被问到)、name、event(拒绝时写进安全日志的事件名;与服务器自己写入的事件同名时,记为 plugin:<包名>:<event>,不会冒充核心条目) |
routes | 后端提供的接口:pattern(POST /api/admin/x/demo/things/{id})、access(admin 走后台的全部检查:会话、两步验证、permission 权限;public 原样开放,后端自己鉴权) |
console | 控制台命令:名字、分组、摘要、用法、帮助、参数、标志、权限、是否破坏性。参数的 rest 写在最后一个参数上,表示后端会读取其后的全部词(以空格连接);不写时,破坏性命令多给出的位置参数会被拒绝。参数或标志的 sensitive 让审计记录里它的值写成 ***,导入文档这类可能带凭据的参数须声明;rest 写在其他位置的清单无法安装 |
ui.module | 浏览器端模块,web/ui.js |
冲突在安装前就查:设置键、账户列、验证码模式、控制台命令名、路由,只要与核心或另一个插件撞了,预览就直接报错,不动任何东西。
路由的约定:管理接口放在 /api/admin/x/<插件名>/…,公开接口放在 /api/x/<插件名>/…。需要沿用外部已经配置的地址(比如已经填进机器人里的 webhook)时,公开路由可以是 /api/ 下任何核心没有占用的路径。
路由的每一个响应(成功、错误、插件被禁用时的 404)都带 Content-Security-Policy: sandbox; default-src 'none',盖掉页面自己的策略。后端能自己定 Content-Type,没有它,一个没有浏览器端、没有任何权限的包也能回一段 text/html,在访客的浏览器里以本站的身份跑脚本;有了它,这样的文档只会在一个没有脚本的不透明来源里打开。JSON、图片、下载都不受影响。
权限
后端能向服务器要的东西分成几族;清单里没声明的,调用一律返回 permission_denied。
| 权限 | 能做什么 | 主机调用 |
|---|---|---|
db | 对实例数据库执行任意 SQL(用 ? 占位,两种数据库通用)、开事务。db.begin 立即取得实例行的锁,直到事务结束(与后台改管理员名单的顺序相同,两者不会互相等死锁);事务开着时,tx 为 false 的语句和其他带 tx 的主机调用返回 tx_open | db.query、db.exec、db.begin、db.commit、db.rollback |
network | 向服务器能到达的任意地址发 HTTP(S) 请求(禁止链路本地、组播、未指定地址和云厂商元数据地址;写成 NAT64 或 6to4(2002::/16)形式的地址按其中嵌入的 IPv4 判断);开着事务时不允许 | http.fetch |
users | 挂起、恢复、删除账户,数活跃管理员。管理员(admin、super_admin)只有在这次调用由一位在职超级管理员发起时才能动(服务器从会话里取出的那个人,在锁内重新读取;包自己说的不算);定时任务、Webhook、委派管理员的请求一律拒绝(错误码 admin_account),最后一个活跃超级管理员谁也动不了(last_admin)。检查和改动在后台改管理员名单时用的同一把行锁下做。停用账户时同时结束它的所有登录。账户不是发起人自己的,还要求发起人持有 users 授权(见下) | users.set_status、users.delete、users.count_active_admins |
cards | 收回账户名下未用的重置卡;账户不是发起人自己的,要求 users 授权(见下) | cards.revoke_available |
rewards | 给账户发赠金(发到指定的赠金条)和重置卡;可并入插件的事务。发给别人的账户的赠金要求 usage 授权,重置卡要求 users 授权(见下)。需要 API 3。没给有效天数时用赠金条的默认到期时间;那个时间已经过了就拒绝(bonus_bar_expired),而不是发一笔永不过期的赠金 | rewards.bonus、rewards.cards |
sessions | 结束账户的所有登录;账户不是发起人自己的,要求 users 授权,且不能用于管理员账户(超级管理员的请求除外) | sessions.revoke_user |
notify | 往账户的收件箱放通知 | notify.push |
security_log | 写安全日志 | security.record |
challenge | 在自己的表单前使用站点的人机验证:问现在该要哪种(随注册页的验证码设置),并校验浏览器交来的证明。密钥不离开服务器。需要 API 3 | challenge.describe、challenge.verify |
console | 以当前操作员身份调用后台接口。一条命令只能调用它在清单 endpoints 里声明的接口(方法加路径,{name} 匹配一段,查询串不比较),之外的调用以 bad_argument 拒绝,所以命令要调的每个后台接口都得列进去 | console.call、console.resolve_user |
为别人的账户做事,要发起人持有授权。 包代表某个请求做事(路由、控制台)时,若操作的账户不是这个请求的发起人自己,发起人必须是在职管理员并持有对应授权:users(挂起、删除、收回卡、结束登录、发重置卡)、usage(发赠金)。超级管理员总是持有。不持有时调用返回 permission_denied,改动不会做。没有发起人的调用(定时任务)不受此限;管理员账户则只能由超级管理员发起的请求改动。
不需要权限的:写日志、生成 ULID、读自己的设置(和 registration.captcha_mode、site.name 两个核心设置)、读配置的公开地址。
db 权限等于给了插件和核心自己的代码一样的手:账户、卡、邀请,全在里面,并且表结构不是稳定接口。安装对话框会把它标成高风险。
一次调用的限额:15 秒、64 MiB 内存、请求和响应各 8 MiB;一次调用里最多一个事务,调用结束时没提交的事务会被回滚。
同时在跑的调用,整个引擎最多 32 个(wasm.Limits.MaxConcurrent)。每个调用是一个带内存上限的新实例,而公开路由和注册、登录守卫谁都能碰,不设上限的话实例数就由洪水决定。第 33 个调用不排队,直接失败:路由回 503(plugin_busy),守卫当作答不上来而拒绝——检查没跑,门不能开着。
占多少内存
后端是一个 Go 编出来的 wasm,约 5 MB;编译成本机代码后常驻内存约 60 MB。而插件的后端多数时候没人在用(操作员翻后台页面、有人注册),所以编译推迟到有调用要用它的时候,闲置 3 分钟后把编译出的代码还回去:下一次调用再编译一次,约 0.7 秒(之后每次调用 3–5 毫秒)。从没被调用过的后端只占它的字节。
编译本身很慢,且和机器有关:在 Axis AI 的生产服务器(4 核 Xeon 2.5 GHz,amd64)上一个约 5 MB 的后端要编译约 17 秒,而在 Apple 芯片的笔记本上不到 1 秒。所以编译出的代码会存进数据目录下的 wasm-cache/(只是缓存,随时可以删),重新编译(闲置回收之后、重启之后)读文件即可;服务器启动后会在后台把已启用的包编译一遍,好让第一个请求不用等。第一次从零编译一个新包时,装它的那次请求要等这一次。
在 macOS arm64 上用 Axis AI 的三个包量过:三个都在用时进程的物理内存约 210 MB,闲置超过 3 分钟后回到约 65 MB(不装任何包时约 30 MB);已经装好的包,服务器重启到能服务约 1 秒(启用了 describe 的插件要在启动时问一次后端)。安装、更新时先编译一次,确认它是好的,再提交。
删除或更新一个包,它旧的编译代码会随旧后端一起释放;进程里没有任何东西会把它留到重启。
后端
后端是一个 Go 程序,用 SDK(sdk/arc)写,编译成 wasip1 的 reactor:
package main
import "github.com/OnyxAxisOwO/ObsidianArc/sdk/arc"
func main() {}
func init() {
arc.Guard("demo", func(c *arc.Ctx, req arc.GuardRequest) (arc.GuardResult, error) {
if req.Token == "block" {
return arc.GuardResult{}, &arc.Refusal{Status: 403, Code: "demo_blocked", Message: "…", Reason: "blocked"}
}
return arc.GuardResult{}, nil
})
arc.Route("POST /api/admin/x/demo/things", func(c *arc.Ctx, r *arc.Request) (*arc.Response, error) {
return arc.JSON(201, map[string]string{"ok": "yes"})
})
}go run ./cmd/arcpack build path/to/plugin -o demo.arcx # 编译后端、打包
go run ./cmd/arcpack inspect demo.arcx # 看它要什么build 用本机的 Go 工具链照常编译源码:符号链接、go.mod 里指向本地目录的 replace、汇编文件的 #include 都按 Go 自己的规则处理, 编译器能读到的文件就可能进 plugin.wasm。所以 build 只用来编译你信任的源码,相当于在本机运行它的构建;别人给的源码先读过再编。 打包这一步(manifest.json、migrations、purge、web)不跟随符号链接,遇到链接或特殊文件直接报错。
每次调用是一个全新的模块实例(约 2.5 毫秒),互不相通,没有跨调用的内存。状态放在数据库、设置里。time.Now() 是服务器的真实时间,crypto/rand 是真随机数(wazero 默认给的是假时钟和确定的随机源,运行时已经替换掉了);没有文件系统、没有环境变量。SDK 里一次调用的全部能力都是 *arc.Ctx 的方法:Query/QueryRow/Exec/Tx、Fetch、Setting、NewID、Log、RevokeSessions、Notify、RecordSecurity、SetStatus、DeleteUser、CountActiveAdmins、RevokeCards、GrantBonus、GrantCards、Challenge、VerifyChallenge。Tx 之内的调用自动并入事务。
- 守卫:注册或登录前被调用。返回
*arc.Refusal拒绝(自己定状态码、错误码、话;检查所依赖的服务挂了时用 503,监控就能把它和一波机器人分开),返回GuardResult{Restrict: true}放行但让新账户的 API 保持关闭;后端崩了或超时则按拒绝处理——检查挂了,门不能开着。 - 路由:拿到已经过会话和权限检查的请求(含客户端访问的
Host)。返回*arc.Error是你想给客户端看的错误,任何 4xx、5xx 状态都原样给出;其他错误(没有措辞过的错误、panic)是 500,原因进日志、不给客户端。 OnDescribe:告诉服务器「浏览器该看到的/api/site区块」和「页面的 Content-Security-Policy 现在该信任哪些来源」。只在插件启用时和它自己的设置变化时被问,不是每个请求——所以只能从设置算,不能读会变的东西。它不要任何权限,却会把来源写进全站的script-src、connect-src和img-src,所以只接受http(s)://主机[:端口](不带通配符、不带路径)和字面的blob:,最多 8 个;别的(*、https:、'unsafe-eval'……)丢掉并在日志里写一行。OnDecorateInvitees:拿到邀请人自己的邀请列表,返回整张列表;可以改、可以加(比如加上已删除账户的记录)。- 控制台命令:
arc.Command("demo things", …),用Console.Call去调后台接口,Table、Printf出结果。输出里除换行和制表符以外的控制字符(转义序列、响铃、回车)会被去掉:后端打印的常常是访客填的内容,操作员的终端要显示它,而不是听它的。接口拒绝了命令要做的事时,把Call的错误原样返回即可,控制台显示的是接口自己的那句话,而不是「插件崩溃」。
浏览器端
web/ui.js 是一个 ES 模块,默认导出一个函数,接收页面借给插件的东西,返回插件的声明:
export default function (host) {
// host.api get / post / put / patch / delete,以及 ApiError
// host.icons 图标组件,如 host.icons.IconUsers
// host.strings 双语词典 helper:host.strings({ hello: 'Hello' }, { hello: '你好' })
// host.format absoluteTime(ms)
return { name: 'demo', fields: {…}, settings: […], lists: […], adminPages: […] };
}声明的形状是 web/src/plugins/types.ts 里的 ArcPlugin:账户字段、守卫(怎么拿令牌)、设置卡片、列表、操作卡片、账户面板里的按钮、错误码和通知的文案、自己的后台子页面;列表的行可以点开(detail:字段、图片、带输入的操作按钮);userPanels 在账户菜单里给所有登录用户加一项,打开一个表单(选项、多选、文字、开关、图片——图片在浏览器里先缩小再发)和「我提交过的」列表;或者是 entries:插件给出的一张列表(比如管理员发布的问卷和抽奖),每一项在 /x/<slug>/<id> 打开自己的表单,需要时核心先画出并完成站点的人机验证,把证明(proof)交给插件带去后端校验。后台的操作卡片可以要数字、开关、段落和一行一行编辑的小表(rows),下拉框可以从服务器取选项。核心用自己的组件把它们画出来,插件不带自己的样式。
页面通过 /api/site 里这个插件的区块里的 _ui 知道去哪取模块(/api/x/<name>/web/ui.js?v=<hash>),只有插件启用时才取;地址带版本哈希,可以永久缓存;模块被当作同源脚本动态导入。它在页面里以页面的权限运行——能调用登录用户能调用的一切接口——所以清单里有 ui 的包,安装对话框会明确写出来。
随部署自带的包
配置 OBSIDIAN_PLUGIN_DIR(镜像里默认 /usr/local/share/obsidian-arc/plugins)指向一个放着 .arcx 的目录,服务器启动时在处理第一个请求之前:
- 如果实例已有这个插件的状态记录(它以前是编进二进制的插件,状态是启用或禁用)而还没有包:接管——状态、设置、数据原样保留,从第一个请求起就以包的形式运行;
- 如果之前是从这个目录装的、现在目录里的版本不更旧:更新;
- 如果是操作员自己上传的:不动;如果是操作员删掉的:不再装回来;
- 全新的:装上,默认禁用,等操作员看过再启用。
与编译进二进制的插件的对照
| 编译进二进制 | 包 | |
|---|---|---|
| 怎么来 | 构建时 PLUGINS=… | 拖进后台 / 随部署自带 |
| 运行在 | 服务器进程里,与核心同权 | wazero 沙箱里,只有声明过的权限 |
| 卸载 | 状态变为「未安装」,代码还在二进制里 | 从数据库删掉,列表里没有了 |
| 语言 | Go,引用核心的 internal/ | Go(或任何能编出 wasm 的语言),只引用 SDK |
| 出错 | 崩溃可能带走整个服务器 | 只有这次调用失败 |
备份与恢复
包在数据库里,所以实例备份带得走它:.arcbackup 里有 plugin_packages 表,恢复时 restore-backup 用其中每个包自己的迁移把空数据库建成归档那个实例的样子,再导入数据。只建归档那个实例跑过的迁移——随部署自带、但这个实例没装过(或删除时清掉了数据)的插件不会在新库里留下多余的表。
唯一的例外是「删除时保留了数据」的插件:表在归档里,包不在。此时恢复会在写入前停下,指出缺的迁移;把它的 .arcx 放进 OBSIDIAN_PLUGIN_DIR 再运行。
多实例
包存在数据库里,第二个实例启动时就能读到;但在一个实例上装、删、改,其他实例要重启才看得到。这和编译进二进制的插件一样。