后端
- 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 零报错
448 lines
17 KiB
Python
448 lines
17 KiB
Python
"""云厂商同步服务
|
||
|
||
将适配器返回的标准化资产(NormalizedVPS / NormalizedDomain / AccountInfo)
|
||
写入或更新到资产库。以 (provider_id, external_id) 作为去重键,
|
||
已存在则更新状态/详情,不存在则新建资产。
|
||
|
||
凭证层级:API 配置存在账号(Account.api_config_encrypted)上,
|
||
同步按账号维度进行(test_account/sync_account);同步产出的资产
|
||
自动挂到该账号名下(Asset.account)。平台不再持有凭证。
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
from typing import Optional
|
||
|
||
from fastapi import HTTPException, status
|
||
from sqlmodel import Session, select
|
||
|
||
logger = logging.getLogger("vps-manager.sync")
|
||
|
||
from app.adapters import registry
|
||
from app.adapters.base import BaseAdapter, parse_iso_date
|
||
from app.core import crypto
|
||
from app.core.timeutils import utcnow
|
||
from app.models.asset import (
|
||
AIAccount,
|
||
Account,
|
||
Asset,
|
||
AssetStatus,
|
||
AssetType,
|
||
DomainDetail,
|
||
VPSDetail,
|
||
)
|
||
from app.models.provider import Provider
|
||
|
||
_VALID_STATUS = {s.value for s in AssetStatus}
|
||
|
||
|
||
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:
|
||
provider = session.get(Provider, provider_id)
|
||
if not provider:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="平台不存在")
|
||
return provider
|
||
|
||
|
||
def _build_adapter(provider: Provider, config: dict) -> BaseAdapter:
|
||
if not provider.sdk_type:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST, detail="该平台未配置 SDK 类型(sdk_type)"
|
||
)
|
||
if not registry.is_supported(provider.sdk_type):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=f"暂不支持的 SDK 类型:{provider.sdk_type}",
|
||
)
|
||
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:
|
||
return AssetStatus(raw) if raw in _VALID_STATUS else AssetStatus.UNKNOWN
|
||
|
||
|
||
def _missing_config(adapter: BaseAdapter) -> list:
|
||
"""检查适配器所需凭证是否已配置"""
|
||
return [f for f in adapter.required_config if not adapter.config.get(f)]
|
||
|
||
|
||
def test_provider(session: Session, provider_id: int) -> dict:
|
||
"""(兼容入口)测试平台连接:凭证已下沉到账号,自动找该平台第一个配了凭证的账号"""
|
||
provider = _get_provider(session, provider_id)
|
||
account = _first_account_with_config(session, provider)
|
||
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)
|
||
if missing:
|
||
return {
|
||
"ok": False,
|
||
"message": f"缺少凭证配置:{', '.join(missing)}(请在账号编辑里填写 API 配置)",
|
||
**base,
|
||
}
|
||
result = adapter.test_connection()
|
||
result.update(base)
|
||
return result
|
||
|
||
|
||
def _find_asset(session: Session, provider_id: int, external_id: str, asset_type: AssetType):
|
||
return session.exec(
|
||
select(Asset).where(
|
||
Asset.provider_id == provider_id,
|
||
Asset.external_id == external_id,
|
||
Asset.asset_type == asset_type,
|
||
)
|
||
).first()
|
||
|
||
|
||
def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, account_id: Optional[int] = None) -> dict:
|
||
created = updated = 0
|
||
for vps in adapter.list_vps():
|
||
existing = _find_asset(session, provider.id, vps.external_id, AssetType.VPS)
|
||
if existing:
|
||
existing.name = vps.name or existing.name
|
||
existing.status = _norm_status(vps.status)
|
||
if vps.monthly_cost is not None:
|
||
existing.cost = vps.monthly_cost
|
||
existing.currency = vps.currency
|
||
if account_id:
|
||
existing.account_id = account_id
|
||
session.add(existing)
|
||
detail = session.exec(
|
||
select(VPSDetail).where(VPSDetail.asset_id == existing.id)
|
||
).first()
|
||
if detail:
|
||
detail.ip_address = vps.ip_address or detail.ip_address
|
||
detail.region = vps.region or detail.region
|
||
detail.os = vps.os or detail.os
|
||
detail.cpu_cores = vps.cpu_cores or detail.cpu_cores
|
||
detail.memory_gb = vps.memory_gb or detail.memory_gb
|
||
detail.disk_gb = vps.disk_gb or detail.disk_gb
|
||
session.add(detail)
|
||
updated += 1
|
||
else:
|
||
asset = Asset(
|
||
name=vps.name or vps.external_id,
|
||
asset_type=AssetType.VPS,
|
||
provider=provider.slug,
|
||
provider_id=provider.id,
|
||
external_id=vps.external_id,
|
||
account_id=account_id,
|
||
status=_norm_status(vps.status),
|
||
cost=vps.monthly_cost or 0,
|
||
currency=vps.currency,
|
||
)
|
||
session.add(asset)
|
||
session.flush() # 获取 asset.id,统一在循环外提交
|
||
session.add(
|
||
VPSDetail(
|
||
asset_id=asset.id,
|
||
ip_address=vps.ip_address or "",
|
||
region=vps.region,
|
||
os=vps.os,
|
||
cpu_cores=vps.cpu_cores or 1,
|
||
memory_gb=vps.memory_gb or 1,
|
||
disk_gb=vps.disk_gb or 20,
|
||
)
|
||
)
|
||
created += 1
|
||
session.commit()
|
||
return {"created": created, "updated": updated}
|
||
|
||
|
||
def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter, account_id: Optional[int] = None) -> dict:
|
||
created = updated = 0
|
||
for dom in adapter.list_domains():
|
||
# 适配器契约上 expiry_date 已是 date;这里再归一一次,
|
||
# 防止旧适配器/自定义适配器返回字符串时整个 commit 抛 TypeError
|
||
expiry = parse_iso_date(dom.expiry_date)
|
||
existing = _find_asset(session, provider.id, dom.external_id, AssetType.DOMAIN)
|
||
if existing:
|
||
existing.status = _norm_status(dom.status)
|
||
if expiry:
|
||
existing.expiry_date = expiry
|
||
if account_id:
|
||
existing.account_id = account_id
|
||
session.add(existing)
|
||
detail = session.exec(
|
||
select(DomainDetail).where(DomainDetail.asset_id == existing.id)
|
||
).first()
|
||
if detail:
|
||
detail.domain_name = dom.domain_name or detail.domain_name
|
||
detail.registrar = dom.registrar or detail.registrar
|
||
session.add(detail)
|
||
updated += 1
|
||
else:
|
||
asset = Asset(
|
||
name=dom.domain_name,
|
||
asset_type=AssetType.DOMAIN,
|
||
provider=provider.slug,
|
||
provider_id=provider.id,
|
||
external_id=dom.external_id,
|
||
account_id=account_id,
|
||
status=_norm_status(dom.status),
|
||
expiry_date=expiry,
|
||
)
|
||
session.add(asset)
|
||
session.flush() # 获取 asset.id,统一在循环外提交
|
||
session.add(
|
||
DomainDetail(
|
||
asset_id=asset.id,
|
||
domain_name=dom.domain_name,
|
||
registrar=dom.registrar or provider.slug,
|
||
)
|
||
)
|
||
created += 1
|
||
session.commit()
|
||
return {"created": created, "updated": updated}
|
||
|
||
|
||
def sync_provider(session: Session, provider_id: int) -> dict:
|
||
"""(兼容入口)同步平台资产:凭证已下沉到账号,自动找该平台第一个配了凭证的账号"""
|
||
provider = _get_provider(session, provider_id)
|
||
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)
|
||
if missing:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=f"缺少凭证配置:{', '.join(missing)}(请先在账号编辑里填写 API 配置)",
|
||
)
|
||
caps = adapter.capabilities()
|
||
result = {"provider": provider.slug, "sdk_type": provider.sdk_type, "account": account.name}
|
||
|
||
if caps["list_vps"]:
|
||
try:
|
||
result["vps"] = _sync_vps(session, provider, adapter, account.id)
|
||
except Exception as e: # noqa: BLE001
|
||
# 写库失败会让 Session 进入 pending-rollback 状态,必须先回滚才能继续,
|
||
# 否则下面更新 last_synced_at 的 commit 会抛 PendingRollbackError → 接口 500
|
||
session.rollback()
|
||
result["vps_error"] = str(e)
|
||
if caps["list_domains"]:
|
||
try:
|
||
result["domains"] = _sync_domains(session, provider, adapter, account.id)
|
||
except Exception as e: # noqa: BLE001
|
||
session.rollback()
|
||
result["domains_error"] = str(e)
|
||
if caps["get_account"]:
|
||
try:
|
||
result["account_info"] = adapter.get_account().to_dict()
|
||
except Exception as e: # noqa: BLE001
|
||
result["account_info_error"] = str(e)
|
||
|
||
account.last_synced_at = utcnow()
|
||
session.add(account)
|
||
session.commit()
|
||
result["last_synced_at"] = account.last_synced_at.isoformat()
|
||
logger.info("同步账号 account=%s provider=%s result=%s", account.name, provider.slug, result)
|
||
return result
|
||
|
||
|
||
# AI 服务商 slug -> sdk_type 映射(Provider 未配 sdk_type 时的回退推断)
|
||
_AI_SDK_MAP = {
|
||
"openai": "openai-api",
|
||
"deepseek": "deepseek-api",
|
||
"kimi": "moonshot-api",
|
||
"moonshot": "moonshot-api",
|
||
"minimax": "minimax-api",
|
||
}
|
||
|
||
|
||
class BalanceUnsupported(HTTPException):
|
||
"""该平台没有可用的余额查询接口
|
||
|
||
与「查询失败」区分开:sync_all_ai_balances 把它计入 skipped 而不是 failed,
|
||
否则每次「同步 AI 余额」都会把这类平台报成错误。
|
||
"""
|
||
|
||
def __init__(self, sdk_type: str):
|
||
super().__init__(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=f"{sdk_type} 暂不支持余额查询",
|
||
)
|
||
|
||
|
||
def refresh_ai_balance(session: Session, asset_id: int) -> dict:
|
||
"""刷新 AI 账号余额:解密 api_key → 适配器 get_account → 更新余额"""
|
||
asset = session.get(Asset, asset_id)
|
||
if not asset or asset.asset_type != AssetType.AI_AGENT:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST, detail="非 AI 账号资产"
|
||
)
|
||
ai = session.exec(select(AIAccount).where(AIAccount.asset_id == asset_id)).first()
|
||
if not ai:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND, detail="AI 账号详情不存在"
|
||
)
|
||
|
||
sdk_type = None
|
||
if asset.provider_id:
|
||
provider = session.get(Provider, asset.provider_id)
|
||
if provider:
|
||
sdk_type = provider.sdk_type
|
||
if not sdk_type:
|
||
sdk_type = _AI_SDK_MAP.get((ai.provider or "").lower())
|
||
if not sdk_type or not registry.is_supported(sdk_type):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=f"无法确定 AI 适配器({ai.provider})",
|
||
)
|
||
|
||
api_key = crypto.decrypt(ai.api_key_encrypted)
|
||
if not api_key:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST, detail="未配置 API Key"
|
||
)
|
||
|
||
adapter = registry.get_adapter(sdk_type, {"api_key": api_key})
|
||
if not adapter.capabilities().get("get_account"):
|
||
raise BalanceUnsupported(sdk_type)
|
||
try:
|
||
acc = adapter.get_account()
|
||
except NotImplementedError as e:
|
||
raise BalanceUnsupported(sdk_type) from e
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e: # noqa: BLE001
|
||
raise HTTPException(
|
||
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"查询余额失败:{e}"
|
||
) from e
|
||
|
||
if acc.balance is not None:
|
||
ai.balance = acc.balance
|
||
ai.currency = acc.currency
|
||
ai.last_synced_at = utcnow()
|
||
session.add(ai)
|
||
session.commit()
|
||
session.refresh(ai)
|
||
return {
|
||
"balance": ai.balance,
|
||
"currency": ai.currency,
|
||
"last_synced_at": ai.last_synced_at.isoformat(),
|
||
}
|
||
|
||
|
||
def sync_all_ai_balances(session: Session, max_workers: int = 5) -> dict:
|
||
"""并发刷新所有 AI 账号余额(单个失败不中断整体)
|
||
|
||
每个账号需调用外部 API(单次最长 30s),串行时总耗时随账号数线性增长;
|
||
改为线程池并发后显著提速。注意:SQLite Session 不能跨线程共享,
|
||
每个 worker 使用独立 Session(WAL 模式下多连接读写安全)。
|
||
"""
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
|
||
from app.database import assets_engine
|
||
|
||
ai_assets = session.exec(
|
||
select(Asset).where(Asset.asset_type == AssetType.AI_AGENT)
|
||
).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
|
||
failed = 0
|
||
skipped = 0
|
||
errors = []
|
||
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:
|
||
future.result()
|
||
success += 1
|
||
except BalanceUnsupported:
|
||
# 平台本身没余额接口,不算失败(否则每次都报一堆红)
|
||
skipped += 1
|
||
except HTTPException as e:
|
||
failed += 1
|
||
errors.append(f"{name}: {e.detail}")
|
||
except Exception as e: # noqa: BLE001
|
||
failed += 1
|
||
errors.append(f"{name}: {e}")
|
||
return {
|
||
"total": len(asset_ids),
|
||
"success": success,
|
||
"failed": failed,
|
||
"skipped": skipped,
|
||
"errors": errors,
|
||
"synced_at": utcnow().isoformat(),
|
||
}
|