refactor: God Mode 安全防護

This commit is contained in:
swpa 2026-06-08 17:01:55 +08:00
parent bc933e1e39
commit 061ee3435a
10 changed files with 487 additions and 86 deletions

View File

@ -150,10 +150,13 @@ async def upsert_device_status(host: str, config_type: str, metadata: dict) -> b
if not pool:
return False
# Extract known fields
cmts_version = metadata.get("cmts_version", "unknown")
cmts_version = metadata.get("cmts_version")
last_scanned = metadata.get("last_scanned", None)
try:
async with pool.acquire() as conn:
# 🌟 關鍵修正:如果傳入的是 unknown 或空值,只更新時間,絕對不動版本號!
if cmts_version and cmts_version != "unknown":
query = """
INSERT INTO device_status (host, config_type, cmts_version, last_scanned)
VALUES ($1, $2, $3, $4)
@ -163,15 +166,20 @@ async def upsert_device_status(host: str, config_type: str, metadata: dict) -> b
last_scanned = EXCLUDED.last_scanned,
updated_at = CURRENT_TIMESTAMP;
"""
try:
async with pool.acquire() as conn:
await conn.execute(query, host, config_type, cmts_version, last_scanned)
else:
query = """
INSERT INTO device_status (host, config_type, last_scanned)
VALUES ($1, $2, $3)
ON CONFLICT (host, config_type)
DO UPDATE SET
last_scanned = EXCLUDED.last_scanned,
updated_at = CURRENT_TIMESTAMP;
"""
await conn.execute(query, host, config_type, last_scanned)
return True
except asyncpg.PostgresError as e:
logger.error(f"❌ DB Error (upsert_device_status): {e}")
return False
except Exception as e:
logger.error(f"❌ Unknown Error (upsert_device_status): {e}")
logger.error(f"❌ DB Error (upsert_device_status): {e}")
return False
async def get_device_status(host: str, config_type: str) -> Optional[Dict[str, Any]]:
@ -1247,6 +1255,31 @@ FILE: index.html
</div>
</div>
<!-- 🌟 新增God Mode 授權視窗 (Modal) -->
<div id="godModeModal" class="modal-overlay">
<!-- 將對話框置中,限制最大寬度,改變一下配色風格 -->
<div class="modal-container" style="max-width: 350px; height: auto; transform: translateY(20px);">
<div class="modal-header" style="background-color: #8e44ad; border-bottom: none;">
<div class="modal-title" style="justify-content: center; width: 100%;">
<span>🔐 系統進階授權</span>
</div>
</div>
<div class="modal-body" style="padding: 25px 20px !important; background: #fdfefe; text-align: center;">
<p style="color: #2c3e50; font-size: 14px; margin-top: 0; margin-bottom: 20px; font-weight: bold;">
請輸入維護者密碼以解鎖隱藏功能
</p>
<input type="password" id="godModePassword" placeholder="Enter Password..." style="width: 100%; box-sizing: border-box; padding: 12px; border: 2px solid #bdc3c7; border-radius: 6px; font-size: 16px; text-align: center; margin-bottom: 10px; transition: border-color 0.2s; outline: none;" onfocus="this.style.borderColor='#8e44ad'" onblur="this.style.borderColor='#bdc3c7'">
<span id="god-mode-error" style="color: #e74c3c; font-size: 13px; font-weight: bold; display: block; min-height: 18px; margin-bottom: 15px;"></span>
<div style="display: flex; gap: 15px; justify-content: center;">
<button onclick="closeGodModeModal()" class="btn-modern btn-disconnect" style="flex: 1; padding: 10px;">取消</button>
<button id="btn-unlock-godmode" onclick="verifyGodMode()" class="btn-modern btn-save" style="flex: 1; background-color: #8e44ad; padding: 10px;">🚀 解鎖</button>
</div>
</div>
</div>
</div>
<!-- 引入獨立的 JavaScript 邏輯 -->
<script type="module" src="/static/app.js"></script>
@ -1542,9 +1575,10 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
# 🌟 新增:在第一批次連線時,先抓取設備版本
if cmts_version == "unknown":
process.stdin.write("show version\n")
# 🌟 補上 | nomore並稍微延長等待時間確保不會被分頁卡住
process.stdin.write("show version | nomore\n")
await process.stdin.drain()
version_output = await read_until_quiet(timeout=1.5, prompt_pattern=r"(?:#|>)")
version_output = await read_until_quiet(timeout=2.0, prompt_pattern=r"(?:#|>)")
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output)
if match:
cmts_version = match.group(1)
@ -1806,7 +1840,7 @@ from contextlib import asynccontextmanager
import database
# 引入我們剛剛拆分出來的路由模組
from routers import query, config, terminal, lock, leaf_options, backup, diagnostics
from routers import query, config, terminal, lock, leaf_options, backup, diagnostics, auth
@asynccontextmanager
async def lifespan(app: FastAPI):
@ -1828,6 +1862,7 @@ app.include_router(leaf_options.router, prefix="/api/v1")
app.include_router(lock.router, prefix="/api/v1")
app.include_router(backup.router, prefix="/api/v1")
app.include_router(diagnostics.router, prefix="/api/v1")
app.include_router(auth.router, prefix="/api/v1")
# WebSocket 通常獨立於 API 版本之外,所以不加前綴
app.include_router(terminal.router)
@ -2223,6 +2258,23 @@ export async function apiSetLogLevel(module, level) {
return response.json();
}
// ==========================================
// 7. 授權與驗證 API (Auth)
// ==========================================
export async function apiVerifyGodMode(password) {
const response = await fetch('/api/v1/auth/god-mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || "驗證失敗");
}
return response.json();
}
================================================================================
FILE: static/app.js
@ -2244,7 +2296,8 @@ import {
apiGetScanStatus, apiGetLockStatus,
apiGetCmDiagnostics,
apiListCms, apiListRpds,
apiGetLogLevels, apiSetLogLevel
apiGetLogLevels, apiSetLogLevel,
apiVerifyGodMode
} from './api.js';
// 3. 共用工具模組 (Utils)
@ -2828,7 +2881,8 @@ async function fetchFullConfig(configType = 'running') {
const optData = await optRes.json();
const cachedVersion = optData.__metadata__?.cmts_version;
if (cachedVersion && cachedVersion !== currentVersion) {
// 🌟 關鍵修正:當資料庫的版本為 unknown 時,不視為版本變更,直接放行,避免無意義的彈窗干擾
if (cachedVersion && cachedVersion !== 'unknown' && cachedVersion !== currentVersion) {
const doClear = confirm(`⚠️ 偵測到 CMTS 韌體版本已變更!\n\n設備當前版本${currentVersion}\n快取紀錄版本${cachedVersion}\n\n為確保指令正確系統強烈建議清空舊版快取。是否立即清空`);
if (doClear) {
const allKeys = Object.keys(optData);
@ -3787,19 +3841,24 @@ window.applyBackupFilters = function() {
// 渲染過濾後的結果
if (filteredData.length > 0) {
// 🌟 新增:判斷當前是否已解鎖上帝模式 (God Mode)
const isGodMode = sessionStorage.getItem('godModeUnlocked') === 'true';
const displayAdmin = isGodMode ? 'inline-block' : 'none';
tbody.innerHTML = filteredData.map(item => `
<tr style="border-bottom: 1px solid #ecf0f1; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#fcfcfc'" onmouseout="this.style.backgroundColor='transparent'">
<td style="padding: 12px 10px; color: #7f8c8d; font-size: 14px;">${new Date(item.timestamp).toLocaleString()}</td>
<td style="padding: 12px 10px; font-weight: bold; color: #2c3e50;">${item.snapshot_name}</td>
<!-- 🟢 新增描述欄位,加上防撐破設計與 title 提示 -->
<!-- 🟢 描述欄位 -->
<td style="padding: 12px 10px; color: #7f8c8d; max-width: 250px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${item.description || ''}">
${item.description || '<span style="color: #bdc3c7; font-style: italic;">無</span>'}
</td>
<td style="padding: 12px 10px; color: #34495e;"><span style="background: #e8f8f5; color: #27ae60; padding: 3px 8px; border-radius: 12px; font-size: 12px;">${item.config_type}</span></td>
<td style="padding: 12px 10px; text-align: right;">
<button onclick="viewBackup('${item.id}')" class="btn-modern btn-scan" style="padding: 4px 8px; font-size: 12px; margin-right: 5px;">👁️ 檢視</button>
<button onclick="restoreBackup('${item.id}', '${item.snapshot_name}', '${item.host}')" class="btn-modern btn-save" style="padding: 4px 8px; font-size: 12px; margin-right: 5px; background-color: #8e44ad;" title="將設備還原至此狀態">⚡ 還原</button>
<button onclick="deleteBackup('${item.id}')" class="btn-modern btn-disconnect" style="padding: 4px 8px; font-size: 12px;">🗑️ 刪除</button>
<!-- 🌟 加上 admin-only-action class 與動態 display 樣式,只有解鎖後才會出現 -->
<button onclick="restoreBackup('${item.id}', '${item.snapshot_name}', '${item.host}')" class="btn-modern btn-save admin-only-action" style="display: ${displayAdmin}; padding: 4px 8px; font-size: 12px; margin-right: 5px; background-color: #8e44ad;" title="將設備還原至此狀態">⚡ 還原</button>
<button onclick="deleteBackup('${item.id}')" class="btn-modern btn-disconnect admin-only-action" style="display: ${displayAdmin}; padding: 4px 8px; font-size: 12px;">🗑️ 刪除</button>
</td>
</tr>
`).join('');
@ -4124,44 +4183,127 @@ function closeModal() {
document.addEventListener("DOMContentLoaded", checkScanButtonState);
// ==========================================
// 🛡️ 系統管理員雙重解鎖與 4 顆按鈕連動邏輯
// 🛡️ 系統管理員安全解鎖邏輯 (God Mode 2.0)
// ==========================================
const ADMIN_BTN_IDS = ['btn-scan-visible', 'btn-clear-visible', 'btn-scan-global', 'btn-clear-global'];
document.addEventListener('DOMContentLoaded', () => {
const appTitle = document.getElementById('app-title');
function unlockAdminFeatures() {
// 獨立拉出功能解鎖函式,供初始化與驗證成功後呼叫
function unlockAdminFeatures() {
// 1. 解鎖全域快取掃描按鈕
ADMIN_BTN_IDS.forEach(id => {
const btn = document.getElementById(id);
if (btn) btn.style.display = 'inline-block';
});
// 🌟 新增:解鎖樹狀圖裡的所有 🔍 預覽按鈕
// 2. 解鎖樹狀圖裡的所有 🔍 預覽按鈕
document.querySelectorAll('.debug-preview-btn').forEach(btn => {
btn.style.display = 'inline-block';
});
// 🌟 3. 新增:解鎖備份歷史紀錄中的「⚡ 還原」與「🗑️ 刪除」按鈕
document.querySelectorAll('.admin-only-action').forEach(btn => {
btn.style.display = 'inline-block';
});
}
document.addEventListener('DOMContentLoaded', () => {
const appTitle = document.getElementById('app-title');
// 🌟 1. 頁面初始化檢查 (依賴 sessionStorage關閉網頁即失效)
if (sessionStorage.getItem('godModeUnlocked') === 'true') {
unlockAdminFeatures();
}
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('mode') === 'godmode') unlockAdminFeatures();
// 🌟 2. 徹底廢除 URL parameter (mode=godmode) 後門
// 🌟 3. 連點標題觸發機制
let clickCount = 0;
let clickTimer = null;
if (appTitle) {
appTitle.addEventListener('click', () => {
// 已解鎖則忽略
if (sessionStorage.getItem('godModeUnlocked') === 'true') return;
clickCount++;
if (clickCount === 5) {
unlockAdminFeatures();
alert('🔓 已解鎖系統維護者模式!');
openGodModeModal(); // 喚出密碼對話框
clickCount = 0;
}
clearTimeout(clickTimer);
clickTimer = setTimeout(() => { clickCount = 0; }, 1000);
});
}
// 🌟 4. 綁定密碼框的 Enter 快捷鍵
const pwInput = document.getElementById('godModePassword');
if (pwInput) {
pwInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') window.verifyGodMode();
});
}
});
// 🌟 Modal 操作與 API 驗證邏輯
window.openGodModeModal = function() {
const modal = document.getElementById('godModeModal');
const input = document.getElementById('godModePassword');
const errorSpan = document.getElementById('god-mode-error');
errorSpan.textContent = '';
input.value = '';
input.classList.remove('shake-animation');
modal.classList.add('active');
setTimeout(() => input.focus(), 100); // 確保動畫完成後聚焦
};
window.closeGodModeModal = function() {
document.getElementById('godModeModal').classList.remove('active');
};
window.verifyGodMode = async function() {
const input = document.getElementById('godModePassword');
const errorSpan = document.getElementById('god-mode-error');
const btn = document.getElementById('btn-unlock-godmode');
const pwd = input.value.trim();
if (!pwd) {
errorSpan.textContent = '密碼不可為空!';
triggerShake(input);
return;
}
btn.disabled = true;
btn.innerHTML = '⏳ 驗證中...';
errorSpan.textContent = '';
input.classList.remove('shake-animation');
try {
await apiVerifyGodMode(pwd);
// 驗證成功
sessionStorage.setItem('godModeUnlocked', 'true');
closeGodModeModal();
alert('🔓 密碼正確!已解鎖系統維護者模式。');
unlockAdminFeatures();
} catch (err) {
// 驗證失敗
errorSpan.textContent = '❌ 密碼錯誤,請重新輸入';
triggerShake(input);
} finally {
btn.disabled = false;
btn.innerHTML = '🚀 解鎖';
}
};
function triggerShake(element) {
element.classList.remove('shake-animation');
void element.offsetWidth; // Trigger reflow 確保 CSS 動畫可以連續觸發
element.classList.add('shake-animation');
element.focus();
}
// 統一控制 4 顆按鈕的狀態
function setAdminButtonsState(isBusy, text = "⏳ 執行中...") {
ADMIN_BTN_IDS.forEach(id => {
@ -4712,6 +4854,21 @@ button:hover { background-color: #3498db; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
/* =========================================
🚨 密碼輸入錯誤震動動畫 (Shake Effect)
========================================= */
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-6px); }
20%, 40%, 60%, 80% { transform: translateX(6px); }
}
.shake-animation {
animation: shake 0.4s ease-in-out;
border-color: #e74c3c !important; /* 震動時外框變紅 */
background-color: #fdedec !important;
}
================================================================================
FILE: static/edit-mode.js
@ -4753,7 +4910,21 @@ export let currentEditDiffs = [];
export async function releaseAllLocks() {
const keysToRelease = Object.keys(ACTIVE_HEARTBEATS);
if (keysToRelease.length === 0) return;
// 🌟 1. 檢查當前是否處於 God Mode 狀態
const wasGodModeUnlocked = sessionStorage.getItem('godModeUnlocked') === 'true';
// 🌟 2. 只要觸發閒置,立刻無條件清除 God Mode 權限
sessionStorage.removeItem('godModeUnlocked');
if (keysToRelease.length === 0) {
// 如果沒有正在編輯的節點,但剛剛是 God Mode依然要登出並重整
if (wasGodModeUnlocked) {
alert("您已閒置超過 5 分鐘,系統已自動為您登出進階維護者模式。");
location.reload();
}
return;
}
const releasePromises = keysToRelease.map(key => {
const [host, path] = key.split('@@');
@ -4764,8 +4935,8 @@ export async function releaseAllLocks() {
});
await Promise.all(releasePromises);
alert("您已閒置超過 5 分鐘,系統已自動釋放編輯鎖定並重新整理頁面。");
location.reload();
alert("您已閒置超過 5 分鐘,系統已自動釋放編輯鎖定並登出進階模式。");
location.reload(); // 重整網頁,確保所有 UI 恢復未解鎖狀態
}
async function sendHeartbeat(path, host, intervalId, lockKey) {
@ -5049,7 +5220,9 @@ export async function startEditLeaf(path, elementId) {
await new Promise(resolve => setTimeout(resolve, 1000));
optData = await apiGetLeafOptions(host, currentMode);
leafCache = optData[cacheKey];
if (leafCache && leafCache.options && leafCache.options.length > 0) {
// 🌟 修正:只要取得 hint(說明) 或選項,就立即終止等待,讓 ❓ 提早顯示出來
if (leafCache && (leafCache.hint || (leafCache.options && leafCache.options.length > 0))) {
found = true; break;
}
}
@ -7612,6 +7785,36 @@ async def list_rpds(host: str, username: str, password: str):
return {"rpds": []}
================================================================================
FILE: routers/auth.py
================================================================================
import os
import secrets
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from logger import get_logger
logger = get_logger("app.auth")
router = APIRouter(tags=["Auth"])
# 定義請求本體格式
class GodModeRequest(BaseModel):
password: str
@router.post("/auth/god-mode", summary="驗證上帝模式進階密碼")
async def verify_god_mode(req: GodModeRequest):
# 從環境變數讀取正確密碼,預設為 "admin999"
expected_password = os.getenv("GOD_MODE_SECRET", "swpa@Serc0mm")
# 使用 compare_digest 防禦計時攻擊
if secrets.compare_digest(req.password, expected_password):
logger.info("🔓 上帝模式解鎖成功!")
return {"status": "success"}
logger.warning("🔒 嘗試解鎖上帝模式失敗 (密碼錯誤)")
raise HTTPException(status_code=401, detail="Invalid authorization code")
================================================================================
FILE: routers/config.py
================================================================================

View File

@ -185,9 +185,10 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
# 🌟 新增:在第一批次連線時,先抓取設備版本
if cmts_version == "unknown":
process.stdin.write("show version\n")
# 🌟 補上 | nomore並稍微延長等待時間確保不會被分頁卡住
process.stdin.write("show version | nomore\n")
await process.stdin.drain()
version_output = await read_until_quiet(timeout=1.5, prompt_pattern=r"(?:#|>)")
version_output = await read_until_quiet(timeout=2.0, prompt_pattern=r"(?:#|>)")
match = re.search(r"(?:infra|vcmts-cd-0)\s+([\w\.\-]+)", version_output)
if match:
cmts_version = match.group(1)

View File

@ -142,10 +142,13 @@ async def upsert_device_status(host: str, config_type: str, metadata: dict) -> b
if not pool:
return False
# Extract known fields
cmts_version = metadata.get("cmts_version", "unknown")
cmts_version = metadata.get("cmts_version")
last_scanned = metadata.get("last_scanned", None)
try:
async with pool.acquire() as conn:
# 🌟 關鍵修正:如果傳入的是 unknown 或空值,只更新時間,絕對不動版本號!
if cmts_version and cmts_version != "unknown":
query = """
INSERT INTO device_status (host, config_type, cmts_version, last_scanned)
VALUES ($1, $2, $3, $4)
@ -155,15 +158,20 @@ async def upsert_device_status(host: str, config_type: str, metadata: dict) -> b
last_scanned = EXCLUDED.last_scanned,
updated_at = CURRENT_TIMESTAMP;
"""
try:
async with pool.acquire() as conn:
await conn.execute(query, host, config_type, cmts_version, last_scanned)
else:
query = """
INSERT INTO device_status (host, config_type, last_scanned)
VALUES ($1, $2, $3)
ON CONFLICT (host, config_type)
DO UPDATE SET
last_scanned = EXCLUDED.last_scanned,
updated_at = CURRENT_TIMESTAMP;
"""
await conn.execute(query, host, config_type, last_scanned)
return True
except asyncpg.PostgresError as e:
logger.error(f"❌ DB Error (upsert_device_status): {e}")
return False
except Exception as e:
logger.error(f"❌ Unknown Error (upsert_device_status): {e}")
logger.error(f"❌ DB Error (upsert_device_status): {e}")
return False
async def get_device_status(host: str, config_type: str) -> Optional[Dict[str, Any]]:

View File

@ -575,6 +575,31 @@
</div>
</div>
<!-- 🌟 新增God Mode 授權視窗 (Modal) -->
<div id="godModeModal" class="modal-overlay">
<!-- 將對話框置中,限制最大寬度,改變一下配色風格 -->
<div class="modal-container" style="max-width: 350px; height: auto; transform: translateY(20px);">
<div class="modal-header" style="background-color: #8e44ad; border-bottom: none;">
<div class="modal-title" style="justify-content: center; width: 100%;">
<span>🔐 系統進階授權</span>
</div>
</div>
<div class="modal-body" style="padding: 25px 20px !important; background: #fdfefe; text-align: center;">
<p style="color: #2c3e50; font-size: 14px; margin-top: 0; margin-bottom: 20px; font-weight: bold;">
請輸入維護者密碼以解鎖隱藏功能
</p>
<input type="password" id="godModePassword" placeholder="Enter Password..." style="width: 100%; box-sizing: border-box; padding: 12px; border: 2px solid #bdc3c7; border-radius: 6px; font-size: 16px; text-align: center; margin-bottom: 10px; transition: border-color 0.2s; outline: none;" onfocus="this.style.borderColor='#8e44ad'" onblur="this.style.borderColor='#bdc3c7'">
<span id="god-mode-error" style="color: #e74c3c; font-size: 13px; font-weight: bold; display: block; min-height: 18px; margin-bottom: 15px;"></span>
<div style="display: flex; gap: 15px; justify-content: center;">
<button onclick="closeGodModeModal()" class="btn-modern btn-disconnect" style="flex: 1; padding: 10px;">取消</button>
<button id="btn-unlock-godmode" onclick="verifyGodMode()" class="btn-modern btn-save" style="flex: 1; background-color: #8e44ad; padding: 10px;">🚀 解鎖</button>
</div>
</div>
</div>
</div>
<!-- 引入獨立的 JavaScript 邏輯 -->
<script type="module" src="/static/app.js"></script>

View File

@ -7,7 +7,7 @@ from contextlib import asynccontextmanager
import database
# 引入我們剛剛拆分出來的路由模組
from routers import query, config, terminal, lock, leaf_options, backup, diagnostics
from routers import query, config, terminal, lock, leaf_options, backup, diagnostics, auth
@asynccontextmanager
async def lifespan(app: FastAPI):
@ -29,6 +29,7 @@ app.include_router(leaf_options.router, prefix="/api/v1")
app.include_router(lock.router, prefix="/api/v1")
app.include_router(backup.router, prefix="/api/v1")
app.include_router(diagnostics.router, prefix="/api/v1")
app.include_router(auth.router, prefix="/api/v1")
# WebSocket 通常獨立於 API 版本之外,所以不加前綴
app.include_router(terminal.router)

25
routers/auth.py Normal file
View File

@ -0,0 +1,25 @@
import os
import secrets
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from logger import get_logger
logger = get_logger("app.auth")
router = APIRouter(tags=["Auth"])
# 定義請求本體格式
class GodModeRequest(BaseModel):
password: str
@router.post("/auth/god-mode", summary="驗證上帝模式進階密碼")
async def verify_god_mode(req: GodModeRequest):
# 從環境變數讀取正確密碼,預設為 "admin999"
expected_password = os.getenv("GOD_MODE_SECRET", "swpa@Serc0mm")
# 使用 compare_digest 防禦計時攻擊
if secrets.compare_digest(req.password, expected_password):
logger.info("🔓 上帝模式解鎖成功!")
return {"status": "success"}
logger.warning("🔒 嘗試解鎖上帝模式失敗 (密碼錯誤)")
raise HTTPException(status_code=401, detail="Invalid authorization code")

View File

@ -225,3 +225,20 @@ export async function apiSetLogLevel(module, level) {
});
return response.json();
}
// ==========================================
// 7. 授權與驗證 API (Auth)
// ==========================================
export async function apiVerifyGodMode(password) {
const response = await fetch('/api/v1/auth/god-mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || "驗證失敗");
}
return response.json();
}

View File

@ -15,7 +15,8 @@ import {
apiGetScanStatus, apiGetLockStatus,
apiGetCmDiagnostics,
apiListCms, apiListRpds,
apiGetLogLevels, apiSetLogLevel
apiGetLogLevels, apiSetLogLevel,
apiVerifyGodMode
} from './api.js';
// 3. 共用工具模組 (Utils)
@ -599,7 +600,8 @@ async function fetchFullConfig(configType = 'running') {
const optData = await optRes.json();
const cachedVersion = optData.__metadata__?.cmts_version;
if (cachedVersion && cachedVersion !== currentVersion) {
// 🌟 關鍵修正:當資料庫的版本為 unknown 時,不視為版本變更,直接放行,避免無意義的彈窗干擾
if (cachedVersion && cachedVersion !== 'unknown' && cachedVersion !== currentVersion) {
const doClear = confirm(`⚠️ 偵測到 CMTS 韌體版本已變更!\n\n設備當前版本:${currentVersion}\n快取紀錄版本:${cachedVersion}\n\n為確保指令正確,系統強烈建議清空舊版快取。是否立即清空?`);
if (doClear) {
const allKeys = Object.keys(optData);
@ -1558,19 +1560,24 @@ window.applyBackupFilters = function() {
// 渲染過濾後的結果
if (filteredData.length > 0) {
// 🌟 新增:判斷當前是否已解鎖上帝模式 (God Mode)
const isGodMode = sessionStorage.getItem('godModeUnlocked') === 'true';
const displayAdmin = isGodMode ? 'inline-block' : 'none';
tbody.innerHTML = filteredData.map(item => `
<tr style="border-bottom: 1px solid #ecf0f1; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#fcfcfc'" onmouseout="this.style.backgroundColor='transparent'">
<td style="padding: 12px 10px; color: #7f8c8d; font-size: 14px;">${new Date(item.timestamp).toLocaleString()}</td>
<td style="padding: 12px 10px; font-weight: bold; color: #2c3e50;">${item.snapshot_name}</td>
<!-- 🟢 新增描述欄位加上防撐破設計與 title 提示 -->
<!-- 🟢 描述欄位 -->
<td style="padding: 12px 10px; color: #7f8c8d; max-width: 250px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${item.description || ''}">
${item.description || '<span style="color: #bdc3c7; font-style: italic;">無</span>'}
</td>
<td style="padding: 12px 10px; color: #34495e;"><span style="background: #e8f8f5; color: #27ae60; padding: 3px 8px; border-radius: 12px; font-size: 12px;">${item.config_type}</span></td>
<td style="padding: 12px 10px; text-align: right;">
<button onclick="viewBackup('${item.id}')" class="btn-modern btn-scan" style="padding: 4px 8px; font-size: 12px; margin-right: 5px;">👁 檢視</button>
<button onclick="restoreBackup('${item.id}', '${item.snapshot_name}', '${item.host}')" class="btn-modern btn-save" style="padding: 4px 8px; font-size: 12px; margin-right: 5px; background-color: #8e44ad;" title="將設備還原至此狀態"> 還原</button>
<button onclick="deleteBackup('${item.id}')" class="btn-modern btn-disconnect" style="padding: 4px 8px; font-size: 12px;">🗑 刪除</button>
<!-- 🌟 加上 admin-only-action class 與動態 display 樣式只有解鎖後才會出現 -->
<button onclick="restoreBackup('${item.id}', '${item.snapshot_name}', '${item.host}')" class="btn-modern btn-save admin-only-action" style="display: ${displayAdmin}; padding: 4px 8px; font-size: 12px; margin-right: 5px; background-color: #8e44ad;" title="將設備還原至此狀態"> 還原</button>
<button onclick="deleteBackup('${item.id}')" class="btn-modern btn-disconnect admin-only-action" style="display: ${displayAdmin}; padding: 4px 8px; font-size: 12px;">🗑 刪除</button>
</td>
</tr>
`).join('');
@ -1895,44 +1902,127 @@ function closeModal() {
document.addEventListener("DOMContentLoaded", checkScanButtonState);
// ==========================================
// 🛡️ 系統管理員雙重解鎖與 4 顆按鈕連動邏輯
// 🛡️ 系統管理員安全解鎖邏輯 (God Mode 2.0)
// ==========================================
const ADMIN_BTN_IDS = ['btn-scan-visible', 'btn-clear-visible', 'btn-scan-global', 'btn-clear-global'];
document.addEventListener('DOMContentLoaded', () => {
const appTitle = document.getElementById('app-title');
function unlockAdminFeatures() {
// 獨立拉出功能解鎖函式,供初始化與驗證成功後呼叫
function unlockAdminFeatures() {
// 1. 解鎖全域快取掃描按鈕
ADMIN_BTN_IDS.forEach(id => {
const btn = document.getElementById(id);
if (btn) btn.style.display = 'inline-block';
});
// 🌟 新增:解鎖樹狀圖裡的所有 🔍 預覽按鈕
// 2. 解鎖樹狀圖裡的所有 🔍 預覽按鈕
document.querySelectorAll('.debug-preview-btn').forEach(btn => {
btn.style.display = 'inline-block';
});
// 🌟 3. 新增:解鎖備份歷史紀錄中的「⚡ 還原」與「🗑️ 刪除」按鈕
document.querySelectorAll('.admin-only-action').forEach(btn => {
btn.style.display = 'inline-block';
});
}
document.addEventListener('DOMContentLoaded', () => {
const appTitle = document.getElementById('app-title');
// 🌟 1. 頁面初始化檢查 (依賴 sessionStorage關閉網頁即失效)
if (sessionStorage.getItem('godModeUnlocked') === 'true') {
unlockAdminFeatures();
}
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('mode') === 'godmode') unlockAdminFeatures();
// 🌟 2. 徹底廢除 URL parameter (mode=godmode) 後門
// 🌟 3. 連點標題觸發機制
let clickCount = 0;
let clickTimer = null;
if (appTitle) {
appTitle.addEventListener('click', () => {
// 已解鎖則忽略
if (sessionStorage.getItem('godModeUnlocked') === 'true') return;
clickCount++;
if (clickCount === 5) {
unlockAdminFeatures();
alert('🔓 已解鎖系統維護者模式!');
openGodModeModal(); // 喚出密碼對話框
clickCount = 0;
}
clearTimeout(clickTimer);
clickTimer = setTimeout(() => { clickCount = 0; }, 1000);
});
}
// 🌟 4. 綁定密碼框的 Enter 快捷鍵
const pwInput = document.getElementById('godModePassword');
if (pwInput) {
pwInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') window.verifyGodMode();
});
}
});
// 🌟 Modal 操作與 API 驗證邏輯
window.openGodModeModal = function() {
const modal = document.getElementById('godModeModal');
const input = document.getElementById('godModePassword');
const errorSpan = document.getElementById('god-mode-error');
errorSpan.textContent = '';
input.value = '';
input.classList.remove('shake-animation');
modal.classList.add('active');
setTimeout(() => input.focus(), 100); // 確保動畫完成後聚焦
};
window.closeGodModeModal = function() {
document.getElementById('godModeModal').classList.remove('active');
};
window.verifyGodMode = async function() {
const input = document.getElementById('godModePassword');
const errorSpan = document.getElementById('god-mode-error');
const btn = document.getElementById('btn-unlock-godmode');
const pwd = input.value.trim();
if (!pwd) {
errorSpan.textContent = '密碼不可為空!';
triggerShake(input);
return;
}
btn.disabled = true;
btn.innerHTML = '⏳ 驗證中...';
errorSpan.textContent = '';
input.classList.remove('shake-animation');
try {
await apiVerifyGodMode(pwd);
// 驗證成功
sessionStorage.setItem('godModeUnlocked', 'true');
closeGodModeModal();
alert('🔓 密碼正確!已解鎖系統維護者模式。');
unlockAdminFeatures();
} catch (err) {
// 驗證失敗
errorSpan.textContent = '❌ 密碼錯誤,請重新輸入';
triggerShake(input);
} finally {
btn.disabled = false;
btn.innerHTML = '🚀 解鎖';
}
};
function triggerShake(element) {
element.classList.remove('shake-animation');
void element.offsetWidth; // Trigger reflow 確保 CSS 動畫可以連續觸發
element.classList.add('shake-animation');
element.focus();
}
// 統一控制 4 顆按鈕的狀態
function setAdminButtonsState(isBusy, text = "⏳ 執行中...") {
ADMIN_BTN_IDS.forEach(id => {

View File

@ -35,7 +35,21 @@ export let currentEditDiffs = [];
export async function releaseAllLocks() {
const keysToRelease = Object.keys(ACTIVE_HEARTBEATS);
if (keysToRelease.length === 0) return;
// 🌟 1. 檢查當前是否處於 God Mode 狀態
const wasGodModeUnlocked = sessionStorage.getItem('godModeUnlocked') === 'true';
// 🌟 2. 只要觸發閒置,立刻無條件清除 God Mode 權限
sessionStorage.removeItem('godModeUnlocked');
if (keysToRelease.length === 0) {
// 如果沒有正在編輯的節點,但剛剛是 God Mode依然要登出並重整
if (wasGodModeUnlocked) {
alert("您已閒置超過 5 分鐘,系統已自動為您登出進階維護者模式。");
location.reload();
}
return;
}
const releasePromises = keysToRelease.map(key => {
const [host, path] = key.split('@@');
@ -46,8 +60,8 @@ export async function releaseAllLocks() {
});
await Promise.all(releasePromises);
alert("您已閒置超過 5 分鐘,系統已自動釋放編輯鎖定並重新整理頁面。");
location.reload();
alert("您已閒置超過 5 分鐘,系統已自動釋放編輯鎖定並登出進階模式。");
location.reload(); // 重整網頁,確保所有 UI 恢復未解鎖狀態
}
async function sendHeartbeat(path, host, intervalId, lockKey) {
@ -331,7 +345,9 @@ export async function startEditLeaf(path, elementId) {
await new Promise(resolve => setTimeout(resolve, 1000));
optData = await apiGetLeafOptions(host, currentMode);
leafCache = optData[cacheKey];
if (leafCache && leafCache.options && leafCache.options.length > 0) {
// 🌟 修正:只要取得 hint(說明) 或選項,就立即終止等待,讓 ❓ 提早顯示出來
if (leafCache && (leafCache.hint || (leafCache.options && leafCache.options.length > 0))) {
found = true; break;
}
}

View File

@ -272,3 +272,18 @@ button:hover { background-color: #3498db; }
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
/* =========================================
🚨 密碼輸入錯誤震動動畫 (Shake Effect)
========================================= */
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-6px); }
20%, 40%, 60%, 80% { transform: translateX(6px); }
}
.shake-animation {
animation: shake 0.4s ease-in-out;
border-color: #e74c3c !important; /* 震動時外框變紅 */
background-color: #fdedec !important;
}