fix: 處理系統代理連線 (Proxy Connection) 狀態落差問題 - 導入「自訂斷線碼 (Custom Close Code)」。

This commit is contained in:
swpa 2026-06-10 15:13:21 +08:00
parent 7a49ef9833
commit 8f026aac9e
3 changed files with 172 additions and 34 deletions

View File

@ -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 FILE: database.py
================================================================================ ================================================================================
import asyncpg import asyncpg
import json import json
import uuid import uuid
import os
from dotenv import load_dotenv # 🌟 1. 引入 load_dotenv
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
from logger import get_logger from logger import get_logger
# 🌟 2. 明確指示 Python 讀取同目錄下的 .env 檔案
load_dotenv()
# ========================================== # ==========================================
# 💡 PostgreSQL 連線與操作 (高可用性版) # 💡 PostgreSQL 連線與操作 (高可用性版)
# ========================================== # ==========================================
logger = get_logger("app.database") logger = get_logger("app.database")
# DB 設定 (從您的 init_db.py 中提取) # 🌟 2. 同步改用 os.getenv 讀取環境變數
DB_CONFIG = { DB_CONFIG = {
"database": "cmts_nms", "database": os.getenv("DB_NAME", "cmts_nms"),
"user": "swpa", "user": os.getenv("DB_USER", "postgres"), # 本地開發常用的預設帳號,或留空 ""
"password": "swpa4920", "password": os.getenv("DB_PASS", ""), # 🌟 絕對機密:預設留空!
"host": "127.0.0.1", "host": os.getenv("DB_HOST", "127.0.0.1"),
"port": "5432" "port": os.getenv("DB_PORT", "5432")
} }
_pool: Optional[asyncpg.Pool] = None _pool: Optional[asyncpg.Pool] = None
@ -371,12 +414,12 @@ def debug_print(msg: str):
if DEBUG_MODE: if DEBUG_MODE:
print(msg) print(msg)
# 預設的 CMTS 連線樣板 # 🌟 2. 淨化預設的 CMTS 連線樣板
CMTS_DEVICE = { CMTS_DEVICE = {
'device_type': 'cisco_ios', 'device_type': 'cisco_ios',
'host': '10.14.110.4', 'host': os.getenv("DEFAULT_CMTS_HOST", ""),
'username': 'admin', 'username': os.getenv("DEFAULT_CMTS_USER", ""),
'password': 'nsgadmin', 'password': os.getenv("DEFAULT_CMTS_PASS", ""),
'port': 22, 'port': 22,
'fast_cli': False, 'fast_cli': False,
'global_delay_factor': 2 'global_delay_factor': 2
@ -646,9 +689,10 @@ FILE: index.html
<div class="global-settings"> <div class="global-settings">
<label><strong>🌐 目標設備:</strong></label> <label><strong>🌐 目標設備:</strong></label>
<input type="text" id="cmtsHost" placeholder="IP 地址" value="10.14.110.4" style="width: 130px;"> <!-- 🌟 修改後 (徹底淨化) -->
<input type="text" id="cmtsUser" placeholder="帳號" value="admin" style="width: 100px;"> <input type="text" id="cmtsHost" placeholder="IP 地址" style="width: 130px;">
<input type="password" id="cmtsPass" placeholder="密碼" value="nsgadmin" style="width: 100px;"> <input type="text" id="cmtsUser" placeholder="帳號" style="width: 100px;">
<input type="password" id="cmtsPass" placeholder="密碼" style="width: 100px;">
<div style="margin-left: 15px; display: flex; align-items: center; gap: 10px;"> <div style="margin-left: 15px; display: flex; align-items: center; gap: 10px;">
<button onclick="connectWebSocket()" id="btnConnect" class="btn-modern btn-connect">連線至 CMTS</button> <button onclick="connectWebSocket()" id="btnConnect" class="btn-modern btn-connect">連線至 CMTS</button>
@ -1238,17 +1282,22 @@ import asyncio
import asyncpg import asyncpg
import logging import logging
import sys import sys
import os
from dotenv import load_dotenv # 🌟 1. 引入 load_dotenv
# 🌟 2. 明確指示 Python 讀取同目錄下的 .env 檔案
load_dotenv()
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# DB 設定 # 🌟 2. 改用 os.getenv 讀取環境變數,並保留原本的設定作為安全預設值
DB_CONFIG = { DB_CONFIG = {
"database": "cmts_nms", "database": os.getenv("DB_NAME", "cmts_nms"),
"user": "swpa", "user": os.getenv("DB_USER", "postgres"), # 本地開發常用的預設帳號,或留空 ""
"password": "swpa4920", "password": os.getenv("DB_PASS", ""), # 🌟 絕對機密:預設留空!
"host": "127.0.0.1", "host": os.getenv("DB_HOST", "127.0.0.1"),
"port": "5432" "port": os.getenv("DB_PORT", "5432")
} }
async def init_database(force_reset: bool = False): 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(): for line in output.splitlines():
line = line.strip() line = line.strip()
# 1. 過濾雜訊與 Echo (包含 prompt 和 ?) # ==========================================
# 🛡️ 1. 終極雜訊與 Echo 過濾器
# ==========================================
if not line or line.startswith('%') or line.startswith('^'): if not line or line.startswith('%') or line.startswith('^'):
continue continue
if '?' in line and ('#' in line or '>' in line):
# 攔截 Echo 的問號指令 (例如 "logging buffered ?")
if line.endswith('?'):
continue 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 (保留最完整的說明給使用者看) # 2. 無差別收集所有有效行作為 Hint (保留最完整的說明給使用者看)
hint_lines.append(line) hint_lines.append(line)
@ -4375,6 +4433,20 @@ function colorizeTerminalStream(text) {
// 4. 匯出 (export) 所有需要被外部呼叫的函數 // 4. 匯出 (export) 所有需要被外部呼叫的函數
export function initTerminal() { 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 }); term = new Terminal({ cursorBlink: true, theme: { background: '#1e1e1e', foreground: '#e0e0e0' }, fontFamily: 'Courier New, monospace', fontSize: 15, scrollback: 100000 });
fitAddon = new FitAddon.FitAddon(); fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon); term.loadAddon(fitAddon);
@ -4409,8 +4481,9 @@ export function connectWebSocket(resetCallback) {
btnLoadTask.style.backgroundColor = '#c0392b'; btnLoadTask.style.backgroundColor = '#c0392b';
btnLoadTask.style.cursor = 'pointer'; btnLoadTask.style.cursor = 'pointer';
} }
document.getElementById('wsStatus').textContent = `狀態:已連線`; // 🌟 UX 升級:剛連上 WS 時,顯示「驗證中...」而不是直接顯示「已連線」
document.getElementById('wsStatus').style.color = '#27ae60'; document.getElementById('wsStatus').textContent = `狀態:連線與驗證中...`;
document.getElementById('wsStatus').style.color = '#f39c12'; // 橘色
document.getElementById('btnConnect').style.display = 'none'; document.getElementById('btnConnect').style.display = 'none';
document.getElementById('btnDisconnect').style.display = 'inline-block'; document.getElementById('btnDisconnect').style.display = 'inline-block';
document.getElementById('cmtsHost').disabled = true; document.getElementById('cmtsHost').disabled = true;
@ -4432,6 +4505,12 @@ export function connectWebSocket(resetCallback) {
}; };
ws.onmessage = (event) => { 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)); term.write(colorizeTerminalStream(event.data));
}; };
@ -4452,6 +4531,11 @@ export function connectWebSocket(resetCallback) {
document.getElementById('cmtsPass').disabled = false; document.getElementById('cmtsPass').disabled = false;
term.writeln('\r\n\x1b[31m--- Connection Closed ---\x1b[0m\r\n'); 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 監聽 // 🌟 新增斷線時強制觸發配置任務切換藉此「關閉」SSE 監聽
const configTaskSelect = document.getElementById('configTask'); const configTaskSelect = document.getElementById('configTask');
if (configTaskSelect) { if (configTaskSelect) {
@ -4986,6 +5070,14 @@ export async function startEditFolder(path, elementId) {
const detailsEl = document.getElementById(`details-${elementId}`); const detailsEl = document.getElementById(`details-${elementId}`);
if (detailsEl) detailsEl.open = true; if (detailsEl) detailsEl.open = true;
// ==========================================
// 🌟 完美修復:在抓取葉子節點前,強制將該資料夾底下的所有 HTML 預先渲染出來
// 這樣 querySelectorAll 就能一次抓到所有深層的欄位!
// ==========================================
if (typeof window.forceRenderFolderHTML === 'function') {
window.forceRenderFolderHTML(elementId);
}
const contentDiv = document.getElementById(`content-${elementId}`); const contentDiv = document.getElementById(`content-${elementId}`);
if (!contentDiv) return; if (!contentDiv) return;
@ -6406,8 +6498,8 @@ export function collapseAll(elementId) {
// ========================================== // ==========================================
// 🌟 核心函數:生成完整設備配置樹狀圖 (Lazy 版) // 🌟 核心函數:生成完整設備配置樹狀圖 (Lazy 版)
// ========================================== // ==========================================
// 🌟 新增 forceExpand 參數,預設為 false // 🌟 修改函數宣告,加入 renderAll 參數
export function buildTree(node, path = '', isParentCommandGroup = false, mode = 'running', forceExpand = false) { export function buildTree(node, path = '', isParentCommandGroup = false, mode = 'running', forceExpand = false, renderAll = false) {
const htmlParts = []; const htmlParts = [];
if (!node || typeof node !== 'object') return htmlParts.join(''); if (!node || typeof node !== 'object') return htmlParts.join('');
@ -6477,13 +6569,14 @@ export function buildTree(node, path = '', isParentCommandGroup = false, mode =
// 🚀 核心魔法:如果 forceExpand 為 true直接在記憶體中遞迴生成子節點 HTML // 🚀 核心魔法:如果 forceExpand 為 true直接在記憶體中遞迴生成子節點 HTML
const isOpenAttr = forceExpand ? 'open' : ''; const isOpenAttr = forceExpand ? 'open' : '';
const isLoadedAttr = forceExpand ? 'true' : 'false'; const isLoadedAttr = (forceExpand || renderAll) ? 'true' : 'false';
const displayClosed = forceExpand ? 'none' : 'inline-block'; const displayClosed = forceExpand ? 'none' : 'inline-block';
const displayOpen = forceExpand ? 'inline-block' : 'none'; const displayOpen = forceExpand ? 'inline-block' : 'none';
let innerContent = '<!-- 🌟 延遲渲染 -->'; let innerContent = '<!-- 🌟 延遲渲染 -->';
if (forceExpand) { if (forceExpand || renderAll) {
innerContent = buildTree(value, currentPath, isCommandGroup, mode, true); // 🌟 遞迴呼叫時,把 renderAll 傳遞下去
innerContent = buildTree(value, currentPath, isCommandGroup, mode, forceExpand, renderAll);
} }
htmlParts.push(` htmlParts.push(`
@ -6755,6 +6848,25 @@ export function buildRealFilterTree(data, parentPath = '', hiddenKeys = [], isPa
return htmlParts.join(''); 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 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(): if ssh_task and not ssh_task.done():
ssh_task.cancel() ssh_task.cancel()
except Exception as e: 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: try:
# 先把錯誤印在終端機畫面上
await websocket.send_text(error_msg) 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: except:
pass pass
logger.error("❌ [Connection Error] 建立連線或執行過程中發生錯誤", exc_info=True) logger.error("❌ [Connection Error] 建立連線或執行過程中發生錯誤", exc_info=True)
return # 🌟 提早結束,避免下方的 finally 再次執行 close 導致報錯
finally: finally:
# 🌟 確保 process 與 conn 絕對被關閉,防止 Memory Leak # 🌟 確保 process 與 conn 絕對被關閉,防止 Memory Leak
if process: if process:
@ -7792,8 +7911,8 @@ class GodModeRequest(BaseModel):
@router.post("/auth/god-mode", summary="驗證上帝模式進階密碼") @router.post("/auth/god-mode", summary="驗證上帝模式進階密碼")
async def verify_god_mode(req: GodModeRequest): async def verify_god_mode(req: GodModeRequest):
# 從環境變數讀取正確密碼,預設為 "admin999" # 從環境變數讀取正確密碼,預設為 "19760107@Serc0mm"
expected_password = os.getenv("GOD_MODE_SECRET", "swpa@Serc0mm") expected_password = os.getenv("GOD_MODE_SECRET", "19760107@Serc0mm")
# 使用 compare_digest 防禦計時攻擊 # 使用 compare_digest 防禦計時攻擊
if secrets.compare_digest(req.password, expected_password): if secrets.compare_digest(req.password, expected_password):

View File

@ -80,12 +80,19 @@ async def websocket_terminal(websocket: WebSocket, host: str, username: str, pas
if ssh_task and not ssh_task.done(): if ssh_task and not ssh_task.done():
ssh_task.cancel() ssh_task.cancel()
except Exception as e: 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: try:
# 先把錯誤印在終端機畫面上
await websocket.send_text(error_msg) 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: except:
pass pass
logger.error("❌ [Connection Error] 建立連線或執行過程中發生錯誤", exc_info=True) logger.error("❌ [Connection Error] 建立連線或執行過程中發生錯誤", exc_info=True)
return # 🌟 提早結束,避免下方的 finally 再次執行 close 導致報錯
finally: finally:
# 🌟 確保 process 與 conn 絕對被關閉,防止 Memory Leak # 🌟 確保 process 與 conn 絕對被關閉,防止 Memory Leak
if process: if process:

View File

@ -79,8 +79,9 @@ export function connectWebSocket(resetCallback) {
btnLoadTask.style.backgroundColor = '#c0392b'; btnLoadTask.style.backgroundColor = '#c0392b';
btnLoadTask.style.cursor = 'pointer'; btnLoadTask.style.cursor = 'pointer';
} }
document.getElementById('wsStatus').textContent = `狀態:已連線`; // 🌟 UX 升級:剛連上 WS 時,顯示「驗證中...」而不是直接顯示「已連線」
document.getElementById('wsStatus').style.color = '#27ae60'; document.getElementById('wsStatus').textContent = `狀態:連線與驗證中...`;
document.getElementById('wsStatus').style.color = '#f39c12'; // 橘色
document.getElementById('btnConnect').style.display = 'none'; document.getElementById('btnConnect').style.display = 'none';
document.getElementById('btnDisconnect').style.display = 'inline-block'; document.getElementById('btnDisconnect').style.display = 'inline-block';
document.getElementById('cmtsHost').disabled = true; document.getElementById('cmtsHost').disabled = true;
@ -102,6 +103,12 @@ export function connectWebSocket(resetCallback) {
}; };
ws.onmessage = (event) => { 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)); term.write(colorizeTerminalStream(event.data));
}; };
@ -122,6 +129,11 @@ export function connectWebSocket(resetCallback) {
document.getElementById('cmtsPass').disabled = false; document.getElementById('cmtsPass').disabled = false;
term.writeln('\r\n\x1b[31m--- Connection Closed ---\x1b[0m\r\n'); 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 監聽 // 🌟 新增斷線時強制觸發配置任務切換藉此「關閉」SSE 監聽
const configTaskSelect = document.getElementById('configTask'); const configTaskSelect = document.getElementById('configTask');
if (configTaskSelect) { if (configTaskSelect) {