feat: 新增备份恢复与公网加固

- 新增备份管理页与 API:在线快照、自动周期备份、按份数清理、下载、一键恢复(恢复前自动兜底)
- 新增 /profile/export,普通用户可导出本人全部数据(不含 Cookie 明文)
- 修复 X-Forwarded-For 可伪造导致三道 IP 防线失效,统一走 client_ip() 取客户端地址
- 取消 admin123 硬编码默认口令,留空则生成随机初始口令并仅打印一次
- .dockerignore 排除 backups/ 并加构建期断言,防止密钥随镜像分发
- 新增会话版本号,改密/停用/删除及恢复备份后其他会话立即失效
- 新增容器资源上限、采集跨度硬顶 31 天、重操作最小间隔与并发 409
- 新增访问日志、HSTS 条件下发、口令黑名单、验证码抗模板匹配、instance.json 0600
- 版本号升至 1.4.0,同步更新 README、SECURITY、.env.example 与 compose 配置
这个提交包含在:
2026-09-18 08:46:34 +08:00
父节点 1bf961f6b3
当前提交 3751dffef9
共修改 30 个文件,包含 3150 行新增和 241 行删除
+76 -8
查看文件
@@ -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)"