From 8f026aac9e8f90ed84de757ff520912d1eeebb0c Mon Sep 17 00:00:00 2001 From: swpa Date: Wed, 10 Jun 2026 15:13:21 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=99=95=E7=90=86=E7=B3=BB=E7=B5=B1?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E9=80=A3=E7=B7=9A=20(Proxy=20Connection)=20?= =?UTF-8?q?=E7=8B=80=E6=85=8B=E8=90=BD=E5=B7=AE=E5=95=8F=E9=A1=8C=20-=20?= =?UTF-8?q?=E5=B0=8E=E5=85=A5=E3=80=8C=E8=87=AA=E8=A8=82=E6=96=B7=E7=B7=9A?= =?UTF-8?q?=E7=A2=BC=20(Custom=20Close=20Code)=E3=80=8D=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- all_code.txt | 181 ++++++++++++++++++++++++++++++++++++-------- routers/terminal.py | 9 ++- static/terminal.js | 16 +++- 3 files changed, 172 insertions(+), 34 deletions(-) diff --git a/all_code.txt b/all_code.txt index b365aac..683cb89 100644 --- a/all_code.txt +++ b/all_code.txt @@ -3,28 +3,71 @@ PROJECT SOURCE CODE EXPORT (FULL) ================================================================================ +================================================================================ +FILE: .env.example +================================================================================ +# --- .env.example --- +# Database Configuration (請填入你的本地端或正式機設定) +DB_NAME=cmts_nms +DB_USER= +DB_PASS= +DB_HOST=127.0.0.1 +DB_PORT=5432 + +# God Mode Secret +GOD_MODE_SECRET= + +# Default CMTS Device +DEFAULT_CMTS_HOST= +DEFAULT_CMTS_USER= +DEFAULT_CMTS_PASS= + +================================================================================ +FILE: .env +================================================================================ +# --- .env --- +# Database Configuration +DB_NAME=cmts_nms +DB_USER=swpa +DB_PASS=swpa4920 +DB_HOST=127.0.0.1 +DB_PORT=5432 + +# God Mode Secret +GOD_MODE_SECRET=swpa@Serc0mm + +# Default CMTS Device (本地開發用,正式環境可留空) +DEFAULT_CMTS_HOST=10.14.110.4 +DEFAULT_CMTS_USER=admin +DEFAULT_CMTS_PASS=nsgadmin + ================================================================================ FILE: database.py ================================================================================ import asyncpg import json import uuid +import os +from dotenv import load_dotenv # 🌟 1. 引入 load_dotenv from typing import Dict, List, Optional, Any from logger import get_logger +# 🌟 2. 明確指示 Python 讀取同目錄下的 .env 檔案 +load_dotenv() + # ========================================== # 💡 PostgreSQL 連線與操作 (高可用性版) # ========================================== logger = get_logger("app.database") -# DB 設定 (從您的 init_db.py 中提取) +# 🌟 2. 同步改用 os.getenv 讀取環境變數 DB_CONFIG = { - "database": "cmts_nms", - "user": "swpa", - "password": "swpa4920", - "host": "127.0.0.1", - "port": "5432" + "database": os.getenv("DB_NAME", "cmts_nms"), + "user": os.getenv("DB_USER", "postgres"), # 本地開發常用的預設帳號,或留空 "" + "password": os.getenv("DB_PASS", ""), # 🌟 絕對機密:預設留空! + "host": os.getenv("DB_HOST", "127.0.0.1"), + "port": os.getenv("DB_PORT", "5432") } _pool: Optional[asyncpg.Pool] = None @@ -371,12 +414,12 @@ def debug_print(msg: str): if DEBUG_MODE: print(msg) -# 預設的 CMTS 連線樣板 +# 🌟 2. 淨化預設的 CMTS 連線樣板 CMTS_DEVICE = { 'device_type': 'cisco_ios', - 'host': '10.14.110.4', - 'username': 'admin', - 'password': 'nsgadmin', + 'host': os.getenv("DEFAULT_CMTS_HOST", ""), + 'username': os.getenv("DEFAULT_CMTS_USER", ""), + 'password': os.getenv("DEFAULT_CMTS_PASS", ""), 'port': 22, 'fast_cli': False, 'global_delay_factor': 2 @@ -646,9 +689,10 @@ FILE: index.html
- - - + + + +
@@ -1238,17 +1282,22 @@ import asyncio import asyncpg import logging import sys +import os +from dotenv import load_dotenv # 🌟 1. 引入 load_dotenv + +# 🌟 2. 明確指示 Python 讀取同目錄下的 .env 檔案 +load_dotenv() logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -# DB 設定 +# 🌟 2. 改用 os.getenv 讀取環境變數,並保留原本的設定作為安全預設值 DB_CONFIG = { - "database": "cmts_nms", - "user": "swpa", - "password": "swpa4920", - "host": "127.0.0.1", - "port": "5432" + "database": os.getenv("DB_NAME", "cmts_nms"), + "user": os.getenv("DB_USER", "postgres"), # 本地開發常用的預設帳號,或留空 "" + "password": os.getenv("DB_PASS", ""), # 🌟 絕對機密:預設留空! + "host": os.getenv("DB_HOST", "127.0.0.1"), + "port": os.getenv("DB_PORT", "5432") } async def init_database(force_reset: bool = False): @@ -1387,11 +1436,20 @@ def parse_question_mark_output(output: str) -> dict: for line in output.splitlines(): line = line.strip() - # 1. 過濾雜訊與 Echo (包含 prompt 和 ?) + # ========================================== + # 🛡️ 1. 終極雜訊與 Echo 過濾器 + # ========================================== if not line or line.startswith('%') or line.startswith('^'): continue - if '?' in line and ('#' in line or '>' in line): + + # 攔截 Echo 的問號指令 (例如 "logging buffered ?") + if line.endswith('?'): continue + + # 攔截終端機 Prompt (例如 "admin@SERCOMM-COS-02(config)# ..." 或 "admin@SERCOMM-COS-02>") + if re.search(r"[a-zA-Z0-9_.@-]+\(.*\)#", line) or re.search(r"^[a-zA-Z0-9_.@-]+>", line): + continue + # ========================================== # 2. 無差別收集所有有效行作為 Hint (保留最完整的說明給使用者看) hint_lines.append(line) @@ -4375,6 +4433,20 @@ function colorizeTerminalStream(text) { // 4. 匯出 (export) 所有需要被外部呼叫的函數 export function initTerminal() { + const container = document.getElementById('terminal-container'); + + // ========================================== + // 🛡️ DOM 防火牆:攔截殘缺的假鍵盤事件 + // ========================================== + container.addEventListener('keydown', (e) => { + // 如果這個事件物件沒有 getModifierState 函數 (代表它是外掛產生的假事件) + if (typeof e.getModifierState !== 'function') { + // 🛑 停止事件傳遞!不讓它流進 Xterm.js 裡 + e.stopPropagation(); + } + }, true); // 🌟 關鍵:傳入 true 啟用「捕獲階段 (Capture Phase)」,確保我們比 Xterm.js 更早拿到事件 + // ========================================== + term = new Terminal({ cursorBlink: true, theme: { background: '#1e1e1e', foreground: '#e0e0e0' }, fontFamily: 'Courier New, monospace', fontSize: 15, scrollback: 100000 }); fitAddon = new FitAddon.FitAddon(); term.loadAddon(fitAddon); @@ -4409,8 +4481,9 @@ export function connectWebSocket(resetCallback) { btnLoadTask.style.backgroundColor = '#c0392b'; btnLoadTask.style.cursor = 'pointer'; } - document.getElementById('wsStatus').textContent = `狀態:已連線`; - document.getElementById('wsStatus').style.color = '#27ae60'; + // 🌟 UX 升級:剛連上 WS 時,顯示「驗證中...」而不是直接顯示「已連線」 + document.getElementById('wsStatus').textContent = `狀態:連線與驗證中...`; + document.getElementById('wsStatus').style.color = '#f39c12'; // 橘色 document.getElementById('btnConnect').style.display = 'none'; document.getElementById('btnDisconnect').style.display = 'inline-block'; document.getElementById('cmtsHost').disabled = true; @@ -4432,6 +4505,12 @@ export function connectWebSocket(resetCallback) { }; ws.onmessage = (event) => { + // 🌟 UX 升級:只要收到設備傳來的第一個字元,就代表 SSH 驗證成功,正式轉為綠色「已連線」 + const statusEl = document.getElementById('wsStatus'); + if (statusEl.textContent.includes('驗證中')) { + statusEl.textContent = `狀態:已連線`; + statusEl.style.color = '#27ae60'; // 綠色 + } term.write(colorizeTerminalStream(event.data)); }; @@ -4452,6 +4531,11 @@ export function connectWebSocket(resetCallback) { document.getElementById('cmtsPass').disabled = false; term.writeln('\r\n\x1b[31m--- Connection Closed ---\x1b[0m\r\n'); + // 🌟 關鍵修復:攔截後端傳來的 4001 錯誤碼,彈出明確的警告視窗 + if (event.code === 4001) { + alert(`❌ 連線失敗!\n請檢查目標設備 IP、帳號與密碼是否正確。\n\n系統訊息: ${event.reason}`); + } + // 🌟 新增:斷線時,強制觸發配置任務切換,藉此「關閉」SSE 監聽 const configTaskSelect = document.getElementById('configTask'); if (configTaskSelect) { @@ -4986,6 +5070,14 @@ export async function startEditFolder(path, elementId) { const detailsEl = document.getElementById(`details-${elementId}`); if (detailsEl) detailsEl.open = true; + // ========================================== + // 🌟 完美修復:在抓取葉子節點前,強制將該資料夾底下的所有 HTML 預先渲染出來 + // 這樣 querySelectorAll 就能一次抓到所有深層的欄位! + // ========================================== + if (typeof window.forceRenderFolderHTML === 'function') { + window.forceRenderFolderHTML(elementId); + } + const contentDiv = document.getElementById(`content-${elementId}`); if (!contentDiv) return; @@ -6406,8 +6498,8 @@ export function collapseAll(elementId) { // ========================================== // 🌟 核心函數:生成完整設備配置樹狀圖 (Lazy 版) // ========================================== -// 🌟 新增 forceExpand 參數,預設為 false -export function buildTree(node, path = '', isParentCommandGroup = false, mode = 'running', forceExpand = false) { +// 🌟 修改函數宣告,加入 renderAll 參數 +export function buildTree(node, path = '', isParentCommandGroup = false, mode = 'running', forceExpand = false, renderAll = false) { const htmlParts = []; if (!node || typeof node !== 'object') return htmlParts.join(''); @@ -6477,13 +6569,14 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode = // 🚀 核心魔法:如果 forceExpand 為 true,直接在記憶體中遞迴生成子節點 HTML const isOpenAttr = forceExpand ? 'open' : ''; - const isLoadedAttr = forceExpand ? 'true' : 'false'; + const isLoadedAttr = (forceExpand || renderAll) ? 'true' : 'false'; const displayClosed = forceExpand ? 'none' : 'inline-block'; const displayOpen = forceExpand ? 'inline-block' : 'none'; let innerContent = ''; - if (forceExpand) { - innerContent = buildTree(value, currentPath, isCommandGroup, mode, true); + if (forceExpand || renderAll) { + // 🌟 遞迴呼叫時,把 renderAll 傳遞下去 + innerContent = buildTree(value, currentPath, isCommandGroup, mode, forceExpand, renderAll); } htmlParts.push(` @@ -6755,6 +6848,25 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa return htmlParts.join(''); } +// ========================================== +// 🌟 輔助函數:強制預先渲染資料夾 HTML (供編輯模式使用) +// ========================================== +window.forceRenderFolderHTML = function(elementId) { + const detailsEl = document.getElementById(`details-${elementId}`); + const contentDiv = document.getElementById(`content-${elementId}`); + const cache = folderDataCache[elementId]; + + if (detailsEl && contentDiv && cache && detailsEl.dataset.loaded !== 'true') { + const currentPath = detailsEl.dataset.path; + const isCommandGroup = detailsEl.dataset.isGroup === 'true'; + const mode = detailsEl.dataset.mode; + + // 重新渲染,傳入 renderAll = true (生成 HTML 但不展開) + contentDiv.innerHTML = buildTree(cache, currentPath, isCommandGroup, mode, false, true); + detailsEl.dataset.loaded = 'true'; + } +}; + ================================================================================ FILE: routers/backup.py ================================================================================ @@ -7190,12 +7302,19 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas if ssh_task and not ssh_task.done(): ssh_task.cancel() except Exception as e: - error_msg = f"\r\n\x1b[31mSSH Connection Error: {str(e)}\x1b[0m\r\n" + err_str = str(e) + error_msg = f"\r\n\x1b[31mSSH Connection Error: {err_str}\x1b[0m\r\n" try: + # 先把錯誤印在終端機畫面上 await websocket.send_text(error_msg) + + # 🌟 關鍵修復:使用自訂斷線碼 4001,並附上錯誤原因 (WebSocket 規範 reason 最長 123 bytes) + safe_reason = err_str[:120] if err_str else "Authentication or Connection Failed" + await websocket.close(code=4001, reason=safe_reason) except: pass logger.error("❌ [Connection Error] 建立連線或執行過程中發生錯誤", exc_info=True) + return # 🌟 提早結束,避免下方的 finally 再次執行 close 導致報錯 finally: # 🌟 確保 process 與 conn 絕對被關閉,防止 Memory Leak if process: @@ -7792,8 +7911,8 @@ class GodModeRequest(BaseModel): @router.post("/auth/god-mode", summary="驗證上帝模式進階密碼") async def verify_god_mode(req: GodModeRequest): - # 從環境變數讀取正確密碼,預設為 "admin999" - expected_password = os.getenv("GOD_MODE_SECRET", "swpa@Serc0mm") + # 從環境變數讀取正確密碼,預設為 "19760107@Serc0mm" + expected_password = os.getenv("GOD_MODE_SECRET", "19760107@Serc0mm") # 使用 compare_digest 防禦計時攻擊 if secrets.compare_digest(req.password, expected_password): diff --git a/routers/terminal.py b/routers/terminal.py index 9122d5c..3a09aba 100644 --- a/routers/terminal.py +++ b/routers/terminal.py @@ -80,12 +80,19 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas if ssh_task and not ssh_task.done(): ssh_task.cancel() except Exception as e: - error_msg = f"\r\n\x1b[31mSSH Connection Error: {str(e)}\x1b[0m\r\n" + err_str = str(e) + error_msg = f"\r\n\x1b[31mSSH Connection Error: {err_str}\x1b[0m\r\n" try: + # 先把錯誤印在終端機畫面上 await websocket.send_text(error_msg) + + # 🌟 關鍵修復:使用自訂斷線碼 4001,並附上錯誤原因 (WebSocket 規範 reason 最長 123 bytes) + safe_reason = err_str[:120] if err_str else "Authentication or Connection Failed" + await websocket.close(code=4001, reason=safe_reason) except: pass logger.error("❌ [Connection Error] 建立連線或執行過程中發生錯誤", exc_info=True) + return # 🌟 提早結束,避免下方的 finally 再次執行 close 導致報錯 finally: # 🌟 確保 process 與 conn 絕對被關閉,防止 Memory Leak if process: diff --git a/static/terminal.js b/static/terminal.js index 16b0df2..505ada5 100644 --- a/static/terminal.js +++ b/static/terminal.js @@ -79,8 +79,9 @@ export function connectWebSocket(resetCallback) { btnLoadTask.style.backgroundColor = '#c0392b'; btnLoadTask.style.cursor = 'pointer'; } - document.getElementById('wsStatus').textContent = `狀態:已連線`; - document.getElementById('wsStatus').style.color = '#27ae60'; + // 🌟 UX 升級:剛連上 WS 時,顯示「驗證中...」而不是直接顯示「已連線」 + document.getElementById('wsStatus').textContent = `狀態:連線與驗證中...`; + document.getElementById('wsStatus').style.color = '#f39c12'; // 橘色 document.getElementById('btnConnect').style.display = 'none'; document.getElementById('btnDisconnect').style.display = 'inline-block'; document.getElementById('cmtsHost').disabled = true; @@ -102,6 +103,12 @@ export function connectWebSocket(resetCallback) { }; ws.onmessage = (event) => { + // 🌟 UX 升級:只要收到設備傳來的第一個字元,就代表 SSH 驗證成功,正式轉為綠色「已連線」 + const statusEl = document.getElementById('wsStatus'); + if (statusEl.textContent.includes('驗證中')) { + statusEl.textContent = `狀態:已連線`; + statusEl.style.color = '#27ae60'; // 綠色 + } term.write(colorizeTerminalStream(event.data)); }; @@ -122,6 +129,11 @@ export function connectWebSocket(resetCallback) { document.getElementById('cmtsPass').disabled = false; term.writeln('\r\n\x1b[31m--- Connection Closed ---\x1b[0m\r\n'); + // 🌟 關鍵修復:攔截後端傳來的 4001 錯誤碼,彈出明確的警告視窗 + if (event.code === 4001) { + alert(`❌ 連線失敗!\n請檢查目標設備 IP、帳號與密碼是否正確。\n\n系統訊息: ${event.reason}`); + } + // 🌟 新增:斷線時,強制觸發配置任務切換,藉此「關閉」SSE 監聽 const configTaskSelect = document.getElementById('configTask'); if (configTaskSelect) {