# OSG Account Server 部署指南 本方案使用一个非 root、只读文件系统兼容的 JDK 21 容器。OpenResty 终止 TLS 并反向代理, MySQL 复用已有实例。`compose.yaml` 不会创建数据库容器。 ## 1. 前置条件 - `account.osglab.com` 与 `osglab.com` 已解析到 1Panel 主机。 - Docker Engine、Docker Compose v2 和 1Panel OpenResty 已安装。 - 已有 MySQL 8 实例可从应用容器通过 Docker 外部网络或私网访问。 - 服务器时钟同步,Apple、DeepSeek、Volcengine 的出站 HTTPS/WSS 可用。 - App 的 Associated Domains 包含 `applinks:osglab.com`。 邀请使用一方 Universal Link,不依赖 Firebase Dynamic Links 或 Branch。 ## 2. 注册邀请网页路由 网页实现在 `features/inviteweb`,资源在 `src/main/resources/invite/index.html`。组合根应只注册 一个 `/i/{code}` 路由;不要与旧 `features/invite` 路由同时挂载。`ReferralLookupPort` 是只读、 大小写敏感的验证边界,适配器应在 referrals 模块中用有界查询检查邀请码及活动状态: 先在 Koin 注册一个 `ReferralLookupPort` 实现;公共接线会从现有 `AppConfig` 读取 App Store URL、 邀请域名和 Apple Team ID/bundle ID: ```kotlin routing { configureInviteWebRoutes() } ``` 测试或不使用 Koin 的宿主可调用显式重载 `configureInviteWebRoutes(referralLookup, InviteWebConfig(...))`。 邀请码规则与当前生成器一致:16 字节随机值编码为无填充 Base64URL,即固定 22 位、区分大小写, 字符白名单为 `A-Z`、`a-z`、`0-9`、`_`、`-`。结构非法或查无记录均返回相同 404;查询超时或 失败返回不泄露内部信息的 503。页面和两个 AASA endpoint 均发送 `no-store`。 ## 3. 准备现有 MySQL 先创建数据库,字符集使用 `utf8mb4`。使用 `docs/mysql-minimum-privileges.sql` 创建相互独立的 迁移账号和运行账号,并将示例来源网段替换为实际应用容器网段。不要使用公网来源或 `%`: ```sql CREATE DATABASE osg_account CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; ``` 应用启动时使用迁移账号执行 Flyway,随后 Hikari 连接池只使用运行账号。运行账号不能更新或删除 不可变 ledger/usage 历史,也不能访问 Flyway 元数据或未来未审查的表。禁止授予全局权限、 `FILE`、`PROCESS`、`SUPER`、`CREATE USER`、`GRANT OPTION`。不要复用 root 或其他应用用户。 若 MySQL 是容器,把它和应用接入同一个已存在的外部网络: ```bash docker network create account-backend docker network connect account-backend ``` 已存在网络时不要重复创建。`DATABASE_URL` 中使用该 MySQL 容器在网络内可解析的名称。若 MySQL 位于私网主机,使用私有 DNS/IP,并在数据库防火墙中只允许应用主机或容器网段。 ## 4. 配置环境与秘密 复制部署所需变量到项目根目录的未跟踪 `.env`,或使用 1Panel 的环境变量/秘密管理。不要把真实 值写入 YAML、镜像层或 Git。 必须设置: - `DATABASE_URL=jdbc:mysql://:3306/osg_account?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true` - `DATABASE_USER`、`DATABASE_PASSWORD` - `DATABASE_MIGRATION_USER`、`DATABASE_MIGRATION_PASSWORD`:必须与运行账号及其密码不同 - `JWT_SECRET`:至少 32 个随机字节 - `FIELD_ENCRYPTION_KEY`:恰好 32 个随机字节的 Base64 - `IDENTITY_HMAC_KEY`:至少 32 个随机字节的独立 Base64,不得复用字段加密密钥 - `APPLE_TEAM_ID`、`APPLE_KEY_ID`、`APPLE_PRIVATE_KEY_PEM` - `VOLCENGINE_API_KEY`、`DEEPSEEK_API_KEY` - `APP_STORE_URL`:正式 App Store HTTPS 地址;生产 Compose 不接受缺失值 生成新秘密的示例: ```bash openssl rand -base64 48 openssl rand -base64 32 openssl rand -base64 32 ``` 三个结果分别用于 JWT、字段加密和身份 HMAC,不能相互复用。`APPLE_PRIVATE_KEY_PEM` 可使用包含 字面量 `\n` 的单行值;应用配置会在内存中还原换行。限制 `.env` 权限: ```bash chmod 600 .env ``` 生产 Compose 已固定 `APP_ENV=production`、Apple production 环境及两项完整性强制开关。 Apple 配置使用所属开发者账号的 Team ID、Key ID、bundle ID 和 `.p8` 私钥;火山引擎使用 SAUC v3 WSS endpoint、资源 ID 和 API Key;DeepSeek 使用 HTTPS endpoint、已开通模型名和 API Key。 三方凭据分别创建、分别轮换,不得复用。 ## 5. 构建与启动 先检查变量插值。`docker compose config` 会展开秘密,不要把输出上传或粘贴到工单: ```bash ./gradlew test docker compose config --quiet docker compose build --pull docker compose up -d docker compose ps curl --fail http://127.0.0.1:18080/health/ready ``` 容器以 UID/GID 10001 运行,根文件系统只读,仅 `/tmp` 是带大小限制的 tmpfs。应用端口只映射到 宿主机回环地址,不能改成 `0.0.0.0`。 查看日志时不得记录或复制 token、Apple subject、音频、prompt、转录或模型响应: ```bash docker compose logs --since=10m account-server ``` ## 6. 配置 1Panel / OpenResty 在 1Panel 创建 `account.osglab.com`、`osglab.com` 两个 HTTPS 网站并签发证书。使用 `deploy/openresty-account.conf`: 1. 将 `map`、`limit_req_zone`、`limit_req_status`、`upstream` 放入 OpenResty `http` 上下文。 2. 将三个 `server` 块作为站点配置;按 1Panel 实际证书路径调整 `ssl_certificate`。 3. 示例 upstream 指向宿主机 `127.0.0.1:18080`。若 OpenResty 自身在容器中,则将其加入 `account-backend`,并改为 `account-server:8080`。 4. 配置明确对 `/admin`、`/internal`、`/v1/admin` 返回 404;不要新增绕过该规则的泛域名代理。 5. API 示例按 IP 限制 20 请求/秒,邀请页限制 5 请求/秒,可基于真实流量谨慎调整。 6. 代理统一支持 HTTP/1.1 Upgrade/Connection,因此当前 HTTP API 与后续 WebSocket 入口都可用。 两个 AASA 地址由 Ktor 根据 `appleAppId` 模板输出,不需要复制静态文件。配置检查成功后再通过 1Panel 重载 OpenResty: ```bash openresty -t ``` ## 7. 验证 检查健康状态、反向代理、隐藏路径、限流和 AASA: ```bash curl --fail https://account.osglab.com/health/ready curl -i https://account.osglab.com/internal/ curl -i https://osglab.com/.well-known/apple-app-site-association curl -i https://osglab.com/i/AbCdEf0123456789_-AbCd ``` 预期结果: - 健康检查返回 200。 - 内部路径返回 404。 - AASA 返回 200、`Content-Type: application/json`,且不发生重定向。 - 合法 22 位邀请码返回双语 HTML;非法、未知或失效邀请码统一返回 404。 - 邀请页包含 nonce CSP、`Cache-Control: no-store`、`X-Robots-Tag`,无第三方请求。 使用真机验证 Universal Link。安装带 Associated Domains entitlement 的 App 后,从信息或邮件点击 `https://osglab.com/i/` 应直接进入 App;未安装时应显示网页。AASA 更新受系统缓存影响, 首次验证应预留传播时间。 ## 8. 防火墙 - 公网入站仅允许 TCP 80/443。 - SSH 仅允许管理员固定 IP 或 VPN;不公开 18080、8080、3306。 - MySQL 3306 仅允许 Docker 私网或指定应用主机。 - 出站允许 DNS、NTP、Apple HTTPS、DeepSeek HTTPS、Volcengine HTTPS/WSS。 - 云安全组、主机防火墙和 1Panel 防火墙应采用相同边界,避免其中一层意外放行。 ## 9. 更新与回滚 更新前备份 MySQL 并记录当前镜像标签。使用不可变标签构建: ```bash IMAGE_TAG= docker compose build IMAGE_TAG= docker compose up -d ``` Flyway 迁移只向前执行。若新版本包含数据库迁移,应用镜像回滚不等于数据库回滚;应先按迁移影响 制定恢复方案。无数据库变更时,可把 `IMAGE_TAG` 切回上一版本并重新执行 `docker compose up -d`。 备份、时间点恢复和每月恢复演练按 `docs/BACKUP.md` 执行。 ## 10. 分阶段验收 1. **本地阶段**:`./gradlew test` 通过;合法、未知、格式错误和 lookup 异常路径符合预期;HTML 不加载第三方资源。 2. **容器阶段**:镜像以 UID/GID 10001 运行;根文件系统只读;`/health/live` 与 `/health/ready` 通过;容器无法取得额外 Linux capability。 3. **预发布阶段**:Flyway 使用迁移账号成功,应用使用运行账号成功;撤销运行账号 DDL 权限后服务 仍正常;OpenResty HTTP、HTTPS 和 WSS 代理验证通过。 4. **Apple 阶段**:两个 AASA 地址返回无重定向 JSON;真机从信息或邮件点击 `https://osglab.com/i/` 可打开 App;未安装 App 时显示双语落地页。 5. **生产阶段**:公网仅开放 80/443,3306/8080/18080 不可达;邀请 URL 不出现在访问日志; 供应商凭据可用;完成加密备份并记录一次隔离恢复结果。