Compare commits

...
26 Commits
Author SHA1 Message Date
gouki cc8c91ddd9 feat(migration): 换宿主平台迁移能力(打包/恢复脚本 + runbook)
make_migration_bundle:sqlite backup API 在线快照(无需停服)打包 data/(两库+escrow+backups)与 .env,含逐文件 sha256 清单与版本/commit 元信息。restore_migration_bundle:sha256 逐文件校验(篡改即拒绝)、目标机原状态留 pre-restore 备份、兼容旧版 Python 的 tar 解包路径校验,并打印 serve/agent/CORS/源机下线等手工步骤。docs/migration-runbook.md:迁移面清单、可选预处理(agent 去 IP 化)、七步迁移、十项验收、回滚与灾难恢复。本地隔离演练验证:密码解密一致、2FA 出码可用、escrow+RESTORE_KEY 可解回 MASTER_KEY、篡改包被拦截。
2026-09-05 23:20:45 +00:00
gouki 6865bb7914 fix(backup): 备份链路纳入 master_key.escrow(覆盖式单份+S3)
escrow 是 MASTER_KEY 遗失恢复链的一环:整机丢失时仅有库备份+离线 RESTORE_KEY 仍无法恢复,缺的正是托管档。随 assets 周期备份,固定文件名覆盖(多版本无意义),未建立托管时 skip。
2026-09-05 23:10:46 +00:00
gouki d711ad5827 feat(vault): 凭据库(密码+2FA 动态码)与 MASTER_KEY 密钥托管
credentials 表为登录凭据唯一事实源(站点×登录方式,含 oauth/2FA);账号密码读写重定向凭据层并幂等迁移历史数据;TOTP 按 RFC6238 零依赖自实现,绑定需当前动态码校验;Key Escrow 防 MASTER_KEY 遗失;前端新增凭据库视图与账号 2FA 联动。64 pytest + 16 浏览器端到端验证通过。
2026-09-05 15:54:41 +00:00
gouki 2fbb658126 fix(security): funnel公网流量强制校验API_KEY双保险(Tailscale-Funnel-Request头,不可伪造) 2026-08-15 22:24:47 +00:00
gouki 8fea6c2288 fix(security): Tailscale内网(100.64/10)免API_KEY+外部强制校验;修复账号重复提交400(提交锁+唯一约束兜底) 2026-08-15 22:19:51 +00:00
gouki b4705331c0 fix(account): 支持跨平台同名账号——(platform,name)联合唯一+Asset.account_id外键重构 2026-08-11 07:27:01 +00:00
gouki bc1b804931 feat(account): 账号登录密码支持查看明文——新增GET /accounts/{id}/password解密接口+前端🔑查看弹窗(8s自动关闭/一键复制) 2026-08-10 14:51:52 +00:00
gouki 09a7b7aa42 test(deploy): timer注释标记,验证sync_units单元自动同步链路 2026-08-09 23:41:52 +00:00
gouki 88e7050cf0 fix(deploy): 版本比对统一用短哈希,修复永不匹配的bug 2026-08-09 23:08:39 +00:00
gouki 8dbccdb238 chore: 版本升至0.3.7(零SSH自动化部署:单元自动同步+1分钟更新+一键deploy.sh) 2026-08-09 22:45:45 +00:00
gouki 750a986821 fix(deploy): update.sh改用reset --hard同步远端,本地误改不再阻塞自动部署 2026-08-09 21:53:05 +00:00
gouki e6323c5e46 fix(deploy): 恢复update.sh可执行位(755) 2026-08-09 21:28:55 +00:00
gouki 270ffc81f9 fix(deploy): 恢复update.sh可执行位;ExecStart改bash调用不再依赖可执行位 2026-08-09 21:18:27 +00:00
gouki 32a4e0da36 fix(deploy): 变量引用加花括号,避免多字节字符粘连导致unbound variable 2026-08-09 20:40:34 +00:00
gouki e4a8cc68b4 feat(deploy): 零SSH自动化部署——update.sh自动同步systemd单元/更新timer缩至1分钟/新增deploy.sh一键部署验证脚本 2026-08-09 20:27:02 +00:00
gouki dc2134cf5a refactor: 凭证下沉账号层——平台不再持有API凭证;账号支持登录凭证+API配置,SDK测试/同步改为账号维度,同步产出自动挂账号;含存量数据自动迁移 2026-08-09 16:50:45 +00:00
gouki cf18cadb6b fix(ui): 账号编辑表单提升z-index至60,修复被账号查看弹窗(z-50)遮挡 2026-08-09 00:29:49 +00:00
gouki ee5a4c81cf feat: 平台卡片「关联N个资产」点击跳转资产页并按平台筛选(后端/api/assets新增provider参数,资产页可清除筛选标签) 2026-08-09 00:13:16 +00:00
gouki 74f9688ba1 fix(cache): 入口HTML加no-cache,杜绝启发式缓存持有旧?v=版本号导致部署后看到旧版 2026-08-08 23:42:40 +00:00
gouki 6829b211ac feat(ui): 平台页默认隐藏未使用平台(无资产/账号/API配置),可一键展开全部,偏好持久化 2026-08-08 23:36:13 +00:00
gouki fcb3153c50 refactor(ui): 账号改为平台→账号→资产三层交互(平台卡片入口+展开看名下资产,移除平铺账号区) 2026-08-08 22:16:14 +00:00
gouki 1c93ac00eb feat(ui): 账号列表以说明为主标题(适配代管场景),账号标识为副;弹窗表单说明字段前置 2026-08-08 22:00:30 +00:00
gouki a33a2b71de feat: 版本号管理机制(VERSION文件+git哈希,部署/启动/UI/health均展示) + 平台账号管理(accounts表CRUD,资产所属账号候选联动,重命名同步引用) 2026-08-08 20:07:03 +00:00
gouki 4883837dda fix: 阿里云/腾讯云list_vps补分页(超100台实例不再漏同步) 2026-08-05 22:55:41 +00:00
gouki 4dba505d82 fix+perf: SQLite连接busy_timeout修复并发锁/静态版本号缓存/删除资产批量清理/安全检查最新记录子查询修正/AI余额与SSL探测并发化/密钥常量时间比较 2026-08-05 22:49:01 +00:00
gouki 4f855ba0da chore: .gitignore 忽略 .env.local(防止 S3 密钥误提交) 2026-08-05 12:41:17 +00:00
52 changed files with 4378 additions and 232 deletions
+2 -2
View File
@@ -4,8 +4,8 @@
APP_NAME=VPS 资产管理系统 APP_NAME=VPS 资产管理系统
# 写操作(POST/PUT/DELETE)鉴权密钥 # 写操作(POST/PUT/DELETE)鉴权密钥
# 留空 = 不校验(适用于纯 Tailscale 内网环境) # Tailscale 内网(100.64.0.0/10,如 100.89.x.x)与本机来源自动放行,无需携带;
# 配置后,所有写操作需在请求头携带 X-API-Key: <此密钥> # 外部来源必须携带 X-API-Key: <此密钥>;留空则外部来源一律拒绝(仅内网可用)
API_KEY= API_KEY=
# Agent 上报鉴权密钥(可选) # Agent 上报鉴权密钥(可选)
+1
View File
@@ -3,5 +3,6 @@ __pycache__/
.venv/ .venv/
data/ data/
.env .env
.env.local
.DS_Store .DS_Store
.pytest_cache/ .pytest_cache/
+1
View File
@@ -0,0 +1 @@
0.4.0
+10 -2
View File
@@ -79,9 +79,13 @@ class AliyunAdapter(BaseAdapter):
return {"ok": False, "message": str(e)} return {"ok": False, "message": str(e)}
def list_vps(self) -> list: def list_vps(self) -> list:
data = self._call("DescribeInstances", {"PageSize": "100"}) # 分页拉取:单次最多 100 条,按 TotalCount 翻页,避免实例超 100 台时漏同步
result = [] result = []
for inst in data.get("Instances", {}).get("Instance", []): page = 1
while True:
data = self._call("DescribeInstances", {"PageSize": "100", "PageNumber": str(page)})
instances = data.get("Instances", {}).get("Instance", [])
for inst in instances:
public_ips = inst.get("PublicIpAddress", {}).get("IpAddress", []) public_ips = inst.get("PublicIpAddress", {}).get("IpAddress", [])
eip = inst.get("EipAddress", {}).get("IpAddress") eip = inst.get("EipAddress", {}).get("IpAddress")
mem_mb = inst.get("Memory") or 0 mem_mb = inst.get("Memory") or 0
@@ -100,4 +104,8 @@ class AliyunAdapter(BaseAdapter):
raw=inst, raw=inst,
) )
) )
total = int(data.get("TotalCount") or 0)
if not instances or page * 100 >= total:
break
page += 1
return result return result
+10 -2
View File
@@ -94,10 +94,14 @@ class TencentAdapter(BaseAdapter):
return {"ok": False, "message": str(e)} return {"ok": False, "message": str(e)}
def list_vps(self) -> list: def list_vps(self) -> list:
data = self._call("DescribeInstances", {"Limit": 100}) # 分页拉取:单次最多 100 条,按 TotalCount 翻页,避免实例超 100 台时漏同步
result = [] result = []
status_map = {"RUNNING": "active", "STOPPED": "stopped"} status_map = {"RUNNING": "active", "STOPPED": "stopped"}
for inst in data.get("InstanceSet", []): offset = 0
while True:
data = self._call("DescribeInstances", {"Limit": 100, "Offset": offset})
inst_set = data.get("InstanceSet", [])
for inst in inst_set:
public_ips = inst.get("PublicIpAddresses", []) public_ips = inst.get("PublicIpAddresses", [])
result.append( result.append(
NormalizedVPS( NormalizedVPS(
@@ -114,4 +118,8 @@ class TencentAdapter(BaseAdapter):
raw=inst, raw=inst,
) )
) )
total = int(data.get("TotalCount") or 0)
offset += len(inst_set)
if not inst_set or offset >= total:
break
return result return result
+4 -2
View File
@@ -1,7 +1,8 @@
"""应用配置 """应用配置
从项目根目录的 .env 文件加载配置(若存在)。 从项目根目录的 .env 文件加载配置(若存在)。
API_KEY 留空表示不启用写操作鉴权(适用于纯内网环境)。 API_KEY 用于保护写操作:Tailscale 内网/本机来源放行,外部来源必须携带正确密钥;
留空则外部来源一律拒绝(仅内网可用)。
""" """
import os import os
@@ -17,7 +18,8 @@ class Settings:
"""应用配置项""" """应用配置项"""
APP_NAME: str = os.getenv("APP_NAME", "VPS 资产管理系统") APP_NAME: str = os.getenv("APP_NAME", "VPS 资产管理系统")
# 写操作(POST/PUT/DELETE)鉴权密钥;留空则不校验 # 写操作(POST/PUT/DELETE)鉴权密钥;Tailscale 内网/本机来源忽略校验
# 外部来源必须携带 X-API-Key(留空则外部来源直接拒绝)
API_KEY: str = os.getenv("API_KEY", "") API_KEY: str = os.getenv("API_KEY", "")
# Agent 上报鉴权密钥;配置后 Agent 上报需携带 X-Agent-Key # Agent 上报鉴权密钥;配置后 Agent 上报需携带 X-Agent-Key
AGENT_KEY: str = os.getenv("AGENT_KEY", "") AGENT_KEY: str = os.getenv("AGENT_KEY", "")
+39
View File
@@ -2,6 +2,10 @@
用于加密存储 SSH 密钥、密码、API Key、平台 API 配置等敏感信息。 用于加密存储 SSH 密钥、密码、API Key、平台 API 配置等敏感信息。
MASTER_KEY 从 .env 读取,不入库。 MASTER_KEY 从 .env 读取,不入库。
密钥托管(Key Escrow):MASTER_KEY 遗失时全量密文不可解,故提供
build_escrow/recover_master_key——用离线保管的 RESTORE_KEY 加密 MASTER_KEY
本身生成 escrow 文件(随 data/ 备份流转),丢失时凭 RESTORE_KEY 找回。
""" """
from functools import lru_cache from functools import lru_cache
@@ -40,3 +44,38 @@ def decrypt(token: Optional[str]) -> Optional[str]:
return _get_fernet().decrypt(token.encode()).decode() return _get_fernet().decrypt(token.encode()).decode()
except (InvalidToken, ValueError): except (InvalidToken, ValueError):
return None return None
# ---------------- 密钥托管(Key Escrow ---------------- #
ESCROW_PREFIX = "v1:"
def _fernet_from_key(key: str) -> Fernet:
"""按给定密钥构造独立 Fernet 实例(不读 settings、不走进程缓存)"""
return Fernet(key.encode() if isinstance(key, str) else key)
def build_escrow(restore_key: str) -> str:
"""用 RESTORE_KEY 加密当前 MASTER_KEY,生成 escrow token'v1:' + Fernet token
escrow 文件内容只有离线保管的 RESTORE_KEY 能解开:数据库/备份泄露也无法
还原 MASTER_KEY。恢复时凭 escrow 找回 MASTER_KEY,全量密文零迁移。
"""
master_key = settings.MASTER_KEY
if not master_key:
raise RuntimeError("MASTER_KEY 未配置,无法建立托管(请先在 .env 配置 MASTER_KEY")
return ESCROW_PREFIX + _fernet_from_key(restore_key).encrypt(master_key.encode()).decode()
def recover_master_key(restore_key: str, escrow: str) -> str:
"""用 RESTORE_KEY 解密 escrow token,还原 MASTER_KEY 明文
钥匙错误、前缀缺失或文件损坏时抛 ValueError。
"""
if not escrow.startswith(ESCROW_PREFIX):
raise ValueError("escrow 格式无效(缺少 v1: 前缀)")
try:
return _fernet_from_key(restore_key).decrypt(escrow[len(ESCROW_PREFIX):].encode()).decode()
except (InvalidToken, ValueError) as e:
raise ValueError("恢复钥匙错误或 escrow 已损坏") from e
+77 -10
View File
@@ -1,37 +1,104 @@
"""API Key 认证依赖 """API Key 认证依赖
用于保护写操作(POST/PUT/DELETE)。 用于保护写操作(POST/PUT/DELETE)。
- 若 settings.API_KEY 为空,则放行所有请求(纯内网场景)。 - 来自可信内网(Tailscale 100.64.0.0/10、本机回环)的请求直接放行;
- 若已配置,则要求请求头携带正确的 X-API-Key,否则返回 401 - 其余来源:API_KEY 已配置则校验 X-API-Key,未配置则拒绝(防止外部裸奔)
""" """
import hmac
import ipaddress
from typing import Optional from typing import Optional
from fastapi import Header, HTTPException, status from fastapi import Header, HTTPException, Request, status
from app.core.config import settings from app.core.config import settings
# 可信来源网段:Tailscale 使用 CGNAT 100.64.0.0/10 分配内网 IP(如 100.89.x.x);
# 回环地址覆盖 tailscale serve 代理转发与本地开发场景。
TRUSTED_NETWORKS = [
ipaddress.ip_network("100.64.0.0/10"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("::1/128"),
]
async def require_api_key(
x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"), def _key_matches(provided: Optional[str], expected: str) -> bool:
) -> None: """常量时间比较密钥,避免时序旁路泄露密钥长度/前缀信息"""
"""校验 API Key(可选启用)""" if not provided:
return False
return hmac.compare_digest(provided.encode(), expected.encode())
def _client_ip(request: Request) -> str:
"""获取客户端真实 IP
tailscale serve 转发到本机时 request.client 为回环地址,真实来源在
X-Forwarded-For 头中;仅信任来自回环的 XFF,防止外部直连时伪造
X-Forwarded-For 绕过鉴权。
"""
host = request.client.host if request.client else ""
if host in ("127.0.0.1", "::1"):
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return host
def _is_trusted(ip: str) -> bool:
"""判断来源 IP 是否属于可信内网(Tailscale 网段 / 本机回环)"""
if not ip:
return False
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return False
return any(addr in net for net in TRUSTED_NETWORKS)
def _is_funnel_request(request: Request) -> bool:
"""判断是否 tailscale funnel 公网流量
tailscale serve 转发时会把客户端伪造的 Tailscale-* 头清空后按真实
情况设置:tailnet 内无此头,funnel 公网流量为 "?1"(不可伪造)。
"""
return request.headers.get("tailscale-funnel-request") == "?1"
async def _require_key(x_api_key: Optional[str]) -> None:
"""按外部来源校验 API Key(可信内网已在调用方排除)"""
if not settings.API_KEY: if not settings.API_KEY:
return raise HTTPException(
if x_api_key != settings.API_KEY: status_code=status.HTTP_401_UNAUTHORIZED,
detail="外部访问需要 API Key,请先在 .env 配置 API_KEY 并设置到前端",
)
if not _key_matches(x_api_key, settings.API_KEY):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="API Key 无效或缺失", detail="API Key 无效或缺失",
) )
async def require_api_key(
request: Request,
x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
) -> None:
"""校验 API KeyTailscale 内网/本机放行,外部来源必须携带有效 Key"""
# funnel 公网流量:即使来源 IP 恰在 tailnet 网段也强制校验(双保险)
if _is_funnel_request(request):
await _require_key(x_api_key)
return
if _is_trusted(_client_ip(request)):
return
await _require_key(x_api_key)
async def require_agent_key( async def require_agent_key(
x_agent_key: Optional[str] = Header(default=None, alias="X-Agent-Key"), x_agent_key: Optional[str] = Header(default=None, alias="X-Agent-Key"),
) -> None: ) -> None:
"""校验 Agent 上报 Key(可选启用)""" """校验 Agent 上报 Key(可选启用)"""
if not settings.AGENT_KEY: if not settings.AGENT_KEY:
return return
if x_agent_key != settings.AGENT_KEY: if not _key_matches(x_agent_key, settings.AGENT_KEY):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Agent Key 无效或缺失", detail="Agent Key 无效或缺失",
+116
View File
@@ -0,0 +1,116 @@
"""TOTP 动态验证码(RFC 6238,零依赖自实现)
用于凭据库 2FA:存储 base32 secret,按 30s 步长生成 6 位动态码。
不引第三方库(pyotp)的原因:零 SSH 部署链路不重装依赖,避免 update.sh
缺包导致服务自毁;正确性用 RFC 6238 附录 B 官方向量在 pytest 锚定
tests/test_totp.py)。
"""
import base64
import hashlib
import hmac
import secrets
import struct
import time
from urllib.parse import parse_qs, quote, unquote, urlparse
PERIOD = 30 # 步长(秒),标准值
DIGITS = 6 # 码位数,标准值
def b32decode(secret: str) -> bytes:
"""Base32 解码(容错:去空白、大写化、自动补 = 填充)"""
s = "".join(secret.split()).upper()
return base64.b32decode(s + "=" * ((-len(s)) % 8))
def random_secret() -> str:
"""生成 20 字节随机 base32 secret(供「生成随机密钥」入口)"""
return base64.b32encode(secrets.token_bytes(20)).decode().rstrip("=")
def _hotp(key: bytes, counter: int, digits: int = DIGITS) -> str:
"""RFC 4226 HOTPHMAC-SHA1(key, counter) 动态截断 → digits 位十进制码"""
digest = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest()
offset = digest[-1] & 0x0F
code = struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF
return str(code % (10 ** digits)).zfill(digits)
def totp_at(
secret_b32: str, ts: float | None = None, period: int = PERIOD, digits: int = DIGITS
) -> tuple[str, int]:
"""计算指定时刻的 TOTP 码
返回 (code, expires_in)expires_in 为当前码剩余有效秒数(前端倒计时用)。
ts 为 None 时取当前时间。
"""
now = time.time() if ts is None else ts
counter = int(now // period)
left = period - int(now % period)
return _hotp(b32decode(secret_b32), counter, digits), left
def verify(
secret_b32: str,
code: str,
window: int = 1,
ts: float | None = None,
period: int = PERIOD,
digits: int = DIGITS,
) -> bool:
"""校验用户输入的动态码(±window 个步进,容忍时钟偏差)
常量时间比较,防时序旁路。录入绑定时用 window=1 即可。
"""
code = "".join(code.split())
if not code.isdigit() or len(code) != digits:
return False
now = time.time() if ts is None else ts
base_counter = int(now // period)
key = b32decode(secret_b32)
return any(
hmac.compare_digest(_hotp(key, base_counter + off, digits), code)
for off in range(-window, window + 1)
)
def parse_otpauth_uri(uri: str) -> dict:
"""解析 otpauth://totp/Label?secret=...&issuer=...&period=30&digits=6
返回 {secret, issuer, account}Label 形如 "Issuer:account""account"
无 issuer 参数时从 Label 前缀提取。非 TOTP、缺 secret 或非标准参数抛 ValueError
(v1 仅支持 6 位/30s 标准参数,避免录入成功但生成码对不上)。
"""
parsed = urlparse(uri.strip())
if parsed.scheme != "otpauth":
raise ValueError("不是 otpauth:// 链接")
if parsed.netloc.lower() != "totp":
raise ValueError(f"仅支持 TOTP(当前类型:{parsed.netloc}")
params = parse_qs(parsed.query)
secret = (params.get("secret") or [""])[0]
if not secret:
raise ValueError("链接缺少 secret 参数")
period = int((params.get("period") or [PERIOD])[0])
digits = int((params.get("digits") or [DIGITS])[0])
if period != PERIOD or digits != DIGITS:
raise ValueError(f"暂仅支持 {DIGITS} 位/{PERIOD}s 标准参数(当前 {digits} 位/{period}s")
label = unquote(parsed.path.lstrip("/"))
issuer = (params.get("issuer") or [""])[0]
account = label
if ":" in label:
prefix, _, rest = label.partition(":")
account = rest or prefix
if not issuer:
issuer = prefix
return {"secret": secret, "issuer": issuer or None, "account": account or None}
def build_otpauth_uri(secret_b32: str, issuer: str | None, account: str | None) -> str:
"""重建 otpauth URI(导出/换机重新绑定用)"""
label = f"{issuer}:{account}" if issuer and account else (account or issuer or "")
q = f"secret={secret_b32}"
if issuer:
q += f"&issuer={quote(issuer)}"
return f"otpauth://totp/{quote(label)}?{q}"
+255 -12
View File
@@ -1,6 +1,6 @@
"""SQLite 数据库连接与初始化(分库) """SQLite 数据库连接与初始化(分库)
- assets.db Provider / Asset / VPSDetail / DomainDetail / AIAccount - assets.db Provider / Asset / VPSDetail / DomainDetail / AIAccount / Account
- metrics.dbMetricPoint / ServerInfo / SecurityCheck / EventLog - metrics.dbMetricPoint / ServerInfo / SecurityCheck / EventLog
""" """
@@ -16,26 +16,26 @@ DATA_DIR.mkdir(parents=True, exist_ok=True)
ASSETS_DB_URL = f"sqlite:///{DATA_DIR / 'assets.db'}" ASSETS_DB_URL = f"sqlite:///{DATA_DIR / 'assets.db'}"
METRICS_DB_URL = f"sqlite:///{DATA_DIR / 'metrics.db'}" METRICS_DB_URL = f"sqlite:///{DATA_DIR / 'metrics.db'}"
# timeoutsqlite3 内置 busy 等待(秒),随连接创建生效。
# SQLite 默认使用 NullPool(每请求新建连接),PRAGMA busy_timeout 只在单连接有效,
# 必须通过 connect_args 传递,否则并发写入(Agent 上报 + 清理任务)会报 database is locked。
assets_engine = create_engine( assets_engine = create_engine(
ASSETS_DB_URL, ASSETS_DB_URL,
echo=False, echo=False,
connect_args={"check_same_thread": False}, connect_args={"check_same_thread": False, "timeout": 30},
pool_pre_ping=True,
) )
metrics_engine = create_engine( metrics_engine = create_engine(
METRICS_DB_URL, METRICS_DB_URL,
echo=False, echo=False,
connect_args={"check_same_thread": False}, connect_args={"check_same_thread": False, "timeout": 30},
pool_pre_ping=True,
) )
def _enable_wal(engine) -> None: def _enable_wal(engine) -> None:
"""启用 WAL 模式,提升 SQLite 并发读写能力""" """启用 WAL 模式,提升 SQLite 并发读写能力journal_mode 持久化到库文件,设置一次即可)"""
import sqlalchemy as sa import sqlalchemy as sa
with engine.connect() as conn: with engine.connect() as conn:
conn.execute(sa.text("PRAGMA journal_mode=WAL")) conn.execute(sa.text("PRAGMA journal_mode=WAL"))
conn.execute(sa.text("PRAGMA busy_timeout=5000"))
_enable_wal(assets_engine) _enable_wal(assets_engine)
@@ -47,7 +47,8 @@ engine = assets_engine
def init_db() -> None: def init_db() -> None:
"""分库建表(幂等,可重复调用)""" """分库建表(幂等,可重复调用)"""
from app.models.asset import AIAccount, Asset, CloudflareDetail, DomainDetail, VPSDetail # noqa: F401 from app.models.asset import Account, AIAccount, Asset, CloudflareDetail, DomainDetail, VPSDetail # noqa: F401
from app.models.credential import Credential # noqa: F401
from app.models.monitor import ( # noqa: F401 from app.models.monitor import ( # noqa: F401
EventLog, EventLog,
MetricPoint, MetricPoint,
@@ -57,7 +58,7 @@ def init_db() -> None:
from app.models.provider import Provider # noqa: F401 from app.models.provider import Provider # noqa: F401
from app.models.ssl import SiteCert, Subdomain # noqa: F401 from app.models.ssl import SiteCert, Subdomain # noqa: F401
asset_models = [Provider, Asset, VPSDetail, DomainDetail, AIAccount, CloudflareDetail, Subdomain, SiteCert] asset_models = [Provider, Asset, VPSDetail, DomainDetail, AIAccount, CloudflareDetail, Subdomain, SiteCert, Credential, Account]
metric_models = [MetricPoint, ServerInfo, SecurityCheck, EventLog] metric_models = [MetricPoint, ServerInfo, SecurityCheck, EventLog]
SQLModel.metadata.create_all( SQLModel.metadata.create_all(
@@ -84,6 +85,10 @@ def _migrate_assets_db() -> None:
conn.execute(sa.text("ALTER TABLE assets ADD COLUMN renew_url VARCHAR")) conn.execute(sa.text("ALTER TABLE assets ADD COLUMN renew_url VARCHAR"))
if "cancel_url" not in cols: if "cancel_url" not in cols:
conn.execute(sa.text("ALTER TABLE assets ADD COLUMN cancel_url VARCHAR")) conn.execute(sa.text("ALTER TABLE assets ADD COLUMN cancel_url VARCHAR"))
if "account_id" not in cols:
conn.execute(sa.text("ALTER TABLE assets ADD COLUMN account_id INTEGER"))
# 资产账号引用从 name 字符串迁移到 account_id 外键(幂等)
_backfill_asset_account_id(conn)
if insp.has_table("providers"): if insp.has_table("providers"):
cols = {c["name"] for c in insp.get_columns("providers")} cols = {c["name"] for c in insp.get_columns("providers")}
if "last_synced_at" not in cols: if "last_synced_at" not in cols:
@@ -98,14 +103,42 @@ def _migrate_assets_db() -> None:
conn.execute(sa.text("ALTER TABLE ai_accounts ADD COLUMN api_key_encrypted VARCHAR")) conn.execute(sa.text("ALTER TABLE ai_accounts ADD COLUMN api_key_encrypted VARCHAR"))
# 迁移:将明文 api_key 加密后存入 api_key_encrypted,并清空原字段 # 迁移:将明文 api_key 加密后存入 api_key_encrypted,并清空原字段
_migrate_plaintext_api_keys(conn) _migrate_plaintext_api_keys(conn)
if insp.has_table("accounts"):
cols = {c["name"] for c in insp.get_columns("accounts")}
if "login_user" not in cols:
conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN login_user VARCHAR"))
if "login_password_encrypted" not in cols:
conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN login_password_encrypted VARCHAR"))
if "api_config_encrypted" not in cols:
conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN api_config_encrypted VARCHAR"))
if "last_synced_at" not in cols:
conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN last_synced_at DATETIME"))
if "credential_id" not in cols:
conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN credential_id INTEGER"))
# 凭证下沉:平台 api_config 迁到默认账号;AI 资产自动挂靠账号
_backfill_account_credentials(conn)
# 合并存量 (platform,name) 重复账号,为建唯一索引做准备
_dedupe_accounts(conn)
# 凭据中心化:账号登录密码搬入 credentials(唯一事实源)
_migrate_account_credentials_to_vault(conn)
def _migrate_indexes() -> None: def _migrate_indexes() -> None:
"""为已有数据库补充复合索引(create_all 不会为已存在的表补索引,IF NOT EXISTS 幂等)""" """为已有数据库补充复合索引(create_all 不会为已存在的表补索引,IF NOT EXISTS 幂等)"""
import sqlalchemy as sa import sqlalchemy as sa
# 旧的全局 name 唯一索引(早期 Account.name unique=True 产物)会阻止跨平台同名,
# 与新的 (platform,name) 联合唯一冲突,需先删除
with assets_engine.begin() as conn:
conn.execute(sa.text("DROP INDEX IF EXISTS ix_accounts_name"))
stmts = [ stmts = [
(assets_engine, "CREATE INDEX IF NOT EXISTS ix_assets_provider_ext_type ON assets (provider_id, external_id, asset_type)"), (assets_engine, "CREATE INDEX IF NOT EXISTS ix_assets_provider_ext_type ON assets (provider_id, external_id, asset_type)"),
(assets_engine, "CREATE INDEX IF NOT EXISTS ix_assets_account_id ON assets (account_id)"),
# (platform, name) 联合唯一:同邮箱可跨平台复用,同平台内不重名
(assets_engine, "CREATE UNIQUE INDEX IF NOT EXISTS uq_accounts_platform_name ON accounts (platform, name)"),
# 已有 accounts 表不会因模型加 index=True 自动补索引,显式创建
(assets_engine, "CREATE INDEX IF NOT EXISTS ix_accounts_credential_id ON accounts (credential_id)"),
(metrics_engine, "CREATE INDEX IF NOT EXISTS ix_metric_points_asset_ts ON metric_points (asset_id, ts)"), (metrics_engine, "CREATE INDEX IF NOT EXISTS ix_metric_points_asset_ts ON metric_points (asset_id, ts)"),
(metrics_engine, "CREATE INDEX IF NOT EXISTS ix_security_checks_asset_ts ON security_checks (asset_id, ts)"), (metrics_engine, "CREATE INDEX IF NOT EXISTS ix_security_checks_asset_ts ON security_checks (asset_id, ts)"),
] ]
@@ -151,13 +184,223 @@ def _backfill_provider_services(conn) -> None:
) )
def _dedupe_accounts(conn) -> None:
"""建 (platform,name) 唯一索引前的防护:合并存量重复账号。
platform 统一按 COALESCE(platform,'') 规范化比较(避免 NULL 绕过)。
重复时保留 id 最小者,将其余行的资产引用(account_id/account)与凭证并入后删除。
当前数据量小,预期无重复;此函数仅在发现重复时产生写操作。
"""
import sqlalchemy as sa
rows = conn.execute(
sa.text(
"SELECT COALESCE(platform,'') AS p, name, COUNT(*) AS c, MIN(id) AS keep_id "
"FROM accounts GROUP BY p, name HAVING c > 1"
)
).fetchall()
for p, name, _c, keep_id in rows:
dups = conn.execute(
sa.text(
"SELECT id FROM accounts WHERE COALESCE(platform,'') = :p AND name = :n AND id != :keep"
),
{"p": p, "n": name, "keep": keep_id},
).fetchall()
for (dup_id,) in dups:
# 资产引用并入保留行
conn.execute(
sa.text("UPDATE assets SET account_id = :keep WHERE account_id = :dup"),
{"keep": keep_id, "dup": dup_id},
)
# 凭证:保留行为空时才从重复行拷贝
conn.execute(
sa.text(
"UPDATE accounts SET "
"api_config_encrypted = COALESCE(api_config_encrypted, (SELECT api_config_encrypted FROM accounts WHERE id = :dup)), "
"login_password_encrypted = COALESCE(login_password_encrypted, (SELECT login_password_encrypted FROM accounts WHERE id = :dup)), "
"login_user = COALESCE(login_user, (SELECT login_user FROM accounts WHERE id = :dup)) "
"WHERE id = :keep"
),
{"keep": keep_id, "dup": dup_id},
)
conn.execute(sa.text("DELETE FROM accounts WHERE id = :dup"), {"dup": dup_id})
def _migrate_account_credentials_to_vault(conn) -> None:
"""凭据中心化迁移(幂等):把账号登录密码搬入 credentials 表。
规则(spec §5):
- 仅迁移 login_password_encrypted 非空且 credential_id 为空的账号(重跑不重复建)
- site = providers.nameplatform=slug 匹配)→ 否则 platform 原文 → 否则 '未分类'
- username = COALESCE(login_user, name)
- login_type 写入 enum 成员名 'PASSWORD'SQLAlchemy Enum 列存名不存值,
与现有 assets.asset_type='VPS' 等形态一致)
- 密文原样搬入(不解密再加密,避免中间态明文暴露)
- 搬入成功后置空 accounts.login_password_encrypted(唯一事实源,防双份漂移)
"""
import sqlalchemy as sa
from datetime import datetime, timezone
rows = conn.execute(
sa.text(
"SELECT a.id, a.name, a.login_user, a.login_password_encrypted, "
"COALESCE(a.platform, ''), COALESCE(p.name, '') "
"FROM accounts a LEFT JOIN providers p ON p.slug = COALESCE(a.platform, '') "
"WHERE a.login_password_encrypted IS NOT NULL AND a.login_password_encrypted != '' "
"AND a.credential_id IS NULL"
)
).fetchall()
for acc_id, name, login_user, pwd_enc, platform, provider_name in rows:
site = provider_name or platform or "未分类"
username = login_user or name
now = datetime.now(timezone.utc).replace(tzinfo=None)
conn.execute(
sa.text(
"INSERT INTO credentials (site, username, login_type, password_encrypted, "
"created_at, updated_at) VALUES (:site, :username, 'PASSWORD', :pwd, :now, :now)"
),
{"site": site, "username": username, "pwd": pwd_enc, "now": now},
)
cred_id = conn.execute(sa.text("SELECT last_insert_rowid()")).scalar()
conn.execute(
sa.text(
"UPDATE accounts SET credential_id = :cid, login_password_encrypted = NULL "
"WHERE id = :id"
),
{"cid": cred_id, "id": acc_id},
)
def _backfill_asset_account_id(conn) -> None:
"""资产账号引用迁移(幂等):把 Asset.account 的 name 字符串引用回填为 account_id 外键。
匹配规则:按 accounts.name 匹配;同名多个时优先 platform 与 asset.provider 一致者。
匹配不到(历史自由文本/未登记账号)则跳过并保留 account 字符串,供人工处理。
"""
import sqlalchemy as sa
rows = conn.execute(
sa.text(
"SELECT id, account, provider FROM assets "
"WHERE account_id IS NULL AND account IS NOT NULL AND account != ''"
)
).fetchall()
if not rows:
return
for asset_id, acc_name, provider in rows:
# 同名账号可能多个:优先 platform 与资产 provider 一致的
cand = conn.execute(
sa.text(
"SELECT id, COALESCE(platform,'') FROM accounts WHERE name = :n "
"ORDER BY (COALESCE(platform,'') = :prov) DESC, id ASC"
),
{"n": acc_name, "prov": provider or ""},
).fetchall()
if cand:
conn.execute(
sa.text("UPDATE assets SET account_id = :aid WHERE id = :id"),
{"aid": cand[0][0], "id": asset_id},
)
def _backfill_account_credentials(conn) -> None:
"""凭证下沉迁移(幂等,仅在首次加列后产生效果):
1. 平台凭证下沉:api_config_encrypted 非空的 Provider → 确保存在
{slug}-默认」账号并拷入凭证(不删平台原值,保留可回滚)。
2. AI 资产挂靠:account 为空的 ai_agent 资产 → 按 ai_accounts.provider
找/建账号并关联;账号无 API 配置时把该资产的 api_key 写入账号配置。
"""
import json
from datetime import datetime, timezone
import sqlalchemy as sa
from app.core.crypto import decrypt, encrypt
def _ensure_account(name: str, platform: str) -> int:
# 按 (platform, name) 查,避免同名跨平台账号混淆
row = conn.execute(
sa.text("SELECT id FROM accounts WHERE name = :name AND COALESCE(platform,'') = :platform"),
{"name": name, "platform": platform},
).first()
if row:
return row[0]
conn.execute(
sa.text("INSERT INTO accounts (name, platform, created_at) VALUES (:name, :platform, :ts)"),
{"name": name, "platform": platform, "ts": datetime.now(timezone.utc).replace(tzinfo=None)},
)
return conn.execute(
sa.text("SELECT id FROM accounts WHERE name = :name AND COALESCE(platform,'') = :platform"),
{"name": name, "platform": platform},
).first()[0]
# 1. 平台凭证下沉到默认账号
for pid, slug, cfg in conn.execute(
sa.text(
"SELECT id, slug, api_config_encrypted FROM providers "
"WHERE api_config_encrypted IS NOT NULL AND api_config_encrypted != ''"
)
).fetchall():
acc_name = f"{slug}-默认"
_ensure_account(acc_name, slug)
conn.execute(
sa.text(
"UPDATE accounts SET api_config_encrypted = :cfg "
"WHERE name = :name AND COALESCE(platform,'') = :platform "
"AND (api_config_encrypted IS NULL OR api_config_encrypted = '')"
),
{"cfg": cfg, "name": acc_name, "platform": slug},
)
# 2. 无账号的 AI 资产按 provider 挂靠(account_id 外键 + account 字符串兼容)
rows = conn.execute(
sa.text(
"SELECT a.id, COALESCE(ai.provider, '') FROM assets a "
"JOIN ai_accounts ai ON ai.asset_id = a.id "
"WHERE a.asset_type = 'ai_agent' AND a.account_id IS NULL AND (a.account IS NULL OR a.account = '')"
)
).fetchall()
for asset_id, provider in rows:
if not provider:
continue
acc_id = _ensure_account(provider, provider)
conn.execute(
sa.text("UPDATE assets SET account = :acc, account_id = :aid WHERE id = :id"),
{"acc": provider, "aid": acc_id, "id": asset_id},
)
# 账号尚无 API 配置时,把该资产的 api_key 写入账号配置(解密后重组 JSON 再加密)
key_row = conn.execute(
sa.text("SELECT api_key_encrypted FROM ai_accounts WHERE asset_id = :id"),
{"id": asset_id},
).first()
if key_row and key_row[0]:
api_key = decrypt(key_row[0])
if api_key:
cfg_json = json.dumps({"api_key": api_key})
cfg_enc = encrypt(cfg_json)
if cfg_enc:
conn.execute(
sa.text(
"UPDATE accounts SET api_config_encrypted = :cfg "
"WHERE name = :name AND COALESCE(platform,'') = :platform "
"AND (api_config_encrypted IS NULL OR api_config_encrypted = '')"
),
{"cfg": cfg_enc, "name": provider, "platform": provider},
)
def get_session() -> Generator[Session, None, None]: def get_session() -> Generator[Session, None, None]:
"""资产库会话(默认)""" """资产库会话(默认)
with Session(assets_engine) as session:
expire_on_commit=Falsecommit 后不失效对象属性,避免后续访问触发隐式
重新加载查询;需要最新值的场景由调用方显式 session.refresh()。
"""
with Session(assets_engine, expire_on_commit=False) as session:
yield session yield session
def get_metrics_session() -> Generator[Session, None, None]: def get_metrics_session() -> Generator[Session, None, None]:
"""监控 / 日志库会话""" """监控 / 日志库会话"""
with Session(metrics_engine) as session: with Session(metrics_engine, expire_on_commit=False) as session:
yield session yield session
+56 -8
View File
@@ -10,6 +10,7 @@ from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
import asyncio import asyncio
import logging import logging
import subprocess
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -21,7 +22,7 @@ from sqlmodel import Session
from app.core.config import settings from app.core.config import settings
from app.core.seed import seed_providers from app.core.seed import seed_providers
from app.database import assets_engine, init_db from app.database import assets_engine, init_db
from app.routers import agent, assets, monitor, notify, providers, ssl, stats, sync from app.routers import accounts, agent, assets, credentials, monitor, notify, providers, ssl, stats, sync
from app.services import cleanup_service from app.services import cleanup_service
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@@ -35,12 +36,44 @@ def _asset_version() -> str:
用于前端引用 ?v= 参数,绕开浏览器启发式缓存(旧响应无 Cache-Control 用于前端引用 ?v= 参数,绕开浏览器启发式缓存(旧响应无 Cache-Control
时存下的条目会被视为新鲜而不再回源验证)。 时存下的条目会被视为新鲜而不再回源验证)。
启动时计算一次并缓存:静态资源只在代码更新时变化,而更新后服务会重启,
避免每次页面请求都遍历 stat 整个 static 目录。
""" """
global _ASSET_VERSION_CACHE
if _ASSET_VERSION_CACHE is None:
latest = 0 latest = 0
for p in STATIC_DIR.rglob("*"): for p in STATIC_DIR.rglob("*"):
if p.is_file(): if p.is_file():
latest = max(latest, int(p.stat().st_mtime)) latest = max(latest, int(p.stat().st_mtime))
return str(latest) _ASSET_VERSION_CACHE = str(latest)
return _ASSET_VERSION_CACHE
_ASSET_VERSION_CACHE: str | None = None
def _read_app_version() -> str:
"""语义版本号:读取项目根目录 VERSION 文件(缺失时回退 dev"""
try:
return (BASE_DIR / "VERSION").read_text(encoding="utf-8").strip() or "dev"
except OSError:
return "dev"
def _read_git_commit() -> str:
"""当前 git 短哈希:用于比对本地与线上代码是否一致(非 git 环境回退 unknown"""
try:
out = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
cwd=BASE_DIR, capture_output=True, text=True, timeout=5,
)
return out.stdout.strip() or "unknown"
except Exception:
return "unknown"
APP_VERSION = _read_app_version()
GIT_COMMIT = _read_git_commit()
# 全局日志配置:统一格式,便于生产环境排查 # 全局日志配置:统一格式,便于生产环境排查
logging.basicConfig( logging.basicConfig(
@@ -63,7 +96,10 @@ async def lifespan(_: FastAPI):
cleanup_task.cancel() cleanup_task.cancel()
app = FastAPI(title=settings.APP_NAME, version="0.2.0", lifespan=lifespan) app = FastAPI(title=settings.APP_NAME, version=f"{APP_VERSION} ({GIT_COMMIT})", lifespan=lifespan)
# 启动即打印版本号,部署后可通过 journalctl 快速确认线上运行版本
logging.getLogger(__name__).info("vps-manager 启动:版本 %scommit %s", APP_VERSION, GIT_COMMIT)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -75,6 +111,8 @@ app.add_middleware(
app.include_router(assets.router) app.include_router(assets.router)
app.include_router(providers.router) app.include_router(providers.router)
app.include_router(accounts.router)
app.include_router(credentials.router)
app.include_router(stats.router) app.include_router(stats.router)
app.include_router(agent.router) app.include_router(agent.router)
app.include_router(monitor.router) app.include_router(monitor.router)
@@ -95,11 +133,16 @@ app.mount("/static", NoCacheStaticFiles(directory=STATIC_DIR), name="static")
@app.get("/", include_in_schema=False) @app.get("/", include_in_schema=False)
def index() -> HTMLResponse: def index() -> HTMLResponse:
"""前端 SPA 入口""" """前端 SPA 入口
no-cache:HTML 必须每次回源校验,否则浏览器启发式缓存会持有旧 HTML,
其中引用的 ?v= 静态资源版本号也是旧的,导致部署后用户看到旧版。
"""
html = jinja_env.get_template("index.html").render( html = jinja_env.get_template("index.html").render(
app_name=settings.APP_NAME, asset_version=_asset_version() app_name=settings.APP_NAME, asset_version=_asset_version(),
app_version=APP_VERSION, git_commit=GIT_COMMIT,
) )
return HTMLResponse(html) return HTMLResponse(html, headers={"Cache-Control": "no-cache"})
@app.get("/sw.js", include_in_schema=False) @app.get("/sw.js", include_in_schema=False)
@@ -121,5 +164,10 @@ def service_worker() -> FileResponse:
@app.get("/health") @app.get("/health")
def health_check() -> dict: def health_check() -> dict:
"""健康检查(version 动态读取,便于验证自动更新)""" """健康检查(version/commit 动态读取,便于验证自动更新与比对本地版本"""
return {"status": "ok", "app": "vps-manager", "version": app.version} return {
"status": "ok",
"app": "vps-manager",
"version": APP_VERSION,
"commit": GIT_COMMIT,
}
+44 -3
View File
@@ -1,17 +1,18 @@
"""资产数据库模型 """资产数据库模型
包含种核心资产模型: 包含种核心模型:
- Asset: 资产主表(统一登记 VPS/域名/AI 账号/Cloudflare 等一切资产) - Asset: 资产主表(统一登记 VPS/域名/AI 账号/Cloudflare 等一切资产)
- VPSDetail: VPS 资产详情(与 Asset 一对一关联) - VPSDetail: VPS 资产详情(与 Asset 一对一关联)
- DomainDetail: 域名资产详情(与 Asset 一对一关联) - DomainDetail: 域名资产详情(与 Asset 一对一关联)
- AIAccount: AI Agent 账号详情(与 Asset 一对一关联) - AIAccount: AI Agent 账号详情(与 Asset 一对一关联)
- Account: 平台账号字典(Asset.account_id 外键引用)
""" """
from datetime import date, datetime from datetime import date, datetime
from enum import Enum from enum import Enum
from typing import Optional from typing import Optional
from sqlalchemy import Index from sqlalchemy import Index, UniqueConstraint
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
from app.core.timeutils import utcnow from app.core.timeutils import utcnow
@@ -64,7 +65,10 @@ class Asset(SQLModel, table=True):
default=None, description="取消订阅网址(去哪取消)" default=None, description="取消订阅网址(去哪取消)"
) )
account: Optional[str] = Field( account: Optional[str] = Field(
default=None, description="所属账号标识(如 Cloudflare 账号邮箱" default=None, description="所属账号标识(历史字段,迁移后由 account_id 取代,仅作迁移数据源"
)
account_id: Optional[int] = Field(
default=None, foreign_key="accounts.id", index=True, description="所属账号 ID(外键关联 accounts"
) )
expiry_date: Optional[date] = Field( expiry_date: Optional[date] = Field(
default=None, index=True, description="到期/续费日期,用于续费提醒" default=None, index=True, description="到期/续费日期,用于续费提醒"
@@ -195,3 +199,40 @@ class CloudflareDetail(SQLModel, table=True):
default=None, index=True, description="关联的 zone/域名" default=None, index=True, description="关联的 zone/域名"
) )
status: Optional[str] = Field(default=None, description="子资产状态") status: Optional[str] = Field(default=None, description="子资产状态")
class Account(SQLModel, table=True):
"""平台账号字典:统一管理各平台下的账号(多账号场景区分归属)
资产的 Asset.account_id 字段外键引用本表,重命名账号不影响资产归属。
唯一性:(platform, name) 联合唯一——同一邮箱/用户名可跨平台复用。
凭证层:登录凭据(用户名/密码/2FA)统一存 credentials 表(唯一事实源),
本表通过 credential_id 关联;API 配置 JSON(SDK 同步用)仍留在本表。
平台本身不再持有凭证(Provider.api_config_encrypted 已弃用,仅留历史值)。
"""
__tablename__ = "accounts"
# (platform, name) 联合唯一:同一邮箱/用户名可跨平台复用,同平台内不允许重名
__table_args__ = (UniqueConstraint("platform", "name", name="uq_accounts_platform_name"),)
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True, description="账号标识(如邮箱/用户名)")
platform: Optional[str] = Field(
default=None, index=True, description="所属平台(slug 或名称)"
)
remark: Optional[str] = Field(default=None, description="备注")
login_user: Optional[str] = Field(default=None, description="登录用户名/邮箱")
login_password_encrypted: Optional[str] = Field(
default=None, description="(已弃用)登录密码已迁至 credentials 表,仅留历史值"
)
credential_id: Optional[int] = Field(
default=None, foreign_key="credentials.id", index=True,
description="关联登录凭据 ID(登录密码唯一事实源在 credentials 表)"
)
api_config_encrypted: Optional[str] = Field(
default=None, description="加密的 API 配置 JSONaccess_key/secret/api_key 等,供 SDK 同步)"
)
last_synced_at: Optional[datetime] = Field(
default=None, description="最近一次 SDK 同步时间"
)
created_at: datetime = Field(default_factory=utcnow, description="创建时间")
+66
View File
@@ -0,0 +1,66 @@
"""登录凭据模型(密码库)
Credential 是登录凭据的唯一事实源,粒度 = 站点 × 登录方式:
- 同一邮箱注册多个站点 = 多条记录(username 重复是常态而非冗余)
- 密码相同也各存一份(改密逐站发生,不做共享联动)
- 授权登录(OAuth)是正常条目:password 为空 + login_type=oauth
- 2FATOTP secret)挂在凭据上,是登录凭据的一部分
平台账号(Account)通过 credential_id 关联本表,账号密码读写全部重定向到此;
普通网站/邮箱等游离凭据直接建在本表,与平台/资产体系解耦。
敏感字段(password / otp secret)用 Fernet 加密存储(MASTER_KEY 不入库),
Read schema 永不返回明文/密文,只给 has_password / has_otp 布尔标记。
"""
from datetime import datetime
from enum import Enum
from typing import Optional
from sqlmodel import Field, SQLModel
from app.core.timeutils import utcnow
class LoginType(str, Enum):
"""登录方式"""
PASSWORD = "password" # 用户名 + 密码
OAUTH = "oauth" # 授权登录(Google/Apple/GitHub 等,本站无密码)
OTHER = "other" # 其他(魔法链接、硬件 key 等)
class Credential(SQLModel, table=True):
"""登录凭据(密码库条目)
不设 (site, username) 库级唯一约束:SQLite 对 NULL 不友好,且同站多账号合法;
重复录入由服务层提示(允许继续)。
"""
__tablename__ = "credentials"
id: Optional[int] = Field(default=None, primary_key=True)
site: str = Field(index=True, description="站点/服务名,如 GitHub、阿里云")
username: Optional[str] = Field(
default=None, index=True, description="登录用户名/邮箱"
)
login_type: LoginType = Field(
default=LoginType.PASSWORD, index=True, description="登录方式"
)
oauth_provider: Optional[str] = Field(
default=None, description="授权登录来源:google/apple/github/wechat 等"
)
password_encrypted: Optional[str] = Field(
default=None, description="加密的登录密码(Fernet);oauth 登录为空"
)
otp_secret_encrypted: Optional[str] = Field(
default=None, description="加密的 TOTP base32 secret2FA),动态码由服务端生成"
)
url: Optional[str] = Field(default=None, description="登录页地址")
note: Optional[str] = Field(default=None, description="备注")
created_at: datetime = Field(default_factory=utcnow, description="创建时间")
updated_at: datetime = Field(
default_factory=utcnow,
sa_column_kwargs={"onupdate": utcnow},
description="更新时间",
)
+82
View File
@@ -0,0 +1,82 @@
"""平台账号 CRUD 路由
统一入口 /api/accounts,写操作(POST/PUT/DELETE)受 API Key 保护。
资产的 Asset.account_id 外键引用账号;删除账号时引用资产的 account_id 置 NULL。
凭证(登录密码/API 配置)存在账号上;账号维度的 SDK 测试/同步见 test/sync 端点。
"""
from typing import List
from fastapi import APIRouter, Depends
from sqlmodel import Session
from app.core.security import require_api_key
from app.database import get_session
from app.schemas.account import AccountCreate, AccountRead, AccountUpdate
from app.services import account_service, sync_service
router = APIRouter(prefix="/api/accounts", tags=["accounts"])
@router.get("", response_model=List[AccountRead], summary="账号列表(含关联资产数)")
def list_accounts(session: Session = Depends(get_session)):
return account_service.list_accounts(session)
@router.post(
"",
response_model=AccountRead,
status_code=201,
summary="创建账号",
dependencies=[Depends(require_api_key)],
)
def create_account(data: AccountCreate, session: Session = Depends(get_session)):
return account_service.create_account(session, data)
@router.put(
"/{account_id}",
response_model=AccountRead,
summary="更新账号(重命名会同步更新引用该账号的资产)",
dependencies=[Depends(require_api_key)],
)
def update_account(
account_id: int, data: AccountUpdate, session: Session = Depends(get_session)
):
return account_service.update_account(session, account_id, data)
@router.delete(
"/{account_id}",
summary="删除账号(资产中已填的账号名不受影响)",
dependencies=[Depends(require_api_key)],
)
def delete_account(account_id: int, session: Session = Depends(get_session)) -> dict:
return account_service.delete_account(session, account_id)
@router.get(
"/{account_id}/password",
summary="查看账号登录密码明文(解密返回)",
dependencies=[Depends(require_api_key)],
)
def reveal_password(account_id: int, session: Session = Depends(get_session)) -> dict:
return account_service.reveal_password(session, account_id)
@router.post(
"/{account_id}/test",
summary="测试账号凭证有效性(按账号所属平台的 SDK)",
dependencies=[Depends(require_api_key)],
)
def test_account(account_id: int, session: Session = Depends(get_session)) -> dict:
return sync_service.test_account(session, account_id)
@router.post(
"/{account_id}/sync",
summary="同步账号资产到本地库(产出自动挂到该账号名下)",
dependencies=[Depends(require_api_key)],
)
def sync_account(account_id: int, session: Session = Depends(get_session)) -> dict:
return sync_service.sync_account(session, account_id)
+3 -2
View File
@@ -20,15 +20,16 @@ router = APIRouter(prefix="/api/assets", tags=["assets"])
@router.get("", response_model=List[AssetRead], summary="资产列表") @router.get("", response_model=List[AssetRead], summary="资产列表")
def list_assets( def list_assets(
asset_type: Optional[AssetType] = Query(default=None, description="按类型筛选"), asset_type: Optional[AssetType] = Query(default=None, description="按类型筛选"),
status: Optional[AssetStatus] = Query(default=None, description="按状态筛选"), status_filter: Optional[AssetStatus] = Query(default=None, alias="status", description="按状态筛选"),
is_archived: Optional[bool] = Query(default=None, description="按归档标记筛选"), is_archived: Optional[bool] = Query(default=None, description="按归档标记筛选"),
q: Optional[str] = Query(default=None, description="搜索名称/服务商"), q: Optional[str] = Query(default=None, description="搜索名称/服务商"),
provider: Optional[str] = Query(default=None, description="按平台筛选(slug 或平台 ID"),
sort: str = Query(default="expiry_date", description="排序字段"), sort: str = Query(default="expiry_date", description="排序字段"),
order: str = Query(default="asc", description="排序方向 asc/desc"), order: str = Query(default="asc", description="排序方向 asc/desc"),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
return asset_service.list_assets( return asset_service.list_assets(
session, asset_type, status, is_archived, q, sort, order session, asset_type, status_filter, is_archived, q, provider, sort, order
) )
+113
View File
@@ -0,0 +1,113 @@
"""登录凭据(密码库)CRUD 路由
统一入口 /api/credentials,写操作(POST/PUT/DELETE)受 API Key 保护
Tailscale 内网/本机放行,外部来源必须携带 X-API-Key)。
- 明文密码经 /{id}/password 专用接口解密返回(与账号 reveal 语义一致)
- 2FA 动态码由服务端生成(secret 永不出库),/{id}/otp 响应 no-store 防缓存残留
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, Query, Response
from sqlmodel import Session
from app.core.security import require_api_key
from app.database import get_session
from app.schemas.credential import (
CredentialCreate,
CredentialRead,
CredentialUpdate,
OtpBindRequest,
)
from app.services import credential_service
router = APIRouter(prefix="/api/credentials", tags=["credentials"])
@router.get("", response_model=List[CredentialRead], summary="凭据列表(密码库,支持搜索/过滤)")
def list_credentials(
q: Optional[str] = Query(default=None, description="搜索站点/用户名/备注"),
login_type: Optional[str] = Query(default=None, description="登录方式:password/oauth/other"),
has_otp: Optional[bool] = Query(default=None, description="是否已绑定 2FA"),
session: Session = Depends(get_session),
):
return credential_service.list_credentials(
session, q=q, login_type=login_type, has_otp=has_otp
)
@router.post(
"",
response_model=CredentialRead,
status_code=201,
summary="创建凭据(可同时绑定 2FA,需当前动态码校验)",
dependencies=[Depends(require_api_key)],
)
def create_credential(data: CredentialCreate, session: Session = Depends(get_session)):
return credential_service.create_credential(session, data)
@router.put(
"/{credential_id}",
response_model=CredentialRead,
summary="更新凭据(密码:留空不改,空串清除)",
dependencies=[Depends(require_api_key)],
)
def update_credential(
credential_id: int, data: CredentialUpdate, session: Session = Depends(get_session)
):
return credential_service.update_credential(session, credential_id, data)
@router.delete(
"/{credential_id}",
summary="删除凭据(账号/资产不受影响,仅解除关联)",
dependencies=[Depends(require_api_key)],
)
def delete_credential(credential_id: int, session: Session = Depends(get_session)) -> dict:
return credential_service.delete_credential(session, credential_id)
@router.get(
"/{credential_id}/password",
summary="查看凭据密码明文(解密返回)",
dependencies=[Depends(require_api_key)],
)
def reveal_password(credential_id: int, session: Session = Depends(get_session)) -> dict:
return credential_service.reveal_password(session, credential_id)
@router.get(
"/{credential_id}/otp",
summary="获取当前 2FA 动态码(服务端生成,secret 不出库)",
dependencies=[Depends(require_api_key)],
)
def current_otp(
credential_id: int, response: Response, session: Session = Depends(get_session)
) -> dict:
# 动态码禁止缓存(浏览器/代理),避免有效期内残留
response.headers["Cache-Control"] = "no-store"
return credential_service.current_otp(session, credential_id)
@router.put(
"/{credential_id}/otp",
response_model=CredentialRead,
summary="绑定 2FA(secret + 当前动态码,校验通过才落库)",
dependencies=[Depends(require_api_key)],
)
def bind_otp(
credential_id: int, data: OtpBindRequest, session: Session = Depends(get_session)
):
return credential_service.bind_otp(session, credential_id, data)
@router.delete(
"/{credential_id}/otp",
response_model=CredentialRead,
summary="解绑 2FA",
dependencies=[Depends(require_api_key)],
)
def unbind_otp(credential_id: int, session: Session = Depends(get_session)):
return credential_service.unbind_otp(session, credential_id)
+2 -13
View File
@@ -7,7 +7,7 @@ from sqlmodel import Session, select
from app.core.security import require_api_key from app.core.security import require_api_key
from app.database import get_metrics_session from app.database import get_metrics_session
from app.models.monitor import MetricPoint, SecurityCheck, ServerInfo from app.models.monitor import MetricPoint, ServerInfo
from app.services import cleanup_service, security_service from app.services import cleanup_service, security_service
router = APIRouter(prefix="/api/monitor", tags=["monitor"]) router = APIRouter(prefix="/api/monitor", tags=["monitor"])
@@ -55,18 +55,7 @@ def info(asset_id: int, session: Session = Depends(get_metrics_session)) -> Opti
@router.get("/{asset_id}/security", summary="安全检查项(每项最新一条)") @router.get("/{asset_id}/security", summary="安全检查项(每项最新一条)")
def security(asset_id: int, session: Session = Depends(get_metrics_session)): def security(asset_id: int, session: Session = Depends(get_metrics_session)):
stmt = ( return security_service.latest_checks(session, asset_id)
select(SecurityCheck)
.where(SecurityCheck.asset_id == asset_id)
.order_by(SecurityCheck.ts.desc())
.limit(50)
)
checks = session.exec(stmt).all()
latest_by_item = {}
for check in checks:
if check.check_item not in latest_by_item:
latest_by_item[check.check_item] = check
return list(latest_by_item.values())
@router.get("/{asset_id}/security-score", summary="单个服务器安全评分与加固建议") @router.get("/{asset_id}/security-score", summary="单个服务器安全评分与加固建议")
+45
View File
@@ -0,0 +1,45 @@
"""平台账号 Schema
凭证语义约定:
- login_password / api_configCreate 时传入即加密存储;Update 时 None 表示不修改。
- login_password 的唯一事实源在 credentials 表(账号通过 credential_id 关联),
本 schema 字段仅为录入入口,服务层重定向写入凭据。
- Read 永不返回凭证明文/密文,只给布尔标记(has_login_password / has_api_config / has_otp)。
"""
from datetime import datetime
from typing import Optional
from sqlmodel import SQLModel
class AccountBase(SQLModel):
name: str
platform: Optional[str] = None
remark: Optional[str] = None
login_user: Optional[str] = None
class AccountCreate(AccountBase):
login_password: Optional[str] = None # 登录密码(明文传入,加密存储)
api_config: Optional[str] = None # API 配置 JSON 字符串(加密存储,供 SDK 同步)
class AccountUpdate(SQLModel):
name: Optional[str] = None
platform: Optional[str] = None
remark: Optional[str] = None
login_user: Optional[str] = None
login_password: Optional[str] = None # None = 不修改
api_config: Optional[str] = None # None = 不修改
class AccountRead(AccountBase):
id: int
created_at: datetime
asset_count: int = 0 # 引用该账号的资产数(列表接口填充)
has_login_password: bool = False
has_api_config: bool = False
credential_id: Optional[int] = None # 关联的登录凭据(密码唯一事实源)
has_otp: bool = False # 关联凭据是否绑定了 2FA
last_synced_at: Optional[datetime] = None
+3 -2
View File
@@ -84,7 +84,7 @@ class AssetBase(SQLModel):
renewal_cycle: Optional[str] = None renewal_cycle: Optional[str] = None
renew_url: Optional[str] = None renew_url: Optional[str] = None
cancel_url: Optional[str] = None cancel_url: Optional[str] = None
account: Optional[str] = None account_id: Optional[int] = None
expiry_date: Optional[date] = None expiry_date: Optional[date] = None
auto_renew: bool = False auto_renew: bool = False
cost: float = 0 cost: float = 0
@@ -113,7 +113,7 @@ class AssetUpdate(SQLModel):
renewal_cycle: Optional[str] = None renewal_cycle: Optional[str] = None
renew_url: Optional[str] = None renew_url: Optional[str] = None
cancel_url: Optional[str] = None cancel_url: Optional[str] = None
account: Optional[str] = None account_id: Optional[int] = None
expiry_date: Optional[date] = None expiry_date: Optional[date] = None
auto_renew: Optional[bool] = None auto_renew: Optional[bool] = None
cost: Optional[float] = None cost: Optional[float] = None
@@ -185,6 +185,7 @@ class AssetRead(AssetBase):
updated_at: datetime updated_at: datetime
days_to_expiry: Optional[int] = None days_to_expiry: Optional[int] = None
provider_name: Optional[str] = None provider_name: Optional[str] = None
account_name: Optional[str] = None # 账号标识(展示用,由 service 按 account_id 填充)
vps_detail: Optional[VPSDetailRead] = None vps_detail: Optional[VPSDetailRead] = None
domain_detail: Optional[DomainDetailRead] = None domain_detail: Optional[DomainDetailRead] = None
ai_detail: Optional[AIAccountRead] = None ai_detail: Optional[AIAccountRead] = None
+55
View File
@@ -0,0 +1,55 @@
"""登录凭据 Schema(密码库)
凭证语义约定(与账号一致):
- password / otp_secretCreate 时传入即加密存储;Update 时 None 表示不修改、'' 表示清除。
- 2FA 绑定必须携带当前 6 位动态码(otp_code)做服务端校验,防 secret 手误成废条目。
- Read 永不返回明文/密文,只给 has_password / has_otp 布尔标记;明文经专用 reveal 接口。
"""
from datetime import datetime
from typing import Optional
from sqlmodel import SQLModel
from app.models.credential import LoginType
class CredentialBase(SQLModel):
site: str
username: Optional[str] = None
login_type: LoginType = LoginType.PASSWORD
oauth_provider: Optional[str] = None
url: Optional[str] = None
note: Optional[str] = None
class CredentialCreate(CredentialBase):
password: Optional[str] = None # 明文传入,加密存储
otp_secret: Optional[str] = None # base32 secret 或 otpauth:// URI
otp_code: Optional[str] = None # 当前 6 位动态码(otp_secret 提供时必填)
class CredentialUpdate(SQLModel):
site: Optional[str] = None
username: Optional[str] = None
login_type: Optional[LoginType] = None
oauth_provider: Optional[str] = None
url: Optional[str] = None
note: Optional[str] = None
password: Optional[str] = None # None=不修改,''=清除,非空=重加密
class CredentialRead(CredentialBase):
id: int
has_password: bool = False
has_otp: bool = False
duplicate: bool = False # 同 (site, username) 已存在其它条目(录入提示用)
account_id: Optional[int] = None # 反向关联的平台账号(展示"来自平台账号")
account_name: Optional[str] = None
created_at: datetime
updated_at: datetime
class OtpBindRequest(SQLModel):
secret: str # base32 secret 或 otpauth:// URI
code: str # 当前 6 位动态码(服务端校验通过才落库)
+188
View File
@@ -0,0 +1,188 @@
"""平台账号业务逻辑
账号与资产的关系:Asset.account_id 外键关联账号(重命名账号不影响引用)。
唯一性:(platform, name) 联合唯一——同一邮箱/用户名可跨平台复用,同平台内不重名。
凭证层:API 配置加密存本表;登录密码的唯一事实源在 credentials 表
credential_service.upsert_for_account 同事务同步),Read 仅返回布尔标记。
"""
from typing import Dict, List, Optional
from fastapi import HTTPException, status
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select
from app.core import crypto
from app.models.asset import Account, Asset
from app.models.credential import Credential
from app.schemas.account import AccountCreate, AccountRead, AccountUpdate
from app.services import credential_service
def _asset_counts(session: Session) -> Dict[int, int]:
"""按 account_id 统计引用资产数"""
rows = session.exec(
select(Asset.account_id, func.count(Asset.id))
.where(Asset.account_id.is_not(None)) # type: ignore[union-attr]
.group_by(Asset.account_id)
).all()
return {aid: cnt for aid, cnt in rows}
def _credential_map(session: Session, ids: List[Optional[int]]) -> Dict[int, Credential]:
"""批量取账号关联的凭据(填充 has_login_password / has_otp,避免 N+1"""
wanted = [i for i in ids if i]
if not wanted:
return {}
rows = session.exec(
select(Credential).where(Credential.id.in_(wanted)) # type: ignore[union-attr]
).all()
return {c.id: c for c in rows}
def _cred_of(session: Session, account: Account) -> Optional[Credential]:
"""取单个账号关联的凭据(未关联返回 None)"""
return session.get(Credential, account.credential_id) if account.credential_id else None
def _to_read(
account: Account, counts: Dict[int, int], cred: Optional[Credential] = None
) -> AccountRead:
read = AccountRead.model_validate(account)
read.asset_count = counts.get(account.id, 0)
# 登录密码唯一事实源在凭据表(本表 login_password_encrypted 已弃用)
read.credential_id = account.credential_id
read.has_login_password = bool(cred and cred.password_encrypted)
read.has_otp = bool(cred and cred.otp_secret_encrypted)
read.has_api_config = bool(account.api_config_encrypted)
return read
def list_accounts(session: Session) -> List[AccountRead]:
counts = _asset_counts(session)
accounts = session.exec(select(Account).order_by(Account.name.asc())).all()
creds = _credential_map(session, [a.credential_id for a in accounts])
return [_to_read(a, counts, creds.get(a.credential_id)) for a in accounts]
def _get_account(session: Session, account_id: int) -> Account:
account = session.get(Account, account_id)
if not account:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="账号不存在")
return account
def _norm_platform(platform: str | None) -> str:
"""platform 规范化为非空字符串(避免 NULL 绕过 (platform,name) 唯一约束)"""
return (platform or "").strip()
def _check_name_taken(session: Session, name: str, platform: str, exclude_id: int | None = None) -> None:
"""校验 (platform, name) 联合唯一:同平台内不允许重名,跨平台可复用
platform 用 coalesce 归一化匹配:历史数据的 NULL 与空串视为同一平台,
避免出现同名账号在不同"空平台"上各存一条。
"""
stmt = select(Account).where(
Account.name == name,
func.coalesce(Account.platform, "") == platform,
)
if exclude_id is not None:
stmt = stmt.where(Account.id != exclude_id)
if session.exec(stmt).first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"该平台下账号已存在:{name}",
)
def create_account(session: Session, data: AccountCreate) -> AccountRead:
name = data.name.strip()
if not name:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="账号名称不能为空")
platform = _norm_platform(data.platform)
_check_name_taken(session, name, platform)
account = Account(name=name, platform=platform, remark=data.remark, login_user=data.login_user)
account.api_config_encrypted = crypto.encrypt(data.api_config)
# 登录密码重定向到凭据库(唯一事实源):同一事务创建并回填 credential_id
# 下方 commit 失败(如并发重名)时凭据随事务一并回滚
if data.login_password:
credential_service.upsert_for_account(session, account, data.login_password)
try:
session.add(account)
session.commit()
except IntegrityError:
# 并发创建同名账号时唯一约束兜底:检查与提交之间存在竞态窗口
session.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"该平台下账号已存在:{name}",
)
session.refresh(account)
return _to_read(account, _asset_counts(session), _cred_of(session, account))
def update_account(session: Session, account_id: int, data: AccountUpdate) -> AccountRead:
account = _get_account(session, account_id)
# platform 可能随本次更新变化,校验重名时用更新后的值
new_platform = _norm_platform(data.platform) if data.platform is not None else _norm_platform(account.platform)
if data.name is not None:
new_name = data.name.strip()
if not new_name:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="账号名称不能为空")
if new_name != account.name or new_platform != _norm_platform(account.platform):
_check_name_taken(session, new_name, new_platform, exclude_id=account.id)
account.name = new_name
if data.platform is not None:
account.platform = new_platform
if data.remark is not None:
account.remark = data.remark or None
if data.login_user is not None:
account.login_user = data.login_user or None
# 登录密码重定向到关联凭据:None=不修改,''=清除,非空=重加密;
# 已有凭据时即使密码未变也同步 site/username(账号改名/换平台保持一致)
if data.login_password is not None or account.credential_id:
credential_service.upsert_for_account(session, account, data.login_password)
if data.api_config is not None:
account.api_config_encrypted = crypto.encrypt(data.api_config)
try:
session.add(account)
session.commit()
except IntegrityError:
# 重命名撞上已有账号时唯一约束兜底,与 _check_name_taken 存在竞态窗口
session.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"该平台下账号已存在:{account.name}",
)
session.refresh(account)
return _to_read(account, _asset_counts(session), _cred_of(session, account))
def delete_account(session: Session, account_id: int) -> Dict[str, int]:
account = _get_account(session, account_id)
# 引用该账号的资产:account_id 置 NULL(保留资产,仅解除关联)
assets = session.exec(select(Asset).where(Asset.account_id == account_id)).all()
for a in assets:
a.account_id = None
session.add(a)
affected = len(assets)
session.delete(account)
session.commit()
return {"affected_assets": affected}
def reveal_password(session: Session, account_id: int) -> Dict[str, str]:
"""解密返回账号登录密码明文(唯一事实源在关联凭据)。
安全说明:接口受 API Key 保护;密码本可逆加密存储,此处合法解密还原。
"""
account = _get_account(session, account_id)
cred = _cred_of(session, account)
plain = crypto.decrypt(cred.password_encrypted) if cred else None
if not plain:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="该账号未配置登录密码"
)
return {"login_user": cred.username or account.login_user or "", "password": plain}
+46 -4
View File
@@ -7,10 +7,12 @@ from datetime import date, datetime, timedelta, timezone
from typing import List, Optional from typing import List, Optional
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import or_
from sqlmodel import Session, select from sqlmodel import Session, select
from app.core import crypto from app.core import crypto
from app.models.asset import ( from app.models.asset import (
Account,
AIAccount, AIAccount,
Asset, Asset,
AssetStatus, AssetStatus,
@@ -92,11 +94,29 @@ def _provider_name(session: Session, asset: Asset) -> Optional[str]:
return None return None
def _account_name(session: Session, asset: Asset) -> Optional[str]:
"""取关联账号标识(展示用)"""
if asset.account_id:
account = session.get(Account, asset.account_id)
if account:
return account.name
return None
def _validate_account(session: Session, account_id: Optional[int]) -> None:
"""account_id 非空时确认账号存在,防外键悬空"""
if account_id is not None and not session.get(Account, account_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=f"账号不存在:id={account_id}"
)
def _to_read(session: Session, asset: Asset, detail) -> AssetRead: def _to_read(session: Session, asset: Asset, detail) -> AssetRead:
"""组装 AssetRead 输出(主表 + 详情 + 计算字段)""" """组装 AssetRead 输出(主表 + 详情 + 计算字段)"""
read = AssetRead.model_validate(asset) read = AssetRead.model_validate(asset)
read.days_to_expiry = _days_to_expiry(asset.expiry_date) read.days_to_expiry = _days_to_expiry(asset.expiry_date)
read.provider_name = _provider_name(session, asset) read.provider_name = _provider_name(session, asset)
read.account_name = _account_name(session, asset)
if isinstance(detail, VPSDetail): if isinstance(detail, VPSDetail):
read.vps_detail = _detail_to_read(detail) read.vps_detail = _detail_to_read(detail)
elif isinstance(detail, DomainDetail): elif isinstance(detail, DomainDetail):
@@ -155,6 +175,12 @@ def _get_details_batch(session: Session, assets: list) -> dict:
if provider_ids: if provider_ids:
for p in session.exec(select(Provider).where(Provider.id.in_(provider_ids))).all(): for p in session.exec(select(Provider).where(Provider.id.in_(provider_ids))).all():
provider_map[p.id] = p.name provider_map[p.id] = p.name
# 批量查 Account 名
account_ids = {a.account_id for a in assets if a.account_id}
account_map = {}
if account_ids:
for acc in session.exec(select(Account).where(Account.id.in_(account_ids))).all():
account_map[acc.id] = acc.name
# 批量查各类型详情 # 批量查各类型详情
detail_map = {} detail_map = {}
for asset_type, (_, model) in DETAIL_MAP.items(): for asset_type, (_, model) in DETAIL_MAP.items():
@@ -162,7 +188,7 @@ def _get_details_batch(session: Session, assets: list) -> dict:
if typed_ids: if typed_ids:
for d in session.exec(select(model).where(model.asset_id.in_(typed_ids))).all(): for d in session.exec(select(model).where(model.asset_id.in_(typed_ids))).all():
detail_map[d.asset_id] = d detail_map[d.asset_id] = d
return {"providers": provider_map, "details": detail_map} return {"providers": provider_map, "accounts": account_map, "details": detail_map}
def _to_read_batch(session: Session, asset: Asset, batch: dict) -> AssetRead: def _to_read_batch(session: Session, asset: Asset, batch: dict) -> AssetRead:
@@ -170,6 +196,7 @@ def _to_read_batch(session: Session, asset: Asset, batch: dict) -> AssetRead:
read = AssetRead.model_validate(asset) read = AssetRead.model_validate(asset)
read.days_to_expiry = _days_to_expiry(asset.expiry_date) read.days_to_expiry = _days_to_expiry(asset.expiry_date)
read.provider_name = batch["providers"].get(asset.provider_id) read.provider_name = batch["providers"].get(asset.provider_id)
read.account_name = batch.get("accounts", {}).get(asset.account_id)
detail = batch["details"].get(asset.id) detail = batch["details"].get(asset.id)
if isinstance(detail, VPSDetail): if isinstance(detail, VPSDetail):
read.vps_detail = _detail_to_read(detail) read.vps_detail = _detail_to_read(detail)
@@ -202,6 +229,7 @@ def _create_asset_no_commit(session: Session, data: AssetCreate) -> Asset:
asset_data = data.model_dump( asset_data = data.model_dump(
exclude={"vps_detail", "domain_detail", "ai_detail", "cloudflare_detail"} exclude={"vps_detail", "domain_detail", "ai_detail", "cloudflare_detail"}
) )
_validate_account(session, asset_data.get("account_id"))
asset = Asset(**asset_data) asset = Asset(**asset_data)
session.add(asset) session.add(asset)
session.flush() # 获取 asset.id,但不提交 session.flush() # 获取 asset.id,但不提交
@@ -238,6 +266,8 @@ def _update_asset_no_commit(session: Session, asset_id: int, data: AssetUpdate)
main_fields = data.model_dump( main_fields = data.model_dump(
exclude_unset=True, exclude={"vps_detail", "domain_detail", "ai_detail", "cloudflare_detail"} exclude_unset=True, exclude={"vps_detail", "domain_detail", "ai_detail", "cloudflare_detail"}
) )
if "account_id" in main_fields:
_validate_account(session, main_fields["account_id"])
for key, value in main_fields.items(): for key, value in main_fields.items():
setattr(asset, key, value) setattr(asset, key, value)
session.add(asset) session.add(asset)
@@ -283,14 +313,19 @@ def delete_asset(session: Session, asset_id: int) -> None:
def _cleanup_metrics_for_asset(asset_id: int) -> None: def _cleanup_metrics_for_asset(asset_id: int) -> None:
"""清理 metrics.db 中该资产的 MetricPoint/ServerInfo/SecurityCheck/EventLog""" """清理 metrics.db 中该资产的 MetricPoint/ServerInfo/SecurityCheck/EventLog
使用批量 DELETE(而非逐行加载后删除):监控时序数据可能上万行,
逐行删除会全部载入内存且产生数万次 ORM 操作。
"""
from sqlmodel import delete
from app.database import metrics_engine from app.database import metrics_engine
from app.models.monitor import EventLog, MetricPoint, SecurityCheck, ServerInfo from app.models.monitor import EventLog, MetricPoint, SecurityCheck, ServerInfo
with Session(metrics_engine) as ms: with Session(metrics_engine) as ms:
for model in (MetricPoint, ServerInfo, SecurityCheck, EventLog): for model in (MetricPoint, ServerInfo, SecurityCheck, EventLog):
for row in ms.exec(select(model).where(model.asset_id == asset_id)).all(): ms.exec(delete(model).where(model.asset_id == asset_id))
ms.delete(row)
ms.commit() ms.commit()
@@ -353,6 +388,7 @@ def list_assets(
asset_status: Optional[AssetStatus] = None, asset_status: Optional[AssetStatus] = None,
is_archived: Optional[bool] = None, is_archived: Optional[bool] = None,
q: Optional[str] = None, q: Optional[str] = None,
provider: Optional[str] = None,
sort: str = "expiry_date", sort: str = "expiry_date",
order: str = "asc", order: str = "asc",
) -> List[AssetRead]: ) -> List[AssetRead]:
@@ -364,6 +400,12 @@ def list_assets(
stmt = stmt.where(Asset.status == asset_status) stmt = stmt.where(Asset.status == asset_status)
if is_archived is not None: if is_archived is not None:
stmt = stmt.where(Asset.is_archived == is_archived) stmt = stmt.where(Asset.is_archived == is_archived)
if provider:
# 兼容两种关联方式:provider slug 文本或 provider_id 外键
conds = [Asset.provider == provider]
if provider.isdigit():
conds.append(Asset.provider_id == int(provider))
stmt = stmt.where(or_(*conds))
if q: if q:
pattern = f"%{q}%" pattern = f"%{q}%"
stmt = stmt.where(Asset.name.like(pattern) | Asset.provider.like(pattern)) stmt = stmt.where(Asset.name.like(pattern) | Asset.provider.like(pattern))
+316
View File
@@ -0,0 +1,316 @@
"""登录凭据(密码库)业务逻辑
唯一事实源:credentials 表。账号(Account)通过 credential_id 关联,
其登录密码的读写全部重定向到本模块(见 account_service)。
安全约定:
- password / otp secret 用 Fernet 加密(MASTER_KEY 不入库),Read 只给布尔标记
- 2FA 绑定必须携带当前动态码做服务端校验(防 secret 手误录入成废条目)
- 动态码由服务端生成(secret 永不出库),前端只做倒计时展示
"""
from typing import Dict, List, Optional
from fastapi import HTTPException, status
from sqlalchemy import or_
from sqlmodel import Session, select
from app.core import crypto, totp
from app.models.asset import Account
from app.models.credential import Credential, LoginType
from app.models.provider import Provider
from app.schemas.credential import (
CredentialCreate,
CredentialRead,
CredentialUpdate,
OtpBindRequest,
)
def _get_credential(session: Session, credential_id: int) -> Credential:
cred = session.get(Credential, credential_id)
if not cred:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="凭据不存在")
return cred
def _account_index(session: Session) -> Dict[int, Account]:
"""credential_id → Account 反向映射(填充「来自平台账号」标记)"""
rows = session.exec(
select(Account).where(Account.credential_id.is_not(None)) # type: ignore[union-attr]
).all()
return {a.credential_id: a for a in rows}
def _norm_pair(site: str, username: Optional[str]) -> tuple:
"""重复判定键:站点 + 用户名(大小写/空白归一化)"""
return (site.strip().lower(), (username or "").strip().lower())
def _duplicate_flags(creds: List[Credential]) -> Dict[int, bool]:
"""同 (site, username) 存在多条时全部标记 duplicate(录入提示用)"""
seen: Dict[tuple, int] = {}
for c in creds:
k = _norm_pair(c.site, c.username)
seen[k] = seen.get(k, 0) + 1
return {c.id: seen[_norm_pair(c.site, c.username)] > 1 for c in creds}
def _has_duplicate(session: Session, cred: Credential) -> bool:
"""单条判重(创建/更新响应用):同站点同用户名是否还有其它条目"""
site, username = _norm_pair(cred.site, cred.username)
stmt = select(Credential).where(Credential.id != cred.id)
rows = session.exec(stmt).all()
return any(_norm_pair(c.site, c.username) == (site, username) for c in rows)
def _to_read(
cred: Credential, duplicate: bool = False, account: Optional[Account] = None
) -> CredentialRead:
read = CredentialRead.model_validate(cred)
read.has_password = bool(cred.password_encrypted)
read.has_otp = bool(cred.otp_secret_encrypted)
read.duplicate = duplicate
if account:
read.account_id = account.id
read.account_name = account.name
return read
def list_credentials(
session: Session,
q: Optional[str] = None,
login_type: Optional[str] = None,
has_otp: Optional[bool] = None,
) -> List[CredentialRead]:
"""凭据列表:搜索 site/username/note,可按登录方式与 2FA 绑定过滤"""
stmt = select(Credential)
if login_type:
stmt = stmt.where(Credential.login_type == login_type)
if has_otp is True:
stmt = stmt.where(Credential.otp_secret_encrypted.is_not(None)) # type: ignore[union-attr]
elif has_otp is False:
stmt = stmt.where(Credential.otp_secret_encrypted.is_(None)) # type: ignore[union-attr]
if q and q.strip():
like = f"%{q.strip()}%"
stmt = stmt.where(
or_(
Credential.site.like(like), # type: ignore[union-attr]
Credential.username.like(like), # type: ignore[union-attr]
Credential.note.like(like), # type: ignore[union-attr]
)
)
creds = list(
session.exec(
stmt.order_by(Credential.site.asc(), Credential.username.asc())
).all()
)
dups = _duplicate_flags(creds)
accounts = _account_index(session)
return [_to_read(c, dups.get(c.id, False), accounts.get(c.id)) for c in creds]
def _account_of(session: Session, credential_id: int) -> Optional[Account]:
"""反向查关联账号(响应里标注「来自平台账号」)"""
return session.exec(
select(Account).where(Account.credential_id == credential_id)
).first()
def get_credential(session: Session, credential_id: int) -> CredentialRead:
cred = _get_credential(session, credential_id)
return _to_read(
cred, duplicate=_has_duplicate(session, cred), account=_account_of(session, cred.id)
)
def _normalize_otp_secret(raw: str) -> str:
"""录入归一化:otpauth:// URI 取 secret;纯 base32 做可解码自检
无效输入统一转 400,避免存入永远算不出码的废 secret。
"""
raw = raw.strip()
try:
if raw.lower().startswith("otpauth://"):
secret = totp.parse_otpauth_uri(raw)["secret"]
else:
secret = raw
totp.totp_at(secret) # base32 可解码性自检
return secret
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=f"2FA secret 无效:{e}"
) from e
def _require_valid_otp_code(secret: str, code: Optional[str]) -> None:
"""2FA 绑定校验:必须提供当前动态码且验证通过(沿用 GitHub 添加 TOTP 模式)"""
if not code or not code.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="绑定 2FA 需同时填写当前 6 位动态码(用于校验 secret 录入正确)",
)
if not totp.verify(secret, code.strip()):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="验证码不匹配:请确认 secret 录入正确,且填写的是当前动态码",
)
def create_credential(session: Session, data: CredentialCreate) -> CredentialRead:
site = data.site.strip()
if not site:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="站点名称不能为空")
cred = Credential(
site=site,
username=(data.username or "").strip() or None,
login_type=data.login_type,
oauth_provider=(data.oauth_provider or "").strip() or None,
url=(data.url or "").strip() or None,
note=(data.note or "").strip() or None,
)
cred.password_encrypted = crypto.encrypt(data.password)
if data.otp_secret and data.otp_secret.strip():
secret = _normalize_otp_secret(data.otp_secret)
_require_valid_otp_code(secret, data.otp_code)
cred.otp_secret_encrypted = crypto.encrypt(secret)
session.add(cred)
session.commit()
session.refresh(cred)
return _to_read(cred, duplicate=_has_duplicate(session, cred))
def update_credential(
session: Session, credential_id: int, data: CredentialUpdate
) -> CredentialRead:
cred = _get_credential(session, credential_id)
if data.site is not None:
site = data.site.strip()
if not site:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="站点名称不能为空")
cred.site = site
if data.username is not None:
cred.username = data.username.strip() or None
if data.login_type is not None:
cred.login_type = data.login_type
if data.oauth_provider is not None:
cred.oauth_provider = data.oauth_provider.strip() or None
if data.url is not None:
cred.url = data.url.strip() or None
if data.note is not None:
cred.note = data.note.strip() or None
# 密码:None=不修改,''=清除,非空=重加密(crypto.encrypt 对空串返回 None
if data.password is not None:
cred.password_encrypted = crypto.encrypt(data.password)
session.add(cred)
session.commit()
session.refresh(cred)
return _to_read(
cred, duplicate=_has_duplicate(session, cred), account=_account_of(session, cred.id)
)
def delete_credential(session: Session, credential_id: int) -> Dict[str, int]:
"""删除凭据:账号/资产不受影响,仅解除关联(credential_id 置 NULL"""
cred = _get_credential(session, credential_id)
accounts = session.exec(
select(Account).where(Account.credential_id == credential_id)
).all()
for a in accounts:
a.credential_id = None
session.add(a)
affected = len(accounts)
session.delete(cred)
session.commit()
return {"affected_accounts": affected}
def reveal_password(session: Session, credential_id: int) -> Dict[str, str]:
"""解密返回凭据密码明文(供前端「查看/复制密码」)。
安全说明:接口受 API Key 保护;密码本可逆加密存储,此处合法解密还原。
"""
cred = _get_credential(session, credential_id)
plain = crypto.decrypt(cred.password_encrypted)
if not plain:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="该凭据未配置登录密码(可能是授权登录)",
)
return {"site": cred.site, "username": cred.username or "", "password": plain}
def bind_otp(session: Session, credential_id: int, data: OtpBindRequest) -> CredentialRead:
"""绑定 2FA:secret 归一化 + 当前动态码校验通过才落库"""
cred = _get_credential(session, credential_id)
secret = _normalize_otp_secret(data.secret)
_require_valid_otp_code(secret, data.code)
cred.otp_secret_encrypted = crypto.encrypt(secret)
session.add(cred)
session.commit()
session.refresh(cred)
return _to_read(cred, account=_account_of(session, cred.id))
def unbind_otp(session: Session, credential_id: int) -> CredentialRead:
"""解绑 2FA"""
cred = _get_credential(session, credential_id)
cred.otp_secret_encrypted = None
session.add(cred)
session.commit()
session.refresh(cred)
return _to_read(cred, account=_account_of(session, cred.id))
def current_otp(session: Session, credential_id: int) -> Dict[str, object]:
"""生成当前动态码(secret 永不出库,前端凭 expires_in 做本地倒计时)"""
cred = _get_credential(session, credential_id)
secret = crypto.decrypt(cred.otp_secret_encrypted)
if not secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="该凭据未绑定 2FA"
)
code, expires_in = totp.totp_at(secret)
return {"code": code, "expires_in": expires_in}
# ---------------- 账号侧复用(account_service 调用) ---------------- #
def provider_display_name(session: Session, platform: Optional[str]) -> str:
"""平台 slug → 显示名(凭据 site 用):providers.name → 原文 → '未分类'"""
platform = (platform or "").strip()
if platform:
p = session.exec(select(Provider).where(Provider.slug == platform)).first()
return p.name if p else platform
return "未分类"
def upsert_for_account(
session: Session, account: Account, password: Optional[str] = None
) -> Optional[int]:
"""账号侧凭据同步(同一事务内调用,不 commit,由调用方提交):
- 账号无关联凭据:提供了密码才创建(避免为无密码账号建空条目),回填 credential_id
- 已有关联凭据:同步 site/username(账号改名/换平台保持一致);
password 非 None 时按语义更新(''=清除,非空=重加密)
返回 credential_id(无凭据时 None)。
"""
site = provider_display_name(session, account.platform)
username = account.login_user or account.name
cred = session.get(Credential, account.credential_id) if account.credential_id else None
if cred is None:
if not password:
return account.credential_id
cred = Credential(site=site, username=username, login_type=LoginType.PASSWORD)
cred.password_encrypted = crypto.encrypt(password)
session.add(cred)
session.flush() # 先取 id 供 account.credential_id 回填(同一事务)
account.credential_id = cred.id
return cred.id
cred.site = site
cred.username = username
if password is not None:
cred.password_encrypted = crypto.encrypt(password)
session.add(cred)
return cred.id
+19 -12
View File
@@ -15,19 +15,26 @@ DEFAULT_WEIGHT = 20
def latest_checks(session: Session, asset_id: int) -> list: def latest_checks(session: Session, asset_id: int) -> list:
"""取每个检查项的最新一条""" """取每个检查项的最新一条
stmt = (
select(SecurityCheck) 用 GROUP BY max(ts) 子查询精确取每项最新记录:若用 limit(50) 后去重,
.where(SecurityCheck.asset_id == asset_id) 当某个检查项连续上报超过 50 次时会把其他项的最新记录挤出,导致评分失真。
.order_by(SecurityCheck.ts.desc()) """
.limit(50) latest_ts = (
select(
SecurityCheck.check_item,
func.max(SecurityCheck.ts).label("max_ts"),
) )
checks = session.exec(stmt).all() .where(SecurityCheck.asset_id == asset_id)
latest = {} .group_by(SecurityCheck.check_item)
for check in checks: .subquery()
if check.check_item not in latest: )
latest[check.check_item] = check stmt = select(SecurityCheck).where(SecurityCheck.asset_id == asset_id).join(
return list(latest.values()) latest_ts,
(SecurityCheck.check_item == latest_ts.c.check_item)
& (SecurityCheck.ts == latest_ts.c.max_ts),
)
return list(session.exec(stmt).all())
def compute_security_score(checks: list): def compute_security_score(checks: list):
+26 -2
View File
@@ -224,12 +224,36 @@ def delete_site_cert(session: Session, cert_id: int) -> None:
def check_all_site_certs(session: Session) -> dict: def check_all_site_certs(session: Session) -> dict:
"""全量刷新所有站点证书,返回统计与异常清单""" """全量刷新所有站点证书,返回统计与异常清单
探测为纯网络 IO(单次最长 8s),用线程池并发探测后统一写库:
串行时 N 个站点最坏耗时 N×8s,并发后接近单站点耗时;
写库集中在主线程一次 commit(原来逐条 commit 产生 N 次事务)。
"""
from concurrent.futures import ThreadPoolExecutor
certs = session.exec(select(SiteCert)).all() certs = session.exec(select(SiteCert)).all()
stats = {"total": len(certs), "ok": 0, "error": 0, "expiring": 0, "expired": 0} stats = {"total": len(certs), "ok": 0, "error": 0, "expiring": 0, "expired": 0}
problems = [] problems = []
if not certs:
return {"stats": stats, "problems": problems}
# 并发探测(不碰数据库,线程安全)
with ThreadPoolExecutor(max_workers=min(8, len(certs))) as pool:
futures = {pool.submit(probe_site_cert, c.hostname, c.port): c for c in certs}
for future in futures:
cert = futures[future]
try:
_apply_probe(cert, future.result())
except Exception as e: # noqa: BLE001
cert.status = "error"
cert.error = str(e)[:200]
cert.last_checked_at = utcnow()
session.add(cert)
session.commit()
for cert in certs: for cert in certs:
check_one(session, cert) session.refresh(cert)
if cert.status == "error": if cert.status == "error":
stats["error"] += 1 stats["error"] += 1
problems.append({"hostname": cert.hostname, "detail": cert.error}) problems.append({"hostname": cert.hostname, "detail": cert.error})
+135 -28
View File
@@ -3,6 +3,10 @@
将适配器返回的标准化资产(NormalizedVPS / NormalizedDomain / AccountInfo 将适配器返回的标准化资产(NormalizedVPS / NormalizedDomain / AccountInfo
写入或更新到资产库。以 (provider_id, external_id) 作为去重键, 写入或更新到资产库。以 (provider_id, external_id) 作为去重键,
已存在则更新状态/详情,不存在则新建资产。 已存在则更新状态/详情,不存在则新建资产。
凭证层级:API 配置存在账号(Account.api_config_encrypted)上,
同步按账号维度进行(test_account/sync_account);同步产出的资产
自动挂到该账号名下(Asset.account)。平台不再持有凭证。
""" """
import json import json
@@ -20,6 +24,7 @@ from app.core import crypto
from app.core.timeutils import utcnow from app.core.timeutils import utcnow
from app.models.asset import ( from app.models.asset import (
AIAccount, AIAccount,
Account,
Asset, Asset,
AssetStatus, AssetStatus,
AssetType, AssetType,
@@ -32,7 +37,7 @@ _VALID_STATUS = {s.value for s in AssetStatus}
def _load_config(provider: Provider) -> dict: def _load_config(provider: Provider) -> dict:
"""解密平台 API 配置 JSON""" """(已弃用)解密平台 API 配置,仅为兼容历史数据保留"""
plain = crypto.decrypt(provider.api_config_encrypted) plain = crypto.decrypt(provider.api_config_encrypted)
if not plain: if not plain:
return {} return {}
@@ -42,6 +47,30 @@ def _load_config(provider: Provider) -> dict:
return {} return {}
def _load_account_config(account: Account) -> dict:
"""解密账号的 API 配置 JSON"""
plain = crypto.decrypt(account.api_config_encrypted)
if not plain:
return {}
try:
return json.loads(plain)
except (json.JSONDecodeError, TypeError):
return {}
def _find_provider_by_platform(session: Session, platform: str) -> Provider:
"""按账号的 platform(slug 或名称)定位平台"""
provider = session.exec(select(Provider).where(Provider.slug == platform)).first()
if not provider:
provider = session.exec(select(Provider).where(Provider.name == platform)).first()
if not provider:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"账号所属平台不存在:{platform}(请先在账号编辑中选择有效平台)",
)
return provider
def _get_provider(session: Session, provider_id: int) -> Provider: def _get_provider(session: Session, provider_id: int) -> Provider:
provider = session.get(Provider, provider_id) provider = session.get(Provider, provider_id)
if not provider: if not provider:
@@ -49,7 +78,7 @@ def _get_provider(session: Session, provider_id: int) -> Provider:
return provider return provider
def _build_adapter(provider: Provider) -> BaseAdapter: def _build_adapter(provider: Provider, config: dict) -> BaseAdapter:
if not provider.sdk_type: if not provider.sdk_type:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="该平台未配置 SDK 类型(sdk_type" status_code=status.HTTP_400_BAD_REQUEST, detail="该平台未配置 SDK 类型(sdk_type"
@@ -59,7 +88,27 @@ def _build_adapter(provider: Provider) -> BaseAdapter:
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"暂不支持的 SDK 类型:{provider.sdk_type}", detail=f"暂不支持的 SDK 类型:{provider.sdk_type}",
) )
return registry.get_adapter(provider.sdk_type, _load_config(provider)) return registry.get_adapter(provider.sdk_type, config)
def _get_account(session: Session, account_id: int) -> Account:
account = session.get(Account, account_id)
if not account:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="账号不存在")
return account
def _build_adapter_for_account(session: Session, account: Account):
"""按账号构建适配器:平台定 sdk_type,账号提供凭证"""
if not account.platform:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该账号未指定所属平台,无法确定 SDK 类型",
)
provider = _find_provider_by_platform(session, account.platform)
config = _load_account_config(account)
adapter = _build_adapter(provider, config)
return provider, adapter
def _norm_status(raw: Optional[str]) -> AssetStatus: def _norm_status(raw: Optional[str]) -> AssetStatus:
@@ -72,15 +121,37 @@ def _missing_config(adapter: BaseAdapter) -> list:
def test_provider(session: Session, provider_id: int) -> dict: def test_provider(session: Session, provider_id: int) -> dict:
"""测试平台连接 / 凭证有效性""" """(兼容入口)测试平台连接:凭证已下沉到账号,自动找该平台第一个配了凭证的账号"""
provider = _get_provider(session, provider_id) provider = _get_provider(session, provider_id)
adapter = _build_adapter(provider) account = _first_account_with_config(session, provider)
base = {"capabilities": adapter.capabilities(), "sdk_type": provider.sdk_type} if not account:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="凭证已改为在账号上配置:请先在该平台的账号管理中新建账号并填写 API 配置",
)
return test_account(session, account.id)
def _first_account_with_config(session: Session, provider: Provider):
"""找该平台下第一个配置了 API 凭证的账号(兼容旧入口)"""
return session.exec(
select(Account)
.where(Account.platform == provider.slug)
.where(Account.api_config_encrypted.is_not(None)) # type: ignore[union-attr]
.order_by(Account.id.asc())
).first()
def test_account(session: Session, account_id: int) -> dict:
"""测试账号凭证有效性(按账号 platform 定 SDK,凭证取自账号)"""
account = _get_account(session, account_id)
provider, adapter = _build_adapter_for_account(session, account)
base = {"capabilities": adapter.capabilities(), "sdk_type": provider.sdk_type, "account": account.name}
missing = _missing_config(adapter) missing = _missing_config(adapter)
if missing: if missing:
return { return {
"ok": False, "ok": False,
"message": f"缺少凭证配置:{', '.join(missing)}(请在平台编辑里填写 API 配置)", "message": f"缺少凭证配置:{', '.join(missing)}(请在账号编辑里填写 API 配置)",
**base, **base,
} }
result = adapter.test_connection() result = adapter.test_connection()
@@ -98,7 +169,7 @@ def _find_asset(session: Session, provider_id: int, external_id: str, asset_type
).first() ).first()
def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter) -> dict: def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, account_id: Optional[int] = None) -> dict:
created = updated = 0 created = updated = 0
for vps in adapter.list_vps(): for vps in adapter.list_vps():
existing = _find_asset(session, provider.id, vps.external_id, AssetType.VPS) existing = _find_asset(session, provider.id, vps.external_id, AssetType.VPS)
@@ -108,6 +179,8 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter) -> dic
if vps.monthly_cost is not None: if vps.monthly_cost is not None:
existing.cost = vps.monthly_cost existing.cost = vps.monthly_cost
existing.currency = vps.currency existing.currency = vps.currency
if account_id:
existing.account_id = account_id
session.add(existing) session.add(existing)
detail = session.exec( detail = session.exec(
select(VPSDetail).where(VPSDetail.asset_id == existing.id) select(VPSDetail).where(VPSDetail.asset_id == existing.id)
@@ -128,6 +201,7 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter) -> dic
provider=provider.slug, provider=provider.slug,
provider_id=provider.id, provider_id=provider.id,
external_id=vps.external_id, external_id=vps.external_id,
account_id=account_id,
status=_norm_status(vps.status), status=_norm_status(vps.status),
cost=vps.monthly_cost or 0, cost=vps.monthly_cost or 0,
currency=vps.currency, currency=vps.currency,
@@ -150,7 +224,7 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter) -> dic
return {"created": created, "updated": updated} return {"created": created, "updated": updated}
def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter) -> dict: def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter, account_id: Optional[int] = None) -> dict:
created = updated = 0 created = updated = 0
for dom in adapter.list_domains(): for dom in adapter.list_domains():
existing = _find_asset(session, provider.id, dom.external_id, AssetType.DOMAIN) existing = _find_asset(session, provider.id, dom.external_id, AssetType.DOMAIN)
@@ -158,6 +232,8 @@ def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter) ->
existing.status = _norm_status(dom.status) existing.status = _norm_status(dom.status)
if dom.expiry_date: if dom.expiry_date:
existing.expiry_date = dom.expiry_date existing.expiry_date = dom.expiry_date
if account_id:
existing.account_id = account_id
session.add(existing) session.add(existing)
detail = session.exec( detail = session.exec(
select(DomainDetail).where(DomainDetail.asset_id == existing.id) select(DomainDetail).where(DomainDetail.asset_id == existing.id)
@@ -174,6 +250,7 @@ def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter) ->
provider=provider.slug, provider=provider.slug,
provider_id=provider.id, provider_id=provider.id,
external_id=dom.external_id, external_id=dom.external_id,
account_id=account_id,
status=_norm_status(dom.status), status=_norm_status(dom.status),
expiry_date=dom.expiry_date, expiry_date=dom.expiry_date,
) )
@@ -192,39 +269,51 @@ def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter) ->
def sync_provider(session: Session, provider_id: int) -> dict: def sync_provider(session: Session, provider_id: int) -> dict:
"""同步平台资产到本地库""" """(兼容入口)同步平台资产:凭证已下沉到账号,自动找该平台第一个配了凭证的账号"""
provider = _get_provider(session, provider_id) provider = _get_provider(session, provider_id)
adapter = _build_adapter(provider) account = _first_account_with_config(session, provider)
if not account:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="凭证已改为在账号上配置:请先在该平台的账号管理中新建账号并填写 API 配置",
)
return sync_account(session, account.id)
def sync_account(session: Session, account_id: int) -> dict:
"""同步账号资产到本地库(凭证取自账号,同步产出自动挂到该账号名下)"""
account = _get_account(session, account_id)
provider, adapter = _build_adapter_for_account(session, account)
missing = _missing_config(adapter) missing = _missing_config(adapter)
if missing: if missing:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"缺少凭证配置:{', '.join(missing)}(请先在平台编辑里填写 API 配置)", detail=f"缺少凭证配置:{', '.join(missing)}(请先在账号编辑里填写 API 配置)",
) )
caps = adapter.capabilities() caps = adapter.capabilities()
result = {"provider": provider.slug, "sdk_type": provider.sdk_type} result = {"provider": provider.slug, "sdk_type": provider.sdk_type, "account": account.name}
if caps["list_vps"]: if caps["list_vps"]:
try: try:
result["vps"] = _sync_vps(session, provider, adapter) result["vps"] = _sync_vps(session, provider, adapter, account.id)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
result["vps_error"] = str(e) result["vps_error"] = str(e)
if caps["list_domains"]: if caps["list_domains"]:
try: try:
result["domains"] = _sync_domains(session, provider, adapter) result["domains"] = _sync_domains(session, provider, adapter, account.id)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
result["domains_error"] = str(e) result["domains_error"] = str(e)
if caps["get_account"]: if caps["get_account"]:
try: try:
result["account"] = adapter.get_account().to_dict() result["account_info"] = adapter.get_account().to_dict()
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
result["account_error"] = str(e) result["account_info_error"] = str(e)
provider.last_synced_at = utcnow() account.last_synced_at = utcnow()
session.add(provider) session.add(account)
session.commit() session.commit()
result["last_synced_at"] = provider.last_synced_at.isoformat() result["last_synced_at"] = account.last_synced_at.isoformat()
logger.info("同步平台 provider=%s result=%s", provider.slug, result) logger.info("同步账号 account=%s provider=%s result=%s", account.name, provider.slug, result)
return result return result
@@ -299,26 +388,44 @@ def refresh_ai_balance(session: Session, asset_id: int) -> dict:
} }
def sync_all_ai_balances(session: Session) -> dict: def sync_all_ai_balances(session: Session, max_workers: int = 5) -> dict:
"""遍历所有 AI 账号资产,逐个刷新余额(单个失败不中断整体)""" """并发刷新所有 AI 账号余额(单个失败不中断整体)
每个账号需调用外部 API(单次最长 30s),串行时总耗时随账号数线性增长;
改为线程池并发后显著提速。注意:SQLite Session 不能跨线程共享,
每个 worker 使用独立 Session(WAL 模式下多连接读写安全)。
"""
from concurrent.futures import ThreadPoolExecutor
from app.database import assets_engine
ai_assets = session.exec( ai_assets = session.exec(
select(Asset).where(Asset.asset_type == AssetType.AI_AGENT) select(Asset).where(Asset.asset_type == AssetType.AI_AGENT)
).all() ).all()
asset_ids = [(a.id, a.name) for a in ai_assets]
def _refresh_one(asset_id: int):
with Session(assets_engine) as worker_session:
refresh_ai_balance(worker_session, asset_id)
success = 0 success = 0
failed = 0 failed = 0
errors = [] errors = []
for asset in ai_assets: with ThreadPoolExecutor(max_workers=max_workers) as pool:
futures = {pool.submit(_refresh_one, aid): name for aid, name in asset_ids}
for future in futures:
name = futures[future]
try: try:
refresh_ai_balance(session, asset.id) future.result()
success += 1 success += 1
except HTTPException as e: except HTTPException as e:
failed += 1 failed += 1
errors.append(f"{asset.name}: {e.detail}") errors.append(f"{name}: {e.detail}")
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
failed += 1 failed += 1
errors.append(f"{asset.name}: {e}") errors.append(f"{name}: {e}")
return { return {
"total": len(ai_assets), "total": len(asset_ids),
"success": success, "success": success,
"failed": failed, "failed": failed,
"errors": errors, "errors": errors,
+2 -1
View File
@@ -14,7 +14,7 @@
<script>tailwind.config = { darkMode: 'class' };</script> <script>tailwind.config = { darkMode: 'class' };</script>
<script src="/static/js/vue.global.prod.js?v={{ asset_version }}"></script> <script src="/static/js/vue.global.prod.js?v={{ asset_version }}"></script>
<script src="/static/js/chart.umd.js?v={{ asset_version }}"></script> <script src="/static/js/chart.umd.js?v={{ asset_version }}"></script>
<script>window.APP_CONFIG = { appName: "{{ app_name }}" };</script> <script>window.APP_CONFIG = { appName: "{{ app_name }}", version: "{{ app_version }}", commit: "{{ git_commit }}" };</script>
<style> <style>
html { -webkit-tap-highlight-color: transparent; } html { -webkit-tap-highlight-color: transparent; }
::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar { width: 8px; height: 8px; }
@@ -36,6 +36,7 @@
<script src="/static/js/views/ai.js?v={{ asset_version }}"></script> <script src="/static/js/views/ai.js?v={{ asset_version }}"></script>
<script src="/static/js/views/monitor.js?v={{ asset_version }}"></script> <script src="/static/js/views/monitor.js?v={{ asset_version }}"></script>
<script src="/static/js/views/settings.js?v={{ asset_version }}"></script> <script src="/static/js/views/settings.js?v={{ asset_version }}"></script>
<script src="/static/js/views/vault.js?v={{ asset_version }}"></script>
<script src="/static/js/modals.js?v={{ asset_version }}"></script> <script src="/static/js/modals.js?v={{ asset_version }}"></script>
<script src="/static/js/app.js?v={{ asset_version }}"></script> <script src="/static/js/app.js?v={{ asset_version }}"></script>
<script> <script>
+11
View File
@@ -0,0 +1,11 @@
"""pytest 全局配置:确保项目根在 sys.path
tests/ __init__.pypytest prepend 模式只会把 tests/ 加入 sys.path
直接运行 `pytest` import app 会失败python -m pytest cwd path 才可用
此文件让两种运行方式行为一致
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
+32 -2
View File
@@ -1,9 +1,13 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# 数据库分库备份脚本(本地 + S3 兼容对象存储) # 数据库分库备份脚本(本地 + S3 兼容对象存储)
# 用法:backup.sh [assets|metrics|all] # 用法:backup.sh [assets|metrics|all]
# assets -> 备份 assets.db,保留 14 份(建议每日) # assets -> 备份 assets.db + master_key.escrow,保留 14 份(建议每日)
# metrics -> 备份 metrics.db,保留 4 份(建议每周) # metrics -> 备份 metrics.db,保留 4 份(建议每周)
# all -> 两者都备份 # all -> 上述都备份
#
# master_key.escrow 随 assets 周期备份(覆盖式单份):它是被离线 RESTORE_KEY
# 加密的 MASTER_KEY 托管档,进备份/S3 安全;缺了它,整机丢失时即使有库备份
# 和离线钥匙也无法恢复。
# #
# S3 上传(可选):配置 .env 中 S3_* 项后,备份文件自动上传到 # S3 上传(可选):配置 .env 中 S3_* 项后,备份文件自动上传到
# 任意 S3 兼容对象存储(Cloudflare R2 / MinIO / 阿里云 OSS / 腾讯云 COS 等), # 任意 S3 兼容对象存储(Cloudflare R2 / MinIO / 阿里云 OSS / 腾讯云 COS 等),
@@ -188,8 +192,34 @@ def run(db_name: str, keep: int) -> None:
sys.exit(1) sys.exit(1)
def backup_escrow() -> None:
"""备份 MASTER_KEY 托管档(覆盖式单份 + S3)
escrow 多版本无意义(解出的都是当前 MASTER_KEY),故固定文件名覆盖;
重建托管(--force)后下次备份自动带上新档。
"""
import shutil
src = data_dir / "master_key.escrow"
if not src.exists():
print("[skip] master_key.escrow 不存在(尚未建立托管)")
return
out_dir = backup_root / "escrow"
out_dir.mkdir(parents=True, exist_ok=True)
dst = out_dir / "master_key.escrow"
shutil.copy2(src, dst)
dst.chmod(0o600)
print(f"[ok] master_key.escrow -> {dst}(覆盖式单份)")
try:
env = load_env(Path(".env"))
s3_upload(env, dst, "escrow", 14)
except Exception as e:
print(f"[s3][error] escrow 上传失败: {e}", file=sys.stderr)
sys.exit(1)
if target in ("assets", "all"): if target in ("assets", "all"):
run("assets.db", 14) run("assets.db", 14)
backup_escrow()
if target in ("metrics", "all"): if target in ("metrics", "all"):
run("metrics.db", 4) run("metrics.db", 4)
PYEOF PYEOF
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# vps-manager 本地一键部署(零 SSH
#
# 流程:
# 1. 检查工作区干净 → git push 到 Gitea
# 2. 轮询 cc1 的 /health(含 commit 字段)直到线上版本追平本地 HEAD
# cc1 上 vps-manager-update.timer 每 1 分钟自动拉取并重启)
# 3. 验证核心 API 全部 200
#
# 可配置项(环境变量):
# DEPLOY_HEALTH_URL 健康检查地址(默认 Tailscale HTTPS 域名)
# DEPLOY_TIMEOUT 等待超时秒数(默认 720;首次使用含旧 5 分钟 timer 周期余量)
# GIT_BRANCH 推送分支(默认 main)
#
# 用法:./deploy/deploy.sh
set -euo pipefail
HEALTH_URL="${DEPLOY_HEALTH_URL:-https://dify.taile5765c.ts.net/health}"
TIMEOUT="${DEPLOY_TIMEOUT:-720}"
BRANCH="${GIT_BRANCH:-main}"
POLL_INTERVAL=15
cd "$(dirname "$0")/.."
echo "==> [1/3] 推送代码"
if [ -n "$(git status --porcelain)" ]; then
echo "⚠️ 存在未提交变更,请先 commit:"
git status --short
exit 1
fi
git push origin "$BRANCH"
LOCAL=$(git rev-parse HEAD)
# /health 返回 7 位短哈希,比对统一用短哈希
LOCAL_SHORT=$(git rev-parse --short=7 HEAD)
echo " 本地版本 ${LOCAL:0:8} 已推送,等待 cc1 自动更新…"
echo "==> [2/3] 等待 cc1 更新(轮询 ${HEALTH_URL}"
fetch_remote_commit() {
curl -s --max-time 10 "$HEALTH_URL" \
| python3 -c "import json,sys; print(json.load(sys.stdin).get('commit',''))" 2>/dev/null || echo ""
}
deadline=$(( $(date +%s) + TIMEOUT ))
REMOTE=""
while [ "$(date +%s)" -lt "$deadline" ]; do
REMOTE=$(fetch_remote_commit)
if [ "$REMOTE" = "$LOCAL_SHORT" ]; then
echo " ✅ cc1 已更新至 ${LOCAL:0:8}"
break
fi
echo " 线上 ${REMOTE:0:8} | 目标 ${LOCAL:0:8}${POLL_INTERVAL}s 后重试…"
sleep "$POLL_INTERVAL"
done
if [ "$REMOTE" != "$LOCAL_SHORT" ]; then
echo "❌ 部署超时(${TIMEOUT}s 内 cc1 未更新至 ${LOCAL:0:8},当前线上 ${REMOTE:0:8}"
echo " 排查:ssh cc1 'journalctl -u vps-manager-update.service -n 30'"
exit 1
fi
echo "==> [3/3] 验证核心 API"
BASE="${HEALTH_URL%/health}"
FAILED=0
for path in /api/assets /api/accounts /api/providers /api/stats/overview; do
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$BASE$path" || echo "000")
if [ "$code" = "200" ]; then
echo "$path -> $code"
else
echo "$path -> $code"
FAILED=1
fi
done
if [ "$FAILED" -ne 0 ]; then
echo "❌ 部署后验证失败,请检查 cc1 服务日志"
exit 1
fi
echo "🎉 部署完成:${LOCAL:0:8} 已在 cc1 生效并通过验证"
+1
View File
@@ -91,6 +91,7 @@ fi
echo "" echo ""
echo "==========================================" echo "=========================================="
echo "部署完成!" echo "部署完成!"
echo " 当前版本: v$(cat VERSION 2>/dev/null | tr -d '[:space:]' || echo dev) ($(git rev-parse --short HEAD 2>/dev/null || echo unknown))"
echo " 本地访问:http://127.0.0.1:8000" echo " 本地访问:http://127.0.0.1:8000"
echo " Tailscale 内网:http://<本机tailscale_ip>:8000" echo " Tailscale 内网:http://<本机tailscale_ip>:8000"
echo " 如需 PWA/HTTPStailscale serve --bg --https=443 http://127.0.0.1:8000" echo " 如需 PWA/HTTPStailscale serve --bg --https=443 http://127.0.0.1:8000"
Regular → Executable
+41 -2
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# vps-manager 自动更新脚本(由 systemd timer 定时触发) # vps-manager 自动更新脚本(由 systemd timer 定时触发)
# 从 Gitea 拉取最新代码,如有更新则重启服务 # 1. 自动同步 deploy/ 下的 systemd 单元文件(有变更才拷贝 + daemon-reload
# 2. 从 Gitea 拉取最新代码,如有更新则装依赖并重启服务
# #
# 可配置项(环境变量): # 可配置项(环境变量):
# VPS_MANAGER_DIR 项目根目录(默认 /opt/vps-manager # VPS_MANAGER_DIR 项目根目录(默认 /opt/vps-manager
@@ -19,6 +20,40 @@ BRANCH="${GIT_BRANCH:-main}"
cd "$APP_DIR" cd "$APP_DIR"
# 输出当前运行版本号(语义版本 + git 短哈希),便于比对本地与线上是否一致
print_version() {
local semver commit
semver=$(cat VERSION 2>/dev/null | tr -d '[:space:]')
[ -z "$semver" ] && semver="dev"
commit=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
echo "[$(date '+%F %T')] 当前版本: v${semver} (${commit})"
}
# 同步 systemd 单元文件:deploy/ 下的 .service/.timer 与 /etc/systemd/system
# 不一致时拷贝并 reloadtimer 变更额外 restart 使新调度周期立即生效。
# 每次都执行(而非仅版本更新时),保证单元变更不依赖代码变更触发。
sync_units() {
local changed=0 f base
for f in "$APP_DIR"/deploy/*.service "$APP_DIR"/deploy/*.timer; do
[ -f "$f" ] || continue
base=$(basename "$f")
if ! cmp -s "$f" "/etc/systemd/system/$base"; then
cp "$f" "/etc/systemd/system/$base"
echo "[$(date '+%F %T')] 单元已更新: $base"
changed=1
case "$base" in
*.timer) systemctl restart "$base" 2>/dev/null || true ;;
esac
fi
done
if [ "$changed" -eq 1 ]; then
systemctl daemon-reload
echo "[$(date '+%F %T')] systemd 单元同步完成"
fi
}
sync_units
git fetch -q origin "$BRANCH" git fetch -q origin "$BRANCH"
LOCAL=$(git rev-parse HEAD) LOCAL=$(git rev-parse HEAD)
@@ -26,11 +61,14 @@ REMOTE=$(git rev-parse "origin/$BRANCH")
if [ "$LOCAL" = "$REMOTE" ]; then if [ "$LOCAL" = "$REMOTE" ]; then
echo "[$(date '+%F %T')] 代码已是最新,无需更新" echo "[$(date '+%F %T')] 代码已是最新,无需更新"
print_version
exit 0 exit 0
fi fi
echo "[$(date '+%F %T')] 检测到新版本: ${LOCAL:0:8} -> ${REMOTE:0:8}" echo "[$(date '+%F %T')] 检测到新版本: ${LOCAL:0:8} -> ${REMOTE:0:8}"
git pull --ff-only -q origin "$BRANCH" # 部署节点始终与远端完全一致:reset --hard 丢弃任何本地变更(含文件 mode 变化),
# 避免 cc1 上误改文件导致 pull 拒绝合并、更新链路卡死
git reset --hard -q "origin/$BRANCH"
# 依赖如有变更自动安装(无变化时 pip 会快速跳过) # 依赖如有变更自动安装(无变化时 pip 会快速跳过)
if [ -x ".venv/bin/pip" ]; then if [ -x ".venv/bin/pip" ]; then
@@ -40,3 +78,4 @@ fi
# 重启服务加载新代码 # 重启服务加载新代码
systemctl restart "$SERVICE_NAME" systemctl restart "$SERVICE_NAME"
echo "[$(date '+%F %T')] 已重启 $SERVICE_NAME 服务" echo "[$(date '+%F %T')] 已重启 $SERVICE_NAME 服务"
print_version
+2 -1
View File
@@ -8,4 +8,5 @@ Wants=network-online.target
[Service] [Service]
Type=oneshot Type=oneshot
ExecStart=/opt/vps-manager/deploy/update.sh # 用 bash 显式调用,不依赖脚本可执行位(避免编辑后 mode 丢失导致更新链路自毁)
ExecStart=/bin/bash /opt/vps-manager/deploy/update.sh
+3 -2
View File
@@ -1,12 +1,13 @@
# vps-manager 自动更新定时器(每 5 分钟检查一次) # vps-manager 自动更新定时器(每 1 分钟检查一次,配合 deploy.sh 实现准实时自动部署
# 部署位置:/etc/systemd/system/vps-manager-update.timer # 部署位置:/etc/systemd/system/vps-manager-update.timer
# 本文件由 update.sh 的 sync_units 自动同步到部署机,修改后只需 push 即可生效
[Unit] [Unit]
Description=Periodic update check for vps-manager Description=Periodic update check for vps-manager
[Timer] [Timer]
OnBootSec=2min OnBootSec=2min
OnUnitActiveSec=5min OnUnitActiveSec=1min
Unit=vps-manager-update.service Unit=vps-manager-update.service
[Install] [Install]
+276
View File
@@ -0,0 +1,276 @@
# Goal Spec:凭据库(密码 + 2FA)与账号体系联动
> 供 goal 机制执行的规格说明。执行顺序:M0 → M1 → M2 → M3(M4 待用户圈选后启动)。
## 1. Goal Objective(可复制的目标摘要)
为 vps-manager 增加统一的「凭据库」:新建 `credentials` 表(站点 × 登录方式粒度,存用户名/密码/2FA secret/URL/备注),作为登录凭据的**唯一事实源**;平台账号(Account)保留「资产归属 + API 配置」语义,通过 `credential_id` 关联其登录凭据,账号密码的读写全部重定向到凭据层;提供独立的密码库前端视图(搜索/复制/授权登录标记)与 TOTP 动态验证码展示(录入需校验码验证);编写幂等迁移把历史 `accounts.login_password_encrypted` 迁入凭据表并清空原字段;全程不新增 Python 依赖(TOTP 按 RFC 6238 自实现)。
**同时建立 MASTER_KEY 密钥托管(M0,最先落地)**:系统生成随机 RESTORE_KEY(离线抄写保存 2~3 份),用其加密 MASTER_KEY 生成 `master_key.escrow` 随 data/ 备份流转;MASTER_KEY 遗失时运行一次性恢复脚本输入 RESTORE_KEY 找回并回写 .env,全量密文零迁移恢复,日常运行零接触 escrow、零新增网络接口。
本地 pytest + uvicorn 端到端验证截图后提交,再由 Gitea → cc1 timer 自动部署并做生产验证。
## 2. 领域模型决策(已与用户确认)
1. **凭据粒度 = 站点 × 登录方式**。同一 gmail 注册 A~F 六个网站 = 六条 credential,邮箱只是 `username` 的取值,重复出现是常态而非冗余。
2. **密码相同不共享**。A/B 站都是 `xxx` 也各存一份密文(改密逐站发生,禁止联动耦合)。
3. **授权登录(OAuth/SSO)是一条正常记录**`login_type='oauth'` + `oauth_provider='google'` 等,`password` 为空,仍占一条便于检索回忆。
4. **2FA 挂在 credential 上**(登录凭据的一部分,非独立孤岛);账号体系通过关联的凭据使用 2FA。
5. **唯一事实源 = credentials 表**`accounts.login_password_encrypted` 迁移后清空、停止写入;Account 的 `login_user` 字段保留(非机密,供展示);`api_config_encrypted`SDK 用)留在 Account 不动。
6. **账号与凭据解耦生命周期**:删除账号不影响其凭据条目(凭据库独立留存);账号侧新建/改密自动同步到凭据。
7. **TOTP 不引第三方库**`app/core/totp.py` 自实现 RFC 6238HMAC-SHA1 + Base326 位 / 30s 步长),原因:零 SSH 部署链路不重装依赖,避免 `update.sh` 缺包导致自毁;用 RFC 6238 附录 B 官方向量做 pytest 锚定。
8. `site` 显示平台中文名(迁移时 join providers),无平台记录时回退 platform 原文 / "未分类"。
9. **MASTER_KEY 防遗失 = Key Escrow(托管),而非"后补万能钥匙"**Fernet 单钥设计下 key 遗失后补的钥匙解不开旧密文,恢复能力必须预先建立(见 §3)。
## 3. MASTER_KEY 密钥托管(M0,最先落地)
### 3.1 设计决策
- **问题**Fernet 单钥架构下 MASTER_KEY 遗失 = 全量密文不可解;其中 2FA secret 无法像密码一样逐站重置,损失不可逆。
- **方案(Key Escrow**
1. 系统生成随机 `RESTORE_KEY`Fernet key 格式,44 字符 urlsafe base64);
2. 用 RESTORE_KEY 加密当前 MASTER_KEY,得 escrow token`v1:` 前缀 + Fernet token),写入 `data/master_key.escrow`
3. escrow 随 data/ 备份流转(其内容被 RESTORE_KEY 加密,库/备份泄露也解不开);RESTORE_KEY **只离线保存**:用户抄写 2~3 份(密码管理器 / 纸质 / 可信家人);
4. MASTER_KEY 遗失时:运行 `scripts/recover_master_key.py`,输入 RESTORE_KEY → 解密 escrow 找回 MASTER_KEY → 写回 .env → 重启服务,全量密文可解、零迁移。
- **恢复钥匙形态**:系统生成随机强钥匙(已确认),不引入口令派生(防弱口令拖库风险)。
- **恢复入口**:一次性 CLI 脚本(已确认),零新增网络接口/攻击面;日常运行不读取 escrow。
- **边界**RESTORE_KEY 自身遗失 = escrow 失效 → 缓解手段是离线多副本抄写;不做 Shamir 秘密拆分(单用户规模过度设计)。
- **托管对象是 key 本身而非用户数据**:escrow 泄露最坏影响 = 需轮换 MASTER_KEY,不直接暴露任何业务密文(还要同时拿到离线 RESTORE_KEY 才有意义)。
### 3.2 实现清单
- `app/core/crypto.py` 增加(不动现有 encrypt/decrypt 与 lru 缓存):
- `_fernet_from_key(key: str) -> Fernet`:按给定 key 构造独立实例(不走 MASTER_KEY 缓存)。
- `build_escrow(restore_key: str) -> str`:读 `settings.MASTER_KEY`,返回 `'v1:' + Fernet(restore_key).encrypt(master_key_bytes)`
- `recover_master_key(restore_key: str, escrow: str) -> str`:解析 `v1:` 前缀并解密,失败抛错(钥匙错误/文件损坏)。
- 新增 `scripts/setup_key_escrow.py`
- 生成 RESTORE_KEY`Fernet.generate_key()`);
- 立即用 build_escrow + recover_master_key 回验自检;
- 写入 `data/master_key.escrow`(权限 0600);幂等:文件已存在且回验通过则跳过并提示,`--force` 才重建;
- 终端**仅此一次**打印 RESTORE_KEY,附保存建议(3 个离线位置),随后清屏提示已保存到 .env 旁说明文件(`data/master_key.escrow.README`,含步骤简述、不含钥匙)。
- 新增 `scripts/recover_master_key.py`
- `--key <RESTORE_KEY>` 必填(也支持环境变量 `RESTORE_KEY`,避免 shell 历史残留);
- 读 `data/master_key.escrow``recover_master_key`;失败提示"恢复钥匙错误或 escrow 损坏"
- 默认仅打印找回的 MASTER_KEY`--write` 则备份 `.env``.env.bak-pre-recover` 后回写 MASTER_KEY 行,提示重启服务。
- `.gitignore`:确认 `data/` 已忽略(escrow 绝不进 git);M0 落地时手动把 escrow 文件复制一份到离线备份介质。
### 3.3 M0 验收门槛
1. 本地运行 setup 脚本生成 escrow,终端显示 RESTORE_KEY(留存截图一次后即离线保存)。
2. **丢失演练**:备份 `.env` → 用错误 MASTER_KEY 启动,验证真实数据解密失败 → 运行 recover(不带 --write)找回原 key → 回写 .env → 重启 → 既有真实账号密码可正常解密查看。
3. escrow 文件权限 0600、未纳入 git、已复制离线备份。
## 4. 数据模型
### 4.1 新表 `credentials`
`app/models/credential.py`,挂 assets.db
| 列 | 类型 | 说明 |
|---|---|---|
| id | int PK | |
| site | str, index | 站点/服务名(如 "GitHub"、"阿里云"),迁移自平台名 |
| username | str, index, nullable | 登录用户名/邮箱 |
| login_type | str, default 'password' | password / oauth / other |
| oauth_provider | str, nullable | 授权来源:google / apple / github / wechat 等 |
| password_encrypted | str, nullable | Fernet 密文;oauth 为空 |
| otp_secret_encrypted | str, nullable | Fernet 密文,存 base32 secret(不存 otpauth URI,可随时重建) |
| url | str, nullable | 登录页地址(可选跳转) |
| note | str, nullable | 备注 |
| created_at / updated_at | datetime | 与 Asset 同款 utcnow 模式 |
- 不建 `(site, username)` 唯一约束(SQLite 对 NULL 不友好 + 同站多账号合法);服务层创建时提示重复但允许继续。
- 2FA 展示所需信息均从 secret 派生:`issuer=site``account=username`,导出时可重建 otpauth URI。
### 4.2 `accounts` 加列
`Account` 增加 `credential_id: Optional[int] = Field(default=None, foreign_key="credentials.id", index=True)`
### 4.3 相关文件
- `app/database.py``init_db``asset_models` 加入 `Credential``_migrate_assets_db` 增加迁移函数(见 §5)。
- `app/main.py``include_router(credentials.router)`
## 5. 幂等迁移(_migrate_assets_db 内)
```text
_migrate_credentials():
1. accounts 表存在时:若无 credential_id 列 → ALTER TABLE accounts ADD COLUMN credential_id INTEGER
2. 建表/加列后执行 backfill(幂等条件:credential_id 已非空则跳过):
遍历 accounts 中 login_password_encrypted 非空 且 credential_id IS NULL 的行:
site = providers.nameplatform=slug 匹配)→ 否则 platform → 否则 '未分类'
username = COALESCE(login_user, name)
login_type = 'password'
password_encrypted = 原密文原样搬入(不解密再加密,避免中间态暴露)
credential_id = 新行 id
搬入成功后置空 accounts.login_password_encrypted(唯一事实源,防双份漂移)
3. 为 credentials 建索引 ix_credentials_site / ix_credentials_usernameIF NOT EXISTS
```
**执行前必须备份**`cp data/assets.db data/assets.db.bak-pre-vault`(本地与生产各自执行一次,人工确认)。
**回滚**git revert 后执行逆迁移(credential.password_encrypted 写回 account.login_password_encrypted 并解除关联)——单用户数据量小,直接在 sqlite3/脚本内完成,仅在需要时编写。
## 6. 后端改动清单
### 6.1 新增 `app/core/totp.py`(无依赖)
- `b32decode(secret)`:容错去空格/补 `=`
- `totp_at(secret_b32, ts=None)``(code6, period_left_seconds)`RFC 6238 标准实现。
- `verify(secret_b32, code, window=1)`:允许 ±1 步进(录入校验容时钟偏差)。
- `parse_otpauth_uri(uri)``{secret, issuer, account}`:支持用户直接粘贴 `otpauth://totp/...` 录入(仅取 secret 入库,issuer/account 仅回填建议)。
- `random_secret()``secrets` 生成 20 字节 → Base32(供"生成随机密钥"按钮,可选)。
### 6.2 新增 `app/schemas/credential.py`
```text
CredentialBase: site, username, login_type='password', oauth_provider, url, note
CredentialCreate: CredentialBase + password(明文, 可空) + otp_secret(可空) + otp_code(可空)
CredentialUpdate: 全字段可空;password: None=不改,''=清除,非空=重加密(沿用 Account 惯例)
CredentialRead: id, site, username, login_type, oauth_provider, url, note,
has_password, has_otp, account_name(可空,反向关联的账号名), created_at, updated_at
OtpBindRequest: secret(必填), code(必填, 6位) # 录入必须过校验
```
### 6.3 新增 `app/services/credential_service.py`
- `list_credentials(session, q, login_type, has_otp)`:搜索 site/username/note;反向查 account 填充 account_name。
- `create_credential``(site, username)` 重复时允许但返回提示(校验在路由层给 warning 字段或直接允许);password 用 `crypto.encrypt`otp 见 §6.5。
- `update_credential / delete_credential`
- `reveal_password(credential_id)``{username, password}`(同账号 reveal 语义)。
- `bind_otp / unbind_otp / current_otp`(见 §6.5)。
- 依赖注入、404、加密方式与 `account_service` 完全同构。
### 6.4 新增 `app/routers/credentials.py`prefix `/api/credentials`
| 方法 | 路径 | 鉴权 | 说明 |
|---|---|---|---|
| GET | `` ?q=&login_type=&has_otp= | 读 | 列表 |
| POST | `` | require_api_key | 创建(含可选 otp 绑定) |
| PUT | `/{id}` | require_api_key | 更新(含改密/清密) |
| DELETE | `/{id}` | require_api_key | 删除凭据(不动任何账号/资产) |
| GET | `/{id}/password` | require_api_key | 解密查看密码 |
| GET | `/{id}/otp` | require_api_key | `{code, expires_in}`;未绑定 404 |
| PUT | `/{id}/otp` | require_api_key | `OtpBindRequest`verify 通过才存 |
| DELETE | `/{id}/otp` | require_api_key | 解绑 2FA |
- `GET /otp` 返回 `Cache-Control: no-store`(动态码防缓存)。
- 所有写操作与账号/资产路由一致使用 `require_api_key`(内网放行规则自动生效)。
### 6.5 2FA 绑定与生成规则
- **绑定**`secret` + 用户当前 6 位码 `code` 一起提交 → 服务端 `verify(secret, code)` 通过才加密入库,失败 400「验证码不匹配」(防止 secret 手误录入成废条目,沿用 GitHub 添加 TOTP 模式)。
- **生成**`current_otp``totp_at` 算出 `{code, expires_in}`;前端本地倒计时、到 0 重新请求(不轮询)。
### 6.6 改造 `app/services/account_service.py`
- `_to_read``has_login_password` 改判 `bool(account.credential_id)``AccountRead``credential_id``has_otp` 两字段(schema 同步加,均默认 False/None,兼容老前端)。 `has_otp` 需查关联 credential —— list 时批量取 `credential_id in (...)` 后填充。
- `create_account`:保存 `login_password` 时(解密前不落库):
- 同一事务内创建 credential(site=平台显示名规则同迁移,username=COALESCE(login_user,name)),记 `account.credential_id`
- `update_account`:改名/换平台时同步 credential.site/username(保持引用一致);`login_password` 非空 → upsert 到关联 credential(没有则新建并回填 id);`''` → 清 credential.password`None` → 不动。
- `reveal_password`:改从关联 credential 解密(未关联 → 404 同旧语义)。
- `delete_account`**不删** credential(生命周期解耦,凭据库独立留存)。
### 6.7 `AccountRead` schema 扩展
```text
credential_id: Optional[int] = None
has_otp: bool = False
```
## 7. 前端改动清单
### 7.1 导航与路由(`app.js` + `static/js/views/vault.js` 新建)
- `NAVS` 增加 `{ key: 'vault', label: '凭据库', icon: '🔐' }`(放在「平台」之前)。
- `VIEW_MAP` 增加 `vault: 'vault-view'`
- 新建 `static/js/views/vault.js``VaultView` 组件。
- `index.html` 在 modals.js 之前加入 `<script src="/static/js/views/vault.js?v={{ asset_version }}">`
- `app.js` 注册 `app.component('vault-view', VaultView)`
### 7.2 凭据库视图(vault.js)行为
- **头部**:搜索框(site/username/note,复用资产页搜索样式);「+ 新增」。
- **列表**:每行 `site` + `username`,徽章:登录方式(密码=无/隐藏值 `••••••``Google 授权` 等、`其他`);`2FA` 蓝色徽章;有密码行提供 `复制密码` 按钮(fetch password 后 clipboard);点击行 → 展开详情。
- **展开详情**:url(可点击跳转)、note、关联账号(若有,`account_name` 显示"来自平台账号")、操作:显示/复制密码、编辑、删除。
- **2FA 区块**`has_otp` 才显示):大字 6 位码 + 倒计时秒(`expires_in` 驱动本地 1s tick,归零重新请求 `GET /otp`+ 复制验证码 + 解绑。
- **空态**:提示"从平台账号迁移的凭据会自动出现在这里"。
### 7.3 凭据表单(扩展 modals.js 或新建 CredentialModal
- 字段:site*、username、url、login_type 下拉(密码登录/授权登录/其他)、oauth_providerlogin_type=oauth 时出现,含常用建议 google/apple/github/wechat + 自由输入)、passwordtype=password,编辑时留空=不改)、note。
- **2FA 区块**secret 输入框(或粘贴 otpauth:// URI 自动解析填入)+「获取当前验证码」辅助说明 + 当前 code 输入(6 位,必须填,提交时服务端校验)→ 校验失败原地报错不落库。
- 保存后刷新列表 + `loadAccounts()`(账号关联展示需要)。
### 7.4 账号体系联动(modals.js / accounts-view 相关)
- 账号列表(accounts-view-modal)与账号弹窗的"查看密码"按钮逻辑不变(后端已重定向)。
- 若账号关联凭据且有 2FA:查看密码弹窗旁增加「获取验证码」按钮 → 调 `GET /api/credentials/{credential_id}/otp` 展示动态码(复用展开详情的 2FA 组件逻辑,独立小函数)。
- 平台页账号入口保持不动。
### 7.5 store.js / api.js
- `store.credentials` + `loadCredentials()``loadAll()` 并联加入。
- CRUD 函数与 credentialModal state(复制 saveAccount 的 saving 锁模式防双击)。
- `Fmt.LOGIN_TYPE_LABELS = { password: '密码', oauth: '授权登录', other: '其他' }` 与 oauth provider 徽章色。
## 8. 交互打磨(M4,占位待用户圈选后启动)
用户对交互尚有保留意见,此处列候选独立小改动,启动前由用户勾选范围:
1. 全局 toast「已复制」取代旧式 alert/瞬时无反馈(可做成 store.toast + 简单组件)。
2. OTP 环形/进度条倒计时视觉 + 复制即消失反馈。
3. `/` 快捷键聚焦当前页搜索框(移动端不启用)。
4. 账号查看密码弹窗合并进凭据详情(统一交互路径,减少两套弹窗)。
5. 快速录入:从平台账号弹窗一键「补全 2FA」跳凭据表单并预填 site/username。
6. 二维码录入(需要引入前端 QR 解码库/后端解码,成本高,默认不做,除非用户点名)。
7. 双击行快速复制密码、长按移动端复制。
> M4 独立成 goal/任务执行,不阻塞 M0–M3。
## 9. 测试与验收
### 9.1 pytesttests/ 新增 test_totp.py、test_credential_migration.py
- RFC 6238 附录 B 官方向量(secret = ASCII "12345678901234567890" 的 Base32T=59 / 1111111109 / 1111111111 / 1234567890 / 2000000000 / 200000000008 位转 6 位 = 取模 1000000 补零)逐条断言 code 与 expires_in。
- `parse_otpauth_uri` 解析标准 URI 与缺 issuer 容错。
- 迁移幂等:造含密码账号 → 跑两次 backfill → 只产生一条 credential、account 密码字段已清空、第二次不重复建。
- 绑定校验:错 code 拒绝、对 code 落库、`current_otp` 用固定时间戳种子断言稳定性。
- escrow 回验:build_escrow 后 recover_master_key 能还原 MASTER_KEY;错误钥匙抛错。
### 9.2 本地端到端(必做,提交前)
1. `.venv/bin/uvicorn app.main:app --port 8000`(项目根 vps-manager/ 下,load_dotenv 自动读 .env)。
2. 先完成 M0 丢失演练(见 §3.3)。
3. 手动建测试凭据(含 oauth 条目 + 2FA 绑定真实 secret,用手机验证器/在线 TOTP 工具对码)。
4. 浏览器逐项验证:列表搜索、复制密码、OTP 倒计时刷新、账号弹窗查密码仍可用、新增账号自动生成凭据条目。
5. 迁移干跑:确认生产量级账号行全部迁移、无残留明文。
6. 截图留存(本地验证 + 产物截图惯例),再 git commit + push。
### 9.3 生产验证(cc1 自动部署后)
- git push origin main → cc1 timer 拉取更新(deploy.sh 若再出现 git push 卡住,按经验手动 `git push origin main` 干预)。
- 生产环境先备份 `data/assets.db`,确认 /health 新 commit。
- 生产也执行 M0 setup(生成新 escrow + RESTORE_KEY 离线保存)。
- 端到端抽查:老账号密码可见、2FA 录入→对码成功→删除测试数据。
- 确认 VERSION/commit 展示与静态资源版本(`?v=` mtime 机制自动生效,无需手动)。
## 10. 里程碑与验收门槛
| 里程碑 | 内容 | 完成门槛 |
|---|---|---|
| M0 | Key Escrow 托管(crypto 扩展 + setup/recover 脚本 + escrow 文件) | 丢失演练通过(§3.3);pytest escrow 回验绿 |
| M1 | 模型/schema/totp/迁移/credential service+router/account_service 改造 + pytest | 全绿;迁移干跑幂等 |
| M2 | vault 视图 + CredentialModal + 账号联动 + store/api | 本地端到端截图验证通过 |
| M3 | commit → push → cc1 生产验证(含生产 escrow 建立) | 生产抽查通过,无回归 |
| M4 | 交互打磨(圈选后另行启动) | 独立 |
## 11. 风险与注意
- 迁移前**必须**备份 assets.db(本地与生产各一次),迁移函数幂等可重跑。
- 迁移/回滚脚本只搬密文、不落明文,避免中间态暴露。
- RESTORE_KEY 一旦遗失 escrow 即失效 → 落地时强制抄写 3 个离线位置,并保存一份 escrow 到离线介质;`.env` 与 escrow 尽量分介质存放。
- 旧前端页面在部署后需刷新(HTML no-cache + asset_version 已保证)。
- credentials 是个人全量密码仓库:`reveal` / `otp` 接口均走 require_api_keyTailscale 内网直连放行策略不变;前端不落任何明文到 localStorage(仅展示期内存持有)。
- 依赖零新增:requirements.txt / .env 均不改(MASTER_KEY 已存在;RESTORE_KEY 只作为脚本参数/环境变量出现,不常驻配置)。
- 本 spec 为凭据与 2FA 的完整闭环;账号的 `api_config_encrypted`(SDK 密钥)不在本次范围(后续可演进为"API 凭据"视图,不阻塞)。
+133
View File
@@ -0,0 +1,133 @@
# 迁移 Runbookvps-manager 换宿主平台
适用场景:当前宿主为 cc1Tailscale `100.89.0.11`HTTPS `https://dify.taile5765c.ts.net`);
选定长期平台后按本手册完成整体搬迁。目标:**数据零丢失、密文零重录、服务可回滚**。
---
## 1. 迁移面:什么要搬,什么能重建
| 类别 | 内容 | 迁移方式 |
|---|---|---|
| **唯一状态(必须搬)** | `data/assets.db``data/metrics.db``data/master_key.escrow``data/backups/``.env` | `make_migration_bundle.py` 打包 → `restore_migration_bundle.py` 恢复 |
| 可重建(不搬) | 代码 | 新机 `git clone` Gitea 仓库(`setup.sh` 自动) |
| 可重建(不搬) | Python venv 与依赖 | `setup.sh``pip install -r requirements.txt` |
| 可重建(不搬) | systemd units 与全部 timer | `deploy/*.service\|timer` 在仓库内,`setup.sh` 安装 |
| 可重建(不搬) | Tailscale HTTPS serve | 一条 `tailscale serve --bg --https=443 http://127.0.0.1:8000` |
| **机器外状态** | 离线 `RESTORE_KEY` 抄本、Gitea deploy key、S3 凭据 | 手工:RESTORE_KEY 抄本随身;新机需可访问 GiteaSSH key 或 HTTPS+token |
关键不变量:**`.env` 里的 `MASTER_KEY` 必须原样搬过去**——库内所有密文(平台账号
api_config、凭据密码、2FA secret)都用它加密。MASTER_KEY 不变,密文即可直接读,无需重录。
---
## 2. 迁移前可选预处理(让将来迁移更省事)
一次性做完,之后换平台时 agent 与访问地址都无需改动:
1. **agent 地址去 IP 化**:把各被管 VPS 的 `/etc/vps-agent.env`
`VPS_MANAGER_URL=http://100.89.0.11:8000` 改为 MagicDNS 名,例如
`http://dify.<tailnet>.ts.net:8000`;迁移时新设备沿用同名 `dify` 即可零改动。
```bash
sed -i 's#http://100.89.0.11:8000#http://<新地址>:8000#' /etc/vps-agent.env && systemctl restart vps-agent
```
2. **建立 MASTER_KEY 托管**(若尚未做):`python scripts/setup_key_escrow.py`
RESTORE_KEY 抄到 2~3 个离线位置——迁移途中 `.env` 出意外时这是唯一后路。
3. 确认备份 timer 正常:`systemctl list-timers | grep vps-`,且 `data/backups/escrow/` 有托管档。
---
## 3. 迁移步骤
### 3.1 源机(cc1)打包
```bash
ssh cc1
cd /opt/vps-manager
.venv/bin/python scripts/make_migration_bundle.py /tmp/vps-bundle.tar.gz
```
记录输出的**包校验和**与文件数。打包用 sqlite backup API 在线快照,**无需停服**。
### 3.2 传输
仅用 `scp` 或加密介质直传新机,不经第三方网盘/聊天工具:
```bash
scp /tmp/vps-bundle.tar.gz root@<新机>:/tmp/
```
### 3.3 新机部署基座
```bash
sudo tailscale up # 入网;建议设备名沿用 dify(保持 HTTPS URL 不变)
git clone <Gitea仓库> /tmp/vps-manager-setup && cd /tmp/vps-manager-setup
sudo bash deploy/setup.sh # 依赖/venv/units/.env(临时)/服务启动
```
`setup.sh` 生成的临时 `.env` 会在 3.4 被包内 `.env` 覆盖(含真实 MASTER_KEY)。
### 3.4 停服并恢复状态
```bash
cd /opt/vps-manager
sudo systemctl stop vps-manager vps-manager-update.timer
sudo .venv/bin/python scripts/restore_migration_bundle.py /tmp/vps-bundle.tar.gz
sudo systemctl start vps-manager
```
脚本逐文件校验 sha256(不匹配即拒绝),并把新机原有 `data/``.env` 备份为
`*.pre-restore-<ts>` 以便回滚。
### 3.5 访问入口
```bash
sudo tailscale serve --bg --https=443 http://127.0.0.1:8000
sudo tailscale status # 确认 https://<设备名>.<tailnet>.ts.net
```
若 URL 与旧的不同:把新域名加入 `/opt/vps-manager/.env``CORS_ORIGINS`
`sudo systemctl restart vps-manager`,并更新浏览器书签 / 手机 PWA。
### 3.6 agent 与通知
- 各被管 VPS:若未做 §2.1 预处理,逐台改 `VPS_MANAGER_URL` 指向新机并 `systemctl restart vps-agent`
- 通知渠道(Telegram/SMTP)配置随 `.env` 迁移,无需改动,可用一次「立即同步」验证送达。
### 3.7 源机(cc1)下线 —— 防双写双通知
确认新机运行正常**至少一个同步/备份周期**后:
```bash
ssh cc1
sudo systemctl disable --now vps-manager vps-manager-update.timer \
vps-backup-assets.timer vps-backup-metrics.timer vps-sync-ai.timer vps-renewal-check.timer
sudo rm -f /tmp/vps-bundle.tar.gz # 迁移包等同最高机密,用完即删
```
新机侧同样删除 `/tmp/vps-bundle.tar.gz`
---
## 4. 验收清单(迁移后逐项打勾)
- [ ] `curl https://<新地址>/health` 返回 `status=ok`version/commit 与源机一致
- [ ] 前端各视图数据完整:资产、订阅、域名、平台账号、监控、AI
- [ ] **凭据可解密**`/api/credentials/{id}/password` 能解出明文(抽查 1~2 条)
- [ ] **2FA 动态码正确**`/api/credentials/{id}/otp` 出的码能在目标站点通过验证
- [ ] 平台账号 api_config 可解密:一次手动同步成功(验证 API 凭据未失效)
- [ ] `systemctl list-timers | grep vps-` 五个 timer 全部 enabled 且有下次触发时间
- [ ] 监控数据开始进新机(`metrics.db` 有 agent 上报的新指标)
- [ ] 备份 timer 跑过一次:`data/backups/assets/``data/backups/escrow/` 有新文件(含 S3
- [ ] 源机所有 timer 已 disable、服务已 stop(防双写)
- [ ] 源机与新机上的迁移包均已删除
---
## 5. 回滚
新机异常时(源机尚未下线):
```bash
sudo systemctl stop vps-manager
cd /opt/vps-manager && sudo mv data data.bad && sudo mv data.pre-restore-<ts> data
sudo mv .env .env.bad && sudo mv .env.pre-restore-<ts> .env
sudo systemctl start vps-manager
```
源机已下线的极端情况:在源机重新 `systemctl enable --now` 各服务与 timer 即可
(其数据未被改动,打包过程只读)。
---
## 6. 灾难恢复(与迁移不同的场景)
- **仅 `.env`/MASTER_KEY 丢失**(库还在):`python scripts/recover_master_key.py --key <RESTORE_KEY> --write`
- **整机丢失**(只有备份):新机 `setup.sh` → 从 S3/离线取最新 `data/backups/assets/assets_*.db`
`metrics_*.db` 放回 `data/` → 取 `data/backups/escrow/master_key.escrow` 放回 `data/`
用离线 RESTORE_KEY 解出 MASTER_KEY 写入 `.env` → 起服验证解密。
- **RESTORE_KEY 也丢**:无解,密文不可恢复(这正是 §2.2 要求多处离线抄本的原因)。
+138
View File
@@ -0,0 +1,138 @@
"""迁移打包:把 vps-manager 的唯一状态打成单一可校验迁移包
在源机器 cc1运行
.venv/bin/python scripts/make_migration_bundle.py [输出tar.gz路径]
打包内容唯一状态代码/venv/systemd units/tailscale serve 均可由
setup.sh 与一条 serve 命令重建故不在包内
- data/assets.dbdata/metrics.dbsqlite backup API 在线一致性快照无需停服
- data/master_key.escrowMASTER_KEY 托管档若已建立
- data/backups/历史备份 escrow 副本
- .envMASTER_KEY / API_KEY / AGENT_KEY / 通知渠道 / S3 配置
- manifest.json + manifest.sha256版本commit主机逐文件校验和
安全说明包内含 MASTER_KEY 与全部密文等同最高机密传输仅用 scp 或加密
介质恢复完成并验证后即刻删除切勿上传公开对象存储或聊天工具
恢复步骤见 scripts/restore_migration_bundle.py docs/migration-runbook.md
"""
import hashlib
import json
import shutil
import socket
import sqlite3
import subprocess
import sys
import tarfile
import tempfile
from datetime import datetime, timezone
from pathlib import Path
BASE = Path(__file__).resolve().parent.parent
def sha256_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def snap_db(src: Path, dst: Path) -> None:
"""sqlite 在线一致性快照:backup API 保证事务边界完整,源库无需停服"""
con = sqlite3.connect(str(src))
bkp = sqlite3.connect(str(dst))
with bkp:
con.backup(bkp)
bkp.close()
con.close()
def read_git_commit() -> str:
try:
out = subprocess.run(
["git", "rev-parse", "--short", "HEAD"], cwd=BASE,
capture_output=True, text=True, timeout=5,
)
return out.stdout.strip() or "unknown"
except Exception:
return "unknown"
def main() -> int:
out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(
f"/tmp/vps-manager-bundle-{datetime.now():%Y%m%d_%H%M%S}.tar.gz"
)
env_src = BASE / ".env"
if not env_src.exists():
print("[abort] 缺少 .envMASTER_KEY 不在包内则迁移无意义)", file=sys.stderr)
return 2
work = Path(tempfile.mkdtemp(prefix="vps-bundle-"))
root = work / "bundle"
data_dst = root / "data"
data_dst.mkdir(parents=True)
data_src = BASE / "data"
copied: list[str] = []
for db in ("assets.db", "metrics.db"):
src = data_src / db
if src.exists():
snap_db(src, data_dst / db)
copied.append(f"data/{db}")
else:
print(f"[skip] {db} 不存在")
escrow = data_src / "master_key.escrow"
if escrow.exists():
shutil.copy2(escrow, data_dst / "master_key.escrow")
(data_dst / "master_key.escrow").chmod(0o600)
copied.append("data/master_key.escrow")
else:
print("[warn] 未建立 MASTER_KEY 托管(master_key.escrow 不存在),恢复链不完整")
backups_src = data_src / "backups"
if backups_src.is_dir():
shutil.copytree(backups_src, data_dst / "backups")
for p in sorted((data_dst / "backups").rglob("*")):
if p.is_file():
copied.append(str(p.relative_to(root)))
shutil.copy2(env_src, root / ".env")
(root / ".env").chmod(0o600)
copied.append(".env")
manifest = {
"app": "vps-manager",
"version": (BASE / "VERSION").read_text(encoding="utf-8").strip()
if (BASE / "VERSION").exists() else "dev",
"commit": read_git_commit(),
"source_host": socket.gethostname(),
"created_at": datetime.now(timezone.utc).isoformat(),
"files": {name: {"sha256": sha256_file(root / name), "size": (root / name).stat().st_size}
for name in copied},
}
(root / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
)
(root / "manifest.sha256").write_text(
"".join(f"{v['sha256']} {name}\n" for name, v in manifest["files"].items()),
encoding="utf-8",
)
out.parent.mkdir(parents=True, exist_ok=True)
with tarfile.open(out, "w:gz") as tar:
tar.add(root, arcname="bundle")
shutil.rmtree(work, ignore_errors=True)
print(f"[ok] 迁移包:{out}")
print(f" 大小:{out.stat().st_size / 1024:.1f} KB")
print(f" 包校验和:{sha256_file(out)}")
print(f" 源主机:{manifest['source_host']} · 版本 {manifest['version']} ({manifest['commit']})")
print(f" 内含 {len(copied)} 个状态文件(两库快照 + escrow + backups + .env")
print(" 恢复:新机器跑 setup.sh 后停服,执行")
print(f" .venv/bin/python scripts/restore_migration_bundle.py {out}")
print(" ⚠ 包等同最高机密:仅 scp/加密介质传输,验证后删除")
return 0
if __name__ == "__main__":
sys.exit(main())
+92
View File
@@ -0,0 +1,92 @@
"""MASTER_KEY 丢失恢复(一次性脚本,日常运行不接触)
用法
RESTORE_KEY=<钥匙> .venv/bin/python scripts/recover_master_key.py # 仅打印找回的 MASTER_KEY
RESTORE_KEY=<钥匙> .venv/bin/python scripts/recover_master_key.py --write # 备份 .env 后回写 MASTER_KEY
也支持 --key 传参会残留 shell 历史建议用环境变量
恢复后重启服务即可全部密文零迁移照常解密
"""
import argparse
import os
import re
import shutil
import sys
from pathlib import Path
# 确保能导入 app 包(脚本位于 scripts/ 子目录)
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.core import crypto
BASE_DIR = Path(__file__).resolve().parent.parent
DEFAULT_ESCROW = BASE_DIR / "data" / "master_key.escrow"
DEFAULT_ENV = BASE_DIR / ".env"
def _write_env(master_key: str, env_path: Path) -> Path | None:
"""备份 .env 后回写 MASTER_KEY 行(不存在则追加),返回备份路径"""
backup: Path | None = None
if env_path.exists():
backup = env_path.parent / (env_path.name + ".bak-pre-recover")
shutil.copy2(env_path, backup)
text = env_path.read_text(encoding="utf-8")
if re.search(r"^MASTER_KEY=.*$", text, flags=re.MULTILINE):
text = re.sub(
r"^MASTER_KEY=.*$", f"MASTER_KEY={master_key}", text, count=1, flags=re.MULTILINE
)
else:
text = text.rstrip("\n") + f"\nMASTER_KEY={master_key}\n"
env_path.write_text(text, encoding="utf-8")
else:
env_path.write_text(f"MASTER_KEY={master_key}\n", encoding="utf-8")
return backup
def main() -> int:
parser = argparse.ArgumentParser(description="凭 RESTORE_KEY 找回 MASTER_KEY")
parser.add_argument(
"--key", help="RESTORE_KEY(建议改用环境变量 RESTORE_KEY,避免 shell 历史残留)"
)
parser.add_argument(
"--write", action="store_true", help="找回后直接回写 .env(先备份为 .env.bak-pre-recover"
)
parser.add_argument(
"--escrow", default=str(DEFAULT_ESCROW),
help="escrow 文件路径(默认 data/master_key.escrow",
)
parser.add_argument("--env", default=str(DEFAULT_ENV), help=".env 路径(默认项目根 .env")
args = parser.parse_args()
restore_key = args.key or os.environ.get("RESTORE_KEY")
if not restore_key:
print("[恢复] 缺少 RESTORE_KEY--key 传入或设置环境变量 RESTORE_KEY", file=sys.stderr)
return 2
escrow_path = Path(args.escrow)
if not escrow_path.exists():
print(f"[恢复] 未找到托管文件 {escrow_path}(尚未建立托管?)", file=sys.stderr)
return 2
try:
master_key = crypto.recover_master_key(
restore_key.strip(), escrow_path.read_text(encoding="utf-8").strip()
)
except ValueError as e:
print(f"[恢复] 失败:{e}", file=sys.stderr)
return 1
if args.write:
backup = _write_env(master_key, Path(args.env))
where = f"(原文件已备份至 {backup.name}" if backup else ""
print(f"[恢复] MASTER_KEY 已找回并回写 {args.env}{where}")
print("[恢复] 请重启服务使 key 生效:sudo systemctl restart vps-manager")
else:
print("[恢复] MASTER_KEY 已找回(未回写 .env):")
print(f" MASTER_KEY={master_key}")
print("[恢复] 确认无误后加 --write 自动回写 .env,或手动粘贴到 .env 的 MASTER_KEY= 行")
return 0
if __name__ == "__main__":
sys.exit(main())
+113
View File
@@ -0,0 +1,113 @@
"""迁移恢复:校验并解包迁移包到目标部署目录
在新机器运行前提已跑 deploy/setup.sh 完成代码/venv/units 部署
sudo systemctl stop vps-manager vps-manager-update.timer
.venv/bin/python scripts/restore_migration_bundle.py <bundle.tar.gz> [--app-dir /opt/vps-manager]
sudo systemctl start vps-manager
行为
- manifest.sha256 逐文件校验任一不匹配即拒绝恢复防传输损坏/调包
- 目标机现有 data/ .env 先备份为 *.pre-restore-<ts>可回滚
- 解包 data/两库 + escrow + backups .envchmod 600
- 打印后续手工步骤serve/agent/CORS/源机 timer 下线 docs/migration-runbook.md
"""
import argparse
import hashlib
import shutil
import sys
import tarfile
import tempfile
from datetime import datetime
from pathlib import Path
def sha256_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def main() -> int:
ap = argparse.ArgumentParser(description="校验并恢复 vps-manager 迁移包")
ap.add_argument("bundle", help="迁移包 tar.gz 路径")
ap.add_argument("--app-dir", default="/opt/vps-manager", help="目标部署目录")
args = ap.parse_args()
bundle = Path(args.bundle)
app_dir = Path(args.app_dir)
if not bundle.exists():
print(f"[abort] 迁移包不存在:{bundle}", file=sys.stderr)
return 2
if not (app_dir / "app" / "main.py").exists():
print("[abort] 目标目录不像已部署的 vps-manager(缺 app/main.py),请先跑 deploy/setup.sh",
file=sys.stderr)
return 2
work = Path(tempfile.mkdtemp(prefix="vps-restore-"))
try:
with tarfile.open(bundle) as tar:
try:
# Python >= 3.11.4/3.12:官方数据过滤器(防路径穿越/硬链接等)
tar.extractall(work, filter="data")
except TypeError:
# 新机自带旧版 Python(如 3.10)无 filter 参数:手工校验成员路径
for m in tar.getmembers():
if m.name.startswith("/") or ".." in Path(m.name).parts:
print(f"[abort] 包内路径异常,拒绝解包:{m.name}", file=sys.stderr)
return 2
tar.extractall(work)
root = work / "bundle"
manifest_sha = root / "manifest.sha256"
if not manifest_sha.exists():
print("[abort] 包内缺 manifest.sha256,拒绝恢复", file=sys.stderr)
return 2
print("[1/4] 校验包内文件…")
for line in manifest_sha.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
digest, name = line.split(" ", 1)
target = root / name
if not target.exists():
print(f"[abort] 包内缺文件:{name}", file=sys.stderr)
return 2
actual = sha256_file(target)
if actual != digest:
print(f"[abort] 校验和不匹配:{name}\n 期望 {digest}\n 实际 {actual}",
file=sys.stderr)
return 2
print(f" ok {name}")
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
print("[2/4] 备份目标机现有状态…")
for src, tag in ((app_dir / "data", f"data.pre-restore-{ts}"),
(app_dir / ".env", f".env.pre-restore-{ts}")):
if src.exists():
dst = app_dir / tag
shutil.move(str(src), str(dst))
print(f" {src} -> {dst}")
print("[3/4] 恢复 data/ 与 .env…")
shutil.move(str(root / "data"), str(app_dir / "data"))
shutil.move(str(root / ".env"), str(app_dir / ".env"))
(app_dir / ".env").chmod(0o600)
escrow = app_dir / "data" / "master_key.escrow"
if escrow.exists():
escrow.chmod(0o600)
print("[4/4] 恢复完成。后续手工步骤(详见 docs/migration-runbook.md):")
print(" 1. systemctl start vps-manager && curl 127.0.0.1:8000/health 比对 version/commit")
print(" 2. tailscale serve --bg --https=443 http://127.0.0.1:8000(设备名沿用旧名可保持 URL 不变)")
print(" 3. .env 的 CORS_ORIGINS 加入新 HTTPS 域名(若 URL 变化)")
print(" 4. 各被管 VPS 的 /etc/vps-agent.envVPS_MANAGER_URL 指向新地址后 restart vps-agent")
print(" 5. 确认新机数据无误后,源机 disable 全部 timer 并 stop 服务(防双写/双通知)")
print(" 6. 删除本迁移包与源机上的包副本(等同最高机密)")
return 0
finally:
shutil.rmtree(work, ignore_errors=True)
if __name__ == "__main__":
sys.exit(main())
+120
View File
@@ -0,0 +1,120 @@
"""建立 MASTER_KEY 密钥托管(Key Escrow,防 key 遗失)
用法
.venv/bin/python scripts/setup_key_escrow.py # 生成托管,终端显示 RESTORE_KEY
.venv/bin/python scripts/setup_key_escrow.py --key <钥匙> # 回验已有托管是否可解
.venv/bin/python scripts/setup_key_escrow.py --force # 重建(旧钥匙抄本随之作废)
- 生成随机 RESTORE_KEYFernet 格式加密当前 MASTER_KEY 写入 data/master_key.escrow
- RESTORE_KEY 仅在终端显示一次请立即抄写到 2~3 个离线位置密码管理器/纸质/可信家人
- escrow data/ 备份流转内容被离线钥匙加密库泄露也无法还原建议另复制一份到离线介质
"""
import argparse
import sys
from pathlib import Path
# 确保能导入 app 包(脚本位于 scripts/ 子目录)
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from cryptography.fernet import Fernet
from app.core import crypto
from app.core.config import settings
BASE_DIR = Path(__file__).resolve().parent.parent
DEFAULT_ESCROW = BASE_DIR / "data" / "master_key.escrow"
README_PATH = BASE_DIR / "data" / "master_key.escrow.README"
README_TEXT = """MASTER_KEY 密钥托管说明(Key Escrow
=====================================
data/master_key.escrow 是用 RESTORE_KEY 加密的 MASTER_KEY 副本
data/ 一起备份其内容只有离线保管的 RESTORE_KEY 能解开
MASTER_KEY 遗失时的恢复步骤
1. 找到离线保存的 RESTORE_KEY44 字符建立托管时终端显示过一次
2. vps-manager 目录执行仅打印找回结果先人工确认
RESTORE_KEY=<你的钥匙> .venv/bin/python scripts/recover_master_key.py
3. 确认无误后回写 .env自动备份为 .env.bak-pre-recover
RESTORE_KEY=<你的钥匙> .venv/bin/python scripts/recover_master_key.py --write
4. 重启服务sudo systemctl restart vps-manager
全部加密数据密码/API Key/2FA secret即可正常解密零迁移
注意
- RESTORE_KEY 只离线保存绝不写入本文件.env 或数据库
- 重建托管setup_key_escrow.py --force RESTORE_KEY 抄本即作废需重新抄写
- 建议把 escrow 文件另复制一份到离线介质 .env 分开存放
"""
def _write_readme() -> None:
"""写恢复步骤说明(不含任何钥匙材料)"""
README_PATH.parent.mkdir(parents=True, exist_ok=True)
README_PATH.write_text(README_TEXT, encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description="建立 MASTER_KEY 密钥托管(Key Escrow")
parser.add_argument("--key", help="已有 RESTORE_KEY:仅回验托管是否可解,不重建")
parser.add_argument("--force", action="store_true", help="强制重建托管(旧 RESTORE_KEY 抄本随之作废)")
parser.add_argument(
"--escrow", default=str(DEFAULT_ESCROW),
help="escrow 文件路径(默认 data/master_key.escrow",
)
args = parser.parse_args()
escrow_path = Path(args.escrow)
# 回验模式:--key 提供时只校验已有托管,不重建
if args.key:
if not escrow_path.exists():
print(f"[托管] 未找到托管文件 {escrow_path}", file=sys.stderr)
return 2
try:
recovered = crypto.recover_master_key(
args.key.strip(), escrow_path.read_text(encoding="utf-8").strip()
)
except ValueError as e:
print(f"[托管] 回验失败:{e}", file=sys.stderr)
return 1
ok = recovered == settings.MASTER_KEY
print(
f"[托管] 回验{'通过' if ok else '不通过'}escrow 可解开,"
f"还原的 MASTER_KEY 与 .env {'一致' if ok else '不一致(.env 已更换 key?)'}"
)
return 0 if ok else 1
if escrow_path.exists() and not args.force:
print(
f"[托管] {escrow_path} 已存在,未重建(重建加 --force;回验已有托管用 --key",
file=sys.stderr,
)
return 2
restore_key = Fernet.generate_key().decode()
escrow = crypto.build_escrow(restore_key)
# 自检:写盘前立即回验,确保托管文件可用(避免生成废档)
if crypto.recover_master_key(restore_key, escrow) != settings.MASTER_KEY:
print("[托管] 自检失败,未写入文件", file=sys.stderr)
return 1
escrow_path.parent.mkdir(parents=True, exist_ok=True)
escrow_path.write_text(escrow + "\n", encoding="utf-8")
escrow_path.chmod(0o600)
if escrow_path == DEFAULT_ESCROW:
_write_readme()
print("[托管] 已建立 MASTER_KEY 密钥托管:")
print(f" escrow 文件:{escrow_path}(权限 600,随 data/ 备份流转)")
print()
print(" RESTORE_KEY(仅此一次显示,请立即抄写保存到 2~3 个离线位置):")
print(f" {restore_key}")
print()
print(" 建议保存位置:密码管理器 / 纸质抄件(防火防潮)/ 可信家人")
print(" 另建议把 escrow 文件复制一份到离线介质(与 .env 分开存放)")
print(" 恢复命令:RESTORE_KEY=<钥匙> .venv/bin/python scripts/recover_master_key.py")
return 0
if __name__ == "__main__":
sys.exit(main())
+7
View File
@@ -35,6 +35,13 @@ window.VpsFmt = {
// 平台可提供的服务(综合平台多标签):字段对应 Provider.services 逗号分隔列表 // 平台可提供的服务(综合平台多标签):字段对应 Provider.services 逗号分隔列表
SERVICES_LABELS: { vps: '云服务器', domain: '域名', ai_agent: 'AI服务', cloudflare: 'Cloudflare', ssl_cert: 'SSL证书', cdn: 'CDN', dns: 'DNS', other: '其他' }, SERVICES_LABELS: { vps: '云服务器', domain: '域名', ai_agent: 'AI服务', cloudflare: 'Cloudflare', ssl_cert: 'SSL证书', cdn: 'CDN', dns: 'DNS', other: '其他' },
CYCLE_LABELS: { monthly: '月付', quarterly: '季付', yearly: '年付' }, CYCLE_LABELS: { monthly: '月付', quarterly: '季付', yearly: '年付' },
// 凭据库登录方式:字段对应 Credential.login_type
LOGIN_TYPE_LABELS: { password: '密码登录', oauth: '授权登录', other: '其他' },
// 授权登录常见来源(表单 datalist 建议,可自由输入)
OAUTH_PROVIDERS: ['google', 'apple', 'github', 'microsoft', 'wechat', 'qq'],
loginTypeBadge(t) {
return { password: 'bg-slate-500/10 text-slate-600 dark:text-slate-400', oauth: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', other: 'bg-amber-500/10 text-amber-600 dark:text-amber-400' }[t] || 'bg-slate-500/10 text-slate-500';
},
typeBadge(t) { typeBadge(t) {
return { vps: 'bg-blue-500/10 text-blue-600 dark:text-blue-400', domain: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', ai_agent: 'bg-violet-500/10 text-violet-600 dark:text-violet-400', cloudflare: 'bg-orange-500/10 text-orange-600 dark:text-orange-400', other: 'bg-slate-500/10 text-slate-600 dark:text-slate-400' }[t] || 'bg-slate-500/10 text-slate-500'; return { vps: 'bg-blue-500/10 text-blue-600 dark:text-blue-400', domain: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', ai_agent: 'bg-violet-500/10 text-violet-600 dark:text-violet-400', cloudflare: 'bg-orange-500/10 text-orange-600 dark:text-orange-400', other: 'bg-slate-500/10 text-slate-600 dark:text-slate-400' }[t] || 'bg-slate-500/10 text-slate-500';
}, },
+17 -2
View File
@@ -5,12 +5,13 @@ const NAVS = [
{ key: 'dashboard', label: '总览', icon: '📊' }, { key: 'dashboard', label: '总览', icon: '📊' },
{ key: 'assets', label: '资产', icon: '📦' }, { key: 'assets', label: '资产', icon: '📦' },
{ key: 'monitor', label: '监控中心', icon: '⏳' }, { key: 'monitor', label: '监控中心', icon: '⏳' },
{ key: 'vault', label: '凭据库', icon: '🔐' },
{ key: 'providers', label: '平台', icon: '🏢' }, { key: 'providers', label: '平台', icon: '🏢' },
{ key: 'settings', label: '设置', icon: '⚙️' }, { key: 'settings', label: '设置', icon: '⚙️' },
]; ];
const VIEW_MAP = { const VIEW_MAP = {
dashboard: 'dashboard-view', assets: 'assets-view', monitor: 'monitor-view', dashboard: 'dashboard-view', assets: 'assets-view', monitor: 'monitor-view',
providers: 'providers-view', settings: 'settings-view', vault: 'vault-view', providers: 'providers-view', settings: 'settings-view',
// 服务器监控为隐藏路由(不进导航,从资产页 VPS「监控」进入) // 服务器监控为隐藏路由(不进导航,从资产页 VPS「监控」进入)
servers: 'servers-view', servers: 'servers-view',
}; };
@@ -53,6 +54,7 @@ const AppRoot = {
</nav> </nav>
<div class="mt-auto pt-3 border-t border-slate-100 dark:border-slate-800"> <div class="mt-auto pt-3 border-t border-slate-100 dark:border-slate-800">
<button @click="toggleDark" class="w-full text-left px-3 py-2 rounded-lg text-sm text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800">{{ store.dark ? '☀️ 浅色' : '🌙 深色' }}</button> <button @click="toggleDark" class="w-full text-left px-3 py-2 rounded-lg text-sm text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800">{{ store.dark ? '☀️ 浅色' : '🌙 深色' }}</button>
<div class="px-3 pt-1 text-[11px] text-slate-400 dark:text-slate-500" :title="'commit ' + appCommit">v{{ appVersion }} · {{ appCommit }}</div>
</div> </div>
</aside> </aside>
@@ -82,15 +84,24 @@ const AppRoot = {
<asset-modal></asset-modal> <asset-modal></asset-modal>
<provider-modal></provider-modal> <provider-modal></provider-modal>
<account-modal></account-modal>
<accounts-view-modal></accounts-view-modal>
<credential-modal></credential-modal>
<!-- 复制等操作的轻提示底部导航上方不遮内容 -->
<div v-if="toast.show" class="fixed bottom-20 md:bottom-6 left-1/2 -translate-x-1/2 z-[80] px-4 py-2 rounded-full bg-slate-900/90 dark:bg-slate-100/90 text-white dark:text-slate-900 text-xs shadow-lg pointer-events-none whitespace-nowrap">{{ toast.text }}</div>
</div>`, </div>`,
setup() { setup() {
const appName = Api.CFG.appName; const appName = Api.CFG.appName;
const appVersion = Api.CFG.version || 'dev';
const appCommit = Api.CFG.commit || 'unknown';
const navs = NAVS; const navs = NAVS;
const currentNav = Vue.computed(() => navs.find(n => n.key === store.view) || navs[0]); const currentNav = Vue.computed(() => navs.find(n => n.key === store.view) || navs[0]);
const viewComponent = Vue.computed(() => VIEW_MAP[store.view] || 'dashboard-view'); const viewComponent = Vue.computed(() => VIEW_MAP[store.view] || 'dashboard-view');
function go(key) { navigate(key); } function go(key) { navigate(key); }
function quickAdd() { function quickAdd() {
if (store.view === 'providers') return openProviderCreate(); if (store.view === 'providers') return openProviderCreate();
if (store.view === 'vault') return openCredentialCreate();
// 资产页:按当前 TAB 预设类型;监控中心/总览/设置:默认 VPS // 资产页:按当前 TAB 预设类型;监控中心/总览/设置:默认 VPS
let preset = 'vps'; let preset = 'vps';
if (store.view === 'assets') { if (store.view === 'assets') {
@@ -107,7 +118,7 @@ const AppRoot = {
if (searchTimer) clearTimeout(searchTimer); if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => { loadAssets(); }, 300); searchTimer = setTimeout(() => { loadAssets(); }, 300);
} }
return { store, appName, navs, currentNav, viewComponent, go, quickAdd, toggleDark, reload: debouncedReload }; return { store, toast, appName, appVersion, appCommit, navs, currentNav, viewComponent, go, quickAdd, toggleDark, reload: debouncedReload };
}, },
}; };
@@ -116,11 +127,15 @@ const app = Vue.createApp(AppRoot);
app.component('dashboard-view', DashboardView); app.component('dashboard-view', DashboardView);
app.component('assets-view', AssetsView); app.component('assets-view', AssetsView);
app.component('monitor-view', MonitorView); app.component('monitor-view', MonitorView);
app.component('vault-view', VaultView);
app.component('providers-view', ProvidersView); app.component('providers-view', ProvidersView);
app.component('servers-view', ServersView); app.component('servers-view', ServersView);
app.component('settings-view', SettingsView); app.component('settings-view', SettingsView);
app.component('asset-modal', AssetModal); app.component('asset-modal', AssetModal);
app.component('provider-modal', ProviderModal); app.component('provider-modal', ProviderModal);
app.component('account-modal', AccountModal);
app.component('accounts-view-modal', AccountsViewModal);
app.component('credential-modal', CredentialModal);
applyDark(); applyDark();
initRouter(); initRouter();
loadAll(); loadAll();
+350 -24
View File
@@ -41,7 +41,10 @@ const AssetModal = {
<option v-for="(l,k) in Fmt.STATUS_LABELS" :key="k" :value="k">{{ l }}</option> <option v-for="(l,k) in Fmt.STATUS_LABELS" :key="k" :value="k">{{ l }}</option>
</select></label> </select></label>
<label class="block"><span class="text-xs text-slate-500">所属账号</span> <label class="block"><span class="text-xs text-slate-500">所属账号</span>
<input v-model="f.account" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label> <select v-model.number="f.account_id" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
<option :value="null">平台页管理账号</option>
<option v-for="a in store.accounts" :key="a.id" :value="a.id">{{ accountLabel(a) }}</option>
</select></label>
<div class="col-span-2 flex gap-5 text-sm text-slate-600 dark:text-slate-300"> <div class="col-span-2 flex gap-5 text-sm text-slate-600 dark:text-slate-300">
<label class="flex items-center gap-2"><input type="checkbox" v-model="f.auto_renew"> 自动续费</label> <label class="flex items-center gap-2"><input type="checkbox" v-model="f.auto_renew"> 自动续费</label>
<label class="flex items-center gap-2"><input type="checkbox" v-model="f.is_archived"> 已归档</label> <label class="flex items-center gap-2"><input type="checkbox" v-model="f.is_archived"> 已归档</label>
@@ -128,7 +131,14 @@ const AssetModal = {
const p = store.providers.find(x => x.id === f.value.provider_id); const p = store.providers.find(x => x.id === f.value.provider_id);
if (p) f.value.provider = p.slug; if (p) f.value.provider = p.slug;
} }
return { store, Fmt, f, providersForType, onProviderChange, save: saveAsset }; // 账号候选显示:平台名 + 账号标识(同名账号靠平台前缀区分),说明优先
function accountLabel(a) {
const p = store.providers.find(x => x.slug === a.platform);
const pname = p ? p.name : (a.platform || '未指定平台');
if (a.remark) return pname + ' · ' + a.remark + '' + a.name + '';
return pname + ' · ' + a.name;
}
return { store, Fmt, f, providersForType, onProviderChange, accountLabel, save: saveAsset };
}, },
}; };
@@ -151,14 +161,10 @@ const ProviderModal = {
<label v-for="(l,k) in Fmt.SERVICES_LABELS" :key="k" class="flex items-center gap-1 text-xs text-slate-600 dark:text-slate-300"><input type="checkbox" :checked="servicesSet.has(k)" @change="toggleService(k)"> {{ l }}</label> <label v-for="(l,k) in Fmt.SERVICES_LABELS" :key="k" class="flex items-center gap-1 text-xs text-slate-600 dark:text-slate-300"><input type="checkbox" :checked="servicesSet.has(k)" @change="toggleService(k)"> {{ l }}</label>
</div> </div>
</div> </div>
<label class="block"><span class="text-xs text-slate-500">SDK类型</span><select v-model="f.sdk_type" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"><option value=""></option><option v-for="o in sdkOptions" :key="o.type" :value="o.type">{{ o.type }}</option></select></label> <label class="block"><span class="text-xs text-slate-500">SDK类型</span><select v-model="f.sdk_type" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"><option value=""></option><option v-for="o in sdkOptions" :key="o" :value="o">{{ o }}</option></select></label>
<label class="col-span-2 block"><span class="text-xs text-slate-500">官网</span><input v-model="f.website" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label> <label class="col-span-2 block"><span class="text-xs text-slate-500">官网</span><input v-model="f.website" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="col-span-2 block"><span class="text-xs text-slate-500">管理面板URL</span><input v-model="f.console_url" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label> <label class="col-span-2 block"><span class="text-xs text-slate-500">管理面板URL</span><input v-model="f.console_url" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<div v-if="currentFields.length" class="col-span-2 border border-slate-200 dark:border-slate-700 rounded-lg p-3 space-y-2"> <div v-if="f.sdk_type" class="col-span-2 text-xs text-slate-400 bg-slate-50 dark:bg-slate-800/50 rounded-lg p-2">API 凭证请在平台账号中配置保存后在平台卡片点账号 新增账号并填写 API 配置即可测试/同步</div>
<div class="text-xs font-medium text-slate-600 dark:text-slate-300">API 凭证加密存储</div>
<label v-for="field in currentFields" :key="field" class="block"><span class="text-xs text-slate-400">{{ field }}</span><input v-model="apiFields[field]" type="password" :placeholder="store.providerModal.editing ? '' : ' ' + field" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
</div>
<div v-else-if="f.sdk_type" class="col-span-2 text-xs text-amber-600 dark:text-amber-400"> SDK 类型暂无预设凭证字段可后续扩展</div>
<label class="col-span-2 flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300"><input type="checkbox" v-model="f.enabled"> 启用</label> <label class="col-span-2 flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300"><input type="checkbox" v-model="f.enabled"> 启用</label>
</div> </div>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
@@ -170,19 +176,14 @@ const ProviderModal = {
</div>`, </div>`,
setup() { setup() {
const f = Vue.computed(() => store.providerModal.form); const f = Vue.computed(() => store.providerModal.form);
const adapterMeta = Vue.ref([]); const sdkOptions = Vue.ref([]);
const apiFields = Vue.reactive({});
Vue.onMounted(async () => { Vue.onMounted(async () => {
try { const r = await Api.get('/providers/adapters'); adapterMeta.value = r.adapters || []; } catch (e) { /* ignore */ } try {
}); const r = await Api.get('/providers/adapters');
const sdkOptions = Vue.computed(() => {
const opts = []; const opts = [];
for (const m of adapterMeta.value) for (const t of (m.sdk_types || [])) opts.push({ type: t, fields: m.required_config || [] }); for (const m of (r.adapters || [])) for (const t of (m.sdk_types || [])) opts.push(t);
return opts; sdkOptions.value = opts;
}); } catch (e) { /* ignore */ }
const currentFields = Vue.computed(() => {
const opt = sdkOptions.value.find(o => o.type === (f.value && f.value.sdk_type));
return opt ? opt.fields : [];
}); });
// 提供服务多选:services 逗号分隔字符串 <-> checkbox 集合 // 提供服务多选:services 逗号分隔字符串 <-> checkbox 集合
const servicesSet = Vue.computed(() => new Set((f.value && f.value.services || '').split(',').map(s => s.trim()).filter(Boolean))); const servicesSet = Vue.computed(() => new Set((f.value && f.value.services || '').split(',').map(s => s.trim()).filter(Boolean)));
@@ -192,16 +193,341 @@ const ProviderModal = {
if (idx >= 0) arr.splice(idx, 1); else arr.push(k); if (idx >= 0) arr.splice(idx, 1); else arr.push(k);
f.value.services = arr.join(','); f.value.services = arr.join(',');
} }
// 模态框每次打开时清空上次残留的凭证输入,避免串号 // 凭证已下沉到账号层,平台表单不再收集 API 凭证
Vue.watch(() => store.providerModal.show, (show) => { return { store, Fmt, f, sdkOptions, servicesSet, toggleService, save: saveProvider };
},
};
/* 账号编辑模态框(z-[60]:可能叠加在账号查看弹窗 z-50 之上打开,需更高层级) */
const AccountModal = {
template: `
<div v-if="store.accountModal.show" class="fixed inset-0 bg-black/40 z-[60] flex items-end md:items-center justify-center p-0 md:p-4">
<div class="bg-white dark:bg-slate-900 w-full md:max-w-md md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
<h3 class="font-semibold text-sm">{{ store.accountModal.editing ? '编辑账号' : '新增账号' }}</h3>
<button @click="store.accountModal.show=false" class="text-slate-400 hover:text-slate-600"></button>
</div>
<form @submit.prevent="save" class="px-5 py-4 space-y-3" v-if="f">
<label class="block"><span class="text-xs text-slate-500">账号说明这个账号是干嘛的</span>
<input v-model="f.remark" placeholder="如:XX公司代管账号" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">账号标识 *邮箱/用户名</span>
<input v-model="f.name" required placeholder="如 you@example.com" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">所属平台</span>
<select v-model="f.platform" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
<option value="">未指定</option>
<option v-for="p in store.providers" :key="p.id" :value="p.slug">{{ p.name }}</option>
</select></label>
<div class="grid grid-cols-2 gap-3">
<label class="block"><span class="text-xs text-slate-500">登录用户名/邮箱</span>
<input v-model="f.login_user" placeholder="网站登录用" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">登录密码加密存储</span>
<input v-model="f.login_password" type="password" :placeholder="store.accountModal.editing ? '留空不修改' : '可选'" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
</div>
<div v-if="currentFields.length" class="border border-slate-200 dark:border-slate-700 rounded-lg p-3 space-y-2">
<div class="text-xs font-medium text-slate-600 dark:text-slate-300">API 配置 SDK 同步加密存储</div>
<label v-for="field in currentFields" :key="field" class="block"><span class="text-xs text-slate-400">{{ field }}</span>
<input v-model="apiFields[field]" type="password" :placeholder="store.accountModal.editing ? '留空不修改' : '填写 ' + field" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
</div>
<p v-if="store.accountModal.editing" class="text-xs text-slate-400">修改账号标识后引用该账号的资产会自动同步更新</p>
<div class="flex justify-end gap-2">
<button type="button" @click="store.accountModal.show=false" class="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300">取消</button>
<button type="submit" :disabled="store.accountSaving" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700 disabled:opacity-60">{{ store.accountSaving ? '保存中…' : '保存' }}</button>
</div>
</form>
</div>
</div>`,
setup() {
const f = Vue.computed(() => store.accountModal.form);
const adapterMeta = Vue.ref([]);
const apiFields = Vue.reactive({});
Vue.onMounted(async () => {
try { const r = await Api.get('/providers/adapters'); adapterMeta.value = r.adapters || []; } catch (e) { /* ignore */ }
});
// 按账号所属平台的 sdk_type 动态渲染所需凭证字段
const currentFields = Vue.computed(() => {
const p = store.providers.find(x => x.slug === (f.value && f.value.platform));
if (!p || !p.sdk_type) return [];
const m = adapterMeta.value.find(x => (x.sdk_types || []).includes(p.sdk_type));
return m ? (m.required_config || []) : [];
});
// 每次打开清空残留输入,避免串号
Vue.watch(() => store.accountModal.show, (show) => {
if (show) { for (const k of Object.keys(apiFields)) delete apiFields[k]; } if (show) { for (const k of Object.keys(apiFields)) delete apiFields[k]; }
}); });
function save() { function save() {
const cfg = {}; const cfg = {};
for (const field of currentFields.value) if (apiFields[field]) cfg[field] = apiFields[field]; for (const field of currentFields.value) if (apiFields[field]) cfg[field] = apiFields[field];
if (Object.keys(cfg).length) f.value.api_config = JSON.stringify(cfg); f.value.api_config = Object.keys(cfg).length ? JSON.stringify(cfg) : '';
saveProvider(); saveAccount();
} }
return { store, Fmt, f, sdkOptions, currentFields, servicesSet, toggleService, apiFields, save }; return { store, f, currentFields, apiFields, save };
},
};
/* 账号查看弹窗:平台 → 账号 → 资产 三层结构,展开账号看名下资产 */
const AccountsViewModal = {
template: `
<div v-if="store.accountsModal.show" class="fixed inset-0 bg-black/40 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
<div class="bg-white dark:bg-slate-900 w-full md:max-w-lg md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center sticky top-0 bg-white dark:bg-slate-900">
<h3 class="font-semibold text-sm">👤 {{ title }}{{ accountList.length }}</h3>
<div class="flex items-center gap-3">
<button @click="add" class="text-xs text-blue-600 dark:text-blue-400">+ 新增账号</button>
<button @click="store.accountsModal.show=false" class="text-slate-400 hover:text-slate-600"></button>
</div>
</div>
<div class="px-5 py-4">
<p v-if="!accountList.length" class="text-xs text-slate-400">暂无账号一个平台可有多个账号如代管/公司账号添加后可在资产表单所属账号中选择</p>
<div v-else class="space-y-4">
<div v-for="g in groups" :key="g.platform">
<div v-if="!singleProvider" class="text-xs font-medium text-slate-400 mb-1">{{ g.platform ? platformName(g.platform) : '未指定平台' }}</div>
<div class="divide-y divide-slate-100 dark:divide-slate-800 border border-slate-100 dark:border-slate-800 rounded-lg">
<div v-for="a in g.accounts" :key="a.id">
<div class="px-3 py-2 flex items-center gap-2">
<button @click="toggle(a.id)" class="text-slate-400 text-xs w-4">{{ expanded[a.id] ? '▾' : '▸' }}</button>
<div class="flex-1 min-w-0">
<div class="text-sm truncate">{{ a.remark || a.name }}
<span v-if="a.has_login_password" class="text-[10px] px-1 py-0.5 ml-1 rounded bg-slate-100 dark:bg-slate-800 text-slate-400" title="已配置登录凭证">🔑登录</span>
<span v-if="a.has_api_config" class="text-[10px] px-1 py-0.5 ml-1 rounded bg-blue-500/10 text-blue-500" title="已配置 API 凭证">API</span>
</div>
<div class="text-xs text-slate-400 truncate"><span v-if="a.remark">{{ a.name }} · </span> {{ assetsOf(a).length }} </div>
</div>
<button v-if="a.has_login_password" @click="viewPwd(a)" class="text-xs text-amber-600 dark:text-amber-400" title="查看登录密码">🔑</button>
<button v-if="a.has_login_password" @click="copyAcctPwd(a)" class="text-xs text-slate-500 dark:text-slate-400" title="复制登录密码">复制</button>
<button v-if="a.has_otp" @click="viewPwd(a)" class="text-xs text-blue-600 dark:text-blue-400" title="该账号已绑定 2FA,点 🔑 一并查看动态码">2FA</button>
<button v-if="canSync(a)" @click="testAcct(a)" class="text-xs text-emerald-600 dark:text-emerald-400">测试</button>
<button v-if="canSync(a)" @click="syncAcct(a)" class="text-xs text-violet-600 dark:text-violet-400">同步</button>
<button @click="edit(a)" class="text-xs text-blue-600 dark:text-blue-400">编辑</button>
<button @click="del(a)" class="text-xs text-red-600 dark:text-red-400">删除</button>
</div>
<div v-if="expanded[a.id]" class="px-3 pb-2 pl-9 space-y-1">
<p v-if="!assetsOf(a).length" class="text-xs text-slate-400">该账号下暂无资产编辑资产时在所属账号选择 {{ a.name }} 即可关联</p>
<div v-for="asset in assetsOf(a)" :key="asset.id" class="text-xs flex items-center gap-2">
<span class="px-1.5 py-0.5 rounded-full shrink-0" :class="Fmt.typeBadge(asset.asset_type)">{{ Fmt.TYPE_LABELS[asset.asset_type] }}</span>
<span class="truncate">{{ asset.name }}</span>
<span v-if="asset.expiry_date" class="text-slate-400 shrink-0">{{ asset.expiry_date }} 到期</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 密码查看弹窗点击账号的 🔑 后展示明文几秒后自动隐藏 -->
<div v-if="pwd.show" class="absolute inset-0 bg-black/50 flex items-center justify-center p-4" @click.self="pwd.show=false">
<div class="bg-white dark:bg-slate-800 rounded-xl w-full max-w-xs p-5 shadow-xl">
<h4 class="font-semibold text-sm mb-3">🔑 {{ pwd.name }} 的登录凭证</h4>
<div class="space-y-2 text-sm">
<div v-if="pwd.login_user" class="flex justify-between items-center gap-2">
<span class="text-slate-400 text-xs shrink-0">用户名</span>
<span class="font-mono truncate">{{ pwd.login_user }}</span>
</div>
<div class="flex justify-between items-center gap-2">
<span class="text-slate-400 text-xs shrink-0">密码</span>
<span class="font-mono break-all">{{ pwd.password }}</span>
</div>
</div>
<!-- 账号绑定了 2FA一并展示动态码服务端生成本地倒计时 -->
<div v-if="pwd.otpId && otp.code" class="mt-3 flex items-center gap-2 bg-blue-50 dark:bg-blue-900/20 rounded-lg px-3 py-2">
<span class="text-[10px] text-slate-500 shrink-0">2FA</span>
<span class="font-mono text-xl tracking-widest text-blue-700 dark:text-blue-300">{{ otp.code }}</span>
<span class="text-[10px] text-slate-400 shrink-0">{{ otp.expiresIn }}s</span>
<button @click="copyOtpCode" class="ml-auto text-[11px] px-2 py-0.5 rounded border border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400 shrink-0">复制</button>
</div>
<div class="flex gap-2 mt-4">
<button @click="copyPwd" class="flex-1 py-1.5 rounded-lg bg-blue-600 text-white text-xs">{{ pwd.copied ? '✓ 已复制' : '复制密码' }}</button>
<button @click="closePwd" class="px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-700 text-xs">关闭</button>
</div>
<p v-if="pwd.countdown" class="text-[10px] text-slate-400 mt-2 text-center">{{ pwd.countdown }}s 后自动关闭</p>
</div>
</div>
</div>`,
setup() {
const expanded = Vue.reactive({});
const pwd = Vue.reactive({ show: false, name: '', login_user: '', password: '', copied: false, countdown: 0, otpId: null, _timer: null });
// 当前动态码(来自全局 otpState,服务端生成、secret 不下发)
const otp = Vue.computed(() => (pwd.otpId ? (otpState[pwd.otpId] || { code: '', expiresIn: 0 }) : { code: '', expiresIn: 0 }));
const singleProvider = Vue.computed(() => !!store.accountsModal.providerSlug);
const title = Vue.computed(() => {
const slug = store.accountsModal.providerSlug;
if (!slug) return '全部账号';
const p = store.providers.find(x => x.slug === slug);
return (p ? p.name : slug) + ' 的账号';
});
const accountList = Vue.computed(() => {
const slug = store.accountsModal.providerSlug;
return slug ? store.accounts.filter(a => a.platform === slug) : store.accounts;
});
// 全部模式下按平台分组,保持 平台→账号→资产 层级
const groups = Vue.computed(() => {
const m = new Map();
for (const a of accountList.value) {
const key = a.platform || '';
if (!m.has(key)) m.set(key, []);
m.get(key).push(a);
}
return [...m.entries()].map(([platform, accounts]) => ({ platform, accounts }));
});
function platformName(slug) {
const p = store.providers.find(x => x.slug === slug);
return p ? p.name : slug;
}
function assetsOf(a) {
return store.assets.filter(x => x.account_id === a.id);
}
function toggle(id) { expanded[id] = !expanded[id]; }
// 账号所属平台配了 sdk_type 且账号有 API 配置时,提供测试/同步
function providerOf(a) {
return store.providers.find(p => p.slug === a.platform) || store.providers.find(p => p.name === a.platform);
}
function canSync(a) {
const p = providerOf(a);
return !!(a.has_api_config && p && p.sdk_type);
}
async function testAcct(a) {
try {
const r = await Api.post('/accounts/' + a.id + '/test', {});
alert(r.ok ? '✅ ' + r.message : '❌ ' + r.message);
} catch (e) { alert('测试失败:' + e.message); }
}
async function syncAcct(a) {
try {
const r = await Api.post('/accounts/' + a.id + '/sync', {});
const lines = ['同步完成'];
if (r.vps) lines.push('VPS:新增 ' + r.vps.created + ' / 更新 ' + r.vps.updated);
if (r.domains) lines.push('域名:新增 ' + r.domains.created + ' / 更新 ' + r.domains.updated);
if (r.vps_error) lines.push('VPS 错误:' + r.vps_error);
if (r.domains_error) lines.push('域名错误:' + r.domains_error);
alert(lines.join('\n'));
await Promise.all([loadAccounts(), loadAssets()]);
} catch (e) { alert('同步失败:' + e.message); }
}
// 每次打开重置展开状态
Vue.watch(() => store.accountsModal.show, (show) => {
if (show) for (const k of Object.keys(expanded)) delete expanded[k];
});
// 查看密码:拉取明文并展示,8 秒后自动关闭;账号绑定 2FA 时一并取动态码且不自动关
async function viewPwd(a) {
try {
const r = await Api.get('/accounts/' + a.id + '/password');
pwd.name = a.remark || a.name;
pwd.login_user = r.login_user || '';
pwd.password = r.password;
pwd.copied = false;
pwd.show = true;
if (pwd._timer) clearInterval(pwd._timer);
if (a.has_otp && a.credential_id) {
// 验证码需看满 30s 周期,此时不自动关闭(countdown=0 不显示倒计时)
pwd.otpId = a.credential_id;
pwd.countdown = 0;
fetchOtp(a.credential_id);
} else {
pwd.otpId = null;
pwd.countdown = 8;
pwd._timer = setInterval(() => {
pwd.countdown--;
if (pwd.countdown <= 0) closePwd();
}, 1000);
}
} catch (e) { alert('获取密码失败:' + e.message); }
}
function closePwd() {
pwd.show = false;
pwd.password = '';
if (pwd.otpId) { stopOtp(pwd.otpId); pwd.otpId = null; }
if (pwd._timer) { clearInterval(pwd._timer); pwd._timer = null; }
}
function copyOtpCode() { if (otp.value.code) copyText(otp.value.code, '验证码'); }
function copyPwd() {
const done = () => { pwd.copied = true; setTimeout(() => { pwd.copied = false; }, 1500); };
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(pwd.password).then(done).catch(() => fallbackCopy());
} else { fallbackCopy(); }
function fallbackCopy() {
const ta = document.createElement('textarea');
ta.value = pwd.password; document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); done(); } catch (e) {}
document.body.removeChild(ta);
}
}
// 关闭弹窗时清定时器并停掉动态码刷新,避免泄漏
Vue.watch(() => pwd.show, (v) => { if (!v) closePwd(); });
return {
store, Fmt, expanded, pwd, otp, singleProvider, title, accountList, groups,
platformName, assetsOf, toggle, canSync, testAcct, syncAcct, viewPwd, copyPwd,
closePwd, copyOtpCode, copyAcctPwd: copyAccountPassword,
add: () => openAccountCreate(store.accountsModal.providerSlug || ''),
edit: openAccountEdit, del: deleteAccount,
};
},
};
/*
*
* 粒度 = 站点 × 登录方式同一邮箱注册多个站点就录多条密码相同也各存一份
* 2FA 录入必须同时填当前动态码服务端校验 secret 手误成废条目
*/
const CredentialModal = {
template: `
<div v-if="store.credentialModal.show" class="fixed inset-0 bg-black/40 z-[60] flex items-end md:items-center justify-center p-0 md:p-4">
<div class="bg-white dark:bg-slate-900 w-full md:max-w-md md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
<h3 class="font-semibold text-sm">{{ store.credentialModal.editing ? '编辑凭据' : '新增凭据' }}</h3>
<button @click="store.credentialModal.show=false" class="text-slate-400 hover:text-slate-600"></button>
</div>
<form @submit.prevent="save" class="px-5 py-4 space-y-3" v-if="f">
<label class="block"><span class="text-xs text-slate-500">站点/服务名 *</span>
<input v-model="f.site" required placeholder="如 GitHub、某论坛、邮箱" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<div class="grid grid-cols-2 gap-3">
<label class="block"><span class="text-xs text-slate-500">登录方式</span>
<select v-model="f.login_type" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
<option v-for="(l,k) in Fmt.LOGIN_TYPE_LABELS" :key="k" :value="k">{{ l }}</option>
</select></label>
<label class="block"><span class="text-xs text-slate-500">用户名/邮箱</span>
<input v-model="f.username" placeholder="如 you@gmail.com" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
</div>
<!-- 授权登录只需记录来源本站无独立密码 -->
<label v-if="f.login_type==='oauth'" class="block"><span class="text-xs text-slate-500">授权来源</span>
<input v-model="f.oauth_provider" list="oauth-provider-options" placeholder="google / apple / github / wechat" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
<datalist id="oauth-provider-options">
<option v-for="p in Fmt.OAUTH_PROVIDERS" :key="p" :value="p"></option>
</datalist></label>
<label v-else-if="f.login_type==='password'" class="block"><span class="text-xs text-slate-500">登录密码加密存储</span>
<input v-model="f.password" type="password" autocomplete="new-password" :placeholder="store.credentialModal.editing ? '留空不修改' : '可选'" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-500">登录页地址</span>
<input v-model="f.url" placeholder="https://…(可选,便于直接跳转)" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<fieldset class="border border-slate-200 dark:border-slate-700 rounded-lg p-3 space-y-2">
<legend class="text-xs font-medium text-slate-600 dark:text-slate-300 px-1">🛡 2FATOTP 动态码</legend>
<p class="text-[11px] text-slate-400 leading-relaxed">粘贴 otpauth:// 链接或直接填 secret;需同时填写当前 6 位动态码做校验,防止 secret 录错后永远算不出正确码。</p>
<label class="block"><span class="text-xs text-slate-400">secret otpauth 链接</span>
<input v-model="f.otp_secret" :placeholder="store.credentialModal.editing ? '留空不修改' : '可选'" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm font-mono"></label>
<label v-if="f.otp_secret" class="block"><span class="text-xs text-slate-400">当前 6 位动态码 *</span>
<input v-model="f.otp_code" inputmode="numeric" maxlength="6" placeholder="如 123456" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm font-mono tracking-widest"></label>
</fieldset>
<label class="block"><span class="text-xs text-slate-500">备注</span>
<textarea v-model="f.note" rows="2" placeholder="如:备用邮箱注册、公司账号等" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></textarea></label>
<div class="flex justify-end gap-2">
<button type="button" @click="store.credentialModal.show=false" class="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300">取消</button>
<button type="submit" :disabled="store.credentialSaving" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700 disabled:opacity-60">{{ store.credentialSaving ? '保存中…' : '保存' }}</button>
</div>
</form>
</div>
</div>`,
setup() {
const f = Vue.computed(() => store.credentialModal.form);
// 2FA 前端预校验:填了 secret 就必须填 6 位码(服务端会再校验一次)
function save() {
const form = f.value;
if (form.otp_secret && form.otp_secret.trim() && !/^\d{6}$/.test((form.otp_code || '').trim())) {
store.error = '绑定 2FA 需同时填写当前 6 位动态码';
return;
}
saveCredential();
}
return { store, Fmt, f, save };
}, },
}; };
+221 -7
View File
@@ -8,6 +8,7 @@ const store = reactive({
view: 'dashboard', view: 'dashboard',
assets: [], assets: [],
providers: [], providers: [],
accounts: [],
subdomains: [], subdomains: [],
siteCerts: [], siteCerts: [],
overview: {}, overview: {},
@@ -16,6 +17,7 @@ const store = reactive({
search: '', search: '',
filterType: '', filterType: '',
filterStatus: '', filterStatus: '',
filterProvider: '', // 按平台筛选(slug),从平台页「关联 N 个资产」跳转而来
assetTab: '', // 资产页 TAB'' 全部 / vps / domain / cloudflare / subscription(付费资产) / ai_agent assetTab: '', // 资产页 TAB'' 全部 / vps / domain / cloudflare / subscription(付费资产) / ai_agent
monitorTab: '', // 监控中心 TAB:同上 monitorTab: '', // 监控中心 TAB:同上
dark: localStorage.getItem('vps_dark') === '1', dark: localStorage.getItem('vps_dark') === '1',
@@ -23,6 +25,15 @@ const store = reactive({
error: '', error: '',
assetModal: { show: false, editing: null, form: null }, assetModal: { show: false, editing: null, form: null },
providerModal: { show: false, editing: null, form: null }, providerModal: { show: false, editing: null, form: null },
accountModal: { show: false, editing: null, form: null },
accountSaving: false, // 账号保存中锁,防止双击/重复提交触发后端 400 重名
accountsModal: { show: false, providerSlug: null }, // 账号查看弹窗:providerSlug 为 null 时看全部
// 凭据库(密码 + 2FA):全量列表,搜索/过滤交给服务端
credentials: [],
credentialModal: { show: false, editing: null, form: null },
credentialSaving: false, // 保存锁,防双击重复提交
vaultSearch: '',
vaultOtpOnly: false, // 只看已绑定 2FA 的条目
}); });
function applyDark() { function applyDark() {
@@ -34,7 +45,7 @@ function applyDark() {
function emptyAssetForm() { function emptyAssetForm() {
return { return {
name: '', asset_type: 'vps', provider: '', provider_id: null, renewal_cycle: 'monthly', renew_url: '', cancel_url: '', name: '', asset_type: 'vps', provider: '', provider_id: null, renewal_cycle: 'monthly', renew_url: '', cancel_url: '',
account: '', expiry_date: '', auto_renew: false, cost: 0, currency: 'USD', account_id: null, expiry_date: '', auto_renew: false, cost: 0, currency: 'USD',
status: 'active', is_archived: false, remark: '', status: 'active', is_archived: false, remark: '',
vps_detail: { ip_address: '', tailscale_ip: '', region: '', os: '', cpu_cores: 1, memory_gb: 1, disk_gb: 20, bandwidth_gb: null, ssh_port: 22, panel_url: '', ssh_user: '', login_method: 'key', ssh_key: '', password: '', purpose: '' }, vps_detail: { ip_address: '', tailscale_ip: '', region: '', os: '', cpu_cores: 1, memory_gb: 1, disk_gb: 20, bandwidth_gb: null, ssh_port: 22, panel_url: '', ssh_user: '', login_method: 'key', ssh_key: '', password: '', purpose: '' },
domain_detail: { domain_name: '', registrar: '', dns_provider: '', cloudflare_account: '', is_using: true, redirect_target: '', bind_asset_id: null }, domain_detail: { domain_name: '', registrar: '', dns_provider: '', cloudflare_account: '', is_using: true, redirect_target: '', bind_asset_id: null },
@@ -48,7 +59,7 @@ function buildAssetPayload(f) {
name: f.name, asset_type: f.asset_type, provider: f.provider, name: f.name, asset_type: f.asset_type, provider: f.provider,
provider_id: f.provider_id || null, renewal_cycle: f.renewal_cycle || null, provider_id: f.provider_id || null, renewal_cycle: f.renewal_cycle || null,
renew_url: f.renew_url || null, cancel_url: f.cancel_url || null, renew_url: f.renew_url || null, cancel_url: f.cancel_url || null,
account: f.account || null, expiry_date: f.expiry_date || null, account_id: f.account_id || null, expiry_date: f.expiry_date || null,
auto_renew: !!f.auto_renew, cost: Number(f.cost) || 0, currency: f.currency, auto_renew: !!f.auto_renew, cost: Number(f.cost) || 0, currency: f.currency,
status: f.status, is_archived: !!f.is_archived, remark: f.remark || null, status: f.status, is_archived: !!f.is_archived, remark: f.remark || null,
}; };
@@ -73,18 +84,26 @@ async function loadAssets() {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (store.filterType) params.set('asset_type', store.filterType); if (store.filterType) params.set('asset_type', store.filterType);
if (store.filterStatus) params.set('status', store.filterStatus); if (store.filterStatus) params.set('status', store.filterStatus);
if (store.filterProvider) params.set('provider', store.filterProvider);
if (store.search) params.set('q', store.search); if (store.search) params.set('q', store.search);
params.set('sort', 'expiry_date'); params.set('order', 'asc'); params.set('sort', 'expiry_date'); params.set('order', 'asc');
store.assets = await Api.get('/assets?' + params.toString()); store.assets = await Api.get('/assets?' + params.toString());
} }
async function loadProviders() { store.providers = await Api.get('/providers'); } async function loadProviders() { store.providers = await Api.get('/providers'); }
async function loadAccounts() { store.accounts = await Api.get('/accounts'); }
async function loadCredentials() {
const params = new URLSearchParams();
if (store.vaultSearch) params.set('q', store.vaultSearch);
if (store.vaultOtpOnly) params.set('has_otp', 'true');
store.credentials = await Api.get('/credentials?' + params.toString());
}
async function loadSubdomains() { store.subdomains = await Api.get('/subdomains'); } async function loadSubdomains() { store.subdomains = await Api.get('/subdomains'); }
async function loadSiteCerts() { store.siteCerts = await Api.get('/site-certs'); } async function loadSiteCerts() { store.siteCerts = await Api.get('/site-certs'); }
async function loadOverview() { store.overview = await Api.get('/stats/overview'); } async function loadOverview() { store.overview = await Api.get('/stats/overview'); }
async function loadExpiring() { store.expiring = await Api.get('/stats/expiring?days=30'); } async function loadExpiring() { store.expiring = await Api.get('/stats/expiring?days=30'); }
async function loadAll() { async function loadAll() {
store.loading = true; store.error = ''; store.loading = true; store.error = '';
try { await Promise.all([loadAssets(), loadProviders(), loadSubdomains(), loadSiteCerts(), loadOverview(), loadExpiring()]); } try { await Promise.all([loadAssets(), loadProviders(), loadAccounts(), loadCredentials(), loadSubdomains(), loadSiteCerts(), loadOverview(), loadExpiring()]); }
catch (e) { store.error = e.message; } finally { store.loading = false; } catch (e) { store.error = e.message; } finally { store.loading = false; }
} }
@@ -107,7 +126,7 @@ function openAICreate(provider) {
} }
function openAssetEdit(a) { function openAssetEdit(a) {
const form = emptyAssetForm(); const form = emptyAssetForm();
['name', 'asset_type', 'provider', 'provider_id', 'renewal_cycle', 'renew_url', 'cancel_url', 'account', 'expiry_date', 'auto_renew', 'cost', 'currency', 'status', 'is_archived', 'remark'].forEach(k => { form[k] = a[k]; }); ['name', 'asset_type', 'provider', 'provider_id', 'renewal_cycle', 'renew_url', 'cancel_url', 'account_id', 'expiry_date', 'auto_renew', 'cost', 'currency', 'status', 'is_archived', 'remark'].forEach(k => { form[k] = a[k]; });
if (a.vps_detail) Object.assign(form.vps_detail, a.vps_detail); if (a.vps_detail) Object.assign(form.vps_detail, a.vps_detail);
if (a.domain_detail) Object.assign(form.domain_detail, a.domain_detail); if (a.domain_detail) Object.assign(form.domain_detail, a.domain_detail);
if (a.ai_detail) Object.assign(form.ai_detail, a.ai_detail); if (a.ai_detail) Object.assign(form.ai_detail, a.ai_detail);
@@ -135,15 +154,16 @@ async function deleteAsset(a) {
/* ---------------- 平台操作 ---------------- */ /* ---------------- 平台操作 ---------------- */
function openProviderCreate() { function openProviderCreate() {
store.providerModal = { show: true, editing: null, form: { slug: '', name: '', name_en: '', category: 'vps', services: '', website: '', console_url: '', sdk_type: '', enabled: true, remark: '', api_config: '' } }; store.providerModal = { show: true, editing: null, form: { slug: '', name: '', name_en: '', category: 'vps', services: '', website: '', console_url: '', sdk_type: '', enabled: true, remark: '' } };
} }
function openProviderEdit(p) { function openProviderEdit(p) {
store.providerModal = { show: true, editing: p.id, form: { slug: p.slug, name: p.name, name_en: p.name_en || '', category: p.category, services: p.services || '', website: p.website || '', console_url: p.console_url || '', sdk_type: p.sdk_type || '', enabled: p.enabled, remark: p.remark || '', api_config: '' } }; store.providerModal = { show: true, editing: p.id, form: { slug: p.slug, name: p.name, name_en: p.name_en || '', category: p.category, services: p.services || '', website: p.website || '', console_url: p.console_url || '', sdk_type: p.sdk_type || '', enabled: p.enabled, remark: p.remark || '' } };
} }
async function saveProvider() { async function saveProvider() {
store.error = ''; store.error = '';
const f = store.providerModal.form; const f = store.providerModal.form;
const payload = { slug: f.slug, name: f.name, name_en: f.name_en || null, category: f.category, services: f.services || null, website: f.website || null, console_url: f.console_url || null, sdk_type: f.sdk_type || null, enabled: !!f.enabled, remark: f.remark || null, api_config: f.api_config || null }; // 凭证已下沉到账号层,平台不再携带 api_config
const payload = { slug: f.slug, name: f.name, name_en: f.name_en || null, category: f.category, services: f.services || null, website: f.website || null, console_url: f.console_url || null, sdk_type: f.sdk_type || null, enabled: !!f.enabled, remark: f.remark || null };
try { try {
if (store.providerModal.editing) await Api.put('/providers/' + store.providerModal.editing, payload); if (store.providerModal.editing) await Api.put('/providers/' + store.providerModal.editing, payload);
else await Api.post('/providers', payload); else await Api.post('/providers', payload);
@@ -162,6 +182,200 @@ async function deleteProvider(p) {
catch (e) { store.error = e.message; } catch (e) { store.error = e.message; }
} }
/* ---------------- 账号操作 ---------------- */
function openAccountsView(providerSlug) {
store.accountsModal = { show: true, providerSlug: providerSlug || null };
}
function openAccountCreate(platform) {
store.accountModal = { show: true, editing: null, form: { name: '', platform: platform || '', remark: '', login_user: '', login_password: '', api_config: '' } };
}
function openAccountEdit(a) {
// login_password / api_config 留空 = 不修改原凭证
store.accountModal = { show: true, editing: a.id, form: { name: a.name, platform: a.platform || '', remark: a.remark || '', login_user: a.login_user || '', login_password: '', api_config: '' } };
}
async function saveAccount() {
// 提交锁:双击/网络慢时重复点击会发出两次请求,第二次必然重名 400
if (store.accountSaving) return;
store.error = '';
store.accountSaving = true;
const f = store.accountModal.form;
const payload = {
name: f.name, platform: f.platform || null, remark: f.remark || null,
login_user: f.login_user || null,
login_password: f.login_password || null,
api_config: f.api_config || null,
};
try {
if (store.accountModal.editing) await Api.put('/accounts/' + store.accountModal.editing, payload);
else await Api.post('/accounts', payload);
store.accountModal.show = false;
// 重命名会同步更新资产端引用,需一并刷新资产
await Promise.all([loadAccounts(), loadAssets()]);
} catch (e) { store.error = e.message; }
finally { store.accountSaving = false; }
}
async function deleteAccount(a) {
let msg = '确认删除账号「' + a.name + '」?';
if (a.asset_count) msg += '\n有 ' + a.asset_count + ' 个资产引用该账号(资产中已填的账号名不受影响)。';
if (!confirm(msg)) return;
store.error = '';
try { await Api.del('/accounts/' + a.id); await loadAccounts(); }
catch (e) { store.error = e.message; }
}
/* ---------------- 凭据库(密码 + 2FA ---------------- */
function emptyCredentialForm() {
return {
site: '', username: '', login_type: 'password', oauth_provider: '',
password: '', url: '', note: '', otp_secret: '', otp_code: '',
};
}
function openCredentialCreate(preset) {
const form = emptyCredentialForm();
if (preset) Object.assign(form, preset);
store.credentialModal = { show: true, editing: null, form };
}
function openCredentialEdit(c) {
// password / otp_secret 留空 = 不修改(与账号凭证惯例一致)
store.credentialModal = {
show: true, editing: c.id,
form: {
site: c.site, username: c.username || '', login_type: c.login_type,
oauth_provider: c.oauth_provider || '', password: '', url: c.url || '',
note: c.note || '', otp_secret: '', otp_code: '',
},
};
}
async function saveCredential() {
// 提交锁:双击/网络慢时重复点击会发出两次请求,造成重复条目
if (store.credentialSaving) return;
store.error = '';
store.credentialSaving = true;
const f = store.credentialModal.form;
const editing = store.credentialModal.editing;
const payload = {
site: f.site, username: f.username || null, login_type: f.login_type,
oauth_provider: f.oauth_provider || null, url: f.url || null, note: f.note || null,
};
try {
if (editing) {
// 密码留空 = 不修改(后端 None 语义),填了才提交
if (f.password) payload.password = f.password;
await Api.put('/credentials/' + editing, payload);
// 2FA 走独立接口(secret + 当前动态码服务端校验)
if (f.otp_secret) {
await Api.put('/credentials/' + editing + '/otp', { secret: f.otp_secret, code: f.otp_code });
}
} else {
payload.password = f.password || null;
if (f.otp_secret) { payload.otp_secret = f.otp_secret; payload.otp_code = f.otp_code || null; }
await Api.post('/credentials', payload);
}
store.credentialModal.show = false;
// 账号侧 has_login_password / has_otp 来自关联凭据,需一并刷新
await Promise.all([loadCredentials(), loadAccounts()]);
} catch (e) { store.error = e.message; }
finally { store.credentialSaving = false; }
}
async function deleteCredential(c) {
const label = c.site + (c.username ? ' · ' + c.username : '');
if (!confirm('确认删除凭据「' + label + '」?此操作不可恢复。')) return;
store.error = '';
try {
await Api.del('/credentials/' + c.id);
await Promise.all([loadCredentials(), loadAccounts()]);
} catch (e) { store.error = e.message; }
}
async function unbindCredentialOtp(c) {
if (!confirm('确认解绑「' + c.site + '」的 2FA?解绑后需重新录入 secret。')) return;
store.error = '';
try { await Api.del('/credentials/' + c.id + '/otp'); await loadCredentials(); }
catch (e) { store.error = e.message; }
}
/* ---------------- 复制与轻提示(toast ---------------- */
const toast = Vue.reactive({ show: false, text: '', _timer: null });
function showToast(text) {
toast.text = text;
toast.show = true;
if (toast._timer) clearTimeout(toast._timer);
toast._timer = setTimeout(() => { toast.show = false; }, 1600);
}
function copyText(text, label) {
const done = () => showToast('✓ ' + (label || '内容') + '已复制');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done).catch(() => _fallbackCopy(text, done));
} else { _fallbackCopy(text, done); }
}
function _fallbackCopy(text, done) {
// 非安全上下文(http 局域网)无 clipboard API,降级用 execCommand
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); done(); } catch (e) { showToast('复制失败,请手动选择'); }
document.body.removeChild(ta);
}
async function copyCredentialPassword(c) {
store.error = '';
try {
const r = await Api.get('/credentials/' + c.id + '/password');
copyText(r.password, '密码');
} catch (e) { store.error = e.message; }
}
async function copyAccountPassword(a) {
// 账号侧复制:后端已重定向到关联凭据,接口语义不变
store.error = '';
try {
const r = await Api.get('/accounts/' + a.id + '/password');
copyText(r.password, '密码');
} catch (e) { store.error = e.message; }
}
/* ---------------- 2FA 动态码(服务端生成,本地倒计时) ---------------- */
// otpState[credential_id] = { code, expiresIn, loading, timer }secret 永不下发到前端
const otpState = Vue.reactive({});
async function fetchOtp(id) {
// 先经 Proxy 创建占位对象,再取回响应式代理。
// 注意不能写 `const s = otpState[id] || (otpState[id] = {...})`:赋值表达式的
// 返回值是 raw 对象,后续 s.code = ... 绕过 Proxy 不触发视图更新(Vue 3 陷阱)。
if (!otpState[id]) otpState[id] = { code: '', expiresIn: 0, loading: false, timer: null };
const s = otpState[id];
s.loading = true;
try {
const r = await Api.get('/credentials/' + id + '/otp');
s.code = r.code;
s.expiresIn = r.expires_in;
if (s.timer) clearInterval(s.timer);
// 本地逐秒倒计时,归零自动拉下一个码(不做秒级轮询)
s.timer = setInterval(() => {
s.expiresIn--;
if (s.expiresIn <= 0) { clearInterval(s.timer); s.timer = null; fetchOtp(id); }
}, 1000);
} catch (e) { store.error = e.message; }
finally { s.loading = false; }
}
function stopOtp(id) {
const s = otpState[id];
if (s && s.timer) { clearInterval(s.timer); s.timer = null; }
}
function stopAllOtp() {
for (const id of Object.keys(otpState)) stopOtp(Number(id));
}
/* ---------------- 平台页跳转:按平台查看资产 ---------------- */
function openAssetsByProvider(p) {
store.filterProvider = p.slug;
store.assetTab = '';
store.filterType = '';
if (window.location.hash !== '#assets') window.location.hash = '#assets';
loadAssets();
}
function clearProviderFilter() {
store.filterProvider = '';
loadAssets();
}
/* ---------------- 服务器监控 ---------------- */ /* ---------------- 服务器监控 ---------------- */
async function viewServer(a) { async function viewServer(a) {
try { try {
+14 -1
View File
@@ -14,6 +14,14 @@ const AssetsView = {
</button> </button>
</div> </div>
<!-- 平台筛选标签从平台页跳转而来 取消 -->
<div v-if="store.filterProvider" class="flex items-center">
<span class="inline-flex items-center gap-1 text-xs px-2.5 py-1 rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400">
🏢 平台{{ providerName(store.filterProvider) }}
<button @click="clearProvider" class="font-bold hover:opacity-70" title="取消平台筛选"></button>
</span>
</div>
<!-- 通用列表全部 / VPS --> <!-- 通用列表全部 / VPS -->
<template v-if="store.assetTab==='' || store.assetTab==='vps'"> <template v-if="store.assetTab==='' || store.assetTab==='vps'">
<div class="flex flex-wrap gap-2 items-center"> <div class="flex flex-wrap gap-2 items-center">
@@ -116,6 +124,11 @@ const AssetsView = {
// 专有视图需要全量数据;若此前按类型过滤过则清空重载 // 专有视图需要全量数据;若此前按类型过滤过则清空重载
if (k !== '' && k !== 'vps' && store.filterType) { store.filterType = ''; loadAssets(); } if (k !== '' && k !== 'vps' && store.filterType) { store.filterType = ''; loadAssets(); }
} }
// 平台筛选标签显示名:filterProvider 存 slug,回退显示原值
function providerName(slug) {
const p = store.providers.find(x => x.slug === slug);
return p ? p.name : slug;
}
async function refreshBalance(a) { async function refreshBalance(a) {
if (!confirm('刷新「' + a.name + '」的余额?')) return; if (!confirm('刷新「' + a.name + '」的余额?')) return;
try { try {
@@ -132,6 +145,6 @@ const AssetsView = {
await loadAssets(); await loadAssets();
} catch (e) { alert('同步失败:' + e.message); } } catch (e) { alert('同步失败:' + e.message); }
} }
return { store, Fmt, tabs, tabView, list, switchTab, reload: loadAssets, edit: openAssetEdit, del: deleteAsset, viewServer, refreshBalance, syncAllAI }; return { store, Fmt, tabs, tabView, list, switchTab, providerName, reload: loadAssets, clearProvider: clearProviderFilter, edit: openAssetEdit, del: deleteAsset, viewServer, refreshBalance, syncAllAI };
}, },
}; };
+28 -43
View File
@@ -4,14 +4,16 @@ const ProvidersView = {
<div class="space-y-3"> <div class="space-y-3">
<!-- 说明条让用户明白平台是什么 --> <!-- 说明条让用户明白平台是什么 -->
<div class="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2"> <div class="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2">
🏢 平台 = 资产的服务商Vultr / 新网 / OpenAI资产中添加项目时选择归属平台支持 API 对接的平台配置好密钥后测试连接同步资产拉取真实数据 🏢 平台 = 资产的服务商Vultr / 新网 / OpenAI凭证登录信息/API Key配在账号上点平台卡片👤 账号新增账号并填写凭证即可在账号上测试/同步拉取真实数据
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2 flex-wrap">
<button @click="seed" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800">初始化预设平台</button> <button @click="seed" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800">初始化预设平台</button>
<button @click="create" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white hover:bg-blue-700">+ 自定义平台</button> <button @click="create" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white hover:bg-blue-700">+ 自定义平台</button>
<button @click="manageAccounts" class="text-sm px-3 py-1.5 rounded-lg border border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20">👤 账号管理</button>
<button @click="toggleShowAll" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800">{{ showAll ? '只显示在用' : '显示全部平台(' + store.providers.length + '' }}</button>
</div> </div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<div v-for="p in store.providers" :key="p.id" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4"> <div v-for="p in visibleProviders" :key="p.id" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div class="flex justify-between items-start"> <div class="flex justify-between items-start">
<div> <div>
<div class="font-medium text-sm">{{ p.name }}</div> <div class="font-medium text-sm">{{ p.name }}</div>
@@ -26,29 +28,24 @@ const ProvidersView = {
<div v-if="p.sdk_type">API 对接: {{ p.sdk_type }}</div> <div v-if="p.sdk_type">API 对接: {{ p.sdk_type }}</div>
<div v-if="p.console_url"><a :href="p.console_url" target="_blank" class="text-blue-600 dark:text-blue-400 hover:underline">管理面板 </a></div> <div v-if="p.console_url"><a :href="p.console_url" target="_blank" class="text-blue-600 dark:text-blue-400 hover:underline">管理面板 </a></div>
<div class="flex items-center gap-2 mt-1"> <div class="flex items-center gap-2 mt-1">
<span class="text-slate-500">📎 关联 {{ countOf(p) }} 个资产</span> <button @click="goAssets(p)" :disabled="!countOf(p)" class="text-slate-500 hover:text-blue-600 dark:hover:text-blue-400 disabled:hover:text-slate-500" :title="countOf(p) ? '查看该平台下的资产' : ''">📎 关联 {{ countOf(p) }} 个资产</button>
<span v-if="p.has_api_config" class="text-emerald-600 dark:text-emerald-400">已配置 API</span> <button @click="openAccounts(p)" class="text-slate-500 hover:text-blue-600 dark:hover:text-blue-400">👤 账号 {{ accountsCountOf(p) }}</button>
<span v-else class="text-slate-400">未配置 API</span>
<span :class="p.enabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-400'">{{ p.enabled ? '启用' : '停用' }}</span> <span :class="p.enabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-400'">{{ p.enabled ? '启用' : '停用' }}</span>
</div> </div>
<div v-if="p.last_synced_at" class="text-slate-400">最后同步{{ fmtTime(p.last_synced_at) }}</div> <div v-if="p.last_synced_at" class="text-slate-400">最后同步{{ fmtTime(p.last_synced_at) }}</div>
</div> </div>
<div class="flex gap-4 mt-3 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs flex-wrap"> <div class="flex gap-4 mt-3 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs flex-wrap">
<button @click="edit(p)" class="text-blue-600 dark:text-blue-400">编辑</button> <button @click="edit(p)" class="text-blue-600 dark:text-blue-400">编辑</button>
<button v-if="isSupported(p.sdk_type)" @click="test(p)" :disabled="testing[p.id]" class="text-emerald-600 dark:text-emerald-400 disabled:opacity-50">{{ testing[p.id] ? '测试中…' : '测试连接' }}</button>
<button v-if="canSync(p.sdk_type)" @click="sync(p)" :disabled="syncing[p.id]" class="text-violet-600 dark:text-violet-400 disabled:opacity-50">{{ syncing[p.id] ? '同步中…' : '同步资产' }}</button>
<button @click="del(p)" class="text-red-600 dark:text-red-400">删除</button> <button @click="del(p)" class="text-red-600 dark:text-red-400">删除</button>
</div> </div>
<div v-if="results[p.id]" class="mt-2 text-xs px-2 py-1.5 rounded-lg break-words" :class="results[p.id].ok ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : 'bg-amber-500/10 text-amber-600 dark:text-amber-400'">{{ results[p.id].text }}</div>
</div> </div>
</div> </div>
<button v-if="!showAll && hiddenCount" @click="toggleShowAll" class="w-full text-center text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 py-2">
已隐藏 {{ hiddenCount }} 个未使用平台无资产/账号/API 配置· 点击显示全部
</button>
</div>`, </div>`,
setup() { setup() {
const testing = Vue.reactive({}); // 凭证已下沉到账号层:平台卡片不再提供测试/同步(见账号弹窗)
const syncing = Vue.reactive({});
const results = Vue.reactive({});
const supported = Vue.ref([]);
const capabilities = Vue.ref({});
// 平台关联资产数:优先按 provider_id 归并,其次按资产里填的平台名/slug // 平台关联资产数:优先按 provider_id 归并,其次按资产里填的平台名/slug
const assetCounts = Vue.computed(() => { const assetCounts = Vue.computed(() => {
const m = {}; const m = {};
@@ -61,41 +58,29 @@ const ProvidersView = {
function countOf(p) { function countOf(p) {
return (assetCounts.value['id:' + p.id] || 0) + (assetCounts.value['p:' + p.slug] || 0) + (assetCounts.value['p:' + p.name] || 0); return (assetCounts.value['id:' + p.id] || 0) + (assetCounts.value['p:' + p.slug] || 0) + (assetCounts.value['p:' + p.name] || 0);
} }
// 平台名下账号数(账号按 platform slug 归属平台)
function accountsCountOf(p) {
return store.accounts.filter(a => a.platform === p.slug).length;
}
// 在用判定:有资产/有账号/已配置 API 任一即算在用
function isUsed(p) {
return countOf(p) > 0 || accountsCountOf(p) > 0 || p.has_api_config;
}
// 默认隐藏未使用平台,避免预设平台撑满屏幕;偏好持久化到 localStorage
const showAll = Vue.ref(localStorage.getItem('vps_show_all_providers') === '1');
function toggleShowAll() {
showAll.value = !showAll.value;
localStorage.setItem('vps_show_all_providers', showAll.value ? '1' : '0');
}
const visibleProviders = Vue.computed(() => showAll.value ? store.providers : store.providers.filter(isUsed));
const hiddenCount = Vue.computed(() => store.providers.length - visibleProviders.value.length);
function serviceList(p) { function serviceList(p) {
return (p.services || '').split(',').map(s => s.trim()).filter(Boolean); return (p.services || '').split(',').map(s => s.trim()).filter(Boolean);
} }
Vue.onMounted(async () => {
try { const r = await Api.get('/providers/adapters'); supported.value = r.supported_types || []; capabilities.value = r.capabilities || {}; } catch (e) { /* ignore */ }
});
function isSupported(t) { return !!t && supported.value.includes(t); }
function canSync(t) { const c = capabilities.value[t]; return !!(c && (c.list_vps || c.list_domains)); }
function fmtTime(iso) { function fmtTime(iso) {
if (!iso) return ''; if (!iso) return '';
return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
} }
async function test(p) { return { store, Fmt, countOf, accountsCountOf, serviceList, showAll, toggleShowAll, visibleProviders, hiddenCount, fmtTime, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider, openAccounts: (p) => openAccountsView(p.slug), manageAccounts: () => openAccountsView(null), goAssets: openAssetsByProvider };
testing[p.id] = true; delete results[p.id];
try {
const r = await Api.post('/providers/' + p.id + '/test', {});
results[p.id] = { ok: r.ok, text: (r.ok ? '✓ ' : '✗ ') + r.message };
} catch (e) { results[p.id] = { ok: false, text: '✗ ' + e.message }; }
finally { testing[p.id] = false; }
}
async function sync(p) {
syncing[p.id] = true; delete results[p.id];
try {
const r = await Api.post('/providers/' + p.id + '/sync', {});
const parts = [];
if (r.vps) parts.push('VPS 新增' + r.vps.created + '/更新' + r.vps.updated);
if (r.domains) parts.push('域名 新增' + r.domains.created + '/更新' + r.domains.updated);
if (r.vps_error) parts.push('VPS错误:' + r.vps_error);
if (r.domains_error) parts.push('域名错误:' + r.domains_error);
if (r.account && r.account.balance !== null && r.account.balance !== undefined) parts.push('余额 ' + r.account.balance);
results[p.id] = { ok: true, text: '✓ 同步完成:' + (parts.join('') || '无变化') };
await loadAll();
} catch (e) { results[p.id] = { ok: false, text: '✗ ' + e.message }; }
finally { syncing[p.id] = false; }
}
return { store, Fmt, testing, syncing, results, countOf, serviceList, isSupported, canSync, fmtTime, test, sync, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider };
}, },
}; };
+8 -1
View File
@@ -37,11 +37,18 @@ const SettingsView = {
</div> </div>
<div v-if="dataResult" class="mt-2 text-xs px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50">{{ dataResult }}</div> <div v-if="dataResult" class="mt-2 text-xs px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50">{{ dataResult }}</div>
</div> </div>
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<h3 class="font-semibold text-sm mb-2">版本信息</h3>
<div class="text-xs text-slate-500">当前运行版本<span class="text-slate-700 dark:text-slate-300 font-mono">v{{ version }} ({{ commit }})</span></div>
<p class="text-xs text-slate-400 mt-1">与本地 git rev-parse --short HEAD 比对可确认线上是否为最新代码</p>
</div>
</div>`, </div>`,
setup() { setup() {
const notifyChannels = Vue.ref([]); const notifyChannels = Vue.ref([]);
const notifyResult = Vue.ref(''); const notifyResult = Vue.ref('');
const dataResult = Vue.ref(''); const dataResult = Vue.ref('');
const version = Api.CFG.version || 'dev';
const commit = Api.CFG.commit || 'unknown';
Vue.onMounted(async () => { Vue.onMounted(async () => {
try { const r = await Api.get('/notify/channels'); notifyChannels.value = r.channels || []; } catch (e) { /* ignore */ } try { const r = await Api.get('/notify/channels'); notifyChannels.value = r.channels || []; } catch (e) { /* ignore */ }
}); });
@@ -91,6 +98,6 @@ const SettingsView = {
}; };
reader.readAsText(file); reader.readAsText(file);
} }
return { store, settings, save: saveSettings, applyDark, notifyChannels, notifyResult, testNotify, checkRenewals, dataResult, exportData, onImportFile }; return { store, settings, save: saveSettings, applyDark, notifyChannels, notifyResult, testNotify, checkRenewals, dataResult, exportData, onImportFile, version, commit };
}, },
}; };
+169
View File
@@ -0,0 +1,169 @@
/* + 2FA
*
* 数据来自 credentials 登录凭据唯一事实源既包含平台账号迁移/联动过来的
* 条目也包含自行录入的普通网站列表以取用快为先搜索一键复制
* 展开看动态码移动端单列卡片桌面端同布局避免横向溢出
*/
const VaultView = {
template: `
<div class="space-y-3">
<div class="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2">
🔐 凭据库 = 各站点的登录方式用户名 / 密码 / 2FA / 授权登录平台账号的登录密码也统一存在这里只维护一份不会两头改
</div>
<div class="flex gap-2 items-center">
<input v-model="store.vaultSearch" @input="reload" placeholder="搜索站点/用户名/备注…"
class="flex-1 min-w-0 text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900">
<button @click="toggleOtpFilter" class="text-sm px-3 py-1.5 rounded-lg border whitespace-nowrap shrink-0"
:class="store.vaultOtpOnly ? 'border-blue-500 bg-blue-500/10 text-blue-600 dark:text-blue-400' : 'border-slate-300 dark:border-slate-700 text-slate-600 dark:text-slate-400'">🛡 2FA</button>
</div>
<p v-if="!store.credentials.length" class="text-xs text-slate-400 py-8 text-center">
{{ store.vaultSearch || store.vaultOtpOnly ? '没有匹配的凭据' : '暂无凭据。点右上「+ 新增」录入网站账号;平台账号填过登录密码的会自动出现在这里。' }}
</p>
<div class="space-y-2">
<div v-for="c in store.credentials" :key="c.id"
class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
<!-- 主行 -->
<div class="px-3 py-2.5 flex items-center gap-2">
<button @click="toggle(c)" class="text-slate-400 text-xs w-4 shrink-0">{{ expanded[c.id] ? '▾' : '▸' }}</button>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">
{{ c.site }}
<span v-if="c.has_otp" class="text-[10px] px-1 py-0.5 ml-1 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 align-middle">2FA</span>
<span v-if="c.duplicate" class="text-[10px] px-1 py-0.5 ml-1 rounded bg-amber-500/10 text-amber-600 dark:text-amber-400 align-middle"
title="同站点同用户名已有多条,确认是否重复录入">重复?</span>
</div>
<div class="text-xs text-slate-400 truncate">
<span v-if="c.username">{{ c.username }}</span><span v-else class="italic"></span>
<span v-if="c.login_type==='oauth'" class="ml-1 text-emerald-600 dark:text-emerald-400">· {{ c.oauth_provider || '授权' }} 登录</span>
<span v-else-if="c.login_type==='other'" class="ml-1">· 其他登录方式</span>
<span v-if="c.account_name" class="ml-1">· 平台账号</span>
</div>
</div>
<button v-if="c.has_otp" @click="toggle(c, true)"
class="text-xs px-2 py-1 rounded-lg bg-blue-600 text-white shrink-0">验证码</button>
<button v-if="c.has_password" @click="copyPwd(c)"
class="text-xs px-2 py-1 rounded-lg border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 shrink-0">复制</button>
</div>
<!-- 展开详情 -->
<div v-if="expanded[c.id]" class="px-3 pb-3 pt-2 border-t border-slate-100 dark:border-slate-800 space-y-2">
<!-- 2FA 动态码服务端生成本地倒计时归零自动刷新 -->
<div v-if="c.has_otp" class="flex items-center gap-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg px-3 py-2">
<div class="font-mono text-2xl tracking-widest text-blue-700 dark:text-blue-300 shrink-0">{{ otpOf(c).code || '······' }}</div>
<div class="min-w-0 flex-1">
<div class="text-[11px] text-slate-500 dark:text-slate-400">{{ otpOf(c).expiresIn }}s 后刷新</div>
<div class="h-1 bg-slate-200 dark:bg-slate-700 rounded mt-1 overflow-hidden">
<div class="h-full bg-blue-500 transition-all duration-1000 ease-linear"
:style="{ width: Math.max(0, otpOf(c).expiresIn) / 30 * 100 + '%' }"></div>
</div>
</div>
<button @click="copyOtp(c)" class="text-xs px-2 py-1 rounded-lg border border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400 shrink-0">复制</button>
</div>
<div class="text-xs text-slate-500 space-y-1">
<div v-if="c.url"><a :href="c.url" target="_blank" rel="noopener" class="text-blue-600 dark:text-blue-400 hover:underline break-all">登录页 </a></div>
<div v-if="c.note" class="whitespace-pre-wrap break-words">{{ c.note }}</div>
<div v-if="c.login_type==='oauth'" class="text-slate-400">授权登录{{ c.oauth_provider || '未记录来源' }}本站无独立密码</div>
<div v-if="c.account_name" class="text-slate-400">关联平台账号{{ c.account_name }}</div>
<div class="text-slate-300 dark:text-slate-600">更新于 {{ fmtTime(c.updated_at) }}</div>
</div>
<div class="flex gap-4 text-xs flex-wrap">
<button v-if="c.has_password" @click="reveal(c)" class="text-amber-600 dark:text-amber-400">查看密码</button>
<button @click="edit(c)" class="text-blue-600 dark:text-blue-400">编辑</button>
<button v-if="c.has_otp" @click="unbindOtp(c)" class="text-slate-500 dark:text-slate-400">解绑 2FA</button>
<button @click="del(c)" class="text-red-600 dark:text-red-400">删除</button>
</div>
</div>
</div>
</div>
<!-- 密码明文弹窗8 秒自动关闭减少明文暴露时间 -->
<div v-if="pwd.show" class="fixed inset-0 bg-black/50 z-[70] flex items-center justify-center p-4" @click.self="closePwd">
<div class="bg-white dark:bg-slate-800 rounded-xl w-full max-w-xs p-5 shadow-xl">
<h4 class="font-semibold text-sm mb-3">🔑 {{ pwd.site }}</h4>
<div class="space-y-2 text-sm">
<div v-if="pwd.username" class="flex justify-between items-center gap-2">
<span class="text-slate-400 text-xs shrink-0">用户名</span>
<span class="font-mono truncate">{{ pwd.username }}</span>
</div>
<div class="flex justify-between items-center gap-2">
<span class="text-slate-400 text-xs shrink-0">密码</span>
<span class="font-mono break-all">{{ pwd.password }}</span>
</div>
</div>
<div class="flex gap-2 mt-4">
<button @click="copyText(pwd.password, '密码')" class="flex-1 py-1.5 rounded-lg bg-blue-600 text-white text-xs">复制密码</button>
<button @click="closePwd" class="px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-700 text-xs">关闭</button>
</div>
<p class="text-[10px] text-slate-400 mt-2 text-center">{{ pwd.countdown }}s 后自动关闭</p>
</div>
</div>
</div>`,
setup() {
const expanded = Vue.reactive({});
const pwd = Vue.reactive({ show: false, site: '', username: '', password: '', countdown: 0, _timer: null });
function otpOf(c) {
return otpState[c.id] || { code: '', expiresIn: 0, loading: false };
}
// 展开即拉动态码并开始倒计时;收起时停掉定时器
function toggle(c, forceOpen) {
const open = forceOpen ? true : !expanded[c.id];
expanded[c.id] = open;
if (!c.has_otp) return;
if (open && !otpState[c.id]) fetchOtp(c.id);
if (!open) stopOtp(c.id);
}
async function reveal(c) {
store.error = '';
try {
const r = await Api.get('/credentials/' + c.id + '/password');
pwd.site = c.site;
pwd.username = r.username || '';
pwd.password = r.password;
pwd.show = true;
if (pwd._timer) clearInterval(pwd._timer);
pwd.countdown = 8;
pwd._timer = setInterval(() => {
pwd.countdown--;
if (pwd.countdown <= 0) closePwd();
}, 1000);
} catch (e) { store.error = e.message; }
}
function closePwd() {
pwd.show = false;
pwd.password = '';
if (pwd._timer) { clearInterval(pwd._timer); pwd._timer = null; }
}
function copyOtp(c) {
const s = otpState[c.id];
if (s && s.code) copyText(s.code, '验证码');
}
function fmtTime(iso) {
if (!iso) return '';
return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
// 搜索防抖:300ms 内连续输入只发一次请求
let searchTimer = null;
function debouncedReload() {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => { loadCredentials(); }, 300);
}
function toggleOtpFilter() {
store.vaultOtpOnly = !store.vaultOtpOnly;
loadCredentials();
}
// 离开视图时停掉全部倒计时,避免后台定时器泄漏
Vue.onUnmounted(() => { stopAllOtp(); closePwd(); });
return {
store, Fmt, expanded, pwd, otpOf, toggle, reveal, closePwd, copyOtp, fmtTime,
reload: debouncedReload, toggleOtpFilter, copyText,
copyPwd: copyCredentialPassword, edit: openCredentialEdit,
del: deleteCredential, unbindOtp: unbindCredentialOtp,
};
},
};
+162
View File
@@ -0,0 +1,162 @@
"""凭据中心化迁移测试(accounts.login_password → credentials
模拟旧库accounts credential_id 跑迁移 断言
密文原样搬入site 取平台显示名账号关联回填原字段置空重跑幂等
无密码账号不产生空条目
运行.venv/bin/pytest tests/test_credential_migration.py -v
"""
from datetime import datetime, timezone
import pytest
import sqlalchemy as sa
from cryptography.fernet import Fernet
from sqlmodel import Session, SQLModel, create_engine, select
import app.database as db
from app.core import crypto
from app.core.config import settings
from app.models.asset import AIAccount, Account, Asset, CloudflareDetail, DomainDetail, VPSDetail
from app.models.credential import Credential, LoginType
from app.models.provider import Provider
from app.models.ssl import SiteCert, Subdomain
# 旧结构 accounts 表:无 credential_id 列(迁移应自动补列)
OLD_ACCOUNTS_DDL = """
CREATE TABLE accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR NOT NULL,
platform VARCHAR,
remark VARCHAR,
login_user VARCHAR,
login_password_encrypted VARCHAR,
api_config_encrypted VARCHAR,
last_synced_at DATETIME,
created_at DATETIME NOT NULL
)
"""
@pytest.fixture()
def legacy_engine(tmp_path, monkeypatch):
"""旧结构测试库:新表齐备,但 accounts 缺 credential_id 列"""
monkeypatch.setattr(settings, "MASTER_KEY", Fernet.generate_key().decode())
crypto._get_fernet.cache_clear() # 避免复用其它测试缓存的 Fernet 实例
engine = create_engine(
f"sqlite:///{tmp_path / 'test.db'}", connect_args={"check_same_thread": False}
)
# 除 Account 外按当前模型建表(含新 credentials 表;accounts 用旧 DDL
SQLModel.metadata.create_all(
engine,
tables=[
Provider.__table__, Asset.__table__, VPSDetail.__table__, DomainDetail.__table__,
AIAccount.__table__, CloudflareDetail.__table__, Subdomain.__table__,
SiteCert.__table__, Credential.__table__,
],
)
with engine.begin() as conn:
conn.execute(sa.text(OLD_ACCOUNTS_DDL))
# 迁移函数引用模块级 assets_engine → 指向测试库
monkeypatch.setattr(db, "assets_engine", engine)
yield engine
crypto._get_fernet.cache_clear()
def _seed(engine, pwd_aliyun: str, pwd_forum: str) -> None:
"""造迁移前现场:平台账号(有密码)/ 无平台账号(有密码)/ 无密码账号"""
with Session(engine) as s:
s.add(Provider(slug="aliyun", name="阿里云", category="vps"))
s.commit()
now = datetime.now(timezone.utc).replace(tzinfo=None)
with engine.begin() as conn:
conn.execute(
sa.text(
"INSERT INTO accounts (name, platform, login_user, login_password_encrypted, created_at) "
"VALUES ('me@gmail.com', 'aliyun', 'me@gmail.com', :pwd, :now)"
),
{"pwd": pwd_aliyun, "now": now},
)
# platform 为空串:site 应回退 '未分类';无 login_userusername 回退 name
conn.execute(
sa.text(
"INSERT INTO accounts (name, platform, login_user, login_password_encrypted, created_at) "
"VALUES ('forum-user', '', NULL, :pwd, :now)"
),
{"pwd": pwd_forum, "now": now},
)
# 无密码账号:不应产生凭据条目
conn.execute(
sa.text(
"INSERT INTO accounts (name, platform, created_at) VALUES ('no-pwd', 'vultr', :now)"
),
{"now": now},
)
def test_migration_moves_passwords_to_vault(legacy_engine):
engine = legacy_engine
pwd_aliyun = crypto.encrypt("p@ss-阿里云")
pwd_forum = crypto.encrypt("forum-pass-中文")
_seed(engine, pwd_aliyun, pwd_forum)
db._migrate_assets_db()
with Session(engine) as s:
creds = s.exec(select(Credential).order_by(Credential.id)).all()
# 仅两条有密码的账号被迁移,无密码账号不产生空条目
assert len(creds) == 2
c1, c2 = creds
# 密文原样搬入(不重新加密,避免中间态明文暴露),仍可解密还原
assert c1.password_encrypted == pwd_aliyun
assert crypto.decrypt(c1.password_encrypted) == "p@ss-阿里云"
# site 取平台显示名(join providers),username 取 login_user
assert c1.site == "阿里云"
assert c1.username == "me@gmail.com"
assert c1.login_type == LoginType.PASSWORD
# 无 provider 匹配 + platform 空 → site 回退 '未分类'username 回退 name
assert c2.password_encrypted == pwd_forum
assert c2.site == "未分类"
assert c2.username == "forum-user"
# 账号侧:credential_id 回填、原密码字段置空(唯一事实源,防双份漂移)
accs = {a.name: a for a in s.exec(select(Account)).all()}
assert accs["me@gmail.com"].credential_id == c1.id
assert accs["me@gmail.com"].login_password_encrypted is None
assert accs["forum-user"].credential_id == c2.id
assert accs["forum-user"].login_password_encrypted is None
assert accs["no-pwd"].credential_id is None
def test_migration_is_idempotent(legacy_engine):
"""重跑迁移不得重复建条目(幂等条件:credential_id 已非空则跳过)"""
engine = legacy_engine
_seed(engine, crypto.encrypt("pw1"), crypto.encrypt("pw2"))
db._migrate_assets_db()
db._migrate_assets_db()
db._migrate_assets_db()
with Session(engine) as s:
assert len(s.exec(select(Credential)).all()) == 2
def test_migration_adds_credential_id_column(legacy_engine):
"""旧库缺 credential_id 列时迁移应自动补列"""
engine = legacy_engine
with engine.connect() as conn:
cols = {c["name"] for c in sa.inspect(conn).get_columns("accounts")}
assert "credential_id" not in cols # 前置:确实是旧结构
db._migrate_assets_db()
with engine.connect() as conn:
cols = {c["name"] for c in sa.inspect(conn).get_columns("accounts")}
indexes = {ix["name"] for ix in sa.inspect(conn).get_indexes("accounts")}
assert "credential_id" in cols
db._migrate_indexes()
with engine.connect() as conn:
indexes = {ix["name"] for ix in sa.inspect(conn).get_indexes("accounts")}
assert "ix_accounts_credential_id" in indexes
+351
View File
@@ -0,0 +1,351 @@
"""凭据服务(密码库 + 2FA)与账号联动测试
覆盖创建/更新/删除语义密码加解密oauth 条目2FA 绑定强制校验
错码拒绝动态码生成搜索过滤重复标记以及账号侧密码重定向
唯一事实源与生命周期解耦删账号不删凭据
运行.venv/bin/pytest tests/test_credential_service.py -v
"""
import pytest
from cryptography.fernet import Fernet
from fastapi import HTTPException
from sqlmodel import Session, SQLModel, create_engine, select
from app.core import crypto, totp
from app.core.config import settings
from app.models.asset import Account
from app.models.credential import Credential, LoginType
from app.models.provider import Provider
from app.schemas.account import AccountCreate, AccountUpdate
from app.schemas.credential import CredentialCreate, CredentialUpdate, OtpBindRequest
from app.services import account_service, credential_service
@pytest.fixture()
def session(tmp_path, monkeypatch):
"""独立测试库(新结构全表)+ 预置一个平台供 site 显示名映射"""
monkeypatch.setattr(settings, "MASTER_KEY", Fernet.generate_key().decode())
crypto._get_fernet.cache_clear()
engine = create_engine(
f"sqlite:///{tmp_path / 'svc.db'}", connect_args={"check_same_thread": False}
)
SQLModel.metadata.create_all(engine)
with Session(engine) as s:
s.add(Provider(slug="aliyun", name="阿里云", category="vps"))
s.commit()
yield s
crypto._get_fernet.cache_clear()
def _otp_pair():
"""生成一对可用的 (secret, 当前动态码)"""
secret = totp.random_secret()
return secret, totp.totp_at(secret)[0]
# ----------------------------- 创建 / 读取 ----------------------------- #
def test_create_with_password(session):
read = credential_service.create_credential(
session, CredentialCreate(site="GitHub", username="me@gmail.com", password="s3cret-中文")
)
assert read.has_password is True
assert read.has_otp is False
# Read 永不返回明文/密文
dumped = read.model_dump()
assert "password" not in dumped and "password_encrypted" not in dumped
revealed = credential_service.reveal_password(session, read.id)
assert revealed["password"] == "s3cret-中文"
assert revealed["username"] == "me@gmail.com"
# 库里确实是密文
row = session.get(Credential, read.id)
assert row.password_encrypted != "s3cret-中文"
assert crypto.decrypt(row.password_encrypted) == "s3cret-中文"
def test_create_oauth_entry(session):
"""授权登录条目:无密码也是正常记录,可检索可回忆"""
read = credential_service.create_credential(
session,
CredentialCreate(
site="某论坛", username="me@gmail.com",
login_type=LoginType.OAUTH, oauth_provider="google",
),
)
assert read.login_type == LoginType.OAUTH
assert read.oauth_provider == "google"
assert read.has_password is False
with pytest.raises(HTTPException) as e:
credential_service.reveal_password(session, read.id)
assert e.value.status_code == 404
def test_create_requires_site(session):
with pytest.raises(HTTPException) as e:
credential_service.create_credential(session, CredentialCreate(site=" "))
assert e.value.status_code == 400
# ----------------------------- 2FA 绑定校验 ----------------------------- #
def test_create_with_otp_requires_code(session):
"""绑定 2FA 必须同时提供当前动态码(防 secret 手误成废条目)"""
secret, _ = _otp_pair()
with pytest.raises(HTTPException) as e:
credential_service.create_credential(
session, CredentialCreate(site="GitHub", otp_secret=secret)
)
assert e.value.status_code == 400 and "动态码" in e.value.detail
def test_create_with_otp_wrong_code_rejected(session):
secret, _ = _otp_pair()
with pytest.raises(HTTPException) as e:
credential_service.create_credential(
session, CredentialCreate(site="GitHub", otp_secret=secret, otp_code="000000")
)
assert e.value.status_code == 400 and "不匹配" in e.value.detail
# 校验失败不得落库
assert session.exec(select(Credential)).first() is None
def test_create_with_otp_valid_code(session):
secret, code = _otp_pair()
read = credential_service.create_credential(
session, CredentialCreate(site="GitHub", username="me@gmail.com", otp_secret=secret, otp_code=code)
)
assert read.has_otp is True
otp = credential_service.current_otp(session, read.id)
assert len(otp["code"]) == 6 and otp["code"].isdigit()
assert 1 <= otp["expires_in"] <= 30
# secret 加密存储,且与录入值等价(能算出同样的码)
row = session.get(Credential, read.id)
assert row.otp_secret_encrypted != secret
assert totp.verify(crypto.decrypt(row.otp_secret_encrypted), otp["code"])
def test_create_with_otpauth_uri(session):
"""支持直接粘贴 otpauth:// 链接录入(自动提取 secret)"""
secret, code = _otp_pair()
uri = f"otpauth://totp/GitHub:me%40gmail.com?secret={secret}&issuer=GitHub"
read = credential_service.create_credential(
session, CredentialCreate(site="GitHub", otp_secret=uri, otp_code=code)
)
assert read.has_otp is True
row = session.get(Credential, read.id)
assert crypto.decrypt(row.otp_secret_encrypted) == secret # 只存 secret,不存整条 URI
def test_create_with_invalid_secret_rejected(session):
secret, code = _otp_pair()
with pytest.raises(HTTPException) as e:
credential_service.create_credential(
session, CredentialCreate(site="x", otp_secret="otpauth://hotp/a?secret=AA", otp_code=code)
)
assert e.value.status_code == 400 and "secret 无效" in e.value.detail
def test_current_otp_unbound_404(session):
read = credential_service.create_credential(session, CredentialCreate(site="GitHub"))
with pytest.raises(HTTPException) as e:
credential_service.current_otp(session, read.id)
assert e.value.status_code == 404
def test_bind_and_unbind_otp(session):
read = credential_service.create_credential(session, CredentialCreate(site="GitHub"))
secret, code = _otp_pair()
updated = credential_service.bind_otp(session, read.id, OtpBindRequest(secret=secret, code=code))
assert updated.has_otp is True
# 错码解绑不了也绑不上
with pytest.raises(HTTPException):
credential_service.bind_otp(session, read.id, OtpBindRequest(secret=secret, code="111111"))
unbound = credential_service.unbind_otp(session, read.id)
assert unbound.has_otp is False
# ----------------------------- 更新 / 删除语义 ----------------------------- #
def test_update_password_semantics(session):
"""None=不改,''=清除,非空=重加密(与账号凭证惯例一致)"""
read = credential_service.create_credential(
session, CredentialCreate(site="GitHub", password="old")
)
# None:不修改
r = credential_service.update_credential(session, read.id, CredentialUpdate(note="记一笔"))
assert r.note == "记一笔"
assert credential_service.reveal_password(session, read.id)["password"] == "old"
# 非空:重加密
credential_service.update_credential(session, read.id, CredentialUpdate(password="new-中文"))
assert credential_service.reveal_password(session, read.id)["password"] == "new-中文"
# 空串:清除
r = credential_service.update_credential(session, read.id, CredentialUpdate(password=""))
assert r.has_password is False
with pytest.raises(HTTPException):
credential_service.reveal_password(session, read.id)
def test_update_fields(session):
read = credential_service.create_credential(session, CredentialCreate(site="GitHub", username="a@b.c"))
r = credential_service.update_credential(
session, read.id,
CredentialUpdate(site="GitLab", username="", login_type=LoginType.OTHER, url="https://x", note=None),
)
assert r.site == "GitLab"
assert r.username is None # 空串归一化为 None
assert r.login_type == LoginType.OTHER
assert r.url == "https://x"
def test_delete_unlinks_account(session):
"""删除凭据:账号保留,仅解除关联(生命周期解耦)"""
acc = account_service.create_account(
session, AccountCreate(name="me@gmail.com", platform="aliyun", login_password="pw")
)
cred_id = acc.credential_id
assert cred_id is not None
credential_service.delete_credential(session, cred_id)
refreshed = session.get(Account, acc.id)
assert refreshed is not None # 账号还在
assert refreshed.credential_id is None # 关联已解除
assert session.get(Credential, cred_id) is None
# ----------------------------- 列表 / 搜索 / 重复标记 ----------------------------- #
def test_list_search_and_filters(session):
credential_service.create_credential(
session, CredentialCreate(site="GitHub", username="me@gmail.com", password="p1")
)
secret, code = _otp_pair()
credential_service.create_credential(
session, CredentialCreate(site="阿里云", username="me@gmail.com", password="p2",
otp_secret=secret, otp_code=code)
)
credential_service.create_credential(
session, CredentialCreate(site="某论坛", login_type=LoginType.OAUTH, oauth_provider="google")
)
all_creds = credential_service.list_credentials(session)
assert len(all_creds) == 3
# 搜索命中 site / username / note
assert len(credential_service.list_credentials(session, q="git")) == 1
assert len(credential_service.list_credentials(session, q="me@gmail.com")) == 2
# 登录方式过滤
assert len(credential_service.list_credentials(session, login_type="oauth")) == 1
# 2FA 过滤
assert len(credential_service.list_credentials(session, has_otp=True)) == 1
assert len(credential_service.list_credentials(session, has_otp=False)) == 2
def test_duplicate_flag(session):
"""同 (site, username) 多条时全部标记 duplicate(录入提示用)"""
credential_service.create_credential(
session, CredentialCreate(site="GitHub", username="me@gmail.com")
)
second = credential_service.create_credential(
session, CredentialCreate(site="github", username="ME@gmail.com") # 大小写/空白归一化
)
assert second.duplicate is True
rows = credential_service.list_credentials(session)
assert all(r.duplicate for r in rows)
# 同站不同用户名不算重复
third = credential_service.create_credential(
session, CredentialCreate(site="GitHub", username="other@gmail.com")
)
assert third.duplicate is False
# ----------------------------- 账号联动(唯一事实源) ----------------------------- #
def test_account_create_redirects_password(session):
"""账号录入密码 → 自动建凭据并关联;账号表不再存密码密文"""
acc = account_service.create_account(
session,
AccountCreate(name="me@gmail.com", platform="aliyun",
login_user="me@gmail.com", login_password="pw-中文"),
)
assert acc.credential_id is not None
assert acc.has_login_password is True
assert acc.has_otp is False
row = session.get(Account, acc.id)
assert row.login_password_encrypted is None # 唯一事实源在凭据表
cred = session.get(Credential, acc.credential_id)
assert cred.site == "阿里云" # 平台 slug → 显示名
assert cred.username == "me@gmail.com"
assert crypto.decrypt(cred.password_encrypted) == "pw-中文"
# 账号查看密码接口仍可用(重定向到凭据)
revealed = account_service.reveal_password(session, acc.id)
assert revealed["password"] == "pw-中文"
assert revealed["login_user"] == "me@gmail.com"
def test_account_create_without_password_no_credential(session):
"""无密码账号不产生空凭据条目"""
acc = account_service.create_account(session, AccountCreate(name="x@y.z", platform="aliyun"))
assert acc.credential_id is None
assert session.exec(select(Credential)).first() is None
def test_account_update_syncs_credential(session):
"""改名/换平台/改密码都同步到关联凭据"""
acc = account_service.create_account(
session, AccountCreate(name="me@gmail.com", platform="aliyun", login_password="pw1")
)
# 改密码
r = account_service.update_account(session, acc.id, AccountUpdate(login_password="pw2"))
assert r.has_login_password is True
assert account_service.reveal_password(session, acc.id)["password"] == "pw2"
# 换平台 → site 同步(无匹配 provider 时回退 slug 原文)
r = account_service.update_account(session, acc.id, AccountUpdate(platform="vultr"))
cred = session.get(Credential, r.credential_id)
assert cred.site == "vultr"
# 清密码(空串)→ 凭据保留但无密码
r = account_service.update_account(session, acc.id, AccountUpdate(login_password=""))
assert r.has_login_password is False
assert session.get(Credential, r.credential_id) is not None
def test_account_otp_visible_in_read(session):
"""账号 Read 暴露 has_otp,供前端在账号侧展示「获取验证码」"""
acc = account_service.create_account(
session, AccountCreate(name="me@gmail.com", platform="aliyun", login_password="pw")
)
secret, code = _otp_pair()
credential_service.bind_otp(session, acc.credential_id, OtpBindRequest(secret=secret, code=code))
listed = {a.id: a for a in account_service.list_accounts(session)}
assert listed[acc.id].has_otp is True
assert listed[acc.id].has_login_password is True
# 账号侧取动态码(走凭据)
otp = credential_service.current_otp(session, acc.credential_id)
assert totp.verify(secret, otp["code"])
def test_account_delete_keeps_credential(session):
"""删除账号不删凭据(密码库独立留存,仍可查/可复制)"""
acc = account_service.create_account(
session, AccountCreate(name="me@gmail.com", platform="aliyun", login_password="pw")
)
cred_id = acc.credential_id
account_service.delete_account(session, acc.id)
cred = session.get(Credential, cred_id)
assert cred is not None
assert crypto.decrypt(cred.password_encrypted) == "pw"
# 凭据列表不再标记「来自平台账号」
rows = credential_service.list_credentials(session)
assert rows[0].account_id is None and rows[0].account_name is None
def test_credential_list_marks_source_account(session):
"""凭据列表反向标注来源账号(密码库中区分「平台账号」与游离条目)"""
acc = account_service.create_account(
session, AccountCreate(name="me@gmail.com", platform="aliyun", login_password="pw")
)
credential_service.create_credential(session, CredentialCreate(site="某论坛", username="a@b.c"))
rows = {r.site: r for r in credential_service.list_credentials(session)}
assert rows["阿里云"].account_id == acc.id
assert rows["阿里云"].account_name == "me@gmail.com"
assert rows["某论坛"].account_id is None
+140
View File
@@ -0,0 +1,140 @@
"""Key EscrowMASTER_KEY 密钥托管)测试
覆盖两层
- crypto build_escrow/recover_master_key 往返错误钥匙拒绝格式校验
- CLI setup 模拟 .env 丢失 key recover --write 找回回写§3.3 丢失演练
运行.venv/bin/pytest tests/test_escrow.py -v
"""
import os
import re
import subprocess
import sys
from pathlib import Path
import pytest
from cryptography.fernet import Fernet
from app.core import crypto
from app.core.config import settings
BASE_DIR = Path(__file__).resolve().parent.parent
SETUP_SCRIPT = BASE_DIR / "scripts" / "setup_key_escrow.py"
RECOVER_SCRIPT = BASE_DIR / "scripts" / "recover_master_key.py"
# ----------------------------- crypto 层 ----------------------------- #
def test_build_recover_roundtrip(monkeypatch):
"""托管往返:build_escrow 后用同一把钥匙能还原 MASTER_KEY"""
monkeypatch.setattr(settings, "MASTER_KEY", Fernet.generate_key().decode())
restore_key = Fernet.generate_key().decode()
escrow = crypto.build_escrow(restore_key)
assert escrow.startswith(crypto.ESCROW_PREFIX)
assert crypto.recover_master_key(restore_key, escrow) == settings.MASTER_KEY
def test_recover_wrong_key_rejected(monkeypatch):
"""错误恢复钥匙必须抛 ValueError(不能静默返回错误 key)"""
monkeypatch.setattr(settings, "MASTER_KEY", Fernet.generate_key().decode())
escrow = crypto.build_escrow(Fernet.generate_key().decode())
with pytest.raises(ValueError):
crypto.recover_master_key(Fernet.generate_key().decode(), escrow)
def test_recover_invalid_prefix():
"""缺 v1: 前缀视为格式无效"""
with pytest.raises(ValueError):
crypto.recover_master_key(Fernet.generate_key().decode(), "garbage-no-prefix")
def test_build_escrow_requires_master_key(monkeypatch):
"""MASTER_KEY 未配置时拒绝建立托管(避免生成解不开真实数据的废档)"""
monkeypatch.setattr(settings, "MASTER_KEY", "")
with pytest.raises(RuntimeError):
crypto.build_escrow(Fernet.generate_key().decode())
# ----------------------------- CLI 端到端 ----------------------------- #
def _run_script(script: Path, env_extra: dict, args: list) -> subprocess.CompletedProcess:
"""以子进程运行脚本(环境变量覆盖 .env:python-dotenv 不覆盖已存在变量)"""
env = {**os.environ, **env_extra}
return subprocess.run(
[sys.executable, str(script), *args], capture_output=True, text=True, env=env
)
# Fernet key = base64url(32 字节) = 43 个 [A-Za-z0-9_-] 字符 + '='
_FERNET_KEY_RE = re.compile(r"^[A-Za-z0-9_-]{43}=$")
def _extract_restore_key(stdout: str) -> str:
"""从 setup 输出提取 RESTORE_KEY44 字符 Fernet key,独立一行)"""
for line in stdout.splitlines():
s = line.strip()
if _FERNET_KEY_RE.match(s):
return s
raise AssertionError("setup 输出未包含 RESTORE_KEY:\n" + stdout)
def test_cli_loss_drill(tmp_path):
"""丢失演练:托管建立 → .env 中 key 变为错误值 → recover --write 找回真实 key"""
master_key = Fernet.generate_key().decode()
escrow_path = tmp_path / "master_key.escrow"
env_path = tmp_path / ".env"
# 模拟丢失现场:.env 里是错误的 key,其它配置需原样保留
env_path.write_text("API_KEY=abc\nMASTER_KEY=lost-wrong-key\n", encoding="utf-8")
# 1. 建立托管(MASTER_KEY 环境变量 = 正确 key
r = _run_script(SETUP_SCRIPT, {"MASTER_KEY": master_key}, ["--escrow", str(escrow_path)])
assert r.returncode == 0, r.stderr
assert escrow_path.exists()
assert (escrow_path.stat().st_mode & 0o777) == 0o600
restore_key = _extract_restore_key(r.stdout)
# 2. 回验模式:--key 校验托管可解且与当前 MASTER_KEY 一致
r = _run_script(
SETUP_SCRIPT, {"MASTER_KEY": master_key},
["--key", restore_key, "--escrow", str(escrow_path)],
)
assert r.returncode == 0 and "通过" in r.stdout
# 3. 恢复:即使运行时环境里的 MASTER_KEY 是错的,也能凭 RESTORE_KEY 找回并回写 .env
r = _run_script(
RECOVER_SCRIPT, {"MASTER_KEY": "irrelevant", "RESTORE_KEY": restore_key},
["--escrow", str(escrow_path), "--env", str(env_path), "--write"],
)
assert r.returncode == 0, r.stderr
text = env_path.read_text(encoding="utf-8")
assert f"MASTER_KEY={master_key}" in text
assert "API_KEY=abc" in text # 其它配置行不动
assert "lost-wrong-key" not in text
assert (tmp_path / ".env.bak-pre-recover").exists() # 回写前已备份
# 4. 错误钥匙恢复必须失败(退出码 1,.env 不被污染)
r = _run_script(
RECOVER_SCRIPT, {"RESTORE_KEY": Fernet.generate_key().decode()},
["--escrow", str(escrow_path), "--env", str(env_path), "--write"],
)
assert r.returncode == 1 and "失败" in (r.stdout + r.stderr)
assert f"MASTER_KEY={master_key}" in env_path.read_text(encoding="utf-8")
# 5. 幂等:托管已存在时默认不重建
r = _run_script(SETUP_SCRIPT, {"MASTER_KEY": master_key}, ["--escrow", str(escrow_path)])
assert r.returncode == 2 and "已存在" in r.stderr
# 6. --force 重建:新钥匙生效,旧钥匙作废
r = _run_script(
SETUP_SCRIPT, {"MASTER_KEY": master_key}, ["--escrow", str(escrow_path), "--force"]
)
assert r.returncode == 0
new_key = _extract_restore_key(r.stdout)
assert new_key != restore_key
r = _run_script(
RECOVER_SCRIPT, {"RESTORE_KEY": new_key}, ["--escrow", str(escrow_path)]
)
assert r.returncode == 0 and f"MASTER_KEY={master_key}" in r.stdout
r = _run_script(
RECOVER_SCRIPT, {"RESTORE_KEY": restore_key}, ["--escrow", str(escrow_path)]
)
assert r.returncode == 1 # 旧钥匙已作废
+112
View File
@@ -0,0 +1,112 @@
"""TOTPRFC 6238)自实现测试
官方向量锚定正确性RFC 6238 附录 BSHA120 字节 ASCII secret
"12345678901234567890"覆盖码生成剩余秒数录入校验窗口
otpauth URI 解析/重建随机 secret 可用性
运行.venv/bin/pytest tests/test_totp.py -v
"""
import pytest
from app.core import totp
# RFC 6238 附录 B 官方向量 secret 的 base32 形式
RFC_SECRET_B32 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
# (时刻 T, 官方 8 位码);本实现为 6 位 = 8 位码取模 10^6 补零
RFC_VECTORS = [
(59, "94287082"),
(1111111109, "07081804"),
(1111111111, "14050471"),
(1234567890, "89005924"),
(2000000000, "69279037"),
(20000000000, "65353130"),
]
@pytest.mark.parametrize("ts,code8", RFC_VECTORS)
def test_rfc6238_official_vectors(ts, code8):
"""逐条对齐 RFC 6238 附录 B 官方向量(6 位截断)"""
code6, _ = totp.totp_at(RFC_SECRET_B32, ts=ts)
assert code6 == str(int(code8) % 10**6).zfill(6)
def test_expires_in():
"""剩余秒数:T=59 窗口已过 29s 剩 1sT=60 新窗口剩 30s"""
_, left = totp.totp_at(RFC_SECRET_B32, ts=59)
assert left == 1
_, left = totp.totp_at(RFC_SECRET_B32, ts=60)
assert left == 30
def test_verify_window_tolerance():
"""录入校验容忍 ±1 步进时钟偏差,隔两个窗口必须失败"""
code, _ = totp.totp_at(RFC_SECRET_B32, ts=59) # counter=1
assert totp.verify(RFC_SECRET_B32, code, ts=59)
assert totp.verify(RFC_SECRET_B32, code, ts=61) # counter=2,窗口含 1
assert totp.verify(RFC_SECRET_B32, code, ts=80) # counter=2
assert not totp.verify(RFC_SECRET_B32, code, ts=150) # counter=5,超出窗口
def test_verify_rejects_bad_input():
assert not totp.verify(RFC_SECRET_B32, "12345") # 位数不对
assert not totp.verify(RFC_SECRET_B32, "abcdef") # 非数字
assert not totp.verify(RFC_SECRET_B32, "000000", ts=59) # 错误码
assert not totp.verify(RFC_SECRET_B32, "", ts=59)
def test_b32decode_tolerant():
"""小写/空白/缺填充都应正确解码"""
raw = totp.b32decode(RFC_SECRET_B32)
assert totp.b32decode(RFC_SECRET_B32.lower()) == raw
assert totp.b32decode("gezdg nbvgy 3tqoj qgezd gnbvg y3tqo jq") == raw
assert totp.b32decode(RFC_SECRET_B32.rstrip("=")) == raw
def test_parse_otpauth_uri_full():
uri = (
"otpauth://totp/GitHub:me@example.com"
"?secret=JBSWY3DPEHPK3PXP&issuer=GitHub&algorithm=SHA1&period=30&digits=6"
)
p = totp.parse_otpauth_uri(uri)
assert p["secret"] == "JBSWY3DPEHPK3PXP"
assert p["issuer"] == "GitHub"
assert p["account"] == "me@example.com"
def test_parse_otpauth_uri_label_only():
"""无 issuer 参数时从 Label 前缀提取,account 需 URL 解码"""
p = totp.parse_otpauth_uri("otpauth://totp/Google:me%40gmail.com?secret=JBSWY3DPEHPK3PXP")
assert p["issuer"] == "Google"
assert p["account"] == "me@gmail.com"
def test_parse_otpauth_uri_rejects():
with pytest.raises(ValueError):
totp.parse_otpauth_uri("https://example.com") # 非 otpauth 协议
with pytest.raises(ValueError):
totp.parse_otpauth_uri("otpauth://hotp/x?secret=JBSWY3DPEHPK3PXP") # 非 TOTP
with pytest.raises(ValueError):
totp.parse_otpauth_uri("otpauth://totp/x?issuer=y") # 缺 secret
with pytest.raises(ValueError):
totp.parse_otpauth_uri("otpauth://totp/x?secret=JBSWY3DPEHPK3PXP&digits=8") # 非 6 位
with pytest.raises(ValueError):
totp.parse_otpauth_uri("otpauth://totp/x?secret=JBSWY3DPEHPK3PXP&period=60") # 非 30s
def test_build_otpauth_uri_roundtrip():
"""重建的 URI 可被解析回同样的 secret/issuer/account(换机导出场景)"""
uri = totp.build_otpauth_uri("JBSWY3DPEHPK3PXP", "GitHub", "me@example.com")
p = totp.parse_otpauth_uri(uri)
assert p["secret"] == "JBSWY3DPEHPK3PXP"
assert p["issuer"] == "GitHub"
assert p["account"] == "me@example.com"
def test_random_secret_usable():
"""随机 secret 生成的码应能通过自身校验(录入闭环)"""
s = totp.random_secret()
code, left = totp.totp_at(s)
assert len(code) == 6 and code.isdigit()
assert 1 <= left <= 30
assert totp.verify(s, code)