From 1b7d4823c331d90645710853017cce00d61984ce Mon Sep 17 00:00:00 2001 From: gouki Date: Wed, 5 Aug 2026 11:57:17 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=BC=E5=90=88=E5=B9=B3=E5=8F=B0ser?= =?UTF-8?q?vices=E5=A4=9A=E6=9C=8D=E5=8A=A1=E6=94=AF=E6=8C=81+SW=E7=BD=91?= =?UTF-8?q?=E7=BB=9C=E4=BC=98=E5=85=88=E7=BC=93=E5=AD=98=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?+=E9=80=82=E9=85=8D=E5=B1=82/=E5=AE=9A=E6=97=B6=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent/collector.py | 14 +- agent/reporter.py | 30 +- app/adapters/ai.py | 40 +- app/adapters/cloudflare.py | 38 +- app/core/config.py | 20 + app/core/crypto.py | 4 +- app/core/seed.py | 33 +- app/core/timeutils.py | 19 + app/database.py | 83 ++- app/main.py | 63 +- app/models/asset.py | 11 +- app/models/monitor.py | 13 +- app/models/provider.py | 7 +- app/models/ssl.py | 69 ++ app/routers/agent.py | 47 +- app/routers/assets.py | 2 +- app/routers/monitor.py | 16 +- app/routers/ssl.py | 148 ++++ app/schemas/agent.py | 4 +- app/schemas/provider.py | 2 + app/schemas/ssl.py | 28 + app/services/asset_service.py | 207 ++++-- app/services/cleanup_service.py | 98 +++ app/services/provider_service.py | 10 + app/services/renewal_service.py | 25 +- app/services/security_service.py | 41 +- app/services/ssl_service.py | 258 +++++++ app/services/sync_service.py | 20 +- app/templates/index.html | 26 +- deploy/update.sh | 30 +- scripts/check_site_certs.py | 55 ++ static/js/api.js | 2 + static/js/app.js | 1082 ++---------------------------- static/js/modals.js | 207 ++++++ static/js/store.js | 193 ++++++ static/js/views/ai.js | 60 ++ static/js/views/assets.js | 137 ++++ static/js/views/cloudflare.js | 46 ++ static/js/views/dashboard.js | 46 ++ static/js/views/domains.js | 172 +++++ static/js/views/monitor.js | 246 +++++++ static/js/views/providers.js | 101 +++ static/js/views/servers.js | 106 +++ static/js/views/settings.js | 96 +++ static/js/views/subscriptions.js | 97 +++ static/sw.js | 29 +- tests/test_adapters.py | 3 +- 47 files changed, 2895 insertions(+), 1189 deletions(-) create mode 100644 app/core/timeutils.py create mode 100644 app/models/ssl.py create mode 100644 app/routers/ssl.py create mode 100644 app/schemas/ssl.py create mode 100644 app/services/cleanup_service.py create mode 100644 app/services/ssl_service.py create mode 100644 scripts/check_site_certs.py create mode 100644 static/js/modals.js create mode 100644 static/js/store.js create mode 100644 static/js/views/ai.js create mode 100644 static/js/views/assets.js create mode 100644 static/js/views/cloudflare.js create mode 100644 static/js/views/dashboard.js create mode 100644 static/js/views/domains.js create mode 100644 static/js/views/monitor.js create mode 100644 static/js/views/providers.js create mode 100644 static/js/views/servers.js create mode 100644 static/js/views/settings.js create mode 100644 static/js/views/subscriptions.js diff --git a/agent/collector.py b/agent/collector.py index 87a3d0e..86bea0a 100644 --- a/agent/collector.py +++ b/agent/collector.py @@ -34,15 +34,21 @@ def collect_metrics() -> dict: def _get_ip() -> str: - """获取本机出口 IP(不实际发包)""" + """获取本机出口 IP(UDP 不实际发包;设超时避免无网环境阻塞)""" + s = None try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(2) # 无网络环境下 connect 可能阻塞,加超时保护 s.connect(("8.8.8.8", 80)) - ip = s.getsockname()[0] - s.close() - return ip + return s.getsockname()[0] except Exception: return "" + finally: + if s is not None: + try: + s.close() + except Exception: + pass def collect_server_info() -> dict: diff --git a/agent/reporter.py b/agent/reporter.py index 5df5e15..35e9eed 100644 --- a/agent/reporter.py +++ b/agent/reporter.py @@ -4,10 +4,12 @@ VPS_MANAGER_URL 中心地址,如 http://100.89.0.11:8000 VPS_ASSET_ID 本机对应的资产 ID VPS_AGENT_KEY Agent Key(中心配置了 AGENT_KEY 时必填) + VPS_AGENT_RETRY 上报失败重试次数(默认 2,指数退避) """ import os import sys +import time import httpx @@ -18,6 +20,7 @@ def main() -> int: url = os.environ.get("VPS_MANAGER_URL", "").rstrip("/") asset_id = os.environ.get("VPS_ASSET_ID", "") key = os.environ.get("VPS_AGENT_KEY", "") + max_retry = int(os.environ.get("VPS_AGENT_RETRY", "2")) if not url or not asset_id: print("错误:需设置 VPS_MANAGER_URL 和 VPS_ASSET_ID") @@ -31,16 +34,23 @@ def main() -> int: } headers = {"X-Agent-Key": key} if key else {} - try: - resp = httpx.post( - f"{url}/api/agent/report", json=payload, headers=headers, timeout=30 - ) - print(f"上报完成 HTTP {resp.status_code}: {resp.text}") - resp.raise_for_status() - return 0 - except Exception as e: # noqa: BLE001 - print(f"上报失败: {e}") - return 1 + for attempt in range(max_retry + 1): + try: + resp = httpx.post( + f"{url}/api/agent/report", json=payload, headers=headers, timeout=30 + ) + print(f"上报完成 HTTP {resp.status_code}: {resp.text}") + resp.raise_for_status() + return 0 + except Exception as e: # noqa: BLE001 + if attempt < max_retry: + wait = 2 ** attempt + print(f"上报失败({attempt + 1}/{max_retry + 1}),{wait}s 后重试: {e}") + time.sleep(wait) + else: + print(f"上报失败(已重试 {max_retry} 次): {e}") + return 1 + return 1 if __name__ == "__main__": diff --git a/app/adapters/ai.py b/app/adapters/ai.py index 19e4fba..7fc7016 100644 --- a/app/adapters/ai.py +++ b/app/adapters/ai.py @@ -100,11 +100,41 @@ class OpenAIAdapter(_AIBase): @register("minimax-api") class MinimaxAdapter(_AIBase): - # Minimax 需 group_id,余额接口因账号类型而异,此处为骨架待完善 - required_config = ["api_key", "group_id"] + """Minimax 适配器(余额查询) + + 接口:GET https://api.minimax.chat/v1/balance + 认证:Bearer api_key(group_id 仅部分旧接口需要,余额查询非必需) + 响应示例:{"balance": 123.45, "currency": "CNY", ...} + 注:Minimax 国内版端点为 api.minimaxi.com,国际版为 api.minimax.chat, + 两者 API Key 不通用;默认使用国际版端点,可通过 config["base_url"] 覆盖。 + """ + + required_config = ["api_key"] BASE = "https://api.minimax.chat/v1" + def _base_url(self) -> str: + return (self.config.get("base_url") or self.BASE).rstrip("/") + + def _balance(self) -> AccountInfo: + data = self._get(self._base_url() + "/balance") + balance = data.get("balance") + try: + balance = float(balance) if balance is not None else None + except (TypeError, ValueError): + balance = None + currency = data.get("currency") or "CNY" + return AccountInfo(balance=balance, currency=currency, raw=data) + def test_connection(self) -> dict: - if not self.config.get("group_id"): - return {"ok": False, "message": "Minimax 需配置 group_id(适配器余额接口待完善)"} - return {"ok": False, "message": "Minimax 适配器余额接口待完善(请提供具体接口文档)"} + try: + acc = self._balance() + if acc.balance is not None: + return {"ok": True, "message": f"连接成功,余额 {acc.balance} {acc.currency}"} + return {"ok": True, "message": "连接成功(未返回余额字段)"} + except httpx.HTTPStatusError as e: + return self._http_error(e) + except Exception as e: # noqa: BLE001 + return {"ok": False, "message": str(e)} + + def get_account(self) -> AccountInfo: + return self._balance() diff --git a/app/adapters/cloudflare.py b/app/adapters/cloudflare.py index e03b6e2..f123cb5 100644 --- a/app/adapters/cloudflare.py +++ b/app/adapters/cloudflare.py @@ -1,8 +1,9 @@ """Cloudflare 适配器(REST API v4,Bearer Token) API 文档:https://developers.cloudflare.com/api/ -所需配置:{"api_token": "..."}(建议用 API Token,权限含 Zone:Read / Account:Read) +所需配置:{"api_token": "..."}(建议用 API Token,权限含 Zone:Read / Account:Read / Registrar:Read) Cloudflare 无传统 VPS,主要同步托管域名(zones);Workers/R2/Tunnel 子资产留待后续阶段。 +域名到期日通过 Registrar API(/accounts/{id}/registrar/domains)补充获取。 """ import httpx @@ -42,16 +43,21 @@ class CloudflareAdapter(BaseAdapter): return {"ok": False, "message": str(e)} def list_domains(self) -> list: + # 先拉取 Registrar 域名到期日映射(domain_name -> expires_at ISO 日期) + expiry_map = self._registrar_expiry_map() + result = [] page = 1 while True: data = self._get(f"/zones?per_page=50&page={page}") for z in data.get("result", []): + name = z.get("name") result.append( NormalizedDomain( external_id=z.get("id"), - domain_name=z.get("name"), + domain_name=name, registrar="cloudflare", + expiry_date=expiry_map.get(name), status="active" if z.get("status") == "active" else (z.get("status") or "unknown"), raw=z, ) @@ -61,3 +67,31 @@ class CloudflareAdapter(BaseAdapter): break page += 1 return result + + def _registrar_expiry_map(self) -> dict: + """拉取 Cloudflare Registrar 域名到期日映射 {domain_name: YYYY-MM-DD} + + 流程:/accounts → 对每个 account 调 /accounts/{id}/registrar/domains。 + 若 Token 无 Registrar 权限或账号无 Registrar 域名,静默返回空映射。 + """ + expiry = {} + try: + accounts = self._get("/accounts?per_page=50").get("result", []) + except Exception: # noqa: BLE001 + return expiry + for acc in accounts: + acc_id = acc.get("id") + if not acc_id: + continue + try: + domains = self._get(f"/accounts/{acc_id}/registrar/domains").get("result", []) + except Exception: # noqa: BLE001 + continue + for d in domains: + # Registrar API 返回的 id 是域名本身(如 example.com) + name = d.get("id") or d.get("domain_name") or "" + expires_at = d.get("expires_at") + if name and expires_at: + # expires_at 为 ISO8601 时间戳,截取日期部分 + expiry[name] = expires_at[:10] + return expiry diff --git a/app/core/config.py b/app/core/config.py index 1d3683d..daddf5c 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -39,5 +39,25 @@ class Settings: # ---- 续费提醒 ---- RENEWAL_THRESHOLD_DAYS: int = int(os.getenv("RENEWAL_THRESHOLD_DAYS", "30")) + # ---- 监控数据保留策略(自动清理)---- + # MetricPoint 时序数据保留天数(Agent 高频上报,默认 30 天) + METRICS_RETENTION_DAYS: int = int(os.getenv("METRICS_RETENTION_DAYS", "30")) + # SecurityCheck 安全检查历史保留天数(默认 90 天) + SECURITY_RETENTION_DAYS: int = int(os.getenv("SECURITY_RETENTION_DAYS", "90")) + # EventLog 事件日志保留天数(默认 180 天) + EVENT_LOG_RETENTION_DAYS: int = int(os.getenv("EVENT_LOG_RETENTION_DAYS", "180")) + # 自动清理间隔(小时),0 表示禁用后台自动清理 + CLEANUP_INTERVAL_HOURS: int = int(os.getenv("CLEANUP_INTERVAL_HOURS", "24")) + + # ---- Agent 上报频率限制 ---- + # 同一 asset_id 两次上报的最小间隔(秒),0 表示不限制 + AGENT_REPORT_MIN_INTERVAL: int = int(os.getenv("AGENT_REPORT_MIN_INTERVAL", "30")) + + # ---- CORS 允许来源(逗号分隔),默认本地 + Tailscale ---- + CORS_ORIGINS: str = os.getenv( + "CORS_ORIGINS", + "http://127.0.0.1:8000,http://localhost:8000,https://dify.taile5765c.ts.net", + ) + settings = Settings() diff --git a/app/core/crypto.py b/app/core/crypto.py index 2e8751c..1a740a2 100644 --- a/app/core/crypto.py +++ b/app/core/crypto.py @@ -4,6 +4,7 @@ MASTER_KEY 从 .env 读取,不入库。 """ +from functools import lru_cache from typing import Optional from cryptography.fernet import Fernet, InvalidToken @@ -11,8 +12,9 @@ from cryptography.fernet import Fernet, InvalidToken from app.core.config import settings +@lru_cache(maxsize=1) def _get_fernet() -> Fernet: - """获取 Fernet 实例(MASTER_KEY 未配置时抛错)""" + """获取 Fernet 实例(进程内缓存,避免每次加解密重建;MASTER_KEY 未配置时抛错)""" key = settings.MASTER_KEY if not key: raise RuntimeError( diff --git a/app/core/seed.py b/app/core/seed.py index 1e22803..4bced3f 100644 --- a/app/core/seed.py +++ b/app/core/seed.py @@ -10,57 +10,78 @@ from app.models.provider import Provider PRESET_PROVIDERS = [ # ---- VPS / 云服务商 ---- + # 综合平台:services 列出可提供的服务,卡片上会显示多标签 {"slug": "aliyun", "name": "阿里云", "name_en": "Aliyun", "category": "vps", + "services": "vps,domain,ssl_cert,cdn,dns", "website": "https://aliyun.com", "console_url": "https://ecs.console.aliyun.com", "sdk_type": "aliyun-sdk"}, {"slug": "tencent", "name": "腾讯云", "name_en": "Tencent Cloud", "category": "vps", + "services": "vps,domain,ssl_cert,cdn,dns", "website": "https://cloud.tencent.com", "console_url": "https://console.cloud.tencent.com/cvm", "sdk_type": "tencent-sdk"}, {"slug": "aliyun-intl", "name": "阿里国际", "name_en": "Alibaba Cloud", "category": "vps", + "services": "vps,domain,ssl_cert,cdn,dns", "website": "https://alibabacloud.com", "console_url": "https://ecs.console.alibabacloud.com", "sdk_type": "alibabacloud-sdk"}, {"slug": "tencent-intl", "name": "腾讯国际", "name_en": "Tencent Cloud Intl", "category": "vps", + "services": "vps,domain,ssl_cert,cdn,dns", "website": "https://intl.cloud.tencent.com", "console_url": "https://console.intl.cloud.tencent.com", "sdk_type": "tencent-intl-sdk"}, {"slug": "vultr", "name": "Vultr", "name_en": "Vultr", "category": "vps", + "services": "vps", "website": "https://vultr.com", "console_url": "https://my.vultr.com", "sdk_type": "vultr-api"}, {"slug": "digitalocean", "name": "DigitalOcean", "name_en": "DigitalOcean", "category": "vps", + "services": "vps,domain", "website": "https://digitalocean.com", "console_url": "https://cloud.digitalocean.com", "sdk_type": "do-api"}, {"slug": "linode", "name": "Linode", "name_en": "Akamai Linode", "category": "vps", + "services": "vps", "website": "https://linode.com", "console_url": "https://cloud.linode.com", "sdk_type": "linode-api"}, {"slug": "cloudcone", "name": "CloudCone", "name_en": "CloudCone", "category": "vps", + "services": "vps", "website": "https://cloudcone.com", "console_url": "https://app.cloudcone.com", "sdk_type": "cloudcone-api"}, {"slug": "zeabur", "name": "Zeabur", "name_en": "Zeabur", "category": "vps", + "services": "vps,domain", "website": "https://zeabur.com", "console_url": "https://dash.zeabur.com", "sdk_type": "zeabur-api"}, # ---- 域名注册商 ---- {"slug": "namesilo", "name": "Namesilo", "name_en": "Namesilo", "category": "domain", + "services": "domain,dns", "website": "https://namesilo.com", "console_url": "https://www.namesilo.com/account_domains.php", "sdk_type": "namesilo-api"}, {"slug": "xinwang", "name": "新网", "name_en": "Xinnet", "category": "domain", + "services": "domain,dns", "website": "https://xinnet.com", "console_url": "https://www.xinnet.com", "sdk_type": None}, {"slug": "dynadot", "name": "Dynadot", "name_en": "Dynadot", "category": "domain", + "services": "domain,dns", "website": "https://dynadot.com", "console_url": "https://www.dynadot.com/account/domains", "sdk_type": "dynadot-api"}, # ---- AI 服务商 ---- {"slug": "openai", "name": "OpenAI", "name_en": "OpenAI", "category": "ai_agent", + "services": "ai_agent", "website": "https://openai.com", "console_url": "https://platform.openai.com", "sdk_type": "openai-api"}, {"slug": "minimax", "name": "Minimax", "name_en": "Minimax", "category": "ai_agent", + "services": "ai_agent", "website": "https://minimax.io", "console_url": "https://platform.minimaxi.com", "sdk_type": "minimax-api"}, {"slug": "kimi", "name": "Kimi", "name_en": "Moonshot", "category": "ai_agent", + "services": "ai_agent", "website": "https://moonshot.cn", "console_url": "https://platform.moonshot.cn", "sdk_type": "moonshot-api"}, {"slug": "agnes", "name": "Agnes", "name_en": "Agnes", "category": "ai_agent", + "services": "ai_agent", "website": None, "console_url": None, "sdk_type": None}, {"slug": "deepseek", "name": "DeepSeek", "name_en": "DeepSeek", "category": "ai_agent", + "services": "ai_agent", "website": "https://deepseek.com", "console_url": "https://platform.deepseek.com", "sdk_type": "deepseek-api"}, # ---- Cloudflare ---- {"slug": "cloudflare", "name": "Cloudflare", "name_en": "Cloudflare", "category": "cloudflare", + "services": "cloudflare,dns,ssl_cert,cdn", "website": "https://cloudflare.com", "console_url": "https://dash.cloudflare.com", "sdk_type": "cloudflare-api"}, ] def seed_providers(session: Session) -> int: - """初始化预设平台(已存在的 slug 跳过),返回新增数量""" + """初始化预设平台(已存在的 slug 跳过),返回新增数量 + + 单次查询获取全部已存在 slug,避免逐条 SELECT 的 N 次往返。 + """ + existing_slugs = set(session.exec(select(Provider.slug)).all()) added = 0 for data in PRESET_PROVIDERS: - existing = session.exec( - select(Provider).where(Provider.slug == data["slug"]) - ).first() - if not existing: + if data["slug"] not in existing_slugs: session.add(Provider(**data)) added += 1 - session.commit() + if added: + session.commit() return added diff --git a/app/core/timeutils.py b/app/core/timeutils.py new file mode 100644 index 0000000..be2a7d0 --- /dev/null +++ b/app/core/timeutils.py @@ -0,0 +1,19 @@ +"""UTC 时间工具(替代已弃用的 datetime.utcnow) + +Python 3.12+ 起 datetime.utcnow() 被标记为弃用, +统一使用 datetime.now(timezone.utc) 的快捷封装。 +注意:SQLite 不保存时区信息,为兼容既有数据与比较逻辑, +默认返回 naive UTC 时间(与 utcnow 行为一致,但来源非弃用 API)。 +""" + +from datetime import datetime, timezone + + +def utcnow() -> datetime: + """返回当前 UTC 时间(naive,与 datetime.utcnow() 行为一致)""" + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def utcnow_iso() -> str: + """返回当前 UTC 时间的 ISO 格式字符串(带 Z 后缀标识 UTC)""" + return datetime.now(timezone.utc).isoformat() diff --git a/app/database.py b/app/database.py index f44d04c..b12eddf 100644 --- a/app/database.py +++ b/app/database.py @@ -17,12 +17,30 @@ ASSETS_DB_URL = f"sqlite:///{DATA_DIR / 'assets.db'}" METRICS_DB_URL = f"sqlite:///{DATA_DIR / 'metrics.db'}" assets_engine = create_engine( - ASSETS_DB_URL, echo=False, connect_args={"check_same_thread": False} + ASSETS_DB_URL, + echo=False, + connect_args={"check_same_thread": False}, + pool_pre_ping=True, ) metrics_engine = create_engine( - METRICS_DB_URL, echo=False, connect_args={"check_same_thread": False} + METRICS_DB_URL, + echo=False, + connect_args={"check_same_thread": False}, + pool_pre_ping=True, ) + +def _enable_wal(engine) -> None: + """启用 WAL 模式,提升 SQLite 并发读写能力""" + import sqlalchemy as sa + with engine.connect() as conn: + conn.execute(sa.text("PRAGMA journal_mode=WAL")) + conn.execute(sa.text("PRAGMA busy_timeout=5000")) + + +_enable_wal(assets_engine) +_enable_wal(metrics_engine) + # 兼容旧代码:默认 engine 指向资产库 engine = assets_engine @@ -37,8 +55,9 @@ def init_db() -> None: ServerInfo, ) from app.models.provider import Provider # noqa: F401 + from app.models.ssl import SiteCert, Subdomain # noqa: F401 - asset_models = [Provider, Asset, VPSDetail, DomainDetail, AIAccount, CloudflareDetail] + asset_models = [Provider, Asset, VPSDetail, DomainDetail, AIAccount, CloudflareDetail, Subdomain, SiteCert] metric_models = [MetricPoint, ServerInfo, SecurityCheck, EventLog] SQLModel.metadata.create_all( @@ -48,6 +67,7 @@ def init_db() -> None: metrics_engine, tables=[m.__table__ for m in metric_models] ) _migrate_assets_db() + _migrate_indexes() def _migrate_assets_db() -> None: @@ -68,10 +88,67 @@ def _migrate_assets_db() -> None: cols = {c["name"] for c in insp.get_columns("providers")} if "last_synced_at" not in cols: conn.execute(sa.text("ALTER TABLE providers ADD COLUMN last_synced_at DATETIME")) + if "services" not in cols: + conn.execute(sa.text("ALTER TABLE providers ADD COLUMN services VARCHAR DEFAULT ''")) + # 为已有预设平台补充 services(仅补空值,用户自定义行不动) + _backfill_provider_services(conn) if insp.has_table("ai_accounts"): cols = {c["name"] for c in insp.get_columns("ai_accounts")} if "api_key_encrypted" not in cols: conn.execute(sa.text("ALTER TABLE ai_accounts ADD COLUMN api_key_encrypted VARCHAR")) + # 迁移:将明文 api_key 加密后存入 api_key_encrypted,并清空原字段 + _migrate_plaintext_api_keys(conn) + + +def _migrate_indexes() -> None: + """为已有数据库补充复合索引(create_all 不会为已存在的表补索引,IF NOT EXISTS 幂等)""" + import sqlalchemy as sa + + stmts = [ + (assets_engine, "CREATE INDEX IF NOT EXISTS ix_assets_provider_ext_type ON assets (provider_id, external_id, asset_type)"), + (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)"), + ] + for eng, sql in stmts: + with eng.begin() as conn: + conn.execute(sa.text(sql)) + + +def _migrate_plaintext_api_keys(conn) -> None: + """一次性迁移:将 ai_accounts 中残留的明文 api_key 加密后存入 api_key_encrypted,并清空原字段""" + import sqlalchemy as sa + from app.core.crypto import encrypt + + rows = conn.execute( + sa.text("SELECT id, api_key FROM ai_accounts WHERE api_key IS NOT NULL AND api_key != ''") + ).fetchall() + if not rows: + return + for row in rows: + encrypted = encrypt(row[1]) + if encrypted: + conn.execute( + sa.text("UPDATE ai_accounts SET api_key_encrypted = :enc, api_key = NULL WHERE id = :id"), + {"enc": encrypted, "id": row[0]}, + ) + + +def _backfill_provider_services(conn) -> None: + """为已有预设平台补充 services 服务列表(只更新 services 为空的预设 slug)""" + import sqlalchemy as sa + from app.core.seed import PRESET_PROVIDERS + + for data in PRESET_PROVIDERS: + services = data.get("services") + if not services: + continue + conn.execute( + sa.text( + "UPDATE providers SET services = :services " + "WHERE slug = :slug AND (services IS NULL OR services = '')" + ), + {"services": services, "slug": data["slug"]}, + ) def get_session() -> Generator[Session, None, None]: diff --git a/app/main.py b/app/main.py index 5f4ac58..e4ed1ee 100644 --- a/app/main.py +++ b/app/main.py @@ -8,44 +8,69 @@ from contextlib import asynccontextmanager from pathlib import Path +import asyncio +import logging from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse -from fastapi.staticfiles import StaticFiles +from starlette.staticfiles import StaticFiles as StarletteStaticFiles from jinja2 import Environment, FileSystemLoader from sqlmodel import Session from app.core.config import settings from app.core.seed import seed_providers from app.database import assets_engine, init_db -from app.routers import agent, assets, monitor, notify, providers, stats, sync +from app.routers import agent, assets, monitor, notify, providers, ssl, stats, sync +from app.services import cleanup_service BASE_DIR = Path(__file__).resolve().parent.parent STATIC_DIR = BASE_DIR / "static" TEMPLATE_DIR = BASE_DIR / "app" / "templates" STATIC_DIR.mkdir(parents=True, exist_ok=True) + +def _asset_version() -> str: + """静态资源版本号:取 static 目录内最新文件 mtime,代码更新后自动变更。 + + 用于前端引用 ?v= 参数,绕开浏览器启发式缓存(旧响应无 Cache-Control + 时存下的条目会被视为新鲜而不再回源验证)。 + """ + latest = 0 + for p in STATIC_DIR.rglob("*"): + if p.is_file(): + latest = max(latest, int(p.stat().st_mtime)) + return str(latest) + +# 全局日志配置:统一格式,便于生产环境排查 +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s [%(name)s] %(message)s", +) + # 直接使用 Jinja2 Environment 渲染(规避 Starlette Jinja2Templates 在 Python 3.14 下的缓存兼容问题) jinja_env = Environment(loader=FileSystemLoader(TEMPLATE_DIR), autoescape=True) @asynccontextmanager async def lifespan(_: FastAPI): - """应用启动时自动建表并初始化预设平台""" + """应用启动时自动建表、初始化预设平台,并启动监控数据定期清理任务""" init_db() with Session(assets_engine) as session: seed_providers(session) + cleanup_task = asyncio.create_task(cleanup_service.cleanup_loop()) yield + cleanup_task.cancel() app = FastAPI(title=settings.APP_NAME, version="0.2.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], + allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()], + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["Content-Type", "X-API-Key", "X-Agent-Key"], + allow_credentials=True, ) app.include_router(assets.router) @@ -55,24 +80,42 @@ app.include_router(agent.router) app.include_router(monitor.router) app.include_router(sync.router) app.include_router(notify.router) +app.include_router(ssl.router) -app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") +# 静态资源:no-cache(每次回源校验 ETag)。避免启发式缓存导致旧版本残留, +# 与 SW 的 no-store 回源配合,保证代码更新后立即生效。 +class NoCacheStaticFiles(StarletteStaticFiles): + def file_response(self, *args, **kwargs): + resp = super().file_response(*args, **kwargs) + resp.headers.setdefault("Cache-Control", "no-cache") + return resp + +app.mount("/static", NoCacheStaticFiles(directory=STATIC_DIR), name="static") @app.get("/", include_in_schema=False) def index() -> HTMLResponse: """前端 SPA 入口""" - html = jinja_env.get_template("index.html").render(app_name=settings.APP_NAME) + html = jinja_env.get_template("index.html").render( + app_name=settings.APP_NAME, asset_version=_asset_version() + ) return HTMLResponse(html) @app.get("/sw.js", include_in_schema=False) def service_worker() -> FileResponse: - """Service Worker(置于根路径以使 scope 覆盖全站)""" + """Service Worker(置于根路径以使 scope 覆盖全站) + + no-cache:保证浏览器每次导航都校验 SW 是否有更新, + 否则浏览器可能长时间持有旧版 SW(默认更新检查间隔长)。 + """ return FileResponse( STATIC_DIR / "sw.js", media_type="application/javascript", - headers={"Service-Worker-Allowed": "/"}, + headers={ + "Service-Worker-Allowed": "/", + "Cache-Control": "no-cache", + }, ) diff --git a/app/models/asset.py b/app/models/asset.py index 5f96beb..59ee896 100644 --- a/app/models/asset.py +++ b/app/models/asset.py @@ -11,8 +11,11 @@ from datetime import date, datetime from enum import Enum from typing import Optional +from sqlalchemy import Index from sqlmodel import Field, SQLModel +from app.core.timeutils import utcnow + class AssetType(str, Enum): """资产类型""" @@ -38,6 +41,8 @@ class Asset(SQLModel, table=True): """资产主表:所有资产在此统一登记,用于续费提醒与状态总览""" __tablename__ = "assets" + # SDK 同步去重查询:provider_id + external_id + asset_type + __table_args__ = (Index("ix_assets_provider_ext_type", "provider_id", "external_id", "asset_type"),) id: Optional[int] = Field(default=None, primary_key=True) name: str = Field(index=True, description="资产名称,如:博客主站 VPS") @@ -72,10 +77,10 @@ class Asset(SQLModel, table=True): default=False, description="已归档:不再使用但可能仍在续费,需重点排查" ) remark: Optional[str] = Field(default=None, description="备注") - created_at: datetime = Field(default_factory=datetime.utcnow, description="创建时间") + created_at: datetime = Field(default_factory=utcnow, description="创建时间") updated_at: datetime = Field( - default_factory=datetime.utcnow, - sa_column_kwargs={"onupdate": datetime.utcnow}, + default_factory=utcnow, + sa_column_kwargs={"onupdate": utcnow}, description="更新时间", ) diff --git a/app/models/monitor.py b/app/models/monitor.py index 206d5ba..f76a126 100644 --- a/app/models/monitor.py +++ b/app/models/monitor.py @@ -9,17 +9,21 @@ from datetime import datetime from typing import Optional +from sqlalchemy import Index from sqlmodel import Field, SQLModel +from app.core.timeutils import utcnow + class MetricPoint(SQLModel, table=True): """资源监控时序数据""" __tablename__ = "metric_points" + __table_args__ = (Index("ix_metric_points_asset_ts", "asset_id", "ts"),) id: Optional[int] = Field(default=None, primary_key=True) asset_id: int = Field(index=True, description="关联资产 ID") - ts: datetime = Field(default_factory=datetime.utcnow, index=True, description="采集时间") + ts: datetime = Field(default_factory=utcnow, index=True, description="采集时间") cpu_pct: Optional[float] = Field(default=None, description="CPU 使用率 %") mem_pct: Optional[float] = Field(default=None, description="内存使用率 %") disk_pct: Optional[float] = Field(default=None, description="磁盘使用率 %") @@ -46,17 +50,18 @@ class ServerInfo(SQLModel, table=True): public_ip: Optional[str] = Field(default=None) tailscale_ip: Optional[str] = Field(default=None) status: Optional[str] = Field(default="online", description="online/offline") - last_seen: datetime = Field(default_factory=datetime.utcnow, description="最近上报时间") + last_seen: datetime = Field(default_factory=utcnow, description="最近上报时间") class SecurityCheck(SQLModel, table=True): """安全检查项结果""" __tablename__ = "security_checks" + __table_args__ = (Index("ix_security_checks_asset_ts", "asset_id", "ts"),) id: Optional[int] = Field(default=None, primary_key=True) asset_id: int = Field(index=True) - ts: datetime = Field(default_factory=datetime.utcnow, index=True) + ts: datetime = Field(default_factory=utcnow, index=True) check_item: str = Field(description="检查项,如 ssh_config/firewall/updates") status: str = Field(default="unknown", description="pass/warn/fail/unknown") detail: Optional[str] = Field(default=None, description="检查详情") @@ -69,7 +74,7 @@ class EventLog(SQLModel, table=True): __tablename__ = "event_logs" id: Optional[int] = Field(default=None, primary_key=True) - ts: datetime = Field(default_factory=datetime.utcnow, index=True) + ts: datetime = Field(default_factory=utcnow, index=True) level: str = Field(default="info", index=True, description="info/warning/error") source: str = Field(default="system", description="来源,如 agent/api/backup") asset_id: Optional[int] = Field(default=None, index=True) diff --git a/app/models/provider.py b/app/models/provider.py index b932f69..d273763 100644 --- a/app/models/provider.py +++ b/app/models/provider.py @@ -10,6 +10,8 @@ from typing import Optional from sqlmodel import Field, SQLModel +from app.core.timeutils import utcnow + class ProviderCategory(str, Enum): """平台分类""" @@ -31,6 +33,9 @@ class Provider(SQLModel, table=True): name: str = Field(index=True, description="显示名,如 阿里云") name_en: Optional[str] = Field(default=None, description="英文名") category: ProviderCategory = Field(index=True, description="平台分类") + services: str = Field( + default="", description="提供的服务列表(逗号分隔):vps/domain/ai_agent/cloudflare/ssl_cert/cdn/dns/other,综合平台可多项" + ) website: Optional[str] = Field(default=None, description="官网") console_url: Optional[str] = Field(default=None, description="管理面板 URL") sdk_type: Optional[str] = Field( @@ -44,4 +49,4 @@ class Provider(SQLModel, table=True): last_synced_at: Optional[datetime] = Field( default=None, description="最近一次 SDK 同步时间" ) - created_at: datetime = Field(default_factory=datetime.utcnow, description="创建时间") + created_at: datetime = Field(default_factory=utcnow, description="创建时间") diff --git a/app/models/ssl.py b/app/models/ssl.py new file mode 100644 index 0000000..f33a177 --- /dev/null +++ b/app/models/ssl.py @@ -0,0 +1,69 @@ +"""SSL 相关数据库模型 + +- Subdomain: 域名资产下的子域名记录(关联域名资产 Asset) +- SiteCert: 站点证书监控记录(探测 https 站点证书的到期情况) +""" + +from datetime import date, datetime +from typing import Optional + +from sqlmodel import Field, SQLModel + +from app.core.timeutils import utcnow + + +class Subdomain(SQLModel, table=True): + """子域名记录(挂在域名资产下)""" + + __tablename__ = "subdomains" + + id: Optional[int] = Field(default=None, primary_key=True) + asset_id: int = Field( + foreign_key="assets.id", index=True, description="所属域名资产 ID" + ) + host: str = Field(index=True, description="子域名主机名,如 www / api / blog(@ 表示根域名)") + record_type: Optional[str] = Field( + default=None, description="DNS 记录类型:A/CNAME/AAAA/MX/TXT" + ) + record_value: Optional[str] = Field( + default=None, description="DNS 记录值(IP 或目标域名)" + ) + is_active: bool = Field(default=True, 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="更新时间", + ) + + +class SiteCert(SQLModel, table=True): + """站点证书监控记录(探测 https 站点证书到期情况)""" + + __tablename__ = "site_certs" + + id: Optional[int] = Field(default=None, primary_key=True) + hostname: str = Field(index=True, description="探测目标主机名,如 www.example.com") + port: int = Field(default=443, description="探测端口") + asset_id: Optional[int] = Field( + default=None, foreign_key="assets.id", index=True, description="关联资产 ID(可选)" + ) + issuer: Optional[str] = Field(default=None, description="签发机构 CN") + subject_cn: Optional[str] = Field(default=None, description="证书主体 CN") + valid_from: Optional[date] = Field(default=None, description="证书生效日期") + valid_to: Optional[date] = Field(default=None, description="证书到期日期") + fingerprint: Optional[str] = Field(default=None, description="证书指纹 SHA-256") + status: str = Field( + default="unknown", + index=True, + description="状态:valid/expiring/expired/error/unknown", + ) + error: Optional[str] = Field(default=None, description="探测失败原因") + last_checked_at: Optional[datetime] = 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="更新时间", + ) diff --git a/app/routers/agent.py b/app/routers/agent.py index 88f8689..a1115c1 100644 --- a/app/routers/agent.py +++ b/app/routers/agent.py @@ -1,17 +1,53 @@ """Agent 上报接收路由(数据写入 metrics.db)""" -from datetime import datetime +import threading +import time -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status from sqlmodel import Session, select +from app.core.config import settings from app.core.security import require_agent_key -from app.database import get_metrics_session +from app.core.timeutils import utcnow +from app.database import assets_engine, get_metrics_session +from app.models.asset import Asset from app.models.monitor import EventLog, MetricPoint, SecurityCheck, ServerInfo from app.schemas.agent import AgentReport router = APIRouter(prefix="/api/agent", tags=["agent"]) +# 内存级频率限制:{asset_id: 上次上报的 monotonic 时间戳} +# 同步路由运行在线程池,多线程并发读写需加锁保证“读-判断-写”原子性 +_last_report_ts: dict = {} +_rate_limit_lock = threading.Lock() + + +def _check_rate_limit(asset_id: int) -> None: + """限制同一资产的上报频率,防止配置错误的 Agent 高频写入填满数据库""" + interval = settings.AGENT_REPORT_MIN_INTERVAL + if interval <= 0: + return + now = time.monotonic() + with _rate_limit_lock: + last = _last_report_ts.get(asset_id) + if last is not None and (now - last) < interval: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=f"上报过于频繁,同一资产最小间隔 {interval} 秒", + ) + _last_report_ts[asset_id] = now + + +def _validate_asset_id(asset_id: int) -> None: + """校验上报的 asset_id 在资产库中真实存在,防止脏数据写入""" + with Session(assets_engine) as session: + asset = session.get(Asset, asset_id) + if not asset: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"asset_id={asset_id} 不存在,请先在资产列表中登记该服务器", + ) + @router.post( "/report", @@ -19,6 +55,9 @@ router = APIRouter(prefix="/api/agent", tags=["agent"]) dependencies=[Depends(require_agent_key)], ) def report(data: AgentReport, session: Session = Depends(get_metrics_session)) -> dict: + _validate_asset_id(data.asset_id) + _check_rate_limit(data.asset_id) + # 资源监控时序 if data.metrics: session.add(MetricPoint(asset_id=data.asset_id, **data.metrics.model_dump())) @@ -26,7 +65,7 @@ def report(data: AgentReport, session: Session = Depends(get_metrics_session)) - # 服务器信息快照(每资产一条,覆盖更新) if data.server_info: info = data.server_info.model_dump() - info["last_seen"] = datetime.utcnow() + info["last_seen"] = utcnow() existing = session.exec( select(ServerInfo).where(ServerInfo.asset_id == data.asset_id) ).first() diff --git a/app/routers/assets.py b/app/routers/assets.py index d863d5d..01e5096 100644 --- a/app/routers/assets.py +++ b/app/routers/assets.py @@ -32,7 +32,7 @@ def list_assets( ) -@router.get("/export", summary="导出所有资产(数据迁移/备份)") +@router.get("/export", summary="导出所有资产(数据迁移/备份)", dependencies=[Depends(require_api_key)]) def export_assets(session: Session = Depends(get_session)) -> dict: return asset_service.export_assets(session) diff --git a/app/routers/monitor.py b/app/routers/monitor.py index 800310d..87ad476 100644 --- a/app/routers/monitor.py +++ b/app/routers/monitor.py @@ -5,9 +5,10 @@ from typing import Optional from fastapi import APIRouter, Depends, Query from sqlmodel import Session, select +from app.core.security import require_api_key from app.database import get_metrics_session from app.models.monitor import MetricPoint, SecurityCheck, ServerInfo -from app.services import security_service +from app.services import cleanup_service, security_service router = APIRouter(prefix="/api/monitor", tags=["monitor"]) @@ -17,6 +18,19 @@ def security_overview(session: Session = Depends(get_metrics_session)): return security_service.get_security_overview(session) +@router.post( + "/cleanup", + summary="手动触发监控数据清理(按保留周期删除过期数据)", + dependencies=[Depends(require_api_key)], +) +def cleanup( + metrics_days: Optional[int] = Query(default=None, description="时序数据保留天数(默认取配置)"), + security_days: Optional[int] = Query(default=None, description="安全检查保留天数(默认取配置)"), + event_log_days: Optional[int] = Query(default=None, description="事件日志保留天数(默认取配置)"), +): + return cleanup_service.cleanup_metrics(metrics_days, security_days, event_log_days) + + @router.get("/{asset_id}/metrics", summary="资源监控时序(倒序,最新在前)") def metrics( asset_id: int, diff --git a/app/routers/ssl.py b/app/routers/ssl.py new file mode 100644 index 0000000..6a907fd --- /dev/null +++ b/app/routers/ssl.py @@ -0,0 +1,148 @@ +"""SSL 监控路由:子域名管理与站点证书监控 + +- /api/subdomains:域名资产下的子域名 CRUD +- /api/site-certs:https 站点证书的探测与监控 +写操作(POST/PUT/DELETE)受 API Key 保护。 +""" + +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlmodel import Session + +from app.core.security import require_api_key +from app.database import get_session +from app.schemas.ssl import SiteCertCreate, SubdomainCreate, SubdomainUpdate +from app.services import ssl_service + +router = APIRouter(prefix="/api", tags=["ssl"]) + + +def _guard(exc: ValueError) -> HTTPException: + """service 抛出的 ValueError 统一转 404""" + return HTTPException(status_code=404, detail=str(exc)) + + +# ---------------- 子域名 ---------------- + +@router.get("/subdomains", summary="子域名列表(可按域名资产筛选)") +def list_subdomains( + asset_id: Optional[int] = Query(default=None, description="按域名资产 ID 筛选"), + session: Session = Depends(get_session), +) -> list: + return ssl_service.list_subdomains(session, asset_id) + + +@router.post( + "/subdomains", + status_code=201, + summary="创建子域名", + dependencies=[Depends(require_api_key)], +) +def create_subdomain(data: SubdomainCreate, session: Session = Depends(get_session)) -> dict: + sub = ssl_service.create_subdomain(session, data.model_dump()) + return { + "id": sub.id, + "asset_id": sub.asset_id, + "host": sub.host, + "record_type": sub.record_type, + "record_value": sub.record_value, + "is_active": sub.is_active, + "note": sub.note, + } + + +@router.put( + "/subdomains/{sub_id}", + summary="更新子域名", + dependencies=[Depends(require_api_key)], +) +def update_subdomain(sub_id: int, data: SubdomainUpdate, session: Session = Depends(get_session)) -> dict: + try: + sub = ssl_service.update_subdomain(session, sub_id, data.model_dump(exclude_unset=True)) + except ValueError as e: + raise _guard(e) from e + return { + "id": sub.id, + "asset_id": sub.asset_id, + "host": sub.host, + "record_type": sub.record_type, + "record_value": sub.record_value, + "is_active": sub.is_active, + "note": sub.note, + } + + +@router.delete( + "/subdomains/{sub_id}", + status_code=204, + summary="删除子域名", + dependencies=[Depends(require_api_key)], +) +def delete_subdomain(sub_id: int, session: Session = Depends(get_session)): + try: + ssl_service.delete_subdomain(session, sub_id) + except ValueError as e: + raise _guard(e) from e + + +# ---------------- 站点证书监控 ---------------- + +@router.get("/site-certs", summary="站点证书监控列表(可按状态/资产筛选)") +def list_site_certs( + status: Optional[str] = Query(default=None, description="按状态筛选"), + asset_id: Optional[int] = Query(default=None, description="按关联资产筛选"), + session: Session = Depends(get_session), +) -> list: + return ssl_service.list_site_certs(session, status, asset_id) + + +@router.post( + "/site-certs", + status_code=201, + summary="创建证书探测目标(创建后立即探测一次)", + dependencies=[Depends(require_api_key)], +) +def create_site_cert(data: SiteCertCreate, session: Session = Depends(get_session)) -> dict: + try: + cert = ssl_service.create_site_cert( + session, data.hostname, data.port, data.asset_id + ) + except ValueError as e: + raise _guard(e) from e + return ssl_service._to_dict(cert) + + +@router.post( + "/site-certs/check-all", + summary="全量重新探测所有站点证书", + dependencies=[Depends(require_api_key)], +) +def check_all_site_certs(session: Session = Depends(get_session)) -> dict: + return ssl_service.check_all_site_certs(session) + + +@router.post( + "/site-certs/{cert_id}/check", + summary="重新探测单条站点证书", + dependencies=[Depends(require_api_key)], +) +def check_one_site_cert(cert_id: int, session: Session = Depends(get_session)) -> dict: + cert = session.get(ssl_service.SiteCert, cert_id) + if not cert: + raise HTTPException(status_code=404, detail=f"证书监控记录不存在(id={cert_id})") + ssl_service.check_one(session, cert) + return ssl_service._to_dict(cert) + + +@router.delete( + "/site-certs/{cert_id}", + status_code=204, + summary="删除证书探测目标", + dependencies=[Depends(require_api_key)], +) +def delete_site_cert(cert_id: int, session: Session = Depends(get_session)): + try: + ssl_service.delete_site_cert(session, cert_id) + except ValueError as e: + raise _guard(e) from e diff --git a/app/schemas/agent.py b/app/schemas/agent.py index c79ec27..c05122f 100644 --- a/app/schemas/agent.py +++ b/app/schemas/agent.py @@ -2,6 +2,7 @@ from typing import List, Optional +from pydantic import Field from sqlmodel import SQLModel @@ -40,4 +41,5 @@ class AgentReport(SQLModel): asset_id: int metrics: Optional[MetricsIn] = None server_info: Optional[ServerInfoIn] = None - security: Optional[List[SecurityCheckIn]] = None + # 限制单次上报的检查项数量,防止恶意/异常 Agent 发送超大数组撑爆内存 + security: Optional[List[SecurityCheckIn]] = Field(default=None, max_length=100) diff --git a/app/schemas/provider.py b/app/schemas/provider.py index 651e902..2ada45a 100644 --- a/app/schemas/provider.py +++ b/app/schemas/provider.py @@ -13,6 +13,7 @@ class ProviderBase(SQLModel): name: str name_en: Optional[str] = None category: ProviderCategory + services: Optional[str] = None website: Optional[str] = None console_url: Optional[str] = None sdk_type: Optional[str] = None @@ -31,6 +32,7 @@ class ProviderUpdate(SQLModel): name: Optional[str] = None name_en: Optional[str] = None category: Optional[ProviderCategory] = None + services: Optional[str] = None website: Optional[str] = None console_url: Optional[str] = None sdk_type: Optional[str] = None diff --git a/app/schemas/ssl.py b/app/schemas/ssl.py new file mode 100644 index 0000000..b45395e --- /dev/null +++ b/app/schemas/ssl.py @@ -0,0 +1,28 @@ +"""SSL 相关请求模型""" + +from typing import Optional + +from pydantic import BaseModel, Field + + +class SubdomainCreate(BaseModel): + asset_id: int = Field(description="所属域名资产 ID") + host: str = Field(min_length=1, max_length=63, description="子域名主机名,如 www/api(@ 表示根域名)") + record_type: Optional[str] = Field(default=None, description="DNS 记录类型") + record_value: Optional[str] = Field(default=None, description="DNS 记录值") + is_active: bool = Field(default=True, description="是否启用") + note: Optional[str] = Field(default=None, description="备注") + + +class SubdomainUpdate(BaseModel): + host: Optional[str] = Field(default=None, min_length=1, max_length=63) + record_type: Optional[str] = Field(default=None) + record_value: Optional[str] = Field(default=None) + is_active: Optional[bool] = Field(default=None) + note: Optional[str] = Field(default=None) + + +class SiteCertCreate(BaseModel): + hostname: str = Field(min_length=1, max_length=253, description="探测目标主机名") + port: int = Field(default=443, ge=1, le=65535, description="探测端口") + asset_id: Optional[int] = Field(default=None, description="关联资产 ID(可选)") diff --git a/app/services/asset_service.py b/app/services/asset_service.py index a7b6a21..22028de 100644 --- a/app/services/asset_service.py +++ b/app/services/asset_service.py @@ -3,7 +3,7 @@ 统一处理 Asset 主表与其一对一详情表(VPSDetail/DomainDetail/AIAccount)的联动。 """ -from datetime import date, datetime +from datetime import date, datetime, timedelta, timezone from typing import List, Optional from fastapi import HTTPException, status @@ -30,6 +30,10 @@ from app.schemas.asset import ( VPSDetailRead, ) +import logging + +logger = logging.getLogger("vps-manager.assets") + # 资产类型 -> (AssetCreate/Update 中的字段名, 详情表模型) DETAIL_MAP = { AssetType.VPS: ("vps_detail", VPSDetail), @@ -140,11 +144,49 @@ def _apply_detail_update(asset_type: AssetType, existing, detail_in) -> None: setattr(existing, key, value) +def _get_details_batch(session: Session, assets: list) -> dict: + """批量预加载所有资产的详情和 Provider 名,避免 N+1 查询""" + if not assets: + return {} + asset_ids = [a.id for a in assets] + # 批量查 Provider 名 + provider_ids = {a.provider_id for a in assets if a.provider_id} + provider_map = {} + if provider_ids: + for p in session.exec(select(Provider).where(Provider.id.in_(provider_ids))).all(): + provider_map[p.id] = p.name + # 批量查各类型详情 + detail_map = {} + for asset_type, (_, model) in DETAIL_MAP.items(): + typed_ids = [a.id for a in assets if a.asset_type == asset_type] + if typed_ids: + for d in session.exec(select(model).where(model.asset_id.in_(typed_ids))).all(): + detail_map[d.asset_id] = d + return {"providers": provider_map, "details": detail_map} + + +def _to_read_batch(session: Session, asset: Asset, batch: dict) -> AssetRead: + """用批量预加载的数据组装 AssetRead(避免逐个查询)""" + read = AssetRead.model_validate(asset) + read.days_to_expiry = _days_to_expiry(asset.expiry_date) + read.provider_name = batch["providers"].get(asset.provider_id) + detail = batch["details"].get(asset.id) + if isinstance(detail, VPSDetail): + read.vps_detail = _detail_to_read(detail) + elif isinstance(detail, DomainDetail): + read.domain_detail = _detail_to_read(detail) + elif isinstance(detail, AIAccount): + read.ai_detail = _detail_to_read(detail) + elif isinstance(detail, CloudflareDetail): + read.cloudflare_detail = _detail_to_read(detail) + return read + + # --------------------------------------------------------------------------- # # CRUD # --------------------------------------------------------------------------- # -def create_asset(session: Session, data: AssetCreate) -> AssetRead: - """创建资产及其详情""" +def _create_asset_no_commit(session: Session, data: AssetCreate) -> Asset: + """创建资产及详情(不 commit,由调用方统一提交)""" item = DETAIL_MAP.get(data.asset_type) detail_in = None model = None @@ -162,17 +204,21 @@ def create_asset(session: Session, data: AssetCreate) -> AssetRead: ) asset = Asset(**asset_data) session.add(asset) - session.commit() - session.refresh(asset) + session.flush() # 获取 asset.id,但不提交 - detail = None if model is not None and detail_in is not None: detail = _build_detail(data.asset_type, asset.id, detail_in) session.add(detail) - session.commit() - session.refresh(detail) + return asset - return _to_read(session, asset, detail) + +def create_asset(session: Session, data: AssetCreate) -> AssetRead: + """创建资产及其详情(单次事务提交)""" + asset = _create_asset_no_commit(session, data) + session.commit() + session.refresh(asset) + logger.info("创建资产 id=%s name=%s type=%s", asset.id, asset.name, asset.asset_type) + return _to_read(session, asset, _get_detail(session, asset)) def get_asset(session: Session, asset_id: int) -> AssetRead: @@ -183,8 +229,8 @@ def get_asset(session: Session, asset_id: int) -> AssetRead: return _to_read(session, asset, _get_detail(session, asset)) -def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRead: - """更新资产主表及详情(仅更新传入字段)""" +def _update_asset_no_commit(session: Session, asset_id: int, data: AssetUpdate) -> Asset: + """更新资产主表及详情(不 commit,由调用方统一提交)""" asset = session.get(Asset, asset_id) if not asset: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在") @@ -197,7 +243,6 @@ def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRea session.add(asset) # 详情表:以更新后的 asset_type 为准 - detail = None item = DETAIL_MAP.get(asset.asset_type) if item: field, model = item @@ -209,22 +254,22 @@ def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRea if existing: _apply_detail_update(asset.asset_type, existing, detail_in) session.add(existing) - detail = existing else: detail = _build_detail(asset.asset_type, asset.id, detail_in) session.add(detail) - else: - detail = existing + return asset + +def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRead: + """更新资产主表及详情(单次事务提交)""" + asset = _update_asset_no_commit(session, asset_id, data) session.commit() session.refresh(asset) - if detail is not None: - session.refresh(detail) - return _to_read(session, asset, detail) + return _to_read(session, asset, _get_detail(session, asset)) def delete_asset(session: Session, asset_id: int) -> None: - """删除资产及其详情""" + """删除资产及其详情,并清理 metrics.db 中的关联监控数据(避免孤儿数据)""" asset = session.get(Asset, asset_id) if not asset: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在") @@ -233,46 +278,72 @@ def delete_asset(session: Session, asset_id: int) -> None: session.delete(detail) session.delete(asset) session.commit() + logger.info("删除资产 id=%s name=%s", asset_id, asset.name) + _cleanup_metrics_for_asset(asset_id) + + +def _cleanup_metrics_for_asset(asset_id: int) -> None: + """清理 metrics.db 中该资产的 MetricPoint/ServerInfo/SecurityCheck/EventLog""" + from app.database import metrics_engine + from app.models.monitor import EventLog, MetricPoint, SecurityCheck, ServerInfo + + with Session(metrics_engine) as ms: + for model in (MetricPoint, ServerInfo, SecurityCheck, EventLog): + for row in ms.exec(select(model).where(model.asset_id == asset_id)).all(): + ms.delete(row) + ms.commit() def export_assets(session: Session) -> dict: """导出所有资产(含 detail)为 JSON,用于数据迁移/备份""" assets = session.exec(select(Asset)).all() + batch = _get_details_batch(session, assets) result = [] for asset in assets: - read = _to_read(session, asset, _get_detail(session, asset)) + read = _to_read_batch(session, asset, batch) result.append(read.model_dump(mode="json")) return { "count": len(result), - "exported_at": datetime.utcnow().isoformat(), + "exported_at": datetime.now(timezone.utc).isoformat(), "assets": result, } def import_assets(session: Session, assets_data: list) -> dict: - """从导出数据导入资产(按 name+asset_type+provider 去重:存在则更新,不存在则创建)""" + """从导出数据导入资产(按 name+asset_type+provider 去重:存在则更新,不存在则创建) + + 整个导入在单个事务中完成:全部成功才提交,任何一条失败则回滚,避免部分导入。 + """ created = 0 updated = 0 errors = [] skip_fields = {"id", "created_at", "updated_at", "days_to_expiry", "provider_name"} - for item in assets_data: - try: - payload = {k: v for k, v in item.items() if k not in skip_fields} - existing = session.exec( - select(Asset).where( - Asset.name == payload.get("name"), - Asset.asset_type == payload.get("asset_type"), - Asset.provider == payload.get("provider"), - ) - ).first() - if existing: - update_asset(session, existing.id, AssetUpdate(**payload)) - updated += 1 - else: - create_asset(session, AssetCreate(**payload)) - created += 1 - except Exception as e: # noqa: BLE001 - errors.append(f"{item.get('name', '?')}: {e}") + try: + for item in assets_data: + try: + payload = {k: v for k, v in item.items() if k not in skip_fields} + existing = session.exec( + select(Asset).where( + Asset.name == payload.get("name"), + Asset.asset_type == payload.get("asset_type"), + Asset.provider == payload.get("provider"), + ) + ).first() + if existing: + _update_asset_no_commit(session, existing.id, AssetUpdate(**payload)) + updated += 1 + else: + _create_asset_no_commit(session, AssetCreate(**payload)) + created += 1 + except Exception as e: # noqa: BLE001 + errors.append(f"{item.get('name', '?')}: {e}") + if errors: + session.rollback() + return {"created": 0, "updated": 0, "errors": errors} + session.commit() + except Exception: # noqa: BLE001 + session.rollback() + raise return {"created": created, "updated": updated, "errors": errors} @@ -301,15 +372,20 @@ def list_assets( stmt = stmt.order_by(sort_col.desc() if order == "desc" else sort_col.asc()) assets = session.exec(stmt).all() - return [_to_read(session, a, _get_detail(session, a)) for a in assets] + batch = _get_details_batch(session, assets) + return [_to_read_batch(session, a, batch) for a in assets] # --------------------------------------------------------------------------- # # 统计 # --------------------------------------------------------------------------- # def get_overview(session: Session) -> dict: - """资产总览:数量分布、异常数、到期预警、支出合计""" - assets = session.exec(select(Asset)).all() + """资产总览:数量分布、异常数、到期预警、支出合计 + + 性能优化:仅查询统计所需的列,避免加载完整 Asset 实体。 + """ + stmt = select(Asset.asset_type, Asset.status, Asset.expiry_date, Asset.cost) + rows = session.exec(stmt).all() today = date.today() by_type: dict = {} @@ -319,22 +395,24 @@ def get_overview(session: Session) -> dict: year_cost = 0.0 month_cost = 0.0 - for a in assets: - by_type[a.asset_type.value] = by_type.get(a.asset_type.value, 0) + 1 - by_status[a.status.value] = by_status.get(a.status.value, 0) + 1 - if a.status in (AssetStatus.STOPPED, AssetStatus.EXPIRED): + for asset_type, status_val, expiry_date, cost in rows: + tval = asset_type.value if hasattr(asset_type, "value") else asset_type + sval = status_val.value if hasattr(status_val, "value") else status_val + by_type[tval] = by_type.get(tval, 0) + 1 + by_status[sval] = by_status.get(sval, 0) + 1 + if status_val in (AssetStatus.STOPPED, AssetStatus.EXPIRED): abnormal += 1 - if a.expiry_date: - days = (a.expiry_date - today).days + if expiry_date: + days = (expiry_date - today).days if 0 <= days <= 30: expiring_30 += 1 - if a.expiry_date.year == today.year: - year_cost += a.cost - if a.expiry_date.month == today.month: - month_cost += a.cost + if expiry_date.year == today.year: + year_cost += cost + if expiry_date.month == today.month: + month_cost += cost return { - "total": len(assets), + "total": len(rows), "by_type": by_type, "by_status": by_status, "abnormal_count": abnormal, @@ -345,13 +423,20 @@ def get_overview(session: Session) -> dict: def get_expiring(session: Session, days: int = 30) -> List[AssetRead]: - """N 天内到期资产列表(按剩余天数升序)""" + """N 天内到期资产列表(按剩余天数升序) + + 性能优化:过滤条件下推到 SQL 层,仅查询 [today, today+days] 区间内的资产。 + """ today = date.today() - assets = session.exec(select(Asset).where(Asset.expiry_date.is_not(None))).all() - result = [] - for a in assets: - delta = (a.expiry_date - today).days - if 0 <= delta <= days: - result.append(_to_read(session, a, _get_detail(session, a))) + deadline = today + timedelta(days=days) + assets = session.exec( + select(Asset).where( + Asset.expiry_date.is_not(None), + Asset.expiry_date >= today, + Asset.expiry_date <= deadline, + ) + ).all() + batch = _get_details_batch(session, assets) + result = [_to_read_batch(session, a, batch) for a in assets] result.sort(key=lambda x: x.days_to_expiry if x.days_to_expiry is not None else 10**9) return result diff --git a/app/services/cleanup_service.py b/app/services/cleanup_service.py new file mode 100644 index 0000000..78abf6c --- /dev/null +++ b/app/services/cleanup_service.py @@ -0,0 +1,98 @@ +"""监控数据自动清理服务 + +按配置的保留周期清理 metrics.db 中的历史数据,防止 SQLite 无限膨胀: +- MetricPoint: 高频时序数据,默认保留 30 天 +- SecurityCheck: 安全检查历史,默认保留 90 天 +- EventLog: 事件日志,默认保留 180 天 + +提供两种触发方式: +1. 应用启动后由 asyncio 后台任务按 CLEANUP_INTERVAL_HOURS 周期执行 +2. 通过 API 手动触发(POST /api/monitor/cleanup) +""" + +import asyncio +import logging +from datetime import timedelta + +from sqlmodel import Session, delete, select + +from app.core.config import settings +from app.core.timeutils import utcnow +from app.database import metrics_engine +from app.models.monitor import EventLog, MetricPoint, SecurityCheck + +logger = logging.getLogger("vps-manager.cleanup") + +# 单批删除行数上限:避免大表单条 DELETE 长时间持有写锁,阻塞 Agent 上报 +_BATCH_SIZE = 1000 + + +def _delete_in_batches(session: Session, model, cutoff, batch_size: int = _BATCH_SIZE) -> int: + """分批删除指定模型的过期数据(ts < cutoff),返回总删除行数 + + SQLite 不支持 DELETE ... LIMIT,用子查询 SELECT id ... LIMIT 实现分批。 + """ + total = 0 + while True: + subq = select(model.id).where(model.ts < cutoff).limit(batch_size) + ids = [row[0] if isinstance(row, tuple) else row for row in session.exec(subq).all()] + if not ids: + break + session.exec(delete(model).where(model.id.in_(ids))) + session.commit() # 每批独立提交,缩短写锁持有时间 + total += len(ids) + if len(ids) < batch_size: + break + return total + + +def cleanup_metrics( + metrics_days: int | None = None, + security_days: int | None = None, + event_log_days: int | None = None, +) -> dict: + """按保留天数清理过期监控数据,返回各类删除条数""" + metrics_days = metrics_days if metrics_days is not None else settings.METRICS_RETENTION_DAYS + security_days = security_days if security_days is not None else settings.SECURITY_RETENTION_DAYS + event_log_days = event_log_days if event_log_days is not None else settings.EVENT_LOG_RETENTION_DAYS + + now = utcnow() + result = {"metric_points": 0, "security_checks": 0, "event_logs": 0} + + with Session(metrics_engine) as session: + if metrics_days > 0: + cutoff = now - timedelta(days=metrics_days) + result["metric_points"] = _delete_in_batches(session, MetricPoint, cutoff) + + if security_days > 0: + cutoff = now - timedelta(days=security_days) + result["security_checks"] = _delete_in_batches(session, SecurityCheck, cutoff) + + if event_log_days > 0: + cutoff = now - timedelta(days=event_log_days) + result["event_logs"] = _delete_in_batches(session, EventLog, cutoff) + + logger.info( + "监控数据清理完成:metric_points=%s, security_checks=%s, event_logs=%s", + result["metric_points"], + result["security_checks"], + result["event_logs"], + ) + return result + + +async def cleanup_loop() -> None: + """后台周期清理任务(CLEANUP_INTERVAL_HOURS=0 时不启动)""" + interval_hours = settings.CLEANUP_INTERVAL_HOURS + if interval_hours <= 0: + logger.info("监控数据自动清理已禁用(CLEANUP_INTERVAL_HOURS=0)") + return + interval_sec = interval_hours * 3600 + logger.info("监控数据自动清理已启动,间隔 %s 小时", interval_hours) + while True: + try: + # 在线程池执行同步 DB 操作,避免阻塞事件循环 + await asyncio.to_thread(cleanup_metrics) + except Exception: # noqa: BLE001 + logger.exception("监控数据自动清理执行失败") + await asyncio.sleep(interval_sec) diff --git a/app/services/provider_service.py b/app/services/provider_service.py index 76494d2..79ee4e5 100644 --- a/app/services/provider_service.py +++ b/app/services/provider_service.py @@ -6,6 +6,7 @@ from fastapi import HTTPException, status from sqlmodel import Session, select from app.core import crypto +from app.models.asset import Asset from app.models.provider import Provider, ProviderCategory from app.schemas.provider import ProviderCreate, ProviderRead, ProviderUpdate @@ -73,6 +74,15 @@ def delete_provider(session: Session, provider_id: int) -> None: provider = session.get(Provider, provider_id) if not provider: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="平台不存在") + # 数据完整性:存在关联资产时禁止删除,避免 provider_id 悬空引用 + linked = session.exec( + select(Asset.id).where(Asset.provider_id == provider_id).limit(1) + ).first() + if linked is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="该平台下仍有关联资产,请先迁移或删除相关资产", + ) session.delete(provider) session.commit() diff --git a/app/services/renewal_service.py b/app/services/renewal_service.py index 5381a99..0c6c9e5 100644 --- a/app/services/renewal_service.py +++ b/app/services/renewal_service.py @@ -3,7 +3,7 @@ 检查即将到期的资产,生成提醒消息并通过通知渠道发送。 """ -from datetime import date +from datetime import date, timedelta from sqlmodel import Session, select @@ -16,16 +16,21 @@ _SKIP_STATUS = {AssetStatus.CANCELLED} def get_expiring_assets(session: Session, threshold_days: int) -> list: - """返回 threshold_days 天内到期的资产列表 [(asset, days), ...],按剩余天数升序""" + """返回 threshold_days 天内到期的资产列表 [(asset, days), ...],按剩余天数升序 + + 性能优化:日期范围与状态过滤均下推到 SQL 层。 + """ today = date.today() - assets = session.exec(select(Asset).where(Asset.expiry_date.is_not(None))).all() - expiring = [] - for asset in assets: - if asset.status in _SKIP_STATUS: - continue - days = (asset.expiry_date - today).days - if 0 <= days <= threshold_days: - expiring.append((asset, days)) + deadline = today + timedelta(days=threshold_days) + assets = session.exec( + select(Asset).where( + Asset.expiry_date.is_not(None), + Asset.expiry_date >= today, + Asset.expiry_date <= deadline, + Asset.status.notin_(_SKIP_STATUS), + ) + ).all() + expiring = [(asset, (asset.expiry_date - today).days) for asset in assets] expiring.sort(key=lambda x: x[1]) return expiring diff --git a/app/services/security_service.py b/app/services/security_service.py index ea40d45..a0587ea 100644 --- a/app/services/security_service.py +++ b/app/services/security_service.py @@ -4,6 +4,7 @@ 评分按检查项加权:pass 满分、warn 半分、fail/unknown 零分。 """ +from sqlalchemy import func from sqlmodel import Session, select from app.models.monitor import SecurityCheck @@ -70,8 +71,42 @@ def get_asset_security(session: Session, asset_id: int) -> dict: def get_security_overview(session: Session) -> list: - """所有有安全检查数据的服务器评分总览(按评分升序,风险高的在前)""" - asset_ids = session.exec(select(SecurityCheck.asset_id).distinct()).all() - result = [get_asset_security(session, aid) for aid in asset_ids] + """所有有安全检查数据的服务器评分总览(按评分升序,风险高的在前) + + 性能优化:用子查询取每个 (asset_id, check_item) 的最新 ts,仅拉取最新记录, + 避免全表扫描(历史数据量大时内存可控)。 + """ + latest_ts = ( + select( + SecurityCheck.asset_id, + SecurityCheck.check_item, + func.max(SecurityCheck.ts).label("max_ts"), + ) + .group_by(SecurityCheck.asset_id, SecurityCheck.check_item) + .subquery() + ) + stmt = select(SecurityCheck).join( + latest_ts, + (SecurityCheck.asset_id == latest_ts.c.asset_id) + & (SecurityCheck.check_item == latest_ts.c.check_item) + & (SecurityCheck.ts == latest_ts.c.max_ts), + ) + all_checks = session.exec(stmt).all() + + # 按 asset_id 分组 + latest_by_asset: dict = {} + for check in all_checks: + latest_by_asset.setdefault(check.asset_id, []).append(check) + + result = [] + for asset_id, checks in latest_by_asset.items(): + score = compute_security_score(checks) + result.append({ + "asset_id": asset_id, + "score": score, + "level": score_level(score), + "checks_count": len(checks), + "suggestions": [c.suggestion for c in checks if c.suggestion], + }) result.sort(key=lambda x: (x["score"] is None, x["score"] if x["score"] is not None else 0)) return result diff --git a/app/services/ssl_service.py b/app/services/ssl_service.py new file mode 100644 index 0000000..e4697da --- /dev/null +++ b/app/services/ssl_service.py @@ -0,0 +1,258 @@ +"""SSL 监控服务:站点证书探测 + 子域名管理 + +- probe_site_cert: 通过 ssl socket 探测站点证书信息(不校验证书链,仅读取到期信息) +- create/check/check_all: 站点证书的创建与刷新 +- 子域名 CRUD:list/create/update/delete +""" + +import socket +import ssl +from datetime import date, datetime +from typing import Optional + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes +from sqlmodel import Session, select + +from app.core.timeutils import utcnow +from app.models.asset import Asset +from app.models.ssl import SiteCert, Subdomain + +PROBE_TIMEOUT = 8 # 探测连接超时(秒) +EXPIRING_THRESHOLD = 30 # 到期提醒阈值(天) + + +def days_until(d: Optional[date]) -> Optional[int]: + """计算距今天数(负数为已过期)""" + if d is None: + return None + return (d - date.today()).days + + +def _judge_status(days: Optional[int]) -> str: + """按剩余天数定级:expired / expiring / valid""" + if days is None: + return "unknown" + if days < 0: + return "expired" + if days <= EXPIRING_THRESHOLD: + return "expiring" + return "valid" + + +def probe_site_cert(hostname: str, port: int = 443) -> dict: + """探测目标站点证书信息(探测失败时抛异常) + + 返回字段:subject_cn / issuer / valid_from / valid_to / fingerprint / san_list + """ + ctx = ssl.create_default_context() + # 不校验证书链:即使证书已过期/自签名也能读到到期信息 + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + with socket.create_connection((hostname, port), timeout=PROBE_TIMEOUT) as sock: + with ctx.wrap_socket(sock, server_hostname=hostname) as ssock: + der = ssock.getpeercert(binary_form=True) + cert = x509.load_der_x509_certificate(der) + try: + san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName) + san_list = san.value.get_values_for_type(x509.DNSName) + except x509.ExtensionNotFound: + san_list = [] + return { + "subject_cn": cert.subject.rfc4514_string() or None, + "issuer": cert.issuer.rfc4514_string() or None, + "valid_from": cert.not_valid_before_utc.date(), + "valid_to": cert.not_valid_after_utc.date(), + "fingerprint": cert.fingerprint(hashes.SHA256()).hex(), + "san_list": san_list, + } + + +def _to_dict(cert: SiteCert, asset_name: Optional[str] = None) -> dict: + """SiteCert 模型转 API 返回结构(含动态计算的剩余天数)""" + days = days_until(cert.valid_to) + return { + "id": cert.id, + "hostname": cert.hostname, + "port": cert.port, + "asset_id": cert.asset_id, + "asset_name": asset_name, + "issuer": cert.issuer, + "subject_cn": cert.subject_cn, + "valid_from": cert.valid_from.isoformat() if cert.valid_from else None, + "valid_to": cert.valid_to.isoformat() if cert.valid_to else None, + "days_to_expiry": days, + "status": cert.status, + "error": cert.error, + "last_checked_at": cert.last_checked_at.isoformat() if cert.last_checked_at else None, + } + + +# ---------------- 子域名 CRUD ---------------- + +def list_subdomains(session: Session, asset_id: Optional[int] = None) -> list: + stmt = select(Subdomain).order_by(Subdomain.host) + if asset_id is not None: + stmt = stmt.where(Subdomain.asset_id == asset_id) + subs = session.exec(stmt).all() + # 带上所属域名,便于前端展示全名 + assets = session.exec(select(Asset).where(Asset.id.in_({s.asset_id for s in subs}))).all() + name_map = {a.id: a.name for a in assets} + return [ + { + "id": s.id, + "asset_id": s.asset_id, + "asset_name": name_map.get(s.asset_id), + "host": s.host, + "record_type": s.record_type, + "record_value": s.record_value, + "is_active": s.is_active, + "note": s.note, + "created_at": s.created_at.isoformat() if s.created_at else None, + } + for s in subs + ] + + +def create_subdomain(session: Session, data: dict) -> Subdomain: + sub = Subdomain( + asset_id=data["asset_id"], + host=data["host"].strip().lower(), + record_type=data.get("record_type"), + record_value=data.get("record_value"), + is_active=data.get("is_active", True), + note=data.get("note"), + ) + session.add(sub) + session.commit() + session.refresh(sub) + return sub + + +def update_subdomain(session: Session, sub_id: int, data: dict) -> Subdomain: + sub = session.get(Subdomain, sub_id) + if not sub: + raise ValueError(f"子域名记录不存在(id={sub_id})") + if "host" in data and data["host"]: + sub.host = data["host"].strip().lower() + for key in ("record_type", "record_value", "note"): + if key in data: + setattr(sub, key, data[key]) + if "is_active" in data: + sub.is_active = bool(data["is_active"]) + session.add(sub) + session.commit() + session.refresh(sub) + return sub + + +def delete_subdomain(session: Session, sub_id: int) -> None: + sub = session.get(Subdomain, sub_id) + if not sub: + raise ValueError(f"子域名记录不存在(id={sub_id})") + session.delete(sub) + session.commit() + + +# ---------------- 站点证书监控 ---------------- + +def _apply_probe(cert: SiteCert, info: dict) -> SiteCert: + """把探测结果写入模型并定级""" + cert.subject_cn = info["subject_cn"] + cert.issuer = info["issuer"] + cert.valid_from = info["valid_from"] + cert.valid_to = info["valid_to"] + cert.fingerprint = info["fingerprint"] + cert.error = None + cert.status = _judge_status(days_until(info["valid_to"])) + cert.last_checked_at = utcnow() + return cert + + +def check_one(session: Session, cert: SiteCert) -> SiteCert: + """重新探测单条证书记录(失败则标记 error,保留旧到期信息)""" + try: + info = probe_site_cert(cert.hostname, cert.port) + _apply_probe(cert, info) + except Exception as e: # noqa: BLE001 + cert.status = "error" + cert.error = str(e)[:200] + cert.last_checked_at = utcnow() + session.add(cert) + session.commit() + session.refresh(cert) + return cert + + +def create_site_cert(session: Session, hostname: str, port: int = 443, asset_id: Optional[int] = None) -> SiteCert: + """创建探测目标并立即探测一次;hostname+port 已存在则复用并刷新""" + hostname = hostname.strip().lower() + existing = session.exec( + select(SiteCert).where(SiteCert.hostname == hostname, SiteCert.port == port) + ).first() + if existing: + if asset_id is not None: + existing.asset_id = asset_id + return check_one(session, existing) + cert = SiteCert(hostname=hostname, port=port, asset_id=asset_id) + session.add(cert) + session.commit() + session.refresh(cert) + return check_one(session, cert) + + +def list_site_certs(session: Session, status: Optional[str] = None, asset_id: Optional[int] = None) -> list: + stmt = select(SiteCert) + if status: + stmt = stmt.where(SiteCert.status == status) + if asset_id is not None: + stmt = stmt.where(SiteCert.asset_id == asset_id) + certs = session.exec(stmt.order_by(SiteCert.id)).all() + ids = {c.asset_id for c in certs if c.asset_id} + assets = session.exec(select(Asset).where(Asset.id.in_(ids))).all() if ids else [] + name_map = {a.id: a.name for a in assets} + return [_to_dict(c, name_map.get(c.asset_id)) for c in certs] + + +def delete_site_cert(session: Session, cert_id: int) -> None: + cert = session.get(SiteCert, cert_id) + if not cert: + raise ValueError(f"证书监控记录不存在(id={cert_id})") + session.delete(cert) + session.commit() + + +def check_all_site_certs(session: Session) -> dict: + """全量刷新所有站点证书,返回统计与异常清单""" + certs = session.exec(select(SiteCert)).all() + stats = {"total": len(certs), "ok": 0, "error": 0, "expiring": 0, "expired": 0} + problems = [] + for cert in certs: + check_one(session, cert) + if cert.status == "error": + stats["error"] += 1 + problems.append({"hostname": cert.hostname, "detail": cert.error}) + elif cert.status == "expired": + stats["expired"] += 1 + problems.append( + {"hostname": cert.hostname, "detail": f"证书已过期 {abs(days_until(cert.valid_to))} 天"} + ) + elif cert.status == "expiring": + stats["expiring"] += 1 + problems.append( + {"hostname": cert.hostname, "detail": f"{days_until(cert.valid_to)} 天后到期"} + ) + else: + stats["ok"] += 1 + return {"stats": stats, "problems": problems} + + +def build_cert_message(cert: SiteCert) -> str: + """生成单条证书提醒文案""" + days = days_until(cert.valid_to) + if days is None: + return f"- {cert.hostname}:证书信息未知" + if days < 0: + return f"- ⚠️ {cert.hostname}:证书已过期 {abs(days)} 天" + return f"- {cert.hostname}:{days} 天后到期({cert.valid_to})" diff --git a/app/services/sync_service.py b/app/services/sync_service.py index 2c2593b..11e6402 100644 --- a/app/services/sync_service.py +++ b/app/services/sync_service.py @@ -6,15 +6,18 @@ """ import json -from datetime import datetime +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 from app.core import crypto +from app.core.timeutils import utcnow from app.models.asset import ( AIAccount, Asset, @@ -130,8 +133,7 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter) -> dic currency=vps.currency, ) session.add(asset) - session.commit() - session.refresh(asset) + session.flush() # 获取 asset.id,统一在循环外提交 session.add( VPSDetail( asset_id=asset.id, @@ -176,8 +178,7 @@ def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter) -> expiry_date=dom.expiry_date, ) session.add(asset) - session.commit() - session.refresh(asset) + session.flush() # 获取 asset.id,统一在循环外提交 session.add( DomainDetail( asset_id=asset.id, @@ -219,10 +220,11 @@ def sync_provider(session: Session, provider_id: int) -> dict: except Exception as e: # noqa: BLE001 result["account_error"] = str(e) - provider.last_synced_at = datetime.utcnow() + provider.last_synced_at = utcnow() session.add(provider) session.commit() result["last_synced_at"] = provider.last_synced_at.isoformat() + logger.info("同步平台 provider=%s result=%s", provider.slug, result) return result @@ -262,7 +264,7 @@ def refresh_ai_balance(session: Session, asset_id: int) -> dict: detail=f"无法确定 AI 适配器({ai.provider})", ) - api_key = crypto.decrypt(ai.api_key_encrypted) or ai.api_key + api_key = crypto.decrypt(ai.api_key_encrypted) if not api_key: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="未配置 API Key" @@ -286,7 +288,7 @@ def refresh_ai_balance(session: Session, asset_id: int) -> dict: if acc.balance is not None: ai.balance = acc.balance ai.currency = acc.currency - ai.last_synced_at = datetime.utcnow() + ai.last_synced_at = utcnow() session.add(ai) session.commit() session.refresh(ai) @@ -320,5 +322,5 @@ def sync_all_ai_balances(session: Session) -> dict: "success": success, "failed": failed, "errors": errors, - "synced_at": datetime.utcnow().isoformat(), + "synced_at": utcnow().isoformat(), } diff --git a/app/templates/index.html b/app/templates/index.html index 75e4635..2043e6b 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -4,16 +4,16 @@ {{ app_name }} - + - + - + - - + +