feat(iwooos): show Wazuh route readback status

This commit is contained in:
ogt
2026-06-25 10:13:44 +08:00
parent d27671d90f
commit 548c8fcae8
4 changed files with 306 additions and 0 deletions

View File

@@ -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 查詢要等正式路由、負責人與機密中繼資料都過關",

View File

@@ -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 查詢要等正式路由、負責人與機密中繼資料都過關",

View File

@@ -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<WazuhReadonlyStatusResponse | null>(null)
const [httpStatus, setHttpStatus] = useState<number | null>(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 (
<section
style={{ marginBottom: 14, maxWidth: '100%', overflow: 'hidden' }}
data-testid="iwooos-wazuh-live-route-readback-board"
>
<div style={{ ...band, padding: 16, background: '#fbfaf6', borderColor: '#d8d3c7' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 300px), 1fr))', gap: 14 }}>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#5f5b52', fontSize: 12, fontWeight: 700 }}>
<Route size={17} color="#6f6a5f" />
{t('eyebrow')}
</div>
<h2 style={{ fontSize: 17, margin: '8px 0 0', color: '#141413' }}>{t('title')}</h2>
<p style={{ fontSize: 12, color: '#5f5b52', margin: '6px 0 0', lineHeight: 1.55, ...textWrap }}>
{t('subtitle')}
</p>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(126px, 1fr))', gap: 8 }}>
{metrics.map(item => {
const Icon = item.icon
return (
<div key={item.key} style={{ border: '0.5px solid #ded8c8', borderRadius: 8, padding: 12, background: '#fff' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span style={{ fontSize: 11, color: '#77736a' }}>{t(`metrics.${item.key}.label` as never)}</span>
<Icon size={16} color={toneColors[item.tone]} />
</div>
<div style={{ fontSize: 19, fontWeight: 700, color: toneColors[item.tone], lineHeight: 1.1, marginTop: 8, ...textWrap }}>
{item.value}
</div>
<p style={{ fontSize: 11, color: '#5f5b52', lineHeight: 1.45, margin: '8px 0 0', ...textWrap }}>
{t(`metrics.${item.key}.detail` as never)}
</p>
</div>
)
})}
</div>
</div>
<div style={{ marginTop: 12, display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 180px), 1fr))', gap: 8 }}>
<div style={{ border: '0.5px solid #ded8c8', borderRadius: 8, background: '#fff', padding: 12, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: toneColors[statusTone], ...textWrap }}>
{t(`status.${statusKey}` as never)}
</div>
<p style={{ fontSize: 11, lineHeight: 1.5, color: '#5f5b52', margin: '8px 0 0', ...textWrap }}>
{t('statusDetail')}
</p>
</div>
{detailRows.map(row => (
<div key={row.key} style={{ border: '0.5px solid #ded8c8', borderRadius: 8, background: '#fff', padding: 12, minWidth: 0 }}>
<div style={{ fontSize: 11, color: '#77736a' }}>{t(`details.${row.key}` as never)}</div>
<div style={{ marginTop: 8, fontSize: 18, fontWeight: 700, color: '#141413' }}>{row.value}</div>
</div>
))}
</div>
<details
data-testid="iwooos-wazuh-live-route-readback-boundaries"
style={{
marginTop: 12,
border: '0.5px solid #ded8c8',
borderRadius: 8,
background: '#fff',
padding: '8px 10px',
}}
>
<summary style={{ cursor: 'pointer', fontSize: 12, fontWeight: 700, color: '#5f5b52' }}>
{t('boundaryTitle')}
</summary>
<p style={{ fontSize: 11, color: '#5f5b52', lineHeight: 1.5, margin: '8px 0', ...textWrap }}>
{t('boundaryIntro')}
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(230px, 1fr))', gap: 6 }}>
{[
`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 => (
<code
key={item}
style={{
border: '0.5px solid #ded8c8',
borderRadius: 8,
padding: '6px 8px',
color: '#5f5b52',
fontSize: 11,
lineHeight: 1.4,
background: '#fbfaf6',
overflowWrap: 'anywhere',
}}
>
{item}
</code>
))}
</div>
</details>
</div>
</section>
)
}
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 } }) {
<IwoooSAgentBountySecurityOnboardingBoard />
<IwoooSRolloutRiskReadOnlyBoard />
<IwoooSWazuhIntrusionReadbackBoard />
<IwoooSWazuhLiveRouteReadbackBoard />
<IwoooSWazuhLiveMetadataEnvGateBoard />
<IwoooSSocSiemKaliWazuhIntegrationBoard />
<IwoooSSecurityAssetControlLedgerBoard />

View File

@@ -1,3 +1,29 @@
## 2026-06-25IwoooS 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-25Wazuh release handoff 分層狀態校正
**背景**Wazuh release handoff 仍把「Gitea push」寫成 `0%`,容易讓另一個工作視窗誤會 feature branch 尚未推送。實際狀態是 feature branch 已推送,但 formal main release、production deploy 與 live metadata env enable 仍為 `0%`