refactor: God Mode 安全防護
This commit is contained in:
parent
bc933e1e39
commit
061ee3435a
261
all_code.txt
261
all_code.txt
|
|
@ -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,43 +4183,126 @@ 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() {
|
||||
// 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';
|
||||
});
|
||||
}
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('mode') === 'godmode') unlockAdminFeatures();
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const appTitle = document.getElementById('app-title');
|
||||
|
||||
// 🌟 1. 頁面初始化檢查 (依賴 sessionStorage,關閉網頁即失效)
|
||||
if (sessionStorage.getItem('godModeUnlocked') === 'true') {
|
||||
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 = "⏳ 執行中...") {
|
||||
|
|
@ -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
|
||||
================================================================================
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
24
database.py
24
database.py
|
|
@ -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]]:
|
||||
|
|
|
|||
25
index.html
25
index.html
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
3
main.py
3
main.py
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
118
static/app.js
118
static/app.js
|
|
@ -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,43 +1902,126 @@ 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() {
|
||||
// 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';
|
||||
});
|
||||
}
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('mode') === 'godmode') unlockAdminFeatures();
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const appTitle = document.getElementById('app-title');
|
||||
|
||||
// 🌟 1. 頁面初始化檢查 (依賴 sessionStorage,關閉網頁即失效)
|
||||
if (sessionStorage.getItem('godModeUnlocked') === 'true') {
|
||||
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 = "⏳ 執行中...") {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue