feat: Phase 2 security audit fixes + script execution platform

Security (P0/P1/P2):
- WebSSH: dedicated short-lived token, server_id binding, 4003 close code
- Remove /api/agent/exec from control plane (RCE surface eliminated)
- Global JWT middleware (JwtAuthMiddleware) with install-mode bypass
- decrypt_value: failure returns None, never leaks ciphertext
- Telegram: sanitize_external_message strips sensitive fields
- Agent auth: startup enforces non-empty API key, compare_digest
- Credentials: password_set bool flag, no plaintext in API responses
- audit_log: writes admin_username + ip_address on all CUD ops
- Install lock: all /api/install/* except GET /status return 403 post-install
- WebSocket dedup: publish once, subscribers deliver locally

Script execution platform:
- script_jobs.py / script_job_callback.py: async batching and callback
- script_execution_store.py / script_callback_rate.py: Redis-backed state
- script_execution_flush.py: background flusher to MySQL
- scripts.html / script-executions.html: full execution UI
- agent_url.py: centralised URL builder

Frontend:
- All 13 pages migrated from CDN to /app/vendor/ (no external deps)
- vendor/: alpinejs, tailwindcss-browser, xterm, xterm-addon-*, qrious
- Dashboard WebSocket real-time alerts; 8h JWT session timeout

Tests:
- test_security_unit.py: 15 unit tests (JWT, sanitize, vendor, install 403)
- test_api.py: env-configurable admin credentials

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name
2026-05-23 15:26:56 +08:00
parent 341d16fd6d
commit 752a24497c
67 changed files with 4496 additions and 602 deletions
+82 -74
View File
@@ -10,13 +10,15 @@ Heartbeat flow:
import json
import logging
import secrets
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from server.api.dependencies import get_server_service
from server.api.schemas import AgentHeartbeat, AgentExec
from server.api.dependencies import get_db, get_server_service
from server.api.schemas import AgentHeartbeat, AgentScriptCallback
from server.api.websocket import broadcast_alert, broadcast_recovery
from server.application.services.server_service import ServerService
from server.config import settings
@@ -40,12 +42,11 @@ def _verify_api_key(x_api_key: str = Header(...)):
match, the request is still allowed through so the handler can verify
the per-server key (which requires server_id from the payload).
For strict global-only verification, use _verify_global_api_key instead.
"""
if not x_api_key:
raise HTTPException(status_code=401, detail="Missing API key")
# If the global key matches, allow immediately
if x_api_key == settings.API_KEY:
if secrets.compare_digest(x_api_key, settings.API_KEY):
return x_api_key
# Non-matching key — allow through for per-server verification in handler.
# The handler MUST call _verify_server_api_key() before processing.
@@ -54,19 +55,6 @@ def _verify_api_key(x_api_key: str = Header(...)):
return x_api_key
def _verify_global_api_key(x_api_key: str = Header(...)):
"""Strict API key verification — only accepts the global API key.
Used for security-sensitive endpoints like /exec where per-server
key fallback is NOT acceptable (arbitrary command execution).
"""
if not x_api_key:
raise HTTPException(status_code=401, detail="Missing API key")
if x_api_key != settings.API_KEY:
raise HTTPException(status_code=403, detail="Invalid API key")
return x_api_key
async def _verify_server_api_key(server_id: int, provided_key: str, service: ServerService) -> bool:
"""Verify API key against per-server agent_api_key, falling back to global API key.
@@ -78,12 +66,21 @@ async def _verify_server_api_key(server_id: int, provided_key: str, service: Ser
try:
server = await service.get_server(server_id)
if server and server.agent_api_key:
return provided_key == server.agent_api_key
return secrets.compare_digest(provided_key, server.agent_api_key)
except Exception:
pass
logger.debug(f"Failed to look up server {server_id} for per-server API key check, falling back to global key")
# Fallback: global API key
return provided_key == settings.API_KEY
return secrets.compare_digest(provided_key, settings.API_KEY)
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 _detect_alerts(system_info: dict, thresholds: dict) -> list:
@@ -97,11 +94,11 @@ def _detect_alerts(system_info: dict, thresholds: dict) -> list:
mem = system_info.get("mem_usage") or system_info.get("mem")
disk = system_info.get("disk_usage") or system_info.get("disk")
if cpu is not None and float(cpu) > thresholds.get("cpu", settings.CPU_ALERT_THRESHOLD):
if cpu is not None and float(cpu) > _threshold_for(thresholds, "cpu"):
alerts.append(("cpu", float(cpu)))
if mem is not None and float(mem) > thresholds.get("mem", settings.MEM_ALERT_THRESHOLD):
if mem is not None and float(mem) > _threshold_for(thresholds, "mem"):
alerts.append(("mem", float(mem)))
if disk is not None and float(disk) > thresholds.get("disk", settings.DISK_ALERT_THRESHOLD):
if disk is not None and float(disk) > _threshold_for(thresholds, "disk"):
alerts.append(("disk", float(disk)))
return alerts
@@ -120,7 +117,7 @@ def _detect_recovery(system_info: dict, prev_alerts: set, thresholds: dict) -> l
metric, prev_val = parts[0], float(parts[1])
current = system_info.get(f"{metric}_usage") or system_info.get(metric)
if current is not None and float(current) < thresholds.get(metric, 80):
if current is not None and float(current) < _threshold_for(thresholds, metric):
recoveries.append((metric, float(current)))
return recoveries
@@ -172,7 +169,7 @@ async def receive_heartbeat(
if server:
server_name = server.name
except Exception:
pass
logger.debug(f"Failed to look up server name for server {server_id}")
# ── 2. Alert detection ──
alert_thresholds = {
@@ -193,7 +190,7 @@ async def receive_heartbeat(
await redis.sadd(f"{REDIS_ALERT_KEY_PREFIX}{server_id}", f"{alert_type}:{alert_value}")
await redis.expire(f"{REDIS_ALERT_KEY_PREFIX}{server_id}", 3600) # 1h TTL
except Exception:
pass # Non-critical
logger.debug(f"Failed to track alert in Redis for server {server_id}")
# ── 3. Recovery detection ──
if system_info:
@@ -218,56 +215,67 @@ async def receive_heartbeat(
return {"status": "ok", "server_id": server_id}
@router.post("/exec", response_model=dict)
async def agent_exec(
payload: AgentExec,
api_key: str = Depends(_verify_global_api_key),
@router.post("/script-callback", response_model=dict)
async def script_job_callback(
payload: AgentScriptCallback,
request: Request,
db: AsyncSession = Depends(get_db),
):
"""Execute a shell command on this Agent server
"""Receive long-task completion from child host (curl after nohup script exits).
This endpoint is called by Nexus to dispatch commands to remote Agents.
Each Agent server runs its own instance of this endpoint.
SECURITY: Only the GLOBAL API key is accepted (no per-server key fallback).
This endpoint executes arbitrary commands — strict authentication required.
Authenticated by one-time job_id + secret stored in Redis at exec time.
"""
command = payload.command
timeout = payload.timeout
if payload.sudo:
command = f"sudo {command}"
from server.application.services.script_job_callback import apply_script_job_callback
from server.api.websocket import broadcast_system_event
from server.infrastructure.database.script_repo import ScriptExecutionRepositoryImpl
from server.infrastructure.redis.script_callback_rate import check_script_callback_rate
import asyncio
try:
proc = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
exit_code = proc.returncode or 0
client_ip = request.client.host if request.client else ""
await check_script_callback_rate(payload.job_id, client_ip)
stdout_text = stdout.decode()[:10000]
stderr_text = stderr.decode()[:10000]
repo = ScriptExecutionRepositoryImpl(db)
result = await apply_script_job_callback(
repo,
job_id=payload.job_id,
server_id=payload.server_id,
secret=payload.secret,
exit_code=payload.exit_code,
message=payload.message,
log_tail=payload.log_tail,
operator="agent",
)
return {
"status": "success" if exit_code == 0 else "failed",
"stdout": stdout_text,
"stderr": stderr_text,
"exit_code": exit_code,
}
except asyncio.TimeoutError:
proc.kill()
return {
"status": "timeout",
"stdout": "",
"stderr": f"Command timed out after {timeout}s",
"exit_code": -1,
}
except Exception as e:
return {
"status": "error",
"stdout": "",
"stderr": str(e),
"exit_code": -1,
}
if not result:
raise HTTPException(status_code=404, detail="Invalid or expired job")
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.domain.models import AuditLog
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username="agent",
action="script_job_callback",
target_type="script_execution",
target_id=result["execution_id"],
detail=(
f"server_id={payload.server_id} job_id={payload.job_id} "
f"exit_code={payload.exit_code} status={result.get('status')}"
),
))
await broadcast_system_event(
"script_job_done",
f"脚本执行 #{result['execution_id']} 服务器 {result['server_id']} "
f"{'成功' if result['exit_code'] == 0 else '失败'}",
)
logger.info(
"Script job callback: execution_id=%s server_id=%s exit_code=%s",
result["execution_id"],
result["server_id"],
result["exit_code"],
)
return {"status": "ok", **result}
# Remote command execution lives on each managed host: web/agent/agent.py POST /exec
# Do NOT expose shell exec on the Nexus control plane (was P0 RCE via global API key).
+13 -7
View File
@@ -3,7 +3,7 @@ All operations require JWT authentication.
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from server.api.dependencies import get_db
from server.api.auth_jwt import get_current_admin
@@ -47,6 +47,7 @@ async def get_platform(
@router.post("/platforms", response_model=dict, status_code=201)
async def create_platform(
payload: PlatformCreate,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
@@ -59,7 +60,7 @@ async def create_platform(
await audit_repo.create(AuditLog(
admin_username=admin.username, action="create_platform",
target_type="platform", target_id=created.id,
detail=f"name={created.name}", ip_address="",
detail=f"name={created.name}", ip_address=request.client.host if request.client else "",
))
return _platform_to_dict(created)
@@ -69,6 +70,7 @@ async def create_platform(
async def update_platform(
id: int,
payload: PlatformUpdate,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
@@ -86,7 +88,7 @@ async def update_platform(
await audit_repo.create(AuditLog(
admin_username=admin.username, action="update_platform",
target_type="platform", target_id=id,
detail=f"name={updated.name}", ip_address="",
detail=f"name={updated.name}", ip_address=request.client.host if request.client else "",
))
return _platform_to_dict(updated)
@@ -95,6 +97,7 @@ async def update_platform(
@router.delete("/platforms/{id}", status_code=204)
async def delete_platform(
id: int,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
@@ -111,7 +114,7 @@ async def delete_platform(
await audit_repo.create(AuditLog(
admin_username=admin.username, action="delete_platform",
target_type="platform", target_id=id,
detail=f"name={platform.name}", ip_address="",
detail=f"name={platform.name}", ip_address=request.client.host if request.client else "",
))
@@ -146,6 +149,7 @@ async def get_node_tree(
@router.post("/nodes", response_model=dict, status_code=201)
async def create_node(
payload: NodeCreate,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
@@ -158,7 +162,7 @@ async def create_node(
await audit_repo.create(AuditLog(
admin_username=admin.username, action="create_node",
target_type="node", target_id=created.id,
detail=f"name={created.name}", ip_address="",
detail=f"name={created.name}", ip_address=request.client.host if request.client else "",
))
return _node_to_dict(created)
@@ -168,6 +172,7 @@ async def create_node(
async def update_node(
id: int,
payload: NodeUpdate,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
@@ -185,7 +190,7 @@ async def update_node(
await audit_repo.create(AuditLog(
admin_username=admin.username, action="update_node",
target_type="node", target_id=id,
detail=f"name={updated.name}", ip_address="",
detail=f"name={updated.name}", ip_address=request.client.host if request.client else "",
))
return _node_to_dict(updated)
@@ -194,6 +199,7 @@ async def update_node(
@router.delete("/nodes/{id}", status_code=204)
async def delete_node(
id: int,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
@@ -210,7 +216,7 @@ async def delete_node(
await audit_repo.create(AuditLog(
admin_username=admin.username, action="delete_node",
target_type="node", target_id=id,
detail=f"name={node.name}", ip_address="",
detail=f"name={node.name}", ip_address=request.client.host if request.client else "",
))
+56 -17
View File
@@ -4,6 +4,7 @@ Presentation layer — receives HTTP requests, delegates to AuthService.
A2: TOTP endpoints now require JWT authentication via get_current_admin.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
@@ -17,6 +18,8 @@ from server.domain.models import Admin, AuditLog
router = APIRouter(prefix="/api/auth", tags=["auth"])
logger = logging.getLogger("nexus.auth")
# ── Request Models ──
@@ -38,6 +41,12 @@ class TotpSetupRequest(BaseModel):
admin_id: int
class TotpDisableRequest(BaseModel):
admin_id: int
current_password: str = Field(..., min_length=1, max_length=255)
totp_code: Optional[str] = Field(None, min_length=6, max_length=6)
class TotpVerifyRequest(BaseModel):
admin_id: int
totp_code: str = Field(..., min_length=6, max_length=6)
@@ -48,6 +57,10 @@ class ChangePasswordRequest(BaseModel):
new_password: str = Field(..., min_length=6, max_length=255)
class WebsshTokenRequest(BaseModel):
server_id: int = Field(..., ge=1)
# ── Public Routes (no JWT required) ──
@router.post("/login")
@@ -76,11 +89,13 @@ async def login(
@router.post("/refresh")
async def refresh_token(
request: Request,
payload: RefreshRequest,
service: AuthService = Depends(get_auth_service),
):
"""Exchange refresh token for new access + refresh token pair"""
result = await service.refresh_token(payload.refresh_token)
ip_address = request.client.host if request.client else ""
result = await service.refresh_token(payload.refresh_token, ip_address=ip_address)
if not result["success"]:
raise HTTPException(status_code=401, detail=result.get("message", "Invalid refresh token"))
return result
@@ -88,10 +103,12 @@ async def refresh_token(
@router.post("/logout")
async def logout(
request: Request,
payload: LogoutRequest,
service: AuthService = Depends(get_auth_service),
):
"""Invalidate refresh token (client should also discard access token)"""
ip_address = request.client.host if request.client else ""
if payload.refresh_token:
result = await service.logout_by_token(payload.refresh_token)
else:
@@ -143,23 +160,27 @@ async def enable_totp(
@router.post("/totp/disable")
async def disable_totp(
payload: TotpSetupRequest,
payload: TotpDisableRequest,
admin: Admin = Depends(get_current_admin),
service: AuthService = Depends(get_auth_service),
):
"""Disable TOTP for admin user (requires JWT auth)
A2: JWT authentication required — admin can only disable TOTP for themselves.
"""
"""Disable TOTP — requires current password + TOTP code when enabled."""
if payload.admin_id != admin.id:
raise HTTPException(status_code=403, detail="Can only disable TOTP for yourself")
result = await service.disable_totp(admin.id)
result = await service.disable_totp(
admin.id,
current_password=payload.current_password,
totp_code=payload.totp_code,
)
if not result.get("success"):
raise HTTPException(status_code=400, detail=result.get("message", "Disable failed"))
return result
@router.put("/password")
async def change_password(
request: Request,
payload: ChangePasswordRequest,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
@@ -187,13 +208,8 @@ async def change_password(
current_admin.jwt_token_expires = None
await admin_repo.update(current_admin)
# Audit log
request_ip = ""
try:
from fastapi import Request
# IP will be added by the caller context if available
except Exception:
pass
# Audit log with client IP
ip_address = request.client.host if request.client else ""
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
@@ -201,12 +217,35 @@ async def change_password(
target_type="admin",
target_id=admin.id,
detail=f"Password changed for {admin.username} (all sessions invalidated)",
ip_address="",
ip_address=ip_address,
))
return {"success": True, "message": "密码已修改,请重新登录"}
@router.post("/webssh-token")
async def issue_webssh_token(
payload: WebsshTokenRequest,
request: Request,
admin: Admin = Depends(get_current_admin),
service: AuthService = Depends(get_auth_service),
db: AsyncSession = Depends(get_db),
):
"""Issue a short-lived JWT bound to one server for WebSSH (P0 IDOR fix)."""
from server.infrastructure.database.server_repo import ServerRepositoryImpl
server_repo = ServerRepositoryImpl(db)
server = await server_repo.get_by_id(payload.server_id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
if not server.domain:
raise HTTPException(status_code=400, detail="Server has no domain configured")
ip_address = request.client.host if request.client else ""
result = await service.create_webssh_token(admin, payload.server_id)
return {**result, "server_name": server.name}
@router.get("/me")
async def get_me(
admin: Admin = Depends(get_current_admin),
@@ -219,8 +258,8 @@ async def get_me(
from server.infrastructure.database.setting_repo import SettingRepositoryImpl
repo = SettingRepositoryImpl(db)
system_name = await repo.get("system_name")
except Exception:
pass
except Exception as e:
logger.warning(f"Failed to load system_name in /me: {e}")
return {
"id": admin.id,
+75 -3
View File
@@ -6,11 +6,16 @@ Old API Key auth remains for /api/agent/* endpoints (Agent→Nexus communication
"""
import logging
from pathlib import Path
from typing import Optional
_ROOT_DIR = Path(__file__).resolve().parent.parent.parent
_INSTALL_MODE = not (_ROOT_DIR / ".env").exists()
from fastapi import Depends, HTTPException, Request, status
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.middleware.base import BaseHTTPMiddleware
from server.domain.models import Admin
from server.api.dependencies import _get_request_session
@@ -26,7 +31,9 @@ security = HTTPBearer(auto_error=False)
PUBLIC_PREFIXES = (
"/api/auth/login",
"/api/auth/refresh",
"/api/auth/logout", # invalidates refresh token from body, no access JWT required
"/api/agent/", # Agent uses X-API-Key header
"/api/install/", # install wizard (403 when already installed, except /status)
"/health",
"/ws/",
"/docs",
@@ -43,6 +50,52 @@ def _is_public_path(path: str) -> bool:
return False
def _unauthorized_response(detail: str) -> JSONResponse:
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={"detail": detail},
headers={"WWW-Authenticate": "Bearer"},
)
def _extract_bearer_token(request: Request) -> Optional[str]:
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return None
token = auth_header[7:].strip()
return token or None
class JwtAuthMiddleware(BaseHTTPMiddleware):
"""Defense-in-depth: reject unauthenticated /api/* before route handlers run.
Must be registered inside DbSessionMiddleware so _verify_token can use request.state.db.
Route-level Depends(get_current_admin) remains for injecting Admin into handlers.
"""
async def dispatch(self, request: Request, call_next):
path = request.url.path
if request.method == "OPTIONS":
return await call_next(request)
if _INSTALL_MODE:
return await call_next(request)
if not path.startswith("/api/"):
return await call_next(request)
if _is_public_path(path):
return await call_next(request)
token = _extract_bearer_token(request)
if not token:
return _unauthorized_response("Missing Authorization header")
admin = await _verify_token(token, request)
if not admin:
return _unauthorized_response("Invalid or expired token")
request.state.admin = admin
return await call_next(request)
async def get_current_admin(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
@@ -58,6 +111,10 @@ async def get_current_admin(
if not credentials:
return None
cached = getattr(request.state, "admin", None)
if cached is not None:
return cached
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -91,6 +148,7 @@ async def _verify_token(token: str, request: Request) -> Optional[Admin]:
logger.debug(f"JWT invalid: {e}")
return None
except Exception:
logger.debug("JWT verification unexpected error", exc_info=True)
return None
admin_id = payload.get("sub")
@@ -120,7 +178,7 @@ async def _verify_token(token: str, request: Request) -> Optional[Admin]:
pass
return admin
except Exception:
pass
logger.debug("JWT admin lookup (get_current_admin) failed", exc_info=True)
return None
@@ -153,6 +211,20 @@ async def get_optional_admin(
session = await _get_request_session(request)
admin_repo = AdminRepositoryImpl(session)
admin = await admin_repo.get_by_id(admin_id)
return admin if admin and admin.is_active else None
if not admin or not admin.is_active:
return None
# Security: invalidate token if admin was updated after token was issued
token_updated = payload.get("updated", 0)
if token_updated and admin.updated_at:
try:
admin_updated_ts = int(admin.updated_at.timestamp())
if admin_updated_ts > token_updated + 5: # 5s grace for clock skew
return None
except (ValueError, OSError):
pass
return admin
except Exception:
logger.debug("JWT admin lookup failed", exc_info=True)
return None
+25 -7
View File
@@ -19,6 +19,8 @@ from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from server.infrastructure.database.engine_compat import patch_aiomysql_do_ping
logger = logging.getLogger("nexus.install")
router = APIRouter(prefix="/api/install", tags=["install"])
@@ -148,7 +150,7 @@ def _write_env(req: InitDbRequest, secret_key: str, api_key: str, encryption_key
f"NEXUS_CORS_ORIGINS={_escape_env_value(f'{site_url},http://localhost:{req.api_port}')}",
"",
"# SSH",
"NEXUS_SSH_STRICT_HOST_CHECKING=false",
"NEXUS_SSH_STRICT_HOST_CHECKING=true",
"",
"# Health Check",
"NEXUS_HEALTH_CHECK_INTERVAL=60",
@@ -300,9 +302,23 @@ async def install_status():
}
def _reject_if_installed():
"""Block install wizard after .env exists (except GET /status)."""
if _is_installed():
raise HTTPException(
status_code=403,
detail="系统已安装,安装向导已关闭。仅可查询 /api/install/status",
)
# Backward-compatible alias
_reject_post_install_wizard = _reject_if_installed
@router.get("/env-check")
async def env_check():
"""Detect environment readiness — Python, MySQL client, Redis, write permissions."""
_reject_post_install_wizard()
import sys
import importlib
@@ -386,13 +402,13 @@ async def env_check():
@router.post("/init-db")
async def init_db(req: InitDbRequest):
"""Step 3: Connect to MySQL, create tables, write configs."""
if _is_installed():
raise HTTPException(400, "系统已安装,不可重复初始化")
_reject_if_installed()
db_url = _make_db_url(req)
redis_url = _build_redis_url(req)
try:
patch_aiomysql_do_ping()
engine = create_async_engine(db_url, pool_pre_ping=True, pool_recycle=300)
except Exception as e:
raise HTTPException(400, f"数据库引擎创建失败: {e}")
@@ -520,8 +536,7 @@ async def init_db(req: InitDbRequest):
@router.post("/create-admin")
async def create_admin(req: CreateAdminRequest):
"""Step 4: Create admin account and set brand name."""
if _is_installed():
raise HTTPException(400, "系统已安装,不可重复操作")
_reject_if_installed()
if _is_locked():
raise HTTPException(400, "系统已锁定")
@@ -531,6 +546,7 @@ async def create_admin(req: CreateAdminRequest):
db_url = _make_db_url(req)
try:
patch_aiomysql_do_ping()
engine = create_async_engine(db_url, pool_pre_ping=True, pool_recycle=300)
async with engine.begin() as conn:
# Hash password with bcrypt
@@ -589,12 +605,12 @@ async def create_admin(req: CreateAdminRequest):
content = ENV_FILE.read_text()
content = re.sub(
r"NEXUS_SYSTEM_NAME=.*",
f"NEXUS_SYSTEM_NAME={req.system_name}",
f"NEXUS_SYSTEM_NAME={_escape_env_value(req.system_name)}",
content,
)
content = re.sub(
r"NEXUS_SYSTEM_TITLE=.*",
f"NEXUS_SYSTEM_TITLE={req.system_title}",
f"NEXUS_SYSTEM_TITLE={_escape_env_value(req.system_title)}",
content,
)
ENV_FILE.write_text(content)
@@ -613,6 +629,7 @@ async def create_admin(req: CreateAdminRequest):
@router.post("/lock")
async def lock_install():
"""Step 5: Lock the installer — prevent re-installation."""
_reject_if_installed()
# Create lock marker files
INSTALL_LOCK.write_text(
f"Nexus installation locked at {datetime.now(timezone.utc).isoformat()}\n"
@@ -637,6 +654,7 @@ async def lock_install():
@router.get("/state")
async def get_install_state():
"""Get current install state (for step navigation)."""
_reject_post_install_wizard()
import json
if not STATE_FILE.exists():
+23 -6
View File
@@ -21,12 +21,6 @@ class AgentHeartbeat(BaseModel):
agent_version: Optional[str] = None
class AgentExec(BaseModel):
command: str = Field(..., min_length=1)
timeout: int = Field(30, ge=1, le=300)
sudo: bool = False
# ── Server ──
class ServerCreate(BaseModel):
@@ -142,7 +136,30 @@ class ScriptExecute(BaseModel):
script_id: Optional[int] = None
command: str = Field(..., min_length=1)
server_ids: List[int] = Field(..., min_length=1)
credential_id: Optional[int] = None
timeout: int = Field(60, ge=1, le=600)
long_task: bool = False
class ScriptExecutionRetry(BaseModel):
server_ids: Optional[List[int]] = None
credential_id: Optional[int] = None
long_task: Optional[bool] = None
timeout: int = Field(120, ge=1, le=600)
class ScriptExecutionMarkStuck(BaseModel):
detail: str = Field("", max_length=500)
class AgentScriptCallback(BaseModel):
"""Child host reports long-running script completion (curl from nohup wrapper)."""
job_id: str = Field(..., min_length=8, max_length=64)
server_id: int = Field(..., ge=1)
secret: str = Field(..., min_length=16, max_length=128)
exit_code: int = 0
message: str = Field("", max_length=500)
log_tail: Optional[str] = Field(None, max_length=2000)
# ── DB Credential ──
+207 -85
View File
@@ -3,12 +3,18 @@ Presentation layer — receives HTTP requests, delegates to ScriptService.
All operations require JWT authentication.
"""
import json
import logging
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from server.config import settings
from server.api.dependencies import get_script_service, get_db
from server.api.auth_jwt import get_current_admin
from server.api.schemas import ScriptCreate, ScriptUpdate, ScriptExecute, DbCredentialCreate
from server.api.schemas import (
ScriptCreate, ScriptUpdate, ScriptExecute, ScriptExecutionRetry,
ScriptExecutionMarkStuck, DbCredentialCreate,
)
from server.application.services.script_service import ScriptService
from server.domain.models import Script, DbCredential, Admin, AuditLog
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
@@ -17,6 +23,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(prefix="/api/scripts", tags=["scripts"])
logger = logging.getLogger("nexus.scripts")
# ── Script CRUD ──
@@ -31,6 +39,178 @@ async def list_scripts(
return [_script_to_dict(s) for s in scripts]
@router.get("/exec-config", response_model=dict)
async def script_exec_config(
admin: Admin = Depends(get_current_admin),
):
"""Limits for script execution UI (built-in batching)."""
return {
"batch_size": settings.SCRIPT_EXEC_BATCH_SIZE,
"concurrency": settings.SCRIPT_EXEC_CONCURRENCY,
"max_timeout": 600,
}
# ── DB Credentials (must register before /{id}) ──
@router.get("/credentials", response_model=list)
async def list_credentials(
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""List all database credentials"""
credentials = await service.list_credentials()
return [_credential_to_dict(c) for c in credentials]
@router.post("/credentials", response_model=dict, status_code=201)
async def create_credential(
payload: DbCredentialCreate,
request: Request,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
db: AsyncSession = Depends(get_db),
):
"""Create a database credential (password will be encrypted)"""
data = payload.model_dump(exclude_none=True)
data["encrypted_password"] = data.pop("password", "")
credential = DbCredential(**data)
created = await service.create_credential(credential)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="create_credential",
target_type="credential", target_id=created.id,
detail=f"name={created.name}", ip_address=request.client.host if request.client else "",
))
return _credential_to_dict(created)
@router.delete("/credentials/{id}", status_code=204)
async def delete_credential(
id: int,
request: Request,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
db: AsyncSession = Depends(get_db),
):
"""Delete a database credential"""
result = await service.delete_credential(id)
if not result:
raise HTTPException(status_code=404, detail="Credential not found")
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="delete_credential",
target_type="credential", target_id=id,
ip_address=request.client.host if request.client else "",
))
@router.get("/executions", response_model=dict)
async def list_executions(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
status: Optional[str] = Query(None, description="Filter: running/completed/failed/partial"),
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""List script execution history (newest first)."""
return await service.list_executions(limit=limit, offset=offset, status=status)
@router.post("/executions/{id}/stop", response_model=dict)
async def stop_execution(
id: int,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""Stop pending long-task processes on child hosts."""
execution = await service.stop_execution(id, operator=admin.username)
if not execution:
raise HTTPException(status_code=404, detail="Execution not found")
detail = await service.get_execution_detail(id)
return detail if detail else _execution_to_dict(execution)
@router.post("/executions/{id}/mark-stuck", response_model=dict)
async def mark_execution_stuck(
id: int,
payload: ScriptExecutionMarkStuck,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""Mark execution as suspected stuck — event timeline + MySQL + audit."""
execution = await service.mark_execution_stuck(
id, operator=admin.username, detail=payload.detail,
)
if not execution:
raise HTTPException(status_code=404, detail="Execution not found")
detail = await service.get_execution_detail(id)
return detail or _execution_to_dict(execution)
@router.post("/executions/{id}/retry", response_model=dict, status_code=201)
async def retry_execution(
id: int,
payload: ScriptExecutionRetry,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""Re-run failed / pending / cancelled servers from a previous execution."""
from server.api.dependencies import check_dangerous_command
prev = await service.get_execution(id)
if not prev:
raise HTTPException(status_code=404, detail="Execution not found")
command, _ = service._parse_execution_meta(prev.command or "")
warnings = check_dangerous_command(command)
if warnings:
raise HTTPException(
status_code=400,
detail={"message": "危险命令已拒绝执行", "warnings": warnings},
)
try:
execution = await service.retry_execution(
id,
server_ids=payload.server_ids,
credential_id=payload.credential_id,
timeout=payload.timeout,
long_task=payload.long_task,
operator=admin.username,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
detail = await service.get_execution_detail(execution.id)
if detail:
detail["parent_execution_id"] = id
return detail
out = _execution_to_dict(execution)
out["parent_execution_id"] = id
return out
@router.get("/executions/{id}", response_model=dict)
async def get_execution(
id: int,
fetch_logs: bool = Query(
False,
description="Tail /var/log/nexus-job/*.log on child hosts (long tasks)",
),
log_lines: int = Query(100, ge=10, le=500),
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""Get execution result; optional live log tail from managed servers."""
detail = await service.get_execution_detail(
id, fetch_logs=fetch_logs, log_tail_lines=log_lines,
)
if not detail:
raise HTTPException(status_code=404, detail="Execution not found")
return detail
@router.get("/{id}", response_model=dict)
async def get_script(
id: int,
@@ -47,6 +227,7 @@ async def get_script(
@router.post("/", response_model=dict, status_code=201)
async def create_script(
payload: ScriptCreate,
request: Request,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
db: AsyncSession = Depends(get_db),
@@ -59,7 +240,7 @@ async def create_script(
await audit_repo.create(AuditLog(
admin_username=admin.username, action="create_script",
target_type="script", target_id=created.id,
detail=f"name={created.name}", ip_address="",
detail=f"name={created.name}", ip_address=request.client.host if request.client else "",
))
return _script_to_dict(created)
@@ -69,6 +250,7 @@ async def create_script(
async def update_script(
id: int,
payload: ScriptUpdate,
request: Request,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
db: AsyncSession = Depends(get_db),
@@ -86,7 +268,7 @@ async def update_script(
await audit_repo.create(AuditLog(
admin_username=admin.username, action="update_script",
target_type="script", target_id=id,
detail=f"name={updated.name}", ip_address="",
detail=f"name={updated.name}", ip_address=request.client.host if request.client else "",
))
return _script_to_dict(updated)
@@ -95,6 +277,7 @@ async def update_script(
@router.delete("/{id}", status_code=204)
async def delete_script(
id: int,
request: Request,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
db: AsyncSession = Depends(get_db),
@@ -111,7 +294,7 @@ async def delete_script(
await audit_repo.create(AuditLog(
admin_username=admin.username, action="delete_script",
target_type="script", target_id=id,
detail=f"name={script.name}", ip_address="",
detail=f"name={script.name}", ip_address=request.client.host if request.client else "",
))
@@ -128,87 +311,25 @@ async def execute_command(
from server.api.dependencies import check_dangerous_command
warnings = check_dangerous_command(payload.command)
if warnings:
logger.warning(f"Admin {admin.username} executing dangerous command: {payload.command[:200]} ({warnings})")
raise HTTPException(
status_code=400,
detail={"message": "危险命令已拒绝执行", "warnings": warnings},
)
execution = await service.execute_command(
command=payload.command,
server_ids=payload.server_ids,
script_id=payload.script_id,
timeout=payload.timeout,
operator=admin.username,
)
result = _execution_to_dict(execution)
if warnings:
result["command_warnings"] = warnings
return result
@router.get("/executions/{id}", response_model=dict)
async def get_execution(
id: int,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""Get execution result by ID"""
execution = await service.get_execution(id)
if not execution:
raise HTTPException(status_code=404, detail="Execution not found")
return _execution_to_dict(execution)
# ── DB Credentials ──
@router.get("/credentials", response_model=list)
async def list_credentials(
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
):
"""List all database credentials"""
credentials = await service.list_credentials()
return [_credential_to_dict(c) for c in credentials]
@router.post("/credentials", response_model=dict, status_code=201)
async def create_credential(
payload: DbCredentialCreate,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
db: AsyncSession = Depends(get_db),
):
"""Create a database credential (password will be encrypted)"""
data = payload.model_dump(exclude_none=True)
data["encrypted_password"] = data.pop("password", "")
credential = DbCredential(**data)
created = await service.create_credential(credential)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="create_credential",
target_type="credential", target_id=created.id,
detail=f"name={created.name}", ip_address="",
))
return _credential_to_dict(created)
@router.delete("/credentials/{id}", status_code=204)
async def delete_credential(
id: int,
admin: Admin = Depends(get_current_admin),
service: ScriptService = Depends(get_script_service),
db: AsyncSession = Depends(get_db),
):
"""Delete a database credential"""
result = await service.delete_credential(id)
if not result:
raise HTTPException(status_code=404, detail="Credential not found")
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="delete_credential",
target_type="credential", target_id=id,
ip_address="",
))
try:
execution = await service.execute_command(
command=payload.command,
server_ids=payload.server_ids,
script_id=payload.script_id,
credential_id=payload.credential_id,
timeout=payload.timeout,
operator=admin.username,
long_task=payload.long_task,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
detail = await service.get_execution_detail(execution.id)
return detail if detail else _execution_to_dict(execution)
# ── Helper functions ──
@@ -235,6 +356,7 @@ def _execution_to_dict(execution) -> dict:
"status": execution.status,
"results": execution.results,
"operator": execution.operator,
"credential_id": getattr(execution, "credential_id", None),
"started_at": str(execution.started_at) if execution.started_at else None,
"completed_at": str(execution.completed_at) if execution.completed_at else None,
}
+8 -4
View File
@@ -3,6 +3,8 @@ Cross-entity search across servers, scripts, credentials, and schedules.
All operations require JWT authentication.
"""
import logging
from fastapi import APIRouter, Depends, Query
from server.api.dependencies import get_db
@@ -14,6 +16,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(prefix="/api/search", tags=["search"])
logger = logging.getLogger("nexus.search")
def _escape_like(s: str) -> str:
"""Escape LIKE wildcard characters in user input.
@@ -56,7 +60,7 @@ async def global_search(
"category": s.category, "is_online": s.is_online,
})
except Exception:
pass
logger.warning("Search failed for 'servers' entity", exc_info=True)
# Scripts
try:
@@ -73,7 +77,7 @@ async def global_search(
"id": s.id, "name": s.name, "category": s.category,
})
except Exception:
pass
logger.warning("Search failed for 'scripts' entity", exc_info=True)
# Credentials
try:
@@ -89,7 +93,7 @@ async def global_search(
"id": c.id, "name": c.name, "db_type": c.db_type, "host": c.host,
})
except Exception:
pass
logger.warning("Search failed for 'credentials' entity", exc_info=True)
# Schedules
try:
@@ -105,7 +109,7 @@ async def global_search(
"id": s.id, "name": s.name, "cron_expr": s.cron_expr, "enabled": s.enabled,
})
except Exception:
pass
logger.warning("Search failed for 'schedules' entity", exc_info=True)
# Total count
total = sum(len(v) for v in results.values())
+43 -18
View File
@@ -9,7 +9,7 @@ import json
import logging
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from server.api.dependencies import get_server_service, get_sync_service, get_db
from server.api.auth_jwt import get_current_admin
@@ -107,30 +107,41 @@ async def server_stats(
categories[key] = cnt
total += cnt
# Online count via SQL (fallback; Redis heartbeat overrides this)
result = await db.execute(
select(func.count(Server.id)).where(Server.is_online == True)
)
db_online = result.scalar() or 0
# Count active alerts in Redis
# Online count from Redis heartbeats (real-time); MySQL is stale between flushes
redis = get_redis()
online = 0
try:
cursor = 0
while True:
cursor, keys = await redis.scan(cursor, match="heartbeat:*", count=200)
for key in keys:
data = await redis.hgetall(key)
if data.get("is_online") == "True":
online += 1
if cursor == 0:
break
except Exception as e:
logger.warning(f"Failed to count Redis heartbeats in server_stats: {e}")
result = await db.execute(
select(func.count(Server.id)).where(Server.is_online == True)
)
online = int(result.scalar() or 0)
alerts = 0
try:
# SCAN for alert keys (avoid KEYS on large datasets)
cursor = 0
while True:
cursor, keys = await redis.scan(cursor, match="alerts:*", count=200)
alerts += len(keys)
if cursor == 0:
break
except Exception:
pass
except Exception as e:
logger.warning(f"Failed to count Redis alerts in server_stats: {e}")
return {
"total": total,
"online": db_online,
"offline": total - db_online,
"online": online,
"offline": max(0, total - online),
"alerts": alerts,
"categories": categories,
}
@@ -171,6 +182,7 @@ async def get_server(
@router.post("/", response_model=dict, status_code=201)
async def create_server(
request: Request,
payload: ServerCreate,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
@@ -189,6 +201,7 @@ async def create_server(
server = Server(**data)
created = await service.create_server(server)
ip_address = request.client.host if request.client else ""
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
@@ -196,7 +209,7 @@ async def create_server(
target_type="server",
target_id=created.id,
detail=f"name={created.name} domain={created.domain}",
ip_address="",
ip_address=ip_address,
))
return _server_to_dict(created)
@@ -204,6 +217,7 @@ async def create_server(
@router.put("/{id}", response_model=dict)
async def update_server(
request: Request,
id: int,
payload: ServerUpdate,
admin: Admin = Depends(get_current_admin),
@@ -226,6 +240,7 @@ async def update_server(
setattr(server, key, value)
updated = await service.update_server(server)
ip_address = request.client.host if request.client else ""
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
@@ -233,7 +248,7 @@ async def update_server(
target_type="server",
target_id=id,
detail=f"name={updated.name}",
ip_address="",
ip_address=ip_address,
))
return _server_to_dict(updated)
@@ -241,6 +256,7 @@ async def update_server(
@router.delete("/{id}", status_code=204)
async def delete_server(
request: Request,
id: int,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
@@ -254,6 +270,7 @@ async def delete_server(
if not result:
raise HTTPException(status_code=404, detail="Server not found")
ip_address = request.client.host if request.client else ""
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
@@ -261,7 +278,7 @@ async def delete_server(
target_type="server",
target_id=id,
detail=f"name={server.name}",
ip_address="",
ip_address=ip_address,
))
@@ -269,6 +286,7 @@ async def delete_server(
@router.post("/{id}/agent-key", response_model=dict)
async def generate_agent_api_key(
request: Request,
id: int,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
@@ -284,6 +302,7 @@ async def generate_agent_api_key(
server.agent_api_key = new_key
await service.update_server(server)
ip_address = request.client.host if request.client else ""
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
@@ -291,10 +310,16 @@ async def generate_agent_api_key(
target_type="server",
target_id=id,
detail=f"Generated new Agent API key for {server.name}",
ip_address="",
ip_address=ip_address,
))
return {"agent_api_key": new_key, "server_id": id}
return {
"server_id": id,
"agent_api_key": new_key,
"agent_api_key_preview": f"{new_key[:12]}...",
"display_once": True,
"message": "完整密钥仅在本响应中返回一次,请立即复制保存",
}
# ── Health Check ──
+49 -9
View File
@@ -7,7 +7,9 @@ Sensitive values are masked in GET responses.
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
import bcrypt
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from server.api.dependencies import get_db
from server.api.auth_jwt import get_current_admin
@@ -29,6 +31,10 @@ SENSITIVE_KEYS = {"secret_key", "api_key", "encryption_key"}
router = APIRouter(prefix="/api/settings", tags=["settings"])
class ApiKeyRevealRequest(BaseModel):
current_password: str = Field(..., min_length=1, max_length=255)
# ── System Settings ──
@router.get("/", response_model=list)
@@ -58,12 +64,36 @@ async def get_setting(key: str, admin: Admin = Depends(get_current_admin), db: A
@router.post("/api-key/reveal", response_model=dict)
async def reveal_api_key(admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
"""Reveal the full API_KEY value (JWT-protected, for admin copy)"""
async def reveal_api_key(
payload: ApiKeyRevealRequest,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Reveal global API_KEY after re-entering account password."""
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
admin_repo = AdminRepositoryImpl(db)
current = await admin_repo.get_by_id(admin.id)
if not current or not bcrypt.checkpw(
payload.current_password.encode(),
current.password_hash.encode(),
):
raise HTTPException(status_code=400, detail="当前密码错误")
repo = SettingRepositoryImpl(db)
value = await repo.get("api_key")
if value is None:
raise HTTPException(status_code=404, detail="API_KEY not found")
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="reveal_api_key",
target_type="setting",
detail="Global API key revealed",
ip_address=request.client.host if request.client else "",
))
return {"key": "api_key", "value": value}
@@ -71,6 +101,7 @@ async def reveal_api_key(admin: Admin = Depends(get_current_admin), db: AsyncSes
async def set_setting(
key: str,
payload: SettingUpdatePayload,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
@@ -88,7 +119,7 @@ async def set_setting(
action="update_setting",
target_type="setting",
detail=f"key={key}",
ip_address="",
ip_address=request.client.host if request.client else "",
))
return {"key": setting.key, "value": setting.value}
@@ -110,6 +141,7 @@ async def list_schedules(admin: Admin = Depends(get_current_admin), db: AsyncSes
@schedule_router.post("/", response_model=dict, status_code=201)
async def create_schedule(
payload: ScheduleCreate,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
@@ -125,7 +157,7 @@ async def create_schedule(
target_type="schedule",
target_id=created.id,
detail=f"name={created.name}",
ip_address="",
ip_address=request.client.host if request.client else "",
))
return _schedule_to_dict(created)
@@ -135,6 +167,7 @@ async def create_schedule(
async def update_schedule(
id: int,
payload: ScheduleUpdate,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
@@ -155,7 +188,7 @@ async def update_schedule(
target_type="schedule",
target_id=id,
detail=f"name={updated.name}",
ip_address="",
ip_address=request.client.host if request.client else "",
))
return _schedule_to_dict(updated)
@@ -164,6 +197,7 @@ async def update_schedule(
@schedule_router.delete("/{id}", status_code=204)
async def delete_schedule(
id: int,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
@@ -179,7 +213,7 @@ async def delete_schedule(
action="delete_schedule",
target_type="schedule",
target_id=id,
ip_address="",
ip_address=request.client.host if request.client else "",
))
@@ -199,6 +233,7 @@ async def list_presets(admin: Admin = Depends(get_current_admin), db: AsyncSessi
@preset_router.post("/", response_model=dict, status_code=201)
async def create_preset(
payload: PresetCreate,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
@@ -216,7 +251,7 @@ async def create_preset(
target_type="preset",
target_id=created.id,
detail=f"name={created.name}",
ip_address="",
ip_address=request.client.host if request.client else "",
))
return {"id": created.id, "name": created.name}
@@ -225,6 +260,7 @@ async def create_preset(
@preset_router.delete("/{id}", status_code=204)
async def delete_preset(
id: int,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
@@ -240,7 +276,7 @@ async def delete_preset(
action="delete_preset",
target_type="preset",
target_id=id,
ip_address="",
ip_address=request.client.host if request.client else "",
))
@@ -292,6 +328,7 @@ async def list_retry_jobs(
@retry_router.post("/{id}/retry", response_model=dict)
async def retry_job(
id: int,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
@@ -317,6 +354,7 @@ async def retry_job(
target_type="retry",
target_id=id,
detail=f"Manual retry triggered for job #{id}",
ip_address=request.client.host if request.client else "",
))
return _retry_to_dict(job)
@@ -325,6 +363,7 @@ async def retry_job(
@retry_router.delete("/{id}", status_code=204)
async def delete_retry_job(
id: int,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
@@ -342,6 +381,7 @@ async def delete_retry_job(
target_type="retry",
target_id=id,
detail=f"Retry job #{id} deleted",
ip_address=request.client.host if request.client else "",
))
await repo.delete(id)
+17 -2
View File
@@ -6,6 +6,8 @@ S3: POST /api/sync/config — system config push
S4: POST /api/sync/sftp — SFTP transfer
"""
import logging
from fastapi import APIRouter, Depends, HTTPException, Request
from server.api.schemas import SyncFiles, SyncCommands, SyncConfig, SyncSftp, SyncBrowse
@@ -20,6 +22,8 @@ from server.domain.models import Admin, AuditLog
router = APIRouter(prefix="/api/sync", tags=["sync"])
logger = logging.getLogger("nexus.sync_v2")
async def _get_sync_engine(request: Request) -> SyncEngineV2:
"""DI factory for SyncEngineV2"""
@@ -58,6 +62,17 @@ async def sync_commands(
admin: Admin = Depends(get_current_admin),
):
"""S2: Execute commands on multiple servers via SSH in parallel"""
from server.api.dependencies import check_dangerous_command
blocked: list[str] = []
for cmd in payload.commands:
blocked.extend(check_dangerous_command(cmd))
if blocked:
raise HTTPException(
status_code=400,
detail={"message": "危险命令已拒绝执行", "warnings": blocked},
)
engine = await _get_sync_engine(request)
return await engine.sync_commands(
server_ids=payload.server_ids,
@@ -189,7 +204,7 @@ async def _audit_sync(action: str, target_type: str, target_id: int, detail: str
target_type=target_type,
target_id=target_id,
detail=detail,
ip_address="",
ip_address=request.client.host if request.client else "",
))
except Exception:
pass # Non-critical — never fail the main operation
logger.warning(f"Audit log write failed for {action} on {target_type}/{target_id}", exc_info=True)
+24 -11
View File
@@ -170,7 +170,7 @@ async def stop_redis_subscriber():
await _redis_subscriber.unsubscribe(REDIS_CHANNEL)
await _redis_subscriber.aclose()
except Exception:
pass
logger.debug("Failed to unsubscribe Redis Pub/Sub during shutdown", exc_info=True)
_redis_subscriber = None
logger.info("Redis Pub/Sub subscriber stopped")
@@ -195,14 +195,29 @@ async def _redis_listen_loop():
await asyncio.sleep(1) # Back off on error
async def _publish_to_redis(message: dict):
async def _publish_to_redis(message: dict) -> bool:
"""Publish message to Redis channel (Layer 2) — other workers will receive it"""
try:
from server.infrastructure.redis.client import get_redis
redis = get_redis()
await redis.publish(REDIS_CHANNEL, json.dumps(message))
return True
except Exception as e:
logger.error(f"Redis Pub/Sub publish failed: {e}")
return False
async def _dispatch_ws_message(message: dict) -> None:
"""Deliver alert to local WebSocket clients without duplicate delivery.
When Pub/Sub is active, only publish — this worker's subscriber calls broadcast_local once.
Fallback to direct broadcast if Redis subscriber is unavailable.
"""
if _pubsub_task is not None and _redis_subscriber is not None:
if not await _publish_to_redis(message):
await manager.broadcast_local(message)
else:
await manager.broadcast_local(message)
# ── WebSocket Endpoint ──
@@ -283,6 +298,7 @@ async def _verify_ws_token(token: str):
return admin
except Exception:
logger.debug("WebSocket JWT verification failed", exc_info=True)
return None
@@ -314,8 +330,8 @@ async def broadcast_alert(server_id: int, alert_type: str, alert_value: float, s
Called when Agent heartbeat detects CPU/memory/disk > threshold.
Publishes to Redis Pub/Sub (multi-worker) + Telegram.
Alert aggregation: WebSocket always broadcasts (real-time),
but Telegram push is deduplicated (5min cooldown per server+metric).
WebSocket refreshes dashboard stats only (no browser sound/list UI).
Telegram push is deduplicated (5min cooldown per server+metric).
"""
msg = {
"type": "alert",
@@ -325,9 +341,8 @@ async def broadcast_alert(server_id: int, alert_type: str, alert_value: float, s
"alert_value": alert_value,
}
# Broadcast to local clients + publish to Redis for other workers
await manager.broadcast_local(msg)
await _publish_to_redis(msg)
# Redis Pub/Sub (multi-worker) — local clients via subscriber only (C9)
await _dispatch_ws_message(msg)
# Telegram push — deduplicated per (server_id, alert_type) with cooldown
alert_key = f"alert:{server_id}:{alert_type}"
@@ -352,8 +367,7 @@ async def broadcast_recovery(server_id: int, metric: str, value: float, server_n
"value": value,
}
await manager.broadcast_local(msg)
await _publish_to_redis(msg)
await _dispatch_ws_message(msg)
# Recovery always sends Telegram + clears cooldown for next alert cycle
alert_key = f"alert:{server_id}:{metric}"
@@ -367,6 +381,5 @@ async def broadcast_system_event(event_type: str, message: str):
"""Broadcast system-level events (Python restart, Redis reconnect, etc.)"""
msg = {"type": "system", "event_type": event_type, "message": message}
await manager.broadcast_local(msg)
await _publish_to_redis(msg)
await _dispatch_ws_message(msg)
+17 -7
View File
@@ -44,9 +44,16 @@ async def _verify_webssh_token(token: str):
import jwt as pyjwt
payload = pyjwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"],
options={"require": ["exp", "sub"]})
if payload.get("purpose") != "webssh":
return None, None
admin_id = payload.get("sub")
server_id = payload.get("server_id") # Embedded in token for WebSSH
if not admin_id:
server_id = payload.get("server_id")
if not admin_id or server_id is None:
return None, None
try:
server_id = int(server_id)
except (TypeError, ValueError):
return None, None
async with AsyncSessionLocal() as session:
@@ -65,6 +72,7 @@ async def _verify_webssh_token(token: str):
return admin, server_id
except Exception:
logger.debug("WebSSH JWT verification failed", exc_info=True)
return None, None
@@ -90,8 +98,8 @@ async def terminal_ws(
await websocket.close(code=4001, reason="Invalid or expired JWT token")
return
# Security: verify the JWT's server_id matches the URL path server_id
if token_server_id is not None and token_server_id != server_id:
# Security: WebSSH token must bind to this server (general access_token rejected)
if token_server_id != server_id:
await websocket.close(code=4003, reason="Token not authorized for this server")
return
@@ -107,16 +115,18 @@ async def terminal_ws(
server = await server_repo.get_by_id(server_id)
if not server:
# Can't close WebSocket before accept with a reason in standard way
# Just return — the client will time out
await websocket.close(code=4003, reason="Server not found")
return
# ── Server-level authorization check ──
if not server.domain:
await websocket.close(code=4003, reason="Server domain not configured")
return
if server.auth_method == "key" and not server.ssh_key_path and not server.ssh_key_private:
await websocket.close(code=4003, reason="SSH key not configured")
return
if server.auth_method == "password" and not server.password:
await websocket.close(code=4003, reason="SSH password not configured")
return
# Audit: WebSSH session start
@@ -292,7 +302,7 @@ async def terminal_ws(
ip_address=client_ip,
))
except Exception:
pass
logger.warning(f"Failed to write disconnect audit log for session {session_id}")
async def _close_ssh_session(session_id: str):
+122 -17
View File
@@ -25,6 +25,7 @@ LOCKOUT_MINUTES = 15
# JWT config
JWT_ACCESS_TOKEN_EXPIRE_MINUTES = 30
JWT_REFRESH_TOKEN_EXPIRE_DAYS = 7
JWT_WEBSSH_TOKEN_EXPIRE_MINUTES = 15
class AuthService:
@@ -50,7 +51,10 @@ class AuthService:
# Check brute-force lockout
failures = await self.attempt_repo.count_recent_failures(username, ip_address, LOCKOUT_MINUTES)
if failures >= MAX_LOGIN_FAILURES:
await self._audit("login_locked", "admin", 0, f"Account locked: {username} from {ip_address}")
await self._audit(
"login_locked", "admin", 0, f"Account locked: {username}",
ip_address=ip_address, admin_username=username,
)
return {"success": False, "reason": "account_locked", "message": "登录尝试过多,请15分钟后重试"}
# Find admin
@@ -87,7 +91,10 @@ class AuthService:
await self.admin_repo.update(admin)
await self._record_attempt(username, ip_address, True)
await self._audit("login_success", "admin", admin.id, f"Login: {username} from {ip_address}")
await self._audit(
"login_success", "admin", admin.id, f"Login: {username}",
ip_address=ip_address, admin_username=admin.username,
)
return {
"success": True,
@@ -103,7 +110,7 @@ class AuthService:
},
}
async def refresh_token(self, refresh_token: str) -> dict:
async def refresh_token(self, refresh_token: str, ip_address: str = "") -> dict:
"""Exchange refresh token for new access token (with rotation + version-based reuse detection)
Security: Refresh token format is ``token:admin_id:token_version``.
@@ -118,36 +125,55 @@ class AuthService:
admin_id = int(admin_id_str)
token_version = int(token_version_str)
except (ValueError, TypeError):
await self._audit("refresh_invalid_format", "admin", 0, "Refresh token has invalid format")
await self._audit(
"refresh_invalid_format", "admin", 0, "Refresh token has invalid format",
ip_address=ip_address,
)
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
# Get admin by ID (not by token)
admin = await self.admin_repo.get_by_id(admin_id)
if not admin:
await self._audit("refresh_admin_not_found", "admin", admin_id, "Refresh token references non-existent admin")
await self._audit(
"refresh_admin_not_found", "admin", admin_id,
"Refresh token references non-existent admin",
ip_address=ip_address,
)
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
# Check token version — mismatch = reuse attack
if admin.token_version != token_version:
# Reuse attack detected! Invalidate all sessions by incrementing version
original_version = admin.token_version
admin.token_version += 1
admin.jwt_refresh_token = None
admin.jwt_token_expires = None
await self.admin_repo.update(admin)
await self._audit("refresh_reuse_attack", "admin", admin.id,
f"Token reuse detected! Expected version {admin.token_version}, got {token_version}. All sessions invalidated.")
await self._audit(
"refresh_reuse_attack", "admin", admin.id,
f"Token reuse detected! DB version={original_version}, token version={token_version}. "
f"All sessions invalidated (new version={admin.token_version}).",
ip_address=ip_address, admin_username=admin.username,
)
return {"success": False, "reason": "token_reuse", "message": "检测到令牌重用,所有会话已失效,请重新登录"}
# Check if token matches current stored token (exact match required)
if admin.jwt_refresh_token != refresh_token:
await self._audit("refresh_token_mismatch", "admin", admin.id, "Refresh token doesn't match stored token")
await self._audit(
"refresh_token_mismatch", "admin", admin.id,
"Refresh token doesn't match stored token",
ip_address=ip_address, admin_username=admin.username,
)
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
else:
# Legacy format (old opaque token) — try exact match for backward compatibility
admin = await self.admin_repo.get_by_refresh_token(refresh_token)
if not admin:
await self._audit("refresh_legacy_not_found", "admin", 0, "Legacy refresh token not found")
await self._audit(
"refresh_legacy_not_found", "admin", 0, "Legacy refresh token not found",
ip_address=ip_address,
)
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
# Force rotation to new format on next refresh
@@ -221,7 +247,10 @@ class AuthService:
admin.jwt_refresh_token = None
admin.jwt_token_expires = None
await self.admin_repo.update(admin)
await self._audit("logout", "admin", admin.id, f"Logout: {admin.username}")
await self._audit(
"logout", "admin", admin.id, "Logout",
admin_username=admin.username,
)
return {"success": True, "message": "已登出"}
async def setup_totp(self, admin_id: int) -> dict:
@@ -241,7 +270,10 @@ class AuthService:
issuer = settings.SYSTEM_NAME
uri = f"otpauth://totp/{issuer}:{admin.username}?secret={secret}&issuer={issuer}"
await self._audit("setup_totp", "admin", admin_id, f"TOTP setup initiated for {admin.username}")
await self._audit(
"setup_totp", "admin", admin_id, "TOTP setup initiated",
admin_username=admin.username,
)
return {"success": True, "secret": secret, "uri": uri}
async def enable_totp(self, admin_id: int, totp_code: str) -> dict:
@@ -255,21 +287,62 @@ class AuthService:
admin.totp_enabled = True
await self.admin_repo.update(admin)
await self._audit("enable_totp", "admin", admin_id, f"TOTP enabled for {admin.username}")
await self._audit(
"enable_totp", "admin", admin_id, "TOTP enabled",
admin_username=admin.username,
)
return {"success": True, "message": "TOTP已启用"}
async def disable_totp(self, admin_id: int) -> dict:
"""Disable TOTP for an admin user"""
async def disable_totp(
self,
admin_id: int,
current_password: str,
totp_code: Optional[str] = None,
) -> dict:
"""Disable TOTP — requires current password and TOTP code when enabled."""
admin = await self.admin_repo.get_by_id(admin_id)
if not admin:
return {"success": False, "reason": "admin_not_found"}
if not self._verify_password(current_password, admin.password_hash):
return {"success": False, "reason": "invalid_password", "message": "当前密码错误"}
if admin.totp_enabled:
if not totp_code:
return {"success": False, "reason": "totp_required", "message": "需要TOTP验证码"}
if not self._verify_totp(totp_code, admin.totp_secret):
return {"success": False, "reason": "invalid_totp", "message": "TOTP验证码错误"}
admin.totp_enabled = False
admin.totp_secret = None
admin.token_version += 1
await self.admin_repo.update(admin)
await self._audit("disable_totp", "admin", admin_id, f"TOTP disabled for {admin.username}")
await self._audit(
"disable_totp", "admin", admin_id, "TOTP disabled",
admin_username=admin.username,
)
return {"success": True, "message": "TOTP已禁用"}
async def create_webssh_token(self, admin: Admin, server_id: int) -> dict:
"""Issue a short-lived JWT scoped to one server for WebSSH.
General access tokens must not be used for terminal WebSocket (no server binding).
"""
token = self._create_webssh_token(admin, server_id)
await self._audit(
"webssh_token",
"server",
server_id,
f"WebSSH token issued for server {server_id}",
admin_username=admin.username,
)
return {
"success": True,
"webssh_token": token,
"server_id": server_id,
"expires_in": JWT_WEBSSH_TOKEN_EXPIRE_MINUTES * 60,
}
# ── JWT Token Helpers ──
def _create_access_token(self, admin: Admin) -> str:
@@ -290,6 +363,23 @@ class AuthService:
}
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
def _create_webssh_token(self, admin: Admin, server_id: int) -> str:
"""JWT for WebSSH only — binds admin to a single server_id."""
import jwt
from server.config import settings
now = datetime.datetime.now(timezone.utc)
payload = {
"sub": str(admin.id),
"username": admin.username,
"server_id": int(server_id),
"purpose": "webssh",
"iat": now,
"exp": now + datetime.timedelta(minutes=JWT_WEBSSH_TOKEN_EXPIRE_MINUTES),
"updated": int(admin.updated_at.timestamp()) if admin.updated_at else 0,
}
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
def _create_refresh_token(self, admin: Admin) -> str:
"""Create refresh token with version binding: token:admin_id:token_version
@@ -350,6 +440,21 @@ class AuthService:
attempt = LoginAttempt(username=username, ip_address=ip_address, success=success)
await self.attempt_repo.create(attempt)
async def _audit(self, action: str, target_type: str, target_id: int, detail: str, ip_address: str = ""):
log = AuditLog(action=action, target_type=target_type, target_id=target_id, detail=detail, ip_address=ip_address)
async def _audit(
self,
action: str,
target_type: str,
target_id: int,
detail: str,
ip_address: str = "",
admin_username: str = "",
):
log = AuditLog(
action=action,
target_type=target_type,
target_id=target_id,
detail=detail,
ip_address=ip_address or None,
admin_username=admin_username or None,
)
await self.audit_repo.create(log)
@@ -0,0 +1,104 @@
"""Apply script job completion callbacks — Redis live state then MySQL flush."""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from typing import Any, Optional
from server.application.services.script_jobs import consume_script_job
from server.domain.repositories import ScriptExecutionRepository
from server.infrastructure.redis import script_execution_store as ses
logger = logging.getLogger("nexus.script_job_callback")
def _aggregate_execution_status(
server_ids: list[int], results: dict[str, Any]
) -> str:
pending = 0
failed = 0
for sid in server_ids:
r = results.get(str(sid), {})
phase = r.get("phase", "done")
if phase == "pending":
pending += 1
continue
if r.get("exit_code", -1) != 0:
failed += 1
if pending:
return "running"
if failed == 0:
return "completed"
if failed == len(server_ids):
return "failed"
return "partial"
async def apply_script_job_callback(
execution_repo: ScriptExecutionRepository,
job_id: str,
server_id: int,
secret: str,
exit_code: int,
message: str = "",
log_tail: Optional[str] = None,
operator: str = "agent",
) -> Optional[dict[str, Any]]:
job = await consume_script_job(job_id, server_id, secret)
if not job:
return None
execution_id = int(job["execution_id"])
view = await ses.get_execution_view(execution_repo, execution_id)
if not view:
logger.warning("Script callback for missing execution_id=%s", execution_id)
return None
results = dict(view.get("results") or {})
entry: dict[str, Any] = {
"phase": "done",
"background": True,
"job_id": job_id,
"status": "completed" if exit_code == 0 else "failed",
"exit_code": exit_code,
"message": message or "",
"completed_at": datetime.now(timezone.utc).isoformat(),
}
if log_tail:
entry["log_tail"] = log_tail[:2000]
prev = results.get(str(server_id), {})
entry["stdout"] = prev.get("stdout", "") or entry.get("stdout", "")
if prev.get("stderr"):
entry["stderr"] = prev.get("stderr", "")
results[str(server_id)] = {**prev, **entry}
try:
server_ids = json.loads(view["server_ids"]) if isinstance(view["server_ids"], str) else view.get("server_ids", [])
except json.JSONDecodeError:
server_ids = []
status = _aggregate_execution_status(server_ids, results)
terminal = status in ses.TERMINAL_STATUSES
await ses.apply_update(
execution_repo,
execution_id,
status=status,
results=results,
event_action="job_completed",
event_detail=(
f"子机 #{server_id} 回调 exit_code={exit_code}"
+ (f"{message}" if message else "")
),
operator=operator,
terminal=terminal,
)
return {
"execution_id": execution_id,
"server_id": server_id,
"status": status,
"exit_code": exit_code,
}
+121
View File
@@ -0,0 +1,121 @@
"""Long-running script jobs — Redis registration and nohup wrapper with completion callback."""
from __future__ import annotations
import json
import logging
import secrets
from datetime import datetime, timezone
from typing import Any, Optional, Tuple
from server.config import settings
from server.infrastructure.redis.client import get_redis
logger = logging.getLogger("nexus.script_jobs")
REDIS_JOB_PREFIX = "script_job:"
REDIS_JOB_TTL = 604800 # 7 days
def master_callback_url() -> str:
"""Public Nexus URL for Agent curl callbacks."""
base = (settings.API_BASE_URL or "").strip().rstrip("/")
if not base:
raise ValueError(
"NEXUS_API_BASE_URL 未配置,无法注册长任务回调;请在 .env 或安装向导中设置主站对外 URL"
)
lower = base.lower()
if lower.startswith("http://"):
host = lower[7:].split("/")[0].split(":")[0]
if host not in ("localhost", "127.0.0.1", "::1"):
raise ValueError(
"生产环境 NEXUS_API_BASE_URL 必须使用 HTTPS"
"本地开发可使用 http://localhost"
)
return f"{base}/api/agent/script-callback"
def build_long_task_command(
user_command: str,
job_id: str,
server_id: int,
secret: str,
callback_url: str,
) -> str:
"""Wrap user script in nohup; on exit POST completion to Nexus."""
safe_script = user_command.replace("'", "'\\''")
# secret/job_id are url-safe tokens from secrets.token_urlsafe
curl_payload = (
f'{{"job_id":"{job_id}","server_id":{server_id},'
f'"secret":"{secret}","exit_code":'"'"'$ec'"'"',"message":"done"}}'
)
inner = (
f"{safe_script}\n"
f"ec=$?\n"
f"curl -fsS -X POST '{callback_url}' "
f"-H 'Content-Type: application/json' "
f"-d '{curl_payload}' "
f"|| true\n"
f"exit $ec"
)
return (
"mkdir -p /var/log/nexus-job && "
'LOG=/var/log/nexus-job/job-$(date +%Y%m%d-%H%M%S).log && '
f"nohup bash -c '{inner}' >> \"$LOG\" 2>&1 & "
f'echo $! > "${LOG}.pid" && '
f'echo "started job_id={job_id} pid=$! log=$LOG"'
)
def sanitize_long_task_command_for_audit(wrapped: str, secret: str) -> str:
"""Redact job secret before persisting execution.command."""
return wrapped.replace(secret, "***")
async def register_script_job(execution_id: int, server_id: int) -> Tuple[str, str]:
"""Create a pending job; returns (job_id, secret)."""
job_id = secrets.token_urlsafe(16)
secret = secrets.token_urlsafe(32)
payload = {
"execution_id": execution_id,
"server_id": server_id,
"secret": secret,
"created_at": datetime.now(timezone.utc).isoformat(),
}
redis = get_redis()
await redis.set(
f"{REDIS_JOB_PREFIX}{job_id}",
json.dumps(payload),
ex=REDIS_JOB_TTL,
)
return job_id, secret
async def revoke_script_job(job_id: str) -> None:
"""Remove pending callback registration (e.g. user cancelled the job)."""
if not job_id:
return
redis = get_redis()
await redis.delete(f"{REDIS_JOB_PREFIX}{job_id}")
async def consume_script_job(
job_id: str, server_id: int, secret: str
) -> Optional[dict[str, Any]]:
"""Validate callback credentials and remove job (one-time use)."""
redis = get_redis()
key = f"{REDIS_JOB_PREFIX}{job_id}"
raw = await redis.get(key)
if not raw:
return None
try:
data = json.loads(raw)
except json.JSONDecodeError:
await redis.delete(key)
return None
if data.get("server_id") != server_id:
return None
if not secrets.compare_digest(data.get("secret", ""), secret):
return None
await redis.delete(key)
return data
+616 -71
View File
@@ -5,11 +5,16 @@ Application layer — orchestrates Repository + Agent /api/exec + DB credential
import asyncio
import json
import logging
from typing import Optional, List, Dict
import re
import shlex
from datetime import datetime, timezone
from typing import Optional, List, Dict, Any
import httpx
from server.config import settings
from server.domain.models import Script, ScriptExecution, DbCredential, AuditLog
from server.infrastructure.redis import script_execution_store as ses
from server.domain.repositories import (
ScriptRepository, ScriptExecutionRepository,
DbCredentialRepository, ServerRepository, AuditLogRepository,
@@ -17,6 +22,9 @@ from server.domain.repositories import (
logger = logging.getLogger("nexus.script_service")
_NEXUS_JOB_LOG_RE = re.compile(r"log=(/var/log/nexus-job/[^\s\"']+)")
_NEXUS_JOB_PID_RE = re.compile(r"pid=(\d+)")
class ScriptService:
"""Business logic for script CRUD and remote command execution via Agent /api/exec"""
@@ -71,6 +79,7 @@ class ScriptService:
credential_id: Optional[int] = None,
timeout: int = 30,
operator: str = "admin",
long_task: bool = False,
) -> ScriptExecution:
"""Execute a shell command on multiple servers via Agent /api/exec.
@@ -81,20 +90,468 @@ class ScriptService:
# Resolve DB credential variables (if provided)
resolved_command = await self._substitute_db_vars(command, credential_id)
from server.application.services.script_jobs import (
build_long_task_command,
master_callback_url,
register_script_job,
)
callback_url = None
if long_task:
callback_url = master_callback_url()
# Create execution record — use sanitized command (password redacted)
sanitized_command = await self._substitute_db_vars(command, credential_id, redact=True)
if long_task:
sanitized_command = f"[long_task] {sanitized_command}"
execution = ScriptExecution(
script_id=script_id,
command=sanitized_command,
server_ids=json.dumps(server_ids),
credential_id=credential_id,
status="running",
operator=operator,
)
execution = await self.execution_repo.create(execution)
await ses.init_live_from_execution(execution, long_task=long_task)
await self._audit(
"execute_started", "script_execution", execution.id,
f"开始执行 {len(server_ids)}"
+ (" [long_task]" if long_task else ""),
operator=operator,
)
# Dispatch to agents concurrently (max 10 at a time)
batch_size = max(1, settings.SCRIPT_EXEC_BATCH_SIZE)
results: Dict[str, dict] = {}
semaphore = asyncio.Semaphore(10)
batches = [
server_ids[i : i + batch_size]
for i in range(0, len(server_ids), batch_size)
]
for batch_idx, batch in enumerate(batches, start=1):
if len(batches) > 1:
logger.info(
"Script exec batch %s/%s (%s servers, execution_id=%s)",
batch_idx,
len(batches),
len(batch),
execution.id,
)
per_server_commands: Optional[Dict[int, str]] = None
job_ids_by_server: Dict[int, str] = {}
if long_task:
per_server_commands = {}
for sid in batch:
job_id, secret = await register_script_job(execution.id, sid)
job_ids_by_server[sid] = job_id
per_server_commands[sid] = build_long_task_command(
resolved_command, job_id, sid, secret, callback_url
)
batch_results = await self._dispatch_to_servers(
batch,
resolved_command,
timeout,
execution_id=execution.id,
per_server_commands=per_server_commands,
long_task=long_task,
job_ids_by_server=job_ids_by_server or None,
)
results.update(batch_results)
await ses.apply_update(
self.execution_repo,
execution.id,
status="running",
results=results,
event_action="batch_progress",
event_detail=f"批次 {batch_idx}/{len(batches)} 完成",
operator=operator,
terminal=False,
long_task=long_task,
)
# Determine overall status
if long_task:
start_failed = sum(
1 for r in results.values() if r.get("exit_code", -1) != 0
)
status = (
"failed"
if start_failed == len(server_ids)
else "running"
)
failed_count = start_failed
else:
failed_count = sum(1 for r in results.values() if r.get("exit_code", -1) != 0)
status = (
"completed"
if failed_count == 0
else "failed"
if failed_count == len(server_ids)
else "partial"
)
terminal = status in ses.TERMINAL_STATUSES
await ses.apply_update(
self.execution_repo,
execution.id,
status=status,
results=results,
event_action="execution_finished",
event_detail=f"执行结束: {status} ({failed_count} 台失败)",
operator=operator,
terminal=terminal,
long_task=long_task,
)
await self._audit(
"execute_command", "script_execution", execution.id,
f"Executed on {len(server_ids)} servers: {status} ({failed_count} failed)",
operator=operator,
)
return await self.execution_repo.get_by_id(execution.id)
async def get_execution(self, id: int) -> Optional[ScriptExecution]:
return await self.execution_repo.get_by_id(id)
async def list_executions(
self,
limit: int = 50,
offset: int = 0,
status: Optional[str] = None,
) -> dict:
"""List execution history (MySQL list + Redis overlay when live)."""
rows, total = await self.execution_repo.list_recent(limit, offset, status)
items = []
for ex in rows:
view = await ses.get_execution_view(self.execution_repo, ex.id)
if view:
items.append(view)
else:
results, events = ses.unpack_results_blob(ex.results)
try:
sids = json.loads(ex.server_ids or "[]")
except json.JSONDecodeError:
sids = []
items.append({
"id": ex.id,
"script_id": ex.script_id,
"command": ex.command,
"server_ids": ex.server_ids,
"status": ex.status,
"results": results,
"events": events,
"operator": ex.operator,
"started_at": str(ex.started_at) if ex.started_at else None,
"completed_at": str(ex.completed_at) if ex.completed_at else None,
"progress": ses.execution_progress_summary(results, sids),
"source": "mysql",
})
return {"items": items, "total": total, "limit": limit, "offset": offset}
async def get_execution_detail(
self, id: int, fetch_logs: bool = False, log_tail_lines: int = 100,
) -> Optional[dict]:
"""Return execution dict (Redis first); optionally tail nexus-job logs."""
view = await ses.get_execution_view(self.execution_repo, id)
if not view:
return None
results: Dict[str, Any] = dict(view.get("results") or {})
if fetch_logs:
await self._attach_remote_logs(results, log_tail_lines)
live = await ses.load_live(id)
if live:
live["results"] = results
await ses.save_live(live)
return {
"id": view["id"],
"script_id": view.get("script_id"),
"command": view.get("command"),
"server_ids": view.get("server_ids"),
"status": view.get("status"),
"results": results,
"events": view.get("events") or [],
"operator": view.get("operator"),
"started_at": view.get("started_at"),
"completed_at": view.get("completed_at"),
"long_task": view.get("long_task", False),
"progress": view.get("progress"),
"source": view.get("source"),
}
@staticmethod
def _parse_execution_meta(command: str) -> tuple[str, bool]:
"""Strip [long_task] prefix from stored command."""
if command.startswith("[long_task] "):
return command[len("[long_task] "):], True
return command, False
@staticmethod
def _extract_nexus_job_pid(stdout: str) -> Optional[int]:
m = _NEXUS_JOB_PID_RE.search(stdout or "")
if not m:
return None
try:
return int(m.group(1))
except ValueError:
return None
@staticmethod
def _build_kill_command(pid: Optional[int], log_path: Optional[str]) -> str:
parts = []
if pid:
parts.append(f"kill -TERM {pid} 2>/dev/null || kill -KILL {pid} 2>/dev/null")
if log_path:
q = shlex.quote(log_path)
parts.append(
f'P=$(cat "{q}.pid" 2>/dev/null); '
f'[ -n "$P" ] && (kill -TERM "$P" 2>/dev/null || kill -KILL "$P" 2>/dev/null)'
)
if not parts:
return "echo 'cannot stop: pid unknown'; exit 1"
return " ; ".join(parts) + '; echo "stopped"'
async def stop_execution(self, execution_id: int, operator: str = "admin") -> Optional[ScriptExecution]:
"""Stop pending long-task processes on child hosts."""
from server.application.services.script_jobs import revoke_script_job
view = await ses.get_execution_view(self.execution_repo, execution_id)
if not view:
return None
results: Dict[str, Any] = dict(view.get("results") or {})
stopped = 0
for sid_str, entry in list(results.items()):
if not isinstance(entry, dict) or entry.get("phase") != "pending":
continue
try:
server_id = int(sid_str)
except ValueError:
continue
server = await self.server_repo.get_by_id(server_id)
if not server or not server.is_online:
entry["phase"] = "cancelled"
entry["status"] = "cancelled"
entry["exit_code"] = -1
entry["message"] = "服务器离线,标记为已停止"
stopped += 1
continue
job_id = entry.get("job_id")
if job_id:
await revoke_script_job(job_id)
pid = entry.get("pid") or self._extract_nexus_job_pid(entry.get("stdout", ""))
log_path = self._extract_nexus_job_log(entry.get("stdout", ""))
try:
if pid:
out = await self._call_agent_kill(
server.domain, server.agent_port, server.agent_api_key, int(pid),
)
else:
kill_cmd = self._build_kill_command(pid, log_path)
out = await self._call_agent_exec(
server.domain,
server.agent_port,
server.agent_api_key,
kill_cmd,
timeout=15,
)
entry["stop_stdout"] = out.get("stdout", "") or out.get("status", "")
entry["stop_stderr"] = out.get("stderr", "")
except Exception as e:
entry["stop_stderr"] = str(e)
entry["phase"] = "cancelled"
entry["status"] = "cancelled"
entry["exit_code"] = -1
entry["message"] = "用户停止"
entry["completed_at"] = datetime.now(timezone.utc).isoformat()
stopped += 1
try:
server_ids = json.loads(view["server_ids"]) if isinstance(view["server_ids"], str) else view.get("server_ids", [])
except json.JSONDecodeError:
server_ids = []
status = _aggregate_from_results(server_ids, results)
terminal = status in ses.TERMINAL_STATUSES
await ses.apply_update(
self.execution_repo,
execution_id,
status=status,
results=results,
event_action="stopped",
event_detail=f"用户停止,共停止 {stopped} 台 pending 任务",
operator=operator,
terminal=terminal,
)
await self._audit(
"stop_execution", "script_execution", execution_id,
f"Stopped {stopped} pending task(s) by {operator}",
operator=operator,
)
return await self.execution_repo.get_by_id(execution_id)
async def mark_execution_stuck(
self,
execution_id: int,
operator: str = "admin",
detail: str = "",
) -> Optional[ScriptExecution]:
"""Record suspected stuck state — persisted to Redis + MySQL + audit."""
view = await ses.get_execution_view(self.execution_repo, execution_id)
if not view:
return None
results = dict(view.get("results") or {})
msg = detail.strip() or "用户标记执行可能卡住"
await ses.apply_update(
self.execution_repo,
execution_id,
status=view.get("status") or "running",
results=results,
event_action="stuck_marked",
event_detail=msg,
operator=operator,
terminal=False,
)
await self._audit(
"mark_stuck", "script_execution", execution_id, msg, operator=operator,
)
return await self.execution_repo.get_by_id(execution_id)
async def retry_execution(
self,
execution_id: int,
server_ids: Optional[List[int]] = None,
credential_id: Optional[int] = None,
timeout: int = 120,
long_task: Optional[bool] = None,
operator: str = "admin",
) -> ScriptExecution:
"""Re-run command for failed / pending / cancelled servers."""
execution = await self.execution_repo.get_by_id(execution_id)
if not execution:
raise ValueError("Execution not found")
command, was_long = self._parse_execution_meta(execution.command or "")
if not command.strip():
raise ValueError("原执行命令为空,无法重试")
try:
all_ids = json.loads(execution.server_ids or "[]")
except json.JSONDecodeError:
all_ids = []
if server_ids is None:
view = await ses.get_execution_view(self.execution_repo, execution_id)
results = dict((view or {}).get("results") or {})
server_ids = []
for sid in all_ids:
r = results.get(str(sid), {})
phase = r.get("phase", "done")
if phase == "pending" or r.get("exit_code", -1) != 0:
server_ids.append(sid)
if not server_ids:
server_ids = list(all_ids)
if not server_ids:
raise ValueError("没有可重试的服务器")
retry_results, _ = ses.unpack_results_blob(execution.results)
await ses.apply_update(
self.execution_repo,
execution_id,
status=execution.status,
results=retry_results,
event_action="retry_requested",
event_detail=f"重试 {len(server_ids)} 台: {server_ids}",
operator=operator,
terminal=False,
)
await self._audit(
"retry_execution", "script_execution", execution_id,
f"Retry requested for servers {server_ids}",
operator=operator,
)
use_long = was_long if long_task is None else long_task
cred_id = credential_id if credential_id is not None else getattr(
execution, "credential_id", None
)
return await self.execute_command(
command=command,
server_ids=server_ids,
script_id=execution.script_id,
credential_id=cred_id,
timeout=timeout,
operator=operator,
long_task=use_long,
)
@staticmethod
def _extract_nexus_job_log(stdout: str) -> Optional[str]:
m = _NEXUS_JOB_LOG_RE.search(stdout or "")
if not m:
return None
path = m.group(1)
if not path.startswith("/var/log/nexus-job/"):
return None
return path
async def _attach_remote_logs(self, results: Dict[str, Any], tail_lines: int) -> None:
"""In-place: add remote_log for servers with a nexus-job log path."""
tail_lines = max(10, min(tail_lines, 500))
async def _tail_one(sid_str: str, entry: dict) -> None:
log_path = self._extract_nexus_job_log(entry.get("stdout", ""))
if not log_path:
return
try:
server_id = int(sid_str)
except ValueError:
return
server = await self.server_repo.get_by_id(server_id)
if not server or not server.is_online:
entry["remote_log"] = "(服务器离线,无法拉取日志)"
return
cmd = f"tail -n {tail_lines} {shlex.quote(log_path)} 2>&1"
try:
out = await self._call_agent_exec(
server.domain,
server.agent_port,
server.agent_api_key,
cmd,
timeout=30,
)
parts = []
if out.get("stdout"):
parts.append(out["stdout"])
if out.get("stderr"):
parts.append(out["stderr"])
entry["remote_log"] = "\n".join(parts).strip() or "(日志为空)"
entry["remote_log_fetched_at"] = datetime.now(timezone.utc).isoformat()
except Exception as e:
entry["remote_log"] = f"(拉取失败: {e})"
await asyncio.gather(
*[_tail_one(sid, entry) for sid, entry in results.items() if isinstance(entry, dict)]
)
async def _dispatch_to_servers(
self,
server_ids: List[int],
resolved_command: str,
timeout: int,
execution_id: Optional[int] = None,
per_server_commands: Optional[Dict[int, str]] = None,
long_task: bool = False,
job_ids_by_server: Optional[Dict[int, str]] = None,
) -> Dict[str, dict]:
"""Run command on server_ids with concurrency limit (one batch wave)."""
results: Dict[str, dict] = {}
semaphore = asyncio.Semaphore(settings.SCRIPT_EXEC_CONCURRENCY)
async def _exec_on_server(server_id: int):
server = await self.server_repo.get_by_id(server_id)
@@ -106,12 +563,73 @@ class ScriptService:
"exit_code": -1,
}
return
cmd = (
per_server_commands.get(server_id, resolved_command)
if per_server_commands
else resolved_command
)
async with semaphore:
try:
result = await self._call_agent_exec(
server.domain, server.agent_port, server.agent_api_key,
resolved_command, timeout,
)
if long_task:
result = await self._call_agent_exec(
server.domain,
server.agent_port,
server.agent_api_key,
cmd,
timeout,
wait=True,
)
if result.get("exit_code", -1) == 0:
result = {
**result,
"phase": "pending",
"background": True,
"job_id": (job_ids_by_server or {}).get(server_id, ""),
"status": "started",
"started_at": datetime.now(timezone.utc).isoformat(),
}
else:
start = await self._call_agent_exec(
server.domain,
server.agent_port,
server.agent_api_key,
cmd,
min(timeout, 30),
wait=False,
)
pid = start.get("pid")
if pid and start.get("status") == "started":
partial = {
"pid": pid,
"phase": "pending",
"status": "started",
"stdout": start.get("stdout", ""),
"stderr": start.get("stderr", ""),
"exit_code": None,
}
if execution_id:
await ses.patch_live_server_result(
execution_id, server_id, partial,
)
result = await self._call_agent_wait(
server.domain,
server.agent_port,
server.agent_api_key,
int(pid),
timeout,
)
result["pid"] = pid
else:
result = await self._call_agent_exec(
server.domain,
server.agent_port,
server.agent_api_key,
cmd,
timeout,
wait=True,
)
if result.get("pid"):
result.setdefault("pid", result["pid"])
results[str(server_id)] = result
except Exception as e:
results[str(server_id)] = {
@@ -121,26 +639,8 @@ class ScriptService:
"exit_code": -1,
}
tasks = [_exec_on_server(sid) for sid in server_ids]
await asyncio.gather(*tasks)
# Determine overall status
failed_count = sum(1 for r in results.values() if r.get("exit_code", -1) != 0)
status = "completed" if failed_count == 0 else "failed" if failed_count == len(server_ids) else "partial"
# Update execution record
execution = await self.execution_repo.update_status(
execution.id, status, json.dumps(results)
)
await self._audit(
"execute_command", "script_execution", execution.id,
f"Executed on {len(server_ids)} servers: {status} ({failed_count} failed)",
)
return execution
async def get_execution(self, id: int) -> Optional[ScriptExecution]:
return await self.execution_repo.get_by_id(id)
await asyncio.gather(*[_exec_on_server(sid) for sid in server_ids])
return results
# ── DB Credential Management ──
@@ -161,22 +661,14 @@ class ScriptService:
return result
async def test_credential_connection(self, credential: DbCredential) -> dict:
"""Test database connection using provided credentials (before saving)"""
import shlex
from server.infrastructure.database.crypto import decrypt_value
password = credential.encrypted_password # Not yet encrypted if testing before save
if credential.id:
password = decrypt_value(credential.encrypted_password)
test_cmd = (
f"mysql -h {shlex.quote(credential.host)} "
f"-P {credential.port} "
f"-u {shlex.quote(credential.username)} "
f"-p{shlex.quote(password)} "
f"-e 'SELECT 1' 2>/dev/null && echo 'OK' || echo 'FAIL'"
)
return {"status": "test_command_ready", "command": test_cmd}
"""Validate credential fields (no shell command returned to API)."""
return {
"status": "ready",
"message": "凭据已校验格式;保存后可通过脚本执行进行连接测试",
"host": credential.host,
"port": credential.port,
"username": credential.username,
}
# ── Private helpers ──
@@ -209,55 +701,108 @@ class ScriptService:
return command
async def _call_agent_exec(
self, host: str, port: int, api_key: str, command: str, timeout: int = 30,
async def _agent_post(
self, host: str, port: int, api_key: str, path: str, payload: dict, timeout: int,
) -> dict:
"""Call Agent /api/exec endpoint to run a command on a remote server"""
url = f"http://{host}:{port}/api/exec"
from server.infrastructure.agent_url import agent_url
url = agent_url(host, port, path)
headers = {"Content-Type": "application/json"}
if api_key:
headers["X-API-Key"] = api_key
payload = {"command": command, "timeout": timeout}
async with httpx.AsyncClient(timeout=timeout + 5) as client:
async with httpx.AsyncClient(timeout=timeout + 10) as client:
try:
resp = await client.post(url, json=payload, headers=headers)
if resp.status_code == 200:
data = resp.json()
return {
"status": data.get("status", "unknown"),
"stdout": data.get("stdout", ""),
"stderr": data.get("stderr", ""),
"exit_code": data.get("exit_code", -1),
}
else:
return {
"status": "error",
"stdout": "",
"stderr": f"Agent returned HTTP {resp.status_code}",
"exit_code": -1,
}
except httpx.TimeoutException:
return resp.json()
return {
"status": "timeout",
"stdout": "",
"stderr": f"Agent timeout after {timeout}s",
"status": "error",
"stderr": f"Agent HTTP {resp.status_code}",
"exit_code": -1,
}
except httpx.TimeoutException:
return {"status": "timeout", "stderr": f"Agent timeout after {timeout}s", "exit_code": -1}
except httpx.ConnectError:
return {
"status": "connection_error",
"stdout": "",
"stderr": f"Cannot connect to Agent at {host}:{port}",
"exit_code": -1,
}
async def _audit(self, action: str, target_type: str, target_id: int, detail: str):
async def _call_agent_exec(
self,
host: str,
port: int,
api_key: str,
command: str,
timeout: int = 30,
wait: bool = True,
) -> dict:
data = await self._agent_post(
host, port, api_key, "/exec",
{"command": command, "timeout": timeout, "wait": wait},
timeout,
)
return {
"status": data.get("status", "unknown"),
"stdout": data.get("stdout", ""),
"stderr": data.get("stderr", ""),
"exit_code": data.get("exit_code", -1) if data.get("exit_code") is not None else -1,
"pid": data.get("pid"),
}
async def _call_agent_wait(
self, host: str, port: int, api_key: str, pid: int, timeout: int,
) -> dict:
data = await self._agent_post(
host, port, api_key, "/exec/wait",
{"pid": pid, "timeout": timeout},
timeout + 5,
)
return {
"status": data.get("status", "unknown"),
"stdout": data.get("stdout", ""),
"stderr": data.get("stderr", ""),
"exit_code": data.get("exit_code", -1),
"pid": pid,
}
async def _call_agent_kill(self, host: str, port: int, api_key: str, pid: int) -> dict:
return await self._agent_post(
host, port, api_key, "/exec/kill", {"pid": pid}, 15,
)
async def _audit(
self,
action: str,
target_type: str,
target_id: int,
detail: str,
operator: str = "system",
):
"""Write audit log entry"""
log = AuditLog(
action=action,
target_type=target_type,
target_id=target_id,
detail=detail,
admin_username=operator,
)
await self.audit_repo.create(log)
await self.audit_repo.create(log)
def _aggregate_from_results(server_ids: List[int], results: Dict[str, Any]) -> str:
pending = failed = 0
for sid in server_ids:
r = results.get(str(sid), {})
if r.get("phase") == "pending":
pending += 1
continue
if r.get("exit_code", -1) != 0:
failed += 1
if pending:
return "running"
if failed == 0:
return "completed"
if failed == len(server_ids):
return "failed"
return "partial"
@@ -52,7 +52,8 @@ class ServerService:
results = {}
async def _check_one(server: Server) -> tuple:
url = f"http://{server.domain}:{server.agent_port}/health"
from server.infrastructure.agent_url import agent_url
url = agent_url(server.domain, server.agent_port, "/health")
try:
async with httpx.AsyncClient(timeout=5.0) as client:
headers = {}
+38 -11
View File
@@ -98,7 +98,9 @@ class SyncEngineV2:
# Update sync log
sync_log.status = "success" if result["exit_code"] == 0 else "failed"
sync_log.finished_at = datetime.now(timezone.utc)
sync_log.duration_seconds = (sync_log.finished_at - sync_log.started_at).seconds if sync_log.started_at else 0
sync_log.duration_seconds = int(
(sync_log.finished_at - sync_log.started_at).total_seconds()
) if sync_log.started_at else 0
sync_log.error_message = result["stderr"][:1000] if result["exit_code"] != 0 else None
sync_log = await self.sync_log_repo.update(sync_log)
@@ -140,6 +142,18 @@ class SyncEngineV2:
concurrency: int = 10,
) -> dict:
"""S2: Execute commands on multiple servers via SSH, collect results"""
from server.api.dependencies import check_dangerous_command
blocked: list[str] = []
for cmd in commands:
blocked.extend(check_dangerous_command(cmd))
if blocked:
return {
"total_servers": len(server_ids),
"results": {},
"errors": [{"error": "dangerous_command_blocked", "detail": blocked}],
}
concurrency = min(concurrency, MAX_CONCURRENT)
sem = asyncio.Semaphore(concurrency)
@@ -226,19 +240,18 @@ class SyncEngineV2:
if not re.match(r'^[a-zA-Z0-9._-]+$', str(key)):
logger.warning(f"Skipping invalid config key: {key!r}")
continue
safe_value = shlex.quote(str(value))
kv_quoted = shlex.quote(f"{key}={value}")
cfg_q = shlex.quote(config_path)
# Deduplicate: remove existing lines for this key before appending
commands.append(f"sed -i '/^{re.escape(key)}\\s*=/d' {config_path} 2>/dev/null || true")
# Append new value
commands.append(f"echo {key}={safe_value} >> {config_path}")
# Apply immediately
commands.append(f"sysctl -w {key}={safe_value} 2>/dev/null || true")
commands.append(f"sed -i '/^{re.escape(key)}\\s*=/d' {cfg_q} 2>/dev/null || true")
commands.append(f"echo {kv_quoted} >> {cfg_q}")
commands.append(f"sysctl -w {kv_quoted} 2>/dev/null || true")
# Pre-push: backup existing config file
backup_commands = [
f"cp {config_path} {backup_path} 2>/dev/null || true",
f"touch {config_path}",
f"cp {shlex.quote(config_path)} {shlex.quote(backup_path)} 2>/dev/null || true",
f"touch {shlex.quote(config_path)}",
]
# Combine: backup first, then deduplicated config commands
@@ -264,6 +277,9 @@ class SyncEngineV2:
concurrency: int = 10,
) -> dict:
"""Rollback config: find the most recent backup and restore it"""
import re
import shlex
config_path = self.BACKUP_CONFIG_PATH
async def _rollback_one(server_id: int):
@@ -279,9 +295,20 @@ class SyncEngineV2:
return {"server_id": server_id, "status": "error", "message": "No backup found for rollback"}
backup_file = result["stdout"].strip().split("\n")[0]
if not re.match(
rf"^{re.escape(config_path)}\.bak\.\d{{14}}$",
backup_file,
):
return {
"server_id": server_id,
"status": "error",
"message": "Invalid backup path (rollback aborted)",
}
# Restore from backup
restore_cmd = f"cp {backup_file} {config_path} && sysctl --system 2>/dev/null || true"
restore_cmd = (
f"cp {shlex.quote(backup_file)} {shlex.quote(config_path)} "
f"&& sysctl --system 2>/dev/null || true"
)
restore_result = await exec_ssh_command(server, restore_cmd, timeout=30)
status = "success" if restore_result["exit_code"] == 0 else "failed"
@@ -110,6 +110,7 @@ class SyncService:
batch_tasks = []
async def _push_to_server(server: Server):
nonlocal completed
async with semaphore:
log = await self._push_single(server, source_path, sync_mode, operator)
results[server.id] = log
@@ -0,0 +1,45 @@
"""Batch-flush script execution live state from Redis to MySQL (every 60s, up to 50 rows/cycle)."""
import asyncio
import logging
from server.domain.models import AuditLog
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.infrastructure.database.script_repo import ScriptExecutionRepositoryImpl
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.redis.script_execution_store import (
FLUSH_INTERVAL_SECONDS,
detect_stuck_executions,
flush_pending_batch,
)
logger = logging.getLogger("nexus.script_execution_flush")
async def script_execution_flush_loop():
"""Every minute: flush queued script execution snapshots to MySQL."""
logger.info("Script execution flush loop started (interval=%ss)", FLUSH_INTERVAL_SECONDS)
while True:
await asyncio.sleep(FLUSH_INTERVAL_SECONDS)
try:
async with AsyncSessionLocal() as session:
repo = ScriptExecutionRepositoryImpl(session)
audit_repo = AuditLogRepositoryImpl(session)
async def _audit(action, target_type, target_id, operator):
await audit_repo.create(AuditLog(
admin_username=operator or "system",
action=action,
target_type=target_type,
target_id=target_id,
detail=f"execution_id={target_id}",
))
stuck = await detect_stuck_executions(repo, _audit)
count = await flush_pending_batch(repo)
if stuck:
logger.info("Script execution: auto-marked %s stuck", stuck)
if count:
logger.info("Script execution flush: %s record(s) written to MySQL", count)
except Exception as e:
logger.error("Script execution flush loop error: %s", e)
+7 -5
View File
@@ -43,11 +43,12 @@ async def self_monitor_loop():
try:
redis = get_redis()
await redis.ping()
except Exception as e:
except Exception:
redis_ok = False
logger.warning("Redis health check failed", exc_info=True)
try:
if settings.REDIS_URL:
await send_telegram_system_alert(f"⚠️ Redis连接丢失,心跳数据可能中断")
await send_telegram_system_alert("⚠️ Redis连接丢失,心跳数据可能中断")
except Exception:
logger.error("Failed to send Redis alert via Telegram")
@@ -66,12 +67,13 @@ async def self_monitor_loop():
try:
async with AsyncSessionLocal() as session:
await session.execute(text("SELECT 1"))
except Exception as e:
except Exception:
mysql_ok = False
logger.warning("MySQL health check failed", exc_info=True)
try:
await send_telegram_system_alert(f"🔴 MySQL连接异常: {str(e)[:100]}")
await send_telegram_system_alert("🔴 MySQL 连接异常,请检查数据库服务")
except Exception:
logger.error("Failed to send MySQL alert via Telegram")
logger.error("Failed to send MySQL alert via Telegram", exc_info=True)
# MySQL recovery notification
if mysql_ok and not _prev_mysql_ok:
+15 -2
View File
@@ -14,7 +14,7 @@ Mutable settings (overridable from DB):
import logging
from typing import Optional
from pydantic_settings import BaseSettings
from pydantic_settings import BaseSettings, SettingsConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -35,6 +35,7 @@ class Settings(BaseSettings):
# Deployment
DEPLOY_PATH: str = "/opt/nexus" # Base directory on the server
CORS_ORIGINS: str = "" # Comma-separated: "https://example.com,http://localhost:8600"
API_BASE_URL: str = "" # Public site URL — used for Agent HTTPS scheme (NEXUS_API_BASE_URL)
# Database (immutable — must be set in .env)
DATABASE_URL: str = "mysql+aiomysql://root:password@127.0.0.1:3306/nexus"
@@ -59,6 +60,10 @@ class Settings(BaseSettings):
# Health Check / Heartbeat
HEALTH_CHECK_INTERVAL: int = 60 # seconds — Agent heartbeat interval
# Script exec — built-in batching (servers per wave; avoids long single HTTP)
SCRIPT_EXEC_BATCH_SIZE: int = 50
SCRIPT_EXEC_CONCURRENCY: int = 10 # max parallel Agent calls per batch
# Alert thresholds (mutable — configurable from settings UI)
CPU_ALERT_THRESHOLD: int = 80
MEM_ALERT_THRESHOLD: int = 80
@@ -68,7 +73,12 @@ class Settings(BaseSettings):
TELEGRAM_BOT_TOKEN: str = ""
TELEGRAM_CHAT_ID: str = ""
model_config = {"env_prefix": "NEXUS_", "env_file": ".env"}
# extra=ignore: .env 中与 MCP 共存的 MYSQL_* / NEXUS_API_BASE_URL 等不进入 Settings
model_config = SettingsConfigDict(
env_prefix="NEXUS_",
env_file=".env",
extra="ignore",
)
# ── MySQL settings table override ──
@@ -114,6 +124,9 @@ class Settings(BaseSettings):
attr_name = self.DB_OVERRIDE_MAP.get(row.key)
if attr_name and hasattr(self, attr_name):
value = row.value
# Skip null values — .env value takes precedence
if value is None:
continue
# Type conversion
if attr_name in self.INT_SETTINGS:
try:
+1
View File
@@ -287,6 +287,7 @@ class ScriptExecution(Base):
script_id = Column(Integer, ForeignKey("scripts.id", ondelete="SET NULL"), nullable=True, comment="关联脚本(手动输入时为null)")
command = Column(Text, nullable=False, comment="实际执行的命令")
server_ids = Column(Text, nullable=False, comment="JSON数组: 目标服务器ID列表")
credential_id = Column(Integer, ForeignKey("db_credentials.id", ondelete="SET NULL"), nullable=True, comment="DB凭据替换$DB_*变量")
status = Column(String(20), default="pending", comment="pending/running/completed/failed")
results = Column(Text, nullable=True, comment="JSON: 每台服务器执行结果{server_id: {stdout,stderr,exit_code}}")
operator = Column(String(100), nullable=True, comment="操作人用户名")
+4 -1
View File
@@ -109,7 +109,10 @@ class ScriptExecutionRepository(Protocol):
async def get_by_id(self, id: int) -> Optional[ScriptExecution]: ...
async def create(self, execution: ScriptExecution) -> ScriptExecution: ...
async def update_status(self, id: int, status: str, results: str) -> ScriptExecution: ...
async def update_status(self, id: int, status: str, results: str) -> Optional[ScriptExecution]: ...
async def list_recent(
self, limit: int = 50, offset: int = 0, status: Optional[str] = None,
) -> tuple[List[ScriptExecution], int]: ...
class DbCredentialRepository(Protocol):
+11
View File
@@ -0,0 +1,11 @@
"""Build Agent HTTP URLs — use HTTPS when Nexus API base URL is HTTPS."""
from server.config import settings
def agent_url(host: str, port: int, path: str) -> str:
"""Agent endpoint URL on a managed host (health, exec, etc.)."""
scheme = "https" if (settings.API_BASE_URL or "").lower().startswith("https://") else "http"
if not path.startswith("/"):
path = f"/{path}"
return f"{scheme}://{host}:{port}{path}"
+12 -4
View File
@@ -14,10 +14,18 @@ logger = logging.getLogger("nexus.crypto")
def _fernet() -> Fernet:
"""Create Fernet instance from ENCRYPTION_KEY.
ENCRYPTION_KEY is mandatory main.py validates it at startup.
No silent fallback: if ENCRYPTION_KEY is empty, that's a configuration
error that must be surfaced, not masked.
"""
key = settings.ENCRYPTION_KEY
if not key:
raw = hashlib.sha256(settings.SECRET_KEY.encode()).digest()
key = base64.urlsafe_b64encode(raw).decode()
raise RuntimeError(
"ENCRYPTION_KEY is empty — cannot initialize Fernet encryption. "
"Set NEXUS_ENCRYPTION_KEY in .env (generated by install wizard)."
)
return Fernet(key.encode() if isinstance(key, str) else key)
@@ -66,10 +74,10 @@ def decrypt_value(ciphertext: str) -> str:
return plain.decode()
except Exception as e:
logger.warning(f"AES decryption failed (key mismatch?): {e}")
return ciphertext
raise ValueError("Failed to decrypt credential (AES)") from e
# Fernet format
try:
return get_fernet().decrypt(ciphertext.encode()).decode()
except Exception as e:
logger.warning(f"Fernet decryption failed (key mismatch?): {e}")
return ciphertext
raise ValueError("Failed to decrypt credential (Fernet)") from e
+8 -2
View File
@@ -111,6 +111,7 @@ async def run_schema_migrations():
migrations = [
"ALTER TABLE push_schedules ADD COLUMN sync_mode VARCHAR(20) DEFAULT 'incremental' COMMENT '同步模式'",
"ALTER TABLE admins ADD COLUMN token_version INT DEFAULT 0 COMMENT '令牌版本(重用时递增使旧token失效)'",
"ALTER TABLE script_executions ADD COLUMN credential_id INT NULL COMMENT 'DB凭据替换$DB_*变量'",
]
async with AsyncSessionLocal() as session:
for sql in migrations:
@@ -118,6 +119,11 @@ async def run_schema_migrations():
await session.execute(text(sql))
await session.commit()
logger.info(f"Schema migration applied: {sql[:60]}...")
except Exception:
except Exception as e:
await session.rollback()
# Column already exists — expected for non-first-run
err = str(e).lower()
if "duplicate column" in err or "1060" in err:
logger.debug("Schema migration skipped (already applied): %s", sql[:60])
else:
logger.error("Schema migration failed: %s%s", sql[:60], e)
raise
+26 -3
View File
@@ -1,7 +1,7 @@
"""Nexus — Script & ScriptExecution Repository (Async SQLAlchemy)"""
from typing import Optional, List
from sqlalchemy import select
from typing import Optional, List, Tuple
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import Script, ScriptExecution
@@ -61,11 +61,34 @@ class ScriptExecutionRepositoryImpl:
await self.session.refresh(execution)
return execution
async def update_status(self, id: int, status: str, results: str) -> ScriptExecution:
async def list_recent(
self,
limit: int = 50,
offset: int = 0,
status: Optional[str] = None,
) -> Tuple[List[ScriptExecution], int]:
filters = []
if status:
filters.append(ScriptExecution.status == status)
count_stmt = select(func.count(ScriptExecution.id))
if filters:
count_stmt = count_stmt.where(*filters)
total = int((await self.session.execute(count_stmt)).scalar() or 0)
stmt = select(ScriptExecution).order_by(ScriptExecution.started_at.desc())
if filters:
stmt = stmt.where(*filters)
result = await self.session.execute(stmt.limit(limit).offset(offset))
return list(result.scalars().all()), total
async def update_status(self, id: int, status: str, results: str) -> Optional[ScriptExecution]:
from datetime import datetime, timezone
execution = await self.session.get(ScriptExecution, id)
if execution:
execution.status = status
execution.results = results
if status != "running" and execution.completed_at is None:
execution.completed_at = datetime.now(timezone.utc)
await self.session.commit()
await self.session.refresh(execution)
return execution
@@ -0,0 +1,27 @@
"""Rate limits for unauthenticated child-host script job callbacks."""
from fastapi import HTTPException
from server.infrastructure.redis.client import get_redis
_JOB_LIMIT = 10
_IP_LIMIT = 60
_WINDOW_SECONDS = 60
async def check_script_callback_rate(job_id: str, client_ip: str) -> None:
redis = get_redis()
ip = (client_ip or "unknown").strip() or "unknown"
checks = (
(f"script_cb:job:{job_id}", _JOB_LIMIT),
(f"script_cb:ip:{ip}", _IP_LIMIT),
)
for key, limit in checks:
count = await redis.incr(key)
if count == 1:
await redis.expire(key, _WINDOW_SECONDS)
if count > limit:
raise HTTPException(
status_code=429,
detail="Too many script callback requests",
)
@@ -0,0 +1,422 @@
"""Script execution live state in Redis (2h TTL); MySQL writes are batched only (no per-event flush)."""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from typing import Any, Optional
from server.domain.models import ScriptExecution
from server.domain.repositories import ScriptExecutionRepository
from server.infrastructure.redis.client import get_redis
logger = logging.getLogger("nexus.script_execution_store")
REDIS_KEY_PREFIX = "script_exec:"
REDIS_FLUSH_QUEUE = "script_exec:flush_queue"
REDIS_TTL_SECONDS = 7200 # 2 hours — live execution state max lifetime
FLUSH_INTERVAL_SECONDS = 60 # background batch flush Redis → MySQL
FLUSH_BATCH_LIMIT = 50 # max execution records written per flush cycle
STUCK_NO_PROGRESS_SECONDS = 3600 # auto mark stuck if running with no update for 1h
TERMINAL_STATUSES = frozenset({"completed", "failed", "partial", "cancelled"})
def execution_progress_summary(
results: dict[str, Any],
server_ids: Optional[list] = None,
) -> str:
"""Return 'done/total' for UI (pending servers count as not done)."""
ids = server_ids
if ids is None:
ids = [int(k) for k in results if k != "_meta" and str(k).isdigit()]
total = len(ids)
if not total:
return ""
done = sum(
1
for sid in ids
if (results.get(str(sid)) or {}).get("phase") != "pending"
)
return f"{done}/{total}"
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _parse_server_ids_json(raw: Optional[str]) -> list:
try:
parsed = json.loads(raw or "[]")
except json.JSONDecodeError:
return []
return parsed if isinstance(parsed, list) else []
def _redis_key(execution_id: int) -> str:
return f"{REDIS_KEY_PREFIX}{execution_id}"
def unpack_results_blob(raw: Optional[str]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
if not raw:
return {}, []
try:
data = json.loads(raw)
except json.JSONDecodeError:
return {}, []
if not isinstance(data, dict):
return {}, []
meta = data.pop("_meta", None) or {}
events = meta.get("events", []) if isinstance(meta, dict) else []
return data, events if isinstance(events, list) else []
def pack_results_blob(results: dict[str, Any], events: list[dict[str, Any]]) -> str:
payload = dict(results)
payload["_meta"] = {
"events": events,
"updated_at": _now_iso(),
}
return json.dumps(payload, ensure_ascii=False)
async def load_live(execution_id: int) -> Optional[dict[str, Any]]:
redis = get_redis()
raw = await redis.get(_redis_key(execution_id))
if not raw:
return None
try:
return json.loads(raw)
except json.JSONDecodeError:
return None
async def save_live(live: dict[str, Any]) -> None:
redis = get_redis()
eid = int(live["id"])
live["updated_at"] = _now_iso()
await redis.set(_redis_key(eid), json.dumps(live, ensure_ascii=False), ex=REDIS_TTL_SECONDS)
await redis.sadd(REDIS_FLUSH_QUEUE, str(eid))
async def init_live_from_execution(
execution: ScriptExecution,
*,
long_task: bool = False,
results: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
server_ids = _parse_server_ids_json(execution.server_ids)
live = {
"id": execution.id,
"script_id": execution.script_id,
"command": execution.command,
"server_ids": server_ids,
"status": execution.status or "running",
"results": results or {},
"events": [
{
"at": _now_iso(),
"action": "started",
"detail": f"开始执行 {len(server_ids)} 台服务器"
+ (" (长任务)" if long_task else ""),
"operator": execution.operator,
}
],
"operator": execution.operator,
"long_task": long_task,
"started_at": str(execution.started_at) if execution.started_at else _now_iso(),
"completed_at": None,
}
if getattr(execution, "credential_id", None) is not None:
live["credential_id"] = execution.credential_id
await save_live(live)
return live
async def append_event(
live: dict[str, Any],
action: str,
detail: str,
operator: Optional[str] = None,
) -> None:
live.setdefault("events", []).append(
{
"at": _now_iso(),
"action": action,
"detail": detail,
"operator": operator or live.get("operator"),
}
)
async def flush_to_mysql(
execution_repo: ScriptExecutionRepository,
live: dict[str, Any],
) -> Optional[ScriptExecution]:
"""Persist current live snapshot to MySQL (results + event timeline in _meta)."""
eid = int(live["id"])
status = live.get("status", "running")
results = live.get("results") or {}
events = live.get("events") or []
blob = pack_results_blob(results, events)
execution = await execution_repo.update_status(eid, status, blob)
if not execution:
logger.warning("Flush skipped: script execution %s not found in MySQL", eid)
redis = get_redis()
await redis.srem(REDIS_FLUSH_QUEUE, str(eid))
return None
if status in TERMINAL_STATUSES and execution:
execution.completed_at = datetime.now(timezone.utc)
# update_status already sets completed_at when not running — good
redis = get_redis()
await redis.srem(REDIS_FLUSH_QUEUE, str(eid))
logger.debug("Flushed script execution %s to MySQL (status=%s)", eid, status)
return execution
async def apply_update(
execution_repo: ScriptExecutionRepository,
execution_id: int,
*,
status: str,
results: dict[str, Any],
event_action: str,
event_detail: str,
operator: str,
terminal: bool = False,
long_task: Optional[bool] = None,
) -> dict[str, Any]:
"""Update Redis only; MySQL is flushed in batches by script_execution_flush (60s)."""
live = await load_live(execution_id)
if not live:
execution = await execution_repo.get_by_id(execution_id)
if not execution:
raise ValueError(f"Execution {execution_id} not found")
res, ev = unpack_results_blob(execution.results)
live = {
"id": execution_id,
"script_id": execution.script_id,
"command": execution.command,
"server_ids": _parse_server_ids_json(execution.server_ids),
"status": execution.status,
"results": res,
"events": ev,
"operator": execution.operator,
"long_task": (execution.command or "").startswith("[long_task] "),
"started_at": str(execution.started_at) if execution.started_at else _now_iso(),
"completed_at": str(execution.completed_at) if execution.completed_at else None,
}
live["status"] = status
live["results"] = results
if long_task is not None:
live["long_task"] = long_task
if terminal or status in TERMINAL_STATUSES:
live["completed_at"] = _now_iso()
await append_event(live, event_action, event_detail, operator)
await save_live(live)
return live
async def get_execution_view(
execution_repo: ScriptExecutionRepository,
execution_id: int,
) -> Optional[dict[str, Any]]:
"""Prefer Redis live view; fall back to MySQL."""
live = await load_live(execution_id)
if live:
results = live.get("results") or {}
return {
"id": live["id"],
"script_id": live.get("script_id"),
"command": live.get("command"),
"server_ids": json.dumps(live.get("server_ids", [])),
"status": live.get("status"),
"results": results,
"events": live.get("events") or [],
"operator": live.get("operator"),
"started_at": live.get("started_at"),
"completed_at": live.get("completed_at"),
"long_task": live.get("long_task", False),
"progress": execution_progress_summary(results, live.get("server_ids")),
"credential_id": live.get("credential_id"),
"source": "redis",
}
execution = await execution_repo.get_by_id(execution_id)
if not execution:
return None
results, events = unpack_results_blob(execution.results)
server_ids = _parse_server_ids_json(execution.server_ids)
return {
"id": execution.id,
"script_id": execution.script_id,
"command": execution.command,
"server_ids": execution.server_ids,
"status": execution.status,
"results": results,
"events": events,
"operator": execution.operator,
"started_at": str(execution.started_at) if execution.started_at else None,
"completed_at": str(execution.completed_at) if execution.completed_at else None,
"long_task": (execution.command or "").startswith("[long_task] "),
"progress": execution_progress_summary(results, server_ids),
"credential_id": getattr(execution, "credential_id", None),
"source": "mysql",
}
async def patch_live_server_result(
execution_id: int,
server_id: int,
entry: dict[str, Any],
) -> None:
"""Merge one server result into Redis live state (for stop while short-task running)."""
live = await load_live(execution_id)
if not live:
return
merged = dict(entry)
if merged.get("phase") == "pending" and "started_at" not in merged:
merged["started_at"] = _now_iso()
results = dict(live.get("results") or {})
results[str(server_id)] = {**(results.get(str(server_id)) or {}), **merged}
live["results"] = results
await save_live(live)
def _parse_iso(ts: str) -> Optional[datetime]:
try:
return datetime.fromisoformat(ts)
except (ValueError, TypeError):
return None
def _pending_stuck_server_ids(results: dict[str, Any], now: datetime) -> list[str]:
stuck: list[str] = []
for sid, entry in results.items():
if sid == "_meta" or not isinstance(entry, dict):
continue
if entry.get("phase") != "pending" or entry.get("auto_stuck"):
continue
started = _parse_iso(entry.get("started_at", ""))
if not started:
continue
if (now - started).total_seconds() >= STUCK_NO_PROGRESS_SECONDS:
stuck.append(str(sid))
return stuck
async def detect_stuck_executions(
execution_repo: ScriptExecutionRepository,
audit_fn,
) -> int:
"""Auto-mark executions with no Redis update for STUCK_NO_PROGRESS_SECONDS."""
redis = get_redis()
marked = 0
now = datetime.now(timezone.utc)
cursor = 0
while True:
cursor, keys = await redis.scan(cursor, match=f"{REDIS_KEY_PREFIX}*", count=50)
for key in keys:
try:
eid = int(key.replace(REDIS_KEY_PREFIX, ""))
except ValueError:
continue
live = await load_live(eid)
if not live:
continue
if live.get("status") not in ("running", "partial"):
continue
results = dict(live.get("results") or {})
events = live.get("events") or []
stuck_sids = _pending_stuck_server_ids(results, now)
if stuck_sids:
for sid in stuck_sids:
row = dict(results.get(sid) or {})
row["auto_stuck"] = True
row["stuck_at"] = _now_iso()
results[sid] = row
await apply_update(
execution_repo,
eid,
status=live.get("status", "running"),
results=results,
event_action="server_auto_stuck",
event_detail=(
f"服务器 {', '.join('#' + s for s in stuck_sids)} "
f"pending 超过 {STUCK_NO_PROGRESS_SECONDS // 3600} 小时"
),
operator="system",
)
await audit_fn(
"server_auto_stuck",
"script_execution",
eid,
live.get("operator", "system"),
)
marked += 1
continue
if live.get("status") != "running":
continue
if any(e.get("action") in ("stuck_marked", "auto_stuck") for e in events[-3:]):
continue
updated = _parse_iso(live.get("updated_at", ""))
if not updated or (now - updated).total_seconds() < STUCK_NO_PROGRESS_SECONDS:
continue
await apply_update(
execution_repo,
eid,
status="running",
results=results,
event_action="auto_stuck",
event_detail=(
f"超过 {STUCK_NO_PROGRESS_SECONDS // 3600} 小时无状态更新,系统自动标记可能卡住"
),
operator="system",
)
await audit_fn(
"auto_stuck",
"script_execution",
eid,
live.get("operator", "system"),
)
marked += 1
if cursor == 0:
break
return marked
async def flush_pending_batch(execution_repo: ScriptExecutionRepository) -> int:
"""Batch-write queued execution snapshots from Redis to MySQL (cap per cycle)."""
redis = get_redis()
try:
ids = await redis.smembers(REDIS_FLUSH_QUEUE)
except Exception as e:
logger.error("Redis smembers script_exec flush queue failed: %s", e)
return 0
id_list = list(ids or [])[:FLUSH_BATCH_LIMIT]
flushed = 0
for raw_id in id_list:
try:
eid = int(raw_id)
except (TypeError, ValueError):
continue
live = await load_live(eid)
if not live:
await redis.srem(REDIS_FLUSH_QUEUE, raw_id)
continue
try:
await flush_to_mysql(execution_repo, live)
flushed += 1
except Exception as e:
logger.error("Flush script execution %s failed: %s", eid, e)
return flushed
+9 -1
View File
@@ -153,14 +153,22 @@ class AsyncSSHPool:
async def _create_connection(self, server: Server) -> asyncssh.SSHClientConnection:
"""Create a new asyncssh connection to a server"""
from server.config import settings
connect_kwargs = {
"host": server.domain,
"port": server.port or 22,
"username": server.username or "root",
"known_hosts": None, # Skip host key verification (like paramiko AutoAddPolicy)
"connect_timeout": self.CONNECT_TIMEOUT,
}
# Host key verification: respect SSH_STRICT_HOST_CHECKING setting
# Default is "true" — reject unknown hosts for security
if settings.SSH_STRICT_HOST_CHECKING.lower() in ("false", "0", "no"):
connect_kwargs["known_hosts"] = None # Skip verification (insecure)
# If strict mode, asyncssh will use default known_hosts behavior
# (rejects unknown hosts — caller should configure ~/.ssh/known_hosts)
if server.auth_method == "password" and server.password:
password = decrypt_value(server.password)
connect_kwargs["password"] = password
+31 -4
View File
@@ -2,7 +2,9 @@
Sends alerts/recovery/system messages via Telegram Bot API.
"""
import html
import logging
import re
import httpx
from datetime import datetime, timezone
@@ -16,6 +18,26 @@ TELEGRAM_API_BASE = "https://api.telegram.org"
# Reusable httpx client — avoids TCP connection churn during alert storms
_http_client: httpx.AsyncClient | None = None
# F2d-04: never forward raw DB/Redis URLs or credentials to Telegram or logs
_REDACT_PATTERNS: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"\b(?:mysql|postgresql|redis)(?:\+\w+)?://\S+", re.I), "<connection-url-redacted>"),
(re.compile(r"\bpassword[=:]\S+", re.I), "password=<redacted>"),
(re.compile(r"\bBearer\s+\S+", re.I), "Bearer <redacted>"),
(re.compile(r"\b(?:SECRET|API|ENCRYPTION)_KEY[=:]\S+", re.I), "<key-redacted>"),
]
def sanitize_external_message(text: str, max_len: int = 240) -> str:
"""Strip connection strings and secrets before external notification channels."""
if not text:
return ""
out = text
for pattern, replacement in _REDACT_PATTERNS:
out = pattern.sub(replacement, out)
if len(out) > max_len:
out = out[: max_len - 3] + "..."
return out
async def _get_client() -> httpx.AsyncClient:
global _http_client
@@ -52,7 +74,7 @@ async def send_telegram(message: str) -> bool:
},
)
if resp.status_code == 200:
logger.info(f"Telegram sent: {message[:50]}...")
logger.info(f"Telegram sent: {sanitize_external_message(message)[:50]}...")
return True
else:
logger.error(f"Telegram API error: {resp.status_code} {resp.text[:100]}")
@@ -65,9 +87,11 @@ async def send_telegram(message: str) -> bool:
async def send_telegram_alert(server_name: str, alert_type: str, value: float) -> bool:
"""Send server alert notification"""
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
safe_name = html.escape(server_name)
safe_type = html.escape(alert_type)
return await send_telegram(
f"🔴 <b>告警</b>\n"
f"服务器 <b>{server_name}</b> {alert_type} 使用率 <b>{value:.1f}%</b>\n"
f"服务器 <b>{safe_name}</b> {safe_type} 使用率 <b>{value:.1f}%</b>\n"
f"时间: {now}"
)
@@ -75,9 +99,11 @@ async def send_telegram_alert(server_name: str, alert_type: str, value: float) -
async def send_telegram_recovery(server_name: str, metric: str, value: float) -> bool:
"""Send server recovery notification"""
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
safe_name = html.escape(server_name)
safe_metric = html.escape(metric)
return await send_telegram(
f"🟢 <b>恢复</b>\n"
f"服务器 <b>{server_name}</b> {metric} 已恢复正常 ({value:.1f}%)\n"
f"服务器 <b>{safe_name}</b> {safe_metric} 已恢复正常 ({value:.1f}%)\n"
f"时间: {now}"
)
@@ -85,9 +111,10 @@ async def send_telegram_recovery(server_name: str, metric: str, value: float) ->
async def send_telegram_system_alert(message: str) -> bool:
"""Send system-level alert (Python crash, Redis disconnect, etc.)"""
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
safe_message = html.escape(sanitize_external_message(message))
return await send_telegram(
f"⚠️ <b>系统告警</b>\n"
f"{message}\n"
f"{safe_message}\n"
f"时间: {now}"
)
+18 -4
View File
@@ -37,6 +37,7 @@ INSTALL_MODE = not (ROOT_DIR / ".env").exists()
# Install wizard (no JWT required — runs before system is configured)
from server.api.install import router as install_router
from server.api.auth_jwt import JwtAuthMiddleware
# API routes (require JWT + full app init)
from server.api.servers import router as servers_router
@@ -59,6 +60,7 @@ from server.api.search import router as search_router
# Background tasks
from server.background.heartbeat_flush import heartbeat_flush_loop
from server.background.script_execution_flush import script_execution_flush_loop
from server.background.self_monitor import self_monitor_loop
from server.background.schedule_runner import schedule_runner_loop
from server.background.retry_runner import retry_runner_loop
@@ -214,10 +216,15 @@ async def lifespan(app: FastAPI):
is_primary = await _acquire_primary_lock()
if is_primary:
task_flush = asyncio.create_task(heartbeat_flush_loop(), name="heartbeat_flush")
task_script_flush = asyncio.create_task(
script_execution_flush_loop(), name="script_execution_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])
_background_tasks.extend([
task_flush, task_script_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)")
@@ -228,8 +235,8 @@ async def lifespan(app: FastAPI):
logger.info(
f"{settings.SYSTEM_NAME} v6.0.0 started — "
f"background tasks: heartbeat_flush(10min), self_monitor(30s), "
f"schedule_runner(60s), retry_runner(5min)"
f"background tasks: heartbeat_flush(10min), script_execution_flush(60s), "
f"self_monitor(30s), schedule_runner(60s), retry_runner(5min)"
)
yield
@@ -285,7 +292,11 @@ class InstallModeMiddleware(BaseHTTPMiddleware):
if path.startswith("/api/install/") or path.startswith("/app/") or path == "/health":
return await call_next(request)
if path.startswith("/ws/"):
return await call_next(request)
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=503,
content={"detail": "安装模式下 WebSocket 不可用,请先完成安装"},
)
# Block everything else
from fastapi.responses import JSONResponse
return JSONResponse(
@@ -321,6 +332,9 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
app.add_middleware(SecurityHeadersMiddleware)
# F2a-10 / F2d-02: global JWT gate for all /api/* (inside DbSession — uses request.state.db)
app.add_middleware(JwtAuthMiddleware)
# D7: DB session leak fix middleware (must be added BEFORE CORS so it wraps all requests)
app.add_middleware(DbSessionMiddleware)
+12 -15
View File
@@ -1,21 +1,20 @@
"""
Nexus 负载压测脚本 P0 #4
Nexus 负载压测脚本
测试: /health, /health/services, /api/servers/stats, /api/agent/heartbeat, /api/servers/exec
用法: python tests/load_test.py [--prod] [--concurrency 50] [--duration 30]
用法:
NEXUS_API_KEY=your-key python tests/load_test.py [--concurrency 50] [--duration 30]
NEXUS_TEST_BASE=http://127.0.0.1:8600 (可选)
"""
import asyncio
import httpx
import time
import sys
import os
import argparse
# 默认本地
BASE = "http://127.0.0.1:8600"
API_KEY = ""
PROD_BASE = "http://127.0.0.1:8600" # MCP 环境走 127.0.0.1
PROD_KEY = "msk-ufkvjm8hoe9j4ekiqpn1xd1gi8inuycu"
BASE = os.environ.get("NEXUS_TEST_BASE", "http://127.0.0.1:8600")
API_KEY = os.environ.get("NEXUS_API_KEY", "")
# 取一台在线服务器做 exec 测试
SERVER_ID = 1 # 默认,会动态查找
@@ -129,18 +128,16 @@ def print_report(concurrency: int, duration: float):
async def main():
global BASE, API_KEY, SERVER_ID, stats
parser = argparse.ArgumentParser()
parser.add_argument("--prod", action="store_true")
parser.add_argument("--concurrency", type=int, default=50)
parser.add_argument("--duration", type=int, default=30)
parser.add_argument("--no-exec", action="store_true", help="跳过 SSH exec 测试")
args = parser.parse_args()
if args.prod:
BASE = PROD_BASE
API_KEY = PROD_KEY
else:
BASE = "http://127.0.0.1:8600"
API_KEY = ""
global BASE, API_KEY
BASE = os.environ.get("NEXUS_TEST_BASE", "http://127.0.0.1:8600")
API_KEY = os.environ.get("NEXUS_API_KEY", "")
if not API_KEY:
print("WARN: NEXUS_API_KEY unset — stats/exec workers may be skipped", file=sys.stderr)
concurrency = args.concurrency
duration = args.duration
+56 -16
View File
@@ -1,46 +1,86 @@
"""轻量级压测 — health + stats"""
import asyncio, httpx, time, sys
"""轻量级压测 — health + stats
BASE = "http://127.0.0.1:8600"
API_KEY = "msk-ufkvjm8hoe9j4ekiqpn1xd1gi8inuycu"
用法:
NEXUS_API_KEY=your-key python tests/quick_load.py 50 30
NEXUS_TEST_BASE=http://127.0.0.1:8600 (可选)
"""
import asyncio
import httpx
import os
import sys
import time
BASE = os.environ.get("NEXUS_TEST_BASE", "http://127.0.0.1:8600")
API_KEY = os.environ.get("NEXUS_API_KEY", "")
oks = [0]
fails = [0]
times = []
async def health(c, d):
end = time.time() + d
while time.time() < end:
t0 = time.perf_counter()
try:
r = await c.get(f"{BASE}/health", timeout=5)
if r.status_code == 200: oks[0] += 1
else: fails[0] += 1
except Exception: fails[0] += 1
if r.status_code == 200:
oks[0] += 1
else:
fails[0] += 1
except Exception:
fails[0] += 1
times.append(time.perf_counter() - t0)
await asyncio.sleep(0.001)
async def stats_w(c, d):
if not API_KEY:
return
end = time.time() + d
while time.time() < end:
t0 = time.perf_counter()
try:
r = await c.get(f"{BASE}/api/servers/stats", headers={"X-API-Key": API_KEY}, timeout=10)
if r.status_code == 200: oks[0] += 1
else: fails[0] += 1
except Exception: fails[0] += 1
r = await c.get(
f"{BASE}/api/servers/stats",
headers={"X-API-Key": API_KEY},
timeout=10,
)
if r.status_code == 200:
oks[0] += 1
else:
fails[0] += 1
except Exception:
fails[0] += 1
times.append(time.perf_counter() - t0)
await asyncio.sleep(0.05)
async def main(n, d):
async with httpx.AsyncClient(limits=httpx.Limits(max_connections=n+100)) as c:
tasks = [health(c, d) for _ in range(int(n*0.7))]
tasks += [stats_w(c, d) for _ in range(max(1, int(n*0.3)))]
if not API_KEY:
print("WARN: NEXUS_API_KEY unset — stats workers skipped", file=sys.stderr)
async with httpx.AsyncClient(limits=httpx.Limits(max_connections=n + 100)) as c:
tasks = [health(c, d) for _ in range(int(n * 0.7))]
if API_KEY:
tasks += [stats_w(c, d) for _ in range(max(1, int(n * 0.3)))]
t0 = time.time()
await asyncio.gather(*tasks, return_exceptions=True)
elapsed = time.time() - t0
total = oks[0] + fails[0]
if not total:
print("No requests completed")
return
t = sorted(times)
nt = len(t)
print(f"并发={n} | RPS={total/elapsed:.0f} | P50={t[int(nt*0.5)]*1000:.1f}ms | P95={t[min(int(nt*0.95),nt-1)]*1000:.1f}ms | P99={t[min(int(nt*0.99),nt-1)]*1000:.1f}ms | ok={oks[0]} fail={fails[0]} err={(fails[0]/total*100):.1f}%")
print(
f"并发={n} | RPS={total/elapsed:.0f} | P50={t[int(nt*0.5)]*1000:.1f}ms | "
f"P95={t[min(int(nt*0.95),nt-1)]*1000:.1f}ms | "
f"P99={t[min(int(nt*0.99),nt-1)]*1000:.1f}ms | "
f"ok={oks[0]} fail={fails[0]} err={(fails[0]/total*100):.1f}%"
)
asyncio.run(main(int(sys.argv[1]), int(sys.argv[2])))
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: NEXUS_API_KEY=... python tests/quick_load.py <concurrency> <seconds>")
sys.exit(1)
asyncio.run(main(int(sys.argv[1]), int(sys.argv[2])))
+10 -3
View File
@@ -2,13 +2,20 @@
Nexus API End-to-End Tests
Usage: python tests/test_api.py
Prerequisites: Python backend must be running (python -m uvicorn server.main:app)
Env:
NEXUS_TEST_BASE default http://127.0.0.1:8600
NEXUS_TEST_ADMIN_USER / NEXUS_TEST_ADMIN_PASSWORD login credentials
"""
import json
import os
import sys
import urllib.request
import urllib.error
BASE = "http://127.0.0.1:8600"
BASE = os.environ.get("NEXUS_TEST_BASE", "http://127.0.0.1:8600")
ADMIN_USER = os.environ.get("NEXUS_TEST_ADMIN_USER", "admin")
ADMIN_PASSWORD = os.environ.get("NEXUS_TEST_ADMIN_PASSWORD", "admin")
PASS = 0
FAIL = 0
@@ -77,8 +84,8 @@ test("GET /health", "GET", "/health")
# --- Auth: Login ---
print("\n[2] Auth")
login_result = test("POST /api/auth/login", "POST", "/api/auth/login", body={
"username": "admin",
"password": "admin",
"username": ADMIN_USER,
"password": ADMIN_PASSWORD,
}, expect_code=200)
if login_result and login_result.get("access_token"):
ACCESS_TOKEN = login_result["access_token"]
+157
View File
@@ -0,0 +1,157 @@
"""P0/P2 security regression unit tests (no live server required)."""
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from starlette.requests import Request
from server.api.dependencies import check_dangerous_command
from server.application.services.script_jobs import master_callback_url
from server.infrastructure.redis.script_execution_store import _parse_server_ids_json
from server.infrastructure.telegram import sanitize_external_message
def test_dangerous_command_rm_rf_detected():
warnings = check_dangerous_command("rm -rf /")
assert warnings
def test_dangerous_command_safe_empty():
assert check_dangerous_command("echo hello") == []
def test_parse_server_ids_invalid_json():
assert _parse_server_ids_json("not-json") == []
def test_parse_server_ids_valid():
assert _parse_server_ids_json("[1, 2]") == [1, 2]
def test_master_callback_url_rejects_http_non_local(monkeypatch):
monkeypatch.setattr(
"server.application.services.script_jobs.settings.API_BASE_URL",
"http://api.example.com",
)
with pytest.raises(ValueError, match="HTTPS"):
master_callback_url()
def test_master_callback_url_allows_localhost_http(monkeypatch):
monkeypatch.setattr(
"server.application.services.script_jobs.settings.API_BASE_URL",
"http://localhost:8600",
)
assert master_callback_url().endswith("/api/agent/script-callback")
def test_sanitize_external_message_redacts_mysql_url():
raw = "connect failed: mysql+pymysql://nexus:SecretPass@db.internal:3306/nexus"
cleaned = sanitize_external_message(raw)
assert "SecretPass" not in cleaned
assert "mysql+pymysql://" not in cleaned
assert "<connection-url-redacted>" in cleaned
def test_sanitize_external_message_redacts_bearer_token():
raw = "auth failed Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig"
cleaned = sanitize_external_message(raw)
assert "eyJhbGci" not in cleaned
assert "Bearer <redacted>" in cleaned
def test_sanitize_external_message_truncates_long_text():
assert len(sanitize_external_message("x" * 500)) <= 240
@pytest.mark.asyncio
async def test_jwt_middleware_blocks_missing_token(monkeypatch):
from server.api import auth_jwt
monkeypatch.setattr(auth_jwt, "_INSTALL_MODE", False)
middleware = auth_jwt.JwtAuthMiddleware(app=MagicMock())
scope = {
"type": "http",
"method": "GET",
"path": "/api/servers/",
"headers": [],
"query_string": b"",
"client": ("testclient", 50000),
"server": ("testserver", 80),
"scheme": "http",
"root_path": "",
}
request = Request(scope)
call_next = AsyncMock()
response = await middleware.dispatch(request, call_next)
assert response.status_code == 401
call_next.assert_not_called()
@pytest.mark.asyncio
async def test_jwt_middleware_skips_public_login(monkeypatch):
from server.api import auth_jwt
monkeypatch.setattr(auth_jwt, "_INSTALL_MODE", False)
middleware = auth_jwt.JwtAuthMiddleware(app=MagicMock())
scope = {
"type": "http",
"method": "POST",
"path": "/api/auth/login",
"headers": [],
"query_string": b"",
"client": ("testclient", 50000),
"server": ("testserver", 80),
"scheme": "http",
"root_path": "",
}
request = Request(scope)
call_next = AsyncMock(return_value=MagicMock(status_code=200))
await middleware.dispatch(request, call_next)
call_next.assert_called_once()
def test_public_path_install_and_logout():
from server.api.auth_jwt import _is_public_path
assert _is_public_path("/api/install/status") is True
assert _is_public_path("/api/auth/logout") is True
def test_html_pages_do_not_load_external_cdn():
app_dir = Path(__file__).resolve().parent.parent / "web" / "app"
offenders = []
for html in app_dir.glob("*.html"):
text = html.read_text(encoding="utf-8")
if "cdn.jsdelivr" in text or "unpkg.com" in text:
offenders.append(html.name)
assert offenders == [], f"CDN still referenced in: {offenders}"
def test_vendor_manifest_lists_core_assets():
manifest_path = Path(__file__).resolve().parent.parent / "web" / "app" / "vendor" / "manifest.json"
assert manifest_path.is_file()
import json
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
for name in ("tailwindcss-browser.js", "alpinejs.min.js"):
assert name in manifest["files"]
assert (manifest_path.parent / name).is_file()
def test_install_reject_when_env_exists(monkeypatch, tmp_path):
from fastapi import HTTPException
from server.api import install as install_api
env_file = tmp_path / ".env"
env_file.write_text("NEXUS_SECRET_KEY=test\n", encoding="utf-8")
monkeypatch.setattr(install_api, "ENV_FILE", env_file)
monkeypatch.setattr(install_api, "INSTALL_LOCK", tmp_path / ".install_locked")
with pytest.raises(HTTPException) as exc:
install_api._reject_if_installed()
assert exc.value.status_code == 403
+128 -46
View File
@@ -2,7 +2,6 @@
Nexus Agent Runs on each managed server
Provides: health endpoint, heartbeat, command execution, config reload
"""
import os
import sys
import json
import time
@@ -10,6 +9,8 @@ import asyncio
import logging
import platform
import subprocess
import secrets
import shlex
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
@@ -35,7 +36,10 @@ def load_config():
config = load_config()
API_KEY = config.get("api_key", "")
API_KEY = (config.get("api_key") or "").strip()
if not API_KEY:
print("FATAL: api_key must be set in config.json", file=sys.stderr)
sys.exit(1)
SERVER_ID = config.get("server_id", 0)
CENTRAL_URL = config.get("central", {}).get("url", "")
CENTRAL_API_KEY = config.get("central", {}).get("api_key", "")
@@ -62,7 +66,7 @@ app = FastAPI(title="Nexus Agent", version="2.0.0")
def verify_api_key(x_api_key: str = Header(default="")):
if API_KEY and x_api_key != API_KEY:
if not secrets.compare_digest(x_api_key or "", API_KEY):
raise HTTPException(status_code=401, detail="Invalid API key")
@@ -98,48 +102,137 @@ async def health_check():
# --- Command Execution ---
_running_procs: dict[int, asyncio.subprocess.Process] = {}
def _proc_result(proc: asyncio.subprocess.Process, stdout: bytes, stderr: bytes) -> dict:
exit_code = proc.returncode if proc.returncode is not None else -1
return {
"status": "success" if exit_code == 0 else "failed",
"stdout": stdout.decode(errors="replace")[:10000],
"stderr": stderr.decode(errors="replace")[:10000],
"exit_code": exit_code,
"pid": proc.pid,
}
@app.post("/exec")
async def exec_command(payload: dict, x_api_key: str = Header(default="")):
"""Execute a shell command — called by Nexus to run commands on this server"""
"""Execute a shell command — wait=true (default) blocks; wait=false returns pid for /exec/wait."""
verify_api_key(x_api_key)
command = payload.get("command", "")
timeout = payload.get("timeout", 30)
sudo = payload.get("sudo", False)
wait = payload.get("wait", True)
if not command:
raise HTTPException(status_code=400, detail="command is required")
if sudo:
command = f"sudo {command}"
logger.warning(
"Shell exec via subprocess_shell (server_id=%s, wait=%s, len=%s)",
SERVER_ID,
wait,
len(command),
)
if sudo:
command = f"sudo -n sh -c {shlex.quote(command)}"
proc = None
try:
proc = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
start_new_session=True,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
exit_code = proc.returncode or 0
_running_procs[proc.pid] = proc
return {
"status": "success" if exit_code == 0 else "failed",
"stdout": stdout.decode()[:10000],
"stderr": stderr.decode()[:10000],
"exit_code": exit_code,
}
if not wait:
return {
"status": "started",
"pid": proc.pid,
"stdout": "",
"stderr": "",
"exit_code": None,
}
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
_running_procs.pop(proc.pid, None)
return _proc_result(proc, stdout, stderr)
except asyncio.TimeoutError:
proc.kill()
if proc:
proc.kill()
_running_procs.pop(proc.pid, None)
return {
"status": "timeout",
"stdout": "",
"stderr": f"Command timed out after {timeout}s",
"exit_code": -1,
"pid": proc.pid if proc else None,
}
except Exception as e:
return {"status": "error", "stdout": "", "stderr": str(e), "exit_code": -1}
@app.post("/exec/kill")
async def exec_kill(payload: dict, x_api_key: str = Header(default="")):
"""Kill a process started with wait=false."""
verify_api_key(x_api_key)
pid = int(payload.get("pid", 0))
if pid <= 0:
raise HTTPException(status_code=400, detail="pid required")
proc = _running_procs.pop(pid, None)
if proc and proc.returncode is None:
proc.kill()
try:
await proc.wait()
except Exception:
pass
return {"status": "killed", "pid": pid}
raise HTTPException(
status_code=404,
detail="process not found or not started by this agent",
)
@app.post("/exec/wait")
async def exec_wait(payload: dict, x_api_key: str = Header(default="")):
"""Wait for a process started with wait=false."""
verify_api_key(x_api_key)
pid = int(payload.get("pid", 0))
timeout = int(payload.get("timeout", 60))
if pid <= 0:
raise HTTPException(status_code=400, detail="pid required")
proc = _running_procs.get(pid)
if not proc:
raise HTTPException(status_code=404, detail="unknown pid (finished or invalid)")
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
_running_procs.pop(pid, None)
return _proc_result(proc, stdout, stderr)
except asyncio.TimeoutError:
_running_procs.pop(pid, None)
if proc.returncode is None:
proc.kill()
try:
await proc.wait()
except Exception:
pass
return {
"status": "timeout",
"stdout": "",
"stderr": f"Command timed out after {timeout}s",
"exit_code": -1,
"pid": pid,
}
# --- Config Reload ---
@app.post("/config/reload")
@@ -168,31 +261,27 @@ async def reload_config(data: dict, x_api_key: str = Header(default="")):
_last_metrics = {}
_last_full_sync = 0
HISTORY_SIZE = 10
_cpu_init = psutil.cpu_percent()
_mem_init = psutil.virtual_memory().percent
_disk_init = psutil.disk_usage("/").percent
_metric_history = [{"cpu": _cpu_init, "memory": _mem_init, "disk": _disk_init}] * min(5, HISTORY_SIZE)
def _check_alert():
"""Check if CPU/mem > 80% or disk > 90% for HISTORY_SIZE consecutive samples"""
if len(_metric_history) < HISTORY_SIZE:
return False, {}
cpu_high = all(h["cpu"] >= 80 for h in _metric_history)
mem_high = all(h["memory"] >= 80 for h in _metric_history)
disk_high = all(h["disk"] >= 90 for h in _metric_history)
alerts = {}
if cpu_high: alerts["cpu_alert"] = True
if mem_high: alerts["memory_alert"] = True
if disk_high: alerts["disk_alert"] = True
return bool(alerts), alerts
def _alert_thresholds() -> dict:
"""Align with Nexus center defaults (config.py / settings table). Override in config.json if needed."""
t = config.get("alert_thresholds") or {}
return {
"cpu": float(t.get("cpu", 80)),
"mem": float(t.get("mem", 80)),
"disk": float(t.get("disk", 80)),
}
def _metrics_over_threshold(cpu: float, mem: float, disk: float) -> bool:
"""Same rule as center _detect_alerts: strictly greater than threshold."""
th = _alert_thresholds()
return cpu > th["cpu"] or mem > th["mem"] or disk > th["disk"]
async def send_heartbeat():
"""Send heartbeat to Nexus central — smart: only when metrics change >10% or alert"""
global _last_metrics, _last_full_sync, _metric_history
"""Send heartbeat to Nexus central — smart: change >10%, over threshold, or every 10min."""
global _last_metrics, _last_full_sync
while True:
try:
if not CENTRAL_URL or not SERVER_ID:
@@ -210,14 +299,9 @@ async def send_heartbeat():
abs(disk - _last_metrics.get("disk", 0)) >= 10
)
force_sync = (now_ts - _last_full_sync) > 600
over_threshold = _metrics_over_threshold(cpu, mem, disk)
_metric_history.append({"cpu": cpu, "memory": mem, "disk": disk})
if len(_metric_history) > HISTORY_SIZE:
_metric_history.pop(0)
has_alert, alert_info = _check_alert()
if changed or force_sync or has_alert:
if changed or force_sync or over_threshold:
_last_metrics = {"cpu": cpu, "memory": mem, "disk": disk}
if force_sync:
_last_full_sync = now_ts
@@ -234,8 +318,6 @@ async def send_heartbeat():
"platform": platform.platform(),
},
}
if has_alert:
payload["alert"] = alert_info
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
@@ -263,7 +345,7 @@ async def startup():
logger.info(f"Nexus Agent starting (server_id={SERVER_ID})")
asyncio.create_task(send_heartbeat())
host = config.get("server", {}).get("host", "0.0.0.0")
host = config.get("server", {}).get("host", "127.0.0.1")
port = config.get("server", {}).get("port", 8601)
logger.info(f"Agent listening on {host}:{port}")
@@ -274,7 +356,7 @@ async def shutdown():
if __name__ == "__main__":
host = config.get("server", {}).get("host", "0.0.0.0")
host = config.get("server", {}).get("host", "127.0.0.1")
port = config.get("server", {}).get("port", 8601)
import uvicorn
uvicorn.run(app, host=host, port=port)
+6 -1
View File
@@ -6,10 +6,15 @@
},
"api_key": "YOUR_API_KEY",
"heartbeat_interval": 60,
"alert_thresholds": {
"cpu": 80,
"mem": 80,
"disk": 80
},
"log_file": "/var/log/nexus-agent.log",
"log_level": "INFO",
"server": {
"host": "0.0.0.0",
"host": "127.0.0.1",
"port": 8601
}
}
+1 -1
View File
@@ -74,7 +74,7 @@ cat > "$INSTALL_DIR/config.json" << EOF
"heartbeat_interval": 60,
"log_file": "/var/log/nexus-agent.log",
"log_level": "INFO",
"server": { "host": "0.0.0.0", "port": ${AGENT_PORT} }
"server": { "host": "127.0.0.1", "port": ${AGENT_PORT} }
}
EOF
-1
View File
@@ -128,7 +128,6 @@ if (!token || !_checkSessionAge()) {
} else {
_recordActivity();
}
function ah() { return apiHeaders(); }
// ── Global Toast Notification System ──
// Call toast('success', '操作成功') or toast('error', '操作失败') from any page.
+1 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 资产管理</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 资产管理</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true,tab:'platforms'}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
+9 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 审计日志</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 审计日志</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
@@ -16,6 +16,14 @@
<option value="login">登录</option>
<option value="retry_job">重试任务</option>
<option value="delete_retry">删除重试</option>
<option value="execute_started">脚本执行开始</option>
<option value="execute_command">脚本执行命令</option>
<option value="stop_execution">停止脚本执行</option>
<option value="retry_execution">重试脚本执行</option>
<option value="mark_stuck">标记脚本卡住</option>
<option value="auto_stuck">自动检测卡住</option>
<option value="server_auto_stuck">单台服务器卡住</option>
<option value="script_job_callback">长任务回调</option>
</select>
<button onclick="_offset=0;loadAudit()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
</div>
+1 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 命令日志</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 命令日志</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
+1 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 凭据管理</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 凭据管理</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true,tab:'db'}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
+1 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 文件管理</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 文件管理</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
+30 -85
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nexus — 仪表盘</title>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script>
<script src="/app/vendor/tailwindcss-browser.js"></script>
<script defer src="/app/vendor/alpinejs.min.js"></script>
<style type="text/tailwindcss">
@theme {
--color-brand: oklch(55% 0.2 250);
@@ -54,23 +54,16 @@
</div>
</div>
<!-- Live Alerts + Category Pie -->
<!-- Run status + Category Pie -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<!-- Alerts -->
<div class="lg:col-span-2">
<div id="alertBanner" class="hidden bg-red-900/30 border border-red-700/50 rounded-xl p-4">
<div class="flex items-center justify-between mb-2">
<h2 class="text-red-300 font-semibold text-sm">实时告警</h2>
<button onclick="clearAlerts()" class="text-red-400 hover:text-red-300 text-xs">清除</button>
</div>
<div id="alertList" class="space-y-1 max-h-40 overflow-y-auto text-sm"></div>
</div>
<!-- No alerts state -->
<div id="noAlertBanner" class="bg-slate-900 rounded-xl border border-slate-800 p-4">
<div class="flex items-center gap-2 text-slate-500 text-sm">
<span class="inline-block w-2 h-2 rounded-full bg-green-400"></span>
所有服务器运行正常
<div id="statusPanel" class="bg-slate-900 rounded-xl border border-slate-800 p-4">
<h2 class="text-white font-semibold text-sm mb-2">运行状态</h2>
<div id="statusSummary" class="flex items-center gap-2 text-slate-500 text-sm">
<span id="statusDot" class="inline-block w-2 h-2 rounded-full bg-slate-600 shrink-0"></span>
<span id="statusText">加载中...</span>
</div>
<p class="text-slate-600 text-xs mt-2">告警详情通过 Telegram 推送;此处仅展示汇总状态。</p>
</div>
</div>
<!-- Category Breakdown -->
@@ -108,8 +101,6 @@
<script src="/app/layout.js"></script>
<script>
initLayout('index');
const MAX_ALERTS = 20;
let _alerts = [];
let _ws = null;
let _wsRetry = 0;
let _stats = null;
@@ -124,11 +115,30 @@
document.getElementById('statOnline').textContent = _stats.online || 0;
document.getElementById('statOffline').textContent = _stats.offline || 0;
document.getElementById('statAlerts').textContent = _stats.alerts || 0;
// Category breakdown
renderCategories(_stats.categories || {});
updateStatusPanel();
} catch(e) {}
}
function updateStatusPanel() {
const n = (_stats && _stats.alerts) ? _stats.alerts : 0;
const panel = document.getElementById('statusPanel');
const dot = document.getElementById('statusDot');
const text = document.getElementById('statusText');
if (!panel || !dot || !text) return;
if (n > 0) {
panel.className = 'bg-slate-900 rounded-xl border border-yellow-700/40 p-4';
dot.className = 'inline-block w-2 h-2 rounded-full bg-yellow-400 shrink-0';
text.className = 'text-yellow-200/90 text-sm';
text.textContent = `当前有 ${n} 台服务器处于告警状态(详见 Telegram)`;
} else {
panel.className = 'bg-slate-900 rounded-xl border border-slate-800 p-4';
dot.className = 'inline-block w-2 h-2 rounded-full bg-green-400 shrink-0';
text.className = 'text-slate-400 text-sm';
text.textContent = '所有服务器运行正常';
}
}
function renderCategories(categories) {
const entries = Object.entries(categories);
if (!entries.length) {
@@ -194,7 +204,6 @@
// ── Helpers ──
function fmtTime(t) { if (!t) return ''; return new Date(t + 'Z').toLocaleString('zh-CN', { hour: '2-digit', minute: '2-digit' }); }
function fmtTimeObj(d) { return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); }
function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
loadDashboard();
@@ -211,20 +220,8 @@
try {
const msg = JSON.parse(e.data);
if (msg.type === 'ping') { _ws.send('pong'); return; }
// Refresh stats + recent syncs on any real-time event
// WS 仅用于刷新仪表盘状态;主动通知走 Telegram
loadDashboard();
loadSyncs();
if (msg.type === 'alert') {
_addAlert('\u{1F534}', `${msg.server_name} ${msg.alert_type} ${msg.alert_value.toFixed(1)}%`);
_playAlertSound();
const el = document.getElementById('statAlerts');
el.classList.add('animate-pulse');
setTimeout(() => el.classList.remove('animate-pulse'), 3000);
} else if (msg.type === 'recovery') {
_addAlert('\u{1F7E2}', `${msg.server_name} ${msg.metric} 恢复正常 (${msg.value.toFixed(1)}%)`);
} else if (msg.type === 'system') {
_addAlert('⚠️', msg.message);
}
} catch(ex) {}
};
_ws.onclose = () => {
@@ -235,58 +232,6 @@
_ws.onerror = () => { _ws.close(); };
}
function _addAlert(icon, text) {
_alerts.unshift({ icon, text, time: new Date() });
if (_alerts.length > MAX_ALERTS) _alerts.pop();
renderAlerts();
}
function renderAlerts() {
if (!_alerts.length) return;
document.getElementById('alertBanner').classList.remove('hidden');
document.getElementById('noAlertBanner').classList.add('hidden');
document.getElementById('alertList').innerHTML = _alerts.map(a =>
`<div class="text-slate-300">${a.icon} ${esc(a.text)} <span class="text-slate-600 text-xs float-right">${fmtTimeObj(a.time)}</span></div>`
).join('');
}
function clearAlerts() {
_alerts = [];
document.getElementById('alertBanner').classList.add('hidden');
document.getElementById('noAlertBanner').classList.remove('hidden');
}
// Alert sound using Web Audio API — no external file needed
function _playAlertSound() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 880;
osc.type = 'sine';
gain.gain.value = 0.15;
osc.start();
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
osc.stop(ctx.currentTime + 0.3);
} catch(e) {}
// Flash tab title
_flashTitle();
}
let _titleFlashTimer = null;
function _flashTitle() {
const orig = document.title;
let count = 0;
clearInterval(_titleFlashTimer);
_titleFlashTimer = setInterval(() => {
document.title = count % 2 === 0 ? '🔴 告警 — Nexus' : orig;
count++;
if (count > 10) { clearInterval(_titleFlashTimer); document.title = orig; }
}, 800);
}
function refreshAll() {
loadDashboard();
loadActivity();
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nexus 6.0 安装向导</title>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script>
<script src="/app/vendor/tailwindcss-browser.js"></script>
<script defer src="/app/vendor/alpinejs.min.js"></script>
<style type="text/tailwindcss">
@theme {
--color-brand: oklch(55% 0.2 250);
+3 -2
View File
@@ -11,6 +11,7 @@ function initLayout(activeNav) {
{ id:'files', icon:'📁', label:'文件管理', href:'/app/files.html' },
{ id:'push', icon:'📤', label:'推送', href:'/app/push.html' },
{ id:'scripts', icon:'📜', label:'脚本库', href:'/app/scripts.html' },
{ id:'script_exec', icon:'📑', label:'执行历史', href:'/app/script-executions.html' },
{ id:'credentials', icon:'🔑', label:'凭据', href:'/app/credentials.html' },
{ id:'schedules', icon:'⏰', label:'调度', href:'/app/schedules.html' },
{ id:'retries', icon:'🔄', label:'重试队列', href:'/app/retries.html' },
@@ -134,7 +135,7 @@ async function loadLayoutUser() {
if (headerEl) headerEl.textContent = u.username;
const brandEl = document.getElementById('brandName');
if (brandEl && u.system_name) brandEl.textContent = u.system_name;
} catch(e) {}
} catch(e) { console.error('Failed to load user info:', e); }
}
// ── Global Search ──
@@ -173,7 +174,7 @@ async function doGlobalSearch(q) {
}
panel.innerHTML = html;
panel.classList.remove('hidden');
} catch(e) {}
} catch(e) { console.error('Global search failed:', e); }
}
// Close search on click outside
+1 -1
View File
@@ -6,7 +6,7 @@
<title>Nexus — 登录</title>
<!-- Tailwind CSS v4 CDN -->
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script src="/app/vendor/tailwindcss-browser.js"></script>
<style type="text/tailwindcss">
@theme {
+1 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 推送</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 推送</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
+1 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 重试队列</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 重试队列</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
+1 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 定时调度</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 定时调度</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
+190
View File
@@ -0,0 +1,190 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 执行历史</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4">
<button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button>
<h1 class="text-white font-semibold">脚本执行历史</h1>
</div>
<div class="flex items-center gap-2">
<a href="/app/scripts.html" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg">脚本库</a>
<select id="statusFilter" onchange="_offset=0;loadList()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<option value="">全部状态</option>
<option value="running">执行中</option>
<option value="completed">已完成</option>
<option value="failed">失败</option>
<option value="partial">部分失败</option>
</select>
<button onclick="_offset=0;loadList()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg">刷新</button>
</div>
</header>
<main class="flex-1 overflow-y-auto p-6">
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-slate-800/50 text-slate-400 text-xs uppercase">
<tr>
<th class="text-left px-4 py-3">ID</th>
<th class="text-left px-4 py-3">状态</th>
<th class="text-left px-4 py-3">进度</th>
<th class="text-left px-4 py-3">操作人</th>
<th class="text-left px-4 py-3">命令</th>
<th class="text-left px-4 py-3">开始</th>
<th class="text-right px-4 py-3">操作</th>
</tr>
</thead>
<tbody id="listBody" class="divide-y divide-slate-800"><tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">加载中…</td></tr></tbody>
</table>
</div>
<div id="pagination" class="hidden mt-4 flex items-center justify-between">
<span id="pageInfo" class="text-slate-500 text-sm"></span>
<div class="flex gap-2">
<button onclick="prevPage()" id="prevBtn" class="px-3 py-1.5 bg-slate-800 text-white text-sm rounded-lg disabled:opacity-40">上一页</button>
<button onclick="nextPage()" id="nextBtn" class="px-3 py-1.5 bg-slate-800 text-white text-sm rounded-lg disabled:opacity-40">下一页</button>
</div>
</div>
<div id="detailPanel" class="hidden mt-6 bg-slate-900 rounded-xl border border-slate-800 p-4">
<div class="flex justify-between items-center mb-2 flex-wrap gap-2">
<h2 class="text-white font-medium" id="detailTitle">执行详情</h2>
<div id="detailActions" class="flex flex-wrap gap-2"></div>
</div>
<pre id="detailPre" class="text-xs text-slate-300 font-mono bg-slate-950 p-3 rounded-lg max-h-96 overflow-auto whitespace-pre-wrap"></pre>
</div>
</main>
</div>
<script src="/app/api.js"></script>
<script src="/app/layout.js"></script>
<script>
initLayout('script_exec');
const PAGE=30;
let _offset=0,_total=0,_pollTimer=null,_detailId=null,_detailData=null;
const ST={running:'执行中',completed:'完成',failed:'失败',partial:'部分失败'};
const CLS={running:'text-amber-300',completed:'text-emerald-300',failed:'text-red-300',partial:'text-orange-300'};
function fmt(t){if(!t)return'--';const s=String(t);const iso=s.endsWith('Z')||s.includes('+')?s:s+'Z';return new Date(iso).toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit',second:'2-digit'})}
function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML}
function hasPending(results){
if(!results||typeof results!=='object')return false;
return Object.entries(results).some(([k,v])=>k!=='_meta'&&v&&v.phase==='pending');
}
function canRetry(status,results){
return status==='failed'||status==='partial'||(status==='running'&&hasPending(results));
}
async function loadList(){
const st=document.getElementById('statusFilter').value;
const url=API+'/scripts/executions?limit='+PAGE+'&offset='+_offset+(st?'&status='+encodeURIComponent(st):'');
const r=await apiFetch(url);if(!r)return;
const data=await r.json();
_total=data.total||0;
const items=data.items||[];
const tb=document.getElementById('listBody');
if(!items.length){tb.innerHTML='<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">暂无记录</td></tr>';updatePage();setupPoll();return}
tb.innerHTML=items.map(ex=>{
const cmd=(ex.command||'').replace(/^\[long_task\] /,'');
const src=ex.source==='redis'?' <span class="text-slate-600">(热)</span>':'';
const prog=ex.progress||'—';
const run=ex.status==='running'||hasPending(ex.results);
const stopBtn=run?`<button onclick="stopExec(${ex.id})" class="text-red-300 text-xs hover:underline mr-2">停止</button>`:'';
const retryBtn=canRetry(ex.status,ex.results)?`<button onclick="retryExec(${ex.id},${!!ex.long_task})" class="text-amber-200 text-xs hover:underline mr-2">重试</button>`:'';
const stuckBtn=run?`<button onclick="markStuck(${ex.id})" class="text-slate-400 text-xs hover:underline mr-2">标记卡住</button>`:'';
return `<tr class="hover:bg-slate-800/30">
<td class="px-4 py-3 text-white">#${ex.id}${src}</td>
<td class="px-4 py-3 ${CLS[ex.status]||''}">${ST[ex.status]||esc(ex.status)}</td>
<td class="px-4 py-3 text-slate-400 text-xs">${esc(prog)}</td>
<td class="px-4 py-3 text-slate-400">${esc(ex.operator||'--')}</td>
<td class="px-4 py-3 text-slate-500 text-xs max-w-xs truncate" title="${esc(cmd)}">${esc(cmd)}</td>
<td class="px-4 py-3 text-slate-500 text-xs whitespace-nowrap">${fmt(ex.started_at)}</td>
<td class="px-4 py-3 text-right text-xs whitespace-nowrap">
${stopBtn}${retryBtn}${stuckBtn}
<button onclick="showDetail(${ex.id},false)" class="text-brand-light hover:underline mr-2">详情</button>
<button onclick="showDetail(${ex.id},true)" class="text-slate-400 hover:underline">+日志</button>
</td>
</tr>`;
}).join('');
updatePage();
setupPoll();
}
function setupPoll(){
if(_pollTimer){clearInterval(_pollTimer);_pollTimer=null}
const st=document.getElementById('statusFilter').value;
if(st==='running'||st===''){
_pollTimer=setInterval(()=>{loadList();if(_detailId)showDetail(_detailId,false,true)},10000);
}
}
function updatePage(){
const page=Math.floor(_offset/PAGE)+1,totalPages=Math.max(1,Math.ceil(_total/PAGE));
document.getElementById('pagination').classList.toggle('hidden',_total<=PAGE);
document.getElementById('pageInfo').textContent=`第 ${page}/${totalPages} 页 · 共 ${_total} 条`;
document.getElementById('prevBtn').disabled=_offset<=0;
document.getElementById('nextBtn').disabled=_offset+PAGE>=_total;
}
function prevPage(){if(_offset>0){_offset-=PAGE;loadList()}}
function nextPage(){if(_offset+PAGE<_total){_offset+=PAGE;loadList()}}
function renderDetailActions(){
const d=_detailData;if(!d)return;
const run=d.status==='running'||hasPending(d.results);
let html='';
if(run)html+=`<button onclick="stopExec(${d.id})" class="text-xs px-2 py-1 rounded border border-red-500/50 text-red-300">停止</button>`;
if(canRetry(d.status,d.results))html+=`<button onclick="retryExec(${d.id},${!!d.long_task})" class="text-xs px-2 py-1 rounded border border-amber-500/50 text-amber-200">重试</button>`;
if(run)html+=`<button onclick="markStuck(${d.id})" class="text-xs px-2 py-1 rounded border border-slate-600 text-slate-400">标记卡住</button>`;
html+=`<button onclick="showDetail(${d.id},true)" class="text-xs px-2 py-1 rounded border border-brand/50 text-brand-light">状态+日志</button>`;
html+=`<button onclick="hideDetail()" class="text-xs px-2 py-1 rounded border border-slate-700 text-slate-400">关闭</button>`;
document.getElementById('detailActions').innerHTML=html;
}
async function showDetail(id,fetchLogs,silent){
_detailId=id;
const r=await apiFetch(API+'/scripts/executions/'+id+'?fetch_logs='+(fetchLogs?'true':'false'));
if(!r||!r.ok){if(!silent)toast('error','加载失败');return}
const d=await r.json();
_detailData=d;
let text='';
if(d.progress)text+=`进度: ${d.progress}\n\n`;
if(d.events&&d.events.length){
text+='[事件]\n'+d.events.map(e=>`${fmt(e.at)} ${e.action}: ${e.detail}`).join('\n')+'\n\n';
}
const res=d.results||{};
for(const[sid,row]of Object.entries(res)){
if(sid==='_meta')continue;
text+=`--- 服务器 #${sid} ---\n${JSON.stringify(row,null,2)}\n\n`;
}
document.getElementById('detailTitle').textContent='执行 #'+id+' · '+(ST[d.status]||d.status);
document.getElementById('detailPre').textContent=text||'(空)';
document.getElementById('detailPanel').classList.remove('hidden');
renderDetailActions();
}
function hideDetail(){document.getElementById('detailPanel').classList.add('hidden');_detailId=null;_detailData=null}
async function stopExec(id){
if(!confirm('停止该执行中 pending 的子机任务?'))return;
const r=await apiFetch(API+'/scripts/executions/'+id+'/stop',{method:'POST',headers:apiHeadersJSON(),body:'{}'});
if(r&&r.ok){toast('success','已发送停止');loadList();if(_detailId===id)showDetail(id,false)}
else toast('error','停止失败');
}
async function retryExec(id,longTask){
if(!confirm('对失败/未完成的服务器重新执行?'))return;
const r=await apiFetch(API+'/scripts/executions/'+id+'/retry',{
method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({timeout:120,long_task:!!longTask}),
});
if(r&&r.ok){
const d=await r.json();
toast('success','已重新提交 #'+(d.id||id));
if(d.id)showDetail(d.id,false);
loadList();
}else toast('error','重试失败');
}
async function markStuck(id){
const r=await apiFetch(API+'/scripts/executions/'+id+'/mark-stuck',{method:'POST',headers:apiHeadersJSON(),body:'{}'});
if(r&&r.ok){toast('success','已标记');loadList();if(_detailId===id)showDetail(id,false)}
else toast('error','标记失败');
}
loadList();
</script>
</body></html>
+390 -25
View File
@@ -1,8 +1,12 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 脚本库</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 脚本库</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0"><header class="bg-slate-900 border-b border-slate-800 px-6 py-3"><div class="flex items-center justify-between"><div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">脚本库</h1></div><button onclick="showNewScript()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg">+ 新建脚本</button></div></header>
<div class="flex-1 flex flex-col min-w-0"><header class="bg-slate-900 border-b border-slate-800 px-6 py-3"><div class="flex items-center justify-between"><div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">脚本库</h1></div><div class="flex items-center gap-2"><a href="/app/script-executions.html" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg">执行历史</a><button onclick="showNewScript()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg">+ 新建脚本</button></div></div></header>
<main class="flex-1 overflow-y-auto p-6">
<div id="execStatusPanel" class="hidden mb-6 bg-slate-900 rounded-xl border border-slate-800 p-4">
<h2 class="text-white font-semibold text-sm mb-3">批量执行状态</h2>
<div id="execStatusList" class="space-y-2"></div>
</div>
<div id="scriptList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
<!-- Add/Edit Script Form -->
@@ -20,9 +24,22 @@
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg space-y-3">
<h2 class="text-white font-semibold text-lg">执行脚本</h2>
<p id="execScriptName" class="text-slate-400 text-sm"></p>
<div><label class="block text-slate-400 text-xs mb-1">选择服务器</label><select id="execServers" multiple class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm h-32"></select></div>
<div><label class="block text-slate-400 text-xs mb-1">超时(秒)</label><input id="execTimeout" type="number" value="60" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
<div id="execResult" class="hidden p-3 bg-slate-950 rounded-lg text-sm font-mono text-slate-300 max-h-64 overflow-y-auto whitespace-pre-wrap"></div>
<div>
<div class="flex items-center justify-between mb-1">
<label class="block text-slate-400 text-xs">选择服务器</label>
<button type="button" onclick="selectAllExecServers()" class="text-brand-light text-xs hover:underline">全选</button>
</div>
<select id="execServers" multiple class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm h-32"></select>
<p id="execBatchHint" class="text-slate-500 text-xs mt-1"></p>
</div>
<div><label class="block text-slate-400 text-xs mb-1">超时(秒)</label><input id="execTimeout" type="number" value="60" min="10" max="600" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><p class="text-slate-500 text-xs mt-1">仅限制「点火」命令;长任务勾选后 60~120 秒即可</p></div>
<div><label class="block text-slate-400 text-xs mb-1">DB 凭据(可选,替换命令中的 $DB_*)</label><select id="execCredential" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="">不使用</option></select></div>
<label class="flex items-start gap-2 cursor-pointer select-none">
<input type="checkbox" id="execLongTask" class="mt-1 rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand" onchange="updateLongTaskHint()">
<span class="text-sm text-slate-300">长任务(后台 <code class="text-slate-400">nohup</code>,日志在子机 <code class="text-slate-400">/var/log/nexus-job/</code></span>
</label>
<p id="execLongHint" class="hidden text-amber-400/90 text-xs pl-6">主站 nohup 包装并注册任务;子机脚本结束后自动 curl 主站回传结果(需配置 NEXUS_API_BASE_URL</p>
<p class="text-slate-500 text-xs">提交后在本页「批量执行状态」查看进度,可关闭此窗口</p>
<div class="flex gap-2"><button onclick="doExec()" id="execBtn" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">执行</button><button onclick="hideExecModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">关闭</button></div>
</div>
</div>
@@ -33,7 +50,305 @@
<script>
initLayout('scripts');
let _scriptsCache=[];
async function loadScripts(){try{const r=await apiFetch(API+'/scripts/');if(!r)return;const scripts=await r.json();_scriptsCache=scripts;document.getElementById('scriptList').innerHTML=scripts.length?scripts.map(s=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4"><div class="flex items-center justify-between"><span class="text-white font-medium cursor-pointer hover:text-brand-light transition" onclick="showEditScript(${s.id})">${esc(s.name)}</span><div class="flex items-center gap-2"><span class="text-xs px-2 py-0.5 rounded bg-slate-800 text-slate-400">${s.category||'ops'}</span><button onclick="showExec(${s.id})" class="text-brand-light text-xs hover:underline">执行</button><button onclick="showEditScript(${s.id})" class="text-slate-400 text-xs hover:underline">编辑</button><button onclick="deleteScript(${s.id})" class="text-red-400 text-xs hover:underline">删除</button></div></div><pre class="mt-2 text-sm text-slate-400 font-mono bg-slate-950 rounded-lg p-3 overflow-x-auto max-h-32">${esc(s.content?.substring(0,300)||'')}</pre></div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无脚本</div>'}catch(e){}}
let _execBatchSize=50;
let _trackedExecutions=[];
let _execPollTimer=null;
const EXEC_STATUS_LABEL={running:'执行中',completed:'执行完',failed:'执行完',partial:'执行完'};
const EXEC_STATUS_CLASS={
running:'bg-amber-500/20 text-amber-300 border-amber-500/40',
completed:'bg-emerald-500/20 text-emerald-300 border-emerald-500/40',
failed:'bg-red-500/20 text-red-300 border-red-500/40',
partial:'bg-orange-500/20 text-orange-300 border-orange-500/40',
};
function parseExecResults(raw){
if(!raw)return{};
let data=raw;
if(typeof raw==='string'){try{data=JSON.parse(raw)}catch(e){return{}}}
if(data&&typeof data==='object'&&!Array.isArray(data)){const c={...data};delete c._meta;return c}
return data||{};
}
function execProgressSummary(results,totalServers){
const entries=Object.entries(parseExecResults(results));
const total=totalServers||entries.length;
if(!total)return{total:0,done:0,pending:0,failed:0,running:0};
let pending=0,failed=0,done=0;
for(const[,r]of entries){
if(r&&r.phase==='pending'){pending++;continue}
done++;
if(r&&r.exit_code!==0)failed++;
}
const running=Math.max(0,total-done-pending);
return{total,done,pending,failed,running};
}
function aggregateExecStatus(statuses){
if(!statuses.length)return'completed';
if(statuses.some(s=>s==='running'))return'running';
if(statuses.every(s=>s==='failed'))return'failed';
if(statuses.some(s=>s==='failed'||s==='partial'))return'partial';
return'completed';
}
function mergeExecResults(target,raw){
Object.assign(target,parseExecResults(raw));
}
function execProgressDetail(t){
const st=t.data?.status||'running';
const prog=execProgressSummary(t.data?.results,t.totalServers);
const batch=t.batchTotal>1?` · 第 ${t.batchCur||0}/${t.batchTotal} 批`:'';
if(t.submitting){
return `正在执行 0/${t.totalServers} 台${batch}`;
}
if(st==='running'){
const left=prog.pending+prog.running;
if(prog.done>0||left<t.totalServers){
return `已完成 ${prog.done}/${prog.total} 台,${left} 台执行中${batch}`;
}
return `正在执行 0/${prog.total} 台${batch}`;
}
const ok=prog.total-prog.failed;
return `${ok}/${prog.total} 台成功`+(prog.failed?`${prog.failed} 台失败`:'');
}
function formatJobLogs(merged,events){
let out='';
if(events&&events.length){
out+='[事件时间线]\n';
events.forEach(ev=>{
out+=`${ev.at||''} ${ev.action||''}: ${ev.detail||''}${ev.operator?' ('+ev.operator+')':''}\n`;
});
out+='\n';
}
for(const[sid,res]of Object.entries(merged||{})){
const phase=res?.phase==='pending'?'执行中':res?.phase==='cancelled'?'已停止':(res?.background?'后台结束':'已结束');
out+=`\n=== 服务器 #${sid} (${phase}) ===\n`;
if(res?.exit_code!==undefined&&res?.exit_code!==null)out+=`退出码: ${res.exit_code}\n`;
if(res?.message)out+=`消息: ${res.message}\n`;
if(res?.stdout)out+=`[点火输出]\n${res.stdout}\n`;
if(res?.stderr)out+=`[stderr]\n${res.stderr}\n`;
if(res?.log_tail)out+=`[回调摘要]\n${res.log_tail}\n`;
if(res?.remote_log)out+=`[子机日志]\n${res.remote_log}\n`;
else if(res?.background&&res?.phase==='pending')
out+=`(无子机日志路径或未拉取;可上机查看 /var/log/nexus-job/)\n`;
}
return out.trim()||'(暂无日志)';
}
async function refreshJobStatusLogs(jobKey,fetchLogs){
const job=_trackedExecutions.find(t=>t.id===jobKey);
if(!job?.executionIds?.length){toast('warning','尚无执行记录');return}
job.logLoading=true;
renderExecStatus();
const merged={};
const statuses=[];
const allEvents=[];
try{
for(const eid of job.executionIds){
const q=fetchLogs?'?fetch_logs=true&log_lines=120':'';
const r=await apiFetch(API+`/scripts/executions/${eid}${q}`);
if(!r||!r.ok){toast('error',`读取执行 #${eid} 失败`);continue}
const d=await r.json();
statuses.push(d.status);
mergeExecResults(merged,d.results);
if(d.events&&d.events.length)allEvents.push(...d.events);
}
allEvents.sort((a,b)=>(a.at||'').localeCompare(b.at||''));
job.data={status:aggregateExecStatus(statuses),results:JSON.stringify(merged),events:allEvents};
job.logText=formatJobLogs(merged,allEvents);
job.logExpanded=true;
if(job.data.status==='running')startExecPolling();
}catch(e){toast('error','刷新失败')}
job.logLoading=false;
renderExecStatus();
}
function jobHasPending(job){
const merged=parseExecResults(job.data?.results);
return Object.values(merged).some(r=>r&&r.phase==='pending');
}
function jobCanRetry(job){
const st=job.data?.status;
return st==='failed'||st==='partial'||st==='running'||jobHasPending(job);
}
async function markStuckJob(jobKey){
const job=_trackedExecutions.find(t=>t.id===jobKey);
if(!job?.executionIds?.length)return;
const note=prompt('卡住说明(可选)','执行长时间无进展');
if(note===null)return;
for(const eid of job.executionIds){
await apiFetch(API+`/scripts/executions/${eid}/mark-stuck`,{
method:'POST',
headers:apiHeadersJSON(),
body:JSON.stringify({detail:note||'用户标记卡住'}),
});
}
toast('info','已记录卡住标记');
await refreshJobStatusLogs(jobKey,false);
}
async function stopJob(jobKey){
const job=_trackedExecutions.find(t=>t.id===jobKey);
if(!job?.executionIds?.length)return;
if(!confirm('停止所有仍在执行中的子机任务?'))return;
for(const eid of job.executionIds){
await apiFetch(API+`/scripts/executions/${eid}/stop`,{method:'POST'});
}
toast('success','已发送停止');
await refreshJobStatusLogs(jobKey,false);
}
async function retryJob(jobKey){
const job=_trackedExecutions.find(t=>t.id===jobKey);
if(!job?.executionIds?.length)return;
if(!confirm('对失败/未完成的服务器重新执行?'))return;
job.submitting=true;
renderExecStatus();
const newIds=[];
try{
for(const eid of job.executionIds){
const r=await apiFetch(API+`/scripts/executions/${eid}/retry`,{
method:'POST',
headers:apiHeadersJSON(),
body:JSON.stringify({timeout:120,long_task:!!job.longTask}),
});
if(r&&r.ok){const d=await r.json();if(d.id)newIds.push(d.id)}
}
if(newIds.length){
job.executionIds=newIds;
job.data={status:'running',results:'{}'};
job.logText='';
job.logExpanded=false;
startExecPolling();
toast('success','已重新提交执行');
}else toast('error','重试失败');
}catch(e){toast('error','重试失败')}
job.submitting=false;
renderExecStatus();
await refreshJobStatusLogs(jobKey,false);
}
function toggleJobLog(jobKey){
const job=_trackedExecutions.find(t=>t.id===jobKey);
if(!job)return;
job.logExpanded=!job.logExpanded;
renderExecStatus();
}
function renderExecStatus(){
const list=document.getElementById('execStatusList');
if(!_trackedExecutions.length){
document.getElementById('execStatusPanel').classList.add('hidden');
list.innerHTML='';
return;
}
list.innerHTML=_trackedExecutions.map(t=>{
const st=t.submitting?'running':(t.data?.status||'running');
const label=EXEC_STATUS_LABEL[st]||'执行中';
const cls=EXEC_STATUS_CLASS[st]||EXEC_STATUS_CLASS.running;
const ids=t.executionIds?.length?`#${t.executionIds.join(',#')}`:'';
const logBlock=t.logExpanded?`<pre class="mt-2 p-3 bg-slate-900 rounded-lg text-xs text-slate-300 font-mono max-h-72 overflow-auto whitespace-pre-wrap border border-slate-800">${esc(t.logText||'(空)')}</pre>`:'';
const btnDis=t.logLoading||!t.executionIds?.length?'disabled':'';
const showStop=jobHasPending(t)&&!t.submitting;
const showRetry=jobCanRetry(t)&&!t.submitting&&!t.logLoading;
const showStuck=(t.data?.status==='running'||jobHasPending(t))&&!t.submitting;
return `<div class="rounded-lg bg-slate-950 border border-slate-800 p-3">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0 flex-1">
<div class="text-white text-sm truncate">${esc(t.scriptName||'脚本')}</div>
<div class="text-slate-500 text-xs mt-0.5">${esc(execProgressDetail(t))}${ids?` <span class="text-slate-600">${esc(ids)}</span>`:''}</div>
</div>
<div class="flex items-center gap-2 shrink-0 flex-wrap justify-end">
${showStuck?`<button type="button" onclick="markStuckJob('${t.id}')" class="text-xs px-2 py-1 rounded border border-slate-600 text-slate-400 hover:bg-slate-800">标记卡住</button>`:''}
${showStop?`<button type="button" onclick="stopJob('${t.id}')" class="text-xs px-2 py-1 rounded border border-red-500/50 text-red-300 hover:bg-red-500/10">停止</button>`:''}
${showRetry?`<button type="button" onclick="retryJob('${t.id}')" class="text-xs px-2 py-1 rounded border border-amber-500/50 text-amber-200 hover:bg-amber-500/10">重试</button>`:''}
<button type="button" onclick="refreshJobStatusLogs('${t.id}',false)" ${btnDis} class="text-xs px-2 py-1 rounded border border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-40">刷新状态</button>
<button type="button" onclick="refreshJobStatusLogs('${t.id}',true)" ${btnDis} class="text-xs px-2 py-1 rounded border border-brand/50 text-brand-light hover:bg-brand/10 disabled:opacity-40">${t.logLoading?'拉取中…':'状态+日志'}</button>
${t.logText?`<button type="button" onclick="toggleJobLog('${t.id}')" class="text-xs px-2 py-1 rounded border border-slate-700 text-slate-400 hover:bg-slate-800">${t.logExpanded?'收起':'展开'}</button>`:''}
<span class="text-xs px-2.5 py-1 rounded-full border ${cls}">${label}</span>
</div>
</div>${logBlock}
</div>`;
}).join('');
}
async function refreshTrackedExecutions(){
let anyRunning=false;
for(const t of _trackedExecutions){
if(t.submitting){anyRunning=true;continue}
const ids=t.executionIds||[];
if(!ids.length){
if(t.data?.status==='running')anyRunning=true;
continue;
}
const merged={};
const statuses=[];
for(const eid of ids){
const r=await apiFetch(API+'/scripts/executions/'+eid);
if(!r||!r.ok)continue;
const d=await r.json();
mergeExecResults(merged,d.results);
statuses.push(d.status);
}
t.data={
status:aggregateExecStatus(statuses),
results:JSON.stringify(merged),
};
if(t.data.status==='running')anyRunning=true;
}
renderExecStatus();
return anyRunning;
}
function startExecPolling(){
if(_execPollTimer)return;
const tick=async()=>{
const running=await refreshTrackedExecutions();
if(!running){
clearInterval(_execPollTimer);
_execPollTimer=null;
}
};
tick();
_execPollTimer=setInterval(tick,5000);
}
async function loadExecConfig(){
try{
const r=await apiFetch(API+'/scripts/exec-config');
if(r&&r.ok){const c=await r.json();_execBatchSize=c.batch_size||50}
}catch(e){}
}
loadExecConfig();
function selectAllExecServers(){
const sel=document.getElementById('execServers');
Array.from(sel.options).forEach(o=>{o.selected=true});
updateExecBatchHint();
}
function updateExecBatchHint(){
const n=document.getElementById('execServers').selectedOptions.length;
const batches=Math.ceil(n/_execBatchSize)||0;
const el=document.getElementById('execBatchHint');
if(!n){el.textContent='';return}
el.textContent=n<=_execBatchSize
? `已选 ${n} 台,单批执行`
: `已选 ${n} 台,将自动分 ${batches} 批(每批最多 ${_execBatchSize} 台)`;
}
document.getElementById('execServers')?.addEventListener?.('change',updateExecBatchHint);
function updateLongTaskHint(){
const on=document.getElementById('execLongTask').checked;
document.getElementById('execLongHint').classList.toggle('hidden',!on);
const t=document.getElementById('execTimeout');
if(on&&parseInt(t.value,10)<90)t.value='120';
}
async function loadScripts(){try{const r=await apiFetch(API+'/scripts/');if(!r)return;const scripts=await r.json();_scriptsCache=scripts;document.getElementById('scriptList').innerHTML=scripts.length?scripts.map(s=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4"><div class="flex items-center justify-between"><span class="text-white font-medium cursor-pointer hover:text-brand-light transition" onclick="showEditScript(${s.id})">${esc(s.name)}</span><div class="flex items-center gap-2"><span class="text-xs px-2 py-0.5 rounded bg-slate-800 text-slate-400">${esc(s.category||'ops')}</span><button onclick="showExec(${s.id})" class="text-brand-light text-xs hover:underline">执行</button><button onclick="showEditScript(${s.id})" class="text-slate-400 text-xs hover:underline">编辑</button><button onclick="deleteScript(${s.id})" class="text-red-400 text-xs hover:underline">删除</button></div></div><pre class="mt-2 text-sm text-slate-400 font-mono bg-slate-950 rounded-lg p-3 overflow-x-auto max-h-32">${esc(s.content?.substring(0,300)||'')}</pre></div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无脚本</div>'}catch(e){}}
function showNewScript(){
document.getElementById('editScriptId').value='';
@@ -69,32 +384,82 @@
async function deleteScript(id){if(!confirm('确定删除脚本?'))return;const r=await apiFetch(API+'/scripts/'+id,{method:'DELETE'});if(r&&r.ok)toast('success','脚本已删除');else toast('error','删除失败');loadScripts()}
async function showExec(id){const s=_scriptsCache.find(x=>x.id===id);if(!s)return;document.getElementById('execScriptName').textContent=s.name;document.getElementById('execResult').classList.add('hidden');document.getElementById('execBtn').disabled=false;const r=await apiFetch(API+'/servers/');if(!r)return;const data=await r.json();const servers=data.items||data;document.getElementById('execServers').innerHTML=servers.map(s=>`<option value="${s.id}">${esc(s.name)} (${esc(s.domain)})</option>`).join('');document.getElementById('execModal').classList.remove('hidden');document.getElementById('execModal').dataset.scriptId=id;document.getElementById('execModal').dataset.cmd=s.content||''}
async function showExec(id){const s=_scriptsCache.find(x=>x.id===id);if(!s)return;document.getElementById('execScriptName').textContent=s.name;document.getElementById('execBtn').disabled=false;document.getElementById('execBtn').textContent='执行';document.getElementById('execLongTask').checked=false;updateLongTaskHint();const [srvR,credR]=await Promise.all([apiFetch(API+'/servers/'),apiFetch(API+'/scripts/credentials')]);if(!srvR)return;const data=await srvR.json();const servers=data.items||data;document.getElementById('execServers').innerHTML=servers.map(s=>`<option value="${s.id}">${esc(s.name)} (${esc(s.domain)})</option>`).join('');const credSel=document.getElementById('execCredential');credSel.innerHTML='<option value="">不使用</option>';if(credR&&credR.ok){const creds=await credR.json();(Array.isArray(creds)?creds:[]).forEach(c=>{const o=document.createElement('option');o.value=c.id;o.textContent=`${esc(c.name)} (${esc(c.host)})`;credSel.appendChild(o)})}updateExecBatchHint();document.getElementById('execModal').classList.remove('hidden');document.getElementById('execModal').dataset.scriptId=id;document.getElementById('execModal').dataset.scriptName=s.name||'';document.getElementById('execModal').dataset.cmd=s.content||''}
function hideExecModal(){document.getElementById('execModal').classList.add('hidden')}
async function doExec(){
const opts=document.getElementById('execServers').selectedOptions;const ids=Array.from(opts).map(o=>parseInt(o.value));
const opts=document.getElementById('execServers').selectedOptions;
const ids=Array.from(opts).map(o=>parseInt(o.value));
if(!ids.length){toast('warning','请选择服务器');return}
const timeout=parseInt(document.getElementById('execTimeout').value)||60;
const el=document.getElementById('execResult');el.classList.remove('hidden');el.textContent='执行中...';
const btn=document.getElementById('execBtn');btn.disabled=true;btn.textContent='执行中...';
const btn=document.getElementById('execBtn');
const modal=document.getElementById('execModal');
const scriptId=parseInt(modal.dataset.scriptId)||null;const cmd=modal.dataset.cmd||'';
const r=await apiFetch(API+'/scripts/exec',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({script_id:scriptId,command:cmd,server_ids:ids,timeout:timeout})});
btn.disabled=false;btn.textContent='执行';
if(!r){el.textContent='认证失败';toast('error','认证失败');return}
const d=await r.json();
// Format execution results
let output='';
if(d.results){
for(const[sid,res]of Object.entries(d.results)){
output+=`\n── 服务器 #${sid} ──\n`;
if(typeof res==='object'&&res.results){res.results.forEach(r=>{output+=`$ ${r.command||''}\n${r.stdout||''}${r.stderr?'STDERR: '+r.stderr+'\n':''}退出码: ${r.exit_code}\n`})}
else{output+=JSON.stringify(res,null,2)}
const scriptId=parseInt(modal.dataset.scriptId)||null;
const scriptName=modal.dataset.scriptName||'脚本';
const longTask=document.getElementById('execLongTask').checked;
const cmd=modal.dataset.cmd||'';
const credEl=document.getElementById('execCredential');
const credentialId=credEl&&credEl.value?parseInt(credEl.value,10):null;
if(!cmd.trim()){toast('warning','脚本内容为空');return}
btn.disabled=true;btn.textContent='提交中...';
const batches=[];
for(let i=0;i<ids.length;i+=_execBatchSize){batches.push(ids.slice(i,i+_execBatchSize))}
document.getElementById('execStatusPanel').classList.remove('hidden');
const jobId='job-'+Date.now();
const merged={};
const job={
id:jobId,
scriptName,
longTask,
totalServers:ids.length,
batchTotal:batches.length,
batchCur:0,
executionIds:[],
submitting:true,
data:{status:'running',results:'{}'},
};
_trackedExecutions.unshift(job);
if(_trackedExecutions.length>20)_trackedExecutions.pop();
renderExecStatus();
hideExecModal();
let failBatches=0;
let anyRunning=false;
const batchStatuses=[];
for(let b=0;b<batches.length;b++){
const batchIds=batches[b];
job.batchCur=b+1;
job.submitting=true;
job.data.status='running';
renderExecStatus();
const body={script_id:scriptId,command:cmd,server_ids:batchIds,timeout:timeout,long_task:longTask};
if(credentialId)body.credential_id=credentialId;
const r=await apiFetch(API+'/scripts/exec',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
if(!r){
toast('error','认证失败');
job.submitting=false;
job.data.status='failed';
btn.disabled=false;btn.textContent='执行';
renderExecStatus();
return;
}
}else{output=JSON.stringify(d,null,2)}
el.textContent=output||'无输出';
toast(d.status==='success'?'success':'warning','脚本执行完成');
const d=await r.json();
job.submitting=false;
batchStatuses.push(d.status);
if(d.id)job.executionIds.push(d.id);
mergeExecResults(merged,d.results);
job.data={status:'running',results:JSON.stringify(merged)};
if(d.status==='running')anyRunning=true;
if(d.status==='failed')failBatches++;
renderExecStatus();
}
job.data.status=aggregateExecStatus(batchStatuses);
if(job.data.status==='running')anyRunning=true;
btn.disabled=false;btn.textContent='执行';
renderExecStatus();
if(anyRunning)startExecPolling();
if(anyRunning)toast('info',`执行中,见上方进度(${ids.length} 台)`);
else if(failBatches===0&&job.data.status==='completed')toast('success',`执行完 ${ids.length} 台`);
else toast('warning',`执行完,部分失败`);
}
function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML}
+13 -7
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 服务器</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css"/><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style>
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 服务器</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><link rel="stylesheet" href="/app/vendor/xterm.css"/><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style>
<style>
.detail-panel{max-height:0;overflow:hidden;transition:max-height .3s ease}
.detail-panel.open{max-height:800px}
@@ -158,7 +158,7 @@
<td class="px-4 py-3"><span class="inline-block w-2.5 h-2.5 rounded-full ${s.is_online?'bg-green-400':'bg-red-400'} ${s.is_online?'':'animate-pulse'}"></span></td>
<td class="px-4 py-3 text-white font-medium">${esc(s.name)}</td>
<td class="px-4 py-3 text-slate-400">${esc(s.domain)}:${s.port||22}</td>
<td class="px-4 py-3 text-slate-400">${s.category||'--'}</td>
<td class="px-4 py-3 text-slate-400">${esc(s.category||'--')}</td>
<td class="px-4 py-3 text-slate-400">${s.agent_version||'--'}</td>
<td class="px-4 py-3 text-slate-500 text-xs">${fmtTime(s.last_heartbeat)}</td>
<td class="px-4 py-3 text-right"><a href="/app/terminal.html?server_id=${s.id}" onclick="event.stopPropagation()" class="text-brand-light hover:underline text-xs mr-2">终端</a><button onclick="event.stopPropagation();showEditServer(${s.id})" class="text-slate-400 hover:underline text-xs mr-2">编辑</button><button onclick="event.stopPropagation();deleteServer(${s.id})" class="text-red-400 hover:underline text-xs">删除</button></td>
@@ -256,8 +256,9 @@
}
async function deleteServer(id){if(!confirm('确定删除服务器 #'+id+'?'))return;const r=await apiFetch(API+'/servers/'+id,{method:'DELETE'});if(r&&r.ok)toast('success','服务器已删除');else toast('error','删除失败');if(selectedServerId===id)hideDetail();loadServers()}
function showAddServer(){document.getElementById('editServerId').value='';document.getElementById('modalTitle').textContent='添加服务器';['srvName','srvDomain','srvPassword','srvCategory','srvTarget','srvDesc','srvKeyPath','srvAgentKey'].forEach(id=>document.getElementById(id).value='');document.getElementById('srvPort').value='22';document.getElementById('srvUser').value='root';document.getElementById('srvAuth').value='key';document.getElementById('srvAgentPort').value='8601';document.getElementById('srvPlatform').value='';document.getElementById('srvNode').value='';toggleAuthFields();loadOrgSelects();document.getElementById('serverModal').classList.remove('hidden')}
function showEditServer(id){const s=_serversCache[id];if(!s)return;document.getElementById('editServerId').value=s.id;document.getElementById('modalTitle').textContent='编辑服务器';document.getElementById('srvName').value=s.name||'';document.getElementById('srvDomain').value=s.domain||'';document.getElementById('srvPort').value=s.port||22;document.getElementById('srvUser').value=s.username||'root';document.getElementById('srvAuth').value=s.auth_method||'key';document.getElementById('srvPassword').value='';document.getElementById('srvKeyPath').value=s.ssh_key_path||'';document.getElementById('srvAgentPort').value=s.agent_port||8601;document.getElementById('srvAgentKey').value=s.agent_api_key||'';document.getElementById('srvCategory').value=s.category||'';document.getElementById('srvTarget').value=s.target_path||'';document.getElementById('srvDesc').value=s.description||'';toggleAuthFields();loadOrgSelects().then(()=>{document.getElementById('srvPlatform').value=s.platform_id||'';document.getElementById('srvNode').value=s.node_id||''});document.getElementById('serverModal').classList.remove('hidden')}
let _pendingAgentKey='';
function showAddServer(){_pendingAgentKey='';document.getElementById('editServerId').value='';document.getElementById('modalTitle').textContent='添加服务器';['srvName','srvDomain','srvPassword','srvCategory','srvTarget','srvDesc','srvKeyPath','srvAgentKey'].forEach(id=>document.getElementById(id).value='');document.getElementById('srvAgentKey').placeholder='留空则使用全局 API Key';document.getElementById('srvPort').value='22';document.getElementById('srvUser').value='root';document.getElementById('srvAuth').value='key';document.getElementById('srvAgentPort').value='8601';document.getElementById('srvPlatform').value='';document.getElementById('srvNode').value='';toggleAuthFields();loadOrgSelects();document.getElementById('serverModal').classList.remove('hidden')}
function showEditServer(id){const s=_serversCache[id];if(!s)return;document.getElementById('editServerId').value=s.id;document.getElementById('modalTitle').textContent='编辑服务器';document.getElementById('srvName').value=s.name||'';document.getElementById('srvDomain').value=s.domain||'';document.getElementById('srvPort').value=s.port||22;document.getElementById('srvUser').value=s.username||'root';document.getElementById('srvAuth').value=s.auth_method||'key';document.getElementById('srvPassword').value='';document.getElementById('srvKeyPath').value=s.ssh_key_path||'';document.getElementById('srvAgentPort').value=s.agent_port||8601;document.getElementById('srvAgentKey').value=_pendingAgentKey||'';document.getElementById('srvAgentKey').placeholder=s.agent_api_key_set&&!_pendingAgentKey?'已设置 — 留空不修改,点🔑重新生成':'留空则使用全局 API Key';document.getElementById('srvCategory').value=s.category||'';document.getElementById('srvTarget').value=s.target_path||'';document.getElementById('srvDesc').value=s.description||'';toggleAuthFields();loadOrgSelects().then(()=>{document.getElementById('srvPlatform').value=s.platform_id||'';document.getElementById('srvNode').value=s.node_id||''});document.getElementById('serverModal').classList.remove('hidden')}
function hideServerModal(){document.getElementById('serverModal').classList.add('hidden')}
function toggleAuthFields(){const isKey=document.getElementById('srvAuth').value==='key';document.getElementById('srvPasswordRow').style.display=isKey?'none':'';document.getElementById('srvKeyPathRow').style.display=isKey?'':'none'}
document.getElementById('srvAuth').addEventListener('change',toggleAuthFields);
@@ -273,9 +274,10 @@
const body={name:document.getElementById('srvName').value,domain:document.getElementById('srvDomain').value,port:parseInt(document.getElementById('srvPort').value)||22,username:document.getElementById('srvUser').value||'root',auth_method:document.getElementById('srvAuth').value,agent_port:parseInt(document.getElementById('srvAgentPort').value)||8601,category:document.getElementById('srvCategory').value||null,target_path:document.getElementById('srvTarget').value||null,description:document.getElementById('srvDesc').value||null,platform_id:document.getElementById('srvPlatform').value?parseInt(document.getElementById('srvPlatform').value):null,node_id:document.getElementById('srvNode').value?parseInt(document.getElementById('srvNode').value):null};
if(body.auth_method==='password'){body.password=document.getElementById('srvPassword').value}
else{body.ssh_key_path=document.getElementById('srvKeyPath').value||null}
const ak=document.getElementById('srvAgentKey').value;if(ak)body.agent_api_key=ak;
const ak=_pendingAgentKey||document.getElementById('srvAgentKey').value;if(ak)body.agent_api_key=ak;
if(!body.name||!body.domain){toast('warning','名称和地址必填');return}
if(id){const r=await apiFetch(API+'/servers/'+id,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify(body)});if(r&&r.ok)toast('success','服务器已更新');else toast('error','更新失败')}else{const r=await apiFetch(API+'/servers/',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});if(r&&r.ok)toast('success','服务器已添加');else toast('error','添加失败')}
_pendingAgentKey='';document.getElementById('srvAgentKey').value='';
hideServerModal();loadServers();
if(selectedServerId&&id==selectedServerId)selectServer(parseInt(id));
}
@@ -290,8 +292,12 @@
const r=await apiFetch(API+'/servers/'+id+'/agent-key',{method:'POST',headers:apiHeadersJSON()});
if(!r)return;
const data=await r.json();
document.getElementById('srvAgentKey').value=data.agent_api_key||'';
toast('success','Agent API Key已生成,请保存服务器以应用');
_pendingAgentKey=data.agent_api_key||'';
document.getElementById('srvAgentKey').value=_pendingAgentKey;
if(_pendingAgentKey&&navigator.clipboard?.writeText){
try{await navigator.clipboard.writeText(_pendingAgentKey)}catch(e){}
}
toast('success',data.message||'密钥已生成并复制到剪贴板,请立即保存服务器');
}catch(e){toast('error','生成密钥失败')}
}
function fmtTime(t){if(!t)return'--';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
+23 -5
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 系统设置</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 系统设置</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true,totpStep:'idle',totpSecret:'',totpUri:''}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
@@ -109,7 +109,7 @@
</div>
<script src="/app/api.js"></script>
<script src="/app/layout.js"></script>
<script src="https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js"></script>
<script src="/app/vendor/qrious.min.js"></script>
<script>
initLayout('settings');
const SECTIONS={
@@ -144,7 +144,7 @@
return `<div class="flex items-center gap-3">
<label class="text-slate-400 text-sm w-36 shrink-0">${cfg.labels[key]||key}</label>
<input id="set_${key}" value="${esc(val)}" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<button onclick="saveSetting('${key}')" class="px-3 py-2 bg-brand hover:bg-brand-dark text-white text-xs rounded-lg shrink-0 transition">保存</button>
<button type="button" data-setting-key="${escAttr(key)}" class="save-setting-btn px-3 py-2 bg-brand hover:bg-brand-dark text-white text-xs rounded-lg shrink-0 transition">保存</button>
</div>`;
}).join('');
}
@@ -186,8 +186,15 @@
const btn=document.getElementById('apiKeyToggleBtn');
const display=document.getElementById('apiKeyDisplay');
if(btn.textContent.trim()==='显示'){
const pwd=prompt('请输入当前管理员密码以查看 API Key:');
if(!pwd)return;
try{
const r=await apiFetch(API+'/settings/api-key/reveal',{method:'POST'});if(!r)return;
const r=await apiFetch(API+'/settings/api-key/reveal',{
method:'POST',
headers:apiHeadersJSON(),
body:JSON.stringify({current_password:pwd}),
});
if(!r)return;
const data=await r.json();
_apiKeyRaw=data.value||'';
display.textContent=_apiKeyRaw||'(空)';
@@ -332,10 +339,14 @@
async function disableTotp(){
if(!confirm('确定要禁用双因素认证?禁用后登录将不再需要验证码。'))return;
const pwd=prompt('请输入当前密码以确认:');
if(!pwd)return;
const code=prompt('请输入当前 TOTP 验证码:');
if(!code)return;
try{
const r=await apiFetch(API+'/auth/totp/disable',{
method:'POST',headers:apiHeadersJSON(),
body:JSON.stringify({admin_id:_currentAdminId})
body:JSON.stringify({admin_id:_currentAdminId,current_password:pwd,totp_code:code})
});
if(!r)return;
const data=await r.json();
@@ -382,6 +393,13 @@
}
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
function escAttr(s){if(!s)return'';return s.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
document.addEventListener('click',e=>{
const btn=e.target.closest('.save-setting-btn');
if(!btn)return;
const key=btn.getAttribute('data-setting-key');
if(key)saveSetting(key);
});
// ── Init ──
loadSettings();
+22 -8
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html><html lang="zh-CN" x-data="{ darkMode: true, sidebarOpen: false, connected: false, serverName: '...', cols: 80, rows: 24, fullscreen: false }" x-bind:class="darkMode ? 'dark' : ''" x-init="$watch('darkMode', v => { localStorage.setItem('darkMode', v); document.documentElement.classList.toggle('dark', v); })"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Nexus — SSH终端</title><script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css"/><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250);--color-surface:oklch(98.5% 0.002 250)}</style><style>.xterm{padding:4px}#terminalWrap{height:calc(100vh - 48px)}#terminalWrap.fs{height:100vh;position:fixed;inset:0;z-index:100}</style></head>
<!DOCTYPE html><html lang="zh-CN" x-data="{ darkMode: true, sidebarOpen: false, connected: false, serverName: '...', cols: 80, rows: 24, fullscreen: false }" x-bind:class="darkMode ? 'dark' : ''" x-init="$watch('darkMode', v => { localStorage.setItem('darkMode', v); document.documentElement.classList.toggle('dark', v); })"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Nexus — SSH终端</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><link rel="stylesheet" href="/app/vendor/xterm.css"/><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250);--color-surface:oklch(98.5% 0.002 250)}</style><style>.xterm{padding:4px}#terminalWrap{height:calc(100vh - 48px)}#terminalWrap.fs{height:100vh;position:fixed;inset:0;z-index:100}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex">
<!-- Sidebar (collapsed by default — terminal needs space) -->
@@ -30,9 +30,9 @@
<script src="/app/api.js"></script>
<script src="/app/layout.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js"></script>
<script src="/app/vendor/xterm.js"></script>
<script src="/app/vendor/xterm-addon-fit.js"></script>
<script src="/app/vendor/xterm-addon-web-links.js"></script>
<script>
initLayout('servers'); // terminal is under servers nav
// ── Parse server_id ──
@@ -75,12 +75,26 @@
// ── WebSocket ──
let ws = null;
function connect() {
const token = localStorage.getItem('access_token');
if (!token) { window.location.href = '/app/login.html'; return; }
async function connect() {
if (!localStorage.getItem('access_token')) {
window.location.href = '/app/login.html';
return;
}
let websshToken;
try {
const data = await apiFetch('/auth/webssh-token', {
method: 'POST',
body: JSON.stringify({ server_id: serverId }),
});
websshToken = data.webssh_token;
} catch (e) {
term.writeln('\r\n\x1b[31m无法获取终端令牌: ' + (e.message || e) + '\x1b[0m');
return;
}
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/ws/terminal/${serverId}?token=${encodeURIComponent(token)}`);
ws = new WebSocket(`${proto}//${location.host}/ws/terminal/${serverId}?token=${encodeURIComponent(websshToken)}`);
ws.onopen = () => {
$d().connected = true;
File diff suppressed because one or more lines are too long
+55
View File
@@ -0,0 +1,55 @@
{
"files": {
"tailwindcss-browser.js": {
"version": "@tailwindcss/browser@4.0.6",
"url": "https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.0.6/dist/index.global.js",
"bytes": 216784,
"integrity": "sha384-t1njZIO8B2XEBt2SREQoUD1bGb2Q9lPkO2Ay1nPuHkioJO7BiaN02ks+8ummust7"
},
"alpinejs.min.js": {
"version": "alpinejs@3.14.8",
"url": "https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js",
"bytes": 44758,
"integrity": "sha384-X9kJyAubVxnP0hcA+AMMs21U445qsnqhnUF8EBlEpP3a42Kh/JwWjlv2ZcvGfphb"
},
"xterm.css": {
"version": "xterm@5.3.0",
"url": "https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css",
"bytes": 5383,
"integrity": "sha384-LJcOxlx9IMbNXDqJ2axpfEQKkAYbFjJfhXexLfiRJhjDU81mzgkiQq8rkV0j6dVh"
},
"xterm.js": {
"version": "xterm@5.3.0",
"url": "https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js",
"bytes": 283404,
"integrity": "sha384-/nfmYPUzWMS6v2atn8hbljz7NE0EI1iGx34lJaNzyVjWGDzMv+ciUZUeJpKA3Glc"
},
"xterm-addon-fit.js": {
"version": "xterm-addon-fit@0.8.0",
"url": "https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js",
"bytes": 1503,
"integrity": "sha384-AQLWHRKAgdTxkolJcLOELg4E9rE89CPE2xMy3tIRFn08NcGKPTsELdvKomqji+DL"
},
"xterm-addon-web-links.js": {
"version": "xterm-addon-web-links@0.9.0",
"url": "https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js",
"bytes": 2921,
"integrity": "sha384-U4fBROT3kCM582gaYiNaOSQiJbXPzd9SfR1598Y7yeGSYVBzikXrNg0XyuU+mOnl"
},
"qrious.min.js": {
"version": "qrious@4.0.2",
"url": "https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js",
"bytes": 17579,
"integrity": "sha384-Dr98ddmUw2QkdCarNQ+OL7xLty7cSxgR0T7v1tq4UErS/qLV0132sBYTolRAFuOV"
}
},
"replacements": {
"https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4": "/app/vendor/tailwindcss-browser.js",
"https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js": "/app/vendor/alpinejs.min.js",
"https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css": "/app/vendor/xterm.css",
"https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js": "/app/vendor/xterm.js",
"https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js": "/app/vendor/xterm-addon-fit.js",
"https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js": "/app/vendor/xterm-addon-web-links.js",
"https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js": "/app/vendor/qrious.min.js"
}
}
+6
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=xterm-addon-fit.js.map
+2
View File
@@ -0,0 +1,2 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.WebLinksAddon=t():e.WebLinksAddon=t()}(self,(()=>(()=>{"use strict";var e={6:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.LinkComputer=t.WebLinkProvider=void 0,t.WebLinkProvider=class{constructor(e,t,n,i={}){this._terminal=e,this._regex=t,this._handler=n,this._options=i}provideLinks(e,t){const i=n.computeLink(e,this._regex,this._terminal,this._handler);t(this._addCallbacks(i))}_addCallbacks(e){return e.map((e=>(e.leave=this._options.leave,e.hover=(t,n)=>{if(this._options.hover){const{range:i}=e;this._options.hover(t,n,i)}},e)))}};class n{static computeLink(e,t,i,r){const o=new RegExp(t.source,(t.flags||"")+"g"),[s,a]=n._getWindowedLineStrings(e-1,i),c=s.join("");let d;const l=[];for(;d=o.exec(c);){const e=d[0];try{const t=new URL(e),n=decodeURI(t.toString());if(e!==n&&e+"/"!==n)continue}catch(e){continue}const[t,o]=n._mapStrIdx(i,a,0,d.index),[s,c]=n._mapStrIdx(i,t,o,e.length);if(-1===t||-1===o||-1===s||-1===c)continue;const p={start:{x:o+1,y:t+1},end:{x:c,y:s+1}};l.push({range:p,text:e,activate:r})}return l}static _getWindowedLineStrings(e,t){let n,i=e,r=e,o=0,s="";const a=[];if(n=t.buffer.active.getLine(e)){const e=n.translateToString(!0);if(n.isWrapped&&" "!==e[0]){for(o=0;(n=t.buffer.active.getLine(--i))&&o<2048&&(s=n.translateToString(!0),o+=s.length,a.push(s),n.isWrapped&&-1===s.indexOf(" ")););a.reverse()}for(a.push(e),o=0;(n=t.buffer.active.getLine(++r))&&n.isWrapped&&o<2048&&(s=n.translateToString(!0),o+=s.length,a.push(s),-1===s.indexOf(" ")););}return[a,i]}static _mapStrIdx(e,t,n,i){const r=e.buffer.active,o=r.getNullCell();let s=n;for(;i;){const e=r.getLine(t);if(!e)return[-1,-1];for(let n=s;n<e.length;++n){e.getCell(n,o);const s=o.getChars();if(o.getWidth()&&(i-=s.length||1,n===e.length-1&&""===s)){const e=r.getLine(t+1);e&&e.isWrapped&&(e.getCell(0,o),2===o.getWidth()&&(i+=1))}if(i<0)return[t,n]}t++,s=0}return[t,s]}}t.LinkComputer=n}},t={};function n(i){var r=t[i];if(void 0!==r)return r.exports;var o=t[i]={exports:{}};return e[i](o,o.exports,n),o.exports}var i={};return(()=>{var e=i;Object.defineProperty(e,"__esModule",{value:!0}),e.WebLinksAddon=void 0;const t=n(6),r=/https?:[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;function o(e,t){const n=window.open();if(n){try{n.opener=null}catch(e){}n.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}e.WebLinksAddon=class{constructor(e=o,t={}){this._handler=e,this._options=t}activate(e){this._terminal=e;const n=this._options,i=n.urlRegex||r;this._linkProvider=this._terminal.registerLinkProvider(new t.WebLinkProvider(this._terminal,i,this._handler,n))}dispose(){var e;null===(e=this._linkProvider)||void 0===e||e.dispose()}}})(),i})()));
//# sourceMappingURL=xterm-addon-web-links.js.map
+209
View File
@@ -0,0 +1,209 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility,
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}
+2
View File
File diff suppressed because one or more lines are too long