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:
+82
-74
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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="操作人用户名")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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,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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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,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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>')}
|
||||
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
@@ -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;
|
||||
|
||||
Vendored
+5
File diff suppressed because one or more lines are too long
Vendored
+55
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+6
File diff suppressed because one or more lines are too long
+876
File diff suppressed because one or more lines are too long
Vendored
+2
@@ -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
@@ -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
|
||||
Vendored
+209
@@ -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;
|
||||
}
|
||||
Vendored
+2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user