diff --git a/.dockerignore b/.dockerignore index 14c4d77..00d1a0f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,17 @@ .git .github +.env +.env.* +node_modules **/node_modules +dist **/dist coverage -.env +**/coverage *.log -docs -*.png +apps/miniprogram/.miniprogram +apps/miniprogram/miniprogram_npm +mysql-data +minio-data +redis-data +docs/ui_reference.png diff --git a/README.md b/README.md index 2f41138..4a9108d 100644 --- a/README.md +++ b/README.md @@ -35,3 +35,9 @@ pnpm build 集成测试只允许连接数据库名为 `lehuo_test` 的 MySQL。可先运行 `docker compose --profile test up -d mysql-test`,再设置 `TEST_DATABASE_URL` 后执行测试。 项目约束、当前阶段和决策分别见 `docs/01_PROJECT_CONSTITUTION.md`、`docs/10_TASK_STATE.md`、`docs/11_DECISIONS.md`。 + +## Ubuntu 26.04 云服务器部署 + +Ubuntu 26.04 LTS 单机部署包位于 `infra/ubuntu/`,提供 Docker 官方源安装、生产环境密钥生成、双域名 TLS、私有 MySQL/Redis/MinIO、API/管理后台镜像、migration、备份恢复、冒烟和回滚脚本。完整步骤见 `infra/ubuntu/README.md`。 + +该部署包不替代 staging、UAT、微信合法域名/真实 AppID、隐私法务、告警接收与异地备份验收;外部项未完成前不得宣称已生产上线。 diff --git a/apps/admin-web/Dockerfile b/apps/admin-web/Dockerfile new file mode 100644 index 0000000..d493ccd --- /dev/null +++ b/apps/admin-web/Dockerfile @@ -0,0 +1,19 @@ +FROM node:24.13.0-alpine AS build +WORKDIR /app +RUN corepack enable +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./ +COPY apps/admin-web/package.json apps/admin-web/package.json +COPY packages/contracts/package.json packages/contracts/package.json +RUN pnpm install --frozen-lockfile +COPY apps/admin-web apps/admin-web +COPY packages/contracts packages/contracts +ARG VITE_API_BASE_URL +RUN test -n "$VITE_API_BASE_URL" +ENV VITE_API_BASE_URL=$VITE_API_BASE_URL +RUN pnpm --filter @lehuo/admin-web build + +FROM nginx:1.28.0-alpine AS runtime +COPY --from=build /app/apps/admin-web/dist /usr/share/nginx/html +COPY infra/ubuntu/nginx/default.conf.template /etc/nginx/templates/default.conf.template +RUN rm -f /etc/nginx/conf.d/default.conf +EXPOSE 80 443 diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 9da4a3e..a330197 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -12,12 +12,13 @@ RUN DATABASE_URL=mysql://build:build@127.0.0.1:3306/build pnpm --filter @lehuo/a FROM node:24.13.0-alpine AS runtime ENV NODE_ENV=production WORKDIR /app -RUN corepack enable COPY --from=build /app/package.json /app/pnpm-lock.yaml /app/pnpm-workspace.yaml ./ COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/apps/api/package.json ./apps/api/package.json COPY --from=build /app/apps/api/node_modules ./apps/api/node_modules COPY --from=build /app/apps/api/dist ./apps/api/dist +COPY --from=build /app/apps/api/prisma ./apps/api/prisma +COPY --from=build /app/apps/api/prisma.config.ts ./apps/api/prisma.config.ts COPY --from=build /app/packages/contracts/package.json ./packages/contracts/package.json COPY --from=build /app/packages/contracts/dist ./packages/contracts/dist USER node diff --git a/docs/10_TASK_STATE.md b/docs/10_TASK_STATE.md index 1fd9b7c..d3cf395 100644 --- a/docs/10_TASK_STATE.md +++ b/docs/10_TASK_STATE.md @@ -6,6 +6,20 @@ 2026-08-15 +任务:生成 Ubuntu 26.04 LTS 云服务器部署版本。 + +完成:新增独立 `infra/ubuntu` 生产部署包,采用 Nginx/管理后台、API、MySQL 8.4、Redis 7.4、私有 MinIO 的单机 Docker Compose 基线,仅发布 80/443。提供 Ubuntu 26.04 Docker 官方仓库安装、生产环境文件生成与 600 权限保护、TLS/域名/磁盘/Compose 预检、不可变镜像构建、数据服务初始化、migration、首次管理员初始化、HTTPS/readiness 冒烟、应用镜像回滚、MySQL 校验和备份、受控恢复和 systemd 每日备份定时器。API 生产镜像包含 Prisma migration 与管理员初始化产物且运行时不依赖 Corepack 下载;管理后台使用独立生产镜像和 Nginx HTTPS 网关。同步补充根目录、国内部署、发布清单与生产就绪文档,新增 D-024。 + +范围:未改变业务架构、认证、数据库 schema、API 契约、权限、状态机或 V1.0 产品范围;没有把单机基线扩展为 Kubernetes、微服务或多租户。部署包是可复现交付,不代表真实生产发布条件已经满足。 + +验证:全仓格式检查、Lint、类型检查、28 个单元测试、38 个真实 MySQL 集成测试和生产构建全部通过;测试库 12 个 migration 无待执行项,小程序 28 个页面完整性与运行时依赖门禁通过。Ubuntu Compose 配置、全部 Bash 脚本语法、22 个控制器权限扫描和 npm 官方 High 级依赖审计通过;MySQL、Redis、MinIO、MinIO Client 的固定镜像标签均可拉取;API 与管理后台生产镜像构建通过,最终镜像内 Prisma 7.9.1 可直接执行,管理端静态资源完整且双域名 HTTPS Nginx 配置通过真实 `nginx -t`。 + +遗留:部署前仍需用户提供两个已备案合法 HTTPS 域名及 TLS 证书、微信 AppID/AppSecret 与合法域名配置,并在真实服务器确认防火墙、安全组、DNS、告警接收方和异地加密备份目标;随后执行 staging、恢复演练、UAT 和生产发布审批。当前未使用示例域名或测试密钥冒充生产配置。 + +--- + +2026-08-15 + 任务:发布前安全与环境配置加固并重新同步仓库。 完成:在既有未提交安全改动基础上完成复核和修正。审计增加原始会话 token 防御性剥离;后台写请求使用精确 Origin 或无 Origin 时的完整 Referer origin 校验,不允许前缀或通配来源;管理 Cookie 仅在 Secure 模式附加 Partitioned,避免本地 HTTP 丢失登录态;私有上传签名由 600 秒缩短到 120 秒;台账导出审计补充关键词条件;数据库启动只记录已完成 migration 数量与最新名称,不把运行时日志冒充待部署校验。小程序开发版保留 localhost 回环调试,体验版/正式版拒绝回环、`0.0.0.0` 和非 HTTPS API,防止不安全发布配置。 diff --git a/docs/11_DECISIONS.md b/docs/11_DECISIONS.md index 20f03b2..032f46d 100644 --- a/docs/11_DECISIONS.md +++ b/docs/11_DECISIONS.md @@ -285,6 +285,20 @@ Excel 在管理端浏览器本地读取和生成,API 只接收固定结构、 --- +## D-024 Ubuntu 26.04 初期生产采用可迁移单机 Compose 基线 + +状态:ACCEPTED + +决定:为用户已准备的 Ubuntu 26.04 LTS 云服务器提供独立 Linux 部署包。初期拓扑在单机 Docker Compose 内运行 Nginx/管理后台、模块化单体 API、MySQL 8.4、Redis 7.4 和私有 MinIO,仅向宿主机发布 80/443;管理后台/API 与签名文件服务使用两个不同的合法 HTTPS 域名。数据、对象和缓存使用独立持久卷,生产环境文件以 600 权限保存在 `/etc/lehuo`,数据库每日备份并生成 SHA-256。 + +API 与管理后台镜像使用批准的 Git commit 作为不可变标签并可本机构建;固定执行预检、构建、迁移前备份、migration、部署、HTTPS/readiness 冒烟和观察。应用回滚只允许切换到本机存在的上一不可变镜像,不自动回滚数据库。自建 MinIO 继续通过既有 S3 Adapter 接入,未来可替换为腾讯 COS 或其他 S3 兼容私有存储,不改变业务服务。 + +该基线是可部署交付,不代表生产发布条件已满足。真实域名/备案/TLS、微信配置、staging、UAT、告警接收、异地加密备份、恢复演练和发布审批仍按 D-016 阻断;单机部署不扩展为 Kubernetes、微服务或多租户架构。 + +原因:在不改变批准技术栈和 V1 边界的前提下,为现有 Ubuntu 云服务器提供可复现、可备份、可回滚的最低复杂度部署路径,并保持未来迁移到托管 MySQL/Redis/COS 的能力。 + +--- + ## 待确认建议 Codex 若有会改变已批准架构的建议,只能写在这里,不得直接实施: diff --git a/docs/13_DEPLOYMENT_CHINA.md b/docs/13_DEPLOYMENT_CHINA.md index 475d699..2324dd6 100644 --- a/docs/13_DEPLOYMENT_CHINA.md +++ b/docs/13_DEPLOYMENT_CHINA.md @@ -153,3 +153,15 @@ CI 自动化测试。 - 审核材料 微信平台规则可能变化,因此上线前必须重新查官方文档,不得只依赖本文件。 + +--- + +## 10. Ubuntu 26.04 单机部署基线 + +- Ubuntu 26.04 LTS 使用 Docker 官方 apt 仓库安装 Docker Engine 与 Compose plugin,不使用生产环境不推荐的 convenience script。 +- 初期单机拓扑采用 Docker Compose:Nginx/管理后台、API、MySQL 8.4、Redis 7.4 和私有 MinIO;仅发布 80/443,其他服务不映射宿主机端口。 +- 管理后台/API 与对象存储使用两个不同的合法 HTTPS 域名;对象存储保持私有 Bucket、版本控制、精确管理端 CORS 和 120 秒签名 URL。 +- API 和管理后台镜像以批准的 Git commit 作为不可变标签,本机构建不使用 `latest`;发布顺序仍为预检、构建、备份、migration、部署、冒烟和观察。 +- 环境文件固定保存到 `/etc/lehuo/lehuo.env` 且权限为 600;TLS、备份和发布状态分别保存在 `/opt/lehuo/shared`、`/opt/lehuo/backups` 和 `/opt/lehuo/releases`。 +- 每日备份由 systemd timer 执行并生成 SHA-256;本机 30 天保留不替代异地加密备份和季度独立库恢复演练。 +- 完整资产与执行命令见 `infra/ubuntu/README.md`。该基线只提供可部署版本,不代表真实 staging、UAT、微信联调或 production approval 已完成。 diff --git a/docs/14_RELEASE_CHECKLIST.md b/docs/14_RELEASE_CHECKLIST.md index dfd8cef..ac8eb3e 100644 --- a/docs/14_RELEASE_CHECKLIST.md +++ b/docs/14_RELEASE_CHECKLIST.md @@ -50,8 +50,13 @@ - [x] health - [x] monitor - [x] alert +- [ ] Ubuntu 26.04 host preflight pass +- [ ] only 80/443 exposed by host/cloud firewall +- [ ] TLS certificate covers admin/API and file domains - [ ] backup - [x] restore procedure +- [ ] offsite encrypted backup configured +- [ ] systemd backup timer verified - [ ] rollback image available ## UAT diff --git a/docs/17_PRODUCTION_READINESS_PLAN.md b/docs/17_PRODUCTION_READINESS_PLAN.md index 0f50350..2e3a6a9 100644 --- a/docs/17_PRODUCTION_READINESS_PLAN.md +++ b/docs/17_PRODUCTION_READINESS_PLAN.md @@ -66,6 +66,8 @@ Phase 0–7 的可自动化开发与本地生产工程验证已完成。当前 当前证据:统计/导出、health/metrics、权限扫描、官方依赖审计、密钥扫描、CodeQL 工作流、备份/恢复脚本、Nginx、immutable image Compose、预检/冒烟和告警规则已完成;本地恢复演练源库与恢复库均为 42 张表。真实 staging、UAT、告警接收和生产发布尚未执行,因此本退出条件仍未满足。 +2026-08-15 补充 Ubuntu 26.04 单机部署包:增加 Docker 官方源安装、生产 Compose、管理后台/API 镜像、双域名 TLS、私有 MySQL/Redis/MinIO、Linux 预检、migration、备份恢复、初始管理员、systemd 定时备份、HTTPS 冒烟与应用回滚。该交付仅关闭“Ubuntu 可执行部署资产”差距,不改变真实域名、微信、staging、UAT、告警接收、异地备份和发布审批阻断项。 + ## 3. 需要人工或外部环境配合 以下内容不能用测试值替代正式验收: diff --git a/infra/deploy/README.md b/infra/deploy/README.md index 4810f33..62b181b 100644 --- a/infra/deploy/README.md +++ b/infra/deploy/README.md @@ -18,3 +18,7 @@ 6. 异常时将 `IMAGE_TAG` 恢复到上一批准版本重新部署。若 migration 不向后兼容,停止发布并按已验证的数据库恢复步骤执行。 备份默认每日一次,保留 30 天;必须每季度在独立数据库做恢复演练并记录表数量、关键行数和耗时。脚本会校验 SHA-256,恢复目标数据库名必须显式提供。 + +## Ubuntu 26.04 + +原有 `.ps1` 工具保留用于 Windows 运维环境。Ubuntu 26.04 云服务器应使用 `infra/ubuntu/` 中的 Compose、Bash 脚本与 systemd timer;完整步骤见 `infra/ubuntu/README.md`。两套工具遵循相同的不可变标签、migration、备份、冒烟、观察和应用回滚顺序。 diff --git a/infra/ubuntu/README.md b/infra/ubuntu/README.md new file mode 100644 index 0000000..006adb3 --- /dev/null +++ b/infra/ubuntu/README.md @@ -0,0 +1,131 @@ +# Ubuntu 26.04 LTS 单机部署包 + +本目录用于把乐活公益平台部署到一台已安装 Ubuntu 26.04 LTS 的云服务器。Docker 官方已将 Ubuntu 26.04(Resolute)列为 Docker Engine 支持系统:。 + +该部署包适合初期单机 staging/production: + +- 管理后台与 API:`https://PUBLIC_HOST` +- 私有对象存储签名访问:`https://FILES_HOST` +- 仅向宿主机发布 `80/443`;MySQL、Redis、MinIO 与 API 不发布主机端口 +- MySQL 8.4、Redis 7.4、MinIO、NestJS API、Vue 管理后台/Nginx 均使用 Docker Compose +- 数据、对象和 Redis 使用独立持久卷 +- 自动 migration、上线前备份、HTTPS 冒烟、不可变 Git 提交标签和应用回滚 +- 每日 MySQL 备份、SHA-256 校验、30 天本机保留;异地加密同步仍须由运维配置 + +这不是“已完成生产上线”的证明。真实域名/备案、TLS、微信配置、隐私材料、staging、UAT、告警接收人和异地备份仍是发布阻断项。 + +## 1. 云服务器准备 + +建议最低资源:4 vCPU、8 GiB 内存、100 GiB SSD;测试环境可降低,但生产容量必须以压测结果为准。云安全组仅开放: + +- SSH 管理端口(优先限制管理 IP,端口以服务器实际配置为准) +- TCP 80(TLS 跳转/证书验证) +- TCP 443(管理后台、API、小程序请求与签名文件上传) + +不要开放 3000、3306、6379、9000 或 9001。Docker 发布端口可能绕过 UFW 规则,因此本 Compose 只发布 80/443;仍需用云安全组做外层限制。 + +为以下两个不同域名配置 A/AAAA 记录到服务器,并准备覆盖二者的证书(双域名 SAN 或通配证书): + +- `admin.example.com`:管理后台和 API +- `files.example.com`:私有对象存储签名上传/下载 + +## 2. 放置代码与安装 Docker + +```bash +sudo install -d -m 0755 /opt/lehuo +sudo git clone --branch main https://gitee.com/climber-ge/wechatapp.git /opt/lehuo/app +cd /opt/lehuo/app +sudo bash ./infra/ubuntu/scripts/install-docker.sh +``` + +安装脚本严格检查 Ubuntu 26.04,使用 Docker 官方 apt 仓库,并且不会自动卸载已有容器运行时,也不会自动把普通用户加入等同 root 权限的 `docker` 用户组。 + +## 3. 生成生产环境文件 + +```bash +cd /opt/lehuo/app +sudo bash ./infra/ubuntu/scripts/generate-env.sh \ + --public-host admin.example.com \ + --files-host files.example.com +sudo install -m 0600 /path/to/fullchain.pem /opt/lehuo/shared/tls/fullchain.pem +sudo install -m 0600 /path/to/privkey.pem /opt/lehuo/shared/tls/privkey.pem +``` + +生成结果位于 `/etc/lehuo/lehuo.env`,权限为 `600`,包含随机数据库密码、Redis 密码、对象存储密钥、会话 pepper、审计 HMAC key 和 32 字节 Base64 字段加密密钥。不要提交、复制到聊天、工单或普通日志。 + +首次部署时微信参数可以保持空值且 `NOTIFICATION_WORKER_ENABLED=false`。取得有效 AppID/AppSecret 和全部模板后再填入真实值并重新部署,禁止使用测试值冒充成功。 + +如果不用自带 MinIO 而使用腾讯 COS 或其他 S3 兼容私有对象存储,应单独评审并修改 Compose 服务,同时保持 Adapter、私有 Bucket、短期签名 URL、最小权限密钥与 CORS 边界。 + +## 4. 预检与首次部署 + +```bash +cd /opt/lehuo/app +sudo bash ./infra/ubuntu/scripts/preflight.sh /etc/lehuo/lehuo.env +sudo bash ./infra/ubuntu/scripts/deploy.sh /etc/lehuo/lehuo.env +sudo bash ./infra/ubuntu/scripts/bootstrap-admin.sh /etc/lehuo/lehuo.env +``` + +`deploy.sh` 固定执行:预检 → 以 Git 提交号构建不可变镜像 → 启动数据服务 → 初始化私有 Bucket/CORS/版本控制 →(已有版本时)迁移前备份 → Prisma migration → 部署 API/Nginx → HTTPS/ready/对象存储冒烟。 + +初始管理员脚本从终端隐藏读取密码,不写入环境文件或 shell history;数据库已有后台凭据时服务端会拒绝重复初始化。 + +## 5. 小程序生产地址 + +在提审的小程序代码中把 `apps/miniprogram/miniprogram/app.ts` 的 `apiBaseUrl` 改为: + +```text +https://admin.example.com +``` + +并在微信公众平台配置 request 合法域名 `https://admin.example.com`,uploadFile/downloadFile 合法域名 `https://files.example.com`。体验版/正式版会拒绝 localhost、`0.0.0.0` 和非 HTTPS 地址。 + +## 6. 备份与恢复演练 + +```bash +sudo bash ./infra/ubuntu/scripts/install-backup-timer.sh +sudo systemctl start lehuo-backup.service +sudo journalctl -u lehuo-backup.service --since today + +# 恢复到隔离数据库做演练,不覆盖生产库 +sudo bash ./infra/ubuntu/scripts/restore.sh \ + /opt/lehuo/backups/lehuo-YYYYMMDDTHHMMSSZ.sql.gz \ + lehuo_restore_test --confirm lehuo_restore_test +``` + +备份文件与 `.sha256` 必须同步到异地加密存储。每季度至少在隔离数据库完成一次恢复演练,核对表数量、关键事实行数、耗时并留存记录。 + +## 7. 升级与回滚 + +```bash +cd /opt/lehuo/app +sudo git fetch --all --prune +sudo git switch main +sudo git pull --ff-only + +# 把 IMAGE_TAG 更新为本次批准的完整/短 Git commit;禁止 latest +sudo editor /etc/lehuo/lehuo.env +sudo bash ./infra/ubuntu/scripts/deploy.sh /etc/lehuo/lehuo.env +``` + +应用异常且 migration 向后兼容时: + +```bash +sudo bash ./infra/ubuntu/scripts/rollback.sh /etc/lehuo/lehuo.env +``` + +回滚脚本只切换本机仍存在的上一版 API/管理后台镜像,不回滚数据库。若 migration 不向后兼容,必须停止发布并按已审批的数据库恢复方案处理,不能盲目执行应用回滚。 + +## 8. 日常检查 + +```bash +cd /opt/lehuo/app +sudo docker compose --project-directory infra/ubuntu \ + --env-file /etc/lehuo/lehuo.env -f infra/ubuntu/compose.yaml ps +curl -fsS https://admin.example.com/health/live +curl -fsS https://admin.example.com/health/ready +sudo docker system df +sudo systemctl status lehuo-backup.timer +``` + +日志中禁止输出 token、完整手机号、证件、详细地址、儿童信息或服务描述。容器日志轮转、CPU/内存/磁盘/API 5xx/P95、数据库连接、通知死信和备份缺失告警需接入实际有人值守的监控渠道。 diff --git a/infra/ubuntu/compose.yaml b/infra/ubuntu/compose.yaml new file mode 100644 index 0000000..1ea4c43 --- /dev/null +++ b/infra/ubuntu/compose.yaml @@ -0,0 +1,223 @@ +name: lehuo-production + +x-api-environment: &api-environment + APP_ENV: production + API_PORT: '3000' + DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required} + REDIS_URL: ${REDIS_URL:?REDIS_URL is required} + SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET is required} + SESSION_TOKEN_PEPPER: ${SESSION_TOKEN_PEPPER:?SESSION_TOKEN_PEPPER is required} + AUDIT_IP_HASH_KEY: ${AUDIT_IP_HASH_KEY:?AUDIT_IP_HASH_KEY is required} + FIELD_ENCRYPTION_KEY: ${FIELD_ENCRYPTION_KEY:?FIELD_ENCRYPTION_KEY is required} + ADMIN_WEB_ORIGIN: ${ADMIN_WEB_ORIGIN:?ADMIN_WEB_ORIGIN is required} + ADMIN_COOKIE_SECURE: 'true' + AUTH_SESSION_TTL_SECONDS: ${AUTH_SESSION_TTL_SECONDS:-28800} + WECHAT_SESSION_TTL_SECONDS: ${WECHAT_SESSION_TTL_SECONDS:-7200} + WECHAT_APP_ID: ${WECHAT_APP_ID:-} + WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:-} + WECHAT_TEMPLATE_APPLICATION_SUBMITTED: ${WECHAT_TEMPLATE_APPLICATION_SUBMITTED:-} + WECHAT_TEMPLATE_APPLICATION_ACCEPTED: ${WECHAT_TEMPLATE_APPLICATION_ACCEPTED:-} + WECHAT_TEMPLATE_SERVICE_ARRANGED: ${WECHAT_TEMPLATE_SERVICE_ARRANGED:-} + WECHAT_TEMPLATE_SERVICE_COMPLETED: ${WECHAT_TEMPLATE_SERVICE_COMPLETED:-} + WECHAT_TEMPLATE_FOLLOW_UP_PENDING: ${WECHAT_TEMPLATE_FOLLOW_UP_PENDING:-} + WECHAT_TEMPLATE_ACTIVITY_REGISTRATION_RESULT: ${WECHAT_TEMPLATE_ACTIVITY_REGISTRATION_RESULT:-} + NOTIFICATION_WORKER_ENABLED: ${NOTIFICATION_WORKER_ENABLED:-false} + OBJECT_STORAGE_ENDPOINT: ${OBJECT_STORAGE_ENDPOINT:?OBJECT_STORAGE_ENDPOINT is required} + OBJECT_STORAGE_REGION: ${OBJECT_STORAGE_REGION:-auto} + OBJECT_STORAGE_BUCKET: ${OBJECT_STORAGE_BUCKET:?OBJECT_STORAGE_BUCKET is required} + OBJECT_STORAGE_ACCESS_KEY: ${OBJECT_STORAGE_ACCESS_KEY:?OBJECT_STORAGE_ACCESS_KEY is required} + OBJECT_STORAGE_SECRET_KEY: ${OBJECT_STORAGE_SECRET_KEY:?OBJECT_STORAGE_SECRET_KEY is required} + OBJECT_STORAGE_FORCE_PATH_STYLE: 'true' + +services: + mysql: + image: mysql:8.4.8 + restart: unless-stopped + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_0900_ai_ci + environment: + MYSQL_DATABASE: ${MYSQL_DATABASE:?MYSQL_DATABASE is required} + MYSQL_USER: ${MYSQL_USER:?MYSQL_USER is required} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD is required} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD is required} + TZ: Asia/Shanghai + volumes: + - mysql-data:/var/lib/mysql + networks: + - backend + healthcheck: + test: + - CMD-SHELL + - MYSQL_PWD=$$MYSQL_ROOT_PASSWORD mysqladmin ping -h 127.0.0.1 -u root --silent + interval: 10s + timeout: 5s + retries: 12 + start_period: 40s + security_opt: + - no-new-privileges:true + + redis: + image: redis:7.4-alpine + restart: unless-stopped + command: + - redis-server + - --appendonly + - 'yes' + - --requirepass + - ${REDIS_PASSWORD:?REDIS_PASSWORD is required} + volumes: + - redis-data:/data + networks: + - backend + healthcheck: + test: [CMD-SHELL, 'redis-cli -a "$$REDIS_PASSWORD" ping | grep -q PONG'] + interval: 10s + timeout: 5s + retries: 8 + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD is required} + security_opt: + - no-new-privileges:true + + minio: + image: minio/minio:RELEASE.2025-09-07T16-13-09Z + restart: unless-stopped + command: server /data + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER is required} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD is required} + MINIO_BROWSER: 'off' + MINIO_SERVER_URL: https://${FILES_HOST:?FILES_HOST is required} + volumes: + - minio-data:/data + networks: + - backend + healthcheck: + test: [CMD, curl, -f, http://localhost:9000/minio/health/live] + interval: 10s + timeout: 5s + retries: 8 + security_opt: + - no-new-privileges:true + + minio-init: + image: minio/mc:RELEASE.2025-08-13T08-35-41Z + restart: 'no' + entrypoint: [/bin/sh, /bootstrap/init-minio.sh] + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER is required} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD is required} + OBJECT_STORAGE_BUCKET: ${OBJECT_STORAGE_BUCKET:?OBJECT_STORAGE_BUCKET is required} + ADMIN_WEB_ORIGIN: ${ADMIN_WEB_ORIGIN:?ADMIN_WEB_ORIGIN is required} + volumes: + - ./minio/init-minio.sh:/bootstrap/init-minio.sh:ro + networks: + - backend + depends_on: + minio: + condition: service_healthy + security_opt: + - no-new-privileges:true + + api: + image: lehuo-api:${IMAGE_TAG:?IMAGE_TAG is required} + build: + context: ../.. + dockerfile: apps/api/Dockerfile + restart: unless-stopped + environment: *api-environment + extra_hosts: + - ${FILES_HOST:?FILES_HOST is required}:host-gateway + expose: + - '3000' + networks: + - backend + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + minio-init: + condition: service_completed_successfully + healthcheck: + test: + - CMD + - node + - -e + - fetch('http://127.0.0.1:3000/health/live').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1)) + interval: 15s + timeout: 5s + retries: 8 + start_period: 30s + read_only: true + tmpfs: + - /tmp:size=64m,noexec,nosuid + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + + migrate: + image: lehuo-api:${IMAGE_TAG:?IMAGE_TAG is required} + profiles: [tools] + command: + - ./apps/api/node_modules/.bin/prisma + - migrate + - deploy + - --config + - apps/api/prisma.config.ts + environment: + DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required} + networks: + - backend + depends_on: + mysql: + condition: service_healthy + read_only: true + tmpfs: + - /tmp:size=64m,noexec,nosuid + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + + web: + image: lehuo-admin:${IMAGE_TAG:?IMAGE_TAG is required} + build: + context: ../.. + dockerfile: apps/admin-web/Dockerfile + args: + VITE_API_BASE_URL: https://${PUBLIC_HOST:?PUBLIC_HOST is required} + restart: unless-stopped + environment: + PUBLIC_HOST: ${PUBLIC_HOST:?PUBLIC_HOST is required} + FILES_HOST: ${FILES_HOST:?FILES_HOST is required} + NGINX_ENVSUBST_FILTER: ^(PUBLIC_HOST|FILES_HOST)$$ + ports: + - '80:80' + - '443:443' + volumes: + - ${TLS_CERT_PATH:?TLS_CERT_PATH is required}:/etc/nginx/tls/fullchain.pem:ro + - ${TLS_KEY_PATH:?TLS_KEY_PATH is required}:/etc/nginx/tls/privkey.pem:ro + networks: + - backend + - edge + depends_on: + api: + condition: service_healthy + read_only: true + tmpfs: + - /var/cache/nginx:size=32m,noexec,nosuid + - /var/run:size=4m,noexec,nosuid + security_opt: + - no-new-privileges:true + +networks: + backend: + edge: + +volumes: + mysql-data: + minio-data: + redis-data: diff --git a/infra/ubuntu/env.production.example b/infra/ubuntu/env.production.example new file mode 100644 index 0000000..0c17008 --- /dev/null +++ b/infra/ubuntu/env.production.example @@ -0,0 +1,44 @@ +# 复制到服务器 /etc/lehuo/lehuo.env,并设置 chmod 600。 +# 推荐使用 scripts/generate-env.sh 自动生成随机密钥,不要直接沿用本文件占位值。 +IMAGE_TAG=replace-with-git-commit +PUBLIC_HOST=admin.example.com +FILES_HOST=files.example.com +TLS_CERT_PATH=/opt/lehuo/shared/tls/fullchain.pem +TLS_KEY_PATH=/opt/lehuo/shared/tls/privkey.pem +BACKUP_DIR=/opt/lehuo/backups + +MYSQL_DATABASE=lehuo +MYSQL_USER=lehuo +MYSQL_PASSWORD=replace-with-random-url-safe-password +MYSQL_ROOT_PASSWORD=replace-with-different-random-password +DATABASE_URL=mysql://lehuo:replace-with-random-url-safe-password@mysql:3306/lehuo + +REDIS_PASSWORD=replace-with-random-password +REDIS_URL=redis://:replace-with-random-password@redis:6379 + +MINIO_ROOT_USER=lehuo_storage +MINIO_ROOT_PASSWORD=replace-with-random-password +OBJECT_STORAGE_ENDPOINT=https://files.example.com +OBJECT_STORAGE_REGION=auto +OBJECT_STORAGE_BUCKET=lehuo-private +OBJECT_STORAGE_ACCESS_KEY=lehuo_storage +OBJECT_STORAGE_SECRET_KEY=replace-with-same-minio-password + +SESSION_SECRET=replace-with-random-value +SESSION_TOKEN_PEPPER=replace-with-random-value +AUDIT_IP_HASH_KEY=replace-with-random-value +FIELD_ENCRYPTION_KEY=replace-with-base64-encoded-32-byte-key +ADMIN_WEB_ORIGIN=https://admin.example.com +AUTH_SESSION_TTL_SECONDS=28800 +WECHAT_SESSION_TTL_SECONDS=7200 + +# 未取得真实微信参数前保持空值,并保持 worker 关闭。 +WECHAT_APP_ID= +WECHAT_APP_SECRET= +WECHAT_TEMPLATE_APPLICATION_SUBMITTED= +WECHAT_TEMPLATE_APPLICATION_ACCEPTED= +WECHAT_TEMPLATE_SERVICE_ARRANGED= +WECHAT_TEMPLATE_SERVICE_COMPLETED= +WECHAT_TEMPLATE_FOLLOW_UP_PENDING= +WECHAT_TEMPLATE_ACTIVITY_REGISTRATION_RESULT= +NOTIFICATION_WORKER_ENABLED=false diff --git a/infra/ubuntu/minio/init-minio.sh b/infra/ubuntu/minio/init-minio.sh new file mode 100644 index 0000000..23e0876 --- /dev/null +++ b/infra/ubuntu/minio/init-minio.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env sh +set -eu + +: "${MINIO_ROOT_USER:?MINIO_ROOT_USER is required}" +: "${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD is required}" +: "${OBJECT_STORAGE_BUCKET:?OBJECT_STORAGE_BUCKET is required}" +: "${ADMIN_WEB_ORIGIN:?ADMIN_WEB_ORIGIN is required}" + +mc alias set lehuo http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" --api S3v4 >/dev/null +mc mb --ignore-existing "lehuo/$OBJECT_STORAGE_BUCKET" >/dev/null +mc anonymous set none "lehuo/$OBJECT_STORAGE_BUCKET" >/dev/null +mc version enable "lehuo/$OBJECT_STORAGE_BUCKET" >/dev/null + +cat > /tmp/cors.xml < + + ${ADMIN_WEB_ORIGIN} + GET + PUT + HEAD + * + ETag + 3600 + + +EOF +mc cors set "lehuo/$OBJECT_STORAGE_BUCKET" /tmp/cors.xml >/dev/null +rm -f /tmp/cors.xml + +echo "MinIO private bucket initialized." diff --git a/infra/ubuntu/nginx/default.conf.template b/infra/ubuntu/nginx/default.conf.template new file mode 100644 index 0000000..9e878fe --- /dev/null +++ b/infra/ubuntu/nginx/default.conf.template @@ -0,0 +1,83 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} +limit_req_zone $binary_remote_addr zone=api_per_ip:10m rate=10r/s; + +server { + listen 80; + server_name ${PUBLIC_HOST} ${FILES_HOST}; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + http2 on; + server_name ${PUBLIC_HOST}; + + ssl_certificate /etc/nginx/tls/fullchain.pem; + ssl_certificate_key /etc/nginx/tls/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_session_cache shared:TLS:10m; + ssl_session_timeout 1d; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + add_header X-Frame-Options DENY always; + + client_max_body_size 12m; + + location /api/ { + limit_req zone=api_per_ip burst=30 nodelay; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_pass http://api:3000; + } + + location /health/ { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_pass http://api:3000; + } + + location / { + try_files $uri $uri/ /index.html; + } +} + +server { + listen 443 ssl; + http2 on; + server_name ${FILES_HOST}; + + ssl_certificate /etc/nginx/tls/fullchain.pem; + ssl_certificate_key /etc/nginx/tls/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_session_cache shared:TLS:10m; + ssl_session_timeout 1d; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy no-referrer always; + + client_max_body_size 12m; + + location / { + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_request_buffering off; + proxy_buffering off; + proxy_pass http://minio:9000; + } +} diff --git a/infra/ubuntu/scripts/backup.sh b/infra/ubuntu/scripts/backup.sh new file mode 100644 index 0000000..b65c99d --- /dev/null +++ b/infra/ubuntu/scripts/backup.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# shellcheck source=lib.sh +source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" + +env_file="${1:-/etc/lehuo/lehuo.env}" +require_command docker +require_command gzip +require_command sha256sum +require_env_file "$env_file" + +backup_dir="$(env_value "$env_file" BACKUP_DIR)" +[[ "$backup_dir" == /opt/lehuo/backups || "$backup_dir" == /opt/lehuo/backups/* ]] || { + echo 'BACKUP_DIR 必须位于 /opt/lehuo/backups。' >&2 + exit 1 +} +install -d -m 0700 "$backup_dir" + +database="$(env_value "$env_file" MYSQL_DATABASE)" +timestamp="$(date -u +%Y%m%dT%H%M%SZ)" +target="$backup_dir/lehuo-${timestamp}.sql.gz" +temporary="${target}.tmp" +trap 'rm -f -- "$temporary"' EXIT + +compose "$env_file" exec -T mysql sh -c \ + 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysqldump --single-transaction --routines --triggers --events --set-gtid-purged=OFF -u root "$MYSQL_DATABASE"' \ + | gzip -9 > "$temporary" + +gzip -t "$temporary" +mv -- "$temporary" "$target" +chmod 600 "$target" +sha256sum "$target" > "${target}.sha256" +chmod 600 "${target}.sha256" +find "$backup_dir" -maxdepth 1 -type f \( -name 'lehuo-*.sql.gz' -o -name 'lehuo-*.sql.gz.sha256' \) -mtime +30 -delete + +printf '数据库备份完成:%s(数据库:%s)\n' "$target" "$database" +echo '请将备份及 SHA-256 文件同步到异地加密存储。' diff --git a/infra/ubuntu/scripts/bootstrap-admin.sh b/infra/ubuntu/scripts/bootstrap-admin.sh new file mode 100644 index 0000000..33868ec --- /dev/null +++ b/infra/ubuntu/scripts/bootstrap-admin.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# shellcheck source=lib.sh +source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" + +env_file="${1:-/etc/lehuo/lehuo.env}" +require_env_file "$env_file" + +read -r -p '初始管理员用户名:' username +read -r -p '初始管理员显示名:' display_name +read -r -s -p '初始管理员密码:' password +echo +[[ -n "$username" && -n "$display_name" && ${#password} -ge 12 ]] || { + echo '用户名/显示名不能为空,密码至少 12 位。' >&2 + exit 1 +} + +compose "$env_file" run --rm \ + -e "BOOTSTRAP_ADMIN_USERNAME=${username}" \ + -e "BOOTSTRAP_ADMIN_DISPLAY_NAME=${display_name}" \ + -e "BOOTSTRAP_ADMIN_PASSWORD=${password}" \ + api node apps/api/dist/scripts/bootstrap-admin.js + +unset password +echo '初始管理员创建完成;命令会拒绝重复初始化。' diff --git a/infra/ubuntu/scripts/deploy.sh b/infra/ubuntu/scripts/deploy.sh new file mode 100644 index 0000000..a2693d6 --- /dev/null +++ b/infra/ubuntu/scripts/deploy.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# shellcheck source=lib.sh +source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" + +env_file="${1:-/etc/lehuo/lehuo.env}" +require_env_file "$env_file" +bash "$(dirname -- "${BASH_SOURCE[0]}")/preflight.sh" "$env_file" + +image_tag="$(env_value "$env_file" IMAGE_TAG)" +release_dir='/opt/lehuo/releases' +install -d -m 0700 "$release_dir" + +current_tag='' +if [[ -f "$release_dir/current" ]]; then + current_tag="$(<"$release_dir/current")" +fi + +echo "构建不可变镜像:${image_tag}" +compose "$env_file" build --pull api web + +echo '启动数据服务并初始化私有对象存储。' +compose "$env_file" up -d mysql redis minio minio-init + +if [[ -n "$current_tag" ]]; then + echo '迁移前创建生产数据库备份。' + bash "$(dirname -- "${BASH_SOURCE[0]}")/backup.sh" "$env_file" +fi + +echo '执行数据库 migration。' +compose "$env_file" --profile tools run --rm migrate + +echo '部署 API 与管理后台网关。' +compose "$env_file" up -d --remove-orphans api web + +bash "$(dirname -- "${BASH_SOURCE[0]}")/smoke.sh" "$env_file" + +if [[ -n "$current_tag" && "$current_tag" != "$image_tag" ]]; then + printf '%s\n' "$current_tag" > "$release_dir/previous" +fi +printf '%s\n' "$image_tag" > "$release_dir/current" +chmod 600 "$release_dir/current" +[[ ! -f "$release_dir/previous" ]] || chmod 600 "$release_dir/previous" + +printf '部署完成,当前镜像标签:%s\n' "$image_tag" diff --git a/infra/ubuntu/scripts/generate-env.sh b/infra/ubuntu/scripts/generate-env.sh new file mode 100644 index 0000000..a6dcc58 --- /dev/null +++ b/infra/ubuntu/scripts/generate-env.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +usage() { + echo '用法:generate-env.sh --public-host admin.example.com --files-host files.example.com [--output /etc/lehuo/lehuo.env]' +} + +public_host='' +files_host='' +output='/etc/lehuo/lehuo.env' +while (($#)); do + case "$1" in + --public-host) public_host="${2:-}"; shift 2 ;; + --files-host) files_host="${2:-}"; shift 2 ;; + --output) output="${2:-}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; exit 1 ;; + esac +done + +[[ "$public_host" =~ ^[A-Za-z0-9.-]+$ ]] || { echo 'PUBLIC_HOST 格式无效。' >&2; exit 1; } +[[ "$files_host" =~ ^[A-Za-z0-9.-]+$ ]] || { echo 'FILES_HOST 格式无效。' >&2; exit 1; } +[[ "$public_host" != "$files_host" ]] || { echo '管理后台/API 域名与文件域名必须不同。' >&2; exit 1; } +command -v openssl >/dev/null 2>&1 || { echo '缺少 openssl。' >&2; exit 1; } +command -v git >/dev/null 2>&1 || { echo '缺少 git。' >&2; exit 1; } + +if [[ -e "$output" ]]; then + printf '拒绝覆盖已有环境文件:%s\n' "$output" >&2 + exit 1 +fi + +install -d -m 0700 "$(dirname -- "$output")" +umask 077 + +mysql_password="$(openssl rand -hex 24)" +mysql_root_password="$(openssl rand -hex 32)" +redis_password="$(openssl rand -hex 32)" +minio_password="$(openssl rand -hex 32)" +session_secret="$(openssl rand -hex 32)" +session_pepper="$(openssl rand -hex 32)" +audit_key="$(openssl rand -hex 32)" +field_key="$(openssl rand -base64 32 | tr -d '\n')" +image_tag="$(git -C "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../.." && pwd)" rev-parse --short=12 HEAD)" + +cat > "$output" <&2 + exit 1 +fi + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ubuntu_dir="$(cd -- "$script_dir/.." && pwd)" +repo_root="$(cd -- "$ubuntu_dir/../.." && pwd)" +[[ "$repo_root" == '/opt/lehuo/app' ]] || { + echo '安装定时器前请把仓库部署到 /opt/lehuo/app。' >&2 + exit 1 +} +[[ -f /etc/lehuo/lehuo.env ]] || { echo '缺少 /etc/lehuo/lehuo.env。' >&2; exit 1; } + +install -m 0644 "$ubuntu_dir/systemd/lehuo-backup.service" /etc/systemd/system/lehuo-backup.service +install -m 0644 "$ubuntu_dir/systemd/lehuo-backup.timer" /etc/systemd/system/lehuo-backup.timer +systemctl daemon-reload +systemctl enable --now lehuo-backup.timer +systemctl list-timers lehuo-backup.timer --no-pager + +echo '每日备份定时器已安装。首次正式发布前仍须手工执行一次备份和恢复演练。' diff --git a/infra/ubuntu/scripts/install-docker.sh b/infra/ubuntu/scripts/install-docker.sh new file mode 100644 index 0000000..05caa84 --- /dev/null +++ b/infra/ubuntu/scripts/install-docker.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +if [[ "${EUID}" -ne 0 ]]; then + echo '请使用 sudo 运行此脚本。' >&2 + exit 1 +fi + +if [[ ! -r /etc/os-release ]]; then + echo '无法识别操作系统。' >&2 + exit 1 +fi + +# shellcheck disable=SC1091 +source /etc/os-release +if [[ "${ID:-}" != 'ubuntu' || "${VERSION_ID:-}" != '26.04' ]]; then + printf '本部署包仅验证 Ubuntu 26.04;当前为 %s %s。\n' "${ID:-unknown}" "${VERSION_ID:-unknown}" >&2 + exit 1 +fi + +if docker version >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then + echo 'Docker Engine 与 Compose plugin 已安装。' + exit 0 +fi + +conflicts=() +for package in docker.io docker-compose docker-compose-v2 podman-docker containerd runc; do + if dpkg-query -W -f='${Status}' "$package" 2>/dev/null | grep -q 'install ok installed'; then + conflicts+=("$package") + fi +done +if ((${#conflicts[@]})); then + printf '检测到可能冲突的软件包:%s\n' "${conflicts[*]}" >&2 + echo '请先人工确认并按 Docker 官方文档移除冲突包,再重新运行;脚本不会自动卸载现有容器运行时。' >&2 + exit 1 +fi + +apt-get update +apt-get install -y ca-certificates curl +install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc +chmod a+r /etc/apt/keyrings/docker.asc + +architecture="$(dpkg --print-architecture)" +codename="${UBUNTU_CODENAME:-${VERSION_CODENAME}}" +cat > /etc/apt/sources.list.d/docker.sources </dev/null 2>&1 || { + printf '缺少命令:%s\n' "$1" >&2 + exit 1 + } +} + +require_env_file() { + local file="$1" + [[ -f "$file" ]] || { + printf '环境文件不存在:%s\n' "$file" >&2 + exit 1 + } + local mode + mode="$(stat -c '%a' "$file")" + if [[ "$mode" != '600' && "$mode" != '400' ]]; then + printf '环境文件权限必须是 600 或 400,当前为 %s:%s\n' "$mode" "$file" >&2 + exit 1 + fi +} + +env_value() { + local file="$1" + local key="$2" + sed -n "s/^${key}=//p" "$file" | tail -n 1 +} + +compose() { + local env_file="$1" + shift + docker compose --project-directory "$(ubuntu_dir)" --env-file "$env_file" -f "$(compose_file)" "$@" +} diff --git a/infra/ubuntu/scripts/preflight.sh b/infra/ubuntu/scripts/preflight.sh new file mode 100644 index 0000000..12acc08 --- /dev/null +++ b/infra/ubuntu/scripts/preflight.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# shellcheck source=lib.sh +source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" + +env_file="${1:-/etc/lehuo/lehuo.env}" +require_command docker +require_command curl +require_command openssl +require_env_file "$env_file" + +docker version >/dev/null +docker compose version >/dev/null + +required_keys=(IMAGE_TAG PUBLIC_HOST FILES_HOST TLS_CERT_PATH TLS_KEY_PATH BACKUP_DIR DATABASE_URL REDIS_URL FIELD_ENCRYPTION_KEY ADMIN_WEB_ORIGIN) +for key in "${required_keys[@]}"; do + value="$(env_value "$env_file" "$key")" + [[ -n "$value" && "$value" != replace-* ]] || { + printf '环境变量缺失或仍为占位值:%s\n' "$key" >&2 + exit 1 + } +done + +image_tag="$(env_value "$env_file" IMAGE_TAG)" +[[ "$image_tag" != 'latest' ]] || { echo '禁止使用 latest 镜像标签。' >&2; exit 1; } + +public_host="$(env_value "$env_file" PUBLIC_HOST)" +files_host="$(env_value "$env_file" FILES_HOST)" +[[ "$public_host" != "$files_host" ]] || { echo 'PUBLIC_HOST 与 FILES_HOST 必须不同。' >&2; exit 1; } + +cert_path="$(env_value "$env_file" TLS_CERT_PATH)" +key_path="$(env_value "$env_file" TLS_KEY_PATH)" +[[ -r "$cert_path" && -r "$key_path" ]] || { echo 'TLS 证书或私钥不存在/不可读。' >&2; exit 1; } +openssl x509 -in "$cert_path" -noout -checkend 604800 >/dev/null || { + echo 'TLS 证书将在 7 天内过期或无效。' >&2 + exit 1 +} +openssl x509 -in "$cert_path" -noout -checkhost "$public_host" >/dev/null || { + printf 'TLS 证书不包含管理端域名:%s\n' "$public_host" >&2 + exit 1 +} +openssl x509 -in "$cert_path" -noout -checkhost "$files_host" >/dev/null || { + printf 'TLS 证书不包含文件域名:%s\n' "$files_host" >&2 + exit 1 +} +openssl pkey -in "$key_path" -noout -check >/dev/null + +compose "$env_file" config --quiet + +available_kb="$(df -Pk "$(repo_root)" | awk 'NR==2 {print $4}')" +((available_kb >= 10 * 1024 * 1024)) || { echo '可用磁盘不足 10 GiB。' >&2; exit 1; } + +echo 'Ubuntu 生产部署预检通过。' diff --git a/infra/ubuntu/scripts/restore.sh b/infra/ubuntu/scripts/restore.sh new file mode 100644 index 0000000..fba33fc --- /dev/null +++ b/infra/ubuntu/scripts/restore.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# shellcheck source=lib.sh +source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" + +usage() { + echo '用法:restore.sh --confirm [env-file]' +} + +backup="${1:-}" +target_database="${2:-}" +confirm_flag="${3:-}" +confirm_database="${4:-}" +env_file="${5:-/etc/lehuo/lehuo.env}" +[[ -n "$backup" && -n "$target_database" && "$confirm_flag" == '--confirm' && "$confirm_database" == "$target_database" ]] || { + usage >&2 + exit 1 +} +[[ "$target_database" =~ ^[A-Za-z0-9_]+$ ]] || { echo '目标数据库名无效。' >&2; exit 1; } + +require_command docker +require_command gzip +require_command sha256sum +require_command realpath +require_env_file "$env_file" + +backup_dir="$(realpath -m "$(env_value "$env_file" BACKUP_DIR)")" +backup_path="$(realpath -e "$backup")" +[[ "$backup_path" == "$backup_dir"/* ]] || { echo '备份文件必须位于 BACKUP_DIR。' >&2; exit 1; } +[[ -f "${backup_path}.sha256" ]] || { echo '缺少 SHA-256 校验文件。' >&2; exit 1; } +(cd -- "$backup_dir" && sha256sum -c "$(basename -- "${backup_path}.sha256")") +gzip -t "$backup_path" + +production_database="$(env_value "$env_file" MYSQL_DATABASE)" +if [[ "$target_database" == "$production_database" && "${ALLOW_PRODUCTION_RESTORE:-}" != 'YES' ]]; then + echo '拒绝覆盖生产数据库。若已完成停机、审批和二次备份,请显式设置 ALLOW_PRODUCTION_RESTORE=YES。' >&2 + exit 1 +fi + +compose "$env_file" exec -T -e RESTORE_DATABASE="$target_database" mysql sh -c \ + 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -u root -e "CREATE DATABASE IF NOT EXISTS \`$RESTORE_DATABASE\` CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"' +gzip -dc "$backup_path" | compose "$env_file" exec -T -e RESTORE_DATABASE="$target_database" mysql sh -c \ + 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -u root "$RESTORE_DATABASE"' + +printf '恢复完成:%s → %s\n' "$backup_path" "$target_database" diff --git a/infra/ubuntu/scripts/rollback.sh b/infra/ubuntu/scripts/rollback.sh new file mode 100644 index 0000000..1660d30 --- /dev/null +++ b/infra/ubuntu/scripts/rollback.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# shellcheck source=lib.sh +source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" + +env_file="${1:-/etc/lehuo/lehuo.env}" +require_env_file "$env_file" +release_dir='/opt/lehuo/releases' +[[ -f "$release_dir/previous" ]] || { echo '没有可回滚的上一镜像标签。' >&2; exit 1; } + +previous_tag="$(<"$release_dir/previous")" +current_tag="$(<"$release_dir/current")" +[[ -n "$previous_tag" && "$previous_tag" != 'latest' ]] || { echo '上一镜像标签无效。' >&2; exit 1; } + +if ! docker image inspect "lehuo-api:${previous_tag}" >/dev/null 2>&1 || ! docker image inspect "lehuo-admin:${previous_tag}" >/dev/null 2>&1; then + echo '上一版本镜像在本机不存在,停止回滚。' >&2 + exit 1 +fi + +echo '注意:此操作只回滚应用镜像,不回滚数据库。migration 必须保持向后兼容。' +IMAGE_TAG="$previous_tag" compose "$env_file" up -d --no-build api web +bash "$(dirname -- "${BASH_SOURCE[0]}")/smoke.sh" "$env_file" +printf '%s\n' "$previous_tag" > "$release_dir/current" +printf '%s\n' "$current_tag" > "$release_dir/previous" +chmod 600 "$release_dir/current" "$release_dir/previous" +printf '应用已回滚到:%s\n' "$previous_tag" diff --git a/infra/ubuntu/scripts/smoke.sh b/infra/ubuntu/scripts/smoke.sh new file mode 100644 index 0000000..5735ee4 --- /dev/null +++ b/infra/ubuntu/scripts/smoke.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# shellcheck source=lib.sh +source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" + +env_file="${1:-/etc/lehuo/lehuo.env}" +require_command curl +require_env_file "$env_file" + +public_host="$(env_value "$env_file" PUBLIC_HOST)" +files_host="$(env_value "$env_file" FILES_HOST)" + +curl --fail --silent --show-error --max-time 15 "https://${public_host}/health/live" >/dev/null +curl --fail --silent --show-error --max-time 15 "https://${public_host}/health/ready" >/dev/null +curl --fail --silent --show-error --max-time 15 "https://${public_host}/" >/dev/null +curl --fail --silent --show-error --max-time 15 "https://${files_host}/minio/health/live" >/dev/null + +echo 'HTTPS、API readiness、管理后台与对象存储冒烟检查通过。' diff --git a/infra/ubuntu/systemd/lehuo-backup.service b/infra/ubuntu/systemd/lehuo-backup.service new file mode 100644 index 0000000..ced0843 --- /dev/null +++ b/infra/ubuntu/systemd/lehuo-backup.service @@ -0,0 +1,16 @@ +[Unit] +Description=Lehuo公益平台 MySQL backup +Requires=docker.service +After=docker.service + +[Service] +Type=oneshot +User=root +WorkingDirectory=/opt/lehuo/app +ExecStart=/usr/bin/bash /opt/lehuo/app/infra/ubuntu/scripts/backup.sh /etc/lehuo/lehuo.env +Nice=10 +IOSchedulingClass=best-effort +IOSchedulingPriority=7 + +[Install] +WantedBy=multi-user.target diff --git a/infra/ubuntu/systemd/lehuo-backup.timer b/infra/ubuntu/systemd/lehuo-backup.timer new file mode 100644 index 0000000..8034163 --- /dev/null +++ b/infra/ubuntu/systemd/lehuo-backup.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run Lehuo公益平台 MySQL backup daily + +[Timer] +OnCalendar=*-*-* 03:30:00 Asia/Shanghai +Persistent=true +RandomizedDelaySec=15m +Unit=lehuo-backup.service + +[Install] +WantedBy=timers.target