后端
- asset_service: delete_asset 先清 subdomains/site_certs 子表(SQLite 不强制
FK 且 assets.id 无 AUTOINCREMENT,删最大 id 后新建会复用 rowid 导致孤儿误挂);
metrics.db 清理改 best-effort,不再让已成功的删除返回 500
- provider_service: slug 唯一约束竞态补 IntegrityError 兜底(与 account_service 一致)
- account_service/sync_service: raise HTTPException 补 from e,保留原始异常链
- sync_service: 删死代码 _load_config;新增 BalanceUnsupported,平台无余额接口时
计入 skipped 而不是静默当作失败;到期日写库前归一
- ai: 删掉 OpenAI 早已下线的 billing 余额接口实现,能力探测如实报「不支持」
- adapters/base: _overridden 改走 MRO,中间层基类实现的能力不再被误判为未实现
- core/timeutils: 删死代码 utcnow_iso(注释声称 Z 后缀,实际输出 +00:00)
- main: lifespan 退出时 await 被 cancel 的清理任务,避免 pending task 被销毁
- ssl_service: 写 site_certs/subdomains 前校验 asset_id 存在,杜绝悬空引用;
到期阈值贯穿 _judge_status/_apply_probe/check_one/check_all_site_certs
- routers/credentials、accounts: 明文密码 GET 补 Cache-Control: no-store
- routers/stats: /stats/expiring 的 Query 描述与实际语义对齐(不设下界,含已过期)
Agent
- collector: 解析 sshd_config 的 Include(含 sshd_config.d/*.conf,按 sshd 语义
「首次出现的值生效」),未显式配置的关键字按编译默认值判定,消除假 pass;
防火墙检测改用 iptables -S 统计 -A 规则行并读 INPUT 策略(-L -n 恒有链头输出)
- reporter: 4xx(除 429)不再重试直接退出,429 优先按 Retry-After 退避
安全与部署
- core/security: require_agent_key 改为对外 fail-closed——tailnet/本机放行,
外部来源未配 AGENT_KEY 时一律 401(此前全放行,而服务默认监听 0.0.0.0,
公网上任何人可伪造监控指标与安全检查结果)
- main: 启动时把「未配 API_KEY/AGENT_KEY 则外部来源不可用」打进日志
- run.sh: HOST/PORT 可用环境变量覆盖,并说明默认 0.0.0.0 的权衡
- deploy/setup.sh: 新装时自动生成随机 API_KEY/AGENT_KEY 并提示同步到各 VPS;
注册 vps-cert-check.timer
- deploy/vps-cert-check.{service,timer}: 新增每日 09:30 的站点证书到期探测
(此前 check_site_certs.py 没有任何 unit,证书提醒不会自动跑)
- deploy/update.sh: sync_units 对 timer 用 enable --now(新增 timer 只 restart
不会建开机自启软链),主服务 unit 变更后额外 restart;pip 安装失败不再被吞
- scripts/check_site_certs.py: threshold 参数真正生效并打印
- scripts/make_migration_bundle.py: 缺 assets.db 直接 abort;umask 077 + chmod 600
(包内含 .env 的 MASTER_KEY 与 escrow,不能默认 644 落在 /tmp)
- scripts/restore_migration_bundle.py: 校验之外补必备文件预检,缺文件在触碰
目标机状态之前就 abort
前端
- store: 拆分列表口径 assets 与统计口径 allAssets(无筛选时同源,不重复请求);
删除已无消费者的 expiring/loadExpiring(每次 loadAll 白发一个请求)
- dashboard/monitor/providers/subscriptions/modals: 统计与计数改用 allAssets,
资产页的筛选(含统计卡片点击跳转带的筛选)不再让其他视图的数字缩水
- modals: 账号同步后补 loadOverview;settings: 导入后改 loadAll
- api: countdownText 支持 unit/empty 选项,monitor/domains 各自的实现改为委托,
assets/domains/subscriptions 的到期列不再直出「(-3天)」
- servers: cpu/mem/disk 百分比统一判空,采样缺项不再渲染「null%」
- assets: 同步 AI 余额的结果提示区分 skipped(平台无余额接口)与 failed
- modals: 修正凭据库头注释——2FA 动态码是选填 + 可事后补验,与实现一致
测试
- 新增 tests/test_agent_collector.py(8 条)、tests/test_security.py(11 条)
- 全量 113 passed;改动 JS 均通过 node --check,deploy 脚本通过 bash -n
- 浏览器实测:筛选态下总览/监控中心/平台页/付费资产的统计仍为全量口径,
到期文案与 null 百分比渲染正确,console 零报错
190 lines
8.2 KiB
Python
190 lines
8.2 KiB
Python
"""平台账号业务逻辑
|
||
|
||
账号与资产的关系: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.otp_verified = bool(cred and cred.otp_verified_at)
|
||
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 as e:
|
||
# 并发创建同名账号时唯一约束兜底:检查与提交之间存在竞态窗口
|
||
session.rollback()
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=f"该平台下账号已存在:{name}",
|
||
) from e
|
||
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 as e:
|
||
# 重命名撞上已有账号时唯一约束兜底,与 _check_name_taken 存在竞态窗口
|
||
session.rollback()
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=f"该平台下账号已存在:{account.name}",
|
||
) from e
|
||
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}
|