fix(web+k8s): CSRF mismatch + NetworkPolicy 缺少監控端口
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 12m19s

1. pending-approvals-card: 改為點擊時即時 fetch 新 CSRF token
   避免多 useCSRF 實例互相覆蓋 cookie 導致 header/cookie 不一致
2. NetworkPolicy: 補開 110:3002(Grafana) 9090(Prometheus) 3001(Gitea)
   修正 monitoring probe "All connection attempts failed"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-09 20:11:00 +08:00
parent 21567a7a6d
commit c200d7a52d
2 changed files with 40 additions and 15 deletions

View File

@@ -10,11 +10,21 @@ import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { useRouter } from 'next/navigation'
import { useLocale } from 'next-intl'
import { useCSRF } from '@/hooks/useCSRF'
import { CURRENT_USER } from '@/lib/constants/user'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
// 2026-04-09 Claude Sonnet 4.6: 每次操作前直接 fetch 新 CSRF token
// 避免多個 useCSRF 實例互相覆蓋 cookie導致 header token 和 cookie 不一致
async function fetchFreshCsrfHeaders(): Promise<Record<string, string>> {
try {
const r = await fetch(`${API_BASE}/api/v1/csrf/token`, { method: 'GET', credentials: 'include' })
if (!r.ok) return {}
const d = await r.json()
return d.token ? { 'X-CSRF-Token': d.token } : {}
} catch { return {} }
}
interface Approval {
id: string
action: string
@@ -35,7 +45,6 @@ export function PendingApprovalsCard() {
const t = useTranslations('dashboard')
const router = useRouter()
const locale = useLocale()
const { getHeaders } = useCSRF()
const [approvals, setApprovals] = useState<Approval[]>([])
const [actionError, setActionError] = useState<string | null>(null)
const [actioningId, setActioningId] = useState<string | null>(null)
@@ -83,27 +92,33 @@ export function PendingApprovalsCard() {
<div style={{ display: 'flex', gap: 4, marginTop: 5 }}>
<button
disabled={actioningId === ap.id}
onClick={() => {
onClick={async () => {
setActionError(null)
setActioningId(ap.id)
fetch(`${API_BASE}/api/v1/approvals/${ap.id}/sign`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', ...getHeaders() }, body: JSON.stringify({ signer_id: CURRENT_USER.id, signer_name: CURRENT_USER.name }) })
.then(r => { if (!r.ok) throw new Error(`${r.status}`); return r })
.then(() => setApprovals(prev => prev.filter(x => x.id !== ap.id)))
.catch(e => setActionError(`approve failed: ${e.message}`))
.finally(() => setActioningId(null))
try {
const csrfHeaders = await fetchFreshCsrfHeaders()
const r = await fetch(`${API_BASE}/api/v1/approvals/${ap.id}/sign`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', ...csrfHeaders }, body: JSON.stringify({ signer_id: CURRENT_USER.id, signer_name: CURRENT_USER.name }) })
if (!r.ok) throw new Error(`${r.status}`)
setApprovals(prev => prev.filter(x => x.id !== ap.id))
} catch (e: unknown) {
setActionError(`approve failed: ${e instanceof Error ? e.message : String(e)}`)
} finally { setActioningId(null) }
}}
style={{ flex: 1, padding: 6, border: 'none', borderRadius: 5, fontSize: 11, fontWeight: 600, cursor: actioningId === ap.id ? 'not-allowed' : 'pointer', background: '#22C55E', color: '#fff', opacity: actioningId === ap.id ? 0.6 : 1 }}
>{actioningId === ap.id ? '...' : t('approve')}</button>
<button
disabled={actioningId === ap.id}
onClick={() => {
onClick={async () => {
setActionError(null)
setActioningId(ap.id)
fetch(`${API_BASE}/api/v1/approvals/${ap.id}/reject`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', ...getHeaders() }, body: JSON.stringify({ reason: 'rejected-from-web' }) })
.then(r => { if (!r.ok) throw new Error(`${r.status}`); return r })
.then(() => setApprovals(prev => prev.filter(x => x.id !== ap.id)))
.catch(e => setActionError(`reject failed: ${e.message}`))
.finally(() => setActioningId(null))
try {
const csrfHeaders = await fetchFreshCsrfHeaders()
const r = await fetch(`${API_BASE}/api/v1/approvals/${ap.id}/reject`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', ...csrfHeaders }, body: JSON.stringify({ reason: 'rejected-from-web' }) })
if (!r.ok) throw new Error(`${r.status}`)
setApprovals(prev => prev.filter(x => x.id !== ap.id))
} catch (e: unknown) {
setActionError(`reject failed: ${e instanceof Error ? e.message : String(e)}`)
} finally { setActioningId(null) }
}}
style={{ flex: 1, padding: 6, border: '0.5px solid #e0ddd4', borderRadius: 5, fontSize: 11, cursor: actioningId === ap.id ? 'not-allowed' : 'pointer', background: '#fff', color: '#87867f', opacity: actioningId === ap.id ? 0.6 : 1 }}
>{t('reject')}</button>

View File

@@ -125,10 +125,11 @@ spec:
- protocol: TCP
port: 11434
# 允許訪問 192.168.0.110 DevOps 金庫 (Harbor + Sentry + Langfuse + SSH Repair)
# 允許訪問 192.168.0.110 DevOps 金庫 (Harbor + Sentry + Langfuse + SSH Repair + Monitoring)
# 2026-03-24 新增: Sentry Self-Hosted
# 2026-03-26 新增: Langfuse LLMOps (Phase 15.1)
# 2026-04-09 新增: SSH port 22 — repair-bot-110.sh 自動修復 (Bug #11 修正)
# 2026-04-09 新增: Grafana(3002) + Prometheus(9090) + Gitea(3001) — monitoring probe
- to:
- ipBlock:
cidr: 192.168.0.110/32
@@ -146,6 +147,15 @@ spec:
# ADR-062: SSH_COMMAND Playbook 需要 K8s Pod → 110:22 的 egress
- protocol: TCP
port: 22
# Grafana — monitoring probe + 監控工具卡片
- protocol: TCP
port: 3002
# Prometheus — monitoring probe + 監控工具卡片
- protocol: TCP
port: 9090
# Gitea — CI/CD 主倉 probe + monitoring
- protocol: TCP
port: 3001
# 允許訪問 192.168.0.112 安全掃描服務
- to: