From ea6f6496263730b4bfd5efafffb9d5d6abcfb423 Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Fri, 17 Apr 2026 20:13:06 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(db):=20=E8=87=AA=E5=BB=BA=20PostgreSQL?= =?UTF-8?q?=20+=20pgAdmin=20GUI=20+=20=E8=87=AA=E5=8A=A8=E5=A4=87=E4=BB=BD?= =?UTF-8?q?=EF=BC=8C=E6=9B=BF=E4=BB=A3=20Neon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neon 免费月度 100 CU-h 配额耗尽后计算节点被暂停,业务全挂(2026-04-17 事故)。 改用本机 compose 起 postgres:18-alpine,附带 pgAdmin 作为带按钮的备份/恢复 GUI、 pg-backup 容器做每日自动快照(保留 30d/8w/12m)。 - docker-compose.yml 新增 pgadmin 与 pg-backup 服务,共享 pg-backups 命名卷 - pgAdmin 预注册 InvolutionHell 服务器,pgpass 通过只读挂载提供,不需每次手填 - pgpass 走 .gitignore,提供 pgpass.example 作模板 - docs/database.md 完整记录:日常使用、手动/定时备份、GUI/CLI 恢复流程、迁移历史 .env 的 PGHOST 已在服务器上从 Neon endpoint 改为 compose 服务名 postgres, 仓库中 .env 不入库故未一并提交,需按 docs/database.md 描述在部署机上同步更新。 --- .gitignore | 3 + docker-compose.yml | 58 ++++++++++++++++ docker/pgadmin/pgpass.example | 1 + docker/pgadmin/servers.json | 16 +++++ docs/database.md | 126 ++++++++++++++++++++++++++++++++++ 5 files changed, 204 insertions(+) create mode 100644 docker/pgadmin/pgpass.example create mode 100644 docker/pgadmin/servers.json create mode 100644 docs/database.md diff --git a/.gitignore b/.gitignore index 25e2543..5e66ea1 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ build/ ga4-sa-key.json .env + +# pgAdmin 密码文件(含明文,不入库) +docker/pgadmin/pgpass diff --git a/docker-compose.yml b/docker-compose.yml index 34ac7f8..d514c92 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -77,9 +77,67 @@ services: networks: - InvolutionHell-net + # pgAdmin:PostgreSQL 的 Web GUI,带完整的备份/恢复按钮。 + # 登录 http://:8082,预注册服务器见 docker/pgadmin/servers.json, + # 密码通过 /pgpass 自动填充,不需要每次手输。 + # 备份卷挂到 /var/lib/pgadmin/storage/.../backups,在 Restore 对话框里直接看得到。 + pgadmin: + image: dpage/pgadmin4:latest + container_name: involution-pgadmin + restart: unless-stopped + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@involutionhell.com} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-change_me} + PGADMIN_CONFIG_SERVER_MODE: "False" + PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: "False" + ports: + - "8082:80" + volumes: + - pgadmin-data:/var/lib/pgadmin + - ./docker/pgadmin/servers.json:/pgadmin4/servers.json:ro + # pgpass 主机文件必须是 UID 5050 所有且 0600,否则 pgAdmin 拒绝加载。 + # 用 `sudo chown 5050:5050 docker/pgadmin/pgpass && sudo chmod 600 …` 设好。 + - ./docker/pgadmin/pgpass:/tmp/pgpass + # 备份卷挂到 pgAdmin 用户默认存储目录,这样在 Restore 对话框里能直接选到 + # pg-backup 容器每天生成的 dump 文件。路径格式:/ + - pg-backups:/var/lib/pgadmin/storage/admin_involutionhell.com/backups + depends_on: + postgres: + condition: service_healthy + networks: + - InvolutionHell-net + + # 自动定时备份:每天 03:00 对 postgres 容器做 pg_dump(custom format), + # 输出到共享卷 pg-backups,pgAdmin 能直接在 Restore 对话框里看到这些文件。 + # 保留最近 30 天日备 / 8 周周备 / 12 个月月备。 + pg-backup: + image: prodrigestivill/postgres-backup-local:18-alpine + container_name: involution-pg-backup + restart: unless-stopped + environment: + POSTGRES_HOST: postgres + POSTGRES_DB: ${POSTGRES_DB:-involution_hell} + POSTGRES_USER: ${POSTGRES_USER:-involution} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change_me} + POSTGRES_EXTRA_OPTS: "-Fc --blobs" + SCHEDULE: "@daily" + BACKUP_KEEP_DAYS: 30 + BACKUP_KEEP_WEEKS: 8 + BACKUP_KEEP_MONTHS: 12 + HEALTHCHECK_PORT: 8080 + volumes: + - pg-backups:/backups + depends_on: + postgres: + condition: service_healthy + networks: + - InvolutionHell-net + networks: InvolutionHell-net: driver: bridge volumes: involution-postgres-data: + pgadmin-data: + pg-backups: diff --git a/docker/pgadmin/pgpass.example b/docker/pgadmin/pgpass.example new file mode 100644 index 0000000..ae1b418 --- /dev/null +++ b/docker/pgadmin/pgpass.example @@ -0,0 +1 @@ +postgres:5432:*:involution:change_me diff --git a/docker/pgadmin/servers.json b/docker/pgadmin/servers.json new file mode 100644 index 0000000..8990462 --- /dev/null +++ b/docker/pgadmin/servers.json @@ -0,0 +1,16 @@ +{ + "Servers": { + "1": { + "Name": "InvolutionHell (local)", + "Group": "Servers", + "Host": "postgres", + "Port": 5432, + "MaintenanceDB": "involution_hell", + "Username": "neondb_owner", + "PassFile": "/tmp/pgpass", + "PassFileReadonly": true, + "SSLMode": "prefer", + "Shared": false + } + } +} diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..43cf33d --- /dev/null +++ b/docs/database.md @@ -0,0 +1,126 @@ +# 数据库运维手册(自建 Docker PostgreSQL) + +> 2026-04-17 起,生产/开发均从 Neon 迁到本机 compose 起的 `postgres:18-alpine`。 +> 迁移动因:Neon 免费月度额度(100 CU-h)耗尽后计算节点被暂停, +> 所有业务请求报错。自建后无配额限制,数据和延迟都本地可控。 + +## 架构总览 + +``` +docker-compose.yml 里四个相关服务: + postgres — PostgreSQL 18 主库,数据卷 involution-postgres-data(命名卷,持久化) + backend — Spring Boot,通过内网 jdbc:postgresql://postgres:5432/involution_hell 访问 + pg-backup — prodrigestivill/postgres-backup-local,每天 03:00 跑 pg_dump,保留 30 天日备/8 周周备/12 月月备 + pgadmin — Web GUI,http://:8082,带完整 Backup/Restore 按钮 +``` + +备份文件写入 `pg-backups` 命名卷,pgAdmin 也挂载同一个卷到 +`/var/lib/pgadmin/storage/admin_involutionhell.com/backups/`, +在 pgAdmin 的 Restore 对话框里直接选得到。 + +## 常用操作 + +### 登录 pgAdmin + +1. 浏览器打开 `http://:8082` +2. 用户名密码见 `.env` 的 `PGADMIN_EMAIL` / `PGADMIN_PASSWORD` +3. 左侧树直接显示预注册的 `InvolutionHell (local)`,双击连上就能用 + +### 手动备份(立刻打一个快照) + +```bash +docker exec involution-pg-backup /backup.sh +# 产物:pg-backups 卷里 last/daily/weekly/monthly 各一份 +# 文件名示例:involution_hell-20260417-201149.sql.gz(plain SQL + gzip) +``` + +### 查看历史备份 + +```bash +docker exec involution-pg-backup ls -lh /backups/daily/ +docker exec involution-pg-backup ls -lh /backups/weekly/ +docker exec involution-pg-backup ls -lh /backups/monthly/ +``` + +或者在 pgAdmin 里:Tools → Storage Manager → `backups/`。 + +### 恢复到指定时间点(命令行) + +```bash +# 1. 选一个备份文件 +FILE=involution_hell-20260417.sql.gz + +# 2. 停写入(避免恢复期间 backend 又写进去造成冲突) +docker stop involution-hell-backend + +# 3. 清空当前库 +docker exec involution-postgres psql -U neondb_owner -d postgres \ + -c "DROP DATABASE involution_hell WITH (FORCE);" \ + -c "CREATE DATABASE involution_hell OWNER neondb_owner;" + +# 4. 恢复(plain SQL.gz 格式用 psql + gunzip;若是 -Fc 自定义格式则改用 pg_restore) +docker exec involution-pg-backup sh -c \ + "gunzip -c /backups/daily/$FILE | psql -h postgres -U neondb_owner -d involution_hell" + +# 5. 启回 backend +docker start involution-hell-backend +``` + +### 恢复到指定时间点(pgAdmin GUI) + +pgAdmin 的 Restore 对话框默认只支持 custom/tar/directory 格式, +`.sql.gz` 不直接支持。解决方式二选一: + +- **Query Tool 导入**:右键数据库 → Query Tool → 把 `.sql.gz` 解压后粘贴 SQL 执行 +- **改用自定义格式备份**:见下方「切换备份格式」章节 + +### 切换备份格式到 pg_restore 兼容的 `-Fc`(可选) + +在 `docker-compose.yml` 的 `pg-backup` 服务改: + +```yaml +environment: + POSTGRES_EXTRA_OPTS: "-Fc" + BACKUP_SUFFIX: ".dump" +``` + +然后重建:`docker compose up -d --force-recreate pg-backup`。 +之后 pgAdmin 右键数据库 → Restore → 文件类型选 Custom,直接点按钮即可。 +代价:备份文件比 gzip 压缩的略大一点。 + +## 连接凭证 + +所有 DB 连接信息在根目录 `.env`: + +| 变量 | 用途 | +|---|---| +| `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` | postgres 容器初始化 + backup 脚本 | +| `SPRING_DATASOURCE_URL` / `*_USERNAME` / `*_PASSWORD` | backend 的 JDBC 连接 | +| `PGHOST` / `PGPORT` / `PGUSER` / `PGPASSWORD` / `PGDATABASE` | psql 兼容环境变量(部分脚本依赖) | +| `PGADMIN_EMAIL` / `PGADMIN_PASSWORD` | pgAdmin Web 登录 | + +> 账号沿用 `neondb_owner` 是为了最小化改动,不是生产建议。 +> 后续可以跑 `ALTER USER neondb_owner RENAME TO involution;` 改得更整洁。 + +## 初始化 pgpass 文件 + +`docker/pgadmin/pgpass` 被 .gitignore,首次部署要在服务器上生成: + +```bash +cd ~/involution-hell +set -a && . ./.env && set +a +printf 'postgres:5432:*:%s:%s\n' "$POSTGRES_USER" "$POSTGRES_PASSWORD" \ + > docker/pgadmin/pgpass +# pgAdmin 容器内 UID=5050,必须把文件 owner 改成 5050,且权限 0600 +sudo chown 5050:5050 docker/pgadmin/pgpass +sudo chmod 600 docker/pgadmin/pgpass +``` + +没这步 pgAdmin 会 restart loop,日志里是 `cp: can't open '/pgpass': Permission denied`。 + +## 数据迁移的历史记录 + +- 2026-04-17:Neon `involution-hell` 项目 → 本机 `involution-postgres` 容器 + - dump 方法:`pg_dump -Fc` 从 pooler endpoint 拉出 14 张表、5487 行 + - 验证:先导入 `involution_hell_test` 对过行数,再切流量 + - `.env` 里 `PGHOST` 由 Neon endpoint 改为 `postgres`(compose 服务名) From b803ca951b6a737a95dbf2194c1aa7f413a36018 Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Fri, 17 Apr 2026 20:45:34 +0000 Subject: [PATCH 2/5] =?UTF-8?q?feat(db):=20pgAdmin=20=E8=B5=B0=20/admin/pg?= =?UTF-8?q?admin=20=E5=8F=8D=E5=90=91=E4=BB=A3=E7=90=86=EF=BC=8C=E4=BE=9B?= =?UTF-8?q?=E4=B8=BB=E7=AB=99=20iframe=20=E5=B5=8C=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主站 involutionhell.com/admin/database 页面 iframe pgAdmin,管理员一个入口 解决。pgAdmin 端口从 0.0.0.0 收回到 127.0.0.1:8082,只接受 Caddy 从本机转发。 - SCRIPT_NAME=/admin/pgadmin 让 pgAdmin 自生成 URL 带正确前缀 - X_FRAME_OPTIONS 清空,由上游 Caddy 用 CSP frame-ancestors 控制 - WTF_CSRF_SSL_STRICT 关闭,避免跨子域 iframe 触发 CSRF 拒绝 Caddy 配置(独立文件 /home/ubuntu/caddy-gateway/Caddyfile,不在本仓库)同步 添加 /admin/pgadmin/* handle,剥 X-Frame-Options 并下发 CSP: frame-ancestors 'self' https://involutionhell.com https://*.involutionhell.com ... docs/database.md 补充 iframe 架构与环境变量说明。 --- docker-compose.yml | 9 ++++++++- docs/database.md | 33 ++++++++++++++++++++++++++++++--- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index d514c92..4920db7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -90,8 +90,15 @@ services: PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-change_me} PGADMIN_CONFIG_SERVER_MODE: "False" PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: "False" + # pgAdmin 被 iframe 嵌入在 involutionhell.com 主站的 /admin/database 页里, + # 流量路径:involutionhell.com(Vercel 前端) → iframe → api.involutionhell.com/admin/pgadmin/* + # SCRIPT_NAME 让 pgAdmin 生成的所有 URL 自带 /admin/pgadmin 前缀, + # X_FRAME_OPTIONS 清空让 Caddy 层自己控制 CSP frame-ancestors。 + SCRIPT_NAME: "/admin/pgadmin" + PGADMIN_CONFIG_X_FRAME_OPTIONS: "''" + PGADMIN_CONFIG_WTF_CSRF_SSL_STRICT: "False" ports: - - "8082:80" + - "127.0.0.1:8082:80" volumes: - pgadmin-data:/var/lib/pgadmin - ./docker/pgadmin/servers.json:/pgadmin4/servers.json:ro diff --git a/docs/database.md b/docs/database.md index 43cf33d..684cd8a 100644 --- a/docs/database.md +++ b/docs/database.md @@ -22,9 +22,36 @@ docker-compose.yml 里四个相关服务: ### 登录 pgAdmin -1. 浏览器打开 `http://:8082` -2. 用户名密码见 `.env` 的 `PGADMIN_EMAIL` / `PGADMIN_PASSWORD` -3. 左侧树直接显示预注册的 `InvolutionHell (local)`,双击连上就能用 +**生产环境(推荐)**:通过主站 iframe 进入 +1. `involutionhell.com` 登录 admin 账号 +2. 个人主页 → "管理员界面" → "数据库管理" +3. 页面内 iframe 加载 `api.involutionhell.com/admin/pgadmin/` +4. 用 `.env` 的 `PGADMIN_EMAIL` / `PGADMIN_PASSWORD` 登录 pgAdmin + +**本机联调**:`http://:8082`(仅 127.0.0.1 监听,不对外暴露,需 SSH 转发) + +左侧树显示预注册的 `InvolutionHell (local)`,双击即连。 + +### 反向代理 / iframe 架构 + +``` +involutionhell.com(Vercel / Next.js) + └─ /admin/database 页面 iframe https://api.involutionhell.com/admin/pgadmin/ + └─ Caddy (global-caddy-gateway, 本机 host 网络) + └─ 匹配 /admin/pgadmin/* → 127.0.0.1:8082 (pgAdmin 容器) +``` + +pgAdmin 容器里的关键环境变量: +- `SCRIPT_NAME=/admin/pgadmin` — 所有自动生成的 URL 自带前缀, + 保证 iframe 里点击链接走对 +- `PGADMIN_CONFIG_X_FRAME_OPTIONS="''"` — 清空默认的 DENY, + 由 Caddy 改用 CSP `frame-ancestors` 控制 +- `PGADMIN_CONFIG_WTF_CSRF_SSL_STRICT=False` — 跨子域 iframe + 情况下 CSRF 严格模式会误伤,关掉 + +Caddy 端响应头改写:剥 `X-Frame-Options`,加 +`Content-Security-Policy: frame-ancestors 'self' https://involutionhell.com …`。 +配置位置:`/home/ubuntu/caddy-gateway/Caddyfile`。 ### 手动备份(立刻打一个快照) From e22ed6ac979b81355a6143938876dc283a789553 Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Fri, 17 Apr 2026 21:47:24 +0000 Subject: [PATCH 3/5] =?UTF-8?q?feat(admin):=20/api/admin/pgadmin-check=20?= =?UTF-8?q?=E4=BD=9C=E4=B8=BA=20Caddy=20forward=5Fauth=20=E7=9B=AE?= =?UTF-8?q?=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pgAdmin 自身跑在 SERVER_MODE=False(无登录页,desktop 模式)对公网暴露是个 critical 漏洞:扫到路径就能对生产 DB 跑 SQL。改方案:外层 Caddy 在 /admin/pgadmin/* 前插 forward_auth 钩子,钩本接口;接口用 @SaCheckRole("admin") 判定当前请求带的 cookie.satoken 对应的用户是不是 admin,通过就 200,否则 sa-token 自动抛异常走 401 / 403。 - 新建 admin/controller 包专门放跨业务的基础设施级接口(不塞进 events/controller 避免语义混淆) - 响应体故意空壳,Caddy 只看状态码不读 body - superadmin 自动包含 admin 角色,天然放行,无需额外分支 - sa-token.is-read-cookie 默认开启,不用改 application.properties 配套前端改动:InvolutionHell/involutionhell#301 登录时把 satoken 同步写到 .involutionhell.com 域名 cookie,浏览器跨子域自动带。 配套 Caddy:/home/ubuntu/caddy-gateway/Caddyfile 改 handle 块,见 docs/database.md。 --- .../controller/AdminInfraController.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/main/java/com/involutionhell/backend/admin/controller/AdminInfraController.java diff --git a/src/main/java/com/involutionhell/backend/admin/controller/AdminInfraController.java b/src/main/java/com/involutionhell/backend/admin/controller/AdminInfraController.java new file mode 100644 index 0000000..1593129 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/admin/controller/AdminInfraController.java @@ -0,0 +1,39 @@ +package com.involutionhell.backend.admin.controller; + +import cn.dev33.satoken.annotation.SaCheckRole; +import com.involutionhell.backend.common.api.ApiResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 管理员基础设施(非业务)辅助接口。 + * + * 目前只有一个:/api/admin/pgadmin-check —— 专门给 Caddy `forward_auth` 调用, + * 用来判断当前请求是否是 admin。通过就 200,否则 sa-token 自动抛 NotLogin / + * NotPermission 异常,全局异常处理器转成 401 / 403,Caddy 据此拒绝代理到 pgAdmin。 + * + * 设计要点: + * - sa-token 默认从 header / cookie 两边读 token(sa-token.is-read-cookie=true 默认开) + * 配合前端在登录时把 satoken 同步写一份到 .involutionhell.com 域名 cookie, + * 浏览器直接访问 api 子域时也能带上,forward_auth 校验链才能成立 + * - 响应体故意空壳,Caddy 只看状态码不看 body;保持最小负载 + * - 单独放在 admin/controller 包下而不是塞进 events/controller:这是 + * "基础设施级"鉴权桩,不属于任何业务域,放一起语义会误导 + */ +@RestController +@RequestMapping("/api/admin") +public class AdminInfraController { + + /** + * Caddy 的 forward_auth 目标。只要通过 @SaCheckRole("admin") 就返回 200。 + * + * superadmin 的 roles 集合也包含 "admin"(由 sa-token 角色体系保证), + * 所以超管也能直接过,不用单独处理。 + */ + @GetMapping("/pgadmin-check") + @SaCheckRole("admin") + public ApiResponse pgadminCheck() { + return ApiResponse.okMessage("authorized"); + } +} From eb8910a21499777fcdfa535a70f4a5bdb9fef7fc Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Fri, 17 Apr 2026 21:47:37 +0000 Subject: [PATCH 4/5] =?UTF-8?q?docs(db):=20pgAdmin=20=E8=B5=B0=20Caddy=20f?= =?UTF-8?q?orward=5Fauth=20+=20=E5=89=8D=E7=AB=AF=20cookie=20=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E7=9A=84=E5=AE=8C=E6=95=B4=E6=9E=B6=E6=9E=84=E8=AF=B4?= =?UTF-8?q?=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 替换掉上一版 iframe-嵌入的描述(那版已经废弃,走不通 CSRF)。新架构: - pgAdmin SERVER_MODE=False 无自身登录页,仅 127.0.0.1:8082 监听 - 唯一公网入口 api.involutionhell.com/admin/pgadmin/* 由 Caddy handle 块 管控,前置 forward_auth 调 127.0.0.1:8080/api/admin/pgadmin-check - 后端 @SaCheckRole("admin") 依赖 sa-token 从 cookie 读 token - 前端 lib/use-auth.tsx 登录成功时把 satoken 同步写 .involutionhell.com 域名 cookie,浏览器跨子域自动带 补 Caddy 配置片段 + 后端 Controller 片段 + 前端同步逻辑说明, future reviewer 不用再去三处代码翻找。 --- docs/database.md | 127 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 103 insertions(+), 24 deletions(-) diff --git a/docs/database.md b/docs/database.md index 684cd8a..fd6fc9a 100644 --- a/docs/database.md +++ b/docs/database.md @@ -20,38 +20,117 @@ docker-compose.yml 里四个相关服务: ## 常用操作 -### 登录 pgAdmin +### 登录 pgAdmin(关键:走 sa-token cookie 校验,不是 pgAdmin 自己的账号密码) -**生产环境(推荐)**:通过主站 iframe 进入 -1. `involutionhell.com` 登录 admin 账号 -2. 个人主页 → "管理员界面" → "数据库管理" -3. 页面内 iframe 加载 `api.involutionhell.com/admin/pgadmin/` -4. 用 `.env` 的 `PGADMIN_EMAIL` / `PGADMIN_PASSWORD` 登录 pgAdmin +**生产环境** -**本机联调**:`http://:8082`(仅 127.0.0.1 监听,不对外暴露,需 SSH 转发) +pgAdmin 容器跑在 `SERVER_MODE=False`(desktop 模式,**自身无登录页**,进去就能操作 DB)。 +安全把门**不在 pgAdmin**,而是外层 Caddy 的 `forward_auth` 钩子: -左侧树显示预注册的 `InvolutionHell (local)`,双击即连。 +``` +浏览器 → api.involutionhell.com/admin/pgadmin/* + │ 携带 cookie: satoken=xxx(Domain=.involutionhell.com) + ▼ + Caddy handle /admin/pgadmin/* { + forward_auth 127.0.0.1:8080 { + uri /api/admin/pgadmin-check ← 后端接口带 @SaCheckRole("admin") + copy_headers Cookie ← satoken cookie 透传 + } + reverse_proxy 127.0.0.1:8082 ← 只有 forward_auth 200 才到这里 + } +``` + +**用户视角**: +1. 先在主站 `involutionhell.com` 用 GitHub OAuth 登录 +2. 登录成功时前端会把 satoken **双写**: + - `localStorage.satoken`:给同源 fetch 手动附 header 用 + - `cookie satoken=...; Domain=.involutionhell.com`:给 api 子域直连用 +3. 随便哪条都能进 pgAdmin: + - 点主站 `/admin/database` 页面的 "打开 pgAdmin" 按钮 + - 直接敲 `https://api.involutionhell.com/admin/pgadmin/` +4. 浏览器自动带 cookie → Caddy forward_auth → 后端看 cookie + 查角色 → admin 就放过 +5. 非 admin 或未登录 → 401(浏览器看到 Cloudflare/Caddy 的 401 错误页) + +**不需要也不应该**再输 pgAdmin 自己的 email/password——`.env` 里 `PGADMIN_EMAIL`/`PASSWORD` +只是 desktop 模式下 pgAdmin 容器初始化用的占位,用户侧感知不到。 -### 反向代理 / iframe 架构 +**本机联调**:直接 `ssh -L 8082:127.0.0.1:8082 ` 然后浏览器开 +`http://localhost:8082/admin/pgadmin/`。这条路径绕过 Caddy,也就没有 forward_auth +校验——管理员自己机器上专用,不对外。 + +左侧树都能看到预注册的 `InvolutionHell (local)`,双击即连。 + +### 反向代理 / forward_auth 架构 ``` -involutionhell.com(Vercel / Next.js) - └─ /admin/database 页面 iframe https://api.involutionhell.com/admin/pgadmin/ - └─ Caddy (global-caddy-gateway, 本机 host 网络) - └─ 匹配 /admin/pgadmin/* → 127.0.0.1:8082 (pgAdmin 容器) +involutionhell.com(主站 / Vercel) + │ 登录成功 → 前端 lib/use-auth.tsx 写 cookie: satoken=xxx; Domain=.involutionhell.com + │ + ├─ /admin/database 页面 → Link target=_blank → api.involutionhell.com/admin/pgadmin/ + │ + └─ 或者用户直接在地址栏敲 api.involutionhell.com/admin/pgadmin/ + │ 浏览器自动带 Domain=.involutionhell.com 的 satoken cookie + ▼ + Caddy (global-caddy-gateway, host 网络) + │ forward_auth 127.0.0.1:8080 uri=/api/admin/pgadmin-check + │ ├─ 200 → 继续代理 + │ └─ 非 200 → 拒绝 + ▼ + 127.0.0.1:8082 (pgAdmin 容器, SERVER_MODE=False) ``` -pgAdmin 容器里的关键环境变量: -- `SCRIPT_NAME=/admin/pgadmin` — 所有自动生成的 URL 自带前缀, - 保证 iframe 里点击链接走对 -- `PGADMIN_CONFIG_X_FRAME_OPTIONS="''"` — 清空默认的 DENY, - 由 Caddy 改用 CSP `frame-ancestors` 控制 -- `PGADMIN_CONFIG_WTF_CSRF_SSL_STRICT=False` — 跨子域 iframe - 情况下 CSRF 严格模式会误伤,关掉 - -Caddy 端响应头改写:剥 `X-Frame-Options`,加 -`Content-Security-Policy: frame-ancestors 'self' https://involutionhell.com …`。 -配置位置:`/home/ubuntu/caddy-gateway/Caddyfile`。 +**pgAdmin 容器环境变量**: +- `SCRIPT_NAME=/admin/pgadmin`:让 pgAdmin 自生成的 URL 自带前缀(含登录跳转 / CSS) +- `PGADMIN_CONFIG_SERVER_MODE=False`:desktop / single-user,**不渲染登录页**。 + 安全由外层 forward_auth 把守 +- `PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED=False`:不用 master password 二次加密 +- `PGADMIN_CONFIG_X_FRAME_OPTIONS=''`:清空默认 DENY,Caddy 负责 CSP +- 容器端口只绑 `127.0.0.1:8082`,不对公网开,唯一公网入口是 Caddy + +**Caddy 配置**(`/home/ubuntu/caddy-gateway/Caddyfile`,不在本仓库): + +```caddy +api.involutionhell.com { + handle /admin/pgadmin/* { + forward_auth 127.0.0.1:8080 { + uri /api/admin/pgadmin-check + copy_headers Cookie + } + header { + -X-Frame-Options + Content-Security-Policy "frame-ancestors 'self' https://involutionhell.com https://*.involutionhell.com https://*.vercel.app http://localhost:3000 http://localhost:3010" + } + reverse_proxy 127.0.0.1:8082 { + header_up X-Script-Name /admin/pgadmin + header_up X-Scheme https + header_up X-Forwarded-Proto https + } + } + handle { reverse_proxy 127.0.0.1:8080 } +} +``` + +**后端端点**(`AdminInfraController.java`): + +```java +@RestController +@RequestMapping("/api/admin") +public class AdminInfraController { + @GetMapping("/pgadmin-check") + @SaCheckRole("admin") + public ApiResponse pgadminCheck() { return ApiResponse.okMessage("authorized"); } +} +``` + +sa-token 默认从 header **和** cookie 同时读(`is-read-cookie=true` 默认开), +所以无论是同源 fetch 带 satoken header、还是跨子域的新标签页靠 cookie 自动带, +都能命中同一套角色校验。 + +**前端 cookie 同步**(`lib/use-auth.tsx` 的 `syncTokenCookie`): + +登录成功 / 每次刷新有效 session 时把 `localStorage.satoken` 复制一份到 cookie, +`Domain=.involutionhell.com; Max-Age=2592000`。localhost 开发时不带 Domain。 +登出 / token 失效时反向清 cookie。 ### 手动备份(立刻打一个快照) From 167fbe09221ed87aed62b0bf0deb16cdd3a57566 Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Fri, 17 Apr 2026 21:48:07 +0000 Subject: [PATCH 5/5] =?UTF-8?q?chore(pgadmin):=20=E6=8A=8A=20SERVER=5FMODE?= =?UTF-8?q?=3DFalse=20=E7=9A=84=E5=89=8D=E6=8F=90=E5=86=99=E5=9C=A8=20comp?= =?UTF-8?q?ose=20=E6=B3=A8=E9=87=8A=E9=87=8C=20+=20=E4=BF=AE=20pg-backups?= =?UTF-8?q?=20=E6=8C=82=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 注释里显式写清楚:pgAdmin 跑 desktop 模式(无登录页)的安全前提是外层 Caddy forward_auth 必须先到位。直接暴露 8082 是重大漏洞。 - pg-backups 卷从 /var/lib/pgadmin/storage/... 改挂到 /backups:ro。 之前 SERVER_MODE=True 尝试时会因为 root 所有的备份目录触发 'user does not have permission to read and write the specified storage directory' 让 pgAdmin 无法启动;切回 desktop 模式后也避免继续污染 pgAdmin 自己的 storage 路径。restore 对话框现在要手填 /backups/daily/xxx.dump 路径。 --- docker-compose.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 4920db7..e6e45ab 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -88,6 +88,9 @@ services: environment: PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@involutionhell.com} PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-change_me} + # pgAdmin 保持 SERVER_MODE=False(desktop / single-user,无登录页)—— + # 关键前提:**外层 Caddy 做 forward_auth**,只有带合法 admin satoken + # cookie 的请求才被代理到这里。直接对公网暴露 8082 绝对不行。 PGADMIN_CONFIG_SERVER_MODE: "False" PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: "False" # pgAdmin 被 iframe 嵌入在 involutionhell.com 主站的 /admin/database 页里, @@ -105,9 +108,11 @@ services: # pgpass 主机文件必须是 UID 5050 所有且 0600,否则 pgAdmin 拒绝加载。 # 用 `sudo chown 5050:5050 docker/pgadmin/pgpass && sudo chmod 600 …` 设好。 - ./docker/pgadmin/pgpass:/tmp/pgpass - # 备份卷挂到 pgAdmin 用户默认存储目录,这样在 Restore 对话框里能直接选到 - # pg-backup 容器每天生成的 dump 文件。路径格式:/ - - pg-backups:/var/lib/pgadmin/storage/admin_involutionhell.com/backups + # 备份卷挂到容器内 /backups(只读)。SERVER_MODE=True 下 /var/lib/pgadmin/ + # storage// 目录由 pgAdmin 运行时自建并校验 5050 所有权,把 root + # 所有的 pg-backups 挂进去会触发 "user does not have permission" 启动失败。 + # 在 pgAdmin Restore 对话框里手填路径 /backups/daily/xxx.dump 即可。 + - pg-backups:/backups:ro depends_on: postgres: condition: service_healthy @@ -126,7 +131,12 @@ services: POSTGRES_DB: ${POSTGRES_DB:-involution_hell} POSTGRES_USER: ${POSTGRES_USER:-involution} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change_me} + # -Fc 产出 custom 格式(.dump),pgAdmin 右键 Restore 直接一键恢复; + # 镜像包装脚本默认会 gzip,这里必须显式设 BACKUP_SUFFIX=.dump 且不加 gzip + # 选项,让镜像按 custom 格式直接落盘。 POSTGRES_EXTRA_OPTS: "-Fc --blobs" + BACKUP_SUFFIX: ".dump" + BACKUP_COMPRESS: "none" SCHEDULE: "@daily" BACKUP_KEEP_DAYS: 30 BACKUP_KEEP_WEEKS: 8