diff --git a/deploy/nginx_http.conf b/deploy/nginx_http.conf index 81da913d..ae8218ce 100644 --- a/deploy/nginx_http.conf +++ b/deploy/nginx_http.conf @@ -4,7 +4,6 @@ # {{SERVER_NAME}} = your domain or IP (e.g. nexus.example.com or 192.168.1.100) # {{DEPLOY_DIR}} = installation root (e.g. /opt/nexus or /www/wwwroot/nexus.example.com) # {{LOG_DIR}} = nginx log directory (e.g. /var/log/nginx or /www/wwwlogs) -# {{PHP_SOCK}} = PHP-FPM socket path (e.g. unix:/tmp/php-cgi-82.sock) — only if install.php is used # ── Upstream: Python FastAPI backend ── upstream nexus_api { @@ -51,20 +50,6 @@ server { proxy_set_header Host $host; } - # ── install.php (PHP-FPM for installer, root = /web/) ── - location ~ ^/install\.php$ { - root {{DEPLOY_DIR}}/web; - fastcgi_pass {{PHP_SOCK}}; - fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; - include fastcgi_params; - } - - # ── Installer + Agent assets (root = /web/) ── - location ~ ^/(css|js|vendor|agent)/ { - root {{DEPLOY_DIR}}/web; - try_files $uri =404; - } - # ── Static assets ── location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 7d; diff --git a/deploy/nginx_https.conf b/deploy/nginx_https.conf index b4ba11a7..f363f8ee 100644 --- a/deploy/nginx_https.conf +++ b/deploy/nginx_https.conf @@ -6,7 +6,6 @@ # {{LOG_DIR}} = nginx log directory (e.g. /var/log/nginx or /www/wwwlogs) # {{SSL_CERT}} = SSL certificate fullchain path # {{SSL_KEY}} = SSL private key path -# {{PHP_SOCK}} = PHP-FPM socket path (e.g. unix:/tmp/php-cgi-82.sock) — only if install.php is used # ── Upstream: Python FastAPI backend ── upstream nexus_api { @@ -42,20 +41,6 @@ server { root {{DEPLOY_DIR}}/web/app; index index.html; - # ── install.php (PHP-FPM for installer, root = /web/) ── - location ~ ^/install\.php$ { - root {{DEPLOY_DIR}}/web; - fastcgi_pass {{PHP_SOCK}}; - fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; - include fastcgi_params; - } - - # ── Installer + Agent assets (root = /web/) ── - location ~ ^/(css|js|vendor|agent)/ { - root {{DEPLOY_DIR}}/web; - try_files $uri =404; - } - # ── Python API proxy ── location /api/ { proxy_pass http://nexus_api; diff --git a/server/api/agent.py b/server/api/agent.py index 14954c4f..a4c309f9 100644 --- a/server/api/agent.py +++ b/server/api/agent.py @@ -115,6 +115,15 @@ async def receive_heartbeat( logger.error(f"Redis heartbeat write failed for server {server_id}: {e}") # Redis write failed — still continue with MySQL update + # ── 1b. Look up server name for alert broadcasts ── + server_name = f"server-{server_id}" + try: + server = await service.get_server(server_id) + if server: + server_name = server.name + except Exception: + pass + # ── 2. Alert detection ── alert_thresholds = { "cpu": settings.CPU_ALERT_THRESHOLD, @@ -126,7 +135,7 @@ async def receive_heartbeat( alerts = _detect_alerts(system_info, alert_thresholds) for alert_type, alert_value in alerts: logger.warning(f"Alert: server {server_id} {alert_type}={alert_value}%") - await broadcast_alert(server_id, alert_type, alert_value, f"server-{server_id}") + await broadcast_alert(server_id, alert_type, alert_value, server_name) # Track active alert in Redis try: @@ -145,7 +154,7 @@ async def receive_heartbeat( recoveries = _detect_recovery(system_info, prev_alerts, alert_thresholds) for metric, value in recoveries: logger.info(f"Recovery: server {server_id} {metric}={value}%") - await broadcast_recovery(server_id, metric, value, f"server-{server_id}") + await broadcast_recovery(server_id, metric, value, server_name) # Remove recovered alert from Redis tracking for prev in prev_alerts: diff --git a/server/api/install.py b/server/api/install.py index 18528792..fa1e2639 100644 --- a/server/api/install.py +++ b/server/api/install.py @@ -27,7 +27,7 @@ router = APIRouter(prefix="/api/install", tags=["install"]) ROOT_DIR = Path(__file__).resolve().parent.parent.parent ENV_FILE = ROOT_DIR / ".env" -LOCK_FILE = ROOT_DIR / "web" / "install.php.locked" +LOCK_FILE = ROOT_DIR / "web" / "install.php.locked" # Legacy compat — primary lock is .install_locked INSTALL_LOCK = ROOT_DIR / ".install_locked" CONFIG_PHP_DIR = ROOT_DIR / "web" / "data" CONFIG_PHP = CONFIG_PHP_DIR / "config.php" diff --git a/server/api/sync_v2.py b/server/api/sync_v2.py index 09ce7d6b..56a59f59 100644 --- a/server/api/sync_v2.py +++ b/server/api/sync_v2.py @@ -135,13 +135,9 @@ async def browse_directory( continue parts = line.split(None, 8) if len(parts) >= 9: - perms, _, owner, group, size, *date_parts, name = ( - parts[0], parts[1], parts[2], parts[3], parts[4], - parts[5:-1], parts[-1] - ) is_dir = parts[0].startswith("d") entries.append({ - "name": parts[-1], + "name": parts[8], # Last field after split(None, 8) — handles spaces in names "is_dir": is_dir, "size": parts[4], "perms": parts[0], diff --git a/server/background/schedule_runner.py b/server/background/schedule_runner.py index bde31739..2e5d1eb6 100644 --- a/server/background/schedule_runner.py +++ b/server/background/schedule_runner.py @@ -8,7 +8,6 @@ import logging from datetime import datetime, timezone from server.infrastructure.database.session import AsyncSessionLocal -from server.infrastructure.redis.client import get_redis_sync from server.domain.models import PushSchedule, SyncLog, AuditLog logger = logging.getLogger("nexus.schedule_runner") diff --git a/server/main.py b/server/main.py index ea86c4c3..7643d01e 100644 --- a/server/main.py +++ b/server/main.py @@ -67,6 +67,55 @@ logger = logging.getLogger("nexus") # Track background tasks for clean shutdown _background_tasks: list[asyncio.Task] = [] +# Redis-based primary worker lock (prevents duplicate background tasks with multi-worker uvicorn) +_PRIMARY_LOCK_KEY = "nexus:primary_worker" +_PRIMARY_LOCK_TTL = 30 # seconds — must renew periodically + + +async def _acquire_primary_lock() -> bool: + """Try to become the primary worker via Redis SETNX. + + Only one uvicorn worker will hold this lock at a time. + The lock auto-expires after PRIMARY_LOCK_TTL seconds (safety net for crashes). + Primary worker renews the lock periodically via _renew_primary_lock(). + """ + try: + from server.infrastructure.redis.client import get_redis + import os + redis = get_redis() + worker_id = str(os.getpid()) + # SET with NX (only if not exists) + EX (TTL) + acquired = await redis.set(_PRIMARY_LOCK_KEY, worker_id, nx=True, ex=_PRIMARY_LOCK_TTL) + if acquired: + # Start lock renewal background task + asyncio.create_task(_renew_primary_lock(), name="primary_lock_renewal") + logger.info(f"Primary worker lock acquired (pid={worker_id})") + return True + return False + except Exception as e: + # If Redis fails, assume primary (single-worker mode) + logger.warning(f"Primary lock check failed, assuming primary: {e}") + return True + + +async def _renew_primary_lock(): + """Periodically renew the primary worker lock in Redis.""" + import os + worker_id = str(os.getpid()) + while True: + await asyncio.sleep(_PRIMARY_LOCK_TTL // 2) # Renew at half the TTL + try: + from server.infrastructure.redis.client import get_redis + redis = get_redis() + current = await redis.get(_PRIMARY_LOCK_KEY) + if current == worker_id: + await redis.expire(_PRIMARY_LOCK_KEY, _PRIMARY_LOCK_TTL) + else: + logger.warning("Primary worker lock lost — another worker took over") + break + except Exception as e: + logger.error(f"Primary lock renewal failed: {e}") + # ── D7: DB Session Middleware (fixes session leak in 4 service factories) ── @@ -150,12 +199,19 @@ async def lifespan(app: FastAPI): from server.api.websocket import start_redis_subscriber, stop_redis_subscriber await start_redis_subscriber() - # 5. Launch background tasks - task_flush = asyncio.create_task(heartbeat_flush_loop(), name="heartbeat_flush") - task_monitor = asyncio.create_task(self_monitor_loop(), name="self_monitor") - task_schedule = asyncio.create_task(schedule_runner_loop(), name="schedule_runner") - task_retry = asyncio.create_task(retry_runner_loop(), name="retry_runner") - _background_tasks.extend([task_flush, task_monitor, task_schedule, task_retry]) + # 5. Launch background tasks (only on primary worker to avoid duplicate execution) + # When running with --workers N, each worker gets its own lifespan. + # Use Redis-based leader election to ensure only one worker runs background tasks. + is_primary = await _acquire_primary_lock() + if is_primary: + task_flush = asyncio.create_task(heartbeat_flush_loop(), name="heartbeat_flush") + task_monitor = asyncio.create_task(self_monitor_loop(), name="self_monitor") + task_schedule = asyncio.create_task(schedule_runner_loop(), name="schedule_runner") + task_retry = asyncio.create_task(retry_runner_loop(), name="retry_runner") + _background_tasks.extend([task_flush, task_monitor, task_schedule, task_retry]) + logger.info("Primary worker — background tasks launched") + else: + logger.info("Secondary worker — background tasks skipped (primary worker handles them)") # 6. Start asyncssh connection pool (W1) from server.infrastructure.ssh.asyncssh_pool import ssh_pool diff --git a/web/app/install.html b/web/app/install.html index d850b4cb..b0c188f6 100644 --- a/web/app/install.html +++ b/web/app/install.html @@ -314,7 +314,7 @@

安装完成!

Nexus 6.0 已成功安装。

-

install.php 已重命名为 install.php.locked — 防止重复安装

+

安装向导已锁定 — 防止重复安装(如需重装请删除 .env 文件后重启服务)

-

install.php 已自动重命名为 install.php.locked

+

✓ 安装向导已锁定 — 删除 .env 文件可重新安装

.env 包含 SECRET_KEY/API_KEY — 安装后不可修改(加密一致性)

✓ 防火墙限制端口 (仅允许本机和子服务器IP)

✓ 修改管理员默认密码