release: nexus btpanel session fix and app-v2

This commit is contained in:
Codex Release Bot
2026-07-08 22:31:31 +08:00
commit 1933f0af6e
2457 changed files with 350105 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
"""Shared Agent install/upgrade paths and shell fragments (aligned with web/agent/install.sh)."""
from __future__ import annotations
from server.application.server_batch_common import agent_files_curl_cmds, sudo_wrap
# Must match web/agent/install.sh
AGENT_INSTALL_DIR = "/opt/nexus-agent"
AGENT_PIP_PACKAGES = (
"fastapi==0.115.6",
"uvicorn==0.34.0",
"httpx==0.28.1",
"psutil",
"python-multipart==0.0.19",
)
def agent_venv_python(install_dir: str = AGENT_INSTALL_DIR) -> str:
return f"{install_dir}/.venv/bin/python"
def agent_pip_install_cmd(venv_python: str | None = None) -> str:
py = venv_python or agent_venv_python()
pkgs = " ".join(AGENT_PIP_PACKAGES)
return f"{py} -m pip install -q {pkgs}"
def agent_python_version_check_cmd(venv_python: str | None = None) -> str:
py = venv_python or agent_venv_python()
return (
f"{py} -c '"
"import sys; v=sys.version_info; "
"print(f\"py_ok_{v.major}.{v.minor}.{v.micro}\") "
"if v.major==3 and v.minor>=10 else sys.exit(1)'"
)
def agent_rsync_install_cmd() -> str:
return (
"(command -v rsync >/dev/null 2>&1 || "
"(apt-get update -qq && apt-get install -y -qq rsync) || "
"(yum install -y -q rsync) || (dnf install -y -q rsync))"
)
def agent_kill_port_cmd(agent_port: int) -> str:
return f"fuser -k {int(agent_port)}/tcp 2>/dev/null || true; "
def build_agent_upgrade_cmd(
*,
base_url: str,
ssh_user: str,
install_dir: str = AGENT_INSTALL_DIR,
agent_port: int = 8601,
) -> str:
"""Shell pipeline: rsync → py check → pip → backup → download → restart → verify."""
venv_python = agent_venv_python(install_dir)
systemctl_prefix = "sudo " if ssh_user != "root" else ""
return sudo_wrap(
f"{agent_rsync_install_cmd()} "
f"&& {agent_python_version_check_cmd(venv_python)} "
f"&& {agent_pip_install_cmd(venv_python)} "
f"&& cp {install_dir}/agent.py {install_dir}/agent.py.bak "
f"&& {agent_files_curl_cmds(base_url, install_dir)} "
f"&& {agent_kill_port_cmd(agent_port)}{systemctl_prefix}systemctl restart nexus-agent "
f"&& sleep 2 "
f"&& {systemctl_prefix}systemctl is-active nexus-agent "
f"&& echo 'upgrade_ok'",
ssh_user,
)
def build_agent_upgrade_rollback_cmd(
*,
ssh_user: str,
install_dir: str = AGENT_INSTALL_DIR,
) -> str:
systemctl_prefix = "sudo " if ssh_user != "root" else ""
return sudo_wrap(
f"cp {install_dir}/agent.py.bak {install_dir}/agent.py "
f"&& {systemctl_prefix}systemctl restart nexus-agent",
ssh_user,
)
+124
View File
@@ -0,0 +1,124 @@
"""Central Nexus Agent version (SSOT) and semver-style comparison."""
from __future__ import annotations
import re
from functools import lru_cache
from pathlib import Path
_AGENT_PY = Path(__file__).resolve().parents[2] / "web" / "agent" / "agent.py"
_AGENT_VERSION_CONST_RE = re.compile(r'^AGENT_VERSION\s*=\s*"([^"]+)"', re.MULTILINE)
@lru_cache(maxsize=1)
def get_central_agent_version() -> str:
"""Read AGENT_VERSION constant from web/agent/agent.py (heartbeat SSOT)."""
text = _AGENT_PY.read_text(encoding="utf-8")
match = _AGENT_VERSION_CONST_RE.search(text)
if not match:
raise RuntimeError(f"AGENT_VERSION not found in {_AGENT_PY}")
return match.group(1)
def version_tuple(version: str) -> tuple[int, ...]:
"""Parse dotted numeric prefix, e.g. 2.0.0 -> (2, 0, 0)."""
version = (version or "").strip()
if not version:
return ()
parts: list[int] = []
for segment in re.split(r"[.-]", version):
if segment.isdigit():
parts.append(int(segment))
else:
break
return tuple(parts)
def is_version_lower(remote: str, central: str) -> bool:
"""True when remote is strictly older than central."""
remote_t = version_tuple(remote)
central_t = version_tuple(central)
if not remote_t:
return True
if not central_t:
return False
return remote_t < central_t
def is_version_at_least(remote: str, central: str) -> bool:
"""True when remote >= central (equal counts as skip)."""
remote_t = version_tuple(remote)
central_t = version_tuple(central)
if not remote_t:
return False
if not central_t:
return bool(remote_t)
return remote_t >= central_t
def agent_is_installed(
*,
agent_version_db: str | None,
heartbeat: dict[str, str] | None = None,
) -> bool:
"""True when Agent has reported a version or heartbeat (not merely a DB API key)."""
if heartbeat:
if (heartbeat.get("agent_version") or "").strip():
return True
if heartbeat.get("last_heartbeat"):
return True
return bool((agent_version_db or "").strip())
def compute_agent_guidance(
*,
agent_version_db: str | None,
heartbeat: dict[str, str] | None = None,
central: str | None = None,
) -> dict[str, str | None]:
"""UI hints: install / upgrade / offline / ok."""
central_v = central or get_central_agent_version()
live_ver = ""
if heartbeat:
live_ver = (heartbeat.get("agent_version") or "").strip()
if not live_ver:
live_ver = (agent_version_db or "").strip()
has_heartbeat = bool(heartbeat and heartbeat.get("last_heartbeat"))
installed = agent_is_installed(agent_version_db=agent_version_db, heartbeat=heartbeat)
base: dict[str, str | None] = {
"central_agent_version": central_v,
"agent_action": "ok",
"agent_action_message": None,
}
if not installed:
return {
**base,
"agent_action": "install",
"agent_action_message": (
f"尚未安装 Agent(主站版本 {central_v})。"
"安装后可自动上报 CPU/内存/磁盘与在线状态;未安装时仅能通过 SSH 手动探活。"
),
}
if live_ver and is_version_lower(live_ver, central_v):
return {
**base,
"agent_action": "upgrade",
"agent_action_message": (
f"当前 Agent {live_ver},低于主站 {central_v},建议升级以获取最新心跳与探针能力。"
),
}
if not has_heartbeat:
return {
**base,
"agent_action": "offline",
"agent_action_message": (
"Agent 已登记但暂无心跳,请检查子机 nexus-agent 服务或点击「诊断」。"
),
}
return base
+71
View File
@@ -0,0 +1,71 @@
"""Shared metric threshold helpers for Agent heartbeat and watch probes."""
from __future__ import annotations
import logging
from typing import Optional
from server.config import settings
logger = logging.getLogger("nexus.alert_metrics")
REDIS_ALERT_KEY_PREFIX = "alerts:"
def threshold_for(thresholds: dict, metric: str) -> float:
defaults = {
"cpu": settings.CPU_ALERT_THRESHOLD,
"mem": settings.MEM_ALERT_THRESHOLD,
"disk": settings.DISK_ALERT_THRESHOLD,
}
return float(thresholds.get(metric, defaults.get(metric, 80)))
def safe_float(value, name: str) -> Optional[float]:
if value is None:
return None
try:
return float(value)
except (ValueError, TypeError):
logger.warning(f"Agent sent non-numeric {name}: {value!r:.50} — skipped")
return None
def detect_alerts(system_info: dict, thresholds: dict) -> list:
alerts = []
cpu = safe_float(system_info.get("cpu_usage") or system_info.get("cpu"), "cpu")
mem = safe_float(system_info.get("mem_usage") or system_info.get("mem"), "mem")
disk = safe_float(system_info.get("disk_usage") or system_info.get("disk"), "disk")
if cpu is not None and cpu > threshold_for(thresholds, "cpu"):
alerts.append(("cpu", cpu))
if mem is not None and mem > threshold_for(thresholds, "mem"):
alerts.append(("mem", mem))
if disk is not None and disk > threshold_for(thresholds, "disk"):
alerts.append(("disk", disk))
return alerts
def alert_metric_key(entry: str) -> str | None:
if not entry:
return None
metric = entry.split(":", 1)[0].strip()
return metric or None
def detect_recovery(system_info: dict, prev_alerts: set, thresholds: dict) -> list:
recoveries: list[tuple[str, float]] = []
seen_metrics: set[str] = set()
for prev in prev_alerts:
metric = alert_metric_key(prev)
if not metric or metric in seen_metrics:
continue
current = safe_float(
system_info.get(f"{metric}_usage") or system_info.get(metric), metric
)
if current is not None and current < threshold_for(thresholds, metric):
seen_metrics.add(metric)
recoveries.append((metric, current))
return recoveries
+147
View File
@@ -0,0 +1,147 @@
"""Consecutive over-threshold heartbeats before metric alerts fire (C2 debounce)."""
from __future__ import annotations
import logging
from typing import Any
from server.utils.alert_metrics import (
REDIS_ALERT_KEY_PREFIX,
alert_metric_key as _alert_metric_key,
detect_recovery as _detect_recovery,
safe_float as _safe_float,
threshold_for as _threshold_for,
)
from server.api.websocket import broadcast_alert, broadcast_recovery
from server.config import settings
logger = logging.getLogger("nexus.alert_streak")
REDIS_STREAK_PREFIX = "alert_streak:"
STREAK_TTL_SECONDS = 3600
METRICS = ("cpu", "mem", "disk")
def streak_required() -> int:
"""Minimum consecutive over-threshold samples before alerting (default 3)."""
return max(1, int(getattr(settings, "ALERT_STREAK_REQUIRED", 3)))
def _metric_value(system_info: dict[str, Any], metric: str) -> float | None:
return _safe_float(
system_info.get(f"{metric}_usage") or system_info.get(metric),
metric,
)
async def record_over_threshold(redis, server_id: int, metric: str) -> int:
key = f"{REDIS_STREAK_PREFIX}{server_id}:{metric}"
count = int(await redis.incr(key))
await redis.expire(key, STREAK_TTL_SECONDS)
return count
async def reset_streak(redis, server_id: int, metric: str) -> None:
await redis.delete(f"{REDIS_STREAK_PREFIX}{server_id}:{metric}")
async def process_metric_alerts(
*,
redis,
server_id: int,
server_name: str,
system_info: dict[str, Any],
thresholds: dict[str, float],
log_prefix: str = "Alert",
) -> None:
"""Evaluate CPU/mem/disk with streak gate; broadcast alert/recovery."""
if not system_info:
return
required = streak_required()
alert_key = f"{REDIS_ALERT_KEY_PREFIX}{server_id}"
try:
prev_alerts = await redis.smembers(alert_key)
except Exception:
logger.debug("Failed to read active alerts for server %s", server_id, exc_info=True)
prev_alerts = set()
active_metrics = {
m for m in (_alert_metric_key(x) for x in prev_alerts) if m
}
for metric in METRICS:
value = _metric_value(system_info, metric)
if value is None:
continue
threshold = _threshold_for(thresholds, metric)
if value > threshold:
try:
streak = await record_over_threshold(redis, server_id, metric)
except Exception:
logger.debug(
"Failed to record alert streak for server %s %s",
server_id,
metric,
exc_info=True,
)
continue
if streak >= required and metric not in active_metrics:
logger.warning(
"%s: server %s %s=%s%% (streak %s/%s)",
log_prefix,
server_id,
metric,
value,
streak,
required,
)
await broadcast_alert(server_id, metric, value, server_name)
try:
await redis.sadd(alert_key, metric)
await redis.expire(alert_key, 3600)
active_metrics.add(metric)
except Exception:
logger.debug(
"Failed to track alert in Redis for server %s",
server_id,
exc_info=True,
)
elif streak < required:
logger.debug(
"%s streak %s/%s: server %s %s=%s%% (suppressed)",
log_prefix,
streak,
required,
server_id,
metric,
value,
)
else:
try:
await reset_streak(redis, server_id, metric)
except Exception:
logger.debug(
"Failed to reset alert streak for server %s %s",
server_id,
metric,
exc_info=True,
)
if not prev_alerts:
return
recoveries = _detect_recovery(system_info, prev_alerts, thresholds)
for metric, value in recoveries:
logger.info("Recovery: server %s %s=%s%%", server_id, metric, value)
await broadcast_recovery(server_id, metric, value, server_name)
try:
await reset_streak(redis, server_id, metric)
for prev in prev_alerts:
if _alert_metric_key(prev) == metric:
await redis.srem(alert_key, prev)
except Exception as e:
logger.error("Recovery cleanup failed for server %s: %s", server_id, e)
+68
View File
@@ -0,0 +1,68 @@
"""Resolve real client IP behind reverse proxy (1Panel / Nginx / Docker)."""
from __future__ import annotations
import ipaddress
from starlette.requests import Request
# Upstream hop is a private/loopback/docker address → trust forwarded headers.
_TRUSTED_PROXY_NETWORKS: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...] = (
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
)
def _parse_ip(value: str) -> str | None:
text = (value or "").strip()
if not text:
return None
# X-Real-IP may rarely contain comma-separated values
candidate = text.split(",")[0].strip()
try:
ipaddress.ip_address(candidate)
except ValueError:
return None
return candidate
def _is_trusted_proxy_hop(ip: str) -> bool:
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return False
return any(addr in net for net in _TRUSTED_PROXY_NETWORKS)
def _ip_from_forwarded_headers(request: Request) -> str | None:
"""X-Real-IP first; X-Forwarded-For last (Nginx appends $remote_addr)."""
x_real = _parse_ip(request.headers.get("X-Real-IP", ""))
if x_real:
return x_real
xff = (request.headers.get("X-Forwarded-For") or "").strip()
if not xff:
return None
parts = [p.strip() for p in xff.split(",") if p.strip()]
if not parts:
return None
return _parse_ip(parts[-1])
def get_client_ip(request: Request) -> str:
"""Return client IP for auth, audit, and rate limits.
When the TCP peer is a trusted reverse proxy (Docker bridge, loopback, LAN),
use X-Real-IP / X-Forwarded-For. Direct public connections ignore those
headers to prevent spoofing.
"""
direct = request.client.host if request.client else ""
if direct and _is_trusted_proxy_hop(direct):
forwarded = _ip_from_forwarded_headers(request)
if forwarded:
return forwarded
return direct or "unknown"
+79
View File
@@ -0,0 +1,79 @@
"""User-visible timestamps — always Asia/Shanghai (北京时间)."""
from __future__ import annotations
import re
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
_CALENDAR_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
BEIJING_TZ = ZoneInfo("Asia/Shanghai")
OPERATOR_TZ = BEIJING_TZ
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def ensure_utc(dt: datetime) -> datetime:
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def to_naive_utc(dt: datetime) -> datetime:
return ensure_utc(dt).replace(tzinfo=None)
def to_beijing(dt: datetime) -> datetime:
"""Convert aware or naive-UTC datetime to Beijing wall clock."""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(BEIJING_TZ)
def format_beijing(dt: datetime | None = None, *, with_label: bool = True) -> str:
"""Format datetime for operators (default: now in 北京时间)."""
when = to_beijing(dt or datetime.now(timezone.utc))
text = when.strftime("%Y-%m-%d %H:%M:%S")
return f"{text} 北京时间" if with_label else text
def format_beijing_now() -> str:
return format_beijing(with_label=True)
def operator_wall(dt: datetime) -> tuple[int, int, int, int, int]:
"""Cron wall-clock components in operator TZ: minute, hour, day, month, dow (0=Sunday)."""
bj = to_beijing(ensure_utc(dt))
return (
bj.minute,
bj.hour,
bj.day,
bj.month,
(bj.weekday() + 1) % 7,
)
def parse_operator_calendar_date(raw: str) -> str:
"""Validate YYYY-MM-DD for operator (Beijing) calendar day filters."""
text = (raw or "").strip()[:10]
if not _CALENDAR_DATE_RE.match(text):
raise ValueError(f"日期格式须为 YYYY-MM-DD,收到: {raw!r}")
try:
datetime.fromisoformat(text)
except ValueError as e:
raise ValueError(f"日期格式须为 YYYY-MM-DD,收到: {raw!r}") from e
return text
def beijing_day_range_utc(date_yyyy_mm_dd: str) -> tuple[datetime, datetime]:
"""Inclusive Beijing calendar day → naive UTC (start, end) for MySQL filters."""
base = datetime.fromisoformat(parse_operator_calendar_date(date_yyyy_mm_dd))
start_bj = base.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=BEIJING_TZ)
end_bj = base.replace(hour=23, minute=59, second=59, microsecond=999999, tzinfo=BEIJING_TZ)
return (
start_bj.astimezone(timezone.utc).replace(tzinfo=None),
end_bj.astimezone(timezone.utc).replace(tzinfo=None),
)
+58
View File
@@ -0,0 +1,58 @@
"""Safety policy for recursive remote chmod."""
from __future__ import annotations
# Paths that must never receive chmod -R / chown -R via the UI.
#
# ``script_service`` is the explicit administrator shell feature and may run
# arbitrary commands by design. File-manager permission changes are safer UI
# actions, so recursive chmod/chown should not be able to accidentally rewrite
# operating-system trees.
FORBIDDEN_RECURSIVE_CHMOD_PATHS = frozenset({
"/",
"/bin",
"/boot",
"/dev",
"/etc",
"/lib",
"/lib64",
"/proc",
"/run",
"/sbin",
"/sys",
"/usr",
"/var",
})
# Block descendants of critical system trees too. ``/var`` is handled more
# narrowly so common web roots like ``/var/www`` can still be managed, while
# state/log/package-manager trees remain protected.
FORBIDDEN_RECURSIVE_CHMOD_PREFIXES = (
"/bin/",
"/boot/",
"/dev/",
"/etc/",
"/lib/",
"/lib64/",
"/proc/",
"/run/",
"/sbin/",
"/sys/",
"/usr/",
"/var/cache/",
"/var/lib/",
"/var/log/",
"/var/run/",
"/var/spool/",
)
RECURSIVE_CHMOD_TIMEOUT_SEC = 300
SINGLE_CHMOD_TIMEOUT_SEC = 15
def assert_recursive_chmod_allowed(remote_path: str) -> None:
"""Raise ValueError if recursive chmod must be rejected for this path."""
if remote_path in FORBIDDEN_RECURSIVE_CHMOD_PATHS or remote_path.startswith(
FORBIDDEN_RECURSIVE_CHMOD_PREFIXES
):
raise ValueError(f"禁止对系统路径递归修改权限: {remote_path}")
+55
View File
@@ -0,0 +1,55 @@
"""Server-level file-management elevation policy (stored in ``servers.extra_attrs``)."""
from __future__ import annotations
from enum import StrEnum
from server.domain.models import Server
FILES_ELEVATION_ATTR = "files_elevation"
VALID_FILES_ELEVATIONS = frozenset({"off", "auto_sudo", "always_sudo"})
DEFAULT_FILES_ELEVATION = "auto_sudo"
SUDOERS_DOC_PATH = "docs/deploy/nexus-files-sudoers.example"
class FilesElevation(StrEnum):
OFF = "off"
AUTO = "auto_sudo"
ALWAYS = "always_sudo"
def normalize_files_elevation(value: object | None) -> FilesElevation:
"""Return elevation mode; unknown values fall back to AUTO."""
if value is None or value == "":
return FilesElevation.AUTO
raw = str(value).strip().lower()
if raw in VALID_FILES_ELEVATIONS:
return FilesElevation(raw)
return FilesElevation.AUTO
def get_files_elevation(server: Server) -> FilesElevation:
attrs = server.extra_attrs if isinstance(server.extra_attrs, dict) else {}
return normalize_files_elevation(attrs.get(FILES_ELEVATION_ATTR))
def merge_files_elevation_into_extra_attrs(
extra_attrs: dict | None,
files_elevation: str | None,
) -> dict | None:
if files_elevation is None:
return extra_attrs
out = dict(extra_attrs or {})
mode = normalize_files_elevation(files_elevation)
out[FILES_ELEVATION_ATTR] = mode.value
return out
def apply_files_elevation_payload(data: dict) -> None:
"""Pop ``files_elevation`` from API payload and merge into ``extra_attrs``."""
fe = data.pop("files_elevation", None)
if fe is not None:
data["extra_attrs"] = merge_files_elevation_into_extra_attrs(
data.get("extra_attrs"),
fe,
)
+195
View File
@@ -0,0 +1,195 @@
"""Post-upload permission fixup for file manager (reuse rsync push defaults)."""
from __future__ import annotations
import logging
import re
import shlex
from typing import TYPE_CHECKING
from server.application.services.sync_engine_v2 import _RSYNC_CHMOD_RE, _RSYNC_CHOWN_RE
if TYPE_CHECKING:
from server.domain.models import Server
logger = logging.getLogger(__name__)
def parse_upload_dir_mode(chmod_setting: str) -> str | None:
"""Extract octal directory mode from rsync-style ``D755,F755``."""
raw = (chmod_setting or "").strip()
if not raw or not _RSYNC_CHMOD_RE.fullmatch(raw):
return None
m = re.search(r"D([0-7]{3,4})", raw, re.IGNORECASE)
if m:
return m.group(1)
return parse_upload_file_mode(raw)
def parse_upload_file_mode(chmod_setting: str) -> str | None:
"""Extract octal file mode from rsync-style ``D755,F755`` or plain ``644``."""
raw = (chmod_setting or "").strip()
if not raw or not _RSYNC_CHMOD_RE.fullmatch(raw):
return None
m = re.search(r"F([0-7]{3,4})", raw, re.IGNORECASE)
if m:
return m.group(1)
if re.fullmatch(r"[0-7]{3,4}", raw):
return raw
for part in reversed(raw.split(",")):
piece = part.strip()
if len(piece) > 1 and piece[0] in "Ff" and re.fullmatch(r"[0-7]{3,4}", piece[1:]):
return piece[1:]
return None
def parse_upload_chown(chown_setting: str) -> tuple[str, str] | None:
"""Return ``(owner, group)`` when setting is valid; else None."""
raw = (chown_setting or "").strip()
if not raw or not _RSYNC_CHOWN_RE.fullmatch(raw):
return None
if ":" in raw:
owner, group = raw.split(":", 1)
return owner.strip(), group.strip()
return raw, raw
def upload_permission_plan() -> tuple[tuple[str, str] | None, str | None]:
"""Read ``NEXUS_RSYNC_PUSH_CHOWN`` / ``NEXUS_RSYNC_PUSH_CHMOD`` for uploads."""
from server.config import settings
return (
parse_upload_chown(settings.RSYNC_PUSH_CHOWN or ""),
parse_upload_file_mode(settings.RSYNC_PUSH_CHMOD or ""),
)
async def apply_upload_file_permissions(server: Server, remote_path: str) -> dict:
"""chown/chmod uploaded file; mirrors rsync push defaults (www:www, F755)."""
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
chown_pair, file_mode = upload_permission_plan()
if not chown_pair and not file_mode:
return {"applied": False, "skipped": True}
path_q = shlex.quote(remote_path)
elevated = False
errors: list[str] = []
if chown_pair:
owner_spec = shlex.quote(f"{chown_pair[0]}:{chown_pair[1]}")
result = await exec_ssh_command_with_fallback(
server, f"chown {owner_spec} {path_q}", timeout=30,
)
elevated = bool(result.get("elevated"))
if result.get("exit_code") != 0:
err = ((result.get("stderr") or "") + (result.get("stdout") or "")).strip()[:200]
errors.append(err or "chown 失败")
if file_mode and not errors:
result = await exec_ssh_command_with_fallback(
server, f"chmod {file_mode} {path_q}", timeout=30,
)
elevated = elevated or bool(result.get("elevated"))
if result.get("exit_code") != 0:
err = ((result.get("stderr") or "") + (result.get("stdout") or "")).strip()[:200]
errors.append(err or "chmod 失败")
if errors:
logger.warning(
"Upload permission fixup failed for %s on server %s: %s",
remote_path,
getattr(server, "id", "?"),
"; ".join(errors),
)
owner, group = chown_pair if chown_pair else (None, None)
return {
"applied": not errors and bool(chown_pair or file_mode),
"skipped": False,
"owner": owner,
"group": group,
"mode": file_mode,
"elevated": elevated,
"error": "; ".join(errors) if errors else None,
}
async def apply_transfer_permissions(
server: Server,
remote_path: str,
*,
recursive: bool = True,
) -> dict:
"""chown/chmod after cross-server relay or package deliver (default www:www, D755/F755)."""
from server.config import settings
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
chown_pair, file_mode = upload_permission_plan()
dir_mode = parse_upload_dir_mode(settings.RSYNC_PUSH_CHMOD or "")
if not chown_pair and not file_mode:
return {"applied": False, "skipped": True, "path": remote_path}
path_q = shlex.quote(remote_path)
elevated = False
errors: list[str] = []
if chown_pair:
owner_spec = shlex.quote(f"{chown_pair[0]}:{chown_pair[1]}")
chown_flag = "-R " if recursive else ""
result = await exec_ssh_command_with_fallback(
server,
f"chown {chown_flag}{owner_spec} {path_q}",
timeout=120,
)
elevated = bool(result.get("elevated"))
if result.get("exit_code") != 0:
err = ((result.get("stderr") or "") + (result.get("stdout") or "")).strip()[:200]
errors.append(err or "chown 失败")
if file_mode and not errors:
if recursive and dir_mode and dir_mode != file_mode:
for kind, mode in (("d", dir_mode), ("f", file_mode)):
result = await exec_ssh_command_with_fallback(
server,
f"find {path_q} -type {kind} -exec chmod {shlex.quote(mode)} {{}} +",
timeout=120,
)
elevated = elevated or bool(result.get("elevated"))
if result.get("exit_code") != 0:
err = ((result.get("stderr") or "") + (result.get("stdout") or "")).strip()[:200]
errors.append(err or f"chmod {kind} 失败")
break
else:
mode = file_mode
chmod_flag = "-R " if recursive else ""
result = await exec_ssh_command_with_fallback(
server,
f"chmod {chmod_flag}{shlex.quote(mode)} {path_q}",
timeout=120,
)
elevated = elevated or bool(result.get("elevated"))
if result.get("exit_code") != 0:
err = ((result.get("stderr") or "") + (result.get("stdout") or "")).strip()[:200]
errors.append(err or "chmod 失败")
if errors:
logger.warning(
"Transfer permission fixup failed for %s on server %s: %s",
remote_path,
getattr(server, "id", "?"),
"; ".join(errors),
)
owner, group = chown_pair if chown_pair else (None, None)
return {
"applied": not errors and bool(chown_pair or file_mode),
"skipped": False,
"path": remote_path,
"owner": owner,
"group": group,
"dir_mode": dir_mode,
"file_mode": file_mode,
"elevated": elevated,
"error": "; ".join(errors) if errors else None,
}
+87
View File
@@ -0,0 +1,87 @@
"""Attach alert_logs to fleet metric sample windows (10min flush alignment)."""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any
from server.utils.display_time import to_naive_utc
ALERT_TYPE_LABELS = {
"cpu": "CPU",
"mem": "内存",
"disk": "磁盘",
"time_drift": "时钟漂移",
"system": "系统",
}
def _parse_naive_utc(value: datetime | str | None) -> datetime | None:
if value is None:
return None
if isinstance(value, datetime):
return to_naive_utc(value)
raw = str(value).strip()
if not raw:
return None
normalized = raw.replace(" ", "T") if " " in raw and "T" not in raw else raw
if normalized.endswith("Z"):
normalized = normalized[:-1] + "+00:00"
try:
dt = datetime.fromisoformat(normalized)
except ValueError:
return None
return to_naive_utc(dt)
def _alert_to_dict(alert: Any) -> dict[str, Any]:
atype = str(getattr(alert, "alert_type", "") or "")
return {
"server_name": getattr(alert, "server_name", None) or "",
"server_id": getattr(alert, "server_id", None),
"alert_type": atype,
"alert_type_label": ALERT_TYPE_LABELS.get(atype, atype),
"value": getattr(alert, "value", None) or "",
"is_recovery": bool(getattr(alert, "is_recovery", False)),
"created_at": str(getattr(alert, "created_at", "") or ""),
}
def attach_alerts_to_fleet_points(
points: list[dict[str, Any]],
alerts: list[Any],
*,
interval_minutes: int = 10,
) -> list[dict[str, Any]]:
"""Map each sample to alerts in (previous_sample, current_sample] window."""
if not points:
return []
parsed: list[tuple[datetime | None, dict[str, Any]]] = []
for p in points:
parsed.append((_parse_naive_utc(p.get("recorded_at")), p))
alert_rows: list[tuple[datetime, dict[str, Any]]] = []
for a in alerts:
adt = _parse_naive_utc(getattr(a, "created_at", None))
if adt is not None:
alert_rows.append((adt, _alert_to_dict(a)))
window = timedelta(minutes=max(1, interval_minutes))
enriched: list[dict[str, Any]] = []
for i, (pt_dt, p) in enumerate(parsed):
if pt_dt is None:
enriched.append({**p, "alerts": [], "alert_count": 0})
continue
prev_dt = parsed[i - 1][0] if i > 0 else None
start = prev_dt if prev_dt is not None else pt_dt - window
bucket = [
item
for adt, item in alert_rows
if start < adt <= pt_dt
]
enriched.append({**p, "alerts": bucket, "alert_count": len(bucket)})
return enriched
+75
View File
@@ -0,0 +1,75 @@
"""Fleet-wide metric aggregation from Agent heartbeat payloads."""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
def _safe_pct(value: Any) -> float | None:
if value is None:
return None
try:
num = float(value)
except (TypeError, ValueError):
return None
if num < 0 or num > 100:
return None
return num
def _metric_from_system_info(system_info: dict[str, Any], primary: str, fallback: str) -> float | None:
return _safe_pct(system_info.get(primary) if system_info.get(primary) is not None else system_info.get(fallback))
@dataclass(frozen=True)
class FleetMetricAggregate:
online_count: int
sample_count: int
cpu_avg: int | None
mem_avg: int | None
disk_avg: int | None
def aggregate_fleet_metrics(heartbeats: list[dict[str, Any]]) -> FleetMetricAggregate:
"""Aggregate Redis heartbeat hash rows (is_online + system_info JSON string)."""
online_count = 0
cpu_vals: list[float] = []
mem_vals: list[float] = []
disk_vals: list[float] = []
for row in heartbeats:
if row.get("is_online") in (True, "True"):
online_count += 1
raw = row.get("system_info") or "{}"
try:
info = json.loads(raw) if isinstance(raw, str) else (raw or {})
except json.JSONDecodeError:
info = {}
if not isinstance(info, dict):
continue
cpu = _metric_from_system_info(info, "cpu_usage", "cpu")
mem = _metric_from_system_info(info, "mem_usage", "mem")
disk = _metric_from_system_info(info, "disk_usage", "disk")
if cpu is not None:
cpu_vals.append(cpu)
if mem is not None:
mem_vals.append(mem)
if disk is not None:
disk_vals.append(disk)
sample_count = max(len(cpu_vals), len(mem_vals), len(disk_vals))
def _avg(vals: list[float]) -> int | None:
if not vals:
return None
return round(sum(vals) / len(vals))
return FleetMetricAggregate(
online_count=online_count,
sample_count=sample_count,
cpu_avg=_avg(cpu_vals),
mem_avg=_avg(mem_vals),
disk_avg=_avg(disk_vals),
)
+116
View File
@@ -0,0 +1,116 @@
"""Translate HTTPException detail strings to Chinese for admin-facing API responses."""
from __future__ import annotations
import re
from typing import Any
# Exact English literals from server/api (and shared services)
_DETAIL_EXACT: dict[str, str] = {
"Not found": "不存在",
"Server not found": "服务器不存在",
"Platform not found": "平台不存在",
"Node not found": "节点不存在",
"Admin not found": "管理员不存在",
"Setting not found": "设置项不存在",
"API_KEY not found": "API_KEY 不存在",
"Schedule not found": "调度任务不存在",
"Preset not found": "凭据预设不存在",
"SSH key preset not found": "SSH 密钥预设不存在",
"telegram_bot_token not found": "telegram_bot_token 不存在",
"Retry job not found": "重试任务不存在",
"Credential not found": "凭据不存在",
"Execution not found": "执行记录不存在",
"Script not found": "脚本不存在",
"Login failed": "登录失败",
"Missing refresh token": "缺少刷新令牌",
"Invalid refresh token": "刷新令牌无效",
"Missing Authorization header": "缺少 Authorization 请求头",
"Invalid or expired token": "令牌无效或已过期",
"Can only setup TOTP for yourself": "只能为自己设置 TOTP",
"Can only enable TOTP for yourself": "只能为自己启用 TOTP",
"Can only disable TOTP for yourself": "只能为自己禁用 TOTP",
"Setup failed": "设置失败",
"Enable failed": "启用失败",
"Disable failed": "禁用失败",
"Server has no domain configured": "服务器未配置域名",
"Missing API key": "缺少 API Key",
"Invalid API key for this server": "该服务器的 API Key 无效",
"Invalid or expired job": "任务无效或已过期",
"new_path required for rename": "重命名需要指定 new_path",
"Invalid operation": "无效操作",
"server_id required": "server_id 必填",
"Too many script callback requests": "脚本回调请求过于频繁",
}
# AuthService ``reason`` codes → user-facing Chinese (when ``message`` absent)
_AUTH_REASON_ZH: dict[str, str] = {
"admin_not_found": "管理员不存在",
"no_secret": "请先完成 TOTP 设置",
"invalid_totp": "TOTP 验证码错误",
"invalid_password": "当前密码错误",
"totp_required": "需要 TOTP 验证码",
"invalid_token": "令牌无效或已过期",
"invalid_credentials": "用户名或密码错误",
"account_locked": "登录尝试过多,请 15 分钟后重试",
"account_disabled": "账户已被禁用",
"ip_blocked": "拒绝访问",
"service_unavailable": "认证服务暂不可用,请稍后重试",
}
_DETAIL_PATTERNS: list[tuple[re.Pattern[str], str]] = [
(
re.compile(r"^Command failed \(exit (\d+)\)$"),
r"命令失败(退出码 \1",
),
(
re.compile(
r"^Setting '([^']+)' is immutable and cannot be modified via API$"
),
r"设置项「\1」不可通过 API 修改",
),
]
def _looks_chinese(text: str) -> bool:
return any("\u4e00" <= ch <= "\u9fff" for ch in text)
def translate_http_detail_str(msg: str) -> str:
"""Return Chinese detail when a known translation exists."""
if not msg or _looks_chinese(msg):
return msg
if msg in _DETAIL_EXACT:
return _DETAIL_EXACT[msg]
for pattern, repl in _DETAIL_PATTERNS:
if pattern.fullmatch(msg):
return pattern.sub(repl, msg)
return msg
def auth_failure_detail(result: dict[str, Any], *, fallback: str = "操作失败") -> str:
"""Map AuthService failure dict to Chinese ``HTTPException.detail``."""
msg = result.get("message")
if isinstance(msg, str) and msg.strip():
return translate_http_detail_str(msg)
reason = str(result.get("reason") or "")
if reason in _AUTH_REASON_ZH:
return _AUTH_REASON_ZH[reason]
return fallback if _looks_chinese(fallback) else translate_http_detail_str(fallback)
def translate_http_detail(detail: Any) -> Any:
"""Translate ``HTTPException.detail`` (str | list | dict) for JSON responses."""
if isinstance(detail, str):
return translate_http_detail_str(detail)
if isinstance(detail, list):
return [translate_http_detail(item) for item in detail]
if isinstance(detail, dict):
out: dict[Any, Any] = {}
for key, value in detail.items():
if key in ("msg", "message") and isinstance(value, str):
out[key] = translate_http_detail_str(value)
else:
out[key] = value
return out
return detail
+92
View File
@@ -0,0 +1,92 @@
"""Login IP allowlist helpers (shared by auth API precheck and AuthService.login)."""
from __future__ import annotations
import ipaddress
from server.config import settings
_TRUTHY = frozenset({"true", "1", "yes", "on"})
def is_allowlist_enforced() -> bool:
"""Whether LOGIN_ALLOWLIST_ENABLED is on."""
val = (getattr(settings, "LOGIN_ALLOWLIST_ENABLED", "false") or "false").lower()
return val in _TRUTHY
def get_allowed_login_ips() -> list[str]:
"""Combined allowlist used by login checks.
Prefer LOGIN_ALLOWED_IPS — rebuilt on each subscription refresh and broadcast
to all workers via Redis. Recomputing subscription + manual locally can diverge
when workers receive partial or out-of-order Pub/Sub updates.
"""
combined_raw = (getattr(settings, "LOGIN_ALLOWED_IPS", "") or "").strip()
if combined_raw:
return [ip.strip() for ip in combined_raw.split(",") if ip.strip()]
sub_ips_raw = (getattr(settings, "LOGIN_SUBSCRIPTION_IPS", "") or "").strip()
manual_ips_raw = (getattr(settings, "LOGIN_MANUAL_IPS", "") or "").strip()
all_ips_raw = ",".join(filter(None, [sub_ips_raw, manual_ips_raw]))
return [ip.strip() for ip in all_ips_raw.split(",") if ip.strip()]
def ip_in_allowlist(client_ip: str, allowed: list[str]) -> bool:
"""Check if client_ip matches any entry in the allowlist."""
try:
client_addr = ipaddress.ip_address(client_ip)
for entry in allowed:
entry = entry.strip()
try:
if "/" in entry:
if client_addr in ipaddress.ip_network(entry, strict=False):
return True
elif client_addr == ipaddress.ip_address(entry):
return True
except ValueError:
if client_ip == entry:
return True
except ValueError:
return client_ip in allowed
return False
def check_login_ip(client_ip: str) -> tuple[bool, bool, str | None]:
"""Evaluate login IP access.
Returns:
(allowlist_enabled, allowed, message)
- allowlist off: (False, True, None)
- allowlist on, empty list: (True, True, None) — nothing to enforce
- allowlist on, IP ok: (True, True, None)
- allowlist on, IP blocked: (True, False, message)
"""
if not is_allowlist_enforced():
return False, True, None
if not client_ip or client_ip in ("", "unknown"):
return True, True, None
allowed_ips = get_allowed_login_ips()
if not allowed_ips:
return True, True, None
if ip_in_allowlist(client_ip, allowed_ips):
return True, True, None
return (
True,
False,
f"当前 IP ({client_ip}) 不在登录白名单中,请检查代理或联系管理员",
)
def is_whitelisted_login_ip(client_ip: str) -> bool:
"""True when allowlist is enforced, non-empty, and client IP matches."""
if not is_allowlist_enforced():
return False
if not client_ip or client_ip in ("", "unknown"):
return False
allowed_ips = get_allowed_login_ips()
return bool(allowed_ips) and ip_in_allowlist(client_ip, allowed_ips)
+63
View File
@@ -0,0 +1,63 @@
"""Detect primary nginx site domain on remote Baota/generic hosts."""
from __future__ import annotations
import shlex
def build_nginx_domain_detect_command(*, target_hint: str = "") -> str:
"""Build remote shell snippet that prints one site hostname to stdout.
Lookup order:
1. Baota vhost basename ``/www/server/panel/vhost/nginx/{domain}.conf``
2. ``server_name`` in Baota vhosts and generic nginx dirs
Prefer *target_hint* when it matches a candidate.
"""
hint = (target_hint or "").strip().lower()
qhint = shlex.quote(hint) if hint else "''"
return (
f"TARGET_HINT={qhint}; "
"_is_domain() { "
'local h="${1,,}"; '
'[[ "$h" == *.* ]] || return 1; '
'[[ "$h" =~ ^[0-9]+(\\.[0-9]+){3}$ ]] && return 1; '
'case "$h" in default|0.default|0.site_total|phpmyadmin|localhost|127.0.0.1|_*|*.local) return 1 ;; esac; '
"return 0; "
"}; "
"_pick() { "
'local best=""; '
"for h in \"$@\"; do "
'[ -n "$h" ] || continue; '
"_is_domain \"$h\" || continue; "
'if [ -n "$TARGET_HINT" ] && [ "$h" = "$TARGET_HINT" ]; then echo "$h"; return 0; fi; '
'if [ -z "$best" ] || [[ "$h" < "$best" ]]; then best="$h"; fi; '
"done; "
'[ -n "$best" ] && echo "$best"; '
"}; "
"cands=(); "
'BT="/www/server/panel/vhost/nginx"; '
'if [ -d "$BT" ]; then '
'for f in "$BT"/*.conf; do '
'[ -f "$f" ] || continue; '
'base=$(basename "$f" .conf); '
"_is_domain \"$base\" && cands+=(\"$base\"); "
"done; "
"fi; "
'if [ ${#cands[@]} -gt 0 ]; then _pick "${cands[@]}"; exit 0; fi; '
"_names_from() { "
'grep -E "^[[:space:]]*server_name[[:space:]]" "$1" 2>/dev/null '
"| head -1 | sed 's/.*server_name[[:space:]]\\+//;s/;//;s/\\$//g'; "
"}; "
'for dir in "$BT" /etc/nginx/sites-enabled /etc/nginx/conf.d; do '
'[ -d "$dir" ] || continue; '
'for f in "$dir"/*; do '
'[ -f "$f" ] || continue; '
'raw=$(_names_from "$f"); '
'for token in $raw; do '
'token="${token,,}"; '
"_is_domain \"$token\" && cands+=(\"$token\"); "
"done; "
"done; "
"done; "
'_pick "${cands[@]}"'
)
+49
View File
@@ -0,0 +1,49 @@
"""Notification toggle helpers — shared by Telegram and alert dispatch."""
from server.config import settings
_DISABLED = frozenset({"false", "0", "no", "off"})
# alert_type (cpu/mem/disk) → Settings attribute
_RESOURCE_ALERT_ATTR: dict[str, str] = {
"cpu": "NOTIFY_ALERT_CPU",
"mem": "NOTIFY_ALERT_MEM",
"disk": "NOTIFY_ALERT_DISK",
}
def notify_enabled(setting_value: str | bool | None) -> bool:
"""Return True when a notify_* setting is enabled."""
if setting_value is None:
return True
if isinstance(setting_value, bool):
return setting_value
return setting_value.strip().lower() not in _DISABLED
def resource_alert_notify_enabled(alert_type: str) -> bool:
"""Check whether Telegram should fire for a resource alert type."""
attr = _RESOURCE_ALERT_ATTR.get(alert_type.lower(), "NOTIFY_ALERT_CPU")
return notify_enabled(getattr(settings, attr, "true"))
def recovery_notify_enabled() -> bool:
"""Global toggle for resource metric recovery Telegram."""
return notify_enabled(settings.NOTIFY_RECOVERY)
def resource_recovery_notify_enabled(metric: str) -> bool:
"""Telegram recovery requires global NOTIFY_RECOVERY and per-metric alert toggle."""
if not recovery_notify_enabled():
return False
return resource_alert_notify_enabled(metric)
def notify_attr_enabled(attr_name: str, default: str = "true") -> bool:
"""Check a named NOTIFY_* settings attribute."""
return notify_enabled(getattr(settings, attr_name, default))
def offline_alert_notify_enabled() -> bool:
"""Telegram toggle for server offline (Agent heartbeat lost) alerts."""
return notify_attr_enabled("NOTIFY_ALERT_OFFLINE")
+178
View File
@@ -0,0 +1,178 @@
"""Linux/POSIX path helpers for remote SSH paths and Nexus-host Unix paths.
Use this module whenever manipulating path *strings* for Linux targets.
Do not use ``os.path`` for remote paths — on Windows dev hosts ``os.path.join``
produces backslashes and breaks SSH/SFTP commands.
For real filesystem I/O on the Nexus host (``open``, ``os.scandir``), keep
``os.path.realpath`` / ``os.walk`` after normalizing with :func:`to_posix`.
"""
from __future__ import annotations
import os
import posixpath
UPLOAD_STAGING_PREFIX = "/tmp/nexus_upload_"
# Baota default web root — push/sync fallback when server.target_path is empty.
DEFAULT_PUSH_TARGET_PATH = "/www/wwwroot"
FORBIDDEN_NEXUS_SOURCE_PREFIXES = (
"/etc",
"/root",
"/home",
"/var",
"/boot",
"/dev",
"/proc",
"/sys",
"/run",
"/srv",
)
class PosixPathError(ValueError):
"""Invalid or unsafe POSIX path."""
def to_posix(path: str) -> str:
"""Normalize slashes to forward slash (does not resolve ``..``)."""
return (path or "").strip().replace("\\", "/")
def reject_windows_separators(path: str, *, field: str = "path") -> None:
if "\\" in path or "\0" in path:
raise PosixPathError(f"{field} 必须使用 Linux 路径(不允许反斜杠)")
def normalize_remote_abs_path(path: str) -> str:
"""Validate and normalize a remote absolute path (SSH/SFTP)."""
raw_in = (path or "").strip()
reject_windows_separators(raw_in, field="path")
raw = to_posix(raw_in)
if not raw.startswith("/"):
raise PosixPathError("路径必须为绝对路径")
parts = [p for p in raw.split("/") if p and p != "."]
if ".." in parts:
raise PosixPathError("路径不允许包含 '..'")
normalized = "/" + "/".join(parts) if parts else "/"
if len(normalized) > 1 and normalized.endswith("/"):
normalized = normalized.rstrip("/")
return normalized or "/"
def posix_join(*parts: str) -> str:
"""Join path segments using POSIX rules (safe on any dev OS)."""
if not parts:
return "/"
acc = to_posix(parts[0])
for part in parts[1:]:
seg = to_posix(part)
if not seg or seg == "/":
continue
acc = posixpath.join(acc, seg.lstrip("/"))
return posixpath.normpath(acc) or "/"
def posix_basename(path: str) -> str:
return posixpath.basename(to_posix(path.rstrip("/")))
def posix_dirname(path: str) -> str:
parent = posixpath.dirname(to_posix(path.rstrip("/")))
return parent or "/"
def remote_join(directory: str, name: str) -> str:
"""Join remote directory + file name (both logical Linux paths)."""
base = normalize_remote_abs_path(directory)
segment = to_posix(name).strip("/")
reject_windows_separators(segment, field="name")
if ".." in segment.split("/"):
raise PosixPathError("名称不能包含 '..'")
if not segment:
return base
return posix_join(base, segment)
def resolve_nexus_host_path(path: str) -> str:
"""Resolve a path on the Nexus host filesystem (Linux deployment)."""
posix = to_posix(path)
reject_windows_separators(posix, field="path")
return os.path.realpath(posix)
def resolve_nexus_push_source_path(path: str) -> str:
"""Resolve and validate a Nexus host push source directory."""
stripped = path.strip()
if ".." in to_posix(stripped).split("/"):
raise PosixPathError("路径不允许包含 '..'")
real_path = resolve_nexus_host_path(stripped)
for prefix in FORBIDDEN_NEXUS_SOURCE_PREFIXES:
if real_path == prefix or real_path.startswith(prefix + "/"):
raise PosixPathError(f"不允许使用系统路径: {prefix}")
if not os.path.isdir(real_path):
raise PosixPathError(f"源路径不存在: {real_path}")
return real_path
def ensure_under_nexus_upload(path: str) -> str:
"""Resolve *path* and ensure it stays under ``/tmp/nexus_upload_*``."""
real = resolve_nexus_host_path(path)
if not to_posix(real).startswith(UPLOAD_STAGING_PREFIX):
raise PosixPathError("仅允许访问上传临时目录")
return real
def assert_zip_member_safe(extract_dir: str, member_name: str) -> str:
"""Zip-slip check; returns normalized member path under *extract_dir*."""
base = normalize_remote_abs_path(extract_dir)
member = to_posix(member_name).lstrip("/")
if not member or member == ".":
return base
if member == ".." or member.startswith("../") or "/../" in f"/{member}/":
raise PosixPathError(f"ZIP 包含非法路径: {member_name}")
candidate = posixpath.normpath(posixpath.join(base, member))
if candidate != base and not candidate.startswith(base + "/"):
raise PosixPathError(f"ZIP 包含非法路径: {member_name}")
return candidate
def is_path_under_prefix(resolved: str, prefix: str) -> bool:
"""True if *resolved* is *prefix* or a child (POSIX string compare after norm)."""
r = normalize_remote_abs_path(to_posix(resolved))
p = normalize_remote_abs_path(to_posix(prefix))
return r == p or r.startswith(p + "/")
def normalize_remote_dest(path: str | None, *, default: str = DEFAULT_PUSH_TARGET_PATH) -> str:
"""Normalize remote target directory (push/rsync/diagnose); uses *default* when empty."""
raw = (path or "").strip() or default
return normalize_remote_abs_path(raw)
def resolve_push_target_path(
server_target_path: str | None,
override: str | None = None,
) -> str:
"""Resolve rsync push destination: override → server.target_path → ``/www/wwwroot``.
Independent of :func:`is_unset_target_path`: empty or placeholder ``/www/wwwroot`` in DB
still counts as「未设路径」in UI, but push may target ``/www/wwwroot`` (Baota web root).
"""
if override is not None and str(override).strip():
return normalize_remote_abs_path(str(override).strip())
raw = (server_target_path or "").strip()
if raw:
return normalize_remote_abs_path(raw)
return DEFAULT_PUSH_TARGET_PATH
def is_unset_target_path(path: str | None) -> bool:
"""True when push target path is empty or still the BT default placeholder.
``/www/wwwroot/`` (trailing slash) is *not* unset — only exact ``/www/wwwroot`` after trim.
"""
p = (path or "").strip()
return p == "" or p == "/www/wwwroot"
+123
View File
@@ -0,0 +1,123 @@
"""Install psutil on managed servers via SSH (system Python for watch probe)."""
from __future__ import annotations
import re
from server.application.server_batch_common import sudo_wrap
from server.domain.models import Server
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
# pip --user first (no root); package managers and system pip via run_root (sudo when non-root).
PSUTIL_INSTALL_SHELL = r"""
run_root() {
if [ "$(id -u)" -eq 0 ]; then
"$@"
else
sudo "$@"
fi
}
verify_psutil() {
for py in python3 python3.12 python3.11 python3.10; do
if command -v "$py" >/dev/null 2>&1 && "$py" -c "import psutil" 2>/dev/null; then
echo psutil_ok
exit 0
fi
done
return 1
}
verify_psutil && exit 0
for py in python3 python3.12 python3.11 python3.10; do
command -v "$py" >/dev/null 2>&1 || continue
if "$py" -m pip install --user -q psutil 2>/dev/null; then
verify_psutil && exit 0
fi
done
if command -v apt-get >/dev/null 2>&1; then
if run_root apt-get update -qq \
&& run_root env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3-psutil; then
verify_psutil && exit 0
fi
fi
if command -v dnf >/dev/null 2>&1; then
if run_root dnf install -y -q python3-psutil; then
verify_psutil && exit 0
fi
fi
if command -v yum >/dev/null 2>&1; then
if run_root yum install -y -q python3-psutil; then
verify_psutil && exit 0
fi
fi
for py in python3 python3.12 python3.11 python3.10; do
command -v "$py" >/dev/null 2>&1 || continue
if run_root "$py" -m pip install -q psutil 2>/dev/null; then
verify_psutil && exit 0
fi
done
echo "ERROR: psutil install failed — pip/apt unavailable or permission denied" >&2
exit 1
"""
_APT_PERM_RE = re.compile(
r"lock file|permission denied|unable to lock|not allowed to execute",
re.I,
)
def _psutil_install_error_zh(stderr: str, stdout: str) -> str:
text = f"{stderr}\n{stdout}"
if _APT_PERM_RE.search(text):
return (
"安装 psutil 需要 root 或免密 sudoapt/pip)。"
"请改用 root SSH,或在子机配置免密 sudo 后重试;也可手动执行:"
"sudo apt-get install -y python3-psutil"
)
if "sudo requires a password" in text.lower():
return "sudo 需要密码,请配置免密 sudo 或使用 root SSH 登录"
line = ""
for chunk in (stderr, stdout):
for raw in reversed(chunk.splitlines()):
raw = raw.strip()
if raw and not raw.startswith("W:"):
line = raw[:300]
break
if line:
break
return line or "psutil 安装失败"
def build_psutil_install_cmd(*, ssh_user: str) -> str:
return sudo_wrap(PSUTIL_INSTALL_SHELL.strip(), ssh_user)
async def install_psutil_via_ssh(server: Server, *, timeout: int = 120) -> dict[str, str | int | bool]:
"""Run remote install; returns success, message, stdout/stderr."""
ssh_user = (server.username or "root").strip()
cmd = build_psutil_install_cmd(ssh_user=ssh_user)
result = await exec_ssh_command(server, cmd, timeout=timeout)
stdout = (result.get("stdout") or "").strip()
stderr = (result.get("stderr") or "").strip()
raw_exit = result.get("exit_code")
exit_code = int(raw_exit) if raw_exit is not None else 1
if exit_code == 0 and "psutil_ok" in stdout:
return {
"success": True,
"exit_code": 0,
"stdout": stdout[:2000],
"stderr": stderr[:500],
"message": "psutil 已就绪",
}
return {
"success": False,
"exit_code": exit_code,
"stdout": stdout[:2000],
"stderr": stderr[:500],
"message": _psutil_install_error_zh(stderr, stdout),
}
+37
View File
@@ -0,0 +1,37 @@
"""Rsync remote receiver elevation — mirrors ``files_elevation`` for file push.
Policy (same as file manager): **root** SSH → no sudo; **non-root** with default
``auto_sudo`` (or ``always_sudo``) → ``sudo -n rsync`` on the receiver.
Only ``files_elevation=off`` disables elevation.
"""
from __future__ import annotations
from server.domain.models import Server
from server.utils.files_elevation import FilesElevation, get_files_elevation
# Fixed remote receiver command (no user input); requires NOPASSWD rsync in sudoers.
RSYNC_SUDO_RECEIVER_PATH = "sudo -n rsync"
def is_root_ssh_user(server: Server) -> bool:
ssh_user = (server.username or "root").strip() or "root"
return ssh_user == "root"
def rsync_receiver_path_for_push(server: Server) -> str | None:
"""Return ``--rsync-path`` for non-root servers when elevation is enabled."""
if is_root_ssh_user(server):
return None
if get_files_elevation(server) == FilesElevation.OFF:
return None
return RSYNC_SUDO_RECEIVER_PATH
def rsync_elevation_log_hint(server: Server) -> str | None:
"""Short tag for sync_logs.push_permission."""
if is_root_ssh_user(server):
return None
if get_files_elevation(server) == FilesElevation.OFF:
return None
return "rsync=sudo"
+150
View File
@@ -0,0 +1,150 @@
"""Schedule cycle ↔ 5-field cron (Baota-compatible)."""
from __future__ import annotations
from typing import Any, TypedDict
class ScheduleCycleConfig(TypedDict, total=False):
type: str
minute: int
hour: int
interval: int
week: int
monthDay: int
customCron: str
def _clamp_int(n: int | float, lo: int, hi: int) -> int:
try:
v = int(n)
except (TypeError, ValueError):
return lo
return max(lo, min(hi, v))
SCHEDULE_MIN_INTERVAL_MINUTES = 1
def build_cron_from_cycle(config: dict[str, Any]) -> str:
kind = config.get("type", "day")
if kind == "custom":
raw = str(config.get("customCron") or "").strip()
if not raw:
raise ValueError("自定义 cron 不能为空")
parts = raw.split()
if len(parts) != 5:
raise ValueError("cron 表达式必须为 5 个字段")
return raw
minute = _clamp_int(config.get("minute", 0), 0, 59)
hour = _clamp_int(config.get("hour", 0), 0, 23)
if kind == "day":
return f"{minute} {hour} * * *"
if kind == "day-n":
n = _clamp_int(config.get("interval", 1), 1, 365)
return f"{minute} {hour} */{n} * *"
if kind == "hour":
return f"{minute} * * * *"
if kind == "hour-n":
n = _clamp_int(config.get("interval", 1), 1, 23)
return f"{minute} */{n} * * *"
if kind == "minute-n":
n = _clamp_int(config.get("interval", 1), SCHEDULE_MIN_INTERVAL_MINUTES, 59)
return f"*/{n} * * * *"
if kind == "week":
w = _clamp_int(config.get("week", 0), 0, 6)
return f"{minute} {hour} * * {w}"
if kind == "month":
dom = _clamp_int(config.get("monthDay", 1), 1, 31)
return f"{minute} {hour} {dom} * *"
raise ValueError(f"未知的周期类型: {kind}")
def _parse_step_or_fixed(field: str) -> tuple[str, int | None]:
if field == "*":
return "star", None
if field.startswith("*/"):
step = int(field[2:])
return "step", step
return "fixed", int(field)
def parse_cron_to_cycle(cron: str) -> dict[str, Any]:
base: dict[str, Any] = {
"type": "day",
"minute": 0,
"hour": 2,
"interval": 2,
"week": 1,
"monthDay": 1,
"customCron": "",
}
expr = (cron or "").strip()
if not expr:
return base
parts = expr.split()
if len(parts) != 5:
return {**base, "type": "custom", "customCron": expr}
min_f, hour_f, dom_f, mon_f, dow_f = parts
if mon_f != "*":
return {**base, "type": "custom", "customCron": expr}
min_k, min_v = _parse_step_or_fixed(min_f)
hour_k, hour_v = _parse_step_or_fixed(hour_f)
dom_k, dom_v = _parse_step_or_fixed(dom_f)
dow_k, dow_v = _parse_step_or_fixed(dow_f)
if min_k == "step" and hour_k == "star" and dom_k == "star" and dow_k == "star":
return {**base, "type": "minute-n", "interval": min_v}
if min_k == "fixed" and hour_k == "star" and dom_k == "star" and dow_k == "star":
return {**base, "type": "hour", "minute": min_v}
if min_k == "fixed" and hour_k == "step" and dom_k == "star" and dow_k == "star":
return {**base, "type": "hour-n", "minute": min_v, "interval": hour_v}
if min_k == "fixed" and hour_k == "fixed" and dom_k == "star" and dow_k == "fixed":
w = 0 if dow_f == "7" else dow_v
return {**base, "type": "week", "minute": min_v, "hour": hour_v, "week": w}
if min_k == "fixed" and hour_k == "fixed" and dom_k == "fixed" and dow_k == "star":
return {**base, "type": "month", "minute": min_v, "hour": hour_v, "monthDay": dom_v}
if min_k == "fixed" and hour_k == "fixed" and dom_k == "step" and dow_k == "star":
return {**base, "type": "day-n", "minute": min_v, "hour": hour_v, "interval": dom_v}
if min_k == "fixed" and hour_k == "fixed" and dom_k == "star" and dow_k == "star":
return {**base, "type": "day", "minute": min_v, "hour": hour_v}
return {**base, "type": "custom", "customCron": expr}
_WEEK_LABELS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
def format_cycle_label(cron_or_config: str | dict[str, Any]) -> str:
config = parse_cron_to_cycle(cron_or_config) if isinstance(cron_or_config, str) else cron_or_config
kind = config.get("type", "day")
if kind == "custom":
cc = config.get("customCron") or ""
return f"自定义: {cc}" if cc else ""
minute = int(config.get("minute", 0))
hour = int(config.get("hour", 0))
time_s = f"{hour:02d}:{minute:02d}"
if kind == "day":
return f"当天 {time_s}"
if kind == "day-n":
return f"{config.get('interval', 1)}{time_s}"
if kind == "hour":
return f"每小时 第 {minute}"
if kind == "hour-n":
return f"{config.get('interval', 1)} 小时 第 {minute}"
if kind == "minute-n":
return f"{config.get('interval', 1)} 分钟"
if kind == "week":
w = int(config.get("week", 0))
wd = _WEEK_LABELS[w] if 0 <= w < 7 else f"{w}"
return f"每周{wd} {time_s}"
if kind == "month":
return f"每月 {config.get('monthDay', 1)}{time_s}"
return ""
+139
View File
@@ -0,0 +1,139 @@
"""Resolve public/private IPs for server alert Telegram messages."""
from __future__ import annotations
import ipaddress
import json
import logging
from typing import Any
logger = logging.getLogger("nexus.server_alert_ips")
def is_public_ip(value: str | None) -> bool:
text = (value or "").strip()
if not text:
return False
try:
addr = ipaddress.ip_address(text)
except ValueError:
return False
return not (
addr.is_private
or addr.is_loopback
or addr.is_link_local
or addr.is_reserved
)
def is_private_ip(value: str | None) -> bool:
text = (value or "").strip()
if not text:
return False
try:
addr = ipaddress.ip_address(text)
except ValueError:
return False
return addr.is_private or addr.is_loopback or addr.is_link_local
def classify_domain_ip(domain: str | None) -> tuple[str | None, str | None]:
"""Return (public_ip, private_ip) when domain is a literal IP."""
text = (domain or "").strip()
if not text:
return None, None
try:
ipaddress.ip_address(text)
except ValueError:
return None, None
if is_public_ip(text):
return text, None
if is_private_ip(text):
return None, text
return None, None
def merge_alert_ips(
*,
domain: str | None = None,
extra_attrs: dict[str, Any] | None = None,
system_info: dict[str, Any] | None = None,
) -> tuple[str | None, str | None]:
"""Pick public/private IPs for Telegram display.
外网 SSOTservers.domain 为公网 IP 时优先(运维登记连接地址,如 120.79.11.13)。
内网:Agent 上报 private_ip 优先,否则 domain 为私网 IP 时使用。
Agent ipify 仅作 domain/extra 均无公网时的兜底。
"""
public_ip: str | None = None
private_ip: str | None = None
info = system_info if isinstance(system_info, dict) else {}
attrs = extra_attrs if isinstance(extra_attrs, dict) else {}
dom_pub, dom_priv = classify_domain_ip(domain)
if dom_pub:
public_ip = dom_pub
if not public_ip:
for key in ("public_ip", "wan_ip", "external_ip"):
raw = attrs.get(key)
if raw and is_public_ip(str(raw)):
public_ip = str(raw).strip()
break
if not public_ip:
for key in ("public_ip", "wan_ip", "external_ip"):
raw = info.get(key)
if raw and is_public_ip(str(raw)):
public_ip = str(raw).strip()
break
for key in ("private_ip", "lan_ip", "internal_ip"):
raw = info.get(key)
if raw and is_private_ip(str(raw)):
private_ip = str(raw).strip()
break
if not private_ip and dom_priv:
private_ip = dom_priv
return public_ip, private_ip
def format_alert_ip_line(public_ip: str | None, private_ip: str | None) -> str:
pub = (public_ip or "").strip() or ""
priv = (private_ip or "").strip() or ""
return f"IP地址:{pub}(外) {priv}(内)"
async def resolve_alert_ips_for_server(server_id: int) -> tuple[str | None, str | None]:
"""Load server row + Redis heartbeat system_info for Telegram IP lines."""
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.redis.client import get_redis
domain: str | None = None
extra_attrs: dict[str, Any] | None = None
system_info: dict[str, Any] | None = None
try:
async with AsyncSessionLocal() as session:
server = await ServerRepositoryImpl(session).get_by_id(server_id)
if server:
domain = server.domain
extra_attrs = server.extra_attrs if isinstance(server.extra_attrs, dict) else None
except Exception as e:
logger.warning("resolve_alert_ips DB read failed server_id=%s: %s", server_id, e)
try:
redis = get_redis()
raw = await redis.hget(f"heartbeat:{server_id}", "system_info")
if raw:
parsed = json.loads(raw)
if isinstance(parsed, dict):
system_info = parsed
except Exception as e:
logger.debug("resolve_alert_ips Redis read failed server_id=%s: %s", server_id, e)
return merge_alert_ips(domain=domain, extra_attrs=extra_attrs, system_info=system_info)
+68
View File
@@ -0,0 +1,68 @@
"""Per-server metric parsing and offline segment merging."""
from __future__ import annotations
import json
from typing import Any
from server.utils.fleet_metrics import _metric_from_system_info
def parse_system_info_metrics(system_info: dict[str, Any] | str | None) -> tuple[int | None, int | None]:
"""Extract CPU/mem integer percentages from Agent system_info."""
if system_info is None:
return None, None
info: dict[str, Any]
if isinstance(system_info, str):
try:
parsed = json.loads(system_info)
except json.JSONDecodeError:
return None, None
info = parsed if isinstance(parsed, dict) else {}
elif isinstance(system_info, dict):
info = system_info
else:
return None, None
cpu = _metric_from_system_info(info, "cpu_usage", "cpu")
mem = _metric_from_system_info(info, "mem_usage", "mem")
cpu_i = round(cpu) if cpu is not None else None
mem_i = round(mem) if mem is not None else None
return cpu_i, mem_i
def sample_from_redis_heartbeat(data: dict[str, Any]) -> tuple[bool, int | None, int | None]:
"""Map Redis heartbeat hash to (is_online, cpu_pct, mem_pct) for one flush sample."""
is_online = data.get("is_online") == "True"
if not is_online:
return False, None, None
cpu, mem = parse_system_info_metrics(data.get("system_info"))
return True, cpu, mem
def merge_offline_segments(points: list[dict[str, Any]]) -> list[dict[str, str]]:
"""Merge consecutive is_online=false points into {from, to} segments (recorded_at strings)."""
if not points:
return []
segments: list[dict[str, str]] = []
seg_start: str | None = None
prev_at: str | None = None
for point in points:
recorded_at = str(point.get("recorded_at") or "")
if not recorded_at:
continue
is_online = bool(point.get("is_online"))
if not is_online:
if seg_start is None:
seg_start = recorded_at
elif seg_start is not None and prev_at is not None:
segments.append({"from": seg_start, "to": prev_at})
seg_start = None
prev_at = recorded_at
if seg_start is not None and prev_at is not None:
segments.append({"from": seg_start, "to": prev_at})
return segments
+66
View File
@@ -0,0 +1,66 @@
"""Server list search history — normalize and merge rules (DB payload shape)."""
from __future__ import annotations
MAX_SERVER_SEARCH_HISTORY = 12
MAX_PUSH_SEARCH_HISTORY = 5
MAX_TERMINAL_SEARCH_HISTORY = 10
MAX_SERVER_SEARCH_QUERY_LEN = 200
SERVERS_SEARCH_CONTEXT = "servers_search"
PUSH_SEARCH_CONTEXT = "push_search"
TERMINAL_SEARCH_CONTEXT = "terminal_search"
def normalize_search_query(query: str | None) -> str:
return (query or "").strip()[:MAX_SERVER_SEARCH_QUERY_LEN]
def empty_search_state() -> dict:
return {"history": [], "last": None}
def parse_search_state(raw: dict | None, *, max_history: int = MAX_SERVER_SEARCH_HISTORY) -> dict:
if not raw:
return empty_search_state()
history_raw = raw.get("history")
history: list[str] = []
if isinstance(history_raw, list):
for item in history_raw:
if not isinstance(item, str):
continue
q = normalize_search_query(item)
if q and q not in history:
history.append(q)
if len(history) >= max_history:
break
last_raw = raw.get("last")
last = normalize_search_query(last_raw) if isinstance(last_raw, str) else ""
last = last or None
return {"history": history, "last": last}
def apply_search_record(state: dict, query: str, *, max_history: int = MAX_SERVER_SEARCH_HISTORY) -> dict:
q = normalize_search_query(query)
history = list(state.get("history") or [])
if not q:
return {"history": history, "last": None}
history = [q] + [x for x in history if x != q][: max_history - 1]
return {"history": history, "last": q}
def apply_search_replace(
*,
history: list[str],
last: str | None = None,
max_history: int = MAX_SERVER_SEARCH_HISTORY,
) -> dict:
cleaned: list[str] = []
for item in history:
q = normalize_search_query(item)
if q and q not in cleaned:
cleaned.append(q)
if len(cleaned) >= max_history:
break
last_norm = normalize_search_query(last) if last else ""
last_val = last_norm or (cleaned[0] if cleaned else None)
return {"history": cleaned, "last": last_val}
+99
View File
@@ -0,0 +1,99 @@
"""Resolve display site hostname from server fields (mirrors frontend serverSiteUrl.ts)."""
from __future__ import annotations
import ipaddress
import re
from typing import Any
from urllib.parse import urlparse
_IP_RE = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$")
_CJK_RE = re.compile(r"[\u4e00-\u9fff]")
_ASCII_HOST_RE = re.compile(
r"^[a-z0-9](?:[a-z0-9-]*\.)+[a-z]{2,63}$",
re.IGNORECASE,
)
_SKIP_TARGET_SEGMENTS = frozenset({"shop", "crmeb", "public", "me", "crmweb"})
def is_ip_host(value: str | None) -> bool:
"""True when *value* is a literal IPv4/IPv6 address (SSH domain field)."""
text = (value or "").strip()
if not text:
return False
try:
ipaddress.ip_address(text.split("/")[0])
return True
except ValueError:
return False
def _host_from_string(raw: str) -> str | None:
trimmed = raw.strip()
if not trimmed:
return None
# 备注(中文 tonex 名等)勿当站点域名
if _CJK_RE.search(trimmed):
return None
if trimmed.startswith(("http://", "https://")):
try:
host = urlparse(trimmed).hostname
except Exception:
return None
if host and not is_ip_host(host) and _ASCII_HOST_RE.match(host):
return host.lower()
return None
host = trimmed.replace("http://", "").replace("https://", "")
host = host.split("/")[0].split(":")[0].strip()
if not host or is_ip_host(host) or not _ASCII_HOST_RE.match(host):
return None
return host.lower()
def parse_site_host_from_target_path(target_path: str | None) -> str | None:
p = (target_path or "").strip().rstrip("/")
if not p:
return None
parts = [seg for seg in p.split("/") if seg]
root_idx = parts.index("wwwroot") if "wwwroot" in parts else -1
candidates: list[str] = []
if root_idx >= 0:
candidates.extend(parts[root_idx + 1 :])
elif parts:
candidates.append(parts[-1])
for seg in reversed(candidates):
if seg.lower() in _SKIP_TARGET_SEGMENTS:
continue
if _IP_RE.match(seg):
continue
if "." in seg:
return seg.lower()
return None
def resolve_server_site_host(
*,
description: str | None = None,
target_path: str | None = None,
extra_attrs: dict[str, Any] | None = None,
) -> str | None:
extra = extra_attrs if isinstance(extra_attrs, dict) else {}
for key in ("site_url", "site_domain", "website"):
val = extra.get(key)
if isinstance(val, str):
host = _host_from_string(val)
if host:
return host
if description and description.strip():
host = _host_from_string(description)
if host:
return host
return parse_site_host_from_target_path(target_path)
def resolve_server_site_host_from_server(server: Any) -> str | None:
return resolve_server_site_host(
description=getattr(server, "description", None),
target_path=getattr(server, "target_path", None),
extra_attrs=getattr(server, "extra_attrs", None),
)
+38
View File
@@ -0,0 +1,38 @@
"""Validate SSH private key PEM and optionally derive OpenSSH public key."""
from __future__ import annotations
import asyncssh
def normalize_ssh_private_key_pem(private_key: str) -> str:
"""Strip outer whitespace; PEM body newlines preserved."""
return (private_key or "").strip()
def prepare_ssh_key_preset_fields(
private_key: str,
public_key: str | None = None,
) -> tuple[str, str | None]:
"""
Validate PEM private key (RSA / OPENSSH / PKCS#8) and return (private, public).
If public_key is empty, derive OpenSSH public key from private (asyncssh).
"""
pem = normalize_ssh_private_key_pem(private_key)
if not pem:
raise ValueError("私钥不能为空")
if "BEGIN" not in pem or "PRIVATE KEY" not in pem:
raise ValueError(
"私钥须为 PEM 格式(整文件粘贴,含 -----BEGIN … PRIVATE KEY----- 首尾行)"
)
try:
key = asyncssh.import_private_key(pem)
except Exception as exc:
raise ValueError("私钥格式无效,无法解析;请确认是未加密的 PEM 私钥") from exc
pub = (public_key or "").strip() or None
if not pub:
exported = key.export_public_key("openssh")
pub = exported.decode() if isinstance(exported, bytes) else exported
return pem, pub
+85
View File
@@ -0,0 +1,85 @@
"""Translate rsync/SSH push error text to Chinese for sync_logs and API responses."""
from __future__ import annotations
import re
# Order matters: more specific patterns first.
_PATTERNS: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"rsync:\s*connection unexpectedly closed", re.I), "rsync:连接意外关闭"),
(re.compile(r"rsync error:\s*some files/attrs were not transferred", re.I), "rsync 错误:部分文件或属性未能传输"),
(re.compile(r"rsync error:\s*error in rsync protocol data stream", re.I), "rsync 错误:协议数据流异常"),
(re.compile(r"rsync error:\s*", re.I), "rsync 错误:"),
(re.compile(r"rsync:\s*", re.I), "rsync"),
(re.compile(r"Host key is not trusted for host", re.I), "SSH 主机密钥未信任(未知主机)"),
(re.compile(r"Host key verification failed", re.I), "SSH 主机密钥验证失败"),
(re.compile(r"Permission denied for user .+ on host", re.I), "SSH 认证失败:用户名或密码/密钥不正确"),
(re.compile(r"Login failed for user", re.I), "SSH 登录失败:用户名或密码/密钥不正确"),
(re.compile(r"Key exchange failed", re.I), "SSH 密钥交换失败"),
(re.compile(r"Disconnect reason:", re.I), "SSH 连接断开:"),
(re.compile(r"OpenSSH protocol error", re.I), "SSH 协议错误"),
(re.compile(r"Connection lost", re.I), "SSH 连接丢失"),
(re.compile(r"Connection closed", re.I), "SSH 连接已关闭"),
(re.compile(r"Could not resolve hostname", re.I), "无法解析主机名"),
(re.compile(r"Permission denied,\s*please try again", re.I), "权限被拒绝,请重试"),
(re.compile(r"Permission denied\s*\(publickey", re.I), "公钥认证失败,权限被拒绝"),
(re.compile(r"Permission denied", re.I), "权限被拒绝"),
(re.compile(r"No such file or directory", re.I), "文件或目录不存在"),
(re.compile(r"Connection refused", re.I), "连接被拒绝"),
(re.compile(r"Connection timed out", re.I), "连接超时"),
(re.compile(r"Connection reset by peer", re.I), "连接被对端重置"),
(re.compile(r"Network is unreachable", re.I), "网络不可达"),
(re.compile(r"No route to host", re.I), "无法路由到主机"),
(re.compile(r"No space left on device", re.I), "磁盘空间不足"),
(re.compile(r"Read-only file system", re.I), "文件系统为只读"),
(re.compile(r"Operation timed out", re.I), "操作超时"),
(re.compile(r"Operation not permitted", re.I), "操作不允许"),
(re.compile(r"Authentication failed", re.I), "认证失败"),
(re.compile(r"Broken pipe", re.I), "连接中断(管道破裂)"),
(re.compile(r"Protocol error", re.I), "协议错误"),
(re.compile(r"Too many open files", re.I), "打开的文件过多"),
(re.compile(r"Name or service not known", re.I), "无法解析主机名或服务名"),
(re.compile(r"ssh:\s*connect to host", re.I), "ssh:无法连接到主机"),
(re.compile(r"ssh:\s*", re.I), "ssh"),
(re.compile(r"Command failed\s*\(exit\s*(\d+)\)", re.I), r"命令失败(退出码 \1"),
(re.compile(r"rsync exited with code\s*(\d+)", re.I), r"rsync 退出码 \1"),
(re.compile(r"\bexit code\s*(\d+)", re.I), r"退出码 \1"),
(re.compile(r"Server not found", re.I), "服务器不存在"),
(re.compile(r"failed to set times on", re.I), "无法设置文件时间戳:"),
(re.compile(r"sender", re.I), "发送端"),
(re.compile(r"receiver", re.I), "接收端"),
]
_CJK_RE = re.compile(r"[\u4e00-\u9fff\u3400-\u4dbf]")
def _is_primarily_chinese(text: str) -> bool:
cjk = len(_CJK_RE.findall(text))
if cjk == 0:
return False
# Heuristic: any CJK and CJK chars >= 20% of non-whitespace length
compact = re.sub(r"\s+", "", text)
if not compact:
return False
return cjk / len(compact) >= 0.2
def translate_ssh_error_message(message: str | None) -> str | None:
"""Return a Chinese-localized SSH/rsync error string, or the original if already Chinese."""
return translate_sync_error_message(message)
def translate_sync_error_message(message: str | None) -> str | None:
"""Return a Chinese-localized push/sync error string, or the original if already Chinese."""
if message is None:
return None
text = message.strip()
if not text:
return message
if _is_primarily_chinese(text):
return message
result = text
for pattern, repl in _PATTERNS:
result = pattern.sub(repl, result)
return result
+51
View File
@@ -0,0 +1,51 @@
"""UTF-8 text I/O and EOL helpers (Windows dev → Linux deploy safe).
Use for config files (.env, .json, shell scripts) written on the Nexus host or
by the install wizard — never rely on platform-default encodings or CRLF from
the OS.
"""
from __future__ import annotations
from pathlib import Path
from typing import Literal
EolKind = Literal["LF", "CRLF", "CR"]
def normalize_text_eol(text: str) -> str:
"""Normalize any common EOL to LF for internal processing."""
return text.replace("\r\n", "\n").replace("\r", "\n")
def detect_text_eol(text: str) -> EolKind:
"""Detect dominant line ending style (for API / editor round-trip)."""
if "\r\n" in text:
return "CRLF"
if "\r" in text:
return "CR"
return "LF"
def read_utf8_text(path: Path) -> str:
"""Read UTF-8 text; strip BOM; do not alter EOL (for round-trip edits)."""
return path.read_text(encoding="utf-8-sig")
def read_utf8_lf(path: Path) -> str:
"""Read UTF-8 and normalize line endings to LF."""
return normalize_text_eol(read_utf8_text(path))
def write_utf8_lf(path: Path, content: str) -> None:
"""Write UTF-8 with LF newlines only (safe on Windows dev, required on Ubuntu)."""
path.write_text(normalize_text_eol(content), encoding="utf-8", newline="\n")
def write_utf8_lf_lines(path: Path, lines: list[str]) -> None:
"""Write lines joined with LF (no trailing newline unless last line is empty)."""
body = "\n".join(lines)
if body and not body.endswith("\n"):
write_utf8_lf(path, body + "\n")
else:
write_utf8_lf(path, body)
+115
View File
@@ -0,0 +1,115 @@
"""Parse GNU `ls -la` permission strings and listing lines."""
from __future__ import annotations
import re
_ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
_TZ_OFFSET = re.compile(r"^[+-]\d{4}$")
def modified_from_ls_parts(parts: list[str]) -> str:
"""Build display timestamp from ``ls`` fields (without filename)."""
if len(parts) < 6:
return ""
if len(parts) >= 7 and _ISO_DATE.match(parts[5]):
time_part = parts[6].split(".", 1)[0] if parts[6] else ""
return f"{parts[5]} {time_part}".strip()
if len(parts) >= 9:
return " ".join(parts[5:8])
return " ".join(parts[5:])
def parse_ls_la_line(line: str) -> dict | None:
"""Parse one ``ls -la`` line; returns None for total/skip lines.
Handles two timestamp formats produced by different ``ls`` implementations:
- Standard (3-token date): ``perms nlinks owner group size Mon DD HH:MM name`` → 9+ fields
- GNU long-iso (2-token): ``perms nlinks owner group size YYYY-MM-DD HH:MM name`` → 8+ fields
With ``--time-style=long-iso`` the date collapses from 3 tokens to 2, so
the total token count drops from 9 to 8. Directories and regular files were
silently dropped (only symlinks survived because their names contain
``" -> target"`` which pushed the count back to 9).
"""
stripped = line.strip()
if not stripped or stripped.startswith("total "):
return None
# Split into at most 9 pieces so filenames with spaces are kept intact.
parts = stripped.split(None, 8)
if len(parts) < 8:
return None
perms = parts[0]
is_dir = perms.startswith("d")
is_link = perms.startswith("l")
# Detect format by checking whether field[5] is an ISO date (YYYY-MM-DD).
# long-iso: [perms nlinks owner group size DATE TIME name] → name at index 7
# standard: [perms nlinks owner group size MON DAY TIME name] → name at index 8
if _ISO_DATE.match(parts[5]):
# long-iso: DATE TIME [TZ] name — GNU ls may append +0800 before filename
if len(parts) >= 9 and _TZ_OFFSET.match(parts[7]):
name = parts[8]
elif len(parts) >= 8:
name = parts[7]
else:
return None
modified = f"{parts[5]} {parts[6].split('.', 1)[0]}"
else:
# standard format — need at least 9 tokens for the name at index 8
if len(parts) < 9:
return None
name = parts[8]
modified = modified_from_ls_parts(parts)
link_target = None
if is_link and " -> " in name:
name, link_target = name.split(" -> ", 1)
if name in (".", ".."):
return None
return {
"name": name,
"is_dir": is_dir,
"is_link": is_link,
"link_target": link_target,
"size": parts[4],
"perms": perms,
"owner": parts[2],
"group": parts[3] if len(parts) > 3 else "",
"modified": modified,
}
def perms_to_mode_octal(perms: str) -> str | None:
"""Convert ``drwxr-xr-x`` / ``-rw-r--r--`` to ``755`` / ``644``."""
if not perms or len(perms) < 10:
return None
bits = perms[1:10]
if len(bits) != 9:
return None
def _tri(chunk: str) -> int:
value = 0
if chunk[0] == "r":
value += 4
if chunk[1] == "w":
value += 2
if chunk[2] in "xXsStT":
value += 1
return value
return f"{_tri(bits[0:3])}{_tri(bits[3:6])}{_tri(bits[6:9])}"
def format_owner_label(owner: str, group: str | None = None) -> str:
"""Primary list label: ``www`` or ``www:nogroup`` when group differs."""
o = (owner or "").strip()
g = (group or "").strip()
if not o:
return ""
if not g or g == o:
return o
return f"{o}:{g}"
+145
View File
@@ -0,0 +1,145 @@
"""Translate Pydantic / FastAPI validation errors to Chinese for API 422 responses."""
from __future__ import annotations
import re
from typing import Any
# Pydantic v2 default messages (exact match)
_MSG_EXACT: dict[str, str] = {
"Field required": "必填",
"Input should be a valid string": "必须是字符串",
"Input should be a valid integer": "必须是整数",
"Input should be a valid number": "必须是数字",
"Input should be a valid boolean": "必须是布尔值",
"Input should be a valid dictionary": "必须是对象",
"Input should be a valid list": "必须是数组",
"JSON decode error": "JSON 格式无效",
"Input should be a valid dictionary or object to extract fields from": "请求体必须是 JSON 对象",
"Input should be a valid UUID": "必须是有效的 UUID",
"Input should be a valid datetime": "必须是有效的日期时间",
"Input should be a valid date": "必须是有效的日期",
"Input should be a valid time": "必须是有效的时间",
"Input should be a valid url": "必须是有效的 URL",
"Input should be a valid email address": "必须是有效的邮箱",
}
# Regex patterns for parameterized Pydantic messages (legacy / string fields)
_MSG_PATTERNS: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"^String should have at least (\d+) characters?$"), r"至少需要 \1 个字符"),
(re.compile(r"^String should have at most (\d+) characters?$"), r"最多 \1 个字符"),
(re.compile(r"^String should match pattern '(.+)'$"), r"格式不符合要求"),
(re.compile(r"^Input should be greater than or equal to (\d+)"), r"不能小于 \1"),
(re.compile(r"^Input should be less than or equal to (\d+)"), r"不能大于 \1"),
(re.compile(r"^Input should be greater than (\d+)"), r"必须大于 \1"),
(re.compile(r"^Input should be less than (\d+)"), r"必须小于 \1"),
(re.compile(r"^List should have at least (\d+) items?(?: .*)?$"), r"至少需要 \1 项"),
(re.compile(r"^List should have at most (\d+) items?(?: after validation, not (\d+))?$"), r"最多 \1 项"),
]
def translate_validation_msg(msg: str, err_type: str = "", ctx: dict[str, Any] | None = None) -> str:
"""Return Chinese message when a known translation exists."""
ctx = ctx or {}
if err_type == "too_long" and ctx.get("field_type") == "List":
max_len = ctx.get("max_length")
actual = ctx.get("actual_length")
if max_len is not None and actual is not None:
return f"最多 {max_len} 项,当前 {actual}"
if max_len is not None:
return f"最多 {max_len}"
if err_type == "too_short" and ctx.get("field_type") == "List":
min_len = ctx.get("min_length")
actual = ctx.get("actual_length")
if min_len is not None and actual is not None:
return f"至少需要 {min_len} 项,当前 {actual}"
if min_len is not None:
return f"至少需要 {min_len}"
if err_type == "missing":
return "必填"
if not msg:
return msg
if msg in _MSG_EXACT:
return _MSG_EXACT[msg]
for pattern, repl in _MSG_PATTERNS:
if pattern.match(msg):
out = pattern.sub(repl, msg)
# too_long list with "not N" suffix — append actual count when present
m = re.search(r"not (\d+)$", msg)
if m and "最多" in out and "当前" not in out:
return f"{out},当前 {m.group(1)}"
return out
if msg.startswith("Value error, "):
return msg[len("Value error, "):]
if err_type == "string_type":
return "必须是字符串"
if err_type in ("int_type", "int_parsing"):
return "必须是整数"
if err_type in ("float_type", "float_parsing"):
return "必须是数字"
if err_type == "bool_parsing":
return "必须是 true 或 false"
if err_type == "less_than_equal" and ctx.get("le") is not None:
return f"不能大于 {ctx['le']}"
if err_type == "greater_than_equal" and ctx.get("ge") is not None:
return f"不能小于 {ctx['ge']}"
if err_type == "less_than" and ctx.get("lt") is not None:
return f"必须小于 {ctx['lt']}"
if err_type == "greater_than" and ctx.get("gt") is not None:
return f"必须大于 {ctx['gt']}"
return msg
_LOC_PREFIX_SKIP = frozenset({"query", "body", "path", "header", "cookie"})
_FIELD_ZH: dict[str, str] = {
"per_page": "每页条数",
"page": "页码",
"limit": "条数",
"offset": "偏移量",
"server_ids": "服务器",
"source_path": "源路径",
"target_path": "目标路径",
"sync_mode": "同步模式",
"batch_id": "批次 ID",
"batch_size": "批次大小",
"concurrency": "并发数",
"value": "",
"username": "用户名",
"password": "密码",
"name": "名称",
"cron_expr": "Cron 表达式",
"fire_at": "执行时间",
}
def translate_loc_zh(loc: Any) -> str:
"""Human-readable Chinese field path (no query/body prefixes)."""
if not isinstance(loc, (list, tuple)):
return "参数"
parts: list[str] = []
for piece in loc:
if isinstance(piece, str) and piece in _LOC_PREFIX_SKIP:
continue
if isinstance(piece, int):
parts.append(f"{piece + 1}")
continue
key = str(piece)
parts.append(_FIELD_ZH.get(key, key))
return ".".join(parts) if parts else "参数"
def translate_validation_errors(errors: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Clone Pydantic error dicts with translated ``msg`` and ``loc_zh``."""
translated: list[dict[str, Any]] = []
for err in errors:
ctx = err.get("ctx") if isinstance(err.get("ctx"), dict) else None
item = {k: v for k, v in err.items() if k != "ctx"}
item["msg"] = translate_validation_msg(
str(err.get("msg", "")),
str(err.get("type", "")),
ctx,
)
item["loc_zh"] = translate_loc_zh(err.get("loc"))
translated.append(item)
return translated
+46
View File
@@ -0,0 +1,46 @@
"""Watch probe alert linkage — reuse agent threshold detection."""
from __future__ import annotations
import logging
from server.config import settings
from server.infrastructure.redis.client import get_redis
from server.utils.alert_streak import process_metric_alerts
from server.utils.watch_metrics import PROBE_OK, WatchProbeSample
logger = logging.getLogger("nexus.watch_alerts")
def _sample_to_system_info(sample: WatchProbeSample) -> dict:
return {
"cpu_usage": sample.cpu_pct,
"mem_usage": sample.mem_pct,
"disk_usage": sample.disk_pct,
}
async def process_watch_probe_alerts(
*,
server_id: int,
server_name: str,
sample: WatchProbeSample,
) -> None:
"""Fire alert/recovery broadcasts when pinned-server probe exceeds thresholds."""
if sample.probe_status != PROBE_OK:
return
thresholds = {
"cpu": settings.CPU_ALERT_THRESHOLD,
"mem": settings.MEM_ALERT_THRESHOLD,
"disk": settings.DISK_ALERT_THRESHOLD,
}
await process_metric_alerts(
redis=get_redis(),
server_id=server_id,
server_name=server_name,
system_info=_sample_to_system_info(sample),
thresholds=thresholds,
log_prefix="Watch alert",
)
+562
View File
@@ -0,0 +1,562 @@
"""Watch slot probe — rate calculation and metric normalization."""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
from server.utils.fleet_metrics import _metric_from_system_info, _safe_pct
WATCH_MAX_SLOTS = 4
WATCH_PIN_TTL_DEFAULT_MINUTES = 30
WATCH_PIN_TTL_ALLOWED_MINUTES = frozenset({30, 60, 120, 480, 1440})
# 兼容旧 API / 列名
WATCH_PIN_TTL_HOURS = 8
WATCH_PIN_TTL_EXTENDED_HOURS = 24
def normalize_watch_ttl_minutes(minutes: int) -> int:
"""监测窗口:30分 / 1h / 2h / 8h / 24h(默认 30 分)。"""
if minutes in WATCH_PIN_TTL_ALLOWED_MINUTES:
return minutes
# 历史:按小时传入
legacy_map = {8: 480, 24: 1440, 1: 60, 2: 120}
if minutes in legacy_map:
return legacy_map[minutes]
return WATCH_PIN_TTL_DEFAULT_MINUTES
def normalize_watch_ttl_hours(hours: int) -> int:
"""Deprecated — use normalize_watch_ttl_minutes(hours * 60) for hour values."""
return normalize_watch_ttl_minutes(hours * 60 if hours in (1, 2, 8, 24) else hours)
def resolve_pin_ttl_minutes(*, ttl_minutes: int | None, ttl_hours: int | None) -> int:
if ttl_minutes and int(ttl_minutes) in WATCH_PIN_TTL_ALLOWED_MINUTES:
return int(ttl_minutes)
if ttl_hours in (8, 24):
return int(ttl_hours) * 60
return WATCH_PIN_TTL_DEFAULT_MINUTES
WATCH_PROBE_INTERVAL_SEC = 5
WATCH_REDIS_FRESH_SEC = 90
WATCH_AGENT_FRESH_SEC = 12 # 2× 5s watch interval + slack
WATCH_AGENT_INTERVAL_SEC = 5
WATCH_PROBE_TIMEOUT_SEC = 8
PROBE_OK = "ok"
PROBE_SSH_TIMEOUT = "ssh_timeout"
PROBE_SSH_AUTH = "ssh_auth"
PROBE_NO_CRED = "no_cred"
PROBE_REDIS_STALE = "redis_stale"
PROBE_PARSE_ERROR = "parse_error"
PROBE_OFFLINE = "offline"
@dataclass
class WatchProbeSample:
probe_status: str
source: str
is_online: bool = False
duration_ms: int = 0
error: str | None = None
cpu_pct: int | None = None
mem_pct: int | None = None
disk_pct: int | None = None
cpu_cores: int | None = None
cpu_cores_physical: int | None = None
cpu_model: str | None = None
memory_total_gb: float | None = None
memory_used_gb: float | None = None
memory_available_gb: float | None = None
disk_total_gb: float | None = None
disk_used_gb: float | None = None
disk_free_gb: float | None = None
swap_total_gb: float | None = None
swap_used_gb: float | None = None
swap_pct: float | None = None
os_release: str | None = None
uptime_seconds: int | None = None
disk_mount: str = "/"
load_1: float | None = None
load_5: float | None = None
load_15: float | None = None
process_running: int | None = None
process_total: int | None = None
per_cpu_pct: list[float] | None = None
mem_free_mb: int | None = None
mem_shared_mb: int | None = None
mem_buffers_mb: int | None = None
mem_cached_mb: int | None = None
net_bytes_sent: int | None = None
net_bytes_recv: int | None = None
disk_read_bytes: int | None = None
disk_write_bytes: int | None = None
net_up_bps: int | None = None
net_down_bps: int | None = None
disk_read_bps: int | None = None
disk_write_bps: int | None = None
processes_json: list[dict[str, Any]] | dict[str, Any] | None = None
_LIVE_HARDWARE_KEYS = (
"cpu_cores",
"cpu_cores_physical",
"cpu_model",
"memory_total_gb",
"memory_used_gb",
"memory_available_gb",
"disk_total_gb",
"disk_used_gb",
"disk_free_gb",
"disk_mount",
"swap_total_gb",
"swap_used_gb",
"swap_pct",
"os_release",
"uptime_seconds",
"process_running",
"process_total",
"per_cpu_pct",
"mem_free_mb",
"mem_shared_mb",
"mem_buffers_mb",
"mem_cached_mb",
)
def to_live_dict(self, *, server_id: int) -> dict[str, Any]:
out: dict[str, Any] = {
"server_id": server_id,
"probe_status": self.probe_status,
"source": self.source,
"is_online": self.is_online,
"cpu_pct": self.cpu_pct,
"mem_pct": self.mem_pct,
"disk_pct": self.disk_pct,
"load_1": self.load_1,
"load_5": self.load_5,
"load_15": self.load_15,
"error": self.error,
}
for key in self._LIVE_HARDWARE_KEYS:
val = getattr(self, key, None)
if val is not None and val != "":
out[key] = val
if self.processes_json:
out["processes"] = self.processes_json
return out
def redis_sample_has_basic_metrics(sample: WatchProbeSample | None) -> bool:
"""True when Agent heartbeat has CPU or memory — IO optional."""
if sample is None or sample.probe_status != PROBE_OK:
return False
return sample.cpu_pct is not None or sample.mem_pct is not None
def parse_heartbeat_system_info(heartbeat: dict[str, str]) -> dict[str, Any]:
raw = heartbeat.get("system_info") or "{}"
try:
info = json.loads(raw) if isinstance(raw, str) else (raw or {})
except json.JSONDecodeError:
return {}
return info if isinstance(info, dict) else {}
def heartbeat_watch_mode_active(heartbeat: dict[str, str]) -> bool:
"""True when Agent explicitly reports watch_mode in the last heartbeat payload."""
return bool(parse_heartbeat_system_info(heartbeat).get("watch_mode"))
def agent_watch_heartbeat_fresh(heartbeat: dict[str, str]) -> bool:
"""Stricter freshness for 5s Agent watch payloads (not 90s fleet heartbeat)."""
from datetime import datetime, timezone
if heartbeat.get("is_online") != "True":
return False
ts = heartbeat.get("last_heartbeat") or ""
if not ts:
return False
try:
agent_ts = datetime.fromisoformat(ts.replace("Z", "+00:00"))
if agent_ts.tzinfo is None:
agent_ts = agent_ts.replace(tzinfo=timezone.utc)
age = (datetime.now(timezone.utc) - agent_ts).total_seconds()
return age <= WATCH_AGENT_FRESH_SEC
except Exception:
return False
def agent_watch_sample_usable(
heartbeat: dict[str, str] | None,
sample: WatchProbeSample | None,
) -> bool:
"""Agent 5s path: fresh heartbeat + watch_mode + basic CPU/mem metrics."""
if not heartbeat or sample is None:
return False
if not agent_watch_heartbeat_fresh(heartbeat):
return False
if not heartbeat_watch_mode_active(heartbeat):
return False
return redis_sample_has_basic_metrics(sample)
def sanitize_processes(raw: Any) -> list[dict[str, Any]] | None:
if not isinstance(raw, list):
return None
out: list[dict[str, Any]] = []
for item in raw[:5]:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "")[:80]
out.append(
{
"pid": item.get("pid"),
"name": name,
"cpu_pct": item.get("cpu_pct"),
"mem_pct": item.get("mem_pct"),
}
)
return out or None
def sanitize_process_bundle(raw: dict[str, Any] | None) -> dict[str, Any] | None:
if not isinstance(raw, dict):
return None
top_cpu = sanitize_processes(raw.get("top_cpu")) or []
top_mem = sanitize_processes(raw.get("top_mem")) or []
legacy = sanitize_processes(raw.get("top_processes"))
if legacy and not top_cpu:
top_cpu = legacy
if not top_cpu and not top_mem:
return None
return {"top_cpu": top_cpu, "top_mem": top_mem}
@dataclass
class _RateState:
net_bytes_sent: int | None = None
net_bytes_recv: int | None = None
disk_read_bytes: int | None = None
disk_write_bytes: int | None = None
recorded_at_ts: float | None = None
def server_can_be_watched(server: Any) -> tuple[bool, str]:
"""Return whether server has Agent or SSH credentials for watch probes."""
if getattr(server, "agent_version", None):
return True, ""
if getattr(server, "password", None) or getattr(server, "ssh_key_private", None):
return True, ""
if getattr(server, "ssh_key_configured", None) or getattr(server, "preset_id", None):
return True, ""
if getattr(server, "ssh_key_preset_id", None):
return True, ""
return False, "需要已安装 Agent 或配置 SSH 凭据"
def _int_pct(value: Any) -> int | None:
pct = _safe_pct(value)
return round(pct) if pct is not None else None
def _positive_int(value: Any) -> int | None:
if value is None:
return None
try:
num = int(value)
except (TypeError, ValueError):
return None
return num if num >= 0 else None
def _cpu_cores(value: Any) -> int | None:
n = _positive_int(value)
return n if n and n > 0 else None
def _float_gb(value: Any) -> float | None:
if value is None:
return None
try:
num = float(value)
except (TypeError, ValueError):
return None
if num < 0:
return None
return round(num, 1)
def _short_text(value: Any, max_len: int = 120) -> str | None:
if value is None:
return None
text = str(value).strip()
if not text:
return None
return text[:max_len]
def _parse_hardware_fields(data: dict[str, Any]) -> dict[str, Any]:
return {
"cpu_cores": _cpu_cores(data.get("cpu_cores")),
"cpu_cores_physical": _cpu_cores(data.get("cpu_cores_physical")),
"cpu_model": _short_text(data.get("cpu_model")),
"memory_total_gb": _float_gb(data.get("memory_total_gb")),
"memory_used_gb": _float_gb(data.get("memory_used_gb")),
"memory_available_gb": _float_gb(data.get("memory_available_gb")),
"disk_total_gb": _float_gb(data.get("disk_total_gb")),
"disk_used_gb": _float_gb(data.get("disk_used_gb")),
"disk_free_gb": _float_gb(data.get("disk_free_gb")),
"swap_total_gb": _float_gb(data.get("swap_total_gb")),
"swap_used_gb": _float_gb(data.get("swap_used_gb")),
"swap_pct": _float_gb(data.get("swap_pct")),
"os_release": _short_text(data.get("os_release")),
"uptime_seconds": _positive_int(data.get("uptime_seconds")),
"process_running": _positive_int(data.get("process_running")),
"process_total": _positive_int(data.get("process_total")),
"per_cpu_pct": _parse_per_cpu_pct(data.get("per_cpu_pct")),
"mem_free_mb": _positive_int(data.get("mem_free_mb")),
"mem_shared_mb": _positive_int(data.get("mem_shared_mb")),
"mem_buffers_mb": _positive_int(data.get("mem_buffers_mb")),
"mem_cached_mb": _positive_int(data.get("mem_cached_mb")),
}
def _parse_per_cpu_pct(raw: Any) -> list[float] | None:
if not isinstance(raw, (list, tuple)):
return None
out: list[float] = []
for item in raw[:128]:
try:
out.append(round(float(item), 1))
except (TypeError, ValueError):
continue
return out or None
def fill_hardware_gaps(sample: WatchProbeSample) -> None:
"""Derive used/free GB from totals + % when probe omitted absolute values."""
if sample.memory_used_gb is None and sample.memory_total_gb is not None and sample.mem_pct is not None:
sample.memory_used_gb = round(sample.memory_total_gb * sample.mem_pct / 100, 1)
if sample.memory_available_gb is None and sample.memory_total_gb is not None and sample.memory_used_gb is not None:
sample.memory_available_gb = round(max(0.0, sample.memory_total_gb - sample.memory_used_gb), 1)
if sample.disk_used_gb is None and sample.disk_total_gb is not None and sample.disk_pct is not None:
sample.disk_used_gb = round(sample.disk_total_gb * sample.disk_pct / 100, 1)
if sample.disk_free_gb is None and sample.disk_total_gb is not None and sample.disk_used_gb is not None:
sample.disk_free_gb = round(max(0.0, sample.disk_total_gb - sample.disk_used_gb), 1)
def _pick_hardware_field(ssh_sample: WatchProbeSample, redis_sample: WatchProbeSample, field: str) -> Any:
sv = getattr(ssh_sample, field, None)
rv = getattr(redis_sample, field, None)
return sv if sv is not None and sv != "" else rv
def compute_rates(
prev: _RateState | None,
*,
net_bytes_sent: int | None,
net_bytes_recv: int | None,
disk_read_bytes: int | None,
disk_write_bytes: int | None,
now_ts: float,
) -> tuple[int | None, int | None, int | None, int | None]:
if prev is None or prev.recorded_at_ts is None:
return None, None, None, None
dt = now_ts - prev.recorded_at_ts
if dt <= 0:
return None, None, None, None
def _rate(curr: int | None, old: int | None) -> int | None:
if curr is None or old is None:
return None
delta = curr - old
if delta < 0:
return None
return int(delta / dt)
return (
_rate(net_bytes_sent, prev.net_bytes_sent),
_rate(net_bytes_recv, prev.net_bytes_recv),
_rate(disk_read_bytes, prev.disk_read_bytes),
_rate(disk_write_bytes, prev.disk_write_bytes),
)
def apply_rates_to_sample(sample: WatchProbeSample, prev: _RateState | None, now_ts: float) -> _RateState:
up, down, dr, dw = compute_rates(
prev,
net_bytes_sent=sample.net_bytes_sent,
net_bytes_recv=sample.net_bytes_recv,
disk_read_bytes=sample.disk_read_bytes,
disk_write_bytes=sample.disk_write_bytes,
now_ts=now_ts,
)
sample.net_up_bps = up
sample.net_down_bps = down
sample.disk_read_bps = dr
sample.disk_write_bps = dw
return _RateState(
net_bytes_sent=sample.net_bytes_sent,
net_bytes_recv=sample.net_bytes_recv,
disk_read_bytes=sample.disk_read_bytes,
disk_write_bytes=sample.disk_write_bytes,
recorded_at_ts=now_ts,
)
def parse_ssh_watch_payload(data: dict[str, Any]) -> WatchProbeSample:
load = data.get("load_avg") or data.get("load") or []
if isinstance(load, (list, tuple)) and len(load) >= 3:
load_1, load_5, load_15 = float(load[0]), float(load[1]), float(load[2])
else:
load_1 = float(data["load_1"]) if data.get("load_1") is not None else None
load_5 = float(data["load_5"]) if data.get("load_5") is not None else None
load_15 = float(data["load_15"]) if data.get("load_15") is not None else None
processes = sanitize_process_bundle(data) if "top_cpu" in data or "top_mem" in data else None
if processes is None:
legacy = sanitize_processes(data.get("top_processes"))
if legacy:
processes = {"top_cpu": legacy, "top_mem": []}
sample = WatchProbeSample(
probe_status=PROBE_OK,
source="ssh",
is_online=True,
cpu_pct=_int_pct(data.get("cpu_usage")),
mem_pct=_int_pct(data.get("mem_usage")),
disk_pct=_int_pct(data.get("disk_usage")),
disk_mount=str(data.get("disk_mount") or "/"),
load_1=load_1,
load_5=load_5,
load_15=load_15,
processes_json=processes,
**_parse_hardware_fields(data),
)
fill_hardware_gaps(sample)
return sample
def sample_needs_load_supplement(sample: WatchProbeSample) -> bool:
"""True when 1/5/15 load averages are missing from Agent-only path."""
if sample.probe_status != PROBE_OK:
return False
return sample.load_1 is None or sample.load_5 is None or sample.load_15 is None
def apply_load_avg_to_sample(sample: WatchProbeSample, load_avg: Any) -> WatchProbeSample:
"""Fill load_1/5/15 on an existing sample (mutates in place)."""
if not isinstance(load_avg, (list, tuple)) or len(load_avg) < 3:
return sample
if sample.load_1 is None:
sample.load_1 = float(load_avg[0])
if sample.load_5 is None:
sample.load_5 = float(load_avg[1])
if sample.load_15 is None:
sample.load_15 = float(load_avg[2])
return sample
def parse_redis_watch_payload(heartbeat: dict[str, str]) -> WatchProbeSample | None:
if heartbeat.get("is_online") != "True":
return None
raw = heartbeat.get("system_info") or "{}"
try:
info = json.loads(raw) if isinstance(raw, str) else (raw or {})
except json.JSONDecodeError:
return None
if not isinstance(info, dict):
return None
cpu = _metric_from_system_info(info, "cpu_usage", "cpu")
mem = _metric_from_system_info(info, "mem_usage", "mem")
disk = _metric_from_system_info(info, "disk_usage", "disk")
load = info.get("load_avg") or []
load_1 = load_5 = load_15 = None
if isinstance(load, (list, tuple)) and len(load) >= 3:
load_1, load_5, load_15 = float(load[0]), float(load[1]), float(load[2])
sample = WatchProbeSample(
probe_status=PROBE_OK,
source="redis",
is_online=True,
cpu_pct=round(cpu) if cpu is not None else None,
mem_pct=round(mem) if mem is not None else None,
disk_pct=round(disk) if disk is not None else None,
load_1=load_1,
load_5=load_5,
load_15=load_15,
**_parse_hardware_fields(info),
)
if cpu is None and mem is None:
return None
fill_hardware_gaps(sample)
return sample
def merge_redis_and_ssh(redis_sample: WatchProbeSample | None, ssh_sample: WatchProbeSample) -> WatchProbeSample:
if redis_sample is None:
return ssh_sample
if ssh_sample.probe_status != PROBE_OK:
return redis_sample
hw = {field: _pick_hardware_field(ssh_sample, redis_sample, field) for field in WatchProbeSample._LIVE_HARDWARE_KEYS}
def _pick_ssh_first(ssh_val, redis_val):
return ssh_val if ssh_val is not None else redis_val
out = WatchProbeSample(
probe_status=PROBE_OK,
source="mixed",
is_online=True,
duration_ms=ssh_sample.duration_ms,
# 5s Agent watch 优先;SSH 仅兜底或无 watch_mode 时
cpu_pct=_pick_ssh_first(ssh_sample.cpu_pct, redis_sample.cpu_pct),
mem_pct=_pick_ssh_first(ssh_sample.mem_pct, redis_sample.mem_pct),
disk_pct=_pick_ssh_first(ssh_sample.disk_pct, redis_sample.disk_pct),
disk_mount=str(hw.get("disk_mount") or ssh_sample.disk_mount or redis_sample.disk_mount or "/"),
load_1=_pick_ssh_first(ssh_sample.load_1, redis_sample.load_1),
load_5=_pick_ssh_first(ssh_sample.load_5, redis_sample.load_5),
load_15=_pick_ssh_first(ssh_sample.load_15, redis_sample.load_15),
cpu_cores=hw.get("cpu_cores"),
cpu_cores_physical=hw.get("cpu_cores_physical"),
cpu_model=hw.get("cpu_model"),
memory_total_gb=hw.get("memory_total_gb"),
memory_used_gb=hw.get("memory_used_gb"),
memory_available_gb=hw.get("memory_available_gb"),
disk_total_gb=hw.get("disk_total_gb"),
disk_used_gb=hw.get("disk_used_gb"),
disk_free_gb=hw.get("disk_free_gb"),
swap_total_gb=hw.get("swap_total_gb"),
swap_used_gb=hw.get("swap_used_gb"),
swap_pct=hw.get("swap_pct"),
os_release=hw.get("os_release"),
uptime_seconds=hw.get("uptime_seconds"),
process_running=hw.get("process_running"),
process_total=hw.get("process_total"),
per_cpu_pct=hw.get("per_cpu_pct"),
mem_free_mb=hw.get("mem_free_mb"),
mem_shared_mb=hw.get("mem_shared_mb"),
mem_buffers_mb=hw.get("mem_buffers_mb"),
mem_cached_mb=hw.get("mem_cached_mb"),
processes_json=ssh_sample.processes_json or redis_sample.processes_json,
)
fill_hardware_gaps(out)
return out
def failure_sample(
*,
status: str,
source: str,
error: str,
duration_ms: int = 0,
is_online: bool = False,
) -> WatchProbeSample:
return WatchProbeSample(
probe_status=status,
source=source,
is_online=is_online,
duration_ms=duration_ms,
error=error[:255],
)
+61
View File
@@ -0,0 +1,61 @@
"""Chinese user-facing messages for watch probe failures."""
from __future__ import annotations
_EXACT: dict[str, str] = {
"parse_error": "探针返回数据无法解析",
"psutil missing": "子机未安装 psutil",
"ssh_probe_failed": "SSH 探针脚本执行失败",
"SSH watch probe failed": "SSH 监测探针失败",
"SSH probe failed": "SSH 探针失败",
"timeout": "连接超时",
"探针失败": "探针失败",
"无 Agent 且 SSH 探针失败": "无 Agent 且 SSH 探针失败",
"服务器不存在": "服务器不存在",
}
def watch_probe_error_zh(error: str | None, *, status: str | None = None) -> str | None:
"""Map probe error text to Chinese for UI / audit display."""
if not error:
if status == "parse_error":
return "探针返回数据无法解析"
if status == "ssh_timeout":
return "SSH 连接超时"
if status == "ssh_auth":
return "SSH 认证失败"
if status == "no_cred":
return "需要 Agent 或 SSH 凭据"
if status == "redis_stale":
return "Agent 心跳已过期"
if status == "offline":
return "目标服务器离线或不可达"
return None
text = error.strip()
if text in _EXACT:
return _EXACT[text]
lower = text.lower()
if lower.startswith("parse_error"):
detail = text.split(":", 1)[1].strip() if ":" in text else ""
if detail in _EXACT:
return f"探针返回数据无法解析:{_EXACT[detail]}"
if detail:
return f"探针返回数据无法解析:{detail}"
return "探针返回数据无法解析"
if "timed out" in lower or lower.endswith("timeout"):
return f"SSH 连接超时:{text}"
if "permission denied" in lower or "authentication failed" in lower:
return f"SSH 认证失败:{text}"
if "psutil missing" in lower:
return "子机未安装 psutil"
if "ssh_probe_failed" in lower or "ssh watch probe failed" in lower:
return "SSH 探针脚本执行失败"
# Already Chinese or mixed — return as-is if contains CJK
if any("\u4e00" <= ch <= "\u9fff" for ch in text):
return text
return f"探针异常:{text}"
+19
View File
@@ -0,0 +1,19 @@
"""Watch slot Redis snapshots — cleared when no active 5s monitoring remains."""
from __future__ import annotations
from server.infrastructure.redis.client import get_redis
REDIS_WATCH_LIVE_PREFIX = "watch:live:"
REDIS_WATCH_PROC_PREFIX = "watch:proc:"
REDIS_WATCH_LAST_PREFIX = "watch:last:"
async def clear_watch_redis_snapshot(server_id: int) -> None:
"""Remove 5s watch sliding window; Agent continues normal 60s heartbeat."""
redis = get_redis()
await redis.delete(
f"{REDIS_WATCH_LIVE_PREFIX}{server_id}",
f"{REDIS_WATCH_PROC_PREFIX}{server_id}",
f"{REDIS_WATCH_LAST_PREFIX}{server_id}",
)