在单台 Mac 上实现多租户托管¶
每个租户使用一个原生 macOS 标准用户和一个 OpenBot 实例。共享应用程序是 /Applications/OpenBot.app。客户端连接使用现有的 Team API、WebRTC 以及运营商的 STUN/TURN 基础设施。不涉及 VM 或 Docker。
此设置通过 macOS 的所有权和权限保护租户文件。它不提供针对恶意租户的资源隔离:原生用户共享 CPU、内存、存储容量和网络命名空间。租户可以消耗资源或占用端口。空闲更新需要每个已注册租户配合;一个租户可能阻塞维护。不要在共享原生主机上承诺独立可用性。
数据与权限¶
注册之前,管理员必须为每个 Standard 账户创建私有主目录:所有者为该租户,模式为 0700,且没有授予其他租户访问权限的 ACL。检查主目录元数据时不要打开租户内容。不要共享可写组,也不要向租户授予 Full Disk Access 或管理员权限。不要为租户内容启用共享文件夹。
主目录边界保护 ~/OpenBot、OpenBot 的数据库和浏览器配置文件(位于 ~/Library/Application Support/OpenBot)、.codex、.claude、凭据和对话。租户可以故意共享自己的文件;这不在私有账户策略范围内。
应用程序及其所有真实内容必须由 root 拥有,不得具有组/公共写权限位,也不得具有授予访问权限的 ACL。/Applications 也必须由 root 拥有且不可由租户写入。系统 admin 组可以写入该父目录;公共或租户组的写访问会被拒绝。Framework 符号链接必须保留在应用程序内部。初始设置接受由 root 或安装管理员签名的应用,然后移除组/公共写访问权限并将其分配给 root。它拒绝租户拥有或公共可写的 bundle。它从不更改租户数据。
Remote Desktop 保留独立的 Sunshine 端口族和 Moonlight WebRTC 范围。每个固定的 WebRTC UDP 范围在其第一个端口处有一个 TCP 预留,直到运行时停止。这可以防止不同进程在流传输开始前选择相同范围。分配的端口改变时,已存储的 Moonlight 端点会重新创建。本地 Moonlight 标头具有随机的每进程凭据,仅存储在私有运行时配置中;基于密码的管理员自动注册已禁用。Sunshine 凭据使用固定原生运行时的加盐哈希文件格式,因此明文密码不会出现在进程参数中。
Host Manager 边界¶
一个独立的已编译辅助程序以 root LaunchDaemon app.openbot.host-manager 身份运行。其入口点是 scripts/host-manager.ts。它仅负责以下功能:
- 读取管理员配置和非敏感租户状态。
- 从固定的 OpenBot GitHub 仓库下载最新的稳定版 Apple Silicon 版本。
- 检查代码签名、公证评估、bundle 版本、所有权和权限。
- 等待每个已注册租户报告安全状态持续五分钟。
- 发布停止请求;每个租户重新检查自身状态并自行退出。
- 通过操作系统进程列表验证所有 OpenBot 主进程均已退出。
- 替换共享 bundle,然后验证其签名、权限和已安装版本。
- 发布
released;按用户安装的 LaunchAgents 会在其现有 Aqua 会话中启动 OpenBot。 - 收集最新健康报告,或记录主机错误。
该辅助程序从不打开租户的主目录、工作区、数据库、provider 目录、浏览器配置文件、对话或附件。它不复制或备份租户数据。它从 ps 读取可执行文件路径和 UID,而非进程参数。下载和应用专用暂存均为 root 私有。旧版应用程序在验证后被移除;绝不会用于自动回滚。辅助程序本身由管理员单独更新,而不是由租户或下载的代码更新。
/Library/Application Support/OpenBot/HostManager/ root:wheel 0755
config.json root:wheel 0644; managed flag and registered numeric UIDs
state.json root:wheel 0644; phase, cycle, version, timestamp, error
host-manager root:wheel 0755; compiled daemon
openbot-host root:wheel 0755; administrator CLI
create-tenants root:wheel 0755; native account helper
host-release.json root:wheel 0644; release version, source commit, architecture
openbot-relaunch.sh root:wheel 0755
private/ root:wheel 0700; download and read-only DMG mount
tenants/ root:wheel 0755
<uid>/ tenant:wheel 0700; parent entry cannot be replaced by tenant
status.json tenant-owned; status for that UID only
租户状态包含 UID、PID、版本、心跳、空闲状态、更新周期和健康布尔值。任何用户路径、活动文本、提示或错误详情都不会通过此接口传递。主机检查文件所有者的 UID、有界大小、常规文件类型、单一链接和权限。读取使用 O_NOFOLLOW;写入使用独占临时文件和原子重命名。主机从不写入租户状态目录内部。缺失、过期、格式错误或未注册的状态不能将租户从维护集合中移除。不存在全局可写目录,也不存在选举机制。
工作事件会重置租户五分钟的空闲宽限期,即使任务在两次状态轮询之间完成也是如此。租户在接受停止请求之前,也会检查自己的完整空闲宽限期。在初始化之前以及初始化失败之后,健康和重启就绪状态保持为 false。
当 managed: false 或没有管理员配置时,主机客户端不会发布状态或停止应用,守护进程也不会协调更新。常规桌面更新控件恢复。当 managed: true 时,所有租户更新控件都被禁用,包括下载和安装。主机下载不会使用或更改租户的 autoDownload 偏好设置。
生产安装¶
普通桌面用户只需要 OpenBot-<VERSION>-arm64.dmg。受管的多租户主机还需要同一个 GitHub Release 中的 OpenBot-Host-<VERSION>-arm64.pkg。该 Host 包包含独立的 ARM64 可执行文件、全局 Aqua LaunchAgent、root LaunchDaemon,以及 /usr/local/bin/openbot-host 管理员命令。目标 Mac 无需 Git checkout、Bun、Node、Xcode 或编译。
- 从同一个 release 下载普通 DMG 和 Host PKG。
- 打开 DMG 并将 OpenBot.app 复制到
/Applications。在初次主机设置之前退出 OpenBot。 - 在安装器中打开 Host PKG,或使用下载的文件名运行下面的命令。
- 创建并注册新的 Standard 账户。保存完成时显示的密码。
- 登录
client-acme一次,并登录该客户的 OpenBot 和提供商。 - 使用快速用户切换,登录
client-bravo,并配置该客户的 OpenBot。 - 以主机管理员身份运行验证。
sudo installer -pkg "$HOME/Downloads/OpenBot-Host-<VERSION>-arm64.pkg" -target /
sudo /usr/local/bin/openbot-host setup --create-user client-acme --create-user client-bravo
sudo /usr/local/bin/openbot-host verify
将 <VERSION> 替换为所下载的 release 版本。/usr/local/bin 是标准 PATH 位置;当管理员的 PATH 中不包含它时,绝对命令仍然有效。
对于已有私有主目录的 Standard 账户,请使用:
这些标志可以混合使用。使用不同的小写名称,以字母开头,最多包含 31 个字母、数字、连字符或下划线。--create-user 会拒绝已存在的账户、旧组成员名称和已存在的主目录路径。--tenant 永远不会更改现有密码或主目录。setup --dry-run 会检查安装、账户和已受保护的应用程序,而不会创建账户或更改配置。实际的初次设置可以保护管理员拥有的 DMG 副本。Setup 会拒绝已存在的主机注册;它不是账户重置命令。
只有管理员需要 sudo。Setup 会创建空的 0700 主目录,并在注册前验证 Standard 成员资格。它不授予 sudo、完全磁盘访问权限、自动登录、安全令牌或 FileVault 解锁权限。管理员必须在重启后解锁受 FileVault 保护的主机。
密码包含 192 个随机比特。原生账户工具会将它们通过内存发送到 OpenDirectory,再通过捕获的管道发送到管理员 CLI;它们绝不会进入进程参数、环境变量、主机配置或日志。在设置成功完成时,CLI 会在控制终端上单独显示每个密码一次,与重定向的 stdout 分开。在新账户设置开始之前需要一个终端。请通过你的安全凭据渠道传递每个密码。CLI 在设置期间会在 /private/var/root 下使用一个新的 root 拥有的 0600 恢复文件,并在显示密码后将其删除。如果设置失败,请保留该文件,直到管理员恢复了部分账户。不要将其附加到诊断信息中,也不要提交到代码仓库。
失败的批处理可能会留下已创建的账户、空主目录或注册信息。Setup 会在第一次失败时停止,并且绝不会在重试时删除账户或覆盖现有注册。不要同时运行其他账户工具或软件包升级。崩溃可能会留下 root 拥有的 setup-in-progress 目录;管理员必须先解决部分设置问题,然后才能删除它。
该软件包将 LaunchAgent 全局安装到 /Library/LaunchAgents。已注销的用户会在下次 Aqua 登录时加载它;设置不需要每个用户都登录。每用户包装器只检查其自身的 UID。Acme 运行中无法阻止 Bravo 启动。15 秒的重试覆盖了会话启动和启动失败的情况。不会使用 sudo -u 从 root 启动任何租户 GUI 应用。每个已注册的租户最终都必须处于运行且健康的状态,才能进行自动应用更新。
验证¶
sudo openbot-host verify 检查应用程序签名和权限、所有主机可执行文件签名、固定的 launchd 定义、守护进程状态、root 拥有的配置/状态,以及每个租户的 Standard 成员资格和主目录元数据。它还会在临时主机验证区域中创建无害文件,以每个租户身份测试双向访问,然后删除该区域。它不会递归检查主目录、读取租户文件名/内容或打印凭据。临时区域测试是对下面的实际主目录边界验收检查的补充,而非替代。检查失败时会返回非零退出状态。请在设置和升级后运行验证。
主机基础设施升级¶
以管理员身份安装较新的、已签名并经过公证的 Host PKG。安装程序会拒绝不安全的现有路径以及正在进行/已中断的维护。它会在替换主机可执行文件之前停止守护进程,并在配置存在时重新启动它。初次软件包安装不会启动未配置的守护进程。现有的配置、状态和租户状态目录位于软件包有效负载之外,并保持不变。软件包安装失败可能会导致守护进程停止;在将主机重新投入使用之前,请解决软件包失败问题并运行验证。
OpenBot.app 继续通过 Host Manager 更新。Host Manager 不会自我更新、下载 Host PKG,也不会更改自身的可执行文件。较新的应用程序可以搭配较旧的 Host 包运行。初次设置需要匹配的或较新的应用程序。Host PKG 永远不会列在 latest-mac.yml 中,普通桌面用户也绝不会通过 Electron 自动更新收到它们。
开发者验证¶
仅存在于仓库中的 scripts/install-host-update-agent.sh 仍然是开发设置路径。生产环境使用 release PKG 和已安装的 CLI。Release CI 使用与 OpenBot.app 相同的 release commit 构建二进制文件。原生测试使用假的账户服务和临时文件:
xcrun swiftc -parse-as-library -D TENANT_SETUP_TESTS scripts/macos-tenant-setup.swift scripts/macos-tenant-setup-tests.swift -o /tmp/openbot-tenant-setup-tests
/tmp/openbot-tenant-setup-tests
macOS 测试会构建并展开一个未签名的 fixture 软件包,而不安装它。生产软件包验证还需要 Developer ID 签名、公证(notarization)、装订(stapling)、精确的所有权/模式清单、在 PATH 中不含 Bun/Node 时的独立启动检查,以及预期的源脚本。
Live status¶
sudo openbot-host status 打印一个只读快照:管理标志、守护进程状态、阶段、暂存版本、更新周期、记录的错误,以及每个已注册租户一行,包含其版本、健康状态、工作状态,以及它阻止维护的原因。--json 为脚本提供同样的报告。sudo openbot-host watch [--interval <seconds>] 会重复输出快照,默认每五秒一次。两个命令都读取 config.json、state.json、每个租户的状态文件,以及守护进程从 ps 中读取的同一个可执行路径和 UID。声明属于另一个 UID 的状态文件会被忽略,与守护进程忽略它的方式完全一致。这些命令从不写入主机状态,从不读取进程参数,从不打开租户主目录,也从不启动或停止维护。
每个报告的阻塞项都对应主机在该阶段应用的规则:waiting 使用重启安全性、空闲状态、周期和一个匹配的主进程;stopping 等待应用包内的每一个进程(包括辅助进程);released 使用健康报告、已安装版本和周期。stopping 的阻塞项仅来自进程列表,因为主机在该阶段不读取租户状态;但该阶段仍会读取租户报告,以填充版本和健康列。暂存版本只会在有待处理版本时显示,因为主机在更新完成后仍保留其 version 字段。进程扫描失败会使 status 失败,并让 watch 将读取结果报告为不可用;它绝不会变成空的进程列表。只有在 waiting、stopping 和 released 阶段,租户才会产生阻塞项,因为这些阶段的规则会读取相关信息;在其他所有阶段,其报告仍会显示,但不会阻塞任何内容。未注册 UID 下的 OpenBot 进程总会单独列成一行,包括辅助进程,因为即使在 stopping 期间,仅一个辅助进程仍会持有应用包。该进程只有在 waiting 阶段且仅当是主进程时才会成为摘要,这是主机将其视为阻塞项的唯一情况。当 waiting 或 stopping 状态停止推进时,摘要会报告停滞的守护进程并且不显示倒计时,因为不进行轮询的守护进程既无法启动关闭,也无法启动安装。未注册的主进程是例外:主机在等待该进程时停止发布,因此状态是旧的,而守护进程仍然存活。未管理的主机是另一个例外,原因相同:它保留最后一个状态且不发布任何内容。倒计时仍然继续,因为主机在该条件下不计宽限期。
在主机等待时,每个空闲租户会显示剩余的五分钟宽限期。该值是最早可能的时间,而非承诺:主机从自己首次观察到该空闲报告时开始计算,新的工作会重置宽限期。在 idle 阶段没有倒计时,因为主机在内存中保存下一次发布检查;检查最多每四分钟发生一次。
只有 waiting 和 stopping 阶段会在每次轮询时重写 state.json,因此只有这些阶段才能通过状态年龄显示停滞的守护进程。在其他阶段,请使用所报告的守护进程状态。
State and recovery¶
以管理员身份阅读 state.json。阶段包括 idle、downloading、waiting、stopping、installing、released、aborted 和 failed。在检查实际共享应用包的 CFBundleShortVersionString 之后,新安装的版本会在 released 中公布。下载或安装程序调用的返回永远不会被视为成功。
两小时空闲超时、两分钟关闭超时或十分钟健康超时会记录错误。替换之前发生的失败使用 aborted:旧应用包保持原样,租户仍然可以手动启动它。如果关闭是部分的,主机将验证未更改的已安装应用包,并将其版本记录在 aborted 中。Aqua 代理随后可以在完成各自的签名/版本检查后重新启动已停止的租户。如果验证失败,版本为 null,不会发生自动重启。网络故障不能阻止核心应用的使用。失败或中断的安装会阻止自动重新启动以及进一步的安装。
停止系统任务,检查只涉及应用本身的暂存内容以及已安装的签名/版本,并解决错误。在所有租户都已停止且应用包通过验证后,管理员可以将 state.json 重置为 idle,附带一个新的空周期和 null 版本,然后重新启动系统任务。替换运行期间不要重置状态。租户迁移其数据库后不要降级。不会创建或假定任何租户数据备份。
要禁用管理,请停止系统守护进程,并在 root 拥有的配置中以原子方式将 managed 设置为 false。租户将重新获得普通的桌面控制权。共享的 root 拥有的应用仍需要管理员维护;不要使其可被租户写入。要重新启用管理,请在重新启动守护进程之前确认没有中断任何维护。
Target-host acceptance (required before paying-client use)¶
这不能由在单个 UID 下运行的测试取代:
- 从 Acme 出发,在 Bravo 的私有主目录中列出/读取/创建/替换一个无害测试文件的尝试必须失败;以相反方向重复同样的操作。每个租户创建自己的测试文件。
- 两个租户在共享应用中创建或删除测试条目以及替换其可执行文件的尝试都必须失败。除了模式位,还要检查 ACL。不要为此测试修改实际的可执行文件。
- 确认每个租户都不能创建另一个 UID 的状态、替换主机配置/状态,或用符号链接替换其父状态目录。
- 运行两个 Remote Desktop 会话,按相反顺序重启,并确认两者都进入各自的屏幕。尝试使用另一个会话的本地 Moonlight 标头发送请求,并确认请求被拒绝。
- 演练忙碌的代理、provider 活动、排队的 channel 工作、文件传输、浏览器控制和 Remote Desktop。每一项都会阻塞维护;忙碌状态会重置五分钟宽限期。
- 将两个租户的下载偏好都设置为关闭。确认在它们工作时仍会发生一次主机下载。
- 使用一个已签名的较新构建。确认所有租户退出,应用包替换完成,然后才出现
released。确认 Acme-first 和 Bravo-first 两种顺序下,它们都在各自的 Aqua 会话中重新启动。 - 模拟维护期间守护进程停止,以及版本验证失败。确认没有成功标记或自动重新启动,并检查主机错误。
- 确认为新版本生成新的健康报告。禁用并重新启用管理,并验证未管理模式绝不会请求自动关闭租户。
用于此变更的开发计算机上没有 /Applications/OpenBot.app,没有原生的 Remote Desktop 运行时构件,也没有双租户验收环境。实际的签名评估、DMG 安装、跨 UID 权限以及 Aqua 重新启动仍需在目标主机上运行。
本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 openbo/openbot