概览

systemcontroller 是 Town OS 的中枢后端服务。它基于 Echo v5 构建,生产环境中监听 5309 端口(TCP) 或一个 Unix 域套接字。所有请求和响应体都使用 JSON。错误遵循 RFC 9457application/problem+json)。

开发环境下启用了 CORS。生产环境中,API 与界面部署在同一来源之下。

身份验证

调用 POST /account/authenticate 并提供用户名和密码即可完成验证。 响应中包含一个 Bearer 令牌。在后续请求中带上它:

Authorization: Bearer <token>

会话在闲置 7 天后过期。共有五种权限级别:

级别说明
公开无需令牌。
已认证任意有效的会话令牌。
管理员属于管理员账户的会话令牌。
授权账户上的某项具体授权可以放行非管理员。对象存储端点使用对象存储授权;对等设备注册使用 wireguard 授权,而按网络的范围与每个对等设备的归属由处理器自身校验。
Localhost来自环回地址的请求无需认证即可通过——能够访问环回地址本身就意味着已经在这台机器上——其他来源则需要徽章旁标注的级别。systemd 单元与日志端点使用它,因为控制器自身的工具会读取这些端点。

即便分区内部的端点并不限于管理员,创建对象存储分区仍然只保留给管理员: 分区是一棵权限树的根,并且会分配一个带配额的 btrfs 子卷, 因此授权准许你操作分区内部的用户,而不是准许你决定这个分区是否应当存在。

分页

所有列表端点都接受以下查询参数,并返回统一的信封结构:

参数类型说明
sort_bystring用于排序的字段名。
sort_orderstringascdesc
limitint每页条数(默认 20)。
offsetint分页偏移量。
searchstring对所有字符串字段做不区分大小写的子串匹配。

响应信封

{
  "entries":     [...],
  "has_more":    true,
  "total_pages": 5,
  "total_count": 97
}

状态

GET /status/ping 公开

健康检查与系统总览。未认证的调用方会得到一个精简响应,只含 statusneeds_setup。已认证的调用方会得到完整的仪表盘数据, 包括文件系统数量、软件包计数、单元状态摘要、磁盘用量、外部/内部 IP,以及是否有可用升级。

账户

POST /account/authenticate 公开

用用户名和密码进行验证。返回一个会话令牌和该账户对象。

字段类型说明
usernamestring必填。账户用户名。
passwordstring必填。账户密码。
POST /account/create 公开 / 管理员

创建新账户。在引导模式下(尚不存在任何已启用的管理员账户),该端点是公开的; 否则需要管理员身份。创建的第一个账户会成为管理员。密码至少 8 个字符。 电子邮件、电话和真实姓名为必填项。

字段类型说明
usernamestring必填。
passwordstring必填。至少 8 个字符。
emailstring必填。
phonestring必填。
real_namestring必填。
adminboolean该账户是否具有管理员权限。
POST /account 已认证

按用户名获取单个账户。请求体:{"username": "alice"}

GET /account 已认证

列出所有账户。支持分页参数。

POST /account/update 已认证

更新账户字段。传入 username 以指明账户,并附上一个 fields 对象,其中可任意组合 passwordemailphonereal_nameadmin。 只有提供的字段会被修改。

GET /account/me 已认证

返回与 Authorization 头中令牌相关联的用户名。

GET /account/sessions 已认证

列出当前已认证用户的所有活动会话。每个会话包含其 ID、用户名、创建时间和最后使用时间。

POST /account/session/revoke 已认证

按 ID 吊销一个会话。请求体:{"session_id": "..."}

POST /account/disable 管理员

禁用一个账户。请求体:{"username": "bob"}

POST /account/enable 管理员

重新启用一个已禁用的账户。请求体:{"username": "bob"}

存储

POST /storage 已认证

列出文件系统。除分页参数外,请求体中还可传入可选的 name (前缀过滤)和 stateuserinstalleduninstalled)。

POST /storage/create 已认证

创建一个新的 btrfs 子卷。传入 name 和可选的 quota (字节)。如果配额为 0 或省略,则使用系统默认值(50 GB)。保留名称 (installeduninstalledarchives)会被拒绝。

POST /storage/modify 已认证

修改已有的文件系统。传入 name 以指明对象,并附上一个 filesystem 对象,其中含更新后的 name 和/或 quota

POST /storage/remove 已认证

移除一个文件系统。请求体:{"name": "mydata"}

POST /storage/upload-archive 管理员

上传归档并解包到目标子卷中。接受 multipart/form-data, 含一个 subvolume 字段和一个 archive 文件。支持 .tar.gz.tgz.tar.bz2.tbz2.tar.xz.txz.tar.zip.7z

字段类型说明
subvolumestring必填。目标子卷路径。
archivefile必填。要上传的归档文件。
subpathstring可选。卷内用于解包的相对路径;会按需创建。
stop_servicestring可选。解包前停止、完成后重启的 systemd 单元名称。
设置项默认值说明
max_archive_size1 GB上传大小上限。
archive_unpack_timeout600 秒解包的最长耗时。
POST /storage/download-archive 管理员

下载子卷内容的归档。会以所请求的格式返回一个流式归档。

字段类型说明
subvolumestring必填。源子卷路径。
pathsstring[]可选。子卷内要包含的具体路径数组。
stop_servicestring可选。归档期间停止、之后重启的 systemd 单元名称。
formatstring可选。压缩格式:tar.gz(默认)、tar.bz2tar.xz
filenamestring可选。下载文件的自定义主文件名,服务端会补上相应扩展名。默认为 download
POST /storage/package-volumes 已认证

按软件包分组列出软件包卷,可选择是否包含已卸载的卷。

POST /storage/remove-package-volume 管理员

按内部名称删除某个特定的软件包卷。

POST /storage/remove-package-volume-group 管理员

一次调用即删除属于某个软件包的全部卷,而不必逐个按内部名称移除。

仓库

GET /repository 已认证

列出所有已配置的软件包仓库,含名称、URL 和任何错误状态。支持分页参数。

POST /repository/add 已认证

添加一个新的软件包仓库。会立即触发一次刷新。

字段类型说明
namestring必填。仓库的显示名称。
urlstring必填。仓库的 Git URL。
usernamestring可选。私有仓库的认证用户名。
passwordstring可选。私有仓库的认证密码。
POST /repository/remove 已认证

按名称移除一个仓库。会立即触发一次刷新。 请求体:{"name": "my-repo"}

POST /repository/move 管理员

把某个仓库重新排到新的位置(从 0 开始计数)。当软件包重名时,靠后的仓库会覆盖靠前的。 请求体:{"name": "my-repo", "position": 0}

POST /repository/refresh 已认证

强制立即刷新所有仓库的元数据。成功时返回空响应体;若有仓库失败, 则返回一个把仓库名映射到错误字符串的 JSON 对象。

软件包

GET /packages 已认证

列出所有仓库中全部可用的软件包。每个条目包含仓库、名称、版本、描述、supplies 标签、 安装状态,以及是否有可用升级。支持分页参数。

GET /packages/by-repo 已认证

按仓库分组列出软件包。接受可选的 search 查询参数。 返回一个由 {"repo": "...", "packages": [...]} 组成的数组。

GET /packages/installed 已认证

列出已安装软件包的标识符。支持分页参数。

POST /packages/installed/info 已认证

获取某个已安装软件包的详细信息。传入 reponameversion。返回提问、用户回答、说明及说明类型。

POST /packages/responses 已认证

获取某个已安装软件包保存的提问回答。传入 reponameversion。返回一个键值映射。

POST /packages/versions 已认证

列出某个软件包的可用版本。请求体:{"name": "nginx"}。 返回一个版本标识符的字符串数组。

POST /packages/children 已认证

列出子软件包。传入 reponame。返回一个字符串数组。

POST /packages/questions 管理员

获取某个软件包的安装提问。请求体:{"name": "nginx"}。 返回一个从提问键到 {"query": "...", "type": "..."} 的映射。

POST /packages/questions/identity 管理员

获取特定软件包版本的提问。传入 reponameversion

POST /packages/oauth/start 管理员

为某个 oauth 提问启动 OAuth 设备流程。传入 reponameversionquestion。系统控制器 会向服务商执行该流程的起始步骤,并返回 flow_idapprove_url(请在用户浏览器中打开它)、 可选的 user_code,以及 interval_ms——轮询间隔。

服务商的各个 URL 来自软件包而非 Town OS,因此在调用前会被检查: 只允许 https,且绝不能是宿主机自身网络上的地址。

POST /packages/oauth/poll 管理员

轮询上面启动的流程。传入 flow_id。返回 status: 用户尚未授权时为 pending,授权完成时为 approved 并附带 token,而流程超时或其令牌已被取走后则为 expired—— 每个流程只能使用一次。取得的令牌随后会作为该提问的回答提交给 /packages/install,与手动输入的回答完全一样。

POST /packages/install-preview 管理员

在真正安装之前预览这次安装会做什么。传入 reponameversion。返回卷的详情、端口映射、 磁盘占用、配额信息、升级的来源版本,以及一段易读的摘要。

POST /packages/install 管理员

安装一个软件包。

字段类型说明
repostring必填。仓库名称。
namestring必填。软件包名称。
versionstring必填。要安装的版本。
responsesobject必填。对安装提问的键值回答。
reuse_volumesboolean复用先前安装遗留的数据卷。
import_from_versionstring升级时要从哪个版本导入卷。
POST /packages/uninstall 管理员

卸载一个软件包。传入 reponameversion, 以及可选的 purge_volumes(布尔值)以删除相关数据。

POST /packages/disable 管理员

禁用一个已安装的软件包(停止其服务)。传入 reponame

POST /packages/enable 管理员

重新启用一个已禁用的软件包(启动其服务)。传入 reponame

POST /packages/purge-volumes 管理员

删除某个已安装软件包的全部数据卷。传入 reponame

POST /packages/uninstalled-volumes 管理员

检查某个软件包是否有先前安装遗留下来的卷。传入 reponame。返回 has_uninstalled_volumesuninstalled_versionsinstalled_versions

POST /packages/purge-uninstalled-volumes 管理员

删除先前已卸载版本遗留下来的卷。传入 reponame

GET /packages/upgrades 已认证

列出已安装软件包的可用升级。每个条目包含 installed_versionlatest_version,以及软件包定义是否 changed

POST /packages/upgrades/dismiss 管理员

忽略当前的升级提示。传入一个空的 JSON 对象。

POST /packages/manifest 已认证

返回原始的 YAML 软件包定义。传入 reponameversion。以 Content-Type: text/x-yaml 返回文件内容。 若该软件包文件不存在则返回 404。

GET /packages/featured 已认证

列出所有仓库中的精选软件包。

POST /packages/last-responses 已认证

取回某个软件包缓存的 last 回答。传入 reponame。返回上一次卸载时保存的回答,供重新安装时复用。

POST /packages/clear-last-responses 管理员

删除某个软件包缓存的 last 回答文件。传入 reponame

POST /packages/rebuild-git 管理员

为某个已安装软件包中由 git 填充的卷拉取最新改动,并重启依赖它的服务。 传入 reponameversion。 重建之前会依据已保存的回答重新计算模板变量。

Systemd

GET /systemd/units 已认证 / Localhost

列出由 Town OS 管理的 systemd 单元。每个条目包含单元名称、描述、 load/active/sub 状态、关联的软件包标识符与描述,以及一个失败标志。支持分页参数。

GET /systemd/units-tree 已认证 / Localhost

与扁平列表相同的单元,但按依赖关系组织成树:根软件包在最上层,依赖逐层嵌套在其父级之下—— 与 /storage/package-volumes 采用的结构相同。每一行都带有扁平端点返回的同样的状态数据, 因此客户端无需再发一次请求来补全信息。

POST /systemd/status 管理员

控制某个 systemd 单元。传入 name(单元名)和 actionstartstoprestartenabledisable)。

POST /systemd/status/tree 管理员

一次调用即按依赖顺序对某个软件包及其整棵依赖树施加同一操作。 这里同样拒绝 enabledisable,原因与 /systemd/status 一致: 级联执行 enable 会把那些已经通过父级关联的依赖重复启用一次。

GET /systemd/logs 管理员 / Localhost

通过 Server-Sent Events 实时推送某个单元的 journal 条目。传入 unit 查询参数;留空或使用 __system__ 会返回全系统日志。 每个 SSE 事件都包含一条 JSON 编码的 journal 条目,字段如 MessagePriorityRealtimeTimestampSystemdUnit

GET /systemd/logs/tail 管理员 / Localhost

获取一页 journal 条目,支持基于游标的分页和过滤。

参数类型说明
unitstringsystemd 单元名。留空或使用 __system__ 表示全系统日志。
linesint要返回的条目数(默认 100)。
beforestring游标——返回该位置之前的条目。
afterstring游标——返回该位置之后的条目。
grepstring对消息文本做不区分大小写的子串过滤。
sinceintUnix 时间戳——返回该时间之后的条目。
untilintUnix 时间戳——收集到该时间为止。
priorityintsyslog 严重级别过滤(0 表示不过滤)。

返回 entriescursor(首条)和 end_cursor(末条),供后续分页使用。

GET /systemd/logs/tree 管理员 / Localhost

/systemd/logs 的树形对应端点:用一条 Server-Sent Events 流承载某个软件包 及其之下所有单元的日志,并按时间顺序合并。即便是没有安装记录的未知根, 也会得到一条打开但没有条目的流,而不是 404,因此日志查看器对单个单元和整棵树的处理方式完全一致。

GET /systemd/logs/tree/tail 管理员 / Localhost

合并后的树形日志的分页形式,接受与 /systemd/logs/tail 相同的游标、过滤与时间范围参数。

设置

GET /settings 管理员

以键值对象的形式获取全部设置。

POST /settings/get 管理员

获取单个设置项。请求体:{"key": "default_quota"}。 返回 keyvalue

POST /settings/set 管理员

设置某个配置项的值。请求体:{"key": "default_quota", "value": "107374182400"}

默认设置

默认值说明
default_quota53687091200(50 GB)新建文件系统的默认配额。
max_archive_size1073741824(1 GB)归档上传大小上限。
archive_unpack_timeout600(秒)归档解包的最长耗时。
localeen-US用于国际化的全系统语言区域。
proton_imagequay.io/town/proton:latestProton/Wine 运行器容器镜像。
dns_tldhome本地 DNS 解析所用的顶级域名。

审计日志

POST /audit/log 管理员

列出审计日志条目。请求体中的所有字段都是可选的。

字段类型说明
before_idint键集分页——返回 ID 小于该值的条目。
accountstring按账户用户名过滤。
sort_bystring用于排序的字段。
sort_orderstringascdesc
limitint每页条数。
offsetint分页偏移量。
searchstring搜索过滤条件。

每条审计记录包含 idaccountactionpathdetailsuccesserrorcreated_at。被审计的操作包括:身份验证,创建/更新/禁用账户, 吊销会话,安装/卸载/禁用/启用软件包,创建/修改/移除文件系统, 添加/移除/移动/刷新仓库,上传/下载归档,更新设置,忽略升级,以及清除卷。

页面

静态网站托管,支持三种内容来源类型:上传归档、容器镜像和 git 仓库。 用户为其指定一个域名,系统则通过 Caddy 容器提供内容服务。 所有会产生改动的端点都需要管理员身份;列表端点只需普通认证。

GET /pages 已认证

列出所有页面,支持排序、搜索和分页。可按名称、仓库 URL、 分支、域名、来源类型、状态和时间戳排序。

POST /pages/create 管理员

创建一个新页面。接受名称、来源类型(archivecontainer_imagegit)、仓库 URL、分支、域名、 容器镜像和镜像内目录。来源类型默认为 archive。 git 和容器镜像类型的页面会异步准备。

POST /pages/upload 管理员

为归档类型的页面上传内容的 tar 包。接受包含 namearchive 文件的 multipart 表单。仅对来源类型为 archive 的页面有效;其他来源类型返回 400。

POST /pages/update 管理员

对页面的仓库 URL、分支、域名、来源类型、容器镜像或镜像内目录做局部更新。 只有提供的字段会被修改。

POST /pages/remove 管理员

从数据库中删除一个页面,移除 webroot 符号链接,并删除对应的 btrfs 子卷。

POST /pages/rebuild 管理员

从来源重新构建页面内容。git 页面会拉取最新改动;容器镜像页面会从镜像中重新提取。 归档页面返回 400(请改用 /pages/upload 重新上传)。

网络

网络是一个具名的 WireGuard 覆盖网,并与一个 DNS TLD 配对。 软件包安装到某个网络中,对等设备加入这个网络,而 TLD 决定谁能解析什么。 网络名称必须是合法的 DNS 标签,且不超过 32 个字符,因为它们同时会被用作 WireGuard 接口后缀和 systemd 单元名称。

home 网络始终存在——它随数据库一同种入,而不是在启动时创建——并且在三个方面很特殊: 它无法删除,也不能被第二次创建;它只提供 DNS (没有 WireGuard 接口、没有子网、没有对等设备);而且在它上面注册对等设备会被拒绝, 返回 400。每个账户都属于 home 网络,因此若在此接受注册,仅凭成员身份就等于获得了进入隧道的途径, 而且保存下来的对等设备所描述的隧道根本不存在。

禁用网络只会停掉传输层:WireGuard 接口不会被拉起,远程访问因此中断, 而本地 DNS 解析和容器本身照常运行。

GET /networks 已认证

列出网络。每个条目包含名称、TLD、子网、本机在覆盖网中的地址、公钥、监听端口和启用标志。 私钥永远不会被序列化。

POST /networks/create 管理员

创建网络。子网由本机身份种子与网络名称确定性地推导而来,取自 10.64.0.0/10,以避开家用路由器常用的地址段。 以本机身份为依据,意味着两台都在提供对等接入的 Town OS 主机会选出不同的子网, 因此同时加入两者的设备永远不会遇到冲突。创建 home 会因 TLD 冲突检查而返回 409。

POST /networks/remove 管理员

删除网络。会拒绝 home 网络。

POST /networks/enable 管理员

拉起该网络的 WireGuard 传输层。

POST /networks/disable 管理员

停掉传输层,同时让 DNS 和容器继续运行。

对等设备

GET /networks/peers 已认证

列出已在某个网络中注册的对等设备。

GET /networks/peers/connected 管理员

列出当前确实处于连接状态的对等设备,而不只是已注册的。

POST /networks/peers/add 授权

注册一个对等设备。wireguard 授权是放行非管理员的依据;按网络的范围与每个对等设备的归属由处理器校验。 对 home 网络返回 400,因为它只提供 DNS。

POST /networks/peers/refresh 授权

在对等设备的 TTL 到期之前续期其注册。注册是有时效的,回收器会清除已过期的条目。

POST /networks/peers/remove 管理员

把某个对等设备从网络中移除。

本地 CA

GET /tls/ca.crt 公开

以 PEM 格式下载本机的本地证书颁发机构。Town OS 会为软件包名称签发自己的证书, 因此信任这张证书正是让那些名称在浏览器中不再报警的关键。它被刻意设为公开: CA 证书本来就是用来分发的那一部分,而且客户端在持有任何可用于认证的凭据之前就需要它。

对象存储

Town OS 通过 gfeh 提供对象存储。一个分区包含一个 btrfs 子卷、 一个 gfehd 进程、一个管理套接字,以及它自己的一套用户。 每个 Town OS 网络恰好对应一个分区,因此对象存储的命名空间与 DNS、WireGuard 沿着同一条边界划分: office 分区中的主体、授权或公开暴露,在 home 中毫无意义。

每个分区在固定的容器端口上提供四种 HTTP 视图——S3 在 9000、HTTP 在 9001、云盘在 9002、IPFS 在 9003—— 并且完全不发布宿主机端口。这正是固定端口之所以安全的原因: 每个分区都有自己的网络命名空间,ingress 通过容器名称访问它,与访问软件包的方式完全一样, 所以两个都在 9000 上提供 S3 的分区不可能冲突。

分区

这四条路由之所以独立于 /storage/*,是因为 /storage/create 会无条件地把提交上来的名称改写为 user/<名称>,因此无法生成 gfeh/ 前缀下的卷。它们的报文形态是 gfeh 客户端会解析的公开契约,而非内部细节。

有两处细节至关重要。前缀是不对称的:请求携带的是裸名称, 响应携带的是 gfeh/<名称>,因为该前缀是 Town OS 的命名空间产物, 并不属于分区自身的身份。以及列表返回的是裸 JSON 数组,而不是分页信封, 这与本 API 中其他所有列表端点都不同:gfeh 客户端直接反序列化一个普通列表, 而分页包装会导致解码失败。

路由权限请求响应
POST /gfeh/partitions/create管理员name(不带前缀)、quotaFilesystem,名称为 gfeh/<n>
POST /gfeh/partitions/modify管理员namequotaFilesystem
POST /gfeh/partitions/remove管理员name200,空响应
POST /gfeh/partitions已认证无请求体Filesystem 的普通数组

客户端应当据以分支的状态码:409 已存在(gfeh 的置备是「创建或调整大小」, 正是靠这个状态区分两者)、404 不存在、400 名称非法、 403 非管理员。含有路径分隔符的名称在这里就会被拒绝, 因为 gfehd 在它自己的边界上也会拒绝——双方对「什么是合法分区名」若不一致, 就会让 ../user/something 这样的名称指向对象存储根之外的卷。

创建分区仅限管理员,且无法通过授权取得: 它是一棵权限树的根,并且会分配一个带配额的 btrfs 子卷, 因此持有授权的账户会在任何处理器运行之前就被拒绝。

浏览

GET /gfeh 已认证

对象存储总览:存在哪些分区,以及它们各自处于什么状态。

主体

分区中的用户。创建一个主体需要名称、父级和权限上限——但不需要密码, 这正是界面从不索要密码的原因。上限遵循 gfeh 的投影规则: Town OS 管理员为 all,其余为读/写。

GET /gfeh/principals 已认证

列出某个分区中的主体。

POST /gfeh/principals/add 授权

在某个父级之下创建主体,并设定其权限上限。

POST /gfeh/principals/remove 授权

删除一个主体。

授权

即访问控制列表。gfehd把授权收窄到主体的权限上限, 因此客户端应当展示返回回来的权限,而不是自己发出去的那些: 管理员必须能够看出某项授权被收窄了。

GET /gfeh/grants 已认证

列出授权,也可以只列出某一个主体的。

POST /gfeh/grants/add 授权

给某个主体授予访问权限。响应中带有实际存储下来的权限。

POST /gfeh/grants/revoke 授权

按 id 撤销一项授权。

公开暴露

一条已发布的文件链接,服务于 /f/<token>

GET /gfeh/exposures 已认证

列出某个分区中已发布的链接。

POST /gfeh/exposures/withdraw 授权

按 token 撤回一条已发布的链接,使该 URL 不再可用。

DNS

rolodex-dns 容器驱动的一体化本地 DNS 解析器。它为已安装的软件包 管理区域文件和记录,并通过 gRPC Unix 套接字接口提供本地名称解析。

GET /dns/status 已认证

返回 DNS 状态,包括启用标志、运行状态、TLD 和记录数量。

GET /dns/records 已认证

列出所有 DNS 记录。

POST /dns/records/add 管理员

添加一条 DNS 记录。接受名称、记录类型、取值和 TTL。

POST /dns/records/remove 管理员

按名称和类型移除一条 DNS 记录。

GET /dns/tld 已认证

获取当前的顶级域名设置。

POST /dns/tld 管理员

设置 TLD。会更改现有的 TLD 并重新注册所有已安装的软件包。

POST /dns/setup 管理员

初始化或重启 DNS 服务器,并注册所有已安装的软件包。

屏蔽列表

这是两份互相独立的列表。DNSBL 基于订阅——由 rolodex 拉取并应用的上游屏蔽列表—— 并配有一份放行列表,用来豁免那些无论上游列表怎么说都希望能够解析的名称。 本地屏蔽列表(RBL)则是本机自己的列表,逐条编辑。

GET /dns/dnsbl 已认证

获取 DNSBL 配置:订阅了哪些上游屏蔽列表,以及如何应用它们。

POST /dns/dnsbl 管理员

替换 DNSBL 配置。

GET /dns/dnsbl/allowlist 已认证

列出已从订阅屏蔽列表中豁免的名称。

POST /dns/dnsbl/allowlist/add 管理员

把某个名称从订阅的屏蔽列表中豁免出来。

POST /dns/dnsbl/allowlist/remove 管理员

删除一条放行记录,让订阅的屏蔽列表重新对该名称生效。

GET /dns/rbl/local 已认证

列出本机自有屏蔽列表中的条目。

POST /dns/rbl/local/add 管理员

向本地屏蔽列表添加一个名称。

POST /dns/rbl/local/remove 管理员

从本地屏蔽列表中移除一个名称。

按服务发布 DNS

GET /dns/services 已认证

列出已安装的服务,以及每个服务是否发布了 DNS 名称。

POST /dns/services/set 管理员

为某个服务开启或关闭 DNS 发布,使软件包可以在不占用网络名称的情况下运行。

监控

一体化的 Prometheus、Node Exporter 与 Grafana 组合,用于系统监控。 整套组件以受 systemd 监管、带 Restart=always 的 podman 容器运行。

GET /monitoring/status 已认证

返回每个监控服务的容器状态(名称、镜像、运行状态、端口)。 当未配置监控时返回 {"status": "disabled"}

如何取得面板数据

系统控制器上并不存在任何反向代理。监控数据在自己专用的 5308 端口上提供,浏览器直接与该端口通信;控制器自身的端口(5309)只承载 /monitoring/status。5308 上监听什么,取决于所配置的后端:

  • uPlot 模式(默认)——由一个 socat 转发器把 Prometheus 的 HTTP API 暴露在 5308 上, 界面直接查询 /api/v1/query_range,并自行绘制图表。
  • Grafana 模式——Grafana 通过 podman 的端口映射直接监听 5308,界面用 iframe 将其嵌入。

TOWN_OS_MONITORING_PORT 可以改变面板端口; TOWN_OS_PROMETHEUS_PORTTOWN_OS_NODE_EXPORTER_PORT 对两个环回端口起同样作用。

系统服务

系统服务是由 systemd 管理的基础设施容器(与用户安装的软件包服务不同)。 它们使用 town-os-system-- 作为单元名前缀。

GET /system-services 公开 / 已认证

列出系统服务及其实时单元状态。从本机访问时无需认证。 每个条目包含键名、显示名称、镜像、端口和 systemd 单元状态字段。

POST /system-services/status 管理员

控制某个系统服务。接受 keyactionstartstoprestart)。

POST /system-services/refresh 管理员

刷新系统服务的单元文件和状态。

语言区域

系统的国际化语言区域信息。

GET /locales 已认证

返回当前语言区域、已填充的语言区域列表、常见语言(含其母语字形名称) 以及扩展语言区域。使用 BCP 47 语言代码。

虚拟机镜像

管理虚拟机软件包所用的、已缓存的虚拟机磁盘镜像。远程镜像会被下载, 并通过 qemu-img convert 转换为 raw 格式;转换后的镜像缓存在 vm-images 子卷中。

GET /vm-images 已认证

列出已缓存的虚拟机磁盘镜像。返回每个镜像的名称和文件大小。

POST /vm-images/upload 管理员

从某个 URL 下载虚拟机镜像并转换为 raw 格式。接受一个 URL 和可选的名称。 名称默认取自 URL 中的文件名,并加上 .raw 扩展名。 下载超时为 30 分钟。

POST /vm-images/delete 管理员

按名称移除一个已缓存的虚拟机镜像。

对象存储管理 API(gfeh)

以上都是 Town OS 的 API,通常应用程序应当使用它。在它之下, 每个 gfehd 分区还有自己的管理接口: 以 JSON over HTTP 提供,并且只在它的 Unix 套接字上,绝不监听端口。

这个接口上既没有令牌,也没有身份验证。套接字的文件系统权限本身就是访问控制, 因此能够访问到它,就已经意味着是这台机器上的 root。套接字位于 btrfs 卷上, 因为那是 gfehd 容器与系统控制器容器都能看到的唯一一个文件系统。

调用方法与路径用途
HealthGET /v1/health存活探测,同时也用作就绪探测。
NamesGET /v1/names该分区希望发布的名称。
ListPrincipalsGET /v1/principals该分区的用户森林。
CreatePrincipalPOST /v1/principals接受 nameparentceiling——不含密码。
DeletePrincipalDELETE /v1/principals/<name>移除一个主体。
ListGrantsGET /v1/grants?principal=访问控制列表,可只取某一个主体的。
CreateGrantPOST /v1/grants授予访问权限;会被收窄到主体的上限。
RevokeGrantDELETE /v1/grants/<id>撤销一项授权。
ListExposuresGET /v1/exposures已发布的 /f/<token> 链接。
WithdrawExposureDELETE /v1/exposures/<token>停止提供某条已发布的链接。

gfehd 会把内部错误映射为 HTTP 状态码——404、409、400—— 而 Go 客户端再把它们映射回哨兵错误,因此 errors.Is 在跨越套接字边界之后依然可用。

对于名为 <network> 的网络,其分区的文件位置如下:

内容位置
分区数据<btrfsBase>/gfeh/<network>,挂载于 /data/<network>
配置<btrfsBase>/gfeh-control/<network>/gfehd.yaml
管理套接字<btrfsBase>/gfeh-control/<network>/run/admin.sock
单元town-os-system--gfeh-<network>.service

DNS gRPC API(rolodex)

上面的 /dns/* 端点是 Town OS 视角下的 DNS。rolodex 本身通过 gRPC 管理,暴露在一个 Unix 套接字上(默认为 /var/run/rolodex-dns.sock),也可以选择暴露在 TCP 上。 开箱即用时,套接字是唯一的管理通道——grpc.tcp_bind 为空。

只有一个服务 rolodex_dns.RolodexDnsService,包含 74 个方法。 所有路径均为 /rolodex_dns.RolodexDnsService/<方法名>。 完整的消息定义位于 rolodex-dns 仓库的 proto/rolodex_dns.proto; 下面的分组说明这些方法各自覆盖了什么。

记录与解析

方法用途
AddRecord向本地数据库添加一条 DNS 记录。
RemoveRecord从本地数据库移除记录。
ListRecords按可选过滤条件查询本地数据库。
SetForwarders配置上游转发器。
SetResolutionMode / GetResolutionMode在运行时更改并读取解析模式。
GetSearchDomains某个客户端 IP 对应的搜索域。
FlushCache清空 DNS 与屏蔽列表缓存。

权威区域

方法用途
AddAuthoritativeZone把某个区域声明为权威区域。
RemoveAuthoritativeZone把某个区域从权威列表中移除。
ListAuthoritativeZones列出所有权威区域。

网络范围

范围是 rolodex 划分「谁能解析什么」的方式,也是 Town OS 网络所映射到的对象。 IP 与范围的关联带有 TTL,需要定期刷新。

方法用途
CreateNetworkScope / DeleteNetworkScope / ListNetworkScopes管理范围。删除一个范围会连同它的记录与关联一起删除。
JoinNetwork / LeaveNetwork把客户端 IP 关联到某个范围,或解除该关联。
GetNetworkAssociations读取 IP 与范围的关联。
AddScopedRecord / RemoveScopedRecord / ListScopedRecords只在某个范围内存在的记录。

范围 TLD

按网络归属的区域,在各网络之间划分。

方法用途
AddScopeTld / RemoveScopeTld / ListScopeTlds把一个全局唯一的 TLD 注册为某个范围所有。
SetScopeTldForwarders / ListScopeTldForwarders某个范围的 TLD 所对应的对端转发器。
ListScopeTldListeners绑定到某个范围各 TLD 上的 ingress DNS 监听器。

屏蔽列表

方法用途
SetDnsblConfig / GetDnsblConfig基于订阅的域名屏蔽列表配置。
AddDnsblAllowlistEntry把某个名称及其子域从基于名称的屏蔽检查中豁免。
RemoveDnsblAllowlistEntry / ListDnsblAllowlistEntries管理放行列表。
AddLocalBlocklistEntry / RemoveLocalBlocklistEntry / ListLocalBlocklistEntries本机自有的屏蔽列表。

加密传输

每种传输都有对应的设置与读取方法。DoH 提供 HTTP/2,并在 enable_h3 开启时, 在同一地址、同一端口、同一证书上提供 HTTP/3。

方法用途
SetDotConfig / GetDotConfigDNS over TLS。
SetDohConfig / GetDohConfigDNS over HTTPS,含 HTTP/3。
SetDoqConfig / GetDoqConfigDNS over QUIC。
SetProxyConfig / GetProxyConfigHTTP 代理配置。

DNSSEC、DANE 与 ACME

方法用途
GenerateDnssecKey / ListDnssecKeys / DeleteDnssecKey按区域管理 DNSSEC 密钥材料。
GetDsRecords某个区域的 DS 记录。
SignZone用区域自己的 DNSSEC 密钥为其签名。
GenerateTlsaRecord / ListTlsaRecords由证书生成的 TLSA 记录。
GenerateDaneRootCa生成用于 DANE 的根 CA 证书。
EnsureZoneCa确保某个区域拥有 CA。
RequestAcmeCert / GetAcmeStatus通过 ACME DNS-01 申请证书,并查询其状态。
CreateEabCredential / RemoveEabCredential签发限定于某个区域的外部账户绑定(kid 加 HMAC),供 ACME 客户端的 newAccount 使用。
ListAcmeAccounts / ListAcmeCertificates已注册的 ACME 账户与已签发的证书。

DHCP

方法用途
AddDhcpPool / RemoveDhcpPool / ListDhcpPools在某个范围内用于分配的地址池。
ListDhcpLeases / DeleteDhcpLease地址租约,按 MAC 地址删除。
SetDhcpCertOption / RemoveDhcpCertOption / ListDhcpCertOptions为某个范围通过 DHCP 下发给客户端的证书。

诊断与调优

方法用途
GetCacheStats / FlushDnsCache缓存统计,以及清空响应缓存。
GetQueryLatencyStats上游查询延迟。
SetTtlDriftConfig / GetTtlDriftConfigTTL 漂移配置。
SetTrackedTlds / ListTrackedTlds按 TLD 指标背后所跟踪的 TLD 列表,包括已存储的与实际生效的。
SetDns64Config / GetDns64ConfigDNS64 配置。

客户端库

Town OS 自带 Go 和 JavaScript 客户端库,覆盖完整的 API。 两个客户端在遇到非 200 响应时,都会依据 RFC 9457 的问题详情抛出带类型的错误。

Go 客户端

Go 客户端位于 src/svc/systemcontroller/client.go,实现了 Client 接口。它同时支持 Unix 套接字和 HTTP 连接。

// 通过 Unix 域套接字连接(生产环境)
client := systemcontroller.InitClient("/run/town-os/systemcontroller.sock")

// 通过 HTTP 连接(开发 / 测试)
client := systemcontroller.FromClient(http.DefaultClient, "http://localhost:5309")

完成身份验证后请设置 client.Token。所有方法的第一个参数都是 context.Context

存储

方法说明
CreateFilesystem(ctx, fs)创建一个新的 btrfs 子卷。
ModifyFilesystem(ctx, name, fs)重命名文件系统或调整其大小。
RemoveFilesystem(ctx, name)按名称删除文件系统。
ListFilesystems(ctx, prefix, state, params)按名称前缀和状态("user""installed""uninstalled")过滤的分页列表。

仓库

方法说明
AddRepository(ctx, name, rawURL, username, password)注册一个软件包仓库,凭据可选。
RemoveRepository(ctx, name)按名称移除仓库。
MoveRepository(ctx, name, position)调整优先级(0 = 最高)。
RefreshRepositories(ctx)刷新所有元数据。返回错误映射。
ListRepositories(ctx, params)仓库的分页列表。

软件包

方法说明
ListPackages(ctx, params)可用软件包的分页列表。
ListPackagesByRepo(ctx, params)按仓库分组的软件包。
ListPackageVersions(ctx, name)某个软件包的可用版本。
GetPackageQuestions(ctx, name)按名称获取配置提问。
GetPackageQuestionsByIdentity(ctx, repo, name, version)特定版本的提问。
ListChildren(ctx, repo, name)子软件包的名称。
InstallPreview(ctx, repo, name, version)不实际安装,预览卷与端口。
InstallPackage(ctx, name, version, responses, reuseVolumes, importFromVersion, skipResponseReuse)安装一个软件包。name 采用 "repo/package" 格式。
UninstallPackage(ctx, repo, name, version, purgeVolumes)移除一个已安装的软件包。
DisablePackage(ctx, repo, name)停止服务但不卸载。
EnablePackage(ctx, repo, name)重新启用一个已禁用的软件包。
PurgeVolumes(ctx, repo, name)删除某个软件包的全部数据卷。
ListUninstalledVolumes(ctx, repo, name)检查是否有遗留的卷。
PurgeUninstalledVolumes(ctx, repo, name)删除遗留的卷。
ListInstalled(ctx, params)"repo/name@version" 形式列出已安装的软件包。
GetResponses(ctx, repo, name, version)已保存的配置回答。
GetInstalledInfo(ctx, repo, name, version)详细信息,含提问、回答与说明。

Systemd

方法说明
ListUnits(ctx, params)systemd 单元的分页列表。
SetUnitStatus(ctx, name, action)执行 "start""stop""restart"
LogReplay(ctx, name)通过 SSE 推送 journal 条目。返回一个 channel。
LogTail(ctx, params)一页 journal 条目,支持游标分页、grep、时间范围和优先级过滤。

账户

方法说明
Authenticate(ctx, username, password)返回会话令牌和账户。
CreateAccount(ctx, username, password, email, phone, realName, admin)创建一个用户。密码至少 8 个字符。
GetAccount(ctx, username)按用户名获取账户。
UpdateAccount(ctx, username, fields)修改账户字段(password、email、phone、real_name、admin)。
ListAccounts(ctx, params)账户的分页列表。
DisableAccount(ctx, username)阻止其进行身份验证。
EnableAccount(ctx, username)重新启用一个已禁用的账户。
ListSessions(ctx, token)该令牌所属用户的活动会话。
SessionUsername(ctx, token)会话令牌对应的用户名。
RevokeSession(ctx, sessionID)使某个会话失效。

审计、设置与升级

方法说明
ListAuditLog(ctx, opts, token)带过滤条件的分页审计日志。
GetSettings(ctx)以键值映射返回全部设置。
GetSetting(ctx, key)按键获取单个设置项。
SetSetting(ctx, key, value)更新某个设置项。
ListUpgrades(ctx)有新版本可用的软件包。
DismissUpgrades(ctx)把待处理的升级标记为已忽略。

归档

方法说明
UploadArchive(ctx, subvolume, archiveReader, filename, subpath, stopService)上传归档并解压到某个子卷。支持格式:tar.gz、tar.bz2、tar.xz。
DownloadArchive(ctx, subvolume, paths, stopService, format)为子卷内容创建归档。返回一个 io.ReadCloser

健康检查

方法说明
Ping(ctx)服务健康状况与各项计数摘要。

JavaScript 客户端

JavaScript 客户端位于 ui/src/api/,由 Town OS 仪表盘界面使用。 它以一组模块化的 mixin 构建在 SystemControllerClient 类之上。 非 200 响应会抛出 ApiError,其中带有解析后的 RFC 9457 问题详情。

import SystemControllerClient from './api/client.js';

const client = new SystemControllerClient('http://localhost:5309');

// 身份验证之后
const result = await client.authenticate('admin', 'password');
client.setToken(result.token);

存储

方法说明
createFilesystem(fs)创建一个新的 btrfs 子卷。
modifyFilesystem(name, fs)重命名文件系统或调整其大小。
removeFilesystem(name)按名称删除文件系统。
listFilesystems(prefix, sortBy, sortOrder, state, limit, offset, search)带过滤条件的分页列表。

仓库

方法说明
addRepository(name, url, username?, password?)注册一个仓库,凭据可选。
removeRepository(name)按名称移除仓库。
moveRepository(name, position)调整优先级(0 = 最高)。
refreshRepositories()刷新所有元数据。返回错误映射或 null。
listRepositories(sortBy, sortOrder, limit, offset, search)分页列表。

软件包

方法说明
listPackages(sortBy, sortOrder, limit, offset, search)可用软件包的分页列表。
listPackagesByRepo(search)按仓库分组的软件包。
listPackageVersions(name)某个软件包的可用版本。
getPackageQuestions(name)按名称获取配置提问。
getPackageQuestionsByIdentity(repo, name, version)特定版本的提问。
installPreview(repo, name, version)不实际安装,预览卷与端口。
installPackage(repo, name, version, responses, reuseVolumes?, importFromVersion?)带配置回答安装一个软件包。
uninstallPackage(repo, name, version, purgeVolumes?)移除一个已安装的软件包。
disablePackage(repo, name)停止服务但不卸载。
enablePackage(repo, name)重新启用一个已禁用的软件包。
purgeVolumes(repo, name)删除某个软件包的全部数据卷。
listUninstalledVolumes(repo, name)检查是否有遗留的卷。
purgeUninstalledVolumes(repo, name)删除遗留的卷。
listInstalled(sortBy, sortOrder, limit, offset, search)"repo/name@version" 形式列出已安装的软件包。
getResponses(repo, name, version)已保存的配置回答。
getInstalledInfo(repo, name, version)详细信息,含提问、回答与说明。

Systemd

方法说明
listUnits(sortBy, sortOrder, limit, offset, search)systemd 单元的分页列表。
setUnitStatus(name, action)执行 "start""stop""restart"
logReplay(unit)通过 SSE 推送 journal 条目。返回一个 AsyncGenerator
logTail(unit, lines?, before?, after?, grep?, since?, until?, priority?)一页 journal 条目,支持游标分页、grep、时间范围和优先级过滤。

账户

方法说明
authenticate(username, password)返回会话令牌和账户。
createAccount(username, password, email, phone, realName, admin)创建一个用户。密码至少 8 个字符。
getAccount(username)按用户名获取账户。
updateAccount(username, fields)修改账户字段。
listAccounts(sortBy, sortOrder, limit, offset, search)账户的分页列表。
disableAccount(username)阻止其进行身份验证。
enableAccount(username)重新启用一个已禁用的账户。
listSessions(token)该令牌所属用户的活动会话。
sessionUsername(token)会话令牌对应的用户名。
revokeSession(sessionID)使某个会话失效。

审计、设置与升级

方法说明
listAuditLog(opts)带过滤条件的分页审计日志。
getSettings()以键值对象返回全部设置。
getSetting(key)按键获取单个设置项。
setSetting(key, value)更新某个设置项。
listUpgrades()有新版本可用的软件包。
dismissUpgrades()把待处理的升级标记为已忽略。

归档

方法说明
uploadArchive(subvolume, file, subpath?, stopService?)通过 FormData 上传并解压归档。返回 {needs_restart, message}
downloadArchive(subvolume, paths?, stopService?, format?)下载子卷归档。返回原始的 Response 以便流式处理。

健康检查

方法说明
ping()服务健康状况与各项计数摘要。

开发参考

Town OS 后端运行在 5309 端口,Vite 开发服务器运行在 5173 端口。 用 make dev 启动完整的开发环境。

核心目标

目标说明
make dev启动完整的开发环境(后端 + Vite 开发服务器)。
make dev-stop停止并删除开发后端容器。
make dev-logs在运行中的开发容器内跟踪 journalctl。
make dev-clean停止容器并拆除开发用的 btrfs 卷。

测试目标

目标说明
make test运行代码检查、Go 单元测试和 JS 单元测试。
make test-integration在特权 Podman 容器中运行 Go 集成测试。
make test-ui-integration针对后端容器运行 Bun 界面集成测试。
make test-full依次运行全部测试套件。
make auto-test监听文件改动并自动重跑测试。

构建目标

目标说明
make production-image构建生产环境容器镜像。
make test-image构建测试用容器镜像。
make pull-images从 Docker Hub 拉取基础容器镜像。

先决条件

  • Go 1.25+
  • Bun——JavaScript 运行时
  • Podman——rootful 模式,需要 sudo
  • btrfs-progs——mkfs.btrfs
  • golangci-lint

创建一个 .env 文件,写入仓库凭据:

TOWN_OS_REPO_USERNAME=<username>
TOWN_OS_REPO_PASSWORD=<password>

装好先决条件之后,请先运行 make pull-images,再执行其他任何目标。