Files
vps-manager/agent/collector.py
T
gouki f09c0f6e4b fix(review): 处理全量审查剩余项(M4-M8/M11/M12 + 21 条 low)
后端
- 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 零报错
2026-09-23 10:15:52 +00:00

197 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""VPS Agent 采集模块
采集本机资源、服务器信息与基础安全状态。
依赖 psutil + 少量系统命令(ufw/iptables/sshd_config)。
"""
import glob
import platform
import socket
import subprocess
import time
import psutil
def collect_metrics() -> dict:
"""资源使用率(CPU/内存/磁盘/网络/负载/运行时长)"""
cpu = psutil.cpu_percent(interval=1)
mem = psutil.virtual_memory()
disk = psutil.disk_usage("/")
net = psutil.net_io_counters()
try:
load_1m = psutil.getloadavg()[0]
except (AttributeError, OSError):
load_1m = None
return {
"cpu_pct": round(cpu, 1),
"mem_pct": round(mem.percent, 1),
"disk_pct": round(disk.percent, 1),
"net_in_mb": round(net.bytes_recv / 1024 / 1024, 2),
"net_out_mb": round(net.bytes_sent / 1024 / 1024, 2),
"load_1m": round(load_1m, 2) if load_1m is not None else None,
"uptime_sec": int(time.time() - psutil.boot_time()),
}
def _get_ip() -> str:
"""获取本机出口 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))
return s.getsockname()[0]
except Exception:
return ""
finally:
if s is not None:
try:
s.close()
except Exception:
pass
def collect_server_info() -> dict:
"""服务器基础信息"""
os_name = platform.system()
try:
with open("/etc/os-release") as f:
for line in f:
if line.startswith("PRETTY_NAME="):
os_name = line.split("=", 1)[1].strip().strip('"')
break
except Exception:
pass
return {
"hostname": socket.gethostname(),
"os": os_name,
"kernel": platform.release(),
"cpu_cores": psutil.cpu_count(),
"mem_total_gb": round(psutil.virtual_memory().total / 1024**3, 1),
"disk_total_gb": round(psutil.disk_usage("/").total / 1024**3, 1),
"public_ip": _get_ip(),
"status": "online",
}
SSHD_CONFIG = "/etc/ssh/sshd_config"
_SSHD_KEYS = ("permitrootlogin", "passwordauthentication")
# sshd 编译默认值:关键字未显式配置时实际生效的就是这些
_SSHD_DEFAULTS = {"permitrootlogin": "prohibit-password", "passwordauthentication": "yes"}
def _parse_sshd_config(path: str, values: dict, seen: set, depth: int = 0) -> bool:
"""按 sshd 语义解析配置:Include 就地展开、同一关键字「首次出现的值生效」
返回是否成功打开该文件。Debian/Ubuntu 云镜像通常把加固项放在
/etc/ssh/sshd_config.d/*.conf,而主配置顶部就有 Include;只读主文件会漏掉
这些值,把已加固的机器误报成 warn,或反过来漏报。
"""
real = path if path.startswith("/") else f"/etc/ssh/{path}"
if depth > 3 or real in seen:
return False
seen.add(real)
try:
with open(real) as f:
lines = f.readlines()
except OSError:
return False
for line in lines:
low = line.strip().lower()
if not low or low.startswith("#"):
continue
parts = low.split()
if parts[0] == "include" and len(parts) > 1:
# Include 的相对路径由 sshd 按 /etc/ssh 解析,不能交给 glob 按 CWD 展开
pattern = parts[1] if parts[1].startswith("/") else f"/etc/ssh/{parts[1]}"
for inc in sorted(glob.glob(pattern)):
_parse_sshd_config(inc, values, seen, depth + 1)
elif parts[0] in _SSHD_KEYS and len(parts) > 1:
values.setdefault(parts[0], parts[1]) # 首次出现生效
return True
def _check_ssh_config() -> dict:
"""检查 SSH 配置(root 登录 / 密码登录),含 sshd_config.d 与默认值判定"""
values: dict = {}
if not _parse_sshd_config(SSHD_CONFIG, values, set()):
return {"check_item": "ssh_config", "status": "unknown",
"detail": "无法读取 sshd_config", "suggestion": None}
permit_root = values.get("permitrootlogin", _SSHD_DEFAULTS["permitrootlogin"])
password_auth = values.get("passwordauthentication", _SSHD_DEFAULTS["passwordauthentication"])
# 未显式配置的关键字按 sshd 默认值判定,不能因为「没写」就当作已通过
# (PasswordAuthentication 默认就是 yes,这类机器实际是开着密码登录的)
implicit = [k for k in _SSHD_KEYS if k not in values]
status = "pass"
suggestions = []
if permit_root == "yes":
status = "warn"
suggestions.append("设置 PermitRootLogin no 或 prohibit-password")
if password_auth == "yes":
status = "warn"
suggestions.append("关闭密码登录 PasswordAuthentication no,改用密钥")
detail = f"PermitRootLogin={permit_root}, PasswordAuthentication={password_auth}"
if implicit:
detail += f"({'/'.join(implicit)} 未显式配置,取 sshd 默认值)"
return {
"check_item": "ssh_config",
"status": status,
"detail": detail,
"suggestion": ";".join(suggestions) if suggestions else None,
}
def _check_firewall() -> dict:
"""检查防火墙状态"""
try:
out = subprocess.run(["ufw", "status"], capture_output=True, text=True, timeout=5)
if "Status: active" in out.stdout:
return {"check_item": "firewall", "status": "pass",
"detail": "ufw 已启用", "suggestion": None}
return {"check_item": "firewall", "status": "warn",
"detail": "ufw 未启用", "suggestion": "启用防火墙 ufw enable"}
except Exception:
try:
# 用 -S 而不是 -L:-L 即使零规则也会输出链头(Chain INPUT ... + 表头),
# 拿 stdout 非空当「有规则」会永远判 pass
out = subprocess.run(["iptables", "-S"], capture_output=True, text=True, timeout=5)
lines = [ln.strip() for ln in out.stdout.splitlines() if ln.strip()]
rules = [ln for ln in lines if ln.startswith("-A")]
policy = next(
(ln.split()[-1] for ln in lines if ln.startswith("-P INPUT")), "ACCEPT"
)
if rules or policy.upper() == "DROP":
return {"check_item": "firewall", "status": "pass",
"detail": f"iptables {len(rules)} 条规则,INPUT 策略 {policy}",
"suggestion": None}
return {"check_item": "firewall", "status": "warn",
"detail": f"iptables 无过滤规则(INPUT 策略 {policy})",
"suggestion": "配置防火墙规则,或启用 ufw/nftables"}
except Exception:
pass
return {"check_item": "firewall", "status": "unknown",
"detail": "未检测到 ufw/iptables", "suggestion": None}
def _check_listening_ports() -> dict:
"""统计监听端口"""
try:
conns = psutil.net_connections(kind="inet")
listening = sorted({c.laddr.port for c in conns if c.status == "LISTEN"})
detail = f"开放端口 {len(listening)} 个: {','.join(map(str, listening[:15]))}"
status = "pass" if len(listening) < 10 else "warn"
suggestion = "端口偏多,建议关闭非必要端口" if status == "warn" else None
return {"check_item": "listening_ports", "status": status,
"detail": detail, "suggestion": suggestion}
except Exception as e: # noqa: BLE001
return {"check_item": "listening_ports", "status": "unknown",
"detail": str(e), "suggestion": None}
def collect_security() -> list:
"""基础安全检查项"""
return [_check_ssh_config(), _check_firewall(), _check_listening_ports()]