后端
- 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 零报错
197 lines
7.8 KiB
Python
197 lines
7.8 KiB
Python
"""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()]
|