diff --git a/.dockerignore b/.dockerignore index a93f66b..2e08909 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,6 +8,18 @@ data/ logs/ +# ---- 备份归档:必须排除,这条是硬要求 ---- +# backups/ 里的 zip 含 data/instance.json,而那个文件躺着 SECRET_KEY 与 +# cookie_key。漏了这一行,`docker build` 会把整库密钥原样烤进镜像层 —— +# 推一次镜像就等于把密钥分发给所有能拉镜像的人,而 Dockerfile 里后续的 +# chown/chmod 都改不掉「镜像层里已经有一份」这个事实(层是只读叠加的, +# 想靠 COPY 之后再 RUN rm 抹掉也只会多留一个含内容的中间层)。 +# 换言之:这不是「少拷一个目录」,是**凭证泄露**,只能在这里挡。 +backups/ + +# 示例数据是 tools/demo_data.py 的产物,运行时不需要(镜像再小一点) +data/demo/ + # Python 缓存 # 注意:`__pycache__/` 只能匹配上下文**根目录**下的同名目录, # 嵌套的(如 tools/__pycache__)必须用 `**/` 前缀——否则会被原样打进镜像。 diff --git a/.env.example b/.env.example index 240b208..f3aca93 100644 --- a/.env.example +++ b/.env.example @@ -10,10 +10,50 @@ WB_PORT=8848 TZ=Asia/Shanghai # ---------- 首个管理员(只在数据库为空时生效)---------- -# 强烈建议:首次启动前就设好,避免用默认的 admin/admin123 暴露在局域网上 +# 强烈建议:首次启动前就设好。 +# 留空时程序会生成**随机**口令,并且只在启动日志里打印一次: +# docker compose logs portal | grep -A1 口令 +# 抄下来登录后立刻改掉。(1.4.0 起不再有 admin123 这类硬编码兜底。) WB_ADMIN_USER=admin WB_ADMIN_PASSWORD= +# ---------- 容器资源上限 ---------- +# 应用层已经有「采集频率 / 并发 / 采集跨度」三重业务刹车,这里是**进程**层面的 +# 兜底:一次异常(内存泄漏、超大导出、正则回溯)不能把整台机器带下去。 +# 单写者架构下不要靠加副本扛负载,限制单实例资源才是正解。 +# +# CPUS : CPU 上限,允许小数。SQLite 是单写者,1.0 已经够用; +# 压测后再调大,别一上来就给满宿主机的核。 +# MEM_LIMIT : 内存上限。500 条/页采集时的常驻内存在 100~200 MB 量级, +# 512m 留了充裕余量;内存与 swap 同时设成这个值 = 禁用 swap, +# 这样超限会「被 OOM 杀掉」而不是「越来越慢」——后者更难查。 +# PIDS_LIMIT : 进程/线程数上限,挡 fork 炸弹。tini + python + waitress +# 线程模型下 256 很宽松。 +WB_CPUS=1.0 +WB_MEM_LIMIT=512m +WB_PIDS_LIMIT=256 + +# ---------- waitress 线程数 ---------- +# 这是「单实例能同时吃进几个慢请求」的上限(采集 / 导出 / 备份恢复都算慢请求)。 +# 与 CPU 上限配套:1 核配 8 线程容易出现「都在等 CPU」的假并发,默认 4。 +WB_THREADS=4 + +# ---------- 反向代理与传输安全(三个必须一起决定)---------- +# WB_TRUST_PROXY:是否信任 X-Forwarded-For。**默认 0 = 不信任**。 +# 直接暴露给公网时必须留 0。置 1 的后果很严重:攻击者每次换一个伪造的 +# XFF,验证码限速、注册配额、登录锁定这三道 IP 防线会**同时失效**。 +# 置 1 的前提只有一个:**你自己的**反向代理会写这个头。 +# 开启后程序取 XFF 里**最右侧**的合法 IP,而最右侧是离你最近的那一跳 +# (由你的代理写入),客户端伪造不了。因此 nginx 侧两种写法都安全: +# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 保留链路,便于排查 +# proxy_set_header X-Forwarded-For $remote_addr; # 覆盖,最保守 +# 真正不能做的是:去信 XFF 里**最左边**那一段(那是客户端自己填的)。 +WB_TRUST_PROXY=0 +# 前面挂了 HTTPS 反代时置 1(读到 X-Forwarded-Proto: https 就不跳转) +WB_FORCE_HTTPS=0 +# 是否记录访问日志(写进 logs/app.log,静态资源与验证码图片除外) +WB_ACCESS_LOG=1 + # ---------- 调度 ---------- # 一个容器一份调度。只有跑多副本时才把除第一份之外的都设成 1。 WB_DISABLE_SCHEDULER=0 @@ -34,11 +74,12 @@ WB_IMPORT_XLSX= # ---------- 镜像名(推送 Gitea 注册表时用)---------- # WB_IMAGE=git.iwali.top/wangchuanli/workbuddy-portal:latest -# WB_IMAGE=git.iwali.top/wangchuanli/workbuddy-portal:1.2.0 +# WB_IMAGE=git.iwali.top/wangchuanli/workbuddy-portal:1.4.0 # ---------- 仅叠加 docker-compose.hostdir.yml 时有效 ---------- -# 把数据/日志放到宿主机目录而不是命名卷。**只建议 Linux 宿主机使用**: +# 把数据/日志/备份放到宿主机目录而不是命名卷。**只建议 Linux 宿主机使用**: # Windows + Docker Desktop 的 9p 挂载下,宿主进程访问过 WAL 库之后, # 容器侧会打不开数据库且不自愈(详见 docs/DEPLOYMENT.md)。 # WB_HOST_DATA_DIR=./data # WB_HOST_LOG_DIR=./logs +# WB_HOST_BACKUP_DIR=./backups diff --git a/Dockerfile b/Dockerfile index 2384f43..dfcfc26 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,7 +26,7 @@ FROM python:3.13-slim AS runtime LABEL org.opencontainers.image.title="WorkBuddy Portal" \ org.opencontainers.image.description="WorkBuddy 积分用量采集 / 存储 / 呈现一体化门户" \ - org.opencontainers.image.version="1.3.0" \ + org.opencontainers.image.version="1.4.0" \ org.opencontainers.image.source="https://git.iwali.top/wangchuanli/workbuddy-portal" ENV PYTHONUNBUFFERED=1 \ @@ -36,6 +36,7 @@ ENV PYTHONUNBUFFERED=1 \ PATH="/opt/venv/bin:$PATH" \ WB_DATA_DIR=/app/data \ WB_LOG_DIR=/app/logs \ + WB_BACKUP_DIR=/app/backups \ WB_HOST=0.0.0.0 \ WB_PORT=8848 @@ -55,16 +56,26 @@ WORKDIR /app # --chown 让非 root 用户能读写挂载卷之外的文件;.dockerignore 已挡掉数据与日志 COPY --chown=app:app . . +# 构建期断言:万一 .dockerignore 漏掉 backups/,在这里直接失败。 +# 只靠「记得改 .dockerignore」不可靠 —— 备份归档里有 instance.json(含 +# SECRET_KEY 与 cookie_key),进了镜像层就是整库密钥随镜像分发,且**无法** +# 用后续的 RUN rm 抹掉(只会多留一个含内容的只读层)。让构建自己拒绝。 +RUN set -eux; \ + if [ -n "$(ls -A /app/backups 2>/dev/null)" ]; then \ + echo "ERROR: /app/backups 里有内容被打进镜像层 —— .dockerignore 漏了 backups/" >&2; \ + exit 1; \ + fi + RUN set -eux; \ chmod +x /app/docker/entrypoint.sh /app/docker/healthcheck.py; \ - mkdir -p /app/data /app/logs; \ - chown -R app:app /app/data /app/logs; \ + mkdir -p /app/data /app/logs /app/backups; \ + chown -R app:app /app/data /app/logs /app/backups; \ python -c "import workbuddy_portal, flask, waitress; print('deps ok', flask.__version__)" USER app EXPOSE 8848 -VOLUME ["/app/data", "/app/logs"] +VOLUME ["/app/data", "/app/logs", "/app/backups"] HEALTHCHECK --interval=30s --timeout=6s --start-period=20s --retries=3 \ CMD ["python", "/app/docker/healthcheck.py"] diff --git a/README.md b/README.md index 0d5882a..c42aa06 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,12 @@ | 前端 | 服务端渲染后台 + 独立 ECharts 大屏(离线自带的 `echarts.min.js`) | | 部署 | **三条路径**:裸机 / Docker 自打包 / compose 拉云端镜像;镜像可推 Gitea 容器注册表 | | 鉴权 | **多用户**(各自的数据与凭证严格隔离)+ 全站登录 + CSRF + 角色(管理员 / 普通) | -| 权限 | 普通账号**只能维护本人的 Cookie / User-Agent**;调度频率、采集参数、日志、用户管理都归管理员 | +| 权限 | 普通账号**只能维护本人的 Cookie / User-Agent**;调度频率、采集参数、日志、用户管理、备份都归管理员 | | 凭证 | Cookie **ChaCha20 + HMAC 静态加密**入库,页面与接口只回掩码 | -| 防攻击 | 登录 / 注册**图形验证码**(服务端出题 + 一次性)、失败限速、注册限额 | -| 版本 | v1.3.0 | +| 防攻击 | 登录 / 注册**图形验证码**(服务端出题 + 一次性)、双维度失败限速、注册限额、口令黑名单 | +| 备份 | **自动 + 手动**一致性快照、按份数清理、在线下载、**一键恢复**(恢复前自动再存一份);用户可导出本人全部数据 | +| 资源上限 | 容器层 `cpus` / `mem_limit` / `pids_limit`;实例层采集最小间隔、采集跨度硬顶 **31 天**、**有任务在跑不允许新建任务** | +| 版本 | v1.4.0 | | **许可证** | **MIT**(第三方组件与再分发资源见 [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)) | **目录**:[核心特性](#核心特性) · [架构](#架构一图) · [快速开始](#快速开始) · [命令一览](#命令一览) · @@ -38,8 +40,9 @@ | **单写者保证** | 文件锁 `data/collect.lock` 让「调度 / 页面手动触发 / CLI」三处不并发写 SQLite;僵尸锁 30 分钟可抢占 | | **全量存档** | 不随官网导出窗口过期而丢数据;官网 xlsx 丢失约 22% 的 `Prompt`,可用 `fill-prompt` 回补 | | **大屏去中间层** | 大屏直接走 `/api`,按当前筛选窗口实时聚合;左侧多取等长一段用于算环比,窗口不变不重复请求 | -| **可观测** | 每次采集落一条 `collect_runs`(含 `[warn]`/`[error]` 逐行原文);另有操作审计与登录审计,均带账号归属 | -| **一键备份** | 正本就是宿主机上的一个 `.sqlite` 文件,拷走即可;`manage.py vacuum` 回收空闲页 | +| **可观测** | 每次采集落一条 `collect_runs`(含 `[warn]`/`[error]` 逐行原文);另有操作审计与登录审计,均带账号归属;**访问日志**写进 `logs/app.log`(waitress 本身不记 access log) | +| **备份与恢复** | 备份走 SQLite **在线备份 API**(不是 `cp`,采集正在写也能拿到一致快照),归档成含所有 `*.sqlite` + `instance.json` 的 zip;可设**周期与保留份数**、管理员下载与**一键恢复**(恢复前自动再存一份当前库)、普通用户**导出本人全部数据**。归档不在 `data/` 卷里,`down -v` 不会连备份一起删 | +| **可安全对外** | 容器层限 `cpus`/`mem_limit`/`memswap`/`pids_limit`/`nofile`;实例层限采集最小间隔、每日时刻数、**采集跨度硬顶 31 天**;**有任务在跑时新建任务直接 409**;默认不信任 `X-Forwarded-For`(否则三道 IP 防线全废) | --- @@ -73,6 +76,7 @@ | 采集 | `workbuddy_portal/collect.py` + `scheduler.py` | 纯 `urllib` 调云端;断点、去重、锁、导入导出,全部按 `uid` 隔离 | | 存储 | `workbuddy_portal/db.py` + `schema.sql` | SQLite WAL,单写者;运行期配置也在库里(`settings` 表,主键 `(user_id, key)`) | | 加固 | `workbuddy_portal/crypto.py` + `captcha.py` | 凭证静态加密(零第三方依赖手写 ChaCha20);验证码用**自写 PNG 编码器**出图 | +| 备份 | `workbuddy_portal/backup.py` | 在线备份 API 出快照 → zip 归档 → 按份数清理 → 校验 → 整表恢复(含恢复前自动兜底) | | 聚合 | `workbuddy_portal/query.py` | `daily / dims / top / records / summary / bundle`,全部下推 SQL,`uid` 是第一个条件 | | 呈现 | `workbuddy_portal/web/` | Jinja 后台(`views.py`)+ JSON API(`api.py`)+ 静态大屏 | @@ -123,8 +127,10 @@ docker compose up -d 三条路径跑起来后都是:打开 `http://:8848` → 用管理员登录 → **先把密码改掉** → 去「配置管理」粘贴 Cookie。 -> 数据与日志放在 Docker **命名卷**(`workbuddy-portal_wb_data` / `_wb_logs`)里, -> `docker compose down` 不会删。要用 CLI 就 `docker compose exec portal python manage.py …`。 +> 数据、日志与备份放在 Docker **命名卷**(`workbuddy-portal_wb_data` / `_wb_logs` / +> `_wb_backups`)里,`docker compose down` 不会删。要用 CLI 就 `docker compose exec portal python manage.py …`。 +> **备份刻意独立成一个卷**:与正本同卷时,一次 `down -v` 会把两者一起带走 —— +> 那正好是最需要备份的时刻。 > **不要在宿主机上跑 `manage.py` 去连容器的库**——Windows + Docker Desktop 的 9p 挂载下, > 宿主进程碰一次 WAL 库就会让容器打不开数据库(纯读也会触发,且不自愈)。 > 想直接看到数据/日志,用 `docker-compose.hostdir.yml` 叠加层(**仅建议 Linux 宿主机**)。 @@ -132,23 +138,30 @@ docker compose up -d > **从旧版本升级不需要手工介入**:`init` 由 `PRAGMA user_version` 驱动,自动迁移且幂等。 > v1.1.0 → v1.2.0 是「单用户 → 多用户」(历史数据归到首个账号、明文 Cookie 就地加密); -> v1.2.0 → v1.3.0 是「配置作用域收敛」(调度与采集参数从个人级提升到实例级)。 -> 两次都带审计留痕,可重复执行。见 [CHANGELOG](docs/CHANGELOG.md)。 +> v1.2.0 → v1.3.0 是「配置作用域收敛」(调度与采集参数从个人级提升到实例级); +> v1.3.0 → v1.4.0 只加一列(`users.session_ver`)与一张新表(`backups`), +> **无数据搬运**。三次都带审计留痕,可重复执行。见 [CHANGELOG](docs/CHANGELOG.md)。 ### 第一次使用必做 **管理员:** -1. **改密码**——局域网可访问,默认密码等于没锁门(「个人中心」或「配置管理 → 修改密码」)。 -2. **确认调度时刻**——「任务管理」里把 `09:00,17:00` 改成你的习惯时刻,保存即生效 - (这是**实例级**的,对全站账号生效)。 -3. **填自己的 Cookie**——「配置管理 → 凭证」,否则采集只会记一条 `no_cookie`。 +1. **拿到并改掉初始密码**——`WB_ADMIN_PASSWORD` 留空时程序会生成**随机**口令, + 只在启动日志里打印一次(`docker compose logs portal | grep -A6 管理员初始口令`)。 + **没有 `admin123` 这类默认口令了**,拿不到就去查看日志。 +2. **填自己的 Cookie**——「配置管理 → 凭证」,否则采集只会记一条 `no_cookie`。 +3. **确认调度与限制**——「任务管理」里把 `09:00,17:00` 改成你的习惯时刻; + 同时确认「每日时刻上限」「采集最小间隔」「单次最长跨度」三个刹车(都是**实例级**的, + 对全站账号生效)。 +4. **确认自动备份**——「备份管理」页看状态与保留份数;建议点一次「立即备份」, + 并**把一份归档下载到容器之外**(备份留在同一台机器上只防「改错了」,不防「机器没了」)。 **普通账号(注册进来的默认身份):** -1. **改密码**——「个人中心 → 修改登录密码」。 +1. **改密码**——「个人中心 → 修改登录密码」(改完其他设备上的登录会立刻失效)。 2. **填自己的 Cookie**——这是你**唯一**需要动手的配置。 3. 想立刻看数据?点「任务管理 → 立即采集一次」,不用等调度时刻。 +4. 想把自己的数据带走?「个人中心 → 导出我的全部数据」。 ### 想给同事开账号? @@ -181,6 +194,9 @@ docker compose up -d | `fill-prompt [-u 账号]` | 回补缺失的 `User Prompt`(官网导出会丢约 22%) | | `export-csv [路径] [-u 账号]` | 导出 CSV(默认 `data/exports/usage_records_<账号>.csv`,文件名带归属) | | `vacuum` | `wal_checkpoint(TRUNCATE)` + `VACUUM`,回收空闲页、压缩 WAL | +| `backup [--note 说明]` | 立即打一份备份(所有 `data/*.sqlite` + `instance.json` → 一个 zip),并按保留份数清理最旧的 | +| `backups [--prune] [--keep N]` | 列出备份(大小 / 条数 / 积分 / 来源 / 时间),`--prune` 顺便按保留份数清理 | +| `restore <文件名> --yes` | **从备份恢复**(整表替换)。破坏性操作,必须显式 `--yes`;不加只打印将要发生什么。恢复前会自动把当前库另存一份 | | `stats [-u 账号]` | 先全库概览(每账号多少条 / 多少积分 / Cookie 状态),再给指定账号的维度明细 | | `users` | 列出所有账号:角色、状态、数据量、凭证状态、最近登录 IP | | `status` | 逐账号显示调度开关 / 下次执行 / Cookie 状态 / 最近采集 | @@ -227,9 +243,11 @@ python tools/shots.py --base http://127.0.0.1:8849 --full # 逐页截图( | `/records` | **数据明细**:快捷区间、日期/模型/客户端/关键词筛选、排序、分页、展开 Prompt、导出 CSV | ✅ | | `/tasks` | **任务管理**:手动采集与按区间补采、运行历史。**调度开关与时刻对普通账号是只读的** | ⚠️ 只读调度 | | `/config` | **配置管理**:自己的 Cookie / UA(**唯一可改**)+ 只读的采集参数 + 维护动作;底部实例级设置区 | ⚠️ 仅凭证可改 | -| `/profile` | **个人中心**:账号概况、我的凭证状态(密文入库)、改密码;点右上角用户名进入 | ✅ | +| `/profile` | **个人中心**:账号概况、我的凭证状态(密文入库)、改密码(其他设备会话立即失效)、**导出我的全部数据**;点右上角用户名进入 | ✅ | +| `/profile/export` | **导出本人全部数据**(zip:使用记录 / 采集历史 / 操作审计 / 我的账号与配置;**不含 Cookie 明文**),10 秒限速 | ✅ | | `/logs` | **日志管理**(**仅管理员**):全实例采集详情(含 `[warn]`/`[error]` 原文)、应用日志尾部、操作审计 | ❌ 403 | | `/users` | **用户管理**(**仅管理员**):新建账号、改显示名/权限/状态/密码、删除、账号操作审计 | ❌ 403 | +| `/backups` | **备份管理**(**仅管理员**):份数/占用/自动备份状态与下次时间、周期与保留份数设置、列表(下载 / 恢复 / 删除) | ❌ 403 | ![概览](docs/images/01-overview.png) @@ -264,6 +282,11 @@ python tools/shots.py --base http://127.0.0.1:8849 --full # 逐页截图( | GET | `/captcha.png?purpose=login\|register` | **图形验证码图片**(唯一无需登录的接口;每次都是新题,带 `no-store`) | 公开 | | GET | `/logs/tail` | 应用日志尾部 | **仅管理员** | | GET | `/records/export` | 按筛选流式导出 CSV | 登录(只导自己) | +| GET/POST | `/api/backups` | 列出备份(含大小/条数/来源/是否存在) / 立即打一份 | **仅管理员** | +| GET | `/api/backups/` | **下载**一份归档(zip) | **仅管理员** | +| POST | `/api/backups//restore` | **从该归档恢复**(整表替换;`include_instance` 决定是否连密钥一起回滚) | **仅管理员** | +| POST | `/api/backups//delete` · `/api/backups/prune` | 删除一份 / 按保留份数清理 | **仅管理员** | +| GET | `/profile/export` | 导出**本人**全部数据(zip,不含 Cookie 明文) | 登录 | --- @@ -274,16 +297,16 @@ workbuddy-portal/ ├── manage.py 统一 CLI(唯一入口) ├── requirements.txt 仅 Flask / waitress / openpyxl(零多余依赖) ├── Dockerfile 多阶段构建(依赖层 / 运行层) -├── docker-compose.yml 单服务编排(数据放 Docker 命名卷) +├── docker-compose.yml 单服务编排(数据 / 日志 / 备份各一个命名卷 + 容器资源上限) ├── docker-compose.hostdir.yml 可选叠加层:改用宿主机目录(仅建议 Linux) -├── .env.example 环境变量样例 +├── .env.example 环境变量样例(含资源上限与反代开关) ├── docker/ │ ├── entrypoint.sh 幂等初始化 → exec serve(LF 行尾) │ └── healthcheck.py 标准库健康检查(免登录页 /login) ├── docs/ 文档 + images/(手册配图,合成数据) ├── data/ 【运行时】正本 usage.sqlite / instance.json / exports/ / demo/ ├── logs/ 【运行时】app.log(滚动 2 MB × 3) -├── backups/ 数据库快照统一落点(整目录被 git 忽略) +├── backups/ 归档落点(**刻意不在 data/ 里**,整目录被 git 与 docker 忽略) ├── tools/ │ ├── smoke.py 离线回归(215 项断言) │ ├── check_live.py 真实 HTTP 验收(122 项断言,支持 --as 普通账号) @@ -300,13 +323,14 @@ workbuddy-portal/ ├── security.py 密码哈希、会话、CSRF、失败限速、验证码策略、角色、响应头 ├── client.py 云端接口(urllib)+ 编辑器凭证读取 ├── collect.py 增量采集 / 去重入库 / 互斥锁 / xlsx 导入 / CSV 导出 - ├── scheduler.py 进程内调度线程(实例级时刻表 + 槽位去重 + 启动补跑) + ├── scheduler.py 进程内调度线程(实例级时刻表 + 槽位去重 + 启动补跑 + 自动备份) ├── query.py SQL 聚合层(uid 必填) + ├── backup.py 备份 / 恢复(在线快照 → zip → 份数清理 → 校验 → 整表恢复) └── web/ - ├── views.py 页面路由(含 /login /register /captcha.png /profile) + ├── views.py 页面路由(含 /login /register /captcha.png /profile /backups) ├── api.py JSON API ├── templates/ base / login / register / profile / overview / tasks / - │ config / logs / records / users / error + │ config / logs / records / users / backups / error └── static/ ├── css/app.css 统一设计令牌 ├── js/app.js 带 CSRF 的请求、表单与维护动作绑定 @@ -316,6 +340,9 @@ workbuddy-portal/ > `backups/` **刻意不放在 `data/`**:`data/` 在 Docker 部署下是命名卷, > `docker compose down -v` 会把备份和正本一起删掉——那正好是最需要备份的时刻。 +> 容器里它挂的是独立的 `wb_backups` 卷;**绝不用绑定挂载**(Windows 9p 下会让容器 +> `unable to open database file`,且不自愈)。该目录同时被 `.gitignore` 与 `.dockerignore` 忽略: +> 归档里含 `instance.json`,进了镜像就等于把整库密钥分发给所有能拉镜像的人。 > > 工作区根下另有一个 `legacy-v1/`(v1.0 单文件版归档,仅作 `migrate-csv` 的来源,可安全删除), > 它**在仓库之外**,所以含真实数据的 CSV 不会被提交。 @@ -342,22 +369,27 @@ workbuddy-portal/ ## 安全须知 -局域网可访问 ⇒ 以下每一条都必要: +局域网甚至公网可访问 ⇒ 以下每一条都必要: -- **必须改默认密码**;给同事发**普通账号**(注册出来的默认身份),不要共用管理员 - ——管理员是能停用别人账号的角色。 +- **初始口令不是固定的**(v1.4.0 起):`WB_ADMIN_PASSWORD` 留空时程序生成**随机**口令, + 只在启动日志里打印一次、库里只存散列。**没有 `admin123` 这类默认口令可猜**, + 代价是「拿不到那行日志就进不去」——首次启动后请立刻 `docker compose logs portal | grep -A6 管理员初始口令`。 + 给同事发**普通账号**(注册出来的默认身份),不要共用管理员 —— 管理员是能停用别人账号的角色。 - **权限只有两档,且规则只有一条**:`config.writable_by(key, is_admin)`。 普通账号能写的只有 `USER_EDITABLE_KEYS = {cookie, user_agent}`(且只限本人这份); - 调度频率、采集参数、注册策略、日志、用户管理全部关死。 + 调度频率、采集参数、注册策略、日志、用户管理、**备份**全部关死。 **页面上的置灰/隐藏只是「不给误导性按钮」,真正的闸门在 `@admin_required` 与 `writable_by()` 这两个服务端判断上**——所以直接敲 URL 或构造请求也过不去。 - **数据按账号隔离**:`uid` 是所有查询的必填位置参数(漏传直接报错,不会静默返回全量); `/api/runs/`、`/api/records/` 这类按 id 取的单条接口也带 `user_id` 约束; - `/logs`、`/logs/tail`、`/users`、`/api/users*` 仅管理员。 + `/logs`、`/logs/tail`、`/users`、`/api/users*`、`/backups`、`/api/backups*` 仅管理员。 +- **不信任 `X-Forwarded-For`(默认)**:全站只有 `security.client_ip()` 一个取客户端地址的 + 入口。默认直接用 `remote_addr`;`WB_TRUST_PROXY=1` 时取**最右侧**合法 IP。 + 这一条不改就是**三道 IP 防线同时失效**:实测每次换一个伪造的 XFF,45 次验证码请求全部放行。 - **Cookie 静态加密**:ChaCha20 + HMAC-SHA256(encrypt-then-MAC)密文入库,主密钥在 - `data/instance.json` 的 `cookie_key`(**与 `SECRET_KEY` 分开**,轮换代价不同)。 - `get_settings()` 把加密键一律置空,要明文只有 `db.get_secret()` 一条路—— - 这样任何「顺手打印全部配置」的代码都带不出凭证。升级时历史明文会被自动加密。 + `data/instance.json` 的 `cookie_key`(**与 `SECRET_KEY` 分开**,轮换代价不同), + POSIX 上该文件权限收到 `0600`。`get_settings()` 把加密键一律置空,要明文只有 + `db.get_secret()` 一条路——这样任何「顺手打印全部配置」的代码都带不出凭证。 - **Cookie 不跨账号回落**:`NO_FALLBACK_KEYS`(`cookie` / `user_agent`)不参与实例级回落, 否则新账号会「继承」管理员的凭证,属于最严重的串号越权。 - **实例级也不存凭证**:`init_db()` 灌默认值时会跳过 `USER_EDITABLE_KEYS` 并显式删除 @@ -365,18 +397,38 @@ workbuddy-portal/ - **验证码先于口令校验**:登录时先验验证码再比密码,避免攻击者拿「密码对不对」当信号, 在解验证码之前就把字典跑完。答案存服务端 `captchas` 表,**一次性、5 分钟过期、按用途隔离**, 下发到浏览器的只有随机 id(Flask 会话是签名不加密的,放答案等于送答案)。 + 字模本身带**逐字符随机旋转 / 切变 / 缩放抖动 / 波浪偏移**,同一验证码两次渲染字节差异 **76.9%**。 - **注册受双重限制**:验证码 + 同 IP 每日配额(默认 3 个,可改;`allow_register=0` 可整体关闭)。 -- **停用账号立即失效**:`current_user()` 每个请求回查 `users.status`,不必等 12 小时会话过期。 +- **停用账号立即失效**:`current_user()` 每个请求回查 `users.status` **与 `users.session_ver`**, + 不必等 12 小时会话过期。改密码 / 管理员重置口令 / 停用 / 删除都会让该账号的**所有其他设备上的 + 会话立刻作废**(改自己密码时保留当前这次会话,否则改完立刻被自己踢下线); + **恢复备份**会让全站所有会话作废。 +- **口令有黑名单**:`WEAK_PASSWORDS`(34 个自动撞库字典的头几页)在**设置/修改**口令时拒绝, + 登录时不校验 —— 否则会把用老口令的存量用户挡在门外。 - **CSRF 全站校验**,退出登录也是 `POST`(GET 型退出能被 `` 静默触发)。 前端 `formData()` 会跳过 `disabled` 控件(含祖先 `fieldset[disabled]`)—— disabled 的 input 仍在 `form.elements` 里,一起提交会让服务端因「越权修改只读项」拒掉**整单**。 - **开放重定向防护**:登录跳转的 `next` 只接受站内相对路径,`//evil.com` 这类协议相对 URL 一律回落到 `/`。 -- **登录限速**:按 **IP 与用户名两个维度**分别计数,任一维度连续失败 5 次即锁 10 分钟; - 失败计数表有上限与 TTL;验证码出图另有 60 秒 40 张的限速(不设限就是一条廉价的 CPU 放大路径)。 +- **登录限速按两个维度、且强度刻意不同**:IP 维度连续失败 5 次**真锁** 10 分钟; + **用户名维度只做秒级递增退避**(封顶 60 秒)——因为「知道用户名就能把对方锁死 10 分钟」 + 本身就是攻击,而**管理员用户名在导航栏里是公开的**。另有**单来源登录尝试总量** + (40 次 / 5 分钟,**含成功**)挡住「慢慢撞、不触发失败阈值」的形态。 + 失败提示是「本来源连续失败 N 次」而不是「剩余 N 次」——不给攻击者倒计时。 + 验证码出图另有 60 秒 40 张的限速(不设限就是一条廉价的 CPU 放大路径)。 +- **重操作都有最小间隔**:手动采集 60 秒、CSV 导出 15 秒、导出本人数据 10 秒、 + `vacuum` 120 秒、补全 prompt 60 秒。**采集跨度硬顶 31 天**(代码层 `COLLECT_MAX_RANGE_DAYS_HARD`, + 改配置也突破不了);**有采集任务在跑时新建任务直接 `409`**。 - **安全响应头**:CSP(`frame-ancestors 'none'`)、`X-Frame-Options: DENY`、`nosniff`、 `Referrer-Policy: same-origin`、COOP;`/api/*` 与 `/captcha*` 带 `Cache-Control: no-store`。 -- **不进版本库的文件**:`data/instance.json`(含 `secret_key` 与 `cookie_key`)、 - `data/usage.sqlite`、`data/shots/`、`data/demo/`、`backups/`、`logs/`、`.env`(含明文密码)。 + **HSTS 只在真的走 HTTPS 时下发**(`COOKIE_SECURE or FORCE_HTTPS`)——纯 HTTP 部署下发会让 + 浏览器强升 https,表现成白屏,是个很难归因的故障。 +- **有访问日志**:waitress 自己不记 access log,本程序补上(写进 `logs/app.log`, + 跳过 `/static/` 与验证码图片)。出事无据可查时这一条很值。 +- **不进版本库、也绝不进镜像的文件**:`data/instance.json`(含 `secret_key` 与 `cookie_key`)、 + `data/usage.sqlite`、`data/shots/`、`data/demo/`、`backups/`(归档里**含 `instance.json`**)、 + `logs/`、`.env`(含明文密码)。`.dockerignore` 里 `backups/` 这条是**硬要求**, + Dockerfile 还有一条构建期断言兜底:万一漏了就直接构建失败 —— + 镜像层是只读叠加的,靠 `RUN rm` 补救只会多留一个含内容的中间层。 --- diff --git a/SECURITY.md b/SECURITY.md index 023374a..e70439a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,14 +2,30 @@ ## 支持范围 -本项目按「自托管、局域网内使用」的定位开发。安全修复只针对当前主分支与最新发布版本。 +本项目按「自托管」定位开发,**1.4.0 起已按「可以暴露到公网」加固**(IP 来源、限速、 +资源上限、采集跨度硬顶都补上了),但仍然建议置于反向代理之后。安全修复只针对当前主分支与最新发布版本。 | 版本 | 是否接受安全修复 | |---|---| -| `1.3.x`(当前) | ✅ | +| `1.4.x`(当前) | ✅ | +| `1.3.x` | ⚠️ 可用,但**公网暴露前必须升级**(1.4.0 修掉了三道 IP 防线可被 XFF 伪造绕过的问题) | | `< 1.3` | ⚠️ 可用,但建议升级(1.3.0 收紧了普通账号的越权面,见下) | | `< 1.2` | ❌ 请先升级(1.2.0 修掉了单用户时代「Cookie 明文入库」与「人人都是管理员」两个根本问题) | +> **1.4.0 修掉了什么**(三条 P0): +> 1. **`X-Forwarded-For` 可伪造 ⇒ 三道 IP 防线(验证码限速 / 注册配额 / 登录锁定)同时失效**。 +> 实测:每次换一个伪造的 XFF,45 次验证码请求**全部放行**。现在默认不信任 XFF, +> 全站只有 `security.client_ip()` 一个取客户端地址的入口。 +> 2. **默认口令 `admin123` 硬编码兜底**:现在没有默认口令,留空则生成随机口令、 +> 只在启动日志打印一次、不写进数据库。 +> 3. **`.dockerignore` 漏了 `backups/` ⇒ 凭证密文被打进镜像**:`backups/` 里那份 +> 4.5 MB 的真实库快照含 `settings` 凭证密文与 `users` 口令散列, +> `docker build` 会把它烤进镜像层,推一次镜像等于把整库密钥分发出去。 +> +> 还收掉了一批 P1:采集/导出无跨度上限与频率限制、`WB_COOKIE_SECURE` 默认关且无 HSTS、 +> 账号锁定可被当武器(管理员用户名公开)、撞库不受限、验证码强度不足(可模板匹配)、 +> 改密码不失效其他会话、`instance.json` 未设 `0600`、无访问日志。 + > **1.3.0 修掉了什么**:此前「调度时刻 / 采集参数」是**个人级**配置,普通账号可以 > 自己改(等于让普通账号决定这台服务器怎么发请求、关不关 TLS 校验); > 且 `/logs` 对普通账号开放。现在这两块都收归管理员,普通账号只保留 @@ -43,19 +59,26 @@ | 项 | 做法 | 位置 | |---|---|---| | 全站鉴权 | 每个页面都有 `@login_required`,每个 `/api/*` 未登录返回 401 JSON | `security.login_required`、`web/views.py` | -| 角色(两档) | 管理员 / 普通。**仅管理员**:`/users`、`/api/users*`、`/logs`、`/logs/tail`、`vacuum` | `security.admin_required` | +| 角色(两档) | 管理员 / 普通。**仅管理员**:`/users`、`/api/users*`、`/logs`、`/logs/tail`、`vacuum`、`/backups`、`/api/backups*`(含下载与**恢复**) | `security.admin_required` | | **写权限只有一条规则** | `config.writable_by(key, is_admin)` —— 普通账号能写的只有 `USER_EDITABLE_KEYS = {cookie, user_agent}`(且只限本人那份)。页面与接口共用这一个判断,杜绝「界面置灰但接口还能写」 | `config.writable_by` | | **界面置灰 ≠ 安全边界** | 页面上的 disabled / hidden 只是「不给误导性按钮」;真正的闸门是服务端 `@admin_required` 与 `writable_by()`,所以直接敲 URL 或构造请求也过不去 | `web/views.py`、`web/api.py` | | 越权写整单拒绝 | 一次提交里只要含不可写的键,整个请求 `400` + `denied` 点名,不做「部分生效」 | `web/api.py:api_settings_post` | -| 停用即失效 | `current_user()` **每个请求**回查 `users.status`,不等 12 小时会话过期 | `security.current_user` | +| 停用即失效 | `current_user()` **每个请求**回查 `users.status` **与 `users.session_ver`**,不等 12 小时会话过期 | `security.current_user` | +| **会话版本号** | 改密码 / 管理员重置口令 / 停用 / **删除**账号 → `bump_session_ver()`,该账号所有其他设备上的会话**立刻作废**。改自己密码时把当前会话刷到新版本(否则改完立刻被自己踢下线)。**恢复备份**会让全站所有会话作废 | `db.session_ver_of` / `db.bump_session_ver` | | 自锁保护 | 管理员不能停用 / 降权 / 删除自己;也不能删掉最后一个启用的管理员 | `web/api.py` | | CSRF | 所有写请求必须带 `X-CSRF-Token`,页面注入 `window.WB_CSRF`,服务端统一拦截;退出登录也是 POST | `security.check_csrf` | | 会话签名 | Flask `secret_key` 由 `data/instance.json` 持有,首次启动随机生成 | `workbuddy_portal/config.py` | | 会话 cookie | `HttpOnly` + `SameSite=Lax` + `Path=/`;HTTPS 部署可设 `WB_COOKIE_SECURE=1` 打开 Secure | `workbuddy_portal/__init__.py` | -| 口令存储 | 加盐哈希(PBKDF2-SHA256),不存明文;强度校验(≥8 位、含两类字符、不得等于用户名) | `security.hash_password` / `password_problem` | +| **客户端 IP 唯一入口** | `security.client_ip()` —— 默认**不信任** `X-Forwarded-For`(直接用 `remote_addr`);`WB_TRUST_PROXY=1` 时取**最右侧**合法 IP(最近一跳由你自己的代理写入,客户端伪造不了)。含 `IPv4:port` 与 IPv6 处理。**所有** IP 相关判断(验证码限速 / 注册配额 / 登录限速 / 审计留痕)都走它 | `security.client_ip` | +| **强制 HTTPS** | `WB_FORCE_HTTPS=1` 时非 HTTPS 的 **GET/HEAD** 跳转(POST 不跳,否则会掉请求体);`WB_TRUST_PROXY` 关闭时不读 `X-Forwarded-Proto` | `security.needs_https_redirect` | +| 口令存储 | 加盐哈希(PBKDF2-SHA256),不存明文;强度校验(≥8 位、含两类字符、不得等于用户名)+ **黑名单** `WEAK_PASSWORDS`(34 个撞库字典头几页)。黑名单**只在设置/修改口令时生效**,登录不校验 —— 否则会把用老口令的存量用户挡在门外 | `security.hash_password` / `password_problem` | +| **初始口令不给默认值** | `WB_ADMIN_PASSWORD` 留空 → `secrets.token_urlsafe(12)` 随机生成,**只在启动日志打印一次、不写进数据库**(审计里出现口令等于永久留档) | `db._create_first_admin` | | 开放重定向 | 登录后的 `next` 只允许站内相对路径,`//evil.com` 一律回落到 `/` | `security.safe_next` | -| 失败限速 | **IP 与用户名两个维度**分别计数,任一维度连续失败 5 次锁 10 分钟;计数表有上限与 TTL | `security.auth_locked` / `note_auth_fail` | +| 失败限速(双维度、**强度刻意不同**) | **IP 维度**:连续失败 5 次**真锁** 10 分钟。**用户名维度**:只做秒级递增退避(封顶 60 秒)—— 「知道用户名就能把对方锁死 10 分钟」本身是攻击,而**管理员用户名在导航里是公开的**。另有**单来源登录尝试总量** 40 次 / 5 分钟(**含成功**),挡「慢慢撞、不触发失败阈值」。计数表有上限与 TTL | `security.auth_block_reason` / `note_try` / `try_window_left` | +| 失败提示不泄漏信息 | 提示是「本来源连续失败 N 次」而不是「剩余 N 次」——不给攻击者倒计时 | `web/views.py:login` | | 响应头 | CSP(`frame-ancestors 'none'`)、`X-Frame-Options: DENY`、`nosniff`、`Referrer-Policy: same-origin`、COOP;`/api/*` 与 `/captcha*` 带 `no-store` | `security.apply_security_headers` | +| **HSTS 只在真 HTTPS 时下发** | `COOKIE_SECURE or FORCE_HTTPS` 才发 `Strict-Transport-Security`。纯 HTTP 部署下发会让浏览器强升 https,表现成白屏 —— 很难归因的一类故障 | `security.apply_security_headers` | +| **访问日志** | waitress 自己不记 access log;本程序补上(跳过 `/static/` 与 `/captcha.png`),写进 `logs/app.log`,`WB_ACCESS_LOG` 控制 | `security._access_log` | ### 多用户数据隔离 @@ -89,17 +112,46 @@ | 项 | 做法 | 位置 | |---|---|---| | 图形验证码 | 手写 PNG 编码器 + 5×7 点阵字模 + 干扰线/噪点;**不用 SVG**(SVG 是文本,答案会明文出现在页面源码里) | `workbuddy_portal/captcha.py` | +| **抗模板匹配** | 让**同一字符两次渲染尽量不同**:逐字符随机旋转 ±22°、切变 ±0.32、缩放抖动、波浪偏移、粗刷笔画(旋转时不断裂)、两色斜向渐变背景、噪点 46 → 70、压线 2~3 条。实测同一验证码两次渲染**字节差异 76.9%**,字符仍可辨认 | `captcha._draw_char` / `captcha._brush_line` | | 答案不进会话 | 答案只写服务端 `captchas` 表;会话里仅存随机 id —— Flask 会话是「签名不加密」的,放答案等于送答案 | `security.issue_captcha` | | 一次性 | 校验后立即删除,且**先删后判**;5 分钟过期、按 `purpose` 隔离,不能拿注册的题去登登录 | `captcha.verify` | | 先验码后验密 | 登录先校验验证码再比对口令,避免攻击者拿「密码对不对」当提前信号跑完字典 | `web/views.py` | -| 出图限速 | 每来源 60 秒最多 40 张(不设限就是一条廉价的 CPU/带宽放大路径) | `security.captcha_fetch_allowed` | -| 注册配额 | 同 IP 每日最多注册 N 个(默认 3,可改);`allow_register=0` 可整体关闭 | `security.register_quota` | +| 出图限速 | 每来源 60 秒最多 40 张(不设限就是一条廉价的 CPU/带宽放大路径)。**来源按 `client_ip()` 判定**,伪造 XFF 无效 | `security.captcha_fetch_allowed` | +| 注册配额 | 同 IP 每日最多注册 N 个(默认 3,可改);`allow_register=0` 可整体关闭。**同样按 `client_ip()` 判定** | `security.register_quota` | + +### 资源与频率限制(对外提供服务时) + +单写者架构下**不加副本扛负载**,所以限制必须落在「单实例资源」与「单账号频率」两处。 + +| 项 | 做法 | 位置 | +|---|---|---| +| **采集三道闸门** | `POST /api/collect`:① 采集锁存在 → `409`(**有任务在跑就不能新建任务**);② `action_allowed("collect:uid", gap)` → `429`(默认最小间隔 60 秒);③ 跨度超上限 → `400` | `web/api.py:api_collect` | +| **采集跨度硬顶** | `COLLECT_MAX_RANGE_DAYS_HARD = 31`(= 1 个月)。`collect_max_range_days` 只是更严的旋钮,**改大也突破不了** —— 上限由代码兜底,不依赖写入校验。历史脏数据、手工改库都过不去 | `config.COLLECT_MAX_RANGE_DAYS_HARD`、`collect.max_range_days` | +| 调度时刻数硬顶 | `SCHEDULE_SLOTS_HARD_MAX = 12`,另有可配置的更严上限 `max_schedule_slots_per_day`(默认 6)。时刻数量直接决定采集频次,是「一个账号能不能把云端打满」的开关 | `config.normalize_setting` | +| 重操作最小间隔 | 采集 60s / CSV 导出 15s / **导出本人数据 10s** / `vacuum` 120s / 补全 prompt 60s / 重算 5s;超限 `429` 并给出剩余等待秒数 | `security.action_allowed` | +| 容器资源上限 | `cpus: 1.0` / `mem_limit: 512m` / `memswap_limit` **与 `mem_limit` 相等**(= 禁用 swap:超限会「被 OOM 杀掉」而不是「越来越慢」,后者更难查)/ `pids_limit: 256`(挡 fork 炸弹)/ `ulimits.nofile 4096:8192`(SQLite 除主库外还持有 `-wal` `-shm`,恢复期还要开临时库 + `ATTACH` 源库) | `docker-compose.yml` | +| 线程数受控 | `WB_THREADS`(容器默认 4,代码默认 8)决定单实例能同时吃进几个慢请求;1 核配 8 线程容易出现「都在等 CPU」的假并发 | `config.THREADS` | +| SSRF 收口 | `api_base` 拒绝云元数据地址(`169.254.169.254` / `metadata.google.internal` / `[fd00:ec2::254]`)—— SSRF 拿云上临时凭证最经典的一跳,且无任何合法采集场景需要它 | `config.normalize_setting` | + +### 备份与数据导出 + +| 项 | 做法 | 位置 | +|---|---|---| +| **归档含密钥,这是有意的取舍** | 归档里的 `instance.json` 带 `cookie_key` —— 没有它就永远解不开 `settings` 里的凭证密文,那样的「恢复」等于把所有人的 Cookie 弄丢。代价是**归档本身是最高机密**,因此它**永不入库、永不进镜像** | `backup.INSTANCE_NAME` | +| **不进镜像** | `.dockerignore` 排除 `backups/`;Dockerfile 在 `COPY` 之后有一条**构建期断言**,`/app/backups` 非空即构建失败。不能靠 `RUN rm` 补救:镜像层是只读叠加的,删掉只会多留一个含内容的中间层 | `.dockerignore`、`Dockerfile` | +| 文件名收口 | `safe_name()` 对下载与恢复接口吃进来的文件名做 basename 收敛 + 后缀 + 字符白名单(防 `../../etc/passwd`、绝对路径、`sub/..`) | `backup.safe_name` | +| 防 zip slip | `_extract_safely()` 只按**基名**解压 —— 归档可能是**外部给的**,`extractall` 会因为条目名里的 `..` 写到目录之外 | `backup._extract_safely` | +| 恢复前护栏 | 自动先给**当前**库打一份 `pre-restore` 快照;恢复错了还能回去 | `backup.restore` | +| **恢复后全站会话作废** | 所有账号 `session_ver` +1。光靠「从归档里搬 `session_ver`」不够:在打备份**之前**登录的那个人,Cookie 里的 `sv` 正好等于归档里的值,会话会「合法地」活下来,而它描述的账号与权限可能已经被换掉了 | `backup.restore` | +| 导出不含凭证 | `/profile/export` 给 4 份 CSV/JSON 与说明,**不含 Cookie 明文** —— 导出自己的数据不等于把凭证交出去。10 秒限速 | `web/views.py:profile_export` | +| 快照用在线备份 API | `Connection.backup` 而不是 `cp`:按页复制并持有读事务,采集正在写也能拿到一致快照。手工 `cp` 一个 WAL 库可能缺最近一段数据,**而且不报错** | `backup._snapshot` | ### 其它 | 项 | 做法 | 位置 | |---|---|---| -| 容器权限 | 运行层非 root(uid/gid 1000 `app`) | `Dockerfile` | +| 容器权限 | 运行层非 root(uid/gid 1000 `app`);`init: true` 让 tini 接管 PID 1,`docker stop` 能干净传到 python | `Dockerfile`、`docker-compose.yml` | +| **密钥文件权限** | `data/instance.json` 在 POSIX 上显式 `chmod 0600`(默认 umask 022 会留下 0644,同机其他用户可读)。恢复时写回也走同一处理,并先把原文件另存 `.pre-restore-` | `config._instance_init`、`backup._restore_instance` | | 上传体量 | `MAX_CONTENT_LENGTH = 4 MiB` | `workbuddy_portal/__init__.py` | | 不索引 | 页面带 `noindex, nofollow` | `web/templates/base.html` | @@ -110,8 +162,13 @@ `.gitignore` 已覆盖;改动忽略规则后请用 `git check-ignore -v ` 逐条复核。 注意 `.gitignore` **不支持行尾注释**(`path # 说明` 会让整行变成永不匹配的模式)。 +**也绝不进镜像**:`.dockerignore` 里 `backups/` 是**硬要求**,理由见上表(P0-3)。 +`Dockerfile` 里那条构建期断言就是防「以后有人又把它删了」。 + > `backups/` 同时也是一个**刻意不放在 `data/`** 的目录:`data/` 在 Docker 部署下是命名卷, > `docker compose down -v` 会把备份和正本一起删掉 —— 那正好是最需要备份的时刻。 +> 容器里它挂独立卷 `wb_backups`;**绝不用绑定挂载**(Windows 9p 下容器会 +> `unable to open database file`,且不自愈)。 ## 已知的**非**目标(部署方需自行处理) @@ -120,28 +177,48 @@ - **没有强制 HTTPS**:请由反向代理(nginx/Caddy)终止 TLS。纯 HTTP 部署时 **不要**设 `WB_COOKIE_SECURE=1`,否则浏览器不回传会话 cookie(表现为反复被弹回登录页)。 注意:纯 HTTP 下流量在网内是明文的,同一局域网内的中间人可以看到会话 Cookie 与 Prompt 内容。 + **反代配置有一个要点**:开启 `WB_TRUST_PROXY=1` 后,程序取 `X-Forwarded-For` 里 + **最右侧**的合法 IP —— 最右侧是离你最近的那一跳(由你自己的代理写入),客户端加不进去。 + 所以 nginx 写 `$proxy_add_x_forwarded_for`(保留链路,便于排查)或 `$remote_addr` + (覆盖)**都安全**;真正不能做的是去信最左边那一段(那是客户端自己填的)。 + 不设反代直接暴露时**必须保持 0**,否则三道 IP 防线全部失效(见 P0-1)。 - **没有 CSRF 之外的重放防护 / 没有 WAF**:公网暴露前请置于反向代理的 rate limit 之后。 -- **没有备份机制**:备份策略需要你自己定(见 `docs/DEPLOYMENT.md`)。 + 应用层已有采集/导出/恢复等重操作的最小间隔,但那只是「别自己把自己打满」, + 挡不住分布式来源。 +- **备份策略只做到「本机自动 + 手动下载」**:程序会按周期打快照、按份数清理、支持下载与恢复, + 但**不会**把归档推到异地。**备份留在同一台机器上只防「改错了」,不防「机器没了」** —— + 请自行把归档同步到别处(那是 3-2-1 原则里属于你的那一半)。 - **没有邮件/短信找回**:邮箱只是联系信息,不参与认证;密码忘掉由管理员重置。 -- **不建议直接暴露到公网**:设计前提是局域网或 VPN 内使用。 +- **不建议在没有任何前置防护时直接暴露到公网**:1.4.0 起 IP 来源、限速、资源上限、 + 采集跨度硬顶都补上了,但设计前提仍是「局域网或 VPN 内使用 + 前面有反代」。 - **Cookie 的获取方式由使用者负责**:手动从浏览器复制、**粘贴给自己的账号**。 它的权限等同于你的账号,请勿分享给他人;轮换后记得在「配置管理」页更新。 - **`cookie_key` 泄露 = 所有 Cookie 泄露**:`data/instance.json` 的权限应与数据库同级看待。 + **备份归档里也有一份 `instance.json`** —— 归档的保密等级与 `instance.json` 完全相同。 - **管理员在运维层面是可信角色**:能登录部署机器的人可以看到数据库文件、应用日志, 理论上也能改代码绕过界面限制。所以团队共用时请把「能登服务器」与「日常使用」分开 —— 界面层的隔离保护的是**使用者之间**,不是「使用者 vs 服务器管理员」。 + **管理员还能下载备份与执行恢复** —— 这等于对全库数据的完整读写权,授管理员前请想清楚。 ## 部署前的最小检查清单 -- [ ] 已修改默认管理员口令(`WB_ADMIN_PASSWORD`),不再是 `admin123` +- [ ] **未设 `WB_ADMIN_PASSWORD` 时已从启动日志抄下随机初始口令并改掉** + (`docker compose logs portal | grep -A6 管理员初始口令`)—— 现在**没有** `admin123` 兜底 - [ ] 已确认是否要开放自助注册;开放时按需调小 `register_max_per_ip` -- [ ] `data/` 与 `logs/` 目录的权限只对服务账号可读写(内含 `instance.json` 的两个密钥) +- [ ] `data/`、`logs/`、`backups/` 的权限只对服务账号可读写(前两者与 `instance.json` 同级机密) - [ ] 前面有反向代理并启用了 HTTPS;若是 HTTPS,已设 `WB_COOKIE_SECURE=1` +- [ ] **反代是否重写了 `X-Forwarded-For`**:是 → `WB_TRUST_PROXY=1` + (`$proxy_add_x_forwarded_for` 或 `$remote_addr` 均可,程序取最右侧); + 否(含直接暴露)→ **保持 `WB_TRUST_PROXY=0`**。这条搞错会让三道 IP 防线同时失效 +- [ ] 容器资源上限符合预期(`docker stats` 看 `MEM LIMIT` 是否为 512MiB;`docker inspect` 看 `PidsLimit`) - [ ] 确认 `data/instance.json` 没有被提交到任何仓库 -- [ ] `backups/` 也未被提交,且已确认 `.gitignore` 生效(`git check-ignore -v backups/x.sqlite`) -- [ ] 已规划备份(SQLite 库是唯一正本);备份文件同样受 `cookie_key` 保护,需按机密对待 -- [ ] 新增的账号一律用**普通角色**;只有确实需要维护实例的人才给管理员 +- [ ] `backups/` 也未被提交,且已确认 `.gitignore` 生效(`git check-ignore -v backups/x.zip`) +- [ ] **确认镜像里没有备份归档**:`docker run --rm <镜像> sh -c 'ls -A /app/backups'` 应为空 +- [ ] 已规划**异地**备份:程序只负责本机打快照,归档需自行同步到别的机器/对象存储 +- [ ] 新增的账号一律用**普通角色**;只有确实需要维护实例的人(含**下载备份与恢复**)才给管理员 +- [ ] 已把「采集最小间隔 / 单次最长跨度 / 每日时刻上限」三个刹车调到符合你的预期 + (「任务管理」页可改;跨度硬顶 31 天不可突破) - [ ] 升级后登录一次「配置管理」,确认 Cookie 状态为「已配置」而不是 「已保存但无法解密」 -- [ ] 升级到 1.3.0 后确认「任务管理」里调度时刻**对普通账号是只读的** - (用普通账号点一次「保存」应被拒并点名越权项) +- [ ] 升级到 1.4.0 后确认「任务管理」里调度时刻**对普通账号是只读的**, + 且访问 `/backups` 返回 403(用普通账号各试一次) diff --git a/docker-compose.hostdir.yml b/docker-compose.hostdir.yml index 484c4c4..8906c15 100644 --- a/docker-compose.hostdir.yml +++ b/docker-compose.hostdir.yml @@ -19,3 +19,7 @@ services: volumes: - ${WB_HOST_DATA_DIR:-./data}:/app/data - ${WB_HOST_LOG_DIR:-./logs}:/app/logs + # 备份同样落到宿主机目录(默认就是仓库里的 ./backups)。 + # 叠加后 `wb_backups` 那个命名卷变成没人用的空卷,可以随手删掉: + # docker volume rm workbuddy-portal_wb_backups + - ${WB_HOST_BACKUP_DIR:-./backups}:/app/backups diff --git a/docker-compose.yml b/docker-compose.yml index 785e20e..cdffb75 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,28 @@ services: container_name: workbuddy-portal restart: unless-stopped init: true # tini 接管 PID 1:docker stop 能干净地传到 python + + # ---- 容器资源上限(对外提供服务时的第一道闸门)---- + # 应用层已经做了「采集频率 / 并发 / 跨度」三重限制,但那些是**业务**刹车; + # 这里限制的是**进程**本身能吃掉多少宿主机资源 —— 两者都要有: + # 业务刹车管「正常的重活别做太多」,容器上限管的是一次异常(内存泄漏、 + # 正则回溯、某次超大导出)能不能把整台机器带下去。 + # + # 单写者架构下**不要**靠加副本扛负载,所以「限制单实例资源 + 限制单账号 + # 频率」才是正解,而不是横向扩展(见文件头的设计取舍说明)。 + cpus: "${WB_CPUS:-1.0}" # 1 核:SQLite 单写者,多给核也并行不起来 + mem_limit: "${WB_MEM_LIMIT:-512m}" + memswap_limit: "${WB_MEM_LIMIT:-512m}" # 与 mem_limit 相等 = 禁用 swap, + # 否则内存超限会悄悄滑进 swap, + # 表现为「越来越慢」而不是「被 OOM 杀掉」 + pids_limit: ${WB_PIDS_LIMIT:-256} # 挡 fork 炸弹 + # 文件句柄:SQLite 会在主库之外再持有 -wal / -shm,备份快照与恢复期间 + # 还要同时开临时库 + ATTACH 源库,默认 1024 在高并发下偏紧。 + ulimits: + nofile: + soft: 4096 + hard: 8192 + ports: - "${WB_BIND:-0.0.0.0}:${WB_PORT:-8848}:8848" environment: @@ -35,16 +57,37 @@ services: WB_HOST: 0.0.0.0 WB_PORT: "8848" WB_ADMIN_USER: ${WB_ADMIN_USER:-admin} + # 留空时程序会生成**随机**口令并只在启动日志里打印一次(不再有 admin123 兜底), + # 所以首次部署要去 `docker compose logs portal | grep 口令` 抄一次。 WB_ADMIN_PASSWORD: ${WB_ADMIN_PASSWORD:-} WB_DISABLE_SCHEDULER: ${WB_DISABLE_SCHEDULER:-0} + # ---- 反向代理与传输安全(三个必须一起决定,别只改一个)---- + # WB_TRUST_PROXY:默认 0 = 不信任 X-Forwarded-For。 + # 直接暴露给公网时必须留 0 —— 否则攻击者每次换一个伪造的 XFF, + # 验证码限速 / 注册配额 / 登录锁定三道 IP 防线会同时失效。 + # 置 1 的前提:**你自己的**反代会写这个头。开启后程序取 XFF 里 + # **最右侧**的合法 IP(最近一跳由你的代理写入,客户端加不进去), + # 所以 nginx 写 $proxy_add_x_forwarded_for(保留链路,便于排查)或 + # 写 $remote_addr(覆盖)都可以 —— 关键是别去信最左边那段。 + WB_TRUST_PROXY: ${WB_TRUST_PROXY:-0} + # 前面挂了 HTTPS 反代时置 1:读到 X-Forwarded-Proto: https 就不跳转 + WB_FORCE_HTTPS: ${WB_FORCE_HTTPS:-0} # 会话 Cookie 是否只走 HTTPS。纯 HTTP 部署必须留 0:设成 1 时浏览器 # 不会回传会话 Cookie,表现为「登录成功又立刻跳回登录页」。改它要 up -d(环境变量)。 WB_COOKIE_SECURE: ${WB_COOKIE_SECURE:-0} + # 访问日志(waitress 自己不记 access log,出事无据可查时很要命) + WB_ACCESS_LOG: ${WB_ACCESS_LOG:-1} + # waitress 线程数 = 单实例能同时吃进几个慢请求(采集/导出/备份恢复) + WB_THREADS: ${WB_THREADS:-4} + WB_BACKUP_DIR: /app/backups WB_IMPORT_CREDS: ${WB_IMPORT_CREDS:-0} WB_IMPORT_XLSX: ${WB_IMPORT_XLSX:-} volumes: - wb_data:/app/data # 数据正本 + 导出 + secret_key - wb_logs:/app/logs # 应用日志(滚动 2 MB × 3) + # 备份**单独一个卷**:备份与正本同卷时,一次 `down -v` 或卷损坏会把 + # 两者一起带走 —— 那正是最需要备份的时刻。分开挂才有意义。 + - wb_backups:/app/backups # 可选:把编辑器配置挂进来,配合 WB_IMPORT_CREDS=1 自动接管 cookie # - ${WB_EDITOR_SETTINGS:-./nonexistent.json}:/mnt/editor-settings.json:ro healthcheck: @@ -62,3 +105,8 @@ services: volumes: wb_data: wb_logs: + # 备份卷。**必须与 wb_data 分开** —— 同卷时 `down -v` 会把正本与备份一起删。 + # 想让它跟着正本走(比如整套搬到别的机器)就先 `docker run --rm -v + # workbuddy-portal_wb_backups:/b -v $PWD/backups:/o alpine cp -a /b/. /o/` + # 把归档捞到宿主机目录里,再一起搬。 + wb_backups: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 6e708f5..705ddb0 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -18,14 +18,19 @@ PORT="${WB_PORT:-8848}" log() { echo "[entrypoint] $*"; } -log "workbuddy-portal 启动:data=${WB_DATA_DIR:-/app/data} logs=${WB_LOG_DIR:-/app/logs} 监听 ${HOST}:${PORT} TZ=${TZ:-未设置}" +log "workbuddy-portal 启动:data=${WB_DATA_DIR:-/app/data} logs=${WB_LOG_DIR:-/app/logs} backups=${WB_BACKUP_DIR:-/app/backups} 监听 ${HOST}:${PORT} TZ=${TZ:-未设置}" # ---------- 1. 初始化(幂等:users 非空时不会重建管理员)---------- +# 口令策略(1.4.0 起):WB_ADMIN_PASSWORD 为空时**没有**默认口令, +# manage.py init 会生成一个随机口令并只打印一次 —— 抄下来,否则进不去。 if [ -n "${ADMIN_PASSWORD}" ]; then python manage.py init --user "${ADMIN_USER}" --password "${ADMIN_PASSWORD}" else + log "未设置 WB_ADMIN_PASSWORD。若这是首次初始化,下面「管理员初始口令」一行就是" + log " 唯一的获取机会(程序不保存明文,库里只有散列,找不回来):" + log " docker compose logs portal | grep -A2 '管理员初始口令'" + log " 已在运行过一次的实例上,这一行不会再出现(管理员早就建好了)。" python manage.py init --user "${ADMIN_USER}" - log "未设置 WB_ADMIN_PASSWORD —— 首次部署的默认密码是 admin123,请登录后立刻修改" fi # ---------- 2. 可选:从挂载进来的 VSCode/Cursor/Trae settings.json 接管 cookie 与 UA ---------- diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6683c5a..8ef89a9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,194 @@ --- +## [1.4.0] — 2026-09-16 + +**主题:备份管理 · 对外提供服务的安全加固 · 资源与频率限制** + +三件事:① 补齐「备份 / 恢复 / 导出本人数据」这条数据安全链路;② 修掉三类 P0 与 +一批 P1;③ 让这个程序可以**安全地暴露到公网**——资源在容器层限制、任务频率在实例层 +限制、采集跨度有硬上限。 + +**数据不会丢**:`manage.py init` 检测到 `PRAGMA user_version` 3 → 4 时自动迁移, +只做一件事(`ALTER TABLE users ADD session_ver`,非空、默认 0)+建一张新表 +`backups`。**无数据搬运、无键位变动**,可重复执行。 + +--- + +### 备份管理(新功能) + +- **`workbuddy_portal/backup.py`(新模块)** —— 备份的全部逻辑 + - **快照走 SQLite 在线备份 API**(`Connection.backup`),不是 `cp`。 + 它按页复制并持有读事务,所以**采集正在写的时候拿到的也是「某一时刻的完整库」**。 + 手工 `cp data/usage.sqlite` 做不到这点:`-wal` 里可能还有没落盘的帧, + 拷出来的库会缺最近一段数据,而且**不报错**。 + - **归档 = 一个 zip**:所有 `data/*.sqlite` + `manifest.json` + `instance.json`。 + 单文件、可校验、可搬到别的机器恢复。 + - **`backup_keep` 保留份数**:超出后按时间删最旧的(只按**磁盘上真实存在**的算份数, + 已经被手工删掉的条目不占名额)。 +- **`backups` 表 + 磁盘双向对齐**:`sync_index()` 把磁盘上的归档登记进表、 + 把消失的标记 `missing=1`(不删行,保留「这里曾有过一份」的痕迹)。 + **磁盘是事实来源**,表只是缓存 —— 手工拷进来/手工删掉都能被正确呈现。 +- **管理员页面 `/backups`**(导航在「用户管理」前):KPI(份数 / 占用 / 自动备份状态 / + 上次与下次)、自动备份设置表单、归档内容说明、备份列表(下载 / 恢复 / 删除)。 +- **接口**(全部仅管理员): + `GET /api/backups`、`POST /api/backups`、`GET /api/backups/`(下载)、 + `POST /api/backups//restore`、`POST /api/backups//delete`、 + `POST /api/backups/prune`。 +- **恢复的三条设计决定**(每条都对应一个会真的踩到的坑) + 1. **SQL 级整表替换,不做文件 swap**。解包 → 临时库先迁移到当前 schema → + 一个 `BEGIN IMMEDIATE` 事务里整表搬过去。好处:不需要停机、不依赖「没有别人 + 持有文件句柄」(Windows 上文件 swap 会因句柄占用直接失败)、备份是旧版本 + (uv=2/3)也能恢复、中途失败就是一次回滚。 + 2. **列名交集而非 `SELECT *`**。老库的列是 `ALTER` 追加的,顺序与新库建表语句 + 不一定一致 —— `SELECT *` 会**静默错位**,字段整体串位而值都「合法」, + 是最难查的一类数据损坏。 + 3. **恢复前自动打一份 `pre-restore` 快照**。恢复错了还能回到恢复之前。 +- **恢复后让所有会话失效**:把所有账号的 `session_ver` +1。光靠「从归档里搬 + `session_ver`」是不够的 —— 在打备份**之前**登录的那个人,他那张 Cookie 里的 `sv` + 正好等于归档里的值,会话会「合法地」活下来,而它描述的账号与权限可能已经被 + 这次恢复整个换掉了。 +- **`instance.json` 在归档里是必要的,不是顺手加的**:没有 `cookie_key` 就永远 + 解不开 `settings` 里的凭证密文,那样的「恢复」等于把所有人的 Cookie 弄丢。 + 代价是归档本身含密钥 ⇒ 它**永不入库、永不进镜像**(见下方 P0-3)。 + 恢复时可以 `include_instance=false` 只搬数据、保留本机当前密钥。 +- **`safe_name()` 路径穿越收口**:下载与恢复接口都直接吃文件名, + 这里做 basename 收敛 + 后缀 + 字符白名单(实测拦住 `../../etc/passwd`、 + `x.txt`、`''`、`..`、`a b.zip`)。 +- **`_extract_safely()` 防 zip slip**:归档可能是**外部给的**, + `extractall` 会因为条目名里的 `..` / 绝对路径写到目录之外。 +- **普通用户导出本人全部数据**:`GET /profile/export`(任何登录用户,10 秒限速), + 用 `SpooledTemporaryFile(max_size=16MB)` 边生成边下发,含 4 份 CSV/JSON + (使用记录 / 采集历史 / 操作审计 / 我的账号与配置)+ 说明。 + **不含 Cookie 明文** —— 导出自己的数据不等于把凭证交出去。 +- **CLI**:`manage.py backup [--note]` / `backups [--prune] [--keep N]` / + `restore <文件名> --yes`(破坏性操作必须显式 `--yes`,不加只打印将要发生什么)。 +- **自动备份**:`backup_enabled` / `backup_interval_hours` / `backup_keep` 三个实例级键。 + 由 `scheduler.tick()` 每 20 秒检查一次(**有采集在跑就跳过**,不跟采集抢磁盘), + 到期就打一份并按份数清理。首次部署无需等待 —— 库里没有自动备份记录时第一轮 tick 即触发。 + +### P0 修复 + +- **P0-1 `X-Forwarded-For` 可伪造 → 三道 IP 防线全废** + - 实测:修改前每次换一个伪造的 `X-Forwarded-For`,45 次验证码请求**全部放行**; + 验证码限速、注册配额、登录锁定三道防线同时失效。 + - 新增 **`security.client_ip()`** —— 全站**唯一**取客户端地址的入口。 + 默认**不信任** XFF,直接用 `remote_addr`;`WB_TRUST_PROXY=1` 时取**最右侧**合法 IP + (最近的一跳由你自己的代理写入,客户端伪造不了),含 `IPv4:port` 与 IPv6 处理。 + - 原来散落在 `views.py` / `api.py` 的 6 处 `request.remote_addr` 全部改走它。 +- **P0-2 默认口令 `admin123` 硬编码兜底** + - 现在 `WB_ADMIN_PASSWORD` 为空时,程序用 `secrets.token_urlsafe(12)` 生成随机口令, + **只在启动日志里打印一次**,且**不写进数据库**(审计日志里出现口令等于永久留档)。 + `manage.py init` 会用醒目的方框打印它 —— 库里只有散列,日志一滚就再也拿不回来。 + - `docker/entrypoint.sh` 里那句「默认密码是 admin123」的提示同时删除(它会给出一个 + 永远登录不上的口令)。 +- **P0-3 `.dockerignore` 漏了 `backups/` → 凭证密文被打进镜像** + - `backups/` 里躺着 `usage.sqlite.bak-pre-v13`(4.5 MB 的**真实生产库**快照), + 含 `settings` 的凭证密文与 `users` 的口令散列。而 `.dockerignore` 里没有任何 + 规则能匹配 `backups/` ⇒ `docker build` 会把它原样烤进镜像层, + **推一次镜像等于把整库密钥分发给所有能拉镜像的人**。 + - 补 `backups/` 与 `data/demo/`;并在 Dockerfile 里加一条**构建期断言**: + `COPY` 之后若 `/app/backups` 非空就直接构建失败。 + 靠「记得改 .dockerignore」不可靠 —— 让构建自己拒绝。 + 注意这不能靠 `RUN rm` 补救:镜像层是只读叠加的,删掉只会多留一个含内容的中间层。 + +### P1 修复 + +- **采集与导出没有任何跨度上限与频率限制**:一个注册账号就能反复打 `/api/collect` + 把云端与线程池占满。现在 `/api/collect` 有**三道闸门**: + ① 采集锁存在 → `409`(**有任务在跑就不能新建任务**); + ② `action_allowed("collect:uid", gap)` → `429`(默认最小间隔 60 秒); + ③ 跨度超过上限 → `400`。 +- **`COLLECT_MAX_RANGE_DAYS_HARD = 31` / `SCHEDULE_SLOTS_HARD_MAX = 12`**: + 代码层**硬顶**。`collect_max_range_days` / `max_schedule_slots_per_day` 只是更严的 + 旋钮,**改大也突破不了** —— 上限必须由代码兜底,不能只靠写入校验。 + 历史脏数据、手工改库都过不去。(用户要求:「最长跨度的为 1 个月」) +- **`action_allowed(key, min_interval)` 通用重操作限速**:采集 / 导出 / 导出本人数据 / + `vacuum` / 补全 prompt / 重算 各有最小间隔(`{"fill-prompt":60, "export-csv":15, + "vacuum":120, "recount":5}`,采集与 CSV 导出见各自常量)。 +- **`WB_COOKIE_SECURE` 默认 0、无 HSTS**:`_env_flag()` 统一解析布尔环境变量; + `apply_security_headers()` 在 **`COOKIE_SECURE or FORCE_HTTPS`** 时下发 HSTS + (**只在真正走 HTTPS 时下发** —— 纯 HTTP 部署下发会让浏览器强升 https, + 表现成白屏,是个很难归因的故障)。 +- **账号锁定可以被当武器**:知道用户名就能把对方锁死 10 分钟,而**管理员用户名在导航栏里 + 是公开的**。改成双维度、强度刻意不同: + IP 维度真锁(`MAX_LOGIN_FAILS` + `LOGIN_LOCK_MINUTES`), + 用户名维度只做秒级递增退避(`USER_SOFT_THRESHOLD` / `USER_SOFT_CAP_SECONDS=60`)。 + 另外加**单 IP 登录尝试总量**(`LOGIN_ATTEMPTS_PER_IP=40` / `LOGIN_ATTEMPTS_WINDOW=300`, + **含成功**)挡住「慢慢撞、不触发失败阈值」的形态。 + 登录失败提示也从「剩余 N 次」改成「本来源连续失败 N 次」—— 不再给攻击者倒计时。 +- **改密码不失效其他会话**:新增 `users.session_ver`(`DB_SCHEMA_VERSION` 3 → 4), + `current_user()` 每个请求把会话里的 `sv` 与库里比对,不等就丢会话。 + 改自己密码时会**把当前会话刷新到新版本**(否则改完立刻被自己踢下线)。 + 管理员重置口令 / 停用账号 / 删除账号同样 `bump_session_ver()` —— 停用立即生效, + 不用等会话过期。 +- **验证码强度不足**(字模在源码里、只整体放大 5 倍、无扭曲,容易被模板匹配): + 改为让**同一字符两次渲染尽量不同** —— 逐字符随机旋转 ±22°、切变 ±0.32、缩放抖动、 + 波浪偏移、粗刷笔画(旋转时不断裂)、两色斜向渐变背景、噪点 46 → 70、压线 2~3 条。 + 实测同一验证码两次渲染字节差异 **76.9%**,字符仍可辨认。 +- **`instance.json` 未设权限**:POSIX 上显式 `chmod 0600`(默认 umask 022 会留下 0644, + 同机其他用户可读)。恢复时写回也走同一处理。 +- **无访问日志**:waitress 自己不记 access log,出事无据可查。 + 新增 `security._access_log()`(跳过 `/static/` 与 `/captcha.png`,写进 `logs/app.log`), + 由 `WB_ACCESS_LOG` 控制(默认开)。 +- **`api_base` 可指向云元数据地址**:拦截 `169.254.169.254` / + `metadata.google.internal` / `[fd00:ec2::254]` —— 这是 SSRF 拿云上临时凭证最经典的一跳, + 而没有任何合法采集场景需要它。 +- **口令黑名单**:`WEAK_PASSWORDS`(34 个自动撞库字典的头几页)。 + 只在**设置/修改**口令时校验,登录不校验 —— 否则会把用老口令的存量用户挡在门外。 + +### 新增(配置项) + +`GLOBAL_KEYS` 17 → **23** 键。新增的 6 个都是实例级: + +| 键 | 默认 | 范围 | 说明 | +|---|---|---|---| +| `max_schedule_slots_per_day` | 6 | 1~12 | 每日调度时刻数上限(挡住「填 200 个时刻」) | +| `collect_min_interval_seconds` | 60 | 0~3600 | 同一账号两次手动采集的最小间隔 | +| `collect_max_range_days` | 31 | 1~31 | 单次采集的最长跨度(硬顶 31 天 = 1 个月) | +| `backup_enabled` | 1 | 0/1 | 是否开启自动备份 | +| `backup_interval_hours` | 24 | 1~720 | 备份周期 | +| `backup_keep` | 7 | 1~100 | 保留最近几份 | + +新增环境变量:`WB_TRUST_PROXY`、`WB_FORCE_HTTPS`、`WB_ACCESS_LOG`、`WB_THREADS`、 +`WB_BACKUP_DIR`、`WB_CPUS` / `WB_MEM_LIMIT` / `WB_PIDS_LIMIT`(compose 用)、 +`WB_HOST_BACKUP_DIR`(叠加层用)。 + +### 部署与外网暴露 + +- **`BACKUP_DIR` 刻意不在 `data/` 里面**:容器里 `data/` 是数据卷, + `docker compose down -v` 会把正本与副本一起删 —— 那正好是最需要备份的时刻。 + 新增 `WB_BACKUP_DIR`(容器里 `/app/backups`)+ compose **命名卷 `wb_backups`**。 + **绝不退回绑定挂载**(Windows 9p 下容器会 `unable to open database file`)。 +- **容器资源上限**(用户要求「资源从容器上限制」): + `cpus: 1.0` / `mem_limit: 512m` / `memswap_limit` 与 `mem_limit` **相等**(= 禁用 swap, + 这样超限会「被 OOM 杀掉」而不是「越来越慢」,后者更难查)/ + `pids_limit: 256`(挡 fork 炸弹)/ `ulimits.nofile 4096:8192` + (SQLite 除主库外还持有 `-wal` `-shm`,恢复期还要开临时库 + `ATTACH` 源库)。 +- **`manage.py serve` 的线程数改为 `config.THREADS`**(原为硬编码 8): + 它决定单实例能同时吃进几个慢请求(采集 / 导出 / 备份恢复),是资源上限的一部分。 + 1 核配 8 线程容易出现「都在等 CPU」的假并发,容器默认给 4。 +- 新增 `docker-compose.yml` 里 `WB_TRUST_PROXY` / `WB_FORCE_HTTPS` / + `WB_COOKIE_SECURE` 三者相邻并写明「必须一起决定」—— 拆开写很容易出现 + 「开了强制 HTTPS 却忘了 Secure」这类半截配置。 + +### 其它 + +- `scheduler.py` 的 `tick()` 末尾增加自动备份检查(在账号循环**之外**,因为它是 + 实例级、与具体账号无关)。 +- `collect.py` 新增 `max_range_days(conn)` / `min_interval_seconds(conn)`: + 接口、页面、`sync()` 三处共用**唯一口径**(此前会出现「页面显示的限值」与 + 「接口实际校验的限值」不是同一个数的情况)。 +- `sync()` 因跨度上限收窄起点时打一条 `[warn] 请求跨度超过上限 %d 天,已自动收窄起点`。 +- `backup.sync_index()` 现在从 manifest 里读真实的 `trigger` / `actor` / `note`, + 不再一律标成 `external` —— 恢复前最需要判断的恰恰是「这份是自动备份、 + 还是我手工留的、还是恢复前系统自动存的那一份」。 +- **新增页**:`/backups`(仅管理员)、`/profile/export`(任何登录用户)。 +- **测试**:`tools/smoke.py` 与 `tools/check_live.py` 补备份链路、越权、 + 跨度上限、并发拒绝、会话失效断言。 + +--- + ## [1.3.0] — 2026-09-16 **主题:权限收敛 · 配置作用域统一 · 目录规范化** diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 9c87f6a..7ec3f2b 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -90,17 +90,26 @@ workbuddy-portal/ **① 首个管理员账号。** 数据库为空时才会创建,之后改密码走「用户管理」或 `manage.py passwd`。 ```bash -# 默认是 admin / admin123 —— 局域网部署下必须改掉! python manage.py init --user admin --password '你的强密码' ``` +> **不设密码会怎样**(v1.4.0 起):程序用 `secrets.token_urlsafe(12)` 生成一个**随机**口令, +> 用醒目的方框打印在输出里,**只在这一次打印**。库里只有散列,日志一滚就再也拿不回来。 +> **没有 `admin123` 这类默认口令了** —— 硬编码一个默认口令等于把公网实例的钥匙挂在门上。 +> 所以要么现在就显式设 `WB_ADMIN_PASSWORD`,要么把打印出来的那行抄走。 +> 容器里的对应做法见 [11.10 拿不到管理员初始口令](#1110-拿不到管理员初始口令)。 + **② 访问方式。** 决定 `WB_BIND` 与 `WB_COOKIE_SECURE`: -| 场景 | `WB_BIND` | `WB_COOKIE_SECURE` | -|---|---|---| -| 只本机用 | `127.0.0.1` | `0` | -| 局域网 `http://IP:8848` | `0.0.0.0` | **`0`**(设成 1 会导致「登录成功又跳回登录页」) | -| 域名 + HTTPS 反代 | `127.0.0.1` | `1` | +| 场景 | `WB_BIND` | `WB_COOKIE_SECURE` | `WB_TRUST_PROXY` | +|---|---|---|---| +| 只本机用 | `127.0.0.1` | `0` | `0` | +| 局域网 `http://IP:8848` | `0.0.0.0` | **`0`**(设成 1 会导致「登录成功又跳回登录页」) | **`0`** | +| 域名 + HTTPS 反代 | `127.0.0.1` | `1` | **`1`**(前提:反代会写 XFF,见第六节) | + +> `WB_TRUST_PROXY` 是 v1.4.0 新增的,**默认 0**。直接暴露给公网时留 0; +> 设成 1 而反代又没写该头,攻击者每次换一个伪造的 `X-Forwarded-For` 就能让 +> 验证码限速 / 注册配额 / 登录锁定**三道防线同时失效**(实测 45 次请求全部放行)。 ### 2.4 从 v1.0 迁移历史数据(可选) @@ -157,16 +166,28 @@ WorkingDirectory=/opt/workbuddy-portal # TZ 直接决定调度时刻与所有日期口径,务必设对 Environment=TZ=Asia/Shanghai Environment=WB_COOKIE_SECURE=0 +# 备份落点默认就是 <仓库>/backups,显式写出来便于以后搬迁 +Environment=WB_BACKUP_DIR=/opt/workbuddy-portal/backups +# 直连(前面没有反代)时保持 0 —— 设成 1 会让三道 IP 防线可被伪造 XFF 绕过 +Environment=WB_TRUST_PROXY=0 ExecStart=/opt/workbuddy-portal/.venv/bin/python manage.py serve --host 0.0.0.0 --port 8848 Restart=always RestartSec=5 +# ---- 资源上限(与容器部署的 cpus / mem_limit 对应)---- +# 单写者架构下不靠加副本扛负载,所以「限制单实例吃多少」才是正解。 +# 数值按需调整:内存超限被 OOM 杀掉有明确日志,比「越来越慢」好查得多。 +CPUQuota=100% +MemoryMax=512M +MemorySwapMax=0 +TasksMax=64 + # ---- 加固(可选但建议)---- NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ProtectHome=true -ReadWritePaths=/opt/workbuddy-portal/data /opt/workbuddy-portal/logs +ReadWritePaths=/opt/workbuddy-portal/data /opt/workbuddy-portal/logs /opt/workbuddy-portal/backups [Install] WantedBy=multi-user.target @@ -179,11 +200,15 @@ sudo systemctl status workbuddy-portal journalctl -u workbuddy-portal -f ``` -> `User=workbuddy` 需要事先建号,并让 `data/`、`logs/` 归它所有: +> `User=workbuddy` 需要事先建号,并让 `data/`、`logs/`、`backups/` 归它所有: > ```bash > sudo useradd --system --home /opt/workbuddy-portal --shell /usr/sbin/nologin workbuddy -> sudo chown -R workbuddy:workbuddy /opt/workbuddy-portal/data /opt/workbuddy-portal/logs +> sudo chown -R workbuddy:workbuddy /opt/workbuddy-portal/data \ +> /opt/workbuddy-portal/logs \ +> /opt/workbuddy-portal/backups > ``` +> `backups/` 千万别漏 —— 漏了的表现是「备份管理」页能打开,但一备份就报错, +> 日志里是 `PermissionError`(服务以 `workbuddy` 身份跑,写不进 root 拥有的目录)。 ### 3.2 Windows @@ -266,20 +291,54 @@ docker compose logs -f ### 4.4 `.env` 主要变量 +#### 监听与调度 + | 变量 | 默认 | 说明 | |---|---|---| | `WB_BIND` | `0.0.0.0` | 宿主机绑定地址。只想本机访问就设 `127.0.0.1` | | `WB_PORT` | `8848` | 宿主机端口 | | `TZ` | `Asia/Shanghai` | **影响调度时刻与所有日期口径** | +| `WB_DISABLE_SCHEDULER` | `0` | `1` = 不启动调度线程(只跑手动采集)。多副本时除第一个外都要设 1 | | `WB_ADMIN_USER` | `admin` | 首个管理员用户名(只在库为空时生效) | -| `WB_ADMIN_PASSWORD` | 空 | 首个管理员密码。**留空会用 `admin123`**,务必显式设置 | -| `WB_COOKIE_SECURE` | `0` | `1` = 会话 Cookie 只走 HTTPS。**纯 HTTP 部署设成 `1` 会导致「登录成功又跳回登录页」** | -| `WB_DISABLE_SCHEDULER` | `0` | `1` = 不启动调度线程(只跑手动采集) | -| `WB_IMPORT_CREDS` | `0` | `1` = 启动时尝试从挂载的编辑器配置导入 Cookie | -| `WB_IMPORT_XLSX` | 空 | 启动时一次性导入这个路径的官网 xlsx | +| `WB_ADMIN_PASSWORD` | 空 | 首个管理员密码。**留空则随机生成并只在启动日志打印一次**(v1.4.0 起不再有 `admin123` 兜底) | +| `WB_THREADS` | `4`(compose)/ `8`(代码) | waitress 线程数 = 单实例能同时吃进几个慢请求(采集 / 导出 / 备份恢复) | | `WB_IMAGE` | `git.iwali.top/…:latest` | 镜像名(见 [第七节](#七把代码与镜像推到-gitea-注册表)) | -> `TZ`、`WB_COOKIE_SECURE`、`WB_DISABLE_SCHEDULER` 都是**进程环境变量**, +#### 容器资源上限(v1.4.0 起) + +| 变量 | 默认 | 说明 | +|---|---|---| +| `WB_CPUS` | `1.0` | CPU 上限(允许小数)。SQLite 是单写者,1 核够用,压测后再调 | +| `WB_MEM_LIMIT` | `512m` | 内存上限。**同时会设 `memswap_limit` 为同值 = 禁用 swap** | +| `WB_PIDS_LIMIT` | `256` | 进程/线程数上限,挡 fork 炸弹 | + +> **为什么 swap 要禁用**:不禁用的话内存超限会悄悄滑进 swap,表现为「越来越慢」 +> 而不是「被 OOM 杀掉」——后者有明确的日志和退出码,前者只会让人怀疑磁盘。 +> `nofile` 上限(4096:8192)在 compose 里写死,因为 SQLite 除主库外还持有 `-wal` `-shm`, +> 恢复期间还要开临时库 + `ATTACH` 源库,默认 1024 在高并发下偏紧。 + +#### 反向代理与传输安全(**三个必须一起决定**) + +| 变量 | 默认 | 说明 | +|---|---|---| +| `WB_TRUST_PROXY` | `0` | 是否信任 `X-Forwarded-For`。**默认不信任**。设 1 的前提是「你自己的反代会写这个头」(见第六节,两种 nginx 写法都安全) | +| `WB_FORCE_HTTPS` | `0` | `1` = 非 HTTPS 的 GET/HEAD 跳转到 https(POST 不跳,否则会掉请求体) | +| `WB_COOKIE_SECURE` | `0` | `1` = 会话 Cookie 只走 HTTPS。**纯 HTTP 部署设成 `1` 会导致「登录成功又跳回登录页」** | +| `WB_ACCESS_LOG` | `1` | 是否记访问日志(写进 `logs/app.log`,跳过静态资源与验证码图) | + +> **HSTS 的触发条件正是 `WB_COOKIE_SECURE` 或 `WB_FORCE_HTTPS` 为 1** —— +> 所以纯 HTTP 部署不会下发 HSTS(下发会让浏览器强升 https,表现成白屏)。 + +#### 可选:启动时自动导入 + +| 变量 | 默认 | 说明 | +|---|---|---| +| `WB_IMPORT_CREDS` | `0` | `1` = 启动时尝试从挂载的编辑器配置导入 Cookie | +| `WB_IMPORT_XLSX` | 空 | 启动时一次性导入这个路径的官网 xlsx | +| `WB_BACKUP_DIR` | `/app/backups` | 归档落点(相对**仓库目录**,不是 `data/`,理由见 [8.1](#81-备份什么)) | + +> `TZ`、`WB_COOKIE_SECURE`、`WB_TRUST_PROXY`、`WB_FORCE_HTTPS`、`WB_ACCESS_LOG`、 +> `WB_THREADS`、`WB_CPUS` / `WB_MEM_LIMIT` / `WB_PIDS_LIMIT` 都是**进程环境变量**, > `docker compose restart` **不生效**,必须 `docker compose up -d` 重建容器。 ### 4.5 数据落点:命名卷,不是绑定挂载 @@ -288,6 +347,13 @@ docker compose logs -f |---|---|---| | `/app/data` | 命名卷 `workbuddy-portal_wb_data` | `usage.sqlite`(正本)、`instance.json`(密钥)、`exports/` | | `/app/logs` | 命名卷 `workbuddy-portal_wb_logs` | `app.log`(滚动 2 MB × 3) | +| `/app/backups` | 命名卷 `workbuddy-portal_wb_backups` | 备份归档 `usage-<时间戳>.zip`(**含 `instance.json`,机密等级等同密钥**) | + +> **备份为什么必须单独一个卷**:如果和 `wb_data` 同卷,一次 `docker compose down -v` +> 或卷损坏会把正本与备份**一起**带走 —— 那正好是最需要备份的时刻。 +> 而且容器里的 `/app/backups` 若不挂卷,写进去的文件只存在于容器**可写层**: +> `docker restart` 还能留住,但 `docker compose up -d --build` 重建容器就没了 —— +> 「构建完发现备份全丢」是个会真的踩到的坑。现在挂的是独立命名卷。 **为什么是命名卷而不是脚本目录里的 `./data`**(这不是随手选的): @@ -308,33 +374,41 @@ docker compose exec portal python manage.py passwd admin 新密码 docker compose logs -f portal ``` -#### 想让宿主机直接看到数据 / 日志 +#### 想让宿主机直接看到数据 / 日志 / 备份 叠加 `docker-compose.hostdir.yml`: ```bash docker compose -f docker-compose.yml -f docker-compose.hostdir.yml up -d -# 可用 WB_HOST_DATA_DIR / WB_HOST_LOG_DIR 指定目标目录 +# 可用 WB_HOST_DATA_DIR / WB_HOST_LOG_DIR / WB_HOST_BACKUP_DIR 指定目标目录 +docker volume rm workbuddy-portal_wb_backups # 叠加后这个卷没人用,可删 ``` > ⚠️ **只建议 Linux 宿主机使用**(bind mount 与容器同一文件系统,WAL 语义正常)。 > Windows + Docker Desktop 下必然踩上面那个坑。 > > Linux 首次运行若报 `unable to open database file`,是宿主目录属主与容器内 uid 1000 -> 不一致:`sudo chown -R 1000:1000 ./data ./logs` +> 不一致:`sudo chown -R 1000:1000 ./data ./logs ./backups` ### 4.6 常用命令 ```bash docker compose ps docker compose logs -f --tail=100 +docker compose logs portal | grep -A6 '管理员初始口令' # 首次初始化时唯一一次机会 docker compose restart # 注意:环境变量改动 restart 不生效 docker compose down # 停并删容器,数据保留在卷里 +docker compose down -v # ⚠️ 连卷一起删(数据 + 日志 + 备份全没) docker compose up -d --build # 改完代码重新构建 docker compose exec portal python manage.py collect # 手动采集一次 docker compose exec portal python manage.py users # 账号 / 角色 / 凭证状态 -docker compose exec portal python manage.py status # 调度与最近采集 +docker compose exec portal python manage.py status # 调度与最近采集 +docker compose exec portal python manage.py backups # 现有备份清单 + +# 看容器实际吃到的资源上限(验证 compose 的 limits 生效了) +docker stats --no-stream workbuddy-portal +docker inspect -f 'CPU={{.HostConfig.NanoCpus}} MEM={{.HostConfig.Memory}} PIDS={{.HostConfig.PidsLimit}}' workbuddy-portal ``` --- @@ -363,10 +437,19 @@ name: workbuddy-portal services: portal: - image: git.iwali.top/wangchuanli/workbuddy-portal:1.3.0 + image: git.iwali.top/wangchuanli/workbuddy-portal:1.4.0 container_name: workbuddy-portal restart: unless-stopped init: true # tini 接管 PID 1,docker stop 能干净传到 python + + # ---- 容器资源上限(对外提供服务时的第一道闸门)---- + cpus: "${WB_CPUS:-1.0}" # SQLite 单写者,多给核也并行不起来 + mem_limit: "${WB_MEM_LIMIT:-512m}" + memswap_limit: "${WB_MEM_LIMIT:-512m}" # 与 mem_limit 相等 = 禁用 swap + pids_limit: ${WB_PIDS_LIMIT:-256} + ulimits: + nofile: {soft: 4096, hard: 8192} + ports: - "${WB_BIND:-0.0.0.0}:${WB_PORT:-8848}:8848" environment: @@ -378,10 +461,17 @@ services: WB_DISABLE_SCHEDULER: ${WB_DISABLE_SCHEDULER:-0} # 纯 HTTP 部署必须留 0,设成 1 会「登录成功又跳回登录页」 WB_COOKIE_SECURE: ${WB_COOKIE_SECURE:-0} + # 直接暴露公网必须留 0;只有自己的反代重写了 XFF 才置 1(见第六节) + WB_TRUST_PROXY: ${WB_TRUST_PROXY:-0} + WB_FORCE_HTTPS: ${WB_FORCE_HTTPS:-0} + WB_ACCESS_LOG: ${WB_ACCESS_LOG:-1} + WB_THREADS: ${WB_THREADS:-4} + WB_BACKUP_DIR: /app/backups WB_IMPORT_CREDS: ${WB_IMPORT_CREDS:-0} volumes: - wb_data:/app/data # 数据正本 + instance.json + exports - wb_logs:/app/logs + - wb_backups:/app/backups # 归档单独一个卷,别和正本同卷 healthcheck: test: ["CMD", "python", "/app/docker/healthcheck.py"] interval: 30s @@ -397,6 +487,7 @@ services: volumes: wb_data: wb_logs: + wb_backups: ``` **`.env`**: @@ -408,13 +499,22 @@ WB_BIND=0.0.0.0 WB_PORT=8848 TZ=Asia/Shanghai -# 首个管理员(只在数据库为空时生效)—— 务必改掉默认值 +# 首个管理员(只在数据库为空时生效)。 +# 留空 = 程序生成随机口令并只在启动日志打印一次(没有 admin123 兜底了)。 WB_ADMIN_USER=admin WB_ADMIN_PASSWORD=换成你的强密码 WB_DISABLE_SCHEDULER=0 WB_COOKIE_SECURE=0 -WB_IMPORT_CREDS=0 +WB_TRUST_PROXY=0 +WB_FORCE_HTTPS=0 +WB_ACCESS_LOG=1 +WB_THREADS=4 + +# 容器资源上限 +WB_CPUS=1.0 +WB_MEM_LIMIT=512m +WB_PIDS_LIMIT=256 EOF chmod 600 .env # 里面有密码 ``` @@ -434,12 +534,30 @@ docker compose logs -f --tail=50 首次启动时容器入口会依次做三件事(见 `docker/entrypoint.sh`): -1. `manage.py init` —— 建表 + 灌默认配置 + 创建首个管理员(**幂等**,库非空时不重建); +1. `manage.py init` —— 建表 + 灌默认配置 + 创建首个管理员(**幂等**,库非空时不重建)。 + 没设 `WB_ADMIN_PASSWORD` 时,这一步会打印一次随机口令,格式长这样: + + ``` + ================= 管理员初始口令 ================= + 用 户 名:admin + 初 始 口 令:xxxxxxxxxxxxxxxx + ================================================== + ``` + + **只有这一次机会**。之后想再看到,唯一的办法是删库重来或 + `docker compose exec portal python manage.py passwd admin 新密码`。 2. 可选导入 —— `WB_IMPORT_CREDS=1` / `WB_IMPORT_XLSX=…` 才触发; -3. `exec manage.py serve` —— 交接给 waitress,调度线程就在这个进程里。 +3. `exec manage.py serve` —— 交接给 waitress(线程数 `WB_THREADS`),调度线程就在这个进程里。 日志里看到 `[entrypoint] 启动 Web 服务(waitress)…` 就是起来了。 +> 口令提示行是被容器启动日志带出来的,所以**别忘了加 `--tail`** +> (默认 `docker compose logs` 只给最近若干行;容器重启多轮后提示会被挤出去): +> +> ```bash +> docker compose logs portal | grep -A6 '管理员初始口令' +> ``` + ### 5.4 完成后的检查清单 ```bash @@ -473,7 +591,10 @@ docker compose exec portal python manage.py status ## 六、反向代理与 HTTPS -前面挂 nginx / Caddy / Traefik 时,有两点必须注意,否则会踩坑: +前面挂 nginx / Caddy / Traefik 时有三个要点,**第 3 条是 v1.4.0 新增的**, +漏了会以「登录限速误伤所有人」的形式出现: + +**① nginx 侧透传真实 IP** ```nginx server { @@ -487,8 +608,7 @@ server { proxy_pass http://127.0.0.1:8848; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - # 必须透传:登录失败限速按真实 IP 计数,否则所有请求都算到代理头上, - # 一个人被锁 → 全站被锁 + # 透传真实 IP:登录失败限速、注册配额、验证码限速都按它计数 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; @@ -499,14 +619,47 @@ server { } ``` -| 现象 | 原因 | +**② 应用侧打开 `WB_TRUST_PROXY=1`(关键,别漏)** + +程序**默认不信任** `X-Forwarded-For` —— 这是为了「直接暴露公网」时的安全: +XFF 的第一段是客户端自己填的,信了它,攻击者每次换一个伪造值就能绕过全部 IP 限速。 + +开启后程序取 XFF 里**最右侧**的合法 IP。最右侧是离你最近的那一跳(由你自己的代理写入), +客户端加不进去,所以下面两种 nginx 写法**都安全**: + +| nginx 写法 | 效果 | |---|---| -| 登录限速「误伤」所有人 | 没透传 `X-Forwarded-For` | -| 导出 CSV 要等很久才出第一个字节 | 没关 `proxy_buffering` | -| 手动采集走到 504 | `proxy_read_timeout` 太短 | +| `$proxy_add_x_forwarded_for` | 在客户端传来的值后面**追加**你的 `$remote_addr` → 链路完整,便于排查 | +| `$remote_addr` | **覆盖**成最近一跳 → 最保守,丢掉链路 | + +真正不能做的是去信 XFF 里**最左边**那一段。 + +```bash +# .env +WB_TRUST_PROXY=1 +``` + +> ⚠️ **漏了这一步的症状**:所有请求都以 `127.0.0.1`(代理的地址)计入限速, +> 于是**一个人失败 5 次,全站被锁 10 分钟**。这与「没透传 XFF」的表现一模一样, +> 所以两件事要一起改。 +> +> 反过来,**不设反代却设了 `WB_TRUST_PROXY=1`** 是更危险的方向: +> 三道 IP 防线直接失效。拿不准就留 `0`。 + +**③ HTTPS 相关开关** 配好 HTTPS 后,把 `.env` 里的 `WB_COOKIE_SECURE` 改成 `1`(会话 Cookie 只走 HTTPS), -然后 `docker compose up -d` 重建容器。**只有真的能用 `https://` 访问时才这么做。** +需要强制跳转再加 `WB_FORCE_HTTPS=1`。这两个任一为 `1` 时程序才下发 HSTS +—— 纯 HTTP 部署**不会**下发(下发会让浏览器强升 https,表现成白屏)。 + +改完都要 `docker compose up -d` 重建容器(环境变量在启动期读取)。 + +| 现象 | 原因 | +|---|---| +| 登录限速「误伤」所有人 | 没透传 `X-Forwarded-For`,**或**忘了设 `WB_TRUST_PROXY=1` | +| 导出 CSV 要等很久才出第一个字节 | 没关 `proxy_buffering` | +| 手动采集走到 504 | `proxy_read_timeout` 太短 | +| 访问 http 时白屏 / 一直跳转 | `WB_FORCE_HTTPS=1` 但实际没有 HTTPS 可用 | --- @@ -522,7 +675,7 @@ server { ```bash export GITEA_TOKEN=<你的令牌> tools/push-all.sh # 推 main 分支 + 镜像 latest -tools/push-all.sh 1.3.0 # 同时打一个版本 tag 并推送 +tools/push-all.sh 1.4.0 # 同时打一个版本 tag 并推送 ``` 脚本做的事: @@ -560,15 +713,15 @@ docker login git.iwali.top -u wangchuanli docker compose build docker tag git.iwali.top/wangchuanli/workbuddy-portal:latest \ - git.iwali.top/wangchuanli/workbuddy-portal:1.3.0 + git.iwali.top/wangchuanli/workbuddy-portal:1.4.0 docker push git.iwali.top/wangchuanli/workbuddy-portal:latest -docker push git.iwali.top/wangchuanli/workbuddy-portal:1.3.0 +docker push git.iwali.top/wangchuanli/workbuddy-portal:1.4.0 ``` ### 7.4 验证远端 ```bash -docker manifest inspect git.iwali.top/wangchuanli/workbuddy-portal:1.3.0 +docker manifest inspect git.iwali.top/wangchuanli/workbuddy-portal:1.4.0 # 或 curl -s -u wangchuanli:TOKEN \ https://git.iwali.top/api/v1/packages/wangchuanli?type=container @@ -589,17 +742,47 @@ curl -s -u wangchuanli:TOKEN \ | `instance.json` | ★★★ | 含 `secret_key`(会话签名)**与 `cookie_key`(各账号 Cookie 的加密主密钥)**。丢了 / 被替换:所有人要重新登录,**且所有账号存的 Cookie 都会变成「无法解密」,需各自重填** | | `exports/*.csv` | ★ | 导出快照,可再生 | | `wb_logs` 卷 | ☆ | 排错用,可再生 | +| `wb_backups` 卷(`/app/backups`) | ★★★ | 备份归档 `usage-<时间戳>.zip`。**里面含一份 `instance.json`** —— 所以它的机密等级和密钥文件完全相同 | `.env` 不在里面 —— 它含密码,**单独用密码管理器保管**。 -工程目录下的 `backups/` 就是给这类快照准备的位置(**刻意不放在 `data/`**: -`data/` 是 Docker 卷,`docker compose down -v` 会把备份和正本一起删掉)。 +> **`instance.json` 为什么必须跟着备份走**:`cookie_key` 在里面,而各账号的 Cookie +> 是用它加密后入库的。只备库不备它,恢复出来的库能读,但**所有账号的 Cookie 都会 +> 显示「无法解密」,需要各自重填**。所以 v1.4.0 的归档里默认打包了它 +> (恢复时可以关掉,见 [8.3](#83-恢复))。 + +容器里归档落在**独立的命名卷** `workbuddy-portal_wb_backups`(对应 `/app/backups`), +**刻意不与 `data/` 同卷**:`docker compose down -v` 会把同卷的正本与副本一起删掉 —— +那正好是最需要备份的时刻。裸机部署下就是工程目录里的 `backups/`。 > **不要**在容器运行时用宿主机的 `manage.py` 去碰库(见 [4.5](#45-数据落点命名卷不是绑定挂载) 与 [11.5](#115-windows-绑定挂载的坑容器打不开数据库))。 ### 8.2 备份 -**方式 A:整体打包命名卷(最完整,升级前做)** +**方式 A:用内置备份(推荐,v1.4.0 起)** + +三种触发方式,产出的是同一种归档: + +| 方式 | 怎么做 | +|---|---| +| 自动 | 「备份管理」页开 `backup_enabled`,设周期(默认 24 小时)与保留份数(默认 7)。调度线程每 20 秒检查一次,**有采集在跑就跳过**(不跟采集抢磁盘),到期自动打一份并按份数清理最旧的 | +| 页面 | 「备份管理 → 立即备份」,列表里可**下载**(拿 zip)、**恢复**、**删除** | +| 命令行 | `docker compose exec portal python manage.py backup --note "升级前"` | + +```bash +# 列出已有备份(大小 / 条数 / 积分 / 来源 / 时间),--prune 顺便清理 +docker compose exec portal python manage.py backups +docker compose exec portal python manage.py backups --prune --keep 5 + +# 把归档拿到宿主机(备份留在容器里不算备份) +docker compose cp portal:/app/backups/. ./backups/ +``` + +> **归档里的快照不是 `cp` 出来的**:走的是 SQLite 在线备份 API(`Connection.backup`), +> 按页复制并持有读事务,所以**采集正在写的时候拿到的也是「某一时刻的完整库」**。 +> 直接 `cp data/usage.sqlite` 可能缺最近一段数据而**不报错**,这是最容易被忽略的差别。 + +**方式 B:整体打包命名卷(升级前做,最完整)** ```bash docker compose stop portal @@ -612,30 +795,6 @@ docker run --rm \ docker compose start portal ``` -**方式 B:只导数据库文件(最常用,每天做)** - -```bash -# 先 checkpoint 把 WAL 落进主库,再拷 —— 这样单独一个 .sqlite 就是完整的 -docker compose exec portal python -c " -from workbuddy_portal import db -db.connect().execute('PRAGMA wal_checkpoint(TRUNCATE)')" - -docker run --rm \ - -v workbuddy-portal_wb_data:/data:ro \ - -v "$PWD/backups":/backup \ - alpine:3.20 cp /data/usage.sqlite /backup/usage-$(date +%F).sqlite -``` - -> 也可以用 SQLite 官方的在线备份 API,**不用停服务、不用手工 checkpoint**: -> ```bash -> docker compose exec -T portal python -c " -> import sqlite3 -> s = sqlite3.connect('/app/data/usage.sqlite'); d = sqlite3.connect('/tmp/b.sqlite') -> s.backup(d); d.close(); s.close()" -> docker compose cp portal:/tmp/b.sqlite ./backups/usage-$(date +%F).sqlite -> docker compose exec -T portal rm -f /tmp/b.sqlite -> ``` - **方式 C:逻辑导出(跨版本最安全,可读性最好)** ```bash @@ -643,11 +802,53 @@ docker compose exec portal python manage.py export-csv /app/data/exports docker compose cp portal:/app/data/exports/. ./backups/exports/ ``` -建议:方式 B 每天跑(可用宿主机 cron)、方式 C 每季度跑一次、方式 A 在**升级前**跑。 -裸机部署把上面命令里的 `docker compose exec portal` 去掉即可,路径换成相对的 `data/`。 +建议:**方式 A 日常自动跑**(并定期把 zip 下载到容器之外/异地)、 +方式 B 在**升级前**跑、方式 C 每季度跑一次。 +裸机部署把上面命令里的 `docker compose exec portal` 去掉即可。 + +> ⚠️ **备份留在同一台机器上,只防「改错了」,不防「机器没了」**。 +> 内置备份负责「本机有历史版本可回滚」,**异地那一份要你自己安排** +> (同步 `backups/` 到 NAS / 对象存储 / 另一台机器)。 ### 8.3 恢复 +**方式 A:用内置恢复(推荐)** + +页面:「备份管理」→ 找到那一份 → 点「恢复」→ 先弹一次确认(是否连 `instance.json` +一起回滚)→ 再弹一次最终确认。 + +```bash +# 命令行:不加 --yes 只打印「将要发生什么」,不会动数据 +docker compose exec portal python manage.py restore usage-20260916-151043.zip +docker compose exec portal python manage.py restore usage-20260916-151043.zip --yes +``` + +它做的事,按顺序: + +1. **先校验**归档(格式版本 / 条目齐全 / `PRAGMA integrity_check` / 库结构版本不高过当前程序)。 + 验不过就**一个字节都不动**。 +2. **再给当前库自动打一份 `pre-restore` 快照**(`trigger=pre-restore`,在备份列表里能看到)。 + 恢复错了你还能回去 —— 这是恢复链路的兜底。 +3. 解包 → 在**临时库**上先把结构迁移到当前版本 → 在一个 `BEGIN IMMEDIATE` 事务里 + **整表替换** `users` / `settings` / `usage_records` / `collect_runs` / `audit_log` / `captchas`。 +4. 把归档里的 `instance.json` 覆盖回来(原文件先另存 `.pre-restore-<时间戳>`)。 +5. 让**所有账号的会话立即失效**(`session_ver` 全体 +1)—— 恢复是全局性事件, + 旧会话描述的账号与权限可能已经被整个换掉了。 + +> **为什么是「整表替换」而不是「换文件」**:换文件需要停机、且要求没人持有文件句柄 +> (Windows 上会直接失败);整表替换在一个事务里完成,中途出错就是一次回滚, +> 而且**老版本的备份(`user_version` 2 / 3)也能恢复** —— 迁移在临时库里先做完。 +> 另外它按**列名交集**搬数据,不是 `SELECT *`:老库的列是 `ALTER` 追加的, +> 顺序与新库建表语句不一定一致,`SELECT *` 会**静默错位**(字段整体串位而值都「合法」)。 + +不想连密钥一起回滚(例如只想把数据退回去、保留本机当前的 `cookie_key`): + +```bash +docker compose exec portal python manage.py restore <文件名> --yes --no-instance +``` + +**方式 B:整体换卷(容器起不来时的兜底)** + ```bash docker compose down @@ -665,9 +866,6 @@ docker compose exec portal python manage.py stats # 核对条数与积分 > **别把旧库和旧 WAL 混着用**:WAL 里记的是相对旧库的增量,配错会损坏数据。 > 恢复时目标目录里**只放一个 `.sqlite`**(外加 `instance.json`)最省心。 -> -> 只恢复了库、没恢复 `instance.json` 的话,所有账号的 Cookie 都会显示「无法解密」, -> 各自重新粘贴一次即可 —— 历史用量数据不受影响。 ### 8.4 迁移:从绑定挂载换到命名卷 @@ -745,8 +943,9 @@ sudo systemctl restart workbuddy-portal |---|---|---|---| | **1.1.0 → 1.2.0**(单用户 → 多用户) | 0 → 2 | 建 `users` 表并写入首个管理员;`settings` / `usage_records` / `collect_runs` / `audit_log` 改为 `(user_id, …)` 复合主键,**老数据整体归到第一个账号**;明文 Cookie **就地加密**;建 `captchas` 表 | 不需要 | | **1.2.0 → 1.3.0**(权限收敛) | 2 → 3 | **配置作用域收敛**:把管理员个人名下的调度与采集参数提升到实例级 `user_id=0`,再清掉个人残留;实例级不再保留 `cookie` / `user_agent` | 不需要 | +| **1.3.0 → 1.4.0**(备份 + 对外加固) | 3 → 4 | 只做两件事:`users` 加一列 `session_ver`(非空、默认 0)、建 `backups` 表(备份索引)。**没有数据搬运、没有键位变动** | 不需要 | -两次迁移都是**幂等**的(可重复启动、可重复执行),各自会写一条审计: +三次迁移都是**幂等**的(可重复启动、可重复执行),各自会写一条审计: ```bash docker compose logs portal | grep -iE "迁移|migrat|提升|promote" @@ -764,10 +963,10 @@ docker compose exec portal python manage.py stats # 各账号条数与积分 | 频率 | 做什么 | |---|---| -| 每天 | 打开「概览」看「采集健康」;确认今天有采集记录 | -| 每周 | 「日志管理」按 `warn` / `error` 筛一遍,看有没有 TLS 或解密类告警(**仅管理员**) | -| 每月 | 确认各账号 Cookie 没过期(「配置管理」看提示);跑一次备份恢复演练 | -| 每季度 | `manage.py vacuum`;出一份全量 CSV 归档;检查磁盘占用 | +| 每天 | 打开「概览」看「采集健康」;确认今天有采集记录;确认「备份管理」里今天/昨天有一份 `auto` 备份 | +| 每周 | 「日志管理」按 `warn` / `error` 筛一遍,看有没有 TLS 或解密类告警(**仅管理员**);`docker stats --no-stream` 看一眼内存有没有贴着 512m 上限 | +| 每月 | 确认各账号 Cookie 没过期(「配置管理」看提示);**把最新归档下载到容器之外**;跑一次备份恢复演练(见下) | +| 每季度 | `manage.py vacuum`;出一份全量 CSV 归档;检查磁盘占用与 `wb_backups` 卷的体积 | 一键体检: @@ -775,10 +974,30 @@ docker compose exec portal python manage.py stats # 各账号条数与积分 docker compose exec portal python manage.py status # 调度与最近采集 docker compose exec portal python manage.py stats # 存档概况 + 逐账号明细 docker compose exec portal python manage.py users # 账号 / 角色 / 凭证状态 +docker compose exec portal python manage.py backups # 备份份数 / 占用 / 来源 / 下次自动备份 +docker stats --no-stream workbuddy-portal # 实际 CPU / 内存占用 ``` -磁盘长这样:数据库每 1000 条记录约 1.5 MB,`app.log` 滚动上限 2 MB × 3。 -增长慢,但**年度归档后记得对旧数据做取舍**(本项目不做自动清理,数据只增不减)。 +**备份恢复演练(每月一次,五分钟)** + +不演练的备份等于没有备份 —— 只有在真的恢复过一次之后,你才知道那份 zip 是好的: + +```bash +# 1) 随便挑一份归档,先只看它校验是否通过(不加 --yes,不会动数据) +docker compose exec portal python manage.py restore <文件名> + +# 2) 真要演练恢复:确认当前库条数 → 恢复 → 再确认条数一致 +docker compose exec portal python manage.py stats | head -3 +docker compose exec portal python manage.py restore <文件名> --yes +docker compose exec portal python manage.py stats | head -3 +``` + +> 恢复是**幂等且可回退**的:它会在动手前自动给你当前库打一份 `pre-restore` 快照, +> 恢复错了就再恢复回那一份。演练完记得 `backups --prune --keep N` 把多出来的清掉。 + +磁盘长这样:数据库每 1000 条记录约 1.5 MB,`app.log` 滚动上限 2 MB × 3, +每份归档约为数据库体积的 1/5(zip 压缩后)。增长慢,但**年度归档后记得对旧数据做取舍** +(本项目不做自动清理,数据只增不减;备份会按 `backup_keep` 自动清理)。 ### 自检脚本(开发者向) @@ -837,9 +1056,11 @@ docker compose up -d # 环境变量,必须 up -d,restart 不生效 |---|---| | `/logs`、`/logs/tail` | 403(导航里不显示) | | `/users`、`/api/users` | 403(导航里不显示) | +| `/backups`、`/api/backups*`(含**下载**与**恢复**) | 403(导航里不显示) | | 任务管理页的调度表单 | 只读(时刻由管理员统一设定) | | 配置管理页的采集参数 | 只读 | | 本人的 Cookie / User-Agent | **可读写**(唯一可改的配置) | +| 「个人中心 → 导出我的全部数据」 | **可用**(只含本人数据,**不含 Cookie 明文**) | 改权限:`docker compose exec portal python manage.py passwd alice 密码 --role admin`。 @@ -934,6 +1155,50 @@ docker compose exec portal date # 应该输出 CST / +0800 echo "WB_DISABLE_SCHEDULER=1" >> .env && docker compose up -d ``` +> 注意这只关调度。手动采集、`manage.py collect`、**自动备份**都还在跑 +> (自动备份由调度线程触发,所以关掉调度后自动备份也停了 —— +> 停调度期间请手动点几次「立即备份」)。 + +### 11.10 拿不到管理员初始口令 + +**症状**:第一次 `docker compose up -d` 之后想登录,不知道密码;日志里也找不到那行提示。 + +原因通常是**没设 `WB_ADMIN_PASSWORD`**(v1.4.0 起没有默认口令),而提示只在 +「首次初始化那一刻」打印一次。依次试: + +```bash +# 1) 翻日志(默认只给最近若干行,容器重启多轮后提示可能已被挤出,加 --tail 或直接 grep) +docker compose logs portal | grep -A6 '管理员初始口令' +docker compose logs --tail=2000 portal | grep -A6 '口令' + +# 2) 如果管理员早就建好了,那行不会再出现 —— 直接重置: +docker compose exec portal python manage.py passwd admin '一个新的强密码' + +# 3) 或者看数据库里到底有没有账号 +docker compose exec portal python manage.py users +``` + +> **口令找不回来是有意的**:库里只有 PBKDF2 散列,明文只在生成时打印那一次、 +> 不落盘。所以别指望「再查一次」——重置是正道。 +> 下次部署记得在 `.env` 里显式写 `WB_ADMIN_PASSWORD`。 +> +> 如果 `manage.py users` 显示**一个账号都没有**,说明初始化那步失败了, +> 去 `docker compose logs portal | grep -iE "init|error|traceback"` 看真实原因 +> (最常见是数据卷权限,见 [4.5](#45-数据落点命名卷不是绑定挂载))。 + +### 11.11 备份相关 + +| 症状 | 原因 / 处理 | +|---|---| +| `/app/backups` 里是空的,但「备份管理」页说有 N 份 | 索引与实际磁盘不一致。页面每次打开都会 `sync_index()` 双向对齐,刷新一次即可;命令行用 `manage.py backups` 也会重建索引 | +| 列表里某一份标着「文件已不存在」 | 被手工删过(或恢复前的临时目录被清)。索引**故意保留这一行**,是为了留下「这里曾有过一份」的痕迹;用 `backups --prune` 或页面上的删除把它清掉 | +| 备份失败,日志里没有明显报错 | 自动备份的失败会记 `log.error`,看 `docker compose logs portal \| grep -i 备份`。常见原因是磁盘满或卷权限 | +| 恢复时提示「有采集任务正在运行」 | 设计如此:恢复要持采集锁。等当前采集结束(`manage.py status` 看互斥锁),或先停调度 | +| 恢复后所有人都被踢下线 | **设计如此**。恢复会让全体 `session_ver` +1 —— 归档里的会话版本与旧 Cookie 可能恰好相等,那样旧会话会带着「已被替换掉的账号与权限」继续用 | +| 恢复后所有 Cookie 显示「无法解密」 | 恢复时选了「不恢复 instance.json」,而两边的 `cookie_key` 不同。重新走一次恢复并带上 `instance.json`(默认会带),或让各账号重填 Cookie | +| 升级前想留一份最完整的 | 用 [8.2](#82-备份) 的方式 B(整体打包命名卷),它连 `exports/` 与 `instance.json` 一起包 | +| 想改保留份数 / 关掉自动备份 | 「备份管理 → 自动备份设置」里的 `backup_enabled` / `backup_interval_hours` / `backup_keep`(实例级,仅管理员) | + --- ## 十二、配置项速查 @@ -943,6 +1208,7 @@ echo "WB_DISABLE_SCHEDULER=1" >> .env && docker compose up -d 表主键是 `(user_id, key)`:`user_id=0` 表示**实例级**(所有账号共用),其余是**个人级**。 **v1.3.0 起只有两个键是个人级的**(`cookie` / `user_agent`)—— 普通账号唯一能改的东西。 +其余全部是**实例级**(`GLOBAL_KEYS`,v1.4.0 起共 **23** 个),只有管理员能改。 写权限判断统一走 `config.writable_by(key, is_admin)`,页面与接口用的是同一个函数。 | 键 | 默认 | 作用域 | 谁能改 | 说明 | @@ -959,6 +1225,12 @@ echo "WB_DISABLE_SCHEDULER=1" >> .env && docker compose up -d | `schedule_times` | `09:00,17:00` | 实例级 | 仅管理员 | 每日时刻,逗号分隔,本地时区 | | `catch_up` | `1` | 实例级 | 仅管理员 | 启动补跑开关 | | `catch_up_grace_hours` | `12` | 实例级 | 仅管理员 | 补跑宽限期(1~168 小时) | +| `max_schedule_slots_per_day` | `6` | 实例级 | 仅管理员 | **每日调度时刻数上限**(1~12,代码硬顶 12)。挡住「填 200 个时刻」 | +| `collect_min_interval_seconds` | `60` | 实例级 | 仅管理员 | **同一账号两次手动采集的最小间隔**(0~3600 秒);期间再点会 `429` | +| `collect_max_range_days` | `31` | 实例级 | 仅管理员 | **单次采集的最长跨度**(1~31 天,代码硬顶 31 = 1 个月)。改大也突破不了硬顶 | +| `backup_enabled` | `1` | 实例级 | 仅管理员 | 是否开启自动备份 | +| `backup_interval_hours` | `24` | 实例级 | 仅管理员 | 备份周期(1~720 小时) | +| `backup_keep` | `7` | 实例级 | 仅管理员 | 保留最近几份,超出的删最旧的(1~100) | | `page_size` | `200` | 实例级 | 仅管理员 | 采集单页条数(20~1000) | | `rewind_minutes` | `2` | 实例级 | 仅管理员 | 断点回退分钟数(0~120) | | `drift_tolerance_minutes` | `5` | 实例级 | 仅管理员 | 云端时间漂移告警阈值(0~720) | @@ -994,13 +1266,19 @@ echo "WB_DISABLE_SCHEDULER=1" >> .env && docker compose up -d | `WB_BIND` | `0.0.0.0` | 宿主机绑定地址(仅 compose 用) | | `WB_HOST` / `WB_PORT` | `0.0.0.0` / `8848` | 容器内监听地址 / 端口 | | `WB_DATA_DIR` / `WB_LOG_DIR` / `WB_DB` | `/app/data` / `/app/logs` / `<数据目录>/usage.sqlite` | 路径覆盖 | -| `WB_COOKIE_SECURE` | `0` | `1` = 会话 Cookie 只走 HTTPS(纯 HTTP 部署必须留 `0`) | -| `WB_DISABLE_SCHEDULER` | `0` | `1` = 不启动调度线程 | -| `WB_ADMIN_USER` / `WB_ADMIN_PASSWORD` | `admin` / 空 | 首个管理员(**仅库为空时生效**) | +| `WB_BACKUP_DIR` | `/app/backups` | 备份归档落点(**不要放进 `data/`**,见 [8.1](#81-备份什么)) | +| `WB_COOKIE_SECURE` | `0` | `1` = 会话 Cookie 只走 HTTPS(纯 HTTP 部署必须留 `0`)。**同时是下发 HSTS 的开关之一** | +| `WB_TRUST_PROXY` | `0` | `1` = 信任 `X-Forwarded-For`(取**最右侧**合法 IP)。**直接暴露公网必须留 0**,见 [第六节](#六反向代理与-https) | +| `WB_FORCE_HTTPS` | `0` | `1` = 非 HTTPS 的 GET/HEAD 跳转(POST 不跳) | +| `WB_ACCESS_LOG` | `1` | 记访问日志到 `logs/app.log`(跳过静态资源与验证码图) | +| `WB_THREADS` | `4`(compose)/ `8`(代码默认) | waitress 线程数 = 单实例并发处理慢请求的上限 | +| `WB_CPUS` / `WB_MEM_LIMIT` / `WB_PIDS_LIMIT` | `1.0` / `512m` / `256` | 容器资源上限(仅 compose 用,见 [4.4](#44-env-主要变量)) | +| `WB_DISABLE_SCHEDULER` | `0` | `1` = 不启动调度线程(**自动备份也会一起停**) | +| `WB_ADMIN_USER` / `WB_ADMIN_PASSWORD` | `admin` / 空 | 首个管理员(**仅库为空时生效**)。留空则随机生成、只在启动日志打印一次 | | `WB_IMPORT_CREDS` | `0` | 启动时从挂载的编辑器配置导入 Cookie | | `WB_IMPORT_XLSX` | 空 | 启动时导入指定路径的官网 xlsx | | `WB_IMAGE` | Gitea 地址 | 镜像名(仅 compose 用) | -| `WB_HOST_DATA_DIR` / `WB_HOST_LOG_DIR` | `./data` / `./logs` | 仅叠加 `hostdir` 时有效(**只建议 Linux**) | +| `WB_HOST_DATA_DIR` / `WB_HOST_LOG_DIR` / `WB_HOST_BACKUP_DIR` | `./data` / `./logs` / `./backups` | 仅叠加 `hostdir` 时有效(**只建议 Linux**) | ### 12.3 `manage.py` 子命令速查 @@ -1019,5 +1297,8 @@ echo "WB_DISABLE_SCHEDULER=1" >> .env && docker compose up -d | `passwd USER [PASSWORD] [--role admin\|user] [--activate]` | 重置 / 创建账号 | | `status` | 各账号的调度与最近采集状态 | | `vacuum` | 整理数据库(checkpoint + VACUUM) | +| `backup [--note 说明]` | 立即打一份备份(所有 `data/*.sqlite` + `instance.json` → zip),并按保留份数清理最旧的 | +| `backups [--prune] [--keep N]` | 列出备份(大小 / 条数 / 积分 / 来源 / 时间);`--prune` 顺便清理 | +| `restore <文件名> [--yes] [--no-instance]` | **从备份恢复**(整表替换)。**不加 `--yes` 只打印将要发生什么**;`--no-instance` = 不覆盖 `instance.json` | Docker 部署下前面加 `docker compose exec portal`。 diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index c9c8c81..f4c1c5c 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -139,7 +139,8 @@ | 查看概览、用量大屏 | ✅(只有你自己的数据) | | 查看 / 搜索数据明细、展开 Prompt | ✅(只有你自己的记录) | | 导出 CSV(当前筛选条件) | ✅(只有你自己的记录) | -| 手动「立即采集一次」、按区间补采 | ✅(只采你自己的) | +| **导出我的全部数据**(zip:记录 / 采集历史 / 审计 / 账号与配置) | ✅ **不含 Cookie 明文**,见 [10](#十个人中心) | +| 手动「立即采集一次」、按区间补采 | ✅(只采你自己的,且有频率与跨度限制,见 [8.2](#82-手动采集你可以用)) | | 「补全 Prompt」「导出我的 CSV」 | ✅(只动你自己的) | | 查看采集运行历史 | ✅(只有你自己的) | | **设置定时任务频率 / 开关 / 补跑策略** | ❌ **只读** | @@ -147,6 +148,7 @@ | **查看日志管理页 / 应用日志** | ❌ 403 | | **改实例级设置**(接口地址、开放注册、验证码策略) | ❌ | | **用户管理**(建号 / 停用 / 删号 / 改权限) | ❌ 403 | +| **备份管理**:下载数据库归档、从备份恢复 | ❌ 403(恢复等于对全库数据有完整读写权,只给管理员) | | **整理数据库**(`VACUUM`,整库操作) | ❌ | > **为什么调度不给你改**:采集策略是**整机一套**的(一台部署一个调度时刻表, @@ -160,9 +162,9 @@ | 是「你的」 | 是「共用的 / 管理员管」 | |---|---| | Cookie 与 User-Agent | 接口基址与路径 | -| 用量记录、采集历史、导出的 CSV | 采集调度时刻表与全部采集参数 | +| 用量记录、采集历史、导出的 CSV / zip | 采集调度时刻表与全部采集参数 | | 个人资料、登录密码 | 是否开放自助注册、注册限额、验证码策略 | -| — | 数据库文件本身、应用日志 | +| — | 数据库文件本身、应用日志、**数据库备份归档** | 三条值得记牢: @@ -172,6 +174,9 @@ **记录条数 / 积分合计 / 最后登录时间与 IP** —— 只给「有多少」,不给「是什么」。 - **采集只使用本人的凭证。** 系统不会拿别人的 Cookie 去替你采集(那会串号), 所以每个账号都必须各自配一次 Cookie。 +- **但备份是管理员的能力**:管理员能下载整个数据库的归档、也能用归档覆盖回来。 + 这是**运维必需**(不然数据丢了没人能救),代价是管理员对全库数据有完整的读写权。 + 介意这一点的话,就自己用「导出我的全部数据」留一份,见 [10](#十个人中心)。 --- @@ -371,9 +376,17 @@ python manage.py import-creds -u alice # 想导给谁就写谁的用户名 重扫**不会产生重复** —— 主键去重,已存在的记录按「更早的本地时间」保留。 > 这两个动作**只采集你自己的**数据,不会碰到别人的。 -> -> **同一时刻只能有一个采集在跑**。重复点击会返回「忙碌」提示,这是设计如此 -> (SQLite 是单写者,并发只会互相拖慢)。等它跑完再点。 + +**三条限制**(v1.4.0 起,页面上会写出来): + +| 限制 | 表现 | 为什么 | +|---|---|---| +| **有任务在跑时不能再发起** | 点「立即采集」返回「**正在采集**,请等它结束」 | SQLite 是单写者,并发采集只会互相拖慢,还可能把云端接口打得太密 | +| **两次手动采集之间有最小间隔**(默认 60 秒) | 返回「操作太频繁,请 N 秒后再试」 | 防止反复点按钮把请求刷爆 | +| **单次最长跨度 31 天**(约 1 个月) | 起始日期填得太早会被自动收窄,运行日志里出现一行 `[warn] 请求跨度超过上限 N 天,已自动收窄起点` | 避免一次拉取过长周期的数据(云端要分很多页,慢且容易失败) | + +> 想补更久以前的数据?**分几次做**:比如先补 1 月,再补 2 月。 +> 跨度过长的请求不是被拒就是跑到一半超时,分段的成功率反而更高。 ### 8.3 运行历史 @@ -459,14 +472,37 @@ python manage.py import-creds -u alice # 想导给谁就写谁的用户名 |---|---| | 四张卡片 | 我的记录数 / 我的积分 / 采集次数 / 我的 Cookie 状态 | | 修改资料 | 改显示名、邮箱(**用户名只读**) | -| 修改登录密码 | 需要原密码;改完当前会话仍然有效 | +| 修改登录密码 | 需要原密码。**改成功后其他设备上的登录会立刻失效**(本机这次会话保留,不然改完立刻被自己踢下线) | | 我的采集凭证 | 是否已配置、多少字符、结尾 4 位、最后更新时间、当前调度时刻(只读) | +| **导出我的全部数据** | 下载一个 zip,把**属于你的**东西一次带走(见下) | 顶栏右侧会有一个 **「普通账号」** 小标签(管理员则是「管理员」)—— 不确定自己是什么权限时看一眼这里。 > 卡片上的「我的积分」只统计**归属你本人的数据**,别人账号的记录不会算进来。 +#### 导出我的全部数据 + +页头有一个「**导出我的全部数据**」按钮(也有单独一张卡片说明它在打包什么)。 +点下去会下载一个 zip,里面是: + +| 文件 | 内容 | +|---|---| +| `使用记录.csv` | 你**全部**的用量明细(不受页面筛选条件影响) | +| `采集历史.csv` | 你的采集运行记录 | +| `操作审计.csv` | **你自己**的操作审计(别人的看不到) | +| `我的账号与配置.json` | 用户名 / 显示名 / 邮箱 / 角色 / 状态 / 本人的采集参数(只读项也在内) | +| `说明.txt` | 各文件的口径说明 | + +> **里面没有 Cookie 明文。** 导出自己的数据不等于把凭证交出去 —— 凭证是密文入库的, +> 导出接口不碰它。需要迁移凭证就在新环境重新粘一次。 +> +> 这个动作有 **10 秒的最小间隔**(防止连点生成一堆大文件)。数据量很大时 +> 服务端是**边生成边下发**的,浏览器会先等一下再开始下载。 + +> 想留在系统里、由运维统一保管的那种备份(含所有人的数据),那是「备份管理」页的事, +> 只有管理员能做。你的 zip 只包含你自己。 + --- ## 十一、管理员专属功能 @@ -479,14 +515,20 @@ python manage.py import-creds -u alice # 想导给谁就写谁的用户名 | 项 | 默认 | 说明 | |---|---|---| -| 启用调度 | 开 | 总开关。关掉后只有手动采集会跑 | +| 启用调度 | 开 | 总开关。关掉后只有手动采集会跑(**注意:自动备份也一起停**,见 [11.5](#115-备份管理页)) | | 每日时刻 | `09:00,17:00` | 逗号分隔的本地时刻。**保存即生效,不用重启** | +| 每日时刻上限 | 6 | 最多允许几个时刻(上限 12,代码硬顶)。时刻数直接决定采集频次 | | 启动补跑 | 开 | 启动时把今天已错过、且还在宽限期内的时刻补采一次 | | 补跑宽限期 | 12 小时 | 超过多少小时就不补了 | +| 采集最小间隔 | 60 秒 | 同一账号两次**手动**采集之间的最小间隔 | +| 单次最长跨度 | 31 天 | 一次采集最多覆盖多少天(硬顶 31 = 1 个月,改大也没用) | | 采集参数 | 见 9.2 | 分页 / 回退 / 超时 / 截断 / 证书校验等 | > ⚠️ **改 `schedule_times` 会清理槽位簿记**:系统只清掉「不再存在的时刻」对应的 > 槽位标记(所有账号一起清),**不做全清** —— 全清会让全部账号在宽限期内一起重采。 +> +> ⚠️ **把「每日时刻上限」调小**时,超出上限的那几个时刻会被一并清掉(这是有意的: +> 上限本身就是刹车,留着超限的配置只会让人以为它还生效)。 ### 11.2 实例级设置 @@ -506,6 +548,9 @@ python manage.py import-creds -u alice # 想导给谁就写谁的用户名 > 验证码的答案只存在服务端 `captchas` 表里,5 分钟过期、用一次就删 —— > 所以它不会随会话 Cookie 泄漏出去。 +> **自动备份的三个设置**(开关 / 周期 / 保留份数)不在这里,它们有自己的页面, +> 见 [11.5 备份管理页](#115-备份管理页)。 + ### 11.3 日志管理页 ![日志管理页](images/05-logs.png) @@ -535,7 +580,7 @@ python manage.py import-creds -u alice # 想导给谁就写谁的用户名 | 改显示名 | 行内直接改,点该行「保存」生效 | | 改权限 | 管理员 ↔ 普通 | | 改状态 | 启用 ↔ 停用(**停用立即生效**,不必等会话过期) | -| 改密码 | 给忘了密码的同事重置 | +| 改密码 | 给忘了密码的同事重置。**重置后他在所有设备上的登录立刻失效**,需重新登录 | | 删除 | **不可逆**,会连同该账号的用量数据与 Cookie 一起删除 | 列表还给出每个账号的**记录条数 / 积分合计 / 最后登录时间与 IP** —— 但**看不到内容**: @@ -553,6 +598,54 @@ python manage.py import-creds -u alice # 想导给谁就写谁的用户名 > **给同事开账号时默认选「普通」**:管理员是能停用别人账号的角色,没必要扩大。 +### 11.5 备份管理页 + +![备份管理页](images/11-backups.png) + +> 这一页**只有管理员能看到**(普通账号导航里没有,直接敲 `/backups` 返回 403)。 +> 理由很实际:**能下载或恢复整个数据库的人,等于对全库数据有完整的读写权。** + +**顶部四张 KPI**:现有份数 / 占用空间 / 自动备份开关 / 上次与下次自动备份时间。 + +**自动备份设置**(实例级,改完即生效): + +| 设置 | 默认 | 说明 | +|---|---|---| +| 开启自动备份 | 开 | 关掉后调度线程不再打新快照(已有的不会删) | +| 备份周期 | 24 小时 | 1 ~ 720 小时。到期就打一份,**有采集在跑时跳过**,等下轮 | +| 保留份数 | 7 | 超出的**按时间删最旧的**。只按磁盘上**真实存在**的文件算份数 | + +**备份列表**:每行给出文件名、大小、记录条数、积分、**来源**(自动 / 手动 / 命令行 / +恢复前的自动快照)、生成时间,以及三个按钮: + +| 按钮 | 做什么 | +|---|---| +| 下载 | 把 zip 存到本地。**归档里含 `instance.json`,也就是全库的加密主密钥**,所以它本身就是最高机密 —— 别放进公开网盘、别随手发人 | +| 恢复 | 把这份归档灌回数据库。**会先弹两次确认**(第二次专门问「是否连密钥一起回滚」) | +| 删除 | 删掉这一份(文件 + 索引行) | + +#### 「恢复」究竟做了什么(要点) + +1. **先校验**这份归档能不能用;验不过就**一个字节都不动**。 +2. **自动给当前库打一份快照**(来源标 `pre-restore`)—— 恢复错了还能回去。 +3. **整表替换**记录 / 配置 / 账号等数据(在一个事务里,中途失败自动回滚)。 +4. 可选把归档里的密钥文件一起覆盖回来。 +5. **让所有人的登录立即失效** —— 恢复是全局性事件,旧会话描述的账号与权限 + 可能已经整个被换掉了。 + +> **几个常见疑问** +> +> - **归档是怎么来的?** 不是 `cp` 出来的。走的是 SQLite 官方在线备份 API, +> 按页复制并持有读事务 —— **采集正在写的时候拿到的也是一个完整一致的库**。 +> 直接拷文件可能缺最近一段数据,而且**不报错**。 +> - **为什么归档里要带密钥?** 不带的话,恢复出来的库里所有账号的 Cookie 都会 +> 变成「无法解密」,等于把大家的凭证弄丢。所以默认带上;不想带就在恢复时选「否」。 +> - **备份留在同一台机器上算备份吗?** 只算一半 —— 它防的是「改错了」, +> 防不了「机器没了」。**请定期点「下载」把归档拿到别的地方去**, +> 异地那一份是这套机制替不了你的部分。 +> - **磁盘占用怎么办?** 「保留份数」会自动清理。也可以手动点删除, +> 或跑 `python manage.py backups --prune --keep 5`。 + --- ## 十二、信息安全与隐私安全 diff --git a/docs/images/11-backups.png b/docs/images/11-backups.png new file mode 100644 index 0000000..57c9829 Binary files /dev/null and b/docs/images/11-backups.png differ diff --git a/manage.py b/manage.py index dbbcd40..c37414c 100644 --- a/manage.py +++ b/manage.py @@ -28,6 +28,9 @@ python manage.py passwd <用户名> --role admin 新建或提权为管理员 python manage.py status 查看各账号的调度与最近采集状态 python manage.py vacuum 整理数据库(checkpoint + VACUUM) + python manage.py backup 立即打一份备份(所有 *.sqlite + instance.json) + python manage.py backups 列出备份;--prune 按保留份数清理最旧的 + python manage.py restore <文件名> --yes 从备份恢复(恢复前会自动再备份一份当前库) """ import argparse import os @@ -35,7 +38,7 @@ import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from workbuddy_portal import client, collect, config, db, query, scheduler # noqa: E402 +from workbuddy_portal import backup, client, collect, config, db, query, scheduler # noqa: E402 from workbuddy_portal import security # noqa: E402 @@ -102,6 +105,19 @@ def cmd_init(args): _p(" 账号数:%d" % db.user_count(conn)) _p(" 主账号存档 %d 条 / %.2f 积分 / %d 个活跃日" % (n["records"], n["credits"], n["days"])) _p(" 操作账号:%s" % args.user) + # 随机口令只在「本次进程刚生成」时返回 —— 重跑 init 时管理员已存在,这里是 None。 + # 必须**明着打印**:库里只有散列,日志一旦滚掉就再也拿不回来了。 + pwd = db.generated_admin_password() + if pwd: + _p("") + _p(" ================= 管理员初始口令 =================") + _p(" 用 户 名:%s" % args.user) + _p(" 初 始 口 令:%s" % pwd) + _p("") + _p(" 这是随机生成的(1.4.0 起不再有 admin123 这类默认口令)。") + _p(" 程序只打印这一次、数据库里只存散列 —— 找不回来,") + _p(" 请立刻抄走,并在登录后到「个人设置」改掉。") + _p(" ==================================================") st = db.secret_state(conn, "cookie", uid) if st["broken"]: _p(" [warn] Cookie 密文无法解开(cookie_key 与写入时不一致),请登录后重新粘贴") @@ -119,8 +135,11 @@ def cmd_serve(args): return try: from waitress import serve - _p("生产模式(waitress)监听 http://%s:%d" % (host, port)) - serve(app, host=host, port=port, threads=8, ident="workbuddy-portal") + # 线程数是资源上限的一部分(见 config.THREADS 的注释): + # 它决定单实例能同时吃进几个慢请求(采集 / 导出 / 备份恢复)。 + _p("生产模式(waitress)监听 http://%s:%d,线程数 %d" + % (host, port, config.THREADS)) + serve(app, host=host, port=port, threads=config.THREADS, ident="workbuddy-portal") except ImportError: _p("[warn] 未安装 waitress,回退到 Flask 内置服务器(生产建议 pip install waitress)") app.run(host=host, port=port, threaded=True) @@ -474,6 +493,120 @@ def cmd_vacuum(args): return 0 +# ---------------- 备份 / 恢复 ---------------- +def cmd_backup(args): + """立即打一份备份。走 SQLite 在线备份 API,采集正在写也安全。""" + db.init_db(create_admin=False) + conn = db.connect() + try: + try: + r = backup.create(conn, trigger="cli", actor="cli", note=args.note or "") + except backup.BackupError as e: + _p("[error] %s" % e) + return 2 + _p(r["message"]) + _p(" 归档:%s" % backup.path_of(r["filename"])) + _p(" 大小:%s" % backup.human(r["bytes"])) + _p(" 校验:sha256 %s…" % r["sha256"][:16]) + _p(" 内容:%d 条记录 / %.2f 积分 / %d 个账号 / 库结构 uv=%s" + % (r["stats"]["records"], r["stats"]["credits"], + r["stats"]["users"], r["stats"]["schema_ver"])) + keep = db.get_int(conn, "backup_keep", 7) + removed = backup.prune(conn, keep=keep, actor="cli") + if removed: + _p(" 已按「保留 %d 份」清理 %d 份最旧的:%s" + % (keep, len(removed), ", ".join(removed))) + _p("") + _p("提示:归档里含 instance.json(SECRET_KEY 与 cookie_key),") + _p(" 权限等同管理员口令 —— 别随镜像 / 仓库分发,也别放进公开网盘。") + finally: + conn.close() + return 0 + + +def cmd_backups(args): + """列出备份(可选清理)。磁盘是事实来源,每次先重建索引。""" + db.init_db(create_admin=False) + conn = db.connect() + try: + n = backup.sync_index(conn) + rows = backup.listing(conn) + _p("备份目录:%s" % backup.backup_dir()) + if not rows: + _p("磁盘上还没有任何归档(%d 份 zip)。" % n) + _p("跑 manage.py backup 打一份;容器里这个目录挂的是独立的 wb_backups 卷。") + return 0 + _p("磁盘 %d 份 · 合计 %s" % (n, backup.human(backup.total_bytes(conn)))) + _p("") + _p("%-3s %-30s %10s %7s %11s %5s %-11s %s" + % ("#", "文件名", "大小", "条数", "积分", "账号", "来源", "生成时间")) + for i, r in enumerate(rows, 1): + _p("%-3d %-30s %10s %7d %11.2f %5d %-11s %s%s" + % (i, r["filename"], r["size_h"], r["records"], r["credits"], + r["users"], r["trigger"] or "-", r["created_at"], + "" if r["exists"] else " [文件已不存在]")) + enabled = db.get_bool(conn, "backup_enabled", True) + every = db.get_int(conn, "backup_interval_hours", 24) + keep = db.get_int(conn, "backup_keep", 7) + nxt = backup.next_auto_at(conn) + tail = (",下次约 %s" % nxt.strftime("%Y-%m-%d %H:%M")) if nxt else "" + _p("") + _p("自动备份:%s(周期 %d 小时,保留 %d 份)%s" + % ("启用" if enabled else "停用", every, keep, tail)) + if args.prune: + want = args.keep or keep + removed = backup.prune(conn, keep=want, actor="cli") + _p("已按保留 %d 份清理 %d 份:%s" + % (want, len(removed), ", ".join(removed) if removed else "无(本来就不够多)")) + finally: + conn.close() + return 0 + + +def cmd_restore(args): + """从归档恢复。**破坏性操作**:必须显式加 --yes。""" + db.init_db(create_admin=False) + conn = db.connect() + try: + try: + info = backup.verify(backup.path_of(args.filename)) + except backup.BackupError as e: + _p("[error] %s" % e) + return 2 + cn, cc = conn.execute("SELECT COUNT(*), COALESCE(SUM(credits),0)" + " FROM usage_records").fetchone() + cu = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] + _p("待恢复:%s" % args.filename) + _p(" 归档生成于 %s(程序 v%s,库结构 uv=%d)" + % (info["created_at"], info["version"], info["schema_ver"])) + _p(" 归档内容:%d 条 / %.2f 积分 / %d 个账号" + % (info["records"], info["credits"], info["users"])) + _p(" 当前正本:%d 条 / %.2f 积分 / %d 个账号" % (cn, cc, cu)) + if not args.yes: + _p("") + _p("这会**整表替换** usage_records / settings / users / collect_runs / " + "audit_log / captchas,") + _p("并且所有既有登录会话会立即失效(所有人需要重新登录)。") + _p("恢复前系统会自动把当前库另存一份备份,所以恢复错了还能回来。") + _p("") + _p("确认无误后,重跑并加上 --yes。") + return 1 + try: + r = backup.restore(conn, args.filename, + include_instance=not args.no_instance, actor="cli") + except backup.BackupError as e: + _p("[error] %s" % e) + return 2 + _p(r["message"]) + _p(" 搬运的表:%s" % ", ".join(r["moved"])) + _p(" instance.json:%s" + % ("已一并恢复(cookie_key 换成了归档里那把)" if r["restored_instance"] + else "未动(保留本机当前的密钥)")) + finally: + conn.close() + return 0 + + def _human(n): for unit in ("B", "KB", "MB", "GB"): if n < 1024 or unit == "GB": @@ -552,6 +685,22 @@ def main(): s = sub.add_parser("vacuum", help="整理数据库(checkpoint + VACUUM)") s.set_defaults(func=cmd_vacuum) + s = sub.add_parser("backup", help="立即打一份备份(所有 *.sqlite + instance.json)") + s.add_argument("--note", default="", help="给这份备份写一句备注(记进 manifest 与审计)") + s.set_defaults(func=cmd_backup) + + s = sub.add_parser("backups", help="列出备份;--prune 按保留份数清理最旧的") + s.add_argument("--prune", action="store_true", help="顺便清理超出保留份数的旧备份") + s.add_argument("--keep", type=int, default=None, help="保留几份(默认取配置里的值)") + s.set_defaults(func=cmd_backups) + + s = sub.add_parser("restore", help="从备份恢复(破坏性操作,必须加 --yes)") + s.add_argument("filename", help="备份文件名(如 usage-20260916-151043.zip,用 backups 查看)") + s.add_argument("--no-instance", action="store_true", + help="不恢复 instance.json(保留本机当前的 secret_key / cookie_key)") + s.add_argument("--yes", action="store_true", help="确认执行(不加只打印将要发生什么)") + s.set_defaults(func=cmd_restore) + args = ap.parse_args() if not getattr(args, "func", None): ap.print_help() diff --git a/tools/shots.py b/tools/shots.py index c7dd73d..30eaeaf 100644 --- a/tools/shots.py +++ b/tools/shots.py @@ -47,6 +47,7 @@ CAPTURES = [ ("06-users.png", "/users", "用户管理", True), ("07-dashboard.png", "/dashboard", "用量大屏", True), ("10-profile.png", "/profile", "个人中心", True), + ("11-backups.png", "/backups", "备份管理(仅管理员)", True), ] # v1.3.0 起「任务管理 / 配置管理」在普通账号下是**只读**形态, @@ -237,7 +238,9 @@ def main() -> int: for name, path, label in USER_CAPTURES: shoot(name, path, label, pg=page2) # 顺带把越权面再验一次:普通账号访问这些必须不是 200 - for probe in ("/logs", "/logs/tail", "/users"): + # /backups 与 /api/backups 是 v1.4.0 新增的:能下载或恢复整个数据库 + # 等于对全库数据有完整读写权,所以必须只给管理员。 + for probe in ("/logs", "/logs/tail", "/users", "/backups", "/api/backups"): r = page2.goto(a.base + probe, wait_until="domcontentloaded") code = r.status if r else 0 ok = code in (403, 401) diff --git a/tools/smoke.py b/tools/smoke.py index 515d864..d781edb 100644 --- a/tools/smoke.py +++ b/tools/smoke.py @@ -97,6 +97,11 @@ def run() -> None: from workbuddy_portal import captcha, config, create_app, crypto, db, query, security print("== 0. 构建应用 ==") + # 先把真实库迁到与代码同版本。init_db 是幂等的,顺带把迁移路径也验一遍。 + # 少了这一步,「库还停在旧 schema」会被 current_user() 判成「未登录」, + # 现场表现是下面所有页面断言集体变 302 —— 看起来像产品坏了, + # 其实只是前置条件没满足。 + db.init_db() app = create_app(start_scheduler=False, do_init_db=False) app.config["WTF_CSRF_ENABLED"] = False n_routes = len([r for r in app.url_map.iter_rules()]) @@ -105,6 +110,9 @@ def run() -> None: # 真实库里的账号:管理员必须有,普通账号按需临时造 conn = db.connect() + uv = conn.execute("PRAGMA user_version").fetchone()[0] + chk("库 schema 已与代码同版本", uv == db.DB_SCHEMA_VERSION, + "uv=%d 期望=%d" % (uv, db.DB_SCHEMA_VERSION)) admin_row = conn.execute("SELECT id FROM users WHERE is_admin=1 AND status='active'" " ORDER BY id LIMIT 1").fetchone() if admin_row is None: @@ -673,6 +681,99 @@ def run() -> None: missing = sorted(c for c in used - css_classes - allow) chk("无「用了但 CSS 里不存在」的类名", not missing, "缺失=%s" % missing if missing else "") + # ---------------- 8. 备份 ---------------- + # 这一节盯三件事: + # ① `safe_name` 是下载/恢复接口**唯一**吃文件名的收口点。漏检就是任意文件 + # 读取 —— `../../data/instance.json` 能直接把主密钥拿走; + # ② 归档里必须同时有 manifest / 主库 / instance.json。少了 instance.json, + # settings 里的凭证密文就永远解不开了(cookie_key 在里面); + # ③ 备份是管理员专属能力,普通账号连列表都不能看。 + # 「破坏 → 恢复 → 比对」的往返**不在这里做**:它要整库替换,不适合对真实库 + # 执行,由隔离环境里的专项验证覆盖(见 docs/DEPLOYMENT.md 8.3)。 + print("== 8. 备份:路径收口 / 归档完整 / 越权面 ==") + import shutil as _sh + import tempfile as _tf + import zipfile as _zf + from workbuddy_portal import backup + + for bad in ("../../etc/passwd", "x.txt", "", "..", "a b.zip", "a.zip/../../x.zip"): + try: + got = backup.safe_name(bad) + # `a.zip/../../x.zip` 这类会被 basename 收敛成合法的 `x.zip`。 + # 收敛不算漏检,但结果里**必须**不含任何路径成分。 + bad_ok = bool(got) and got == os.path.basename(got) \ + and "/" not in got and "\\" not in got and ".." not in got + except backup.BackupError: + bad_ok = True + chk("⑧ safe_name 收口 %r" % bad, bad_ok) + + real_dir = config.BACKUP_DIR + tmp_bk = _tf.mkdtemp(prefix="wb-smoke-bk-") + # 第 5 节末尾把 conn 关掉了(那是它自己的收尾动作),这里重新拿一个独立的。 + bconn = db.connect() + seen_before = {r["filename"] for r in bconn.execute("SELECT filename FROM backups")} + try: + config.BACKUP_DIR = tmp_bk # 别把真实 backups/ 搅乱 + res = backup.create(bconn, "manual", "smoke", "smoke 断言") + ap = backup.path_of(res["filename"]) + chk("⑧ 备份生成成功", bool(res["ok"]) and os.path.exists(ap), res["message"]) + with _zf.ZipFile(ap) as z: + names = set(z.namelist()) + chk("⑧ 归档含 manifest.json", backup.MANIFEST in names) + chk("⑧ 归档含主库快照", os.path.basename(config.SQLITE_PATH) in names) + chk("⑧ 归档含 instance.json(否则 cookie_key 丢失)", + backup.INSTANCE_NAME in names) + + v = backup.verify(ap) + chk("⑧ verify 通过", v["integrity"] == "ok", + "uv=%s 条数=%s 账号=%s" % (v["schema_ver"], v["records"], v["users"])) + chk("⑧ verify 报的条数与库一致", + v["records"] == bconn.execute("SELECT COUNT(*) FROM usage_records").fetchone()[0], + "records=%s" % v["records"]) + + backup.create(bconn, "manual", "smoke", "第二份") + backup.sync_index(bconn) + backup.prune(bconn, keep=1, actor="smoke") + left = [f for f in os.listdir(tmp_bk) if f.endswith(backup.SUFFIX)] + chk("⑧ prune keep=1 后只剩 1 份", len(left) == 1, "剩=%d" % len(left)) + + tmp_user = "smoke_b_%s" % _rand() + bconn.execute("INSERT INTO users(username,password_hash,display_name,is_admin," + "status,created_at) VALUES(?,?,'备份越权探针',0,'active',?)", + (tmp_user, security.hash_password("Smoke-Pass1"), db.now_str())) + bid = bconn.execute("SELECT id FROM users WHERE username=?", (tmp_user,)).fetchone()["id"] + try: + with app.test_client() as cb: + login(cb, bid) + for p in ("/backups", "/api/backups"): + st, _ = page(cb, p) + chk("⑧ 普通账号 GET %s 被拒" % p, st in (403, 401), "status=%s" % st) + # 导出是 zip,不能走 page()(它按文本解码,会炸在二进制上) + er = cb.get("/profile/export") + body = er.get_data() + try: + with _zf.ZipFile(io.BytesIO(body)) as z: + zn = z.namelist() + except Exception: # noqa: BLE001 + zn = [] + chk("⑧ 普通账号可导出本人数据(zip)", + er.status_code == 200 and len(zn) >= 4, + "状态=%s 条目=%s" % (er.status_code, zn)) + chk("⑧ 导出包里没有 cookie 明文", + not any("cookie" in n.lower() for n in zn)) + finally: + bconn.execute("DELETE FROM users WHERE id=?", (bid,)) + finally: + config.BACKUP_DIR = real_dir + _sh.rmtree(tmp_bk, ignore_errors=True) + # 归档文件已经随临时目录没了,登记行留着就是脏数据(列表里会显示「已丢失」) + fresh = [r["filename"] for r in bconn.execute("SELECT filename FROM backups") + if r["filename"] not in seen_before] + for n in fresh: + bconn.execute("DELETE FROM backups WHERE filename=?", (n,)) + bconn.commit() + bconn.close() + def main() -> int: print("工程目录:%s\n" % BASE) diff --git a/workbuddy_portal/__init__.py b/workbuddy_portal/__init__.py index dce1540..86f81d4 100644 --- a/workbuddy_portal/__init__.py +++ b/workbuddy_portal/__init__.py @@ -25,7 +25,7 @@ from flask import Flask, jsonify, render_template, request from . import config, db, security -__version__ = "1.3.0" +__version__ = "1.4.0" PROJECT_NAME = config.PROJECT_NAME diff --git a/workbuddy_portal/backup.py b/workbuddy_portal/backup.py new file mode 100644 index 0000000..8bf049b --- /dev/null +++ b/workbuddy_portal/backup.py @@ -0,0 +1,637 @@ +# -*- coding: utf-8 -*- +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Wang Chuanli + +"""备份管理:一致性快照、自动周期、保留份数、下载与恢复。 + +为什么需要它 +------------ +原来只有「手工把 data/usage.sqlite 拷一份」这一条路,问题有三个: + * 直接 cp 一个 WAL 库拿到的**不是**一致快照(-wal 里可能还有没落盘的帧) + * 备份躺在数据卷里,`docker compose down -v` 会把正本与副本一起删掉 + * 恢复没有任何护栏:覆盖上去就完了,恢复错了也没有退路 + +本模块的三条设计决定 +-------------------- +1. **快照用 SQLite 在线备份 API**(`Connection.backup`),不是文件拷贝。 + 它按页复制并在复制期间持有读事务,所以采集正在写的时候拿到的也是 + 一个「某一时刻的完整库」。手工 cp 做不到这一点。 +2. **归档是一个 zip**,内含所有 `*.sqlite` + `manifest.json`(+ 可选 + `instance.json`)。好处是单文件下载、可校验、可跨机器搬到别处恢复; + 而 `instance.json` 在里面是必要的 —— 没有 cookie_key 就解不开 + settings 里的凭证密文,那样的「恢复」等于把所有人的 Cookie 弄丢。 + 代价是归档本身含密钥,所以它**永不入库、永不进镜像**(见 .gitignore / + .dockerignore 与 docs/DEPLOYMENT.md)。 +3. **恢复走 SQL 级替换,不做文件 swap**。把归档解出来建一个临时库、先迁移 + 到当前 schema,然后在**一个写事务**里整表搬过去。这样: + * 不需要停机、不需要保证没有别的连接持有文件句柄(Windows 上文件 + swap 会因句柄占用直接失败); + * 备份是老版本(user_version=2)也能恢复,迁移在临时库里先做完; + * 中途失败就是一个事务回滚,不会留下半个库。 + +恢复前的护栏:先给**当前**库自动打一份 `pre-restore` 快照。恢复错了还能回去。 + +恢复后还会做一件事:把所有账号的 `session_ver` 都 +1,于是**所有既有登录会话 +立即失效**。理由见 `restore()` 里的注释 —— 归档里的 sv 可能与旧 Cookie 恰好 +相等,那样会话会带着「一整套已被替换掉的账号与权限」继续用下去。 +""" +import hashlib +import json +import logging +import os +import shutil +import sqlite3 +import tempfile +import zipfile +from datetime import datetime, timedelta + +from . import collect, config, db + +log = logging.getLogger("wb.backup") + +SUFFIX = ".zip" +MANIFEST = "manifest.json" +INSTANCE_NAME = "instance.json" +FORMAT_VERSION = 1 + +# 恢复时整表搬运的表清单。 +# 刻意**不含 backups 自己**:它记的是「本机备份目录里有什么」, +# 属于当前实例的运行索引,拿旧库里的那份覆盖会凭空丢掉期间新增的条目 +# (而按需重建索引是幂等的,见 sync_index)。 +RESTORE_TABLES = ("users", "settings", "usage_records", + "collect_runs", "audit_log", "captchas") + + +class BackupError(Exception): + """备份/恢复的业务性失败(归档损坏、文件缺失、跨度不符等)。""" + + +# ---------------- 路径与文件名 ---------------- +def backup_dir(): + config.ensure_dirs() + return config.BACKUP_DIR + + +def safe_name(name): + """把用户传来的文件名收敛成「备份目录下的一个 zip」。 + + 必须防住 `../../etc/passwd`、绝对路径、`sub/..` 这类穿越写法 —— + 下载与恢复接口都直接吃文件名,这里是唯一的收口点。 + """ + base = os.path.basename(str(name or "").strip().replace("\\", "/")) + if not base or base in (".", "..") or not base.endswith(SUFFIX): + raise BackupError("备份文件名不合法") + if any(c not in "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ._-" + for c in base): + raise BackupError("备份文件名含非法字符") + return base + + +def path_of(name): + return os.path.join(backup_dir(), safe_name(name)) + + +def human(n): + n = float(n or 0) + for unit in ("B", "KB", "MB", "GB"): + if n < 1024 or unit == "GB": + return ("%d B" % n) if unit == "B" else ("%.1f %s" % (n, unit)) + n /= 1024.0 + + +def _app_version(): + from . import __version__ # 延迟导入,避开包初始化顺序 + return __version__ + + +# ---------------- 元数据 ---------------- +def _db_files(): + """数据目录下所有 SQLite 库(不含 -wal / -shm 侧车)。""" + out = [] + d = config.DATA_DIR + if not os.path.isdir(d): + return out + for fn in sorted(os.listdir(d)): + if not fn.endswith(".sqlite"): + continue + p = os.path.join(d, fn) + if os.path.isfile(p): + out.append(p) + return out + + +def _sha256(path): + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +def _snapshot(src_path, dst_path): + """用在线备份 API 生成一致快照(src 有并发写也安全)。""" + src = sqlite3.connect(src_path, timeout=8.0) + try: + dst = sqlite3.connect(dst_path) + try: + src.backup(dst) + finally: + dst.close() + finally: + src.close() + + +def _stats(): + """归档时的关键计数,用于「挑一份恢复」与事后核对。""" + p = config.SQLITE_PATH + if not os.path.exists(p): + return {"records": 0, "credits": 0.0, "users": 0, "schema_ver": 0} + conn = sqlite3.connect("file:%s?mode=ro" % p.replace("\\", "/"), uri=True) + try: + n, cr = conn.execute("SELECT COUNT(*), COALESCE(SUM(credits),0)" + " FROM usage_records").fetchone() + u = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] + uv = conn.execute("PRAGMA user_version").fetchone()[0] + return {"records": n, "credits": round(cr or 0, 2), "users": u, "schema_ver": uv} + finally: + conn.close() + + +def _extract_safely(zf, dest, names): + """只按**基名**解压到 dest —— 归档可能是别人给的,挡掉 zip slip。 + + `ZipFile.extractall` 会因为条目名里的 `..` / 绝对路径写到目录之外, + 而恢复接口正好是「吃一个外部文件」的入口,必须在这一层挡住。 + """ + for n in names: + base = os.path.basename(n.replace("\\", "/")) + if not base or base in (".", ".."): + continue + with zf.open(n) as fsrc, open(os.path.join(dest, base), "wb") as fdst: + shutil.copyfileobj(fsrc, fdst) + + +# ---------------- 生成备份 ---------------- +def create(conn, trigger="manual", actor=None, note=""): + """打一份新备份,返回结果 dict。失败抛 BackupError。""" + dbs = _db_files() + if not dbs: + raise BackupError("数据目录里没有找到任何 *.sqlite,没什么可备份的") + + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + name = "usage-%s%s" % (stamp, SUFFIX) + dest = os.path.join(backup_dir(), name) + seq = 1 + while os.path.exists(dest): # 同一秒内连打两次也不能互相覆盖 + name = "usage-%s-%d%s" % (stamp, seq, SUFFIX) + dest = os.path.join(backup_dir(), name) + seq += 1 + + st = _stats() + tmpdir = tempfile.mkdtemp(prefix="wb-backup-") + files_meta = [] + try: + for i, src in enumerate(dbs): + fn = os.path.basename(src) + # 主库用在线备份 API;其余库(当前没有,但口径要留全)同理 + snap = os.path.join(tmpdir, fn) + _snapshot(src, snap) + files_meta.append({"name": fn, "bytes": os.path.getsize(snap), + "sha256": _sha256(snap), "primary": src == config.SQLITE_PATH}) + inst = config.INSTANCE_FILE + if os.path.exists(inst): + shutil.copy2(inst, os.path.join(tmpdir, INSTANCE_NAME)) + files_meta.append({"name": INSTANCE_NAME, + "bytes": os.path.getsize(os.path.join(tmpdir, INSTANCE_NAME)), + "sha256": _sha256(os.path.join(tmpdir, INSTANCE_NAME)), + "secret": True}) + manifest = { + "format": FORMAT_VERSION, + "app": config.PROJECT_NAME, + "version": _app_version(), + "created_at": db.now_str(), + "trigger": trigger, + "actor": actor or "", + "note": note or "", + "stats": st, + "files": files_meta, + "restore_note": ("恢复会整表替换 usage_records / settings / users 等;" + "恢复前系统会自动先备份当前库。"), + } + with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr(MANIFEST, json.dumps(manifest, ensure_ascii=False, indent=2)) + for fn in [f["name"] for f in files_meta]: + z.write(os.path.join(tmpdir, fn), fn) + except Exception: + try: + os.remove(dest) # 半成品不留,否则「列表里有一份打不开的备份」 + except OSError: + pass + raise + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + size = os.path.getsize(dest) + digest = _sha256(dest) + db.audit(conn, "backup_create", actor or "system", + "生成备份 %s(%s,%d 条 / %.2f 积分)" % (name, human(size), st["records"], st["credits"]), + "127.0.0.1", 0) + return {"ok": True, "filename": name, "bytes": size, "sha256": digest, + "trigger": trigger, "stats": st, + "message": "已生成备份 %s(%s,%d 条记录)" % (name, human(size), st["records"])} + + +# ---------------- 索引(表 <- 磁盘) ---------------- +def _row_exists(conn, filename): + return conn.execute("SELECT 1 FROM backups WHERE filename=?", (filename,)).fetchone() is not None + + +def sync_index(conn): + """把磁盘上的归档登记进 backups 表,并把消失的标记 missing=1。 + + 手工拷进来 / 手工删掉的备份都能因此被正确呈现 —— 索引只是缓存, + **磁盘才是事实来源**,所以这里做双向对齐而不是只信表。 + """ + files = [f for f in os.listdir(backup_dir()) if f.endswith(SUFFIX)] + for fn in files: + if _row_exists(conn, fn): + continue + p = os.path.join(backup_dir(), fn) + meta = {"records": 0, "credits": 0.0, "users": 0, "schema_ver": 0} + created = "" + # 归档的 manifest 里记着它的真实来源(cli / manual / auto / pre-restore)。 + # 直接用它的,不要一律标成 external —— 恢复前最需要判断的恰恰是 + # 「这份是自动备份、还是我手工留的、还是恢复前系统自动存的那一份」。 + trig = "external" + actor = None + note = "从磁盘发现" + try: + info = read_manifest(p) + meta = dict(meta, **info.get("stats", {})) + created = info.get("created_at") or "" + trig = (info.get("trigger") or "").strip() or "external" + actor = (info.get("actor") or "").strip() or None + note = (info.get("note") or "").strip() or note + except BackupError: + pass + if not created: + # 读不出 manifest 就用文件时间,至少让排序有意义 + try: + created = datetime.fromtimestamp(os.path.getmtime(p)).strftime("%Y-%m-%d %H:%M:%S") + except OSError: + created = db.now_str() + conn.execute( + "INSERT OR IGNORE INTO backups(filename,bytes,sha256,created_at,trigger,actor," + " schema_ver,records,credits,users,note,missing)" + " VALUES(?,?,?,?,?,?,?,?,?,?,?,0)", + (fn, os.path.getsize(p), "", created, trig, actor, + meta.get("schema_ver", 0), meta.get("records", 0), + meta.get("credits", 0.0), meta.get("users", 0), note)) + # 磁盘上没了 -> 标记,不删行:保留「这里曾经有过一份」的记录更利于追责 + for r in conn.execute("SELECT id,filename,missing FROM backups").fetchall(): + gone = not os.path.exists(os.path.join(backup_dir(), r["filename"])) + want = 1 if gone else 0 + if r["missing"] != want: + conn.execute("UPDATE backups SET missing=? WHERE id=?", (want, r["id"])) + return len(files) + + +def listing(conn): + """备份清单(新→旧),附磁盘实际大小。""" + out = [] + for r in conn.execute("SELECT * FROM backups ORDER BY created_at DESC, id DESC"): + p = os.path.join(backup_dir(), r["filename"]) + exists = os.path.exists(p) + d = dict(r) + d["exists"] = exists + if exists: + d["bytes"] = os.path.getsize(p) + d["size_h"] = human(d["bytes"]) + out.append(d) + return out + + +def total_bytes(conn): + return conn.execute("SELECT COALESCE(SUM(bytes),0) FROM backups WHERE missing=0").fetchone()[0] + + +# ---------------- 校验 ---------------- +def _name_map(zf): + """归档条目名 -> 真实条目名(按基名索引,容忍归档里带目录前缀)。""" + m = {} + for n in zf.namelist(): + b = os.path.basename(n.replace("\\", "/")) + if b: + m[b] = n + return m + + +def read_manifest(path): + try: + with zipfile.ZipFile(path) as z: + entry = _name_map(z).get(MANIFEST) + if entry is None: + raise BackupError("归档里没有 %s,不是本程序生成的备份" % MANIFEST) + return json.loads(z.read(entry).decode("utf-8")) + except (zipfile.BadZipFile, KeyError, ValueError, OSError) as e: + raise BackupError("归档无法解析(%s):%s" % (os.path.basename(path), e)) + + +def verify(path): + """校验一份归档是否可用于恢复(不修改任何东西)。""" + if not os.path.exists(path): + raise BackupError("备份文件不存在或已被删除") + man = read_manifest(path) + if int(man.get("format") or 0) > FORMAT_VERSION: + raise BackupError("归档格式版本 %s 高于本程序支持的 %s,请先升级程序" + % (man.get("format"), FORMAT_VERSION)) + files = man.get("files") or [] + primary = [f for f in files if f.get("primary")] or (files[:1] if files else []) + if not primary: + raise BackupError("归档里没有数据库文件") + + tmpdir = tempfile.mkdtemp(prefix="wb-verify-") + try: + with zipfile.ZipFile(path) as z: + nm = _name_map(z) + for f in files: + fn = os.path.basename(str(f.get("name") or "")) + if not fn: + raise BackupError("归档条目名不合法:%s" % f.get("name")) + if fn not in nm: + raise BackupError("归档缺少文件:%s" % f.get("name")) + _extract_safely(z, tmpdir, [nm[fn]]) + db_path = os.path.join(tmpdir, os.path.basename(primary[0]["name"])) + if not os.path.exists(db_path): + raise BackupError("归档里的主数据库文件解不出来") + conn = sqlite3.connect("file:%s?mode=ro" % db_path.replace("\\", "/"), uri=True) + try: + integ = conn.execute("PRAGMA integrity_check").fetchone()[0] + uv = conn.execute("PRAGMA user_version").fetchone()[0] + n, cr = conn.execute("SELECT COUNT(*), COALESCE(SUM(credits),0)" + " FROM usage_records").fetchone() + users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] + finally: + conn.close() + except sqlite3.Error as e: + raise BackupError("归档里的数据库打不开:%s" % e) + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + if integ != "ok": + raise BackupError("归档里的数据库完整性检查未通过:%s" % integ) + if uv > db.DB_SCHEMA_VERSION: + raise BackupError("归档的库结构版本 %d 比当前程序(%d)还新,无法恢复" + % (uv, db.DB_SCHEMA_VERSION)) + return {"manifest": man, "integrity": integ, "schema_ver": uv, + "records": n, "credits": round(cr or 0, 2), "users": users, + "created_at": man.get("created_at") or "", + "version": man.get("version") or ""} + + +# ---------------- 恢复 ---------------- +def _table_cols(conn, db_name, table): + return [r["name"] for r in conn.execute("PRAGMA %s.table_info(%s)" % (db_name, table))] + + +def _copy_tables(conn, src_path): + """把 src 库里的业务表整表搬到主库,一个事务内完成。 + + 用**列名交集**而不是 `SELECT *` 对齐:老库 ALTER 出来的列顺序与新库 + 建表语句的列顺序不一定一致(ALTER 追加在末尾),`SELECT *` 会静默 + 错位 —— 那是最难查的一类数据损坏(字段整体串位,值都「合法」)。 + """ + conn.execute("ATTACH DATABASE ? AS src", (src_path,)) + try: + plan = [] + for t in RESTORE_TABLES: + dst_cols = _table_cols(conn, "main", t) + src_cols = _table_cols(conn, "src", t) + if not src_cols: + continue # 归档里没有这张表(更老的版本) + common = [c for c in dst_cols if c in src_cols] + if not common: + continue + plan.append((t, common)) + conn.execute("BEGIN IMMEDIATE") + try: + for t, cols in plan: + cl = ",".join('"%s"' % c for c in cols) + conn.execute("DELETE FROM main.%s" % t) + conn.execute("INSERT INTO main.%s(%s) SELECT %s FROM src.%s" + % (t, cl, cl, t)) + conn.execute("COMMIT") + except Exception: + conn.execute("ROLLBACK") + raise + return [t for t, _ in plan] + finally: + conn.execute("DETACH DATABASE src") + + +def _restore_instance(src_file): + """把归档里的 instance.json 覆盖回来(含 cookie_key / secret_key)。 + + 必须先留一份当前文件:直接覆盖会让「本来就正常的那把钥匙」消失, + 而以旧钥匙加密不了新数据 —— 那才是真正不可逆的一步。 + """ + dst = config.INSTANCE_FILE + if os.path.exists(dst): + bak = "%s.pre-restore-%s" % (dst, datetime.now().strftime("%Y%m%d%H%M%S")) + try: + shutil.copy2(dst, bak) + except OSError as e: + raise BackupError("备份 instance.json 失败,已中止:%s" % e) + try: + shutil.copy2(src_file, dst) + if os.name == "posix": + os.chmod(dst, 0o600) + except OSError as e: + raise BackupError("写入 instance.json 失败:%s" % e) + return True + + +def restore(conn, filename, include_instance=True, actor=None): + """从归档恢复。整表替换,恢复前自动给当前库留一份 pre-restore 快照。 + + 副作用:所有账号的 `session_ver` 会被 +1,**所有既有登录会话立即失效** + (恢复是全局性事件,旧会话描述的账号与权限可能已经被换掉了)。 + + 返回结果 dict;失败抛 BackupError。 + """ + path = path_of(filename) + info = verify(path) # 先验,验不过就不动任何东西 + + # 1) 护栏:先把**当前**库完整备份一份。恢复错了还能回到恢复之前。 + try: + safety = create(conn, trigger="pre-restore", actor=actor or "system", + note="恢复 %s 之前的自动快照" % filename) + except Exception as e: # noqa: BLE001 + raise BackupError("恢复前的安全备份失败,已中止(不会动你的数据):%s" % e) + + tmpdir = tempfile.mkdtemp(prefix="wb-restore-") + try: + with zipfile.ZipFile(path) as z: + _extract_safely(z, tmpdir, z.namelist()) + primary = [f for f in info["manifest"]["files"] if f.get("primary")] + if not primary: + primary = [info["manifest"]["files"][0]] + src_path = os.path.join(tmpdir, os.path.basename(primary[0]["name"])) + if not os.path.exists(src_path): + raise BackupError("归档里缺少主数据库文件") + + # 2) 在临时库上先迁移到当前 schema —— 备份是旧版本(uv=2/3)也能恢复, + # 而且迁移失败时正本一个字节都没动。 + tmp_conn = sqlite3.connect(src_path) + try: + tmp_conn.row_factory = sqlite3.Row + db.init_db(conn=tmp_conn, create_admin=False) + finally: + tmp_conn.close() + # 迁移过程会把临时库带进 WAL 模式,侧车文件里可能还有未合并的帧。 + # 切回 DELETE 模式让**单文件自包含** —— 后面 ATTACH 时就不依赖 -wal 了。 + tmp_conn = sqlite3.connect(src_path) + try: + tmp_conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + tmp_conn.execute("PRAGMA journal_mode=DELETE") + finally: + tmp_conn.close() + + # 3) 搬数据。持采集锁:搬的过程中不能让采集往里写。 + with collect._Lock(): + moved = _copy_tables(conn, src_path) + # 数据整表换过了 —— 让**所有**会话立即失效。 + # + # 光靠「users.session_ver 从归档里搬过来」是不够的:如果某个人是在 + # 打这份备份**之前**登录的,他那张 Cookie 里的 sv 正好等于归档里的值, + # 于是会话会「合法地」活下来 —— 而它描述的账号、角色、权限可能已经 + # 被这次恢复整个替换过了。恢复是全局性事件,一律要求重新登录。 + conn.execute("UPDATE users SET session_ver=COALESCE(session_ver,0)+1") + inst = False + if include_instance: + inst_file = os.path.join(tmpdir, INSTANCE_NAME) + if os.path.exists(inst_file): + inst = _restore_instance(inst_file) + out = _stats() + out["moved"] = moved + out["instance"] = inst + except collect.Busy as e: + raise BackupError("有采集任务正在运行,请等它结束后再恢复(%s)" % e) + except sqlite3.Error as e: + raise BackupError("恢复过程中数据库报错,已回滚:%s" % e) + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + db.audit(conn, "backup_restore", actor or "system", + "从 %s 恢复:%d 条 / %.2f 积分 / %d 个账号%s(恢复前已自动备份 %s)" + % (filename, out["records"], out["credits"], out["users"], + ",含 instance.json" if out["instance"] else "", + safety.get("filename")), + "127.0.0.1", 0) + return { + "ok": True, "filename": filename, "safety_backup": safety.get("filename"), + "moved": out["moved"], "restored_instance": out["instance"], + "stats": out, "message": + "已从 %s 恢复:%d 条记录 / %.2f 积分 / %d 个账号。" + "恢复前的库已自动备份为 %s。所有既有登录会话已失效,请重新登录。" + % (filename, out["records"], out["credits"], out["users"], + safety.get("filename")), + } + + +# ---------------- 删除 / 清理 ---------------- +def delete(conn, filename, actor=None): + """删除一份备份(文件 + 索引行)。""" + name = safe_name(filename) + p = path_of(name) + if not os.path.exists(p): + conn.execute("UPDATE backups SET missing=1 WHERE filename=?", (name,)) + raise BackupError("备份文件不存在(可能已被手工删除)") + os.remove(p) + conn.execute("DELETE FROM backups WHERE filename=?", (name,)) + db.audit(conn, "backup_delete", actor or "system", "删除备份 %s" % name, "127.0.0.1", 0) + return {"ok": True, "filename": name, "message": "已删除备份 " + name} + + +def prune(conn, keep=None, actor=None): + """按「保留份数」清理最旧的备份。返回删除清单。""" + if keep is None: + keep = db.get_int(conn, "backup_keep", 7) + keep = max(1, min(100, int(keep or 1))) + rows = conn.execute("SELECT * FROM backups ORDER BY created_at DESC, id DESC").fetchall() + # 只按「磁盘上真实存在」的算份数:已经手工删掉的条目不该占名额 + alive = [r for r in rows if os.path.exists(os.path.join(backup_dir(), r["filename"]))] + victims = alive[keep:] + removed = [] + for r in victims: + try: + os.remove(os.path.join(backup_dir(), r["filename"])) + conn.execute("DELETE FROM backups WHERE id=?", (r["id"],)) + removed.append(r["filename"]) + except OSError as e: + log.warning("清理旧备份 %s 失败:%s", r["filename"], e) + if removed: + db.audit(conn, "backup_prune", actor or "system", + "按保留 %d 份清理旧备份:%s" % (keep, ", ".join(removed)), + "127.0.0.1", 0) + return removed + + +# ---------------- 自动备份 ---------------- +def last_auto_at(conn): + r = conn.execute("SELECT MAX(created_at) FROM backups WHERE trigger IN ('auto','startup')" + ).fetchone() + return (r[0] if r and r[0] else "") + + +def due(conn, now=None): + """自动备份是否到期。返回 (是否到期, 距离上次的小时数, 周期小时数)。""" + if not db.get_bool(conn, "backup_enabled", True): + return False, 0.0, 0 + every = max(1, min(720, db.get_int(conn, "backup_interval_hours", 24))) + last = last_auto_at(conn) + now = now or datetime.now() + if not last: + return True, -1.0, every + try: + dt = datetime.strptime(last, "%Y-%m-%d %H:%M:%S") + except ValueError: + return True, -1.0, every + hrs = (now - dt).total_seconds() / 3600.0 + return hrs >= every, hrs, every + + +def maybe_auto(conn, now=None): + """调度器每轮调用:到期就打一份 + 按份数清理。返回结果或 None。""" + ok, hrs, every = due(conn, now) + if not ok: + return None + try: + r = create(conn, trigger="auto", actor="system", + note="自动备份(周期 %d 小时)" % every) + except Exception as e: # noqa: BLE001 + log.error("自动备份失败:%s", e) + return None + removed = prune(conn, actor="system") + if removed: + log.info("自动备份后清理旧备份 %d 份", len(removed)) + log.info("自动备份完成:%s", r["message"]) + return r + + +def next_auto_at(conn, now=None): + """下一次自动备份时间(给界面显示)。""" + if not db.get_bool(conn, "backup_enabled", True): + return None + every = max(1, min(720, db.get_int(conn, "backup_interval_hours", 24))) + last = last_auto_at(conn) + now = now or datetime.now() + if not last: + return now + try: + dt = datetime.strptime(last, "%Y-%m-%d %H:%M:%S") + except ValueError: + return now + nxt = dt + timedelta(hours=every) + return nxt if nxt > now else now + diff --git a/workbuddy_portal/captcha.py b/workbuddy_portal/captcha.py index 0e65ee8..0b472b2 100644 --- a/workbuddy_portal/captcha.py +++ b/workbuddy_portal/captcha.py @@ -18,11 +18,20 @@ 字模 ---- -5×7 点阵,`#` 为前景。渲染时按整数倍放大并逐字符抖动, -再叠噪点与干扰线,普通 OCR 与「按色块切分」都会被破坏。 +5×7 点阵,`#` 为前景。渲染时**逐字符**随机放大、旋转、切变、加波浪偏移, +笔画用粗刷子画(旋转后不会出现断点),再叠背景纹理、噪点与压线干扰。 + +强度说明(为什么要这么多花样) +------------------------------ +字模是固定的,而且就在这份源码里 —— 也就是说攻击者**知道**每个字符长什么样。 +在这种情况下,提高自动识别成本的唯一手段就是让「同一字符的两次渲染」在像素上 +尽量不同:角度、切变、缩放、波形相位、笔画粗细、颜色、干扰线位置全部随机。 +纯模板匹配在这种变形下会失效,必须上带形变增强的模型,成本高一个数量级。 +反过来说,也**不要**指望它能挡住有充足算力、专门针对本站训练的对手 —— +验证码是「提高成本」而不是「杜绝」。 """ import hmac -import os +import math import random import secrets import struct @@ -140,54 +149,126 @@ class _Canvas: return bytes(self.buf) +def _brush_line(cv, x0, y0, x1, y1, color, r): + """用 r×r 方刷画一条线。 + + 旋转后的笔画如果只用点阵格逐个平移,会出现锯齿状断点 —— 一圈一圈的 + 缝隙正好给「连通域分析」留了把手。这里改成沿线段走样并盖方刷, + 笔画连续,旋转也不散架。 + """ + steps = int(max(abs(x1 - x0), abs(y1 - y0))) + 1 + o = r // 2 + for i in range(steps + 1): + t = i / float(steps) + x = int(round(x0 + (x1 - x0) * t)) + y = int(round(y0 + (y1 - y0) * t)) + cv.rect(x - o, y - o, r, r, color) + + +def _draw_char(cv, glyph, cx, cy, scale, color, rng): + """在 (cx, cy) 为中心画一个字符:随机旋转 + 切变 + 波浪 + 粗笔画。 + + 三段变换按「点阵坐标 -> 缩放居中 -> 切变 -> 旋转 -> 波浪纵向偏移」依次施加。 + 顺序不能乱:先切变再旋转,得到的才是「斜着写的手写体」而不是「被斜切的旋转体」。 + """ + ang = rng.uniform(-0.38, 0.38) # 弧度,约 ±22° + cos_a, sin_a = math.cos(ang), math.sin(ang) + shear = rng.uniform(-0.32, 0.32) + amp = rng.uniform(0.0, 2.6) # 波浪振幅(像素) + period = rng.uniform(18.0, 42.0) + phase = rng.uniform(0.0, 6.283) + half_w = GLYPH_W * scale / 2.0 + half_h = GLYPH_H * scale / 2.0 + r = max(2, scale) + + def place(col, row): + px = (col + 0.5) * scale - half_w + py = (row + 0.5) * scale - half_h + px += shear * py + x = cx + px * cos_a - py * sin_a + y = cy + px * sin_a + py * cos_a + return x, y + amp * math.sin(x / period + phase) + + for row, bits in enumerate(glyph): + col = 0 + while col < len(bits): + if bits[col] != "#": + col += 1 + continue + start = col + while col + 1 < len(bits) and bits[col + 1] == "#": + col += 1 # 连续的一段合起来画,笔画才连得上 + x0, y0 = place(start, row) + x1, y1 = place(col, row) + _brush_line(cv, x0, y0, x1, y1, color, r) + col += 1 + + def render(code, width=150, height=56, scale=5, rng=None): """把验证码渲染成 PNG 字节串。 - 刻意不做「清晰排版」而是加抖动/噪点/干扰线:这是防机器识别的核心, - 可读性靠放大字符(scale=5 即 25×35 像素)来补偿。 + 字体大小、角度、切变、波浪、颜色、干扰线全部逐次随机 —— + 目标不是「好看」,而是让同一串字符的两次渲染在像素上尽量不同, + 从而让「预存字模 + 模板匹配」这条最便宜的攻击路线失效。 """ rng = rng or random.SystemRandom() n = len(code) - gap = 7 - text_w = n * GLYPH_W * scale + (n - 1) * gap - if text_w + 16 > width: - width = text_w + 16 - x0 = max(4, (width - text_w) // 2) - y0 = max(3, (height - GLYPH_H * scale) // 2) + gap = 9 + # 宽度按最大可能字号算,且左右各留够旋转半径 —— + # 旋转后的字符会往两侧探出约半个字高,留窄了最外侧那个字会被裁掉一截, + # 而「被裁掉一角的字符」会直接变成一次没道理的输错(体验问题,不是安全问题)。 + text_w = n * GLYPH_W * (scale + 1) + (n - 1) * gap + need_w = text_w + int(GLYPH_H * (scale + 1) * 0.9) + 8 + if need_w > width: + width = need_w + # 高度同理:旋转后的字符比原始点阵高不少,切了顶就等于少一个笔画特征 + need_h = int(GLYPH_H * (scale + 1) * 1.7) + 8 + if need_h > height: + height = need_h + x0 = max(5, (width - text_w) // 2) + y0 = height // 2 - # 背景取浅色,前景取深色 —— 深色底+浅字在缩略图上更容易糊, - # 而且打印/截图后对比度更差。 - bg = tuple(rng.randint(238, 252) for _ in range(3)) - cv = _Canvas(width, height, bg) + # 背景不做纯色:纯色底可以用一个阈值把前景整片切出来。 + # 用「两色之间做斜向渐变」能让全局二值化的效果明显变差。 + c1 = tuple(rng.randint(236, 252) for _ in range(3)) + c2 = tuple(rng.randint(214, 240) for _ in range(3)) + slant = rng.uniform(-1.0, 1.0) + cv = _Canvas(width, height, c1) + for y in range(height): + for x in range(width): + t = (x / float(width - 1 or 1)) * 0.6 + (y / float(height - 1 or 1)) * 0.4 + t = min(1.0, max(0.0, t + slant * 0.15)) + cv.dot(x, y, tuple(int(c1[i] + (c2[i] - c1[i]) * t) for i in range(3))) - # 1) 干扰线(先画,压在字下面,不遮挡主体) - for _ in range(4): + # 1) 底层干扰线(先画,压在字下面) + for _ in range(3): cv.line(rng.randint(0, width - 1), rng.randint(0, height - 1), rng.randint(0, width - 1), rng.randint(0, height - 1), - tuple(rng.randint(150, 205) for _ in range(3))) + tuple(rng.randint(170, 215) for _ in range(3))) - # 2) 字符本体:逐字符随机取色 + 整数抖动,破坏固定网格切分 + # 2) 字符本体 + step = GLYPH_W * (scale + 1) + gap for i, ch in enumerate(code): glyph = _FONT.get(ch) if glyph is None: continue - color = tuple(rng.randint(20, 105) for _ in range(3)) - gx = x0 + i * (GLYPH_W * scale + gap) + rng.randint(-1, 1) - gy = y0 + rng.randint(-2, 2) - for row, bits in enumerate(glyph): - for col, bit in enumerate(bits): - if bit == "#": - cv.rect(gx + col * scale, gy + row * scale, scale, scale, color) + # 逐字符字号抖动:字符宽度不再一致,按列投影切分就失效了 + s = max(3, scale + rng.choice((-1, 0, 0, 1))) + cx = x0 + i * step + GLYPH_W * (scale + 1) / 2.0 + rng.uniform(-3.0, 3.0) + cy = y0 + rng.uniform(-3.0, 3.0) + color = tuple(rng.randint(15, 95) for _ in range(3)) + _draw_char(cv, glyph, cx, cy, s, color, rng) - # 3) 前景噪点:少量深色点会让「按连通域找字符」变得不可靠 - for _ in range(46): + # 3) 前景噪点:破坏「按连通域找字符」的假设 + for _ in range(70): cv.dot(rng.randint(0, width - 1), rng.randint(0, height - 1), - tuple(rng.randint(90, 190) for _ in range(3))) + tuple(rng.randint(90, 195) for _ in range(3))) - # 4) 压在字上的细斜线:这是最有效的反 OCR 手段,但别太密,否则人也认不出 - for _ in range(3): + # 4) 压在字上的干扰线:最有效的反 OCR 手段,但太密人也认不出, + # 所以刻意控制成 2~3 条细线。 + for _ in range(rng.randint(2, 3)): y = rng.randint(2, height - 3) - cv.line(0, y, width - 1, y + rng.randint(-9, 9), + cv.line(0, y, width - 1, y + rng.randint(-11, 11), tuple(rng.randint(120, 175) for _ in range(3))) return encode_png(width, height, cv.bytes()) diff --git a/workbuddy_portal/collect.py b/workbuddy_portal/collect.py index ebc2ae9..cb6afc2 100644 --- a/workbuddy_portal/collect.py +++ b/workbuddy_portal/collect.py @@ -205,6 +205,22 @@ def record_count(conn, uid): (uid or 0,)).fetchone()[0] +def max_range_days(conn): + """单次采集允许的最长跨度(天)。 + + 配置值是给管理员的旋钮,**代码层的硬顶**才是兜底:历史脏数据、直接改库、 + 或者某次误配置都不该让一次请求变成几千次云端调用。 + 接口校验、页面提示、sync() 里的实际收窄都读这一个函数,避免三处口径漂移。 + """ + n = db.get_int(conn, "collect_max_range_days", config.COLLECT_MAX_RANGE_DAYS_HARD) + return max(1, min(config.COLLECT_MAX_RANGE_DAYS_HARD, n)) + + +def min_interval_seconds(conn): + """同一账号两次手动采集之间的最小间隔(秒)。""" + return max(0, min(3600, db.get_int(conn, "collect_min_interval_seconds", 60))) + + def last_ts(conn, uid): return conn.execute("SELECT MAX(ts) FROM usage_records WHERE user_id=?", (uid or 0,)).fetchone()[0] @@ -285,6 +301,18 @@ def sync(conn, uid, trigger="manual", from_dt=None, to_dt=None, verify_days=None end = to_dt or now if start >= end: start = end - timedelta(minutes=rewind) + # 跨度上限(**代码层兜底**,不只是接口校验)。 + # 采集一次 = 对云端发 ceil(条数/page_size) 次请求,跨度越长请求越多。 + # 不设顶时,一个注册账号用 from=2000-01-01 就能让服务端替它打几千次云端, + # 同时独占全局采集锁与一个 waitress 线程 —— 最省力的资源耗尽方式。 + # 这里对「显式跨度」和「断点很旧导致的实际跨度」一视同仁地收窄。 + max_days = max_range_days(conn) + if end - start > timedelta(days=max_days): + original = start + start = end - timedelta(days=max_days) + _log("[warn] 请求跨度超过上限 %d 天,已自动收窄起点:%s -> %s" + % (max_days, original.strftime("%Y-%m-%d %H:%M:%S"), + start.strftime("%Y-%m-%d %H:%M:%S"))) _log("同步区间:%s ~ %s" % (start.strftime("%Y-%m-%d %H:%M:%S"), end.strftime("%Y-%m-%d %H:%M:%S"))) diff --git a/workbuddy_portal/config.py b/workbuddy_portal/config.py index 93f5a21..15b3c2b 100644 --- a/workbuddy_portal/config.py +++ b/workbuddy_portal/config.py @@ -28,6 +28,13 @@ EXPORT_DIR = os.path.join(DATA_DIR, "exports") APP_LOG = os.path.join(LOG_DIR, "app.log") INSTANCE_FILE = os.path.join(DATA_DIR, "instance.json") +# 备份落点。**刻意与 DATA_DIR 分开**: +# * 容器里 DATA_DIR 挂的是数据卷,`docker compose down -v` 会连卷一起删; +# 备份若躺在同一个卷里,就等于「正本与副本同时消失」—— 备份的意义没了。 +# * 备份里含 settings 的凭证密文与 users 的口令散列,必须能单独控制权限、 +# 单独挂卷、单独排除出镜像(见 .dockerignore 的 backups/)。 +BACKUP_DIR = os.environ.get("WB_BACKUP_DIR") or os.path.join(BASE_DIR, "backups") + # 旧版脚本项目的存档(迁移用;--migrate-csv 默认读这里)。 # v1.3.0 起旧版被收进工作区级的 legacy-v1/ 目录,所以第一个候选是新位置, # 后面两个保留以兼容「还没挪走」的部署。 @@ -58,6 +65,13 @@ DEFAULTS = { "schedule_times": "09:00,17:00", # 每天固定时刻(逗号分隔,本地时区) "catch_up": "1", # 启动时补跑当天已错过且未执行的槽位 "catch_up_grace_hours": "12", # 超过该小时数就不再补跑 + "max_schedule_slots_per_day": "6", # 每天最多几个时刻(挡住「填 200 个时刻」) + "collect_min_interval_seconds": "60", # 同一账号两次手动采集的最小间隔 + "collect_max_range_days": "31", # 单次采集的最长跨度(硬顶 31 天 = 1 个月) + # ---- 实例级:自动备份 ---- + "backup_enabled": "1", # 是否开启自动备份 + "backup_interval_hours": "24", # 备份周期(小时) + "backup_keep": "7", # 保留最近几份,超出的自动删除最旧的 # ---- 实例级:采集参数 ---- "page_size": "200", "rewind_minutes": "2", # 断点回退分钟数 @@ -91,6 +105,12 @@ GLOBAL_KEYS = { "allow_register", "register_max_per_ip", "captcha_policy", "captcha_length", # 采集调度(v1.3.0 起为实例级:普通用户只读,不能设置频率) "schedule_enabled", "schedule_times", "catch_up", "catch_up_grace_hours", + "max_schedule_slots_per_day", + # 任务频率与采集跨度(v1.4.0 起:对外提供服务时必须能限流, + # 否则一个注册账号就能拿 /api/collect 把云端与线程池打满) + "collect_min_interval_seconds", "collect_max_range_days", + # 自动备份(谁掌握备份谁就掌握全库数据,所以归管理员) + "backup_enabled", "backup_interval_hours", "backup_keep", # 采集参数(同理:允许普通用户调 page_size/关 ssl_verify 都是越权) "page_size", "rewind_minutes", "drift_tolerance_minutes", "max_prompt", "verify_days", "timeout", "ssl_verify", @@ -155,18 +175,33 @@ NUM_SETTINGS = { "catch_up_grace_hours": (1, 168, "小时"), "captcha_length": (4, 6, "个字符"), "register_max_per_ip": (1, 50, "个/天"), + "max_schedule_slots_per_day": (1, 12, "个/天"), + "collect_min_interval_seconds": (0, 3600, "秒"), + "collect_max_range_days": (1, 31, "天"), + "backup_interval_hours": (1, 720, "小时"), + "backup_keep": (1, 100, "份"), } -BOOL_SETTINGS = {"schedule_enabled", "catch_up", "allow_register"} +BOOL_SETTINGS = {"schedule_enabled", "catch_up", "allow_register", "backup_enabled"} + +# 采集跨度的**硬顶**:无论 settings 里被改成什么(含历史脏数据、手工改库), +# 代码层一律按这个上限夹一次。写进配置只是给管理员一个更严的旋钮, +# 不是「改大就能突破」——上限必须由代码兜底,不能只靠校验。 +COLLECT_MAX_RANGE_DAYS_HARD = 31 +# 每日调度时刻的硬顶(同上) +SCHEDULE_SLOTS_HARD_MAX = 12 _TRUE = ("1", "true", "yes", "on", "是", "启用") -def normalize_setting(key, raw): +def normalize_setting(key, raw, conn=None): """校验并规范化单个设置值。 返回 (value, error): * value 为可直接写入 settings 表的字符串;error 非空时 value 为 None。 * 未知键(不在 DEFAULTS 里)直接拒绝,避免接口被用来写任意键。 + * `conn` 可选:个别键的上限本身是可配置的(如每日时刻数受 + `max_schedule_slots_per_day` 约束),有连接时才查得到。 + 不传时只做代码层的硬顶校验,所以离线调用不会因此失败。 """ if key not in DEFAULTS: return None, "未知配置项:%s" % key @@ -196,6 +231,19 @@ def normalize_setting(key, raw): parsed = scheduler.parse_times(raw) if not parsed: return None, "每日时刻格式不对,正确写法如 09:00,17:00" + if len(parsed) > SCHEDULE_SLOTS_HARD_MAX: + return None, "每日时刻最多 %d 个(当前填了 %d 个)" % ( + SCHEDULE_SLOTS_HARD_MAX, len(parsed)) + # 可配置的更严上限:时刻数量直接决定调度器的采集频次, + # 是「一个账号能不能把云端与线程池打满」的开关,所以要有刹车。 + if conn is not None: + from . import db as _db + cap = _db.get_int(conn, "max_schedule_slots_per_day", 6) + cap = max(1, min(SCHEDULE_SLOTS_HARD_MAX, cap)) + if len(parsed) > cap: + return None, ("每日时刻最多 %d 个(当前填了 %d 个)。" + "如需更多,请先把「每日调度时刻上限」调大。" + % (cap, len(parsed))) return ",".join(parsed), None if key == "captcha_policy": @@ -208,8 +256,14 @@ def normalize_setting(key, raw): v = str(raw).strip() if not v: return None, "%s 不能为空" % key - if key == "api_base" and not v.startswith(("http://", "https://")): - return None, "接口基址需以 http:// 或 https:// 开头" + if key == "api_base": + if not v.startswith(("http://", "https://")): + return None, "接口基址需以 http:// 或 https:// 开头" + # 云元数据地址永远不该是「云端接口」:它是 SSRF 拿云上临时凭证 + # 最经典的一跳,而且没有任何合法的采集场景需要它。 + host = v.split("//", 1)[1].split("/", 1)[0].split(":")[0].lower() + if host in ("169.254.169.254", "metadata.google.internal", "[fd00:ec2::254]"): + return None, "接口基址不能指向云元数据地址" return v, None if key == "cookie": @@ -222,8 +276,57 @@ def normalize_setting(key, raw): DEFAULT_HOST = "0.0.0.0" # 局域网可访问 DEFAULT_PORT = 8848 SESSION_HOURS = 12 -MAX_LOGIN_FAILS = 5 # 同 IP / 同用户名连续失败次数 -LOGIN_LOCK_MINUTES = 10 +MAX_LOGIN_FAILS = 5 # 同 IP 连续失败次数(硬锁) +LOGIN_LOCK_MINUTES = 10 # 硬锁时长(仅 IP 维度) +# 用户名维度的**软退避**:对外提供服务后,「知道一个用户名就能把它锁死 10 分钟」 +# 本身就是一种攻击(拿管理员用户名当武器,别人也用不了)。所以用户名维度 +# 只产生秒级、递增、有封顶的等待,真正的重锁只按来源 IP 施加。 +USER_SOFT_THRESHOLD = 5 # 同一用户名失败超过这个次数才开始退避 +USER_SOFT_CAP_SECONDS = 60 # 退避封顶 +# 同一来源的登录尝试总量(含成功):挡住「慢慢撞、不触发失败阈值」的形态 +LOGIN_ATTEMPTS_PER_IP = 40 +LOGIN_ATTEMPTS_WINDOW = 300 # 秒 + +# ---------------- 反向代理与传输安全 ---------------- +def _env_flag(name, default="0"): + return os.environ.get(name, default).strip().lower() in ("1", "true", "yes", "on") + + +# 是否信任 X-Forwarded-For。**默认不信任**。 +# 直接暴露给公网(或前面只有一个「追加型」代理)时,XFF 的第 0 段是攻击者 +# 自己填的:一旦采信,验证码限速、注册配额、登录锁定三道 IP 防线会同时失效 +# (实测:每次换一个伪造 XFF,45 次验证码请求全部放行)。 +# 只有在**你自己的**反向代理会重写该头(nginx: `$remote_addr`)时才置 1。 +TRUST_PROXY = _env_flag("WB_TRUST_PROXY") +# 强制跳转 HTTPS(配合反代时用;读到 X-Forwarded-Proto: https 就不跳) +FORCE_HTTPS = _env_flag("WB_FORCE_HTTPS") +# 会话 Cookie 是否只走 HTTPS。纯局域网 HTTP 部署必须留 0,否则浏览器不发送, +# 表现为「登录成功但立刻又跳回登录页」,极难排查。 +COOKIE_SECURE = _env_flag("WB_COOKIE_SECURE") + +# 访问日志:waitress 自己不记 access log,上线后没有访问日志等于出事无据可查。 +# 只记非静态资源请求,写进 logs/app.log(滚动 2MB × 3)。 +ACCESS_LOG = _env_flag("WB_ACCESS_LOG", "1") + +# waitress 线程数。与容器 CPU 上限配套:线程越多,单实例能同时吃进的 +# 慢请求(如采集、导出)就越多 —— 对外提供服务时这是资源上限的一部分。 +THREADS = int(os.environ.get("WB_THREADS") or 8) + +# 首个管理员的初始口令。**不给默认值**:留空时 db.init_db 会生成一个随机口令 +# 并只在启动日志里打印一次 —— 硬编码一个 admin123 等于把公网实例的钥匙挂在门上。 +ADMIN_USER = os.environ.get("WB_ADMIN_USER") or "admin" +ADMIN_PASSWORD = os.environ.get("WB_ADMIN_PASSWORD") or "" + +# 口令黑名单:这些是自动撞库字典的头几页,命中即拒。 +# 只在「设置/修改口令」时校验(登录不校验),所以不会把用老口令的人挡在门外。 +WEAK_PASSWORDS = { + "12345678", "123456789", "1234567890", "password", "password1", "password123", + "passw0rd", "qwertyui", "qwerty123", "abc12345", "abcd1234", "admin123", + "admin888", "admin1234", "administrator", "root1234", "letmein1", "welcome1", + "iloveyou", "monkey123", "dragon123", "sunshine", "princess", "football", + "baseball", "11111111", "00000000", "88888888", "66666666", "asdasd123", + "1qaz2wsx", "zxcvbnm1", "a1234567", "workbuddy", "codebuddy", +} # ---------------- 账号与口令策略 ---------------- USERNAME_RE = r"^[A-Za-z0-9][A-Za-z0-9_.\-]{2,31}$" # 3~32 位,字母开头 @@ -233,13 +336,13 @@ PASSWORD_MAX = 128 # 采集只使用**本人**的 Cookie,绝不复用别人的(否则会串号)。 PROFILE_EMAIL_MAX = 128 -# 会话 Cookie 是否只走 HTTPS。纯局域网 HTTP 部署必须留 0,否则浏览器不发送, -# 表现为「登录成功但立刻又跳回登录页」,极难排查。 -COOKIE_SECURE = os.environ.get("WB_COOKIE_SECURE", "0").strip() in ("1", "true", "yes", "on") +# 会话 Cookie 的 Secure 开关在文件上方的「反向代理与传输安全」段, +# 与 TRUST_PROXY / FORCE_HTTPS 放在一起 —— 这三个必须一起决定, +# 拆开写很容易出现「开了强制 HTTPS 却忘了 Secure」这类半截配置。 def ensure_dirs(): - for d in (DATA_DIR, LOG_DIR, EXPORT_DIR): + for d in (DATA_DIR, LOG_DIR, EXPORT_DIR, BACKUP_DIR): os.makedirs(d, exist_ok=True) @@ -265,6 +368,11 @@ def _instance_init(key, maker): try: with open(INSTANCE_FILE, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) + # 这个文件里躺着 SECRET_KEY 与 cookie_key,权限等同管理员口令。 + # 默认 umask 022 会留下 0644(同机其他用户可读),所以在 POSIX 上 + # 显式收紧到 0600。Windows 没有这个概念,忽略即可。 + if os.name == "posix": + os.chmod(INSTANCE_FILE, 0o600) except OSError: pass # 只读文件系统时退化为「本次进程内有效」 return val diff --git a/workbuddy_portal/db.py b/workbuddy_portal/db.py index 95dd2c0..763eea4 100644 --- a/workbuddy_portal/db.py +++ b/workbuddy_portal/db.py @@ -26,13 +26,17 @@ * `slot:*` 调度簿记键是**个人级**(每个账号各自记「今天这个槽位跑过没」), 虽然时刻本身是实例级的 —— 这两个千万别一起改。 """ +import logging import os +import secrets import sqlite3 import threading from datetime import datetime from . import config, crypto +log = logging.getLogger("wb.db") + _local = threading.local() _init_lock = threading.Lock() _initialized = False @@ -41,11 +45,22 @@ _initialized = False # 1 -> 单用户布局(settings 以 key 为主键,usage_records 以 request_id 为主键) # 2 -> 多用户布局(见 schema.sql 顶部说明) # 3 -> 调度与采集参数从个人级提升为实例级(普通用户只读;见 config.GLOBAL_KEYS) -DB_SCHEMA_VERSION = 3 +# 4 -> users.session_ver(改密/重置/停用后旧会话立即失效)+ backups 备份索引表 +DB_SCHEMA_VERSION = 4 # 这些键即使个人作用域没有值,也**不**回落到实例级 NO_FALLBACK_KEYS = {"cookie", "user_agent"} +# 首次初始化时若没有提供管理员口令,生成的随机口令只留在这里, +# **绝不写进数据库或审计**(审计里出现口令等于把它永久留档)。 +# `manage.py init` / entrypoint 负责把它打印到启动日志。 +_generated_admin_password = None + + +def generated_admin_password(): + """本次进程启动时生成的管理员口令(没有生成过则为 None)。""" + return _generated_admin_password + class SecretUnreadable(Exception): """密文解不开 —— 通常是 data/instance.json 里的 cookie_key 被换过。""" @@ -167,7 +182,8 @@ def _migrate(conn): for col, ddl in (("email", "TEXT"), ("status", "TEXT NOT NULL DEFAULT 'active'"), ("register_ip", "TEXT"), - ("last_login_ip", "TEXT")): + ("last_login_ip", "TEXT"), + ("session_ver", "INTEGER NOT NULL DEFAULT 0")): if col not in tusers: conn.execute("ALTER TABLE users ADD COLUMN %s %s" % (col, ddl)) done.append("users 增加 %s" % col) @@ -264,6 +280,38 @@ def _promote_personal_to_global(conn): return promoted, cleaned +def _create_first_admin(conn, admin_user, admin_password, ts): + """建库后的第一个管理员。 + + 口令优先级:显式参数 -> 环境变量 `WB_ADMIN_PASSWORD` -> **随机生成**。 + 这里刻意**不再兜底 `admin123`**:对外提供服务时,一个硬编码的默认口令 + 等于「所有按默认配置部署的实例共用同一把钥匙」,而扫描器恰好就在扫它。 + 生成的口令只在启动日志里打印一次,并被提示立即修改。 + """ + global _generated_admin_password + from .security import hash_password + pwd = (admin_password or config.ADMIN_PASSWORD or "").strip() + generated = False + if not pwd: + pwd = secrets.token_urlsafe(12) + generated = True + conn.execute( + "INSERT INTO users(username,password_hash,display_name,is_admin,status," + " created_at) VALUES(?,?,?,1,'active',?)", + (admin_user, hash_password(pwd), "管理员", ts)) + if generated: + _generated_admin_password = pwd + # 只进日志,**不进数据库**:审计表里出现口令等于把它永久留档。 + log.warning("=" * 68) + log.warning("首次初始化:已为管理员 %s 生成随机口令 —— 请立即抄走并登录修改", admin_user) + log.warning(" 用户名:%s", admin_user) + log.warning(" 口 令:%s", pwd) + log.warning(" 该口令只在这里显示一次,不会写入数据库、日志文件之外的任何地方。") + log.warning(" 下次启动不会再显示(账号已存在)。忘了就用 manage.py passwd 重置。") + log.warning("=" * 68) + return generated + + def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=None): """建表 / 迁移 / 灌默认配置。可重复执行(幂等)。返回迁移说明列表。""" global _initialized @@ -316,12 +364,7 @@ def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=Non if create_admin: n = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] if n == 0: - from .security import hash_password - pwd = admin_password or "admin123" - conn.execute( - "INSERT INTO users(username,password_hash,display_name,is_admin,status," - " created_at) VALUES(?,?,?,1,'active',?)", - (admin_user, hash_password(pwd), "管理员", ts)) + _create_first_admin(conn, admin_user, admin_password, ts) _initialized = True return migrated finally: @@ -458,6 +501,31 @@ def user_count(conn): return conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] +def session_ver_of(row): + """安全读取 session_ver。 + + 取不到就返回 0 —— 迁移中途 / 老库尚未 ALTER 时不该因此抛异常, + 那会把「一次可恢复的登录失效」变成「整站 500」。 + """ + try: + if "session_ver" not in row.keys(): + return 0 + except AttributeError: # 不是 Row(dict 等) + return 0 + return int(row["session_ver"] or 0) + + +def bump_session_ver(conn, uid): + """把该账号所有既有会话立即作废(改密 / 管理员重置 / 停用 / 删除前)。 + + 会话里记着签发时的 session_ver,每个请求回查一次;不等就丢弃会话。 + 少了这一步,「我怀疑会话泄漏了所以改密码」会变成一个假的安心动作 —— + 旧会话依然有效到 12 小时之后。 + """ + conn.execute("UPDATE users SET session_ver=COALESCE(session_ver,0)+1 WHERE id=?", + (int(uid or 0),)) + + # ---------------- 审计 ---------------- def audit(conn, action, actor=None, detail=None, ip=None, uid=0): conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)" diff --git a/workbuddy_portal/scheduler.py b/workbuddy_portal/scheduler.py index 97c7efa..3994183 100644 --- a/workbuddy_portal/scheduler.py +++ b/workbuddy_portal/scheduler.py @@ -26,7 +26,7 @@ import os import threading from datetime import datetime, timedelta -from . import collect, db +from . import backup, collect, db log = logging.getLogger("wb.scheduler") SLOT_PREFIX = "slot:" # settings 键:slot:09:00 -> 最近执行的日期(按 user_id 存) @@ -176,6 +176,16 @@ class Scheduler: log.error("账号 %s 的 Cookie 解不开:%s", u["username"], e) except Exception as e: log.error("账号 %s 采集失败:%s", u["username"], e) + + # ---- 自动备份(实例级,与具体账号无关,所以放在账号循环之外)---- + # 有采集在跑就跳过,等下一轮:备份会整库读一遍,没必要和采集抢磁盘。 + try: + if os.path.exists(collect.LOCK_PATH): + log.debug("有采集在跑,本次跳过自动备份") + else: + backup.maybe_auto(conn, now) + except Exception as e: # 备份失败不能拖累调度本身 + log.exception("自动备份出错:%s", e) return True diff --git a/workbuddy_portal/schema.sql b/workbuddy_portal/schema.sql index b6690ae..a3e8ce0 100644 --- a/workbuddy_portal/schema.sql +++ b/workbuddy_portal/schema.sql @@ -85,6 +85,7 @@ CREATE TABLE IF NOT EXISTS users ( email TEXT, is_admin INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'active', -- active | disabled + session_ver INTEGER NOT NULL DEFAULT 0, -- 会话版本:改密/重置/停用 +1,旧会话立即失效 created_at TEXT, register_ip TEXT, -- 自助注册来源,用于每日限额 last_login_at TEXT, @@ -119,3 +120,25 @@ CREATE TABLE IF NOT EXISTS captchas ( ); CREATE INDEX IF NOT EXISTS idx_captcha_expires ON captchas(expires_at); + +-- ---------------- 备份索引 ---------------- +-- 表里只放「元数据」,备份文件本身在 config.BACKUP_DIR(**不在** data/ 卷里, +-- 免得 `docker compose down -v` 把正本和备份一起删掉)。 +-- 刻意不记录备份内容、也不记录任何凭证 —— 这里只是一份可下载清单。 +CREATE TABLE IF NOT EXISTS backups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT NOT NULL UNIQUE, -- 备份目录内的文件名(不含路径) + bytes INTEGER NOT NULL DEFAULT 0, -- 归档大小 + sha256 TEXT NOT NULL DEFAULT '', -- 归档整体校验值(前 64 位十六进制) + created_at TEXT NOT NULL, + trigger TEXT NOT NULL DEFAULT 'manual', -- manual | auto | cli | pre-restore + actor TEXT, -- 触发者用户名(自动备份为 system) + schema_ver INTEGER NOT NULL DEFAULT 0, -- 归档时的 user_version + records INTEGER NOT NULL DEFAULT 0, -- 归档时的记录条数(便于挑一份恢复) + credits REAL NOT NULL DEFAULT 0, + users INTEGER NOT NULL DEFAULT 0, + note TEXT, -- 备注 / 恢复来源 + missing INTEGER NOT NULL DEFAULT 0 -- 1 = 文件已不在磁盘上(手工删过) +); + +CREATE INDEX IF NOT EXISTS idx_backups_at ON backups(created_at DESC); diff --git a/workbuddy_portal/security.py b/workbuddy_portal/security.py index 481db5d..2e16519 100644 --- a/workbuddy_portal/security.py +++ b/workbuddy_portal/security.py @@ -10,14 +10,26 @@ `db.get_secret(conn, "cookie", uid)`;`db.get_settings()` 会把凭证置空, 所以「顺手把配置回传给前端」这类代码不可能把它带出去。 2. **禁用/删除账号立刻失效**:`current_user()` 每个请求回查一次 - users.status,不靠会话过期来兜底(默认会话 12 小时,太久了)。 -3. **失败限速按「来源 IP」和「用户名」双维度计数**:只按 IP 挡不住 - 「一批肉鸡轮流撞同一个账号」,只按用户名又会让一个 IP 无限注册。 + users.status 与 users.session_ver,不靠会话过期来兜底。 +3. **限速按「来源 IP」和「用户名」双维度计数**,但两者的**强度刻意不同**: + IP 维度是真锁,用户名维度只是秒级退避。原因见 `user_soft_left` 的注释。 + +来源 IP 的取法(对外提供服务时最容易出错的一处) +------------------------------------------------ +`client_ip()` 是**全站唯一**的取客户端地址入口。默认只信 `remote_addr`: +反向代理若用 `$proxy_add_x_forwarded_for`(追加语义),请求头里第 0 段就是 +攻击者自己填的字符串,采信它等于把验证码限速、注册配额、登录锁定三道 +IP 防线一起交出去。只有显式设置 `WB_TRUST_PROXY=1`(且你的代理会重写该头) +时才读 X-Forwarded-For,而且**取最右侧**那一段 —— 最右边是离我们最近的 +一跳,由我们自己的代理写入,客户端伪造不了。 """ import functools import hmac +import ipaddress +import logging import re import secrets +import sqlite3 import time from flask import (current_app, flash, g, jsonify, redirect, render_template, @@ -25,10 +37,50 @@ from flask import (current_app, flash, g, jsonify, redirect, render_template, from . import captcha, config, db +log = logging.getLogger("wb.security") + +# ---------------- 来源 IP(全站唯一入口) ---------------- +def _valid_ip(s): + try: + ipaddress.ip_address(s) + return True + except ValueError: + return False + + +def client_ip(): + """当前请求的客户端地址。 + + * `WB_TRUST_PROXY` 未开启(默认):直接用 `remote_addr`。 + 直接暴露公网、或前面挂了「追加型」代理时,XFF 的第一段是攻击者可控的。 + * 已开启:读 X-Forwarded-For 并**取最右侧**合法 IP。 + 最右侧是最近一跳(我们自己的代理)写入的,客户端加不进去。 + 多级代理(CDN -> nginx)需要按跳数取值,本项目不支持 —— 那样只能靠 + 代理侧传 `X-Real-IP` 之类的可信头,不要在这里猜。 + """ + remote = (request.remote_addr or "").strip() + if not config.TRUST_PROXY: + return remote + raw = request.headers.get("X-Forwarded-For", "") + if not raw: + return remote + for part in reversed([p.strip() for p in raw.split(",")]): + # 去掉 IPv6 的 [..]:port 写法 + cand = part.strip("[]").split("%")[0] + if cand.count(":") == 1 and cand.rsplit(":", 1)[1].isdigit(): + cand = cand.rsplit(":", 1)[0] # IPv4:port + if _valid_ip(cand): + return cand + # 头里全是垃圾 -> 退回 remote_addr,而不是把一个伪造值当 IP 用 + log.warning("X-Forwarded-For 里没有合法 IP,已回退 remote_addr:%r", raw[:120]) + return remote + + # ---------------- 失败计数(内存即可) ---------------- # 单进程部署(见 README 的部署约束),重启清零可接受; # 真正的防爆破靠「验证码 + 双维度限速」两道,而不是靠计数持久化。 _fails = {} # key -> [count, last_ts] +_tries = {} # ip -> [count, window_started_at](含成功,只看总量) _FAILS_MAX_KEYS = 8192 # 上限,防止海量来源把字典撑爆 _FAILS_TTL = 3600 # 超过 1 小时无更新即清理 @@ -88,9 +140,76 @@ def fail_count(key): return c[0] if c else 0 +# ---- IP 维度:真锁(来源地址现在已经不可伪造,锁得住真正的攻击者)---- +def ip_lock_left(ip): + c = _fails.get(_ip_key(ip)) + if not c or c[0] < config.MAX_LOGIN_FAILS: + return 0 + return max(0, int(config.LOGIN_LOCK_MINUTES * 60 - (time.time() - c[1]))) + + +# ---- 用户名维度:只做秒级退避,**不做长锁** ---- +def user_soft_left(username): + """知道一个用户名就能把它锁死 10 分钟 —— 那本身就是攻击。 + + 对外提供服务后,管理员用户名是公开信息(导航里就写着), + 如果按用户名施加长锁,任何人只要连打 5 次错误口令,就能让真正的管理员 + 十分钟进不去。所以这里改成「递增且有封顶」的秒级等待: + 超过阈值后第 1 次 1s、第 2 次 2s …… 封顶 60s。 + 真正的重锁只按来源 IP 施加(`ip_lock_left`)—— 那才是攻击者无法伪造、 + 也无法甩锅给别人的东西。命中阈值的同时,攻击者自己的 IP 也在计数, + 所以这种「软」不会让爆破变得可行。 + """ + c = _fails.get(_user_key(username)) + if not c or c[0] < config.USER_SOFT_THRESHOLD: + return 0 + delay = min(config.USER_SOFT_CAP_SECONDS, + 1 << min(10, c[0] - config.USER_SOFT_THRESHOLD)) + return max(0, int(delay - (time.time() - c[1]))) + + +# ---- 单 IP 登录尝试总量(含成功):挡住「慢慢撞、不触发失败阈值」---- +def note_try(ip): + now = time.time() + if len(_tries) > _FAILS_MAX_KEYS: + _tries.clear() + cur = _tries.get(ip) + if cur is None or now - cur[1] > config.LOGIN_ATTEMPTS_WINDOW: + _tries[ip] = [1, now] + return 1 + cur[0] += 1 + return cur[0] + + +def try_window_left(ip): + cur = _tries.get(ip) + if not cur or cur[0] < config.LOGIN_ATTEMPTS_PER_IP: + return 0 + return max(0, int(config.LOGIN_ATTEMPTS_WINDOW - (time.time() - cur[1]))) + + def auth_locked(ip, username=""): - """返回还需锁定的秒数(0 = 未锁)。IP 与用户名任一超限即锁。""" - return max(lock_left(_ip_key(ip)), lock_left(_user_key(username))) + """还需等待的秒数(0 = 放行)。""" + return max(ip_lock_left(ip), try_window_left(ip), user_soft_left(username)) + + +def auth_block_reason(ip, username=""): + """被挡的原因码:ip / rate / user / 空。用于给出**准确**的提示语。""" + if ip_lock_left(ip): + return "ip" + if try_window_left(ip): + return "rate" + if user_soft_left(username): + return "user" + return "" + + +def auth_block_message(reason, seconds): + if reason == "ip": + return "该来源登录失败次数过多,请 %d 秒后再试" % seconds + if reason == "rate": + return "登录请求过于频繁,请 %d 秒后再试" % seconds + return "尝试过于频繁,请 %d 秒后再试" % seconds def note_auth_fail(ip, username=""): @@ -100,11 +219,40 @@ def note_auth_fail(ip, username=""): def clear_auth_fail(ip, username=""): + """登录成功后清掉两个维度的计数。 + + 用户名维度必须在成功时清零:否则「攻击者打了几次 + 主人自己登一次」 + 之后,主人仍会被自己之前那几次的退避拖住。 + """ clear_fail(_ip_key(ip)) if username: clear_fail(_user_key(username)) +# ---------------- 通用动作限速(重操作保护) ---------------- +# 采集 / 导出 / 整库整理这类动作的代价远高于普通页面请求: +# 一次 /api/collect 会让服务端对云端发起成百上千次请求,并独占一个线程; +# 一次导出会把整张表扫一遍。对外提供服务时必须有刹车, +# 否则**一个注册账号**就能把实例的线程与带宽吃干净。 +_actions = {} # "name:uid" -> next_allowed_ts + + +def action_allowed(key, min_interval): + """返回 (是否允许, 还需等待秒数)。允许时会把下次可执行时间推后。""" + now = time.time() + if len(_actions) > _FAILS_MAX_KEYS: + _actions.clear() + nxt = _actions.get(key) or 0 + if now < nxt: + return False, int(nxt - now) + 1 + _actions[key] = now + max(0, int(min_interval)) + return True, 0 + + +def action_wait_left(key): + return max(0, int((_actions.get(key) or 0) - time.time())) + + # ---------------- 口令 / 用户名策略 ---------------- _USERNAME_RE = re.compile(config.USERNAME_RE) @@ -149,6 +297,10 @@ def password_problem(new, new2=None, username=None): (r"[a-z]", r"[A-Z]", r"[0-9]", r"[^A-Za-z0-9]")) if classes < 2: return "密码需包含大写字母、小写字母、数字、符号中的至少两类" + # 黑名单只挡「字典头几页」,命中即拒。只在设置/修改口令时校验, + # 登录路径不校验 —— 所以不会把用老口令的人挡在门外。 + if new.lower() in config.WEAK_PASSWORDS: + return "这个密码在常见弱口令字典里,请换一个" if new2 is not None and new2 != new: return "两次输入的新密码不一致" if username and new.lower() == str(username).lower(): @@ -182,8 +334,12 @@ def login_ok(conn, username, password): def current_user(): """当前登录用户(dict)或 None。 - 每个请求回查一次 users 表:账号被停用/删除后**立刻**失效, - 而不是等 12 小时会话自然过期。结果缓存在 flask.g 里,一次请求只查一次。 + 每个请求回查一次 users 表,两道校验: + * `status` —— 账号被停用/删除后**立刻**失效,而不是等 12 小时会话过期 + * `session_ver` —— 改密码 / 管理员重置 / 停用后,签发时的那一版会话 + 立即作废。少了它,「怀疑会话泄漏了所以改密码」就是个假的安心动作: + 旧会话照样有效到 12 小时之后。 + 结果缓存在 flask.g 里,一次请求只查一次。 """ if "wb_user" in g: return g.wb_user @@ -192,12 +348,24 @@ def current_user(): if uid: try: row = db.get_db().execute( - "SELECT id,username,display_name,is_admin,status FROM users WHERE id=?", - (uid,)).fetchone() + "SELECT id,username,display_name,is_admin,status,session_ver" + " FROM users WHERE id=?", (uid,)).fetchone() + except sqlite3.OperationalError as e: + # users 表结构与代码不一致(典型场景:升级到新版本后没跑 init_db + # 就先把 Web 起起来了,老库还没有 session_ver 这一列)。 + # 这里必须**大声**报错。若和下面「无请求上下文」一起被静默吞掉, + # 症状会变成「全站所有人被踢下线、日志里什么都没有」—— + # 界面上只看到「登录成功又立刻跳回登录页」,极难归因。 + log.error("会话校验失败:users 表结构与代码不一致(%s);" + "请执行 `python manage.py init` 完成迁移", e) + row = None except Exception: # noqa: BLE001 (无请求上下文等) row = None if row is None or (row["status"] or "active") != "active": session.clear() + elif session.get("sv", 0) != db.session_ver_of(row): + # 老会话没有 sv 字段时按 0 处理,这样升级本身不会把所有人踢下线 + session.clear() else: user = {"id": row["id"], "username": row["username"], "display_name": row["display_name"] or row["username"], @@ -219,12 +387,16 @@ def login_session(user): `session.clear()` 是必须的:既清掉前一次的残留, 也顺带换掉 CSRF token 与验证码 id —— 这正是防「会话固定」的做法。 + + `sv` 记下签发时的 users.session_ver:之后一旦账号改密 / 被重置 / 被停用, + 这一版会话会在下一个请求就被判为过期。 """ session.clear() session["uid"] = user["id"] session["uname"] = user["username"] session["dname"] = user["display_name"] or user["username"] session["adm"] = 1 if user["is_admin"] else 0 + session["sv"] = db.session_ver_of(user) session["login_at"] = db.now_str() session.permanent = True @@ -401,19 +573,54 @@ def apply_security_headers(resp): resp.headers.setdefault("Referrer-Policy", "same-origin") resp.headers.setdefault("Content-Security-Policy", CSP) resp.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin") + # HSTS 只在「确认这个部署跑在 HTTPS 上」时才发:在纯 HTTP 部署上发它, + # 浏览器会把该域名的 http 访问强行升级,表现成「打开就白屏」。 + # 判据是管理员显式打开了 COOKIE_SECURE 或 FORCE_HTTPS。 + if config.COOKIE_SECURE or config.FORCE_HTTPS: + resp.headers.setdefault("Strict-Transport-Security", + "max-age=31536000; includeSubDomains") if request.path.startswith("/api/") or request.path.startswith("/captcha"): resp.headers.setdefault("Cache-Control", "no-store") return resp +def needs_https_redirect(): + """当前请求是否该被跳到 https(仅在显式开启 WB_FORCE_HTTPS 时才判断)。""" + if not config.FORCE_HTTPS or request.is_secure: + return False + # 反代终止 TLS 时,Flask 看到的是 http;靠 X-Forwarded-Proto 还原真实协议。 + # 这个头只在「你已经决定信任代理」的前提下才有意义,所以与 TRUST_PROXY 绑定。 + if config.TRUST_PROXY and (request.headers.get("X-Forwarded-Proto") or "").lower() == "https": + return False + if request.method not in ("GET", "HEAD"): + return False # 不重定向 POST:会丢请求体,行为难以预期 + return True + + +def _access_log(resp, started): + if not config.ACCESS_LOG: + return resp + path = request.path + if path.startswith("/static/") or path == "/captcha.png": + return resp # 静态资源与验证码出图会把日志刷满 + log.info("%s %s -> %s %dms ip=%s", request.method, path, resp.status_code, + int((time.time() - started) * 1000), client_ip()) + return resp + + def init_app(app): app.jinja_env.globals["csrf_token"] = csrf_token app.jinja_env.globals["current_user"] = current_user @app.before_request def _guard(): + g.wb_t0 = time.time() + if needs_https_redirect(): + url = request.url.replace("http://", "https://", 1) + return redirect(url, code=301) return check_csrf() @app.after_request def _headers(resp): + _access_log(resp, getattr(g, "wb_t0", time.time())) return apply_security_headers(resp) diff --git a/workbuddy_portal/web/api.py b/workbuddy_portal/web/api.py index c98b77b..08c6a80 100644 --- a/workbuddy_portal/web/api.py +++ b/workbuddy_portal/web/api.py @@ -23,9 +23,9 @@ import os from datetime import datetime -from flask import Blueprint, jsonify, request +from flask import Blueprint, jsonify, request, send_file, session -from .. import collect, config, db, query, scheduler, security +from .. import backup, collect, config, db, query, scheduler, security from ..security import admin_required, current_user, is_admin, login_required bp = Blueprint("api", __name__, url_prefix="/api") @@ -36,6 +36,12 @@ def _uid(): return u["id"] if u else 0 +def _ip(): + """客户端地址。一律走 security.client_ip() —— 见那里的注释: + 直接取 X-Forwarded-For 第 0 段会让「来源」变成请求方自己填的字符串。""" + return security.client_ip() + + def _arg(name, default=None): v = request.args.get(name) return v if v not in (None, "") else default @@ -193,14 +199,26 @@ def api_status(): running = conn.execute("SELECT COUNT(*) FROM collect_runs WHERE user_id=? AND status='running'", (uid,)).fetchone()[0] cred = db.secret_state(conn, "cookie", uid) + nxt_bk = backup.next_auto_at(conn) if u["is_admin"] else None return jsonify({ "server_time": db.now_str(), # 角色能力:大屏等**静态页**拿不到 Jinja 上下文,只能靠这个字段 - # 决定要不要渲染管理员专属入口(如「日志管理」)。服务端仍会 + # 决定要不要渲染管理员专属入口(如「日志管理」「备份管理」)。服务端仍会 # 对这些入口再做一次鉴权,前端隐藏只是为了不给出误导性的按钮。 "is_admin": bool(u["is_admin"]), "can_edit_schedule": bool(u["is_admin"]), "can_view_logs": bool(u["is_admin"]), + "can_manage_backups": bool(u["is_admin"]), + "limits": { + # 对外提供服务时的三道闸门,前端据此提前禁用按钮而不是等 409 + "collect_min_interval_seconds": collect.min_interval_seconds(conn), + "collect_max_range_days": collect.max_range_days(conn), + "schedule_slots": len(scheduler.slots(conn, uid)), + "schedule_slots_cap": + max(1, min(config.SCHEDULE_SLOTS_HARD_MAX, + db.get_int(conn, "max_schedule_slots_per_day", 6))), + }, + "running_lock": os.path.exists(collect.LOCK_PATH), "scheduler": { "running": sch.running, "enabled": db.get_bool(conn, "schedule_enabled", True, uid), @@ -209,6 +227,13 @@ def api_status(): "catch_up": db.get_bool(conn, "catch_up", True, uid), "lockfile": os.path.exists(collect.LOCK_PATH), }, + "backup": { + "enabled": db.get_bool(conn, "backup_enabled", True), + "interval_hours": db.get_int(conn, "backup_interval_hours", 24), + "keep": db.get_int(conn, "backup_keep", 7), + "last": backup.last_auto_at(conn) or None, + "next": nxt_bk.strftime("%Y-%m-%d %H:%M:%S") if nxt_bk else None, + }, "running_runs": running, "last_run": dict(last) if last else None, # 只回「有没有配」与字符数,绝不回凭证内容 @@ -221,10 +246,40 @@ def api_status(): @bp.post("/collect") @login_required def api_collect(): - """手动触发一次采集(后台线程之外同步执行,页面等待结果)。""" + """手动触发一次采集(同步执行,页面等待结果)。 + + 对外提供服务后,这里必须有三道闸门,缺一不可: + + 1. **已有任务在跑就拒绝新任务**。采集是全局单写者(SQLite 同一时刻只允许 + 一个写进程,见 collect._Lock)。原来的实现是「后来者在锁上等」, + 而 waitress 只有 8 个线程 —— 一个人连点几下就能把线程占满, + 表现为整个站点变慢甚至无响应。现在明确回 409。 + 2. **同一账号的最小间隔**。挡住「连点按钮 / 脚本循环调」这种形态; + 间隔由 `collect_min_interval_seconds` 控制(实例级,管理员可调)。 + 3. **跨度上限**。`from=2000-01-01` 会让服务端对云端发出成百上千次请求, + 这是最省力的资源耗尽方式。上限 `collect_max_range_days`(硬顶 31 天)。 + """ u = current_user() + conn = db.get_db() + uid = u["id"] body = _json_body() + + # ---- 闸门 1:不能有别的任务在跑 ---- + if os.path.exists(collect.LOCK_PATH): + return jsonify({"ok": False, "error": "busy", + "message": "已有采集任务正在运行,请等它结束后再试" + "(进度见「任务管理」)"}), 409 + + # ---- 闸门 2:频率 ---- + gap = collect.min_interval_seconds(conn) + allowed, wait = security.action_allowed("collect:%d" % uid, gap) + if not allowed: + return jsonify({"ok": False, "error": "too_frequent", + "message": "同一账号两次采集之间需间隔 %d 秒,请 %d 秒后再试" + % (gap, wait)}), 429 + frm, to = body.get("from"), body.get("to") + limit = collect.max_range_days(conn) try: kw = {} if frm: @@ -239,7 +294,16 @@ def api_collect(): kw["to_dt"] = datetime.strptime(d, "%Y-%m-%d").replace(hour=23, minute=59, second=59) if kw.get("from_dt") and kw.get("to_dt") and kw["from_dt"] > kw["to_dt"]: raise BadParam("起始日期不能晚于结束日期") - r = collect.run_sync(trigger="manual", uid=u["id"], **kw) + # ---- 闸门 3:跨度 ---- + if kw.get("from_dt"): + end = kw.get("to_dt") or datetime.now() + days = (end - kw["from_dt"]).days + if days > limit: + raise BadParam( + "采集跨度最长 %d 天,本次请求是 %d 天。" + "请缩小日期范围后分批采集(每次最多 %d 天)。" + % (limit, days, limit)) + r = collect.run_sync(trigger="manual", uid=uid, **kw) except BadParam as e: return jsonify({"ok": False, "error": "bad_request", "message": str(e)}), 400 except collect.Busy as e: @@ -256,7 +320,7 @@ def api_collect(): "message": str(e)}), code except Exception as e: # noqa: BLE001 return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500 - db.audit(db.get_db(), "collect", u["username"], r["message"], request.remote_addr, u["id"]) + db.audit(conn, "collect", u["username"], r["message"], _ip(), uid) return jsonify({"ok": True, "result": r}) @@ -307,6 +371,14 @@ def api_maintenance(action): if action == "vacuum" and not u["is_admin"]: return jsonify({"ok": False, "error": "forbidden", "message": "数据库整理是整库操作,仅管理员可执行"}), 403 + # 这四类动作都是「整库扫一遍」级别:导出会全表流式扫、补全会连续打云端、 + # vacuum 会锁库。各自加一个按账号的最小间隔,挡住脚本循环调用。 + hvy = {"fill-prompt": 60, "export-csv": 15, "vacuum": 120, "recount": 5} + if action in hvy: + ok, wait = security.action_allowed("maint:%s:%d" % (action, uid), hvy[action]) + if not ok: + return jsonify({"ok": False, "error": "too_frequent", + "message": "该动作刚执行过,请 %d 秒后再试" % wait}), 429 try: if action == "fill-prompt": try: @@ -336,10 +408,118 @@ def api_maintenance(action): return jsonify({"ok": False, "error": "unknown", "message": "未知维护动作"}), 404 except Exception as e: # noqa: BLE001 return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500 - db.audit(conn, "maintenance:" + action, u["username"], msg, request.remote_addr, uid) + db.audit(conn, "maintenance:" + action, u["username"], msg, _ip(), uid) return jsonify({"ok": True, "message": msg}) +# ---------------- 备份管理(仅管理员) ---------------- +def _backup_error(e): + return jsonify({"ok": False, "error": "backup", "message": str(e)}), 400 + + +@bp.get("/backups") +@admin_required +def api_backups(): + conn = db.get_db() + backup.sync_index(conn) + return jsonify({ + "items": backup.listing(conn), + "dir": config.BACKUP_DIR, + "total_human": backup.human(backup.total_bytes(conn)), + "enabled": db.get_bool(conn, "backup_enabled", True), + "interval_hours": db.get_int(conn, "backup_interval_hours", 24), + "keep": db.get_int(conn, "backup_keep", 7), + "last_auto": backup.last_auto_at(conn), + }) + + +@bp.post("/backups") +@admin_required +def api_backup_create(): + conn = db.get_db() + u = current_user() + # 打一份整库快照是重活(整库读一遍 + 压缩),别让脚本连打 + ok, wait = security.action_allowed("backup:create", 30) + if not ok: + return jsonify({"ok": False, "error": "too_frequent", + "message": "刚打过备份,请 %d 秒后再试" % wait}), 429 + note = str(_json_body().get("note") or "").strip()[:200] + try: + r = backup.create(conn, trigger="manual", actor=u["username"], note=note) + except backup.BackupError as e: + return _backup_error(e) + except Exception as e: # noqa: BLE001 + return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500 + removed = backup.prune(conn, actor=u["username"]) + if removed: + r["message"] += ";按保留份数清理了 %d 份旧备份" % len(removed) + r["pruned"] = removed + return jsonify(r) + + +@bp.get("/backups/") +@admin_required +def api_backup_download(filename): + """下载一份归档。文件名必须过 backup.safe_name 的收口。""" + try: + p = backup.path_of(filename) + except backup.BackupError as e: + return _backup_error(e) + if not os.path.exists(p): + return jsonify({"ok": False, "error": "not_found", + "message": "备份文件不存在或已被删除"}), 404 + db.audit(db.get_db(), "backup_download", current_user()["username"], + "下载备份 %s" % os.path.basename(p), _ip(), 0) + return send_file(p, as_attachment=True, download_name=os.path.basename(p), + mimetype="application/zip") + + +@bp.post("/backups//restore") +@admin_required +def api_backup_restore(filename): + """从归档恢复整库。 + + 这是本系统里**破坏性最强**的一个操作:它会把当前所有账号、所有用量、 + 所有配置替换成归档里的那一份。所以: + * 恢复前自动给当前库打一份 pre-restore 快照(错了能回去) + * 默认把 instance.json 一并恢复(否则 Cookie 密文解不开) + * 完成后所有既有会话失效(密钥与账号可能都变了),必须重新登录 + """ + conn = db.get_db() + u = current_user() + body = _json_body() + include_instance = str(body.get("include_instance", "1")).lower() not in ("0", "false", "off", "no") + try: + r = backup.restore(conn, filename, include_instance=include_instance, + actor=u["username"]) + except backup.BackupError as e: + return _backup_error(e) + except Exception as e: # noqa: BLE001 + return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500 + return jsonify(r) + + +@bp.post("/backups//delete") +@admin_required +def api_backup_delete(filename): + conn = db.get_db() + try: + r = backup.delete(conn, filename, actor=current_user()["username"]) + except backup.BackupError as e: + return _backup_error(e) + return jsonify(r) + + +@bp.post("/backups/prune") +@admin_required +def api_backup_prune(): + conn = db.get_db() + removed = backup.prune(conn, actor=current_user()["username"]) + return jsonify({"ok": True, "removed": removed, + "message": ("已清理 %d 份旧备份" % len(removed)) if removed + else "没有需要清理的备份"}) + + def _human(n): for unit in ("B", "KB", "MB", "GB"): if n < 1024 or unit == "GB": @@ -363,6 +543,7 @@ def api_settings_get(): s["_globalKeys"] = sorted(config.GLOBAL_KEYS) s["_userKeys"] = sorted(config.USER_EDITABLE_KEYS) s["_canEditGlobal"] = bool(u["is_admin"]) + s["_canManageBackups"] = bool(u["is_admin"]) s["_role"] = "admin" if u["is_admin"] else "user" return jsonify(s) @@ -397,7 +578,7 @@ def api_settings_post(): db.set_secret(conn, "cookie", "", uid) changed.append(k) continue - val, err = config.normalize_setting(k, v) + val, err = config.normalize_setting(k, v, conn) if err: errors.append(err) continue @@ -412,7 +593,7 @@ def api_settings_post(): % "、".join(sorted(denied))) if errors: db.audit(conn, "settings_rejected", u["username"], ";".join(errors)[:500], - request.remote_addr, uid) + _ip(), uid) return jsonify({"ok": False, "error": "invalid", "message": ";".join(errors), "errors": errors, "changed": sorted(changed)}), 400 # 改了每日时刻:清掉**不再存在的**槽位标记(所有账号一起清)。 @@ -425,8 +606,17 @@ def api_settings_post(): if r["key"][len(scheduler.SLOT_PREFIX):] not in keep] for row_uid, skey in stale: conn.execute("DELETE FROM settings WHERE user_id=? AND key=?", (row_uid, skey)) + # 调小时刻数上限后,多余的槽位标记也该跟着清,否则「缩到 2 个时刻」之后 + # 另外几个时刻的标记会一直躺在库里,看着像系统还在按旧配置跑。 + if "max_schedule_slots_per_day" in changed: + keep = set(scheduler.slots(conn, 0)) + for r in conn.execute("SELECT user_id,key FROM settings WHERE key LIKE ?", + (scheduler.SLOT_PREFIX + "%",)).fetchall(): + if r["key"][len(scheduler.SLOT_PREFIX):] not in keep: + conn.execute("DELETE FROM settings WHERE user_id=? AND key=?", + (r["user_id"], r["key"])) db.audit(conn, "settings", u["username"], - "修改:" + (",".join(sorted(changed)) or "(无变化)"), request.remote_addr, uid) + "修改:" + (",".join(sorted(changed)) or "(无变化)"), _ip(), uid) return jsonify({"ok": True, "changed": sorted(changed), "ignored": sorted(ignored)}) @@ -445,8 +635,16 @@ def api_password(): return jsonify({"ok": False, "message": err}), 400 conn.execute("UPDATE users SET password_hash=? WHERE id=?", (security.hash_password(new), u["id"])) - db.audit(conn, "password", u["username"], "修改登录密码", request.remote_addr, u["id"]) - return jsonify({"ok": True, "message": "密码已更新"}) + # 改密即作废**其他**设备的会话:改密码的动机往往就是怀疑它泄漏了, + # 只改散列却留着旧会话,等于给自己一个假的安心。 + db.bump_session_ver(conn, u["id"]) + # 当前这次会话跟着刷新到新版本,否则用户改完密码立刻被自己踢下线。 + # (安全上「全部踢掉」更好,但体验太差会让人不敢改密码。) + with_row = conn.execute("SELECT * FROM users WHERE id=?", (u["id"],)).fetchone() + session["sv"] = db.session_ver_of(with_row) + db.audit(conn, "password", u["username"], "修改登录密码(其他设备会话已失效)", + _ip(), u["id"]) + return jsonify({"ok": True, "message": "密码已更新,其他设备上的登录已失效"}) @bp.post("/profile") @@ -470,7 +668,7 @@ def api_profile(): if not changed: return jsonify({"ok": False, "message": "没有要修改的内容"}), 400 db.audit(conn, "profile", u["username"], "修改:" + "、".join(changed), - request.remote_addr, u["id"]) + _ip(), u["id"]) return jsonify({"ok": True, "message": "已更新:" + "、".join(changed)}) @@ -514,7 +712,7 @@ def api_user_create(): (body.get("display_name") or name).strip()[:64], (body.get("email") or "").strip()[:128] or None, adm, db.now_str())) db.audit(conn, "user_create", me["username"], - "新建用户 %s(%s)" % (name, "管理员" if adm else "普通"), request.remote_addr, me["id"]) + "新建用户 %s(%s)" % (name, "管理员" if adm else "普通"), _ip(), me["id"]) return jsonify({"ok": True, "message": "已创建用户 %s" % name, "id": cur.lastrowid}) @@ -561,6 +759,11 @@ def api_user_update(uid): return jsonify({"ok": False, "message": "至少要保留一个启用状态的管理员"}), 400 conn.execute("UPDATE users SET status=? WHERE id=?", (v, uid)) + if v != "active": + # 停用必须**连会话一起断**:只改 status 的话,对方手上那台设备 + # 要到下一个请求才被拦(也不是不行),但版本号一并推掉更干净 —— + # 将来若有人把 current_user 的状态检查挪走,这里还有一道。 + db.bump_session_ver(conn, uid) changed.append("状态→" + ("启用" if v == "active" else "停用")) pwd = (body.get("password") or "").strip() if pwd: @@ -569,12 +772,15 @@ def api_user_update(uid): return jsonify({"ok": False, "message": err}), 400 conn.execute("UPDATE users SET password_hash=? WHERE id=?", (security.hash_password(pwd), uid)) + # 管理员重置口令后,该账号在别处的登录必须立刻失效 —— + # 重置口令的典型场景就是「怀疑账号被盗」。 + db.bump_session_ver(conn, uid) changed.append("密码") if not changed: return jsonify({"ok": False, "message": "没有要修改的内容"}), 400 db.audit(conn, "user_update", me["username"], "修改用户 %s:%s" % (row["username"], "、".join(changed)), - request.remote_addr, me["id"]) + _ip(), me["id"]) return jsonify({"ok": True, "message": "已更新:" + "、".join(changed)}) @@ -605,7 +811,7 @@ def api_user_delete(uid): conn.execute("DELETE FROM users WHERE id=?", (uid,)) db.audit(conn, "user_delete", me["username"], "删除用户 %s(%s)" % (row["username"], "保留其数据" if keep else "连同数据一并删除"), - request.remote_addr, me["id"]) + _ip(), me["id"]) return jsonify({"ok": True, "message": "已删除 " + row["username"]}) diff --git a/workbuddy_portal/web/templates/backups.html b/workbuddy_portal/web/templates/backups.html new file mode 100644 index 0000000..ab9eac7 --- /dev/null +++ b/workbuddy_portal/web/templates/backups.html @@ -0,0 +1,236 @@ +{% extends "base.html" %} +{% block title %}备份管理 · {{ project_title }}{% endblock %} +{% block body %} + +
+
+

备份管理

+

+ 整库一致性快照:可自动按周期备份、按份数自动清理,也能手工下载与恢复到任意一份 +

+
+
+ 仅管理员可见 + +
+
+ + +
+
+ 归档份数{{ info.count }} + 保留上限 {{ info.keep }} 份 +
+
+ 备份占用{{ info.total }} + 当前库 {{ info.db_bytes }} +
+
+ 自动备份{{ '已开启' if info.enabled else '已关闭' }} + 每 {{ info.interval }} 小时一次 +
+
+ 上次 / 下次自动备份 + {{ info.last_auto[5:16] if info.last_auto != '—' else '还没有' }} + {% if info.never_auto %}等待调度线程首轮检查(约 20 秒) + {%- elif info.next_auto != '—' %}下次 {{ info.next_auto[5:16] }} + {%- else %}未开启{% endif %} +
+
+ +
+ 恢复会覆盖当前全部数据(账号、用量、配置一起换成归档里的那一份)。 + 系统会在恢复前自动先备份一次当前库并保留在列表里,恢复错了可以再恢复到那一份。 + 恢复完成后所有既有登录会话失效,需要重新登录。 +
+ +
+
+
+

自动备份设置

+ 实例级 +
+
+ {% set b = num_settings %} + + + + +

+ 自动备份在调度线程里执行,有采集任务在跑时会自动让路、下一轮再打。 + 备份使用 SQLite 的在线备份 API,采集写入期间也能拿到一致快照 —— + 这一点是手工 cp usage.sqlite 做不到的。 + 每份归档打完后按「保留份数」清理最旧的那些。 +

+
+
+ +
+
+

归档里有什么

+ 安全提示 +
+ + + + + + +
备份目录{{ info.dir }}
内容 + 所有 *.sqlite 的一致性快照
+ instance.json(含两把主密钥)
+ manifest.json(时间、行数、积分、逐文件校验值) +
为什么带上密钥 + 没有 instance.json 里的 cookie_key,归档里的凭证密文 + 就永远解不开 —— 那样的「恢复」等于把所有账号的 Cookie 弄丢。 +
因此 + 归档等于全库数据 + 密钥,下载后请当作机密文件保管; + 它不会进入代码仓库、也不会被打进镜像(见 .gitignore / + .dockerignore)。 +
目录独立 + 备份目录刻意不在 data/ 里面:容器里 data/ 是数据卷, + docker compose down -v 会把正本和副本一起删掉。 + 容器里它挂在独立卷 wb_backups 上。 +
+
+
+ +
+
+

备份列表

+ 新 → 旧,共 {{ rows|length }} 条(含已丢失的条目) +
+
+ + + + + + + + {% for r in rows %} + + + + + + + + + + + + {% else %} + + {% endfor %} + +
文件名生成时间来源大小记录积分账号库版本操作
+ {{ r.filename }} + {% if not r.exists %}
文件已丢失{% endif %} +
{{ r.created_at or '—' }} + + {{ r.trigger }} + {# CLI 造的备份 trigger 与 actor 都是 "cli",印两遍是纯噪音; + 只在两者不同时(auto/system、manual/张三)才补一行操作者 #} + {% if r.actor and r.actor != r.trigger %} +
{{ r.actor }}{% endif %} +
{{ r.size_h }}{% if r.records %}{{ '{:,}'.format(r.records) }}{% else %}—{% endif %}{% if r.credits %}{{ '%.2f'|format(r.credits) }}{% else %}—{% endif %}{% if r.users %}{{ r.users }}{% else %}—{% endif %}uv={{ r.schema_ver }} + {% if r.exists %} + 下载 + + + {% else %} + + {% endif %} +
还没有任何备份。点右上角「立即备份」生成第一份。
+
+

+ 列表里的「记录 / 积分 / 账号」是归档生成时的计数,用来挑一份合适的恢复 —— + 恢复前程序还会再校验一次归档的完整性与库结构版本,校验不过不会动你的数据。 + 档案文件名只能由本程序生成(服务端会拒绝任何带路径的文件名)。 +

+
+ +{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/workbuddy_portal/web/templates/base.html b/workbuddy_portal/web/templates/base.html index 5455641..93f2605 100644 --- a/workbuddy_portal/web/templates/base.html +++ b/workbuddy_portal/web/templates/base.html @@ -32,6 +32,9 @@ {# 日志管理里是实例运行信息(数据库路径 / 账号名 / 来源 IP),仅管理员可见; 服务端另有 @admin_required 兜底,这里隐藏只是不给出会 403 的死链。 #} {% if cur.is_admin %} + 备份管理 + {% endif %} + {% if cur.is_admin %} 日志管理 {% endif %} {% if cur.is_admin %} diff --git a/workbuddy_portal/web/templates/profile.html b/workbuddy_portal/web/templates/profile.html index 612930e..e41ea2e 100644 --- a/workbuddy_portal/web/templates/profile.html +++ b/workbuddy_portal/web/templates/profile.html @@ -10,6 +10,8 @@ @@ -57,12 +59,32 @@ -

至少 {{ pwd_min }} 位,且需包含大写字母、小写字母、数字、符号中的至少两类。 - 修改成功后当前会话仍有效,不必重新登录。

+

至少 {{ pwd_min }} 位,且需包含大写字母、小写字母、数字、符号中的至少两类, + 且不能是常见弱口令。 + 修改成功后其他设备上的登录会立刻失效(本机这次会话保留,不必重新登录)—— + 「怀疑被盗所以改密码」才能真正生效。

+
+
+

导出我的全部数据

+ 数据可携带 +
+

+ 打包下载属于你账号的所有内容:用量明细、采集历史、与你相关的操作审计、 + 以及账号信息与对你有效的配置。 +
不含 Cookie 明文(只写「配没配、多少字符、什么时候更新的」)、 + 也不含其他任何账号的数据与实例级运行日志。 +

+ +

导出动作按账号有最小间隔(10 秒)保护,避免脚本反复触发整表扫描。

+
+

我的采集凭证

diff --git a/workbuddy_portal/web/templates/tasks.html b/workbuddy_portal/web/templates/tasks.html index ae90277..2bb2cc5 100644 --- a/workbuddy_portal/web/templates/tasks.html +++ b/workbuddy_portal/web/templates/tasks.html @@ -31,7 +31,10 @@

本地时区,逗号分隔,支持 HH:MM(也可只写 9)。 - 保存后立即生效;已经过去且不再存在的时刻会被清掉,新的时刻当天就会接管。

+ 保存后立即生效;已经过去且不再存在的时刻会被清掉,新的时刻当天就会接管。 +
最多 {{ max_slots }} 个时刻(当前 {{ sch.times|length }} 个)—— 时刻数量直接决定 + 采集频次,是对外提供服务时控制云端压力的旋钮。需要更多请先到「配置管理」 + 把「每日调度时刻上限」调大。

+

指定区间重新拉取云端明细,已存在的记录按 RequestID 去重,不会重复计入。 - 区间越大耗时越长(云端按天分页拉取)。

+
单次最长 {{ max_days }} 天(超过会被服务端拒绝,请分批补), + 且同一账号两次采集之间需间隔 {{ min_gap }} 秒、有任务在跑时不能再发起 —— + 这三条是为了避免把云端接口与本站线程池打满。 + 手动点「立即采集一次」只走增量(从最后一条记录续拉),通常几秒完成。

diff --git a/workbuddy_portal/web/views.py b/workbuddy_portal/web/views.py index 0837d8f..a1f1898 100644 --- a/workbuddy_portal/web/views.py +++ b/workbuddy_portal/web/views.py @@ -28,13 +28,16 @@ """ import csv import io +import json import os import sqlite3 +import tempfile +import zipfile from flask import (Blueprint, current_app, flash, jsonify, redirect, render_template, request, send_from_directory, session, url_for) -from .. import collect, config, db, query, scheduler, security +from .. import backup, collect, config, db, query, scheduler, security from ..security import (admin_required, current_user, is_admin, login_required, safe_next) @@ -42,7 +45,13 @@ bp = Blueprint("views", __name__) def _ip(): - return request.headers.get("X-Forwarded-For", request.remote_addr or "").split(",")[0].strip() + """客户端地址。**全站统一走 security.client_ip()**。 + + 原来这里直接取 `X-Forwarded-For` 的第 0 段,等于把「来源 IP」交给请求方 + 自己申报:验证码出图限速、注册配额、登录锁定三道 IP 防线会一起失效。 + 具体取法与开关见 security.client_ip 的注释。 + """ + return security.client_ip() def _uid(): @@ -104,10 +113,15 @@ def login(): username = (request.form.get("username") or "").strip() pwd = request.form.get("password") or "" + # 先记一次「尝试」(含成功)。只按失败计数会被「慢慢撞」绕过: + # 攻击者只要把失败次数控制在阈值以下就能无限试。 + security.note_try(ip) left = security.auth_locked(ip, username) if left: - security.audit_login_fail(conn, username, "已锁定,剩余 %d 秒" % left, ip) - flash("登录失败次数过多,请 %d 秒后再试" % left, "error") + reason = security.auth_block_reason(ip, username) + security.audit_login_fail(conn, username, + "已限速(%s),剩余 %d 秒" % (reason, left), ip) + flash(security.auth_block_message(reason, left), "error") return render_template("login.html", **_login_ctx(conn, next_url=nxt, username=username, need_captcha=True)), 429 @@ -127,7 +141,9 @@ def login(): if user is None: n = security.note_auth_fail(ip, username) security.audit_login_fail(conn, username, err + "(第 %d 次)" % n, ip) - flash("%s(剩余尝试 %d 次)" % (err, max(0, config.MAX_LOGIN_FAILS - n)), "error") + # 不再报「剩余 N 次」:用户名维度的计数已经在攻击者手里了, + # 报出来的数字会变成「还差几次就能把这个人锁住」的倒计时。 + flash("%s(本来源连续失败 %d 次)" % (err, n), "error") # 必须把 next 显式回填:失败后 request.args 为空, # 若模板从 request.args 取值会导致跳转目标丢失(历史 bug)。 return render_template("login.html", @@ -176,9 +192,11 @@ def register(): ctx = _register_ctx(username=username, display_name=display, email=email, need_captcha=True) + security.note_try(ip) left = security.auth_locked(ip, username) if left: - flash("操作过于频繁,请 %d 秒后再试" % left, "error") + reason = security.auth_block_reason(ip, username) + flash(security.auth_block_message(reason, left), "error") return render_template("register.html", **ctx), 429 # 注册一律要验证码:这是唯一能让陌生人写库的入口 @@ -313,9 +331,14 @@ def tasks(): " LIMIT ? OFFSET ?", (uid, size, (page - 1) * size)).fetchall() s = db.get_settings(conn, uid=uid) pages = max(1, (total + size - 1) // size) + # 三道闸门的当前取值,直接交给页面:前端据此提前禁用/限位, + # 而不是让用户填完了再吃一个 400/409/429。 return render_template("tasks.html", runs=runs, sch=_sch_info(conn, uid), s_times=s.get("schedule_times") or "", s_grace=s.get("catch_up_grace_hours") or "12", + max_slots=s.get("max_schedule_slots_per_day") or "6", + max_days=collect.max_range_days(conn), + min_gap=collect.min_interval_seconds(conn), can_edit=is_admin(), page=page, pages=pages, total=total, page_window=_page_window(page, pages), @@ -572,6 +595,190 @@ def records_export(): return resp +# ---------------- 备份管理(仅管理员) ---------------- +@bp.get("/backups") +@admin_required +def backups_page(): + """备份管理 —— **仅管理员**。 + + 这一页能下载整库归档、也能把整库恢复回某个时刻,权限等价于 + 「拿到所有人的数据并覆盖它」,所以必须是管理员专属:页面用 + @admin_required,接口层另有同样的一层。 + """ + conn = db.get_db() + backup.sync_index(conn) # 磁盘才是事实来源,进页面对一次账 + rows = backup.listing(conn) + last_auto = backup.last_auto_at(conn) + nxt = backup.next_auto_at(conn) + info = { + "dir": config.BACKUP_DIR, + "count": len([r for r in rows if r["exists"]]), + "total": backup.human(backup.total_bytes(conn)), + "enabled": db.get_bool(conn, "backup_enabled", True), + "interval": db.get_int(conn, "backup_interval_hours", 24), + "keep": db.get_int(conn, "backup_keep", 7), + "last_auto": last_auto or "—", + # 从来没有跑过自动备份时,next_auto_at() 返回的是**当前时间** + # (语义是「马上就轮到它」)。直接印成时间会让人以为那是个已经过去的 + # 计划点,所以这里区分成「还没跑过」与「下次某时刻」两种显示。 + "next_auto": nxt.strftime("%Y-%m-%d %H:%M:%S") if (nxt and last_auto) else "—", + "never_auto": not last_auto, + "db_bytes": backup.human(os.path.getsize(config.SQLITE_PATH) + if os.path.exists(config.SQLITE_PATH) else 0), + } + s = db.get_settings(conn) + return render_template("backups.html", rows=rows, info=info, s=s, + num_settings=config.NUM_SETTINGS, active="backups") + + +# ---------------- 个人数据导出(每个账号都能导自己的) ---------------- +def _zip_stream(buf, filename, mimetype="application/zip"): + """把已生成好的临时缓冲流给浏览器,并在流结束后关掉它。""" + from flask import Response + + def gen(): + try: + buf.seek(0) + while True: + chunk = buf.read(65536) + if not chunk: + break + yield chunk + finally: + try: + buf.close() + except Exception: # noqa: BLE001 + pass + + resp = Response(gen(), mimetype=mimetype, + headers={"Content-Disposition": 'attachment; filename="%s"' % filename}) + resp.headers["X-Accel-Buffering"] = "no" + return resp + + +@bp.get("/profile/export") +@login_required +def profile_export(): + """导出「我的全部数据」。 + + 这是普通账号的数据可携带出口,所以**只含本人的数据**,且 + **绝不含 Cookie 明文**(只写「有没有配、多少字符、什么时候更新的」)。 + 归档里放使用记录、采集历史、本人审计与本人配置四份,另加一份说明。 + """ + u = current_user() + uid = u["id"] + ok, wait = security.action_allowed("export:%d" % uid, 10) + if not ok: + return render_template("error.html", code=429, + message="导出太频繁了,请 %d 秒后再试" % wait), 429 + + conn = db.get_db() + buf = tempfile.SpooledTemporaryFile(max_size=16 * 1024 * 1024) + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: + # ---- 1. 使用记录(与官网 xlsx、CSV 导出同构的列)---- + with z.open("使用记录.csv", "w") as f: + f.write("\ufeff".encode("utf-8")) + b = io.StringIO() + w = csv.writer(b, lineterminator="\r\n") + w.writerow(collect.FIELDS) + f.write(b.getvalue().encode("utf-8")) + for r in query.iter_records(conn, uid, None, None): + b.seek(0) + b.truncate(0) + w.writerow([r["request_id"], "%.2f" % r["credits"], r["prompt"] or "", + r["model"], r["client"], r["ts"]]) + f.write(b.getvalue().encode("utf-8")) + + # ---- 2. 采集历史 ---- + with z.open("采集历史.csv", "w") as f: + f.write("\ufeff".encode("utf-8")) + b = io.StringIO() + w = csv.writer(b, lineterminator="\r\n") + w.writerow(["id", "触发方式", "状态", "开始", "结束", "耗时ms", + "窗口起", "窗口止", "云端返回", "新增", "重复", "存档总数", + "冲突", "结论"]) + for r in conn.execute( + "SELECT id,trigger,status,started_at,finished_at,duration_ms,win_from," + "win_to,fetched,added,dup,total,conflicts,message FROM collect_runs" + " WHERE user_id=? ORDER BY id", (uid,)): + b.seek(0) + b.truncate(0) + w.writerow(list(r)) + f.write(b.getvalue().encode("utf-8")) + + # ---- 3. 本人相关的操作审计 ---- + with z.open("操作审计.csv", "w") as f: + f.write("\ufeff".encode("utf-8")) + b = io.StringIO() + w = csv.writer(b, lineterminator="\r\n") + w.writerow(["时间", "操作者", "动作", "说明", "来源 IP"]) + for r in conn.execute( + "SELECT at,actor,action,detail,ip FROM audit_log" + " WHERE user_id=? ORDER BY id", (uid,)): + b.seek(0) + b.truncate(0) + w.writerow(list(r)) + f.write(b.getvalue().encode("utf-8")) + + # ---- 4. 账号与有效配置(凭证只回状态)---- + row = db.user_by_id(conn, uid) + st = db.secret_state(conn, "cookie", uid) + cfg = db.get_settings(conn, uid=uid) + for k in [k for k in list(cfg) if config.is_internal_key(k)]: + cfg.pop(k, None) + cfg.pop("cookie", None) + snaps = {k: v for k, v in cfg.items() + if not (isinstance(v, str) and len(v) > 200)} + payload = { + "导出时间": db.now_str(), + "程序版本": _app_version(), + "账号": { + "id": row["id"], "用户名": row["username"], + "显示名": row["display_name"], "邮箱": row["email"], + "角色": "管理员" if row["is_admin"] else "普通账号", + "状态": row["status"], "注册时间": row["created_at"], + "注册来源 IP": row["register_ip"], + "最后登录": row["last_login_at"], "登录次数": row["login_count"], + }, + "数据量": { + "记录条数": conn.execute("SELECT COUNT(*) FROM usage_records" + " WHERE user_id=?", (uid,)).fetchone()[0], + "积分合计": round(conn.execute( + "SELECT COALESCE(SUM(credits),0) FROM usage_records" + " WHERE user_id=?", (uid,)).fetchone()[0], 2), + }, + "凭证状态": { + "Cookie": ("已配置 %d 字符,尾部 …%s" % (st["chars"], st["tail"])) if st["set"] + else ("无法解密" if st["broken"] else "未配置"), + "说明": "出于安全考虑,导出文件里不含 Cookie 明文;如需迁移请到「配置管理」重新粘贴。", + }, + "有效配置": snaps, + } + with z.open("我的账号与配置.json", "w") as f: + f.write(json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")) + + with z.open("说明.txt", "w") as f: + f.write(("本归档是账号「%s」在本站的全部数据副本。\n\n" + "包含:\n" + " 使用记录.csv —— 你的全部用量明细(与官网导出同构)\n" + " 采集历史.csv —— 你的采集任务运行历史\n" + " 操作审计.csv —— 与你账号相关的操作记录\n" + " 我的账号与配置.json —— 账号信息与对你有有效的配置\n\n" + "不包含:Cookie 明文、任何他人的数据、实例级运行日志。\n" + "导出时间:%s\n程序版本:%s\n" + % (u["username"], db.now_str(), _app_version())).encode("utf-8")) + + name = "my-data_%s_%s.zip" % (u["username"], db.now_str()[:10]) + db.audit(conn, "export_self", u["username"], "导出个人全部数据(%s)" % name, + _ip(), uid) + return _zip_stream(buf, name) + + +def _app_version(): + from .. import __version__ + return __version__ + + # ---------------- 兼容旧地址 ---------------- @bp.get("/index.html") def legacy_index():