From 548c8fcae85c6ca517f0f500810698ca30e10407 Mon Sep 17 00:00:00 2001 From: ogt Date: Thu, 25 Jun 2026 10:13:44 +0800 Subject: [PATCH] feat(iwooos): show Wazuh route readback status --- apps/web/messages/en.json | 42 +++++ apps/web/messages/zh-TW.json | 42 +++++ apps/web/src/app/[locale]/iwooos/page.tsx | 196 ++++++++++++++++++++++ docs/LOGBOOK.md | 26 +++ 4 files changed, 306 insertions(+) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index a2c00982..35bd408f 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -19003,6 +19003,48 @@ } } }, + "wazuhLiveRouteReadback": { + "eyebrow": "Wazuh 正式路由只讀讀回", + "title": "正式站必須直接顯示 Wazuh 只讀路由狀態", + "subtitle": "這張卡只讀 `/api/iwooos/wazuh`,把未部署、未啟用、代理清單為空、低於預期與可讀狀態轉成操作員可理解的繁中訊號;不顯示代理身分、內網位址、原始內容或機密。", + "statusDetail": "此狀態只代表正式路由讀回結果;退化或未部署時不能顯示綠燈,也不授權主機操作、主動回應、掃描、重啟或機密變更。", + "boundaryTitle": "正式路由讀回邊界", + "boundaryIntro": "以下鍵值只用於避免誤判:正式路由未部署、唯讀查詢未啟用或代理清單退化時,都不能顯示綠燈。", + "status": { + "loading": "讀取正式路由中", + "predeploy": "正式路由尚未部署", + "disabled": "唯讀查詢尚未啟用", + "available": "只讀中繼資料可讀", + "registryEmpty": "代理清單為空,禁止顯示綠燈", + "belowExpected": "代理數低於預期,禁止顯示綠燈", + "misconfigured": "伺服端環境尚未通過", + "unavailable": "正式路由讀回不可用" + }, + "metrics": { + "route": { + "label": "路由狀態", + "detail": "正式站 HTTP 讀回碼。" + }, + "readonly": { + "label": "唯讀查詢", + "detail": "只顯示是否啟用,不顯示連線資訊。" + }, + "agents": { + "label": "代理總數", + "detail": "只顯示 aggregate,不列代理清單。" + }, + "runtimeGate": { + "label": "執行閘門", + "detail": "主動回應與主機寫入仍維持 0。" + } + }, + "details": { + "active": "在線代理", + "disconnected": "離線代理", + "pending": "等待代理", + "expectedMin": "預期下限" + } + }, "wazuhLiveMetadataEnvGate": { "eyebrow": "Wazuh 即時中繼資料環境閘門", "title": "Wazuh 查詢要等正式路由、負責人與機密中繼資料都過關", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index a2c00982..35bd408f 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -19003,6 +19003,48 @@ } } }, + "wazuhLiveRouteReadback": { + "eyebrow": "Wazuh 正式路由只讀讀回", + "title": "正式站必須直接顯示 Wazuh 只讀路由狀態", + "subtitle": "這張卡只讀 `/api/iwooos/wazuh`,把未部署、未啟用、代理清單為空、低於預期與可讀狀態轉成操作員可理解的繁中訊號;不顯示代理身分、內網位址、原始內容或機密。", + "statusDetail": "此狀態只代表正式路由讀回結果;退化或未部署時不能顯示綠燈,也不授權主機操作、主動回應、掃描、重啟或機密變更。", + "boundaryTitle": "正式路由讀回邊界", + "boundaryIntro": "以下鍵值只用於避免誤判:正式路由未部署、唯讀查詢未啟用或代理清單退化時,都不能顯示綠燈。", + "status": { + "loading": "讀取正式路由中", + "predeploy": "正式路由尚未部署", + "disabled": "唯讀查詢尚未啟用", + "available": "只讀中繼資料可讀", + "registryEmpty": "代理清單為空,禁止顯示綠燈", + "belowExpected": "代理數低於預期,禁止顯示綠燈", + "misconfigured": "伺服端環境尚未通過", + "unavailable": "正式路由讀回不可用" + }, + "metrics": { + "route": { + "label": "路由狀態", + "detail": "正式站 HTTP 讀回碼。" + }, + "readonly": { + "label": "唯讀查詢", + "detail": "只顯示是否啟用,不顯示連線資訊。" + }, + "agents": { + "label": "代理總數", + "detail": "只顯示 aggregate,不列代理清單。" + }, + "runtimeGate": { + "label": "執行閘門", + "detail": "主動回應與主機寫入仍維持 0。" + } + }, + "details": { + "active": "在線代理", + "disconnected": "離線代理", + "pending": "等待代理", + "expectedMin": "預期下限" + } + }, "wazuhLiveMetadataEnvGate": { "eyebrow": "Wazuh 即時中繼資料環境閘門", "title": "Wazuh 查詢要等正式路由、負責人與機密中繼資料都過關", diff --git a/apps/web/src/app/[locale]/iwooos/page.tsx b/apps/web/src/app/[locale]/iwooos/page.tsx index 56c4297d..5e59c6b2 100644 --- a/apps/web/src/app/[locale]/iwooos/page.tsx +++ b/apps/web/src/app/[locale]/iwooos/page.tsx @@ -279,6 +279,22 @@ type WazuhLiveMetadataEnvGateItem = { tone: 'steady' | 'warn' | 'locked' } +type WazuhReadonlyStatusResponse = { + status?: string + configured?: boolean + summary?: { + readonly_api_enabled_count?: number + agent_total?: number + agent_active?: number + agent_disconnected?: number + agent_pending?: number + expected_min_agent_count?: number + agent_registry_empty_count?: number + agent_below_expected_minimum_count?: number + runtime_gate_count?: number + } +} + type SocSiemKaliWazuhIntegrationItem = { key: string check: string @@ -7721,6 +7737,185 @@ function IwoooSWazuhIntrusionReadbackBoard() { ) } +function wazuhReadonlyStatusKey(httpStatus: number | null, data: WazuhReadonlyStatusResponse | null) { + if (httpStatus === 404) return 'predeploy' + if (!data) return 'unavailable' + if (data.status === 'readonly_metadata_available') return 'available' + if (data.status === 'wazuh_agent_registry_empty') return 'registryEmpty' + if (data.status === 'wazuh_agent_registry_below_expected') return 'belowExpected' + if (data.status === 'disabled_waiting_iwooos_wazuh_owner_gate') return 'disabled' + if (data.status === 'misconfigured_missing_server_side_wazuh_env') return 'misconfigured' + if (data.status === 'wazuh_readonly_metadata_unavailable') return 'unavailable' + return 'unavailable' +} + +function IwoooSWazuhLiveRouteReadbackBoard() { + const t = useTranslations('iwooos.wazuhLiveRouteReadback') + const textWrap = { overflowWrap: 'anywhere' as const, wordBreak: 'break-word' as const } + const [data, setData] = useState(null) + const [httpStatus, setHttpStatus] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const controller = new AbortController() + + async function loadReadback() { + setLoading(true) + try { + const response = await fetch('/api/iwooos/wazuh', { + headers: { Accept: 'application/json' }, + cache: 'no-store', + signal: controller.signal, + }) + setHttpStatus(response.status) + if (response.ok) { + const payload = await response.json() as WazuhReadonlyStatusResponse + setData(payload) + } else { + setData(null) + } + } catch { + if (!controller.signal.aborted) { + setHttpStatus(null) + setData(null) + } + } finally { + if (!controller.signal.aborted) { + setLoading(false) + } + } + } + + loadReadback() + return () => controller.abort() + }, []) + + const summary = data?.summary ?? {} + const statusKey = loading ? 'loading' : wazuhReadonlyStatusKey(httpStatus, data) + const statusTone = statusKey === 'available' ? 'steady' : statusKey === 'predeploy' || statusKey === 'disabled' ? 'locked' : 'warn' + const routeValue = loading ? '...' : httpStatus === 404 ? '404' : httpStatus ? String(httpStatus) : '0' + const readonlyValue = summary.readonly_api_enabled_count === 1 ? '1' : '0' + const agentTotal = typeof summary.agent_total === 'number' ? String(summary.agent_total) : '0' + const runtimeGate = typeof summary.runtime_gate_count === 'number' ? String(summary.runtime_gate_count) : '0' + const metrics = [ + { key: 'route', value: routeValue, icon: Route, tone: statusTone }, + { key: 'readonly', value: readonlyValue, icon: Radar, tone: readonlyValue === '1' ? 'warn' : 'locked' }, + { key: 'agents', value: agentTotal, icon: Server, tone: summary.agent_registry_empty_count === 1 || summary.agent_below_expected_minimum_count === 1 ? 'warn' : 'locked' }, + { key: 'runtimeGate', value: runtimeGate, icon: Lock, tone: 'locked' }, + ] as const + const detailRows = [ + { key: 'active', value: typeof summary.agent_active === 'number' ? String(summary.agent_active) : '0' }, + { key: 'disconnected', value: typeof summary.agent_disconnected === 'number' ? String(summary.agent_disconnected) : '0' }, + { key: 'pending', value: typeof summary.agent_pending === 'number' ? String(summary.agent_pending) : '0' }, + { key: 'expectedMin', value: typeof summary.expected_min_agent_count === 'number' ? String(summary.expected_min_agent_count) : '0' }, + ] as const + + return ( +
+
+
+
+
+ + {t('eyebrow')} +
+

{t('title')}

+

+ {t('subtitle')} +

+
+ +
+ {metrics.map(item => { + const Icon = item.icon + return ( +
+
+ {t(`metrics.${item.key}.label` as never)} + +
+
+ {item.value} +
+

+ {t(`metrics.${item.key}.detail` as never)} +

+
+ ) + })} +
+
+ +
+
+
+ {t(`status.${statusKey}` as never)} +
+

+ {t('statusDetail')} +

+
+ {detailRows.map(row => ( +
+
{t(`details.${row.key}` as never)}
+
{row.value}
+
+ ))} +
+ +
+ + {t('boundaryTitle')} + +

+ {t('boundaryIntro')} +

+
+ {[ + `production_route_http_status=${httpStatus ?? 0}`, + `readonly_api_enabled_count=${readonlyValue}`, + `agent_registry_empty_count=${summary.agent_registry_empty_count ?? 0}`, + `agent_below_expected_minimum_count=${summary.agent_below_expected_minimum_count ?? 0}`, + `runtime_gate_count=${runtimeGate}`, + 'agent_identity_public_display_allowed=false', + 'internal_ip_public_display_allowed=false', + 'raw_wazuh_payload_storage_allowed=false', + ].map(item => ( + + {item} + + ))} +
+
+
+
+ ) +} + function IwoooSWazuhLiveMetadataEnvGateBoard() { const t = useTranslations('iwooos.wazuhLiveMetadataEnvGate') const textWrap = { overflowWrap: 'anywhere' as const, wordBreak: 'break-word' as const } @@ -20775,6 +20970,7 @@ export default function IwoooSPage({ params }: { params: { locale: string } }) { + diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 6bc216b1..3f26eef8 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,29 @@ +## 2026-06-25|IwoooS Wazuh 正式路由只讀讀回前台 + +**背景**:IwoooS 前台已有 Wazuh 入侵回讀與環境閘門,但缺少直接顯示 `/api/iwooos/wazuh` 目前讀回狀態的卡片;operator 仍可能把「Wazuh 已建置」誤解成「正式路由已部署、agent registry 已驗收」。 + +**完成**: +- `/zh-TW/iwooos` 新增 `Wazuh 正式路由只讀讀回` 卡片。 +- 卡片只讀 `/api/iwooos/wazuh`,把未部署、未啟用、只讀可用、代理清單為空、低於預期、伺服端環境未通過與不可用狀態轉成繁中訊號。 +- 僅顯示 HTTP 讀回碼、唯讀查詢啟用計數、aggregate 代理數、執行閘門與退化計數;不顯示 agent alias、內網位址、raw payload、帳密或完整 response。 +- 可見文案明確標示退化或未部署時不能顯示綠燈,且不授權主機操作、主動回應、掃描、重啟或機密變更。 + +**驗證**: +- `node -e "JSON.parse(...)"`:`zh-TW` / `en` JSON 解析通過。 +- `cmp -s apps/web/messages/zh-TW.json apps/web/messages/en.json`:通過。 +- `pnpm --filter @awoooi/web typecheck`:通過。 +- `python3 scripts/security/security-mirror-progress-guard.py --root .`:通過。 +- `python3 scripts/security/wazuh-readonly-route-boundary-guard.py --root .`:通過。 +- 本地 Chrome smoke `/zh-TW/iwooos` desktop `1440x1100` 與 mobile `390x1200`:`data-testid=iwooos-wazuh-live-route-readback-board` 可見、`horizontalOverflow=0`、可見文案包含 `不能顯示綠燈`、未出現工作視窗片段、直白主機別名、Wazuh env key、token 或內網位址。 + +**完成度同步**: +- IwoooS Wazuh 正式路由讀回前台:`100%` source-side。 +- IwoooS Wazuh 前台可讀性:`91% -> 94%` source-side。 +- Wazuh live route production readback:仍為 `0%`,正式站仍需部署後驗收。 +- Wazuh manager registry live accepted、Dashboard stored API 修復、owner response、active response / host write:仍維持 `0%`。 + +**邊界**:本輪沒有查 live Wazuh manager、沒有 SSH、沒有讀 secret、沒有送 Telegram、沒有部署、沒有修改 Wazuh / Docker / Nginx / firewall,也沒有 active response。 + ## 2026-06-25|Wazuh release handoff 分層狀態校正 **背景**:Wazuh release handoff 仍把「Gitea push」寫成 `0%`,容易讓另一個工作視窗誤會 feature branch 尚未推送。實際狀態是 feature branch 已推送,但 formal main release、production deploy 與 live metadata env enable 仍為 `0%`。