diff --git a/.gitignore b/.gitignore index d158b6e3..ac9c6330 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,8 @@ ENV/ .env.* .env.local .env.*.local +!.env.example +!apps/**/.env.example *.pem *.key secrets/ @@ -68,6 +70,11 @@ Thumbs.db *-secret.yaml *-secrets.yaml +# SQLite(HARD_RULES 禁止,必須用 PostgreSQL) +*.db +*.sqlite +*.sqlite3 + # 暫存檔案 tmp/ temp/ @@ -84,3 +91,4 @@ tsconfig.tsbuildinfo .superpowers/ .aider* !.aiderignore +.claude/settings.local.json diff --git a/apps/api/awoooi.db b/apps/api/awoooi.db deleted file mode 100644 index 8cd09999..00000000 Binary files a/apps/api/awoooi.db and /dev/null differ diff --git a/apps/api/scripts/run_migration.py b/apps/api/scripts/run_migration.py index 44a95914..5314eb6e 100644 --- a/apps/api/scripts/run_migration.py +++ b/apps/api/scripts/run_migration.py @@ -9,12 +9,14 @@ Phase 18 AuditLog Migration Script """ import asyncio +import os from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine -# 數據庫連接 -DATABASE_URL = "postgresql+asyncpg://awoooi:changeme@192.168.0.188:5432/awoooi_prod" +# 2026-04-22 ogt: 移除硬碼 changeme,改為讀取環境變數(強制要求設定)。 +# 執行前: export DATABASE_URL="postgresql+asyncpg://awoooi:@192.168.0.188:5432/awoooi_prod" +DATABASE_URL = os.environ["DATABASE_URL"] MIGRATION_SQLS = [ # 1. authorization_channel diff --git a/apps/api/src/api/v1/auto_repair.py b/apps/api/src/api/v1/auto_repair.py index 46b55781..980f5e72 100644 --- a/apps/api/src/api/v1/auto_repair.py +++ b/apps/api/src/api/v1/auto_repair.py @@ -16,6 +16,8 @@ Phase 8.2: API Router 實作 from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel, Field +from src.core.csrf import CSRFToken # Phase 20: CSRF Protection + from src.services.auto_repair_service import ( get_auto_repair_service, ) @@ -106,7 +108,7 @@ async def evaluate_auto_repair(incident_id: str) -> EvaluateResponse: @router.post("/execute", response_model=ExecuteResponse) -async def execute_auto_repair(request: ExecuteRequest) -> ExecuteResponse: +async def execute_auto_repair(request: ExecuteRequest, _csrf_token: CSRFToken) -> ExecuteResponse: # Phase 20: CSRF Protection (驗證用,不需要使用值) """ 執行自動修復 diff --git a/apps/api/src/api/v1/drift.py b/apps/api/src/api/v1/drift.py index 71b0d9f5..b5954f20 100644 --- a/apps/api/src/api/v1/drift.py +++ b/apps/api/src/api/v1/drift.py @@ -15,6 +15,8 @@ leWOOOgo 積木化原則: from fastapi import APIRouter, BackgroundTasks, HTTPException +from src.core.csrf import CSRFToken # Phase 20: CSRF Protection + from src.models.drift import ( DriftListResponse, DriftReport, @@ -95,7 +97,7 @@ async def list_drift_reports() -> DriftListResponse: @router.post("/reports/{report_id}/rollback", summary="覆蓋回 Git 狀態") -async def rollback_drift(report_id: str) -> dict: +async def rollback_drift(report_id: str, _csrf_token: CSRFToken) -> dict: # Phase 20: CSRF Protection (驗證用,不需要使用值) """ 將 K8s 狀態覆蓋回 Git YAML(kubectl apply) @@ -112,7 +114,7 @@ async def rollback_drift(report_id: str) -> dict: @router.post("/reports/{report_id}/adopt", summary="承認變更並建立 Git PR") -async def adopt_drift(report_id: str) -> dict: +async def adopt_drift(report_id: str, _csrf_token: CSRFToken) -> dict: # Phase 20: CSRF Protection (驗證用,不需要使用值) """ 承認 K8s 漂移,透過 Gitea PR API 將漂移寫回 Git diff --git a/apps/api/src/api/v1/terminal.py b/apps/api/src/api/v1/terminal.py index 7d343687..ed56fe48 100644 --- a/apps/api/src/api/v1/terminal.py +++ b/apps/api/src/api/v1/terminal.py @@ -34,6 +34,7 @@ from src.models.terminal import ( TerminalIntentResponse, TerminalStatusResponse, ) +from src.core.csrf import CSRFToken # Phase 20: CSRF Protection from src.services.terminal_service import TerminalService, get_terminal_service # Type alias for dependency injection @@ -58,6 +59,7 @@ logger = get_logger("awoooi.terminal.router") async def submit_intent( request: TerminalIntentRequest, service: TerminalServiceDep, + _csrf_token: CSRFToken, # Phase 20: CSRF Protection (驗證用,不需要使用值) ) -> TerminalIntentResponse: """ 提交意圖請求 diff --git a/apps/api/src/core/config.py b/apps/api/src/core/config.py index dcd0afbc..d5f47fea 100644 --- a/apps/api/src/core/config.py +++ b/apps/api/src/core/config.py @@ -168,9 +168,10 @@ class Settings(BaseSettings): # ========================================================================== # Database (PostgreSQL on 192.168.0.188) # ========================================================================== + # 2026-04-22 ogt: 移除含 changeme 的 default,改為必填。 + # 來源: K8s Secret awoooi-secrets → DATABASE_URL DATABASE_URL: str = Field( - default="postgresql+asyncpg://awoooi:changeme@192.168.0.188:5432/awoooi_prod", - description="PostgreSQL connection URL", + description="PostgreSQL connection URL (必填,從 K8s Secret awoooi-secrets → DATABASE_URL 取得)", ) # ========================================================================== diff --git a/apps/api/tests/integration/setup_test_schema.sql b/apps/api/tests/integration/setup_test_schema.sql index 9afdf465..bff76201 100644 --- a/apps/api/tests/integration/setup_test_schema.sql +++ b/apps/api/tests/integration/setup_test_schema.sql @@ -104,3 +104,18 @@ CREATE TABLE IF NOT EXISTS rag_chunks ( metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); + +-- adr091: aider_events schema (2026-04-22 @ Asia/Taipei, 補入 integration test schema) +CREATE TABLE IF NOT EXISTS aider_events ( + id BIGSERIAL PRIMARY KEY, + session_id TEXT NOT NULL, + ts TIMESTAMPTZ NOT NULL, + type TEXT NOT NULL, + host TEXT DEFAULT 'ogt-mac', + payload JSONB NOT NULL, + incident_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS aider_events_session_idx ON aider_events(session_id); +CREATE INDEX IF NOT EXISTS aider_events_type_ts_idx ON aider_events(type, ts DESC); +CREATE INDEX IF NOT EXISTS aider_events_ts_idx ON aider_events(ts DESC); diff --git a/apps/api/tests/integration/test_ai_router_feedback_integration.py b/apps/api/tests/integration/test_ai_router_feedback_integration.py new file mode 100644 index 00000000..5ee34493 --- /dev/null +++ b/apps/api/tests/integration/test_ai_router_feedback_integration.py @@ -0,0 +1,149 @@ +# tests/integration/test_ai_router_feedback_integration.py | 2026-04-22 @ Asia/Taipei +"""AIRouter.feedback_from_aider_events() 整合測試 — 使用真實 awoooi_dev PostgreSQL + +替換 tests/test_ai_router_feedback.py 中違反 feedback_no_mock_testing.md 的 +FakeRepo / FakeSession mock。 + +AIRouter.feedback_from_aider_events() 本質是聚合查詢 — 直接用真實 DB 驗證 +比 mock DB 更準確,且能抓到 SQL 語法錯誤。 + +規則: 每個測試後 rollback(由 integration/conftest.py db_session fixture 保證) +禁止 Mock Repository / Session — 直接使用真實 DB 連線。 +""" +from __future__ import annotations + +from datetime import datetime, timezone, timedelta + +import pytest +from sqlalchemy import text + +from src.repositories.aider_event_repository import AiderEventRepository + +TAIPEI = timezone(timedelta(hours=8)) + + +def _ts(offset_days: int = 0) -> datetime: + return datetime.now(TAIPEI) - timedelta(days=offset_days) + + +async def _insert_session(db_session, session_id: str, model: str, + repo_cwd: str, has_error: bool = False) -> None: + """插入一組 session_start + (可選) error event。""" + repo = AiderEventRepository(db_session) + await repo.insert( + session_id=session_id, + ts=_ts(), + type_="session_start", + host="ogt-mac", + payload={"cwd": repo_cwd, "model": model, + "aider_args": [], "aider_pid": 1, "cli_version": "0.86"}, + ) + if has_error: + await repo.insert( + session_id=session_id, + ts=_ts(), + type_="error", + host="ogt-mac", + payload={"cwd": repo_cwd, "model": model, + "kind": "api_rate_limit", "message": "429", + "context_50chars": ""}, + ) + + +# ============================================================================= +# model_stats_since() 聚合正確性 +# ============================================================================= + +class TestModelStatsAggregation: + + @pytest.mark.asyncio + async def test_empty_db_returns_empty_list(self, db_session): + """無資料時 model_stats_since 應回傳空 list(不崩潰)。""" + repo = AiderEventRepository(db_session) + result = await repo.model_stats_since(days=1) + # 可能有其他 session 留存(dev DB),但至少型別正確 + assert isinstance(result, list) + + @pytest.mark.asyncio + async def test_inserted_sessions_appear_in_stats(self, db_session): + """插入 2 筆 session(1 成功 1 失敗),stats 應正確回傳。""" + await _insert_session(db_session, "s-ok-001", "elephant-alpha", + "/awoooi", has_error=False) + await _insert_session(db_session, "s-err-001", "elephant-alpha", + "/awoooi", has_error=True) + await db_session.flush() + + repo = AiderEventRepository(db_session) + result = await repo.model_stats_since(days=1) + + # 找出我們插入的 model + elephant_rows = [r for r in result + if r.get("model") == "elephant-alpha" + and r.get("repo") is not None + and "/awoooi" in (r.get("repo") or "")] + assert len(elephant_rows) >= 1 + + row = elephant_rows[0] + assert row["total"] >= 2 + assert 0.0 <= float(row["success_rate"]) <= 1.0 + + @pytest.mark.asyncio + async def test_success_rate_field_is_float(self, db_session): + """success_rate 欄位必須可轉換為 float(AIRouter 依賴此保證)。""" + await _insert_session(db_session, "s-float-001", "gemini-pro", + "/clawbot", has_error=False) + await db_session.flush() + + repo = AiderEventRepository(db_session) + result = await repo.model_stats_since(days=1) + for row in result: + # 不應 raise + _ = float(row.get("success_rate") or 0) + + @pytest.mark.asyncio + async def test_repo_filter_works(self, db_session): + """插入兩個不同 cwd 的 session,手動 filter by repo 應只回傳對應資料。""" + await _insert_session(db_session, "s-awoooi-001", "elephant-alpha", + "/awoooi", has_error=False) + await _insert_session(db_session, "s-other-001", "elephant-alpha", + "/other-repo", has_error=True) + await db_session.flush() + + repo = AiderEventRepository(db_session) + all_stats = await repo.model_stats_since(days=1) + + # 手動過濾(模擬 AIRouter.feedback_from_aider_events(repo="awoooi")) + awoooi_rows = [r for r in all_stats if "/awoooi" in (r.get("repo") or "")] + other_rows = [r for r in all_stats if "/other-repo" in (r.get("repo") or "")] + + # 若兩筆都有資料,它們的 success_rate 應該不同(一個 1.0,一個 0.0) + # 這裡只確認 filter 邏輯本身不混淆 + for row in awoooi_rows: + assert "/other-repo" not in (row.get("repo") or "") + for row in other_rows: + assert "/awoooi" not in (row.get("repo") or "") + + +# ============================================================================= +# AIRouter.feedback_from_aider_events() error handling(不 mock Session) +# ============================================================================= + +class TestAIRouterFeedbackDBBehavior: + """驗證 feedback_from_aider_events() 不崩潰即可(透過 model_stats_since 間接測試)。""" + + @pytest.mark.asyncio + async def test_model_stats_since_does_not_raise_on_empty(self, db_session): + """空 DB 時聚合查詢不應拋例外。""" + repo = AiderEventRepository(db_session) + try: + result = await repo.model_stats_since(days=7) + assert isinstance(result, list) + except Exception as e: + pytest.fail(f"model_stats_since raised unexpectedly: {e}") + + @pytest.mark.asyncio + async def test_daily_pattern_candidates_no_error(self, db_session): + """daily_pattern_candidates() 不應崩潰。""" + repo = AiderEventRepository(db_session) + result = await repo.daily_pattern_candidates(days=1) + assert isinstance(result, list) diff --git a/apps/api/tests/integration/test_aider_event_repository.py b/apps/api/tests/integration/test_aider_event_repository.py new file mode 100644 index 00000000..6e5d4ed0 --- /dev/null +++ b/apps/api/tests/integration/test_aider_event_repository.py @@ -0,0 +1,165 @@ +# tests/integration/test_aider_event_repository.py | 2026-04-22 @ Asia/Taipei +"""AiderEventRepository 整合測試 — 使用真實 awoooi_dev PostgreSQL + +替換 tests/test_aider_event_processor.py 中違反 feedback_no_mock_testing.md 的 +FakeRepo / FakeSession mock。 + +原測試 (test_aider_event_processor.py) 驗證的是 AiderEventProcessor._process_one() +的整體流程(parse → incident → DB write → ACK),其中: +- FakeRepo / FakeSession → 此整合測試改用真實 DB 驗證 insert 行為 +- fake_r.xack (Redis) → Redis 屬外部 broker,仍可在 unit test 中 mock(符合「外部 API」例外) +- fake_engine (IncidentEngine) → AI 推斷服務,屬外部呼叫,仍可 mock + +此檔案: 只測「DB 層」— AiderEventRepository.insert() + model_stats_since() +其餘路由邏輯已在 test_aider_event_processor.py 的 parser/ACK 部份覆蓋。 + +規則: 每個測試後 rollback(由 integration/conftest.py db_session fixture 保證) +禁止 Mock — 直接使用真實 DB 連線。 +""" +from __future__ import annotations + +from datetime import datetime, timezone, timedelta + +import pytest + +from src.repositories.aider_event_repository import AiderEventRepository + +TAIPEI = timezone(timedelta(hours=8)) + + +def _ts() -> datetime: + return datetime.now(TAIPEI) + + +# ============================================================================= +# insert() 基本寫入 +# ============================================================================= + +class TestAiderEventRepositoryInsert: + """驗證 insert() 正確寫入 aider_events 表。""" + + @pytest.mark.asyncio + async def test_insert_error_event_returns_id(self, db_session): + """error event 插入後應回傳有效 BIGSERIAL id。""" + repo = AiderEventRepository(db_session) + row_id = await repo.insert( + session_id="s-test-001", + ts=_ts(), + type_="error", + host="ogt-mac", + payload={"cwd": "/r", "model": "elephant-alpha", + "kind": "api_rate_limit", "message": "429", + "context_50chars": ""}, + ) + assert isinstance(row_id, int) + assert row_id > 0 + + @pytest.mark.asyncio + async def test_insert_session_start_with_incident_id(self, db_session): + """insert 可附帶 incident_id(nullable FK)。""" + repo = AiderEventRepository(db_session) + row_id = await repo.insert( + session_id="s-test-002", + ts=_ts(), + type_="session_start", + host="ogt-mac", + payload={"cwd": "/r", "model": "elephant-alpha", + "aider_args": [], "aider_pid": 1, "cli_version": "0.86"}, + incident_id="INC-20260422-0001", + ) + assert row_id > 0 + + @pytest.mark.asyncio + async def test_insert_without_incident_id(self, db_session): + """incident_id 可為 None(常見情境)。""" + repo = AiderEventRepository(db_session) + row_id = await repo.insert( + session_id="s-test-003", + ts=_ts(), + type_="commit", + host="ogt-mac", + payload={"cwd": "/r", "model": "gemini-pro", + "commit_hash": "abc123", "message": "fix: something"}, + ) + assert row_id > 0 + + +# ============================================================================= +# count_by_session() +# ============================================================================= + +class TestAiderEventRepositoryCount: + + @pytest.mark.asyncio + async def test_count_returns_correct_value(self, db_session): + """插入 3 筆相同 session_id,count 應回傳 3。""" + repo = AiderEventRepository(db_session) + sid = "s-count-test-001" + for i in range(3): + await repo.insert( + session_id=sid, + ts=_ts(), + type_="error", + host="ogt-mac", + payload={"kind": "test", "message": f"err-{i}", + "model": "m", "cwd": "/r", "context_50chars": ""}, + ) + count = await repo.count_by_session(sid) + assert count == 3 + + @pytest.mark.asyncio + async def test_count_unknown_session_is_zero(self, db_session): + """不存在的 session_id 應回傳 0。""" + repo = AiderEventRepository(db_session) + count = await repo.count_by_session("nonexistent-session-xyz") + assert count == 0 + + +# ============================================================================= +# model_stats_since() — AI Router feedback 聚合查詢 +# ============================================================================= + +class TestAiderEventRepositoryModelStats: + + @pytest.mark.asyncio + async def test_model_stats_returns_list(self, db_session): + """model_stats_since() 應回傳 list(即使空)。""" + repo = AiderEventRepository(db_session) + result = await repo.model_stats_since(days=1) + assert isinstance(result, list) + + @pytest.mark.asyncio + async def test_model_stats_aggregates_correctly(self, db_session): + """插入 session_start + error,stats 應正確統計 error_rate。 + + 注意:model_stats_since 聚合邏輯依賴 session_start/session_end payload.model 欄位, + 且需要多筆 session 才能統計。此測試驗證:不崩潰、回傳正確型別。 + """ + repo = AiderEventRepository(db_session) + ts_now = _ts() + + # 插入一個 session 含 session_start(提供 model 資訊)+ 一筆 error + sid = "s-stats-test-001" + await repo.insert( + session_id=sid, ts=ts_now, + type_="session_start", host="ogt-mac", + payload={"cwd": "/awoooi", "model": "elephant-alpha", + "aider_args": [], "aider_pid": 1, "cli_version": "0.86"}, + ) + await repo.insert( + session_id=sid, ts=ts_now, + type_="error", host="ogt-mac", + payload={"cwd": "/awoooi", "model": "elephant-alpha", + "kind": "api_rate_limit", "message": "429", + "context_50chars": ""}, + ) + await db_session.flush() # flush 使 SQL CTE 能看到資料(不 commit) + + result = await repo.model_stats_since(days=1) + assert isinstance(result, list) + # 若有回傳結果,驗證欄位格式正確 + for row in result: + assert "model" in row + assert "total" in row + assert "errors" in row + assert "success_rate" in row diff --git a/apps/api/tests/test_ai_router_feedback.py b/apps/api/tests/test_ai_router_feedback.py index 3586c322..a97f6ef1 100644 --- a/apps/api/tests/test_ai_router_feedback.py +++ b/apps/api/tests/test_ai_router_feedback.py @@ -1,5 +1,11 @@ # apps/api/tests/test_ai_router_feedback.py | 2026-04-20 @ Asia/Taipei -"""Task A8: AIRouter.feedback_from_aider_events read-only aggregation test.""" +# 2026-04-22 @ Asia/Taipei: FakeRepo / FakeSession 違反 feedback_no_mock_testing.md +# → DB 聚合查詢測試已遷移至 integration/test_ai_router_feedback_integration.py(真實 DB) +# 此檔案保留的測試驗證「DB 不可用時的降級行為」(fail_sf) — 此為錯誤路徑邏輯, +# 非正常 DB 查詢,可留作 unit 層覆蓋。 +# FakeRepo 測試(test_feedback_aggregates_by_model 等)已被 integration test 取代, +# 下方保留作參考,但實際 DB 行為請以 integration test 為準。 +"""Task A8: AIRouter.feedback_from_aider_events — 降級行為 + 邊界條件測試。""" import pytest from unittest.mock import AsyncMock, MagicMock from src.services.ai_router import AIRouter diff --git a/apps/api/tests/test_aider_event_processor.py b/apps/api/tests/test_aider_event_processor.py index 675e65cc..0423f12e 100644 --- a/apps/api/tests/test_aider_event_processor.py +++ b/apps/api/tests/test_aider_event_processor.py @@ -1,5 +1,11 @@ # test_aider_event_processor | 2026-04-20 @ Asia/Taipei -"""Unit tests for AiderEventProcessor.""" +# 2026-04-22 @ Asia/Taipei: DB/Redis mock 違反 feedback_no_mock_testing.md +# - FakeRepo / FakeSession → 已遷移至 integration/test_aider_event_repository.py(真實 DB) +# - fake_r (Redis xack) → 屬外部 broker,保留 mock 符合「外部 API 例外」 +# - fake_engine (IncidentEngine) → 屬外部 AI 呼叫,保留 mock 符合「外部 API 例外」 +# 此檔案保留 _process_one 的 parse / ACK / incident routing 邏輯測試, +# DB 寫入行為已由 integration test 覆蓋。 +"""Unit tests for AiderEventProcessor — parse/ACK/incident routing 邏輯。""" import pytest import json from datetime import datetime, timezone, timedelta diff --git a/apps/api/tests/test_terminal.py b/apps/api/tests/test_terminal.py index 3cd35eb3..b69641df 100644 --- a/apps/api/tests/test_terminal.py +++ b/apps/api/tests/test_terminal.py @@ -27,6 +27,7 @@ from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from src.api.v1.terminal import router +from src.core.csrf import verify_csrf_token from src.models.terminal import TerminalSessionStatus from src.services.terminal_service import TerminalService, get_terminal_service @@ -50,6 +51,7 @@ async def _get_test_service() -> TerminalService: _test_app = FastAPI() _test_app.include_router(router, prefix="/api/v1") _test_app.dependency_overrides[get_terminal_service] = _get_test_service +_test_app.dependency_overrides[verify_csrf_token] = lambda: "test-bypass" # tests have no browser session @pytest.fixture(autouse=True) diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 00000000..315423a9 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,35 @@ +# ============================================================================= +# apps/web — Environment Variables +# 複製此檔案為 .env.local 並填入實際值 +# 生成日期: 2026-04-22 Claude Code +# ============================================================================= + +# ---------------------------------------------------------------------------- +# 必填 (REQUIRED) +# ---------------------------------------------------------------------------- + +# API 後端 URL(Next.js build-time 寫入 JS bundle,禁止使用內網 IP) +NEXT_PUBLIC_API_URL=http://192.168.0.188:32334 + +# ---------------------------------------------------------------------------- +# 可選 (OPTIONAL) +# ---------------------------------------------------------------------------- + +# 是否啟用 Demo 模式(true/false) +NEXT_PUBLIC_ENABLE_DEMO=false + +# SignOz 可觀測性平台 URL +NEXT_PUBLIC_SIGNOZ_URL=http://192.168.0.110:3301 + +# 主機 IP 列表(逗號分隔,live-dashboard 用於 fallback 顯示) +NEXT_PUBLIC_HOST_IPS=192.168.0.110,192.168.0.112,192.168.0.120,192.168.0.188 + +# K8s Cluster VIP 資訊字串(host-grid 顯示用) +NEXT_PUBLIC_K8S_VIP_INFO=VIP 192.168.0.125 · kubectl :6443 · Web :32335 · API :32334 + +# ---------------------------------------------------------------------------- +# Server-side Only(不含 NEXT_PUBLIC_ 前綴,不會暴露在 JS bundle) +# ---------------------------------------------------------------------------- + +# Sentry 自建主機 URL(sentry-tunnel route handler 使用) +SENTRY_HOST=http://192.168.0.110:9000 diff --git a/apps/web/src/app/api/sentry-tunnel/route.ts b/apps/web/src/app/api/sentry-tunnel/route.ts index 15ea237e..29efe8c3 100644 --- a/apps/web/src/app/api/sentry-tunnel/route.ts +++ b/apps/web/src/app/api/sentry-tunnel/route.ts @@ -17,7 +17,9 @@ import { type NextRequest, NextResponse } from 'next/server'; // Sentry Self-Hosted 內網地址 -const SENTRY_HOST = 'http://192.168.0.110:9000'; +// 2026-04-22 ogt: 改為讀 env var,避免內網 IP 硬碼進 bundle。 +// K8s: awoooi-secrets → SENTRY_HOST;本機 dev fallback 維持原值不中斷。 +const SENTRY_HOST = process.env.SENTRY_HOST ?? 'http://192.168.0.110:9000'; // 允許的 Project IDs (防止濫用) const ALLOWED_PROJECT_IDS = new Set(['2', '3']); // awoooi-web: 2, awoooi-api: 3 diff --git a/apps/web/src/components/dashboard/live-dashboard.tsx b/apps/web/src/components/dashboard/live-dashboard.tsx index 89c1d071..ad54e2ff 100644 --- a/apps/web/src/components/dashboard/live-dashboard.tsx +++ b/apps/web/src/components/dashboard/live-dashboard.tsx @@ -46,7 +46,7 @@ const _getApiBaseUrl = () => { return url } -const HOST_IPS = ['192.168.0.110', '192.168.0.112', '192.168.0.120', '192.168.0.188'] +const HOST_IPS = (process.env.NEXT_PUBLIC_HOST_IPS ?? '192.168.0.110,192.168.0.112,192.168.0.120,192.168.0.188').split(',') // ============================================================================= // Component diff --git a/apps/web/src/components/infra/host-grid.tsx b/apps/web/src/components/infra/host-grid.tsx index 0356460a..57d3fa7a 100644 --- a/apps/web/src/components/infra/host-grid.tsx +++ b/apps/web/src/components/infra/host-grid.tsx @@ -150,7 +150,7 @@ export function HostGrid({ hosts }: HostGridProps) { ☸ K3S CLUSTER (HA) - VIP 192.168.0.125 · kubectl :6443 · Web :32335 · API :32334 + {process.env.NEXT_PUBLIC_K8S_VIP_INFO ?? 'VIP 192.168.0.125 · kubectl :6443 · Web :32335 · API :32334'}
diff --git a/apps/web/src/components/panels/AutoRepairPanel.tsx b/apps/web/src/components/panels/AutoRepairPanel.tsx index 9788781b..a8c5a1b2 100644 --- a/apps/web/src/components/panels/AutoRepairPanel.tsx +++ b/apps/web/src/components/panels/AutoRepairPanel.tsx @@ -137,9 +137,14 @@ function IncidentEvalRow({ setExecuting(true) try { const base = getApiBaseUrl() + // Phase 20: CSRF Protection — 先取得 token 再執行 + const csrfRes = await fetch(`${base}/api/v1/csrf/token`, { method: 'GET', credentials: 'include' }) + if (!csrfRes.ok) throw new Error(`CSRF fetch failed: ${csrfRes.status}`) + const csrfData = await csrfRes.json() + const csrfHeaders: Record = csrfData.token ? { 'X-CSRF-Token': String(csrfData.token) } : {} const res = await fetch(`${base}/api/v1/auto-repair/execute`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...csrfHeaders }, body: JSON.stringify({ incident_id: incidentId, playbook_id: eval_.playbook_id }), }) if (res.ok) setResult(await res.json()) diff --git a/apps/web/src/stores/terminal.store.ts b/apps/web/src/stores/terminal.store.ts index 9bc1c062..abbd407a 100644 --- a/apps/web/src/stores/terminal.store.ts +++ b/apps/web/src/stores/terminal.store.ts @@ -340,10 +340,16 @@ export const useTerminalStore = create((set, get) => ({ set({ _abortController: abortController }) // Step 1: POST intent 到後端建立 session + // Phase 20: CSRF Protection — 先取得 token 再提交 + const csrfRes = await fetch(`${API_BASE_URL}/api/v1/csrf/token`, { method: 'GET', credentials: 'include' }) + if (!csrfRes.ok) throw new Error(`CSRF fetch failed: ${csrfRes.status}`) + const csrfData = await csrfRes.json() + const csrfHeaders: Record = csrfData.token ? { 'X-CSRF-Token': String(csrfData.token) } : {} const response = await fetch(`${API_BASE_URL}/api/v1/terminal/intent`, { method: 'POST', headers: { 'Content-Type': 'application/json', + ...csrfHeaders, }, body: JSON.stringify({ intent: text, diff --git a/k8s/awoooi-prod/03-secrets.yaml b/k8s/awoooi-prod/03-secrets.yaml deleted file mode 100644 index 67fb56b3..00000000 --- a/k8s/awoooi-prod/03-secrets.yaml +++ /dev/null @@ -1,64 +0,0 @@ -# AWOOOI 正式環境 Secrets 模板 -# 負責人: CIO / CISO -# 版本: v1.0 -# 日期: 2026-03-20 -# -# ⚠️ 注意: 此檔案為模板,實際值由 CI/CD 或手動注入 -# 實際 Secret 值不應提交到 Git - -apiVersion: v1 -kind: Secret -metadata: - name: awoooi-secrets - namespace: awoooi-prod -type: Opaque -stringData: - # 資料庫連線 (實際值請替換) - # 重要: 必須使用 +asyncpg 驅動 (2026-03-28 K-HA 遷移確認) - DATABASE_URL: "postgresql+asyncpg://awoooi:CHANGE_ME@192.168.0.188:5432/awoooi_prod" - - # Redis 連線 - REDIS_URL: "redis://192.168.0.188:6380/10" - - # JWT 認證 - JWT_SECRET: "CHANGE_ME_TO_RANDOM_STRING" - JWT_ALGORITHM: "HS256" - - # AI 服務 (雲端備援) - ADR-006 v1.3 + ADR-036 - GEMINI_API_KEY: "CHANGE_ME" - CLAUDE_API_KEY: "CHANGE_ME" - # 2026-03-29 ogt: ADR-036 Nemotron Tool Calling (83% 精準度) - NVIDIA_API_KEY: "CHANGE_ME" - - # 通知服務 - SMTP_HOST: "smtp.example.com" - SMTP_USER: "CHANGE_ME" - SMTP_PASSWORD: "CHANGE_ME" - - # Phase 5.5: Telegram Gateway (OpenClaw) - OPENCLAW_TG_BOT_TOKEN: "CHANGE_ME" - OPENCLAW_TG_CHAT_ID: "CHANGE_ME" - OPENCLAW_TG_USER_WHITELIST: "CHANGE_ME" - - # 2026-04-03 ogt: SRE 戰情室群組三頭政治 (Triumvirate ADR-053) - # 實際值由 CD 注入 (kubectl patch secret),此處為佔位 - OPENCLAW_BOT_TOKEN: "CHANGE_ME" - NEMOTRON_BOT_TOKEN: "CHANGE_ME" - - # Webhook 安全 (CISO 要求) - WEBHOOK_HMAC_SECRET: "CHANGE_ME_TO_RANDOM_64_CHARS" - # ADR-059: Gitea Webhook → AWOOOI API 簽章驗證 (X-Gitea-Signature) - # 2026-04-05 Claude Code: GitHub → Gitea 遷移,新增此 Secret - # 實際值由 CD 注入 (kubectl patch secret),此處為佔位 - GITEA_WEBHOOK_SECRET: "CHANGE_ME" - - # ============================================================================ - # Phase 10: Sentry Self-Hosted (192.168.0.110:9000) - # 2026-03-27: 首席架構師審查 - 補齊遺漏配置 - # DSN 格式: http://{public_key}@{host}:{port}/{project_id} - # ============================================================================ - SENTRY_DSN: "CHANGE_ME" - # 2026-03-29 ogt: ADR-037 - Comment 回寫需要 Auth Token - # 取得方式: Sentry UI → Settings → Auth Tokens → Create New Token - # 權限: event:admin, project:read, project:write - SENTRY_AUTH_TOKEN: "CHANGE_ME" diff --git a/k8s/awoooi-prod/05-deployment-web.yaml b/k8s/awoooi-prod/05-deployment-web.yaml index baf76a63..3b504791 100644 --- a/k8s/awoooi-prod/05-deployment-web.yaml +++ b/k8s/awoooi-prod/05-deployment-web.yaml @@ -48,6 +48,13 @@ spec: # 正式域名 (必須 https) - name: NEXT_PUBLIC_API_URL value: "https://awoooi.wooo.work" + # 2026-04-22 ogt: 移除前端硬碼 IP,改由 K8s 注入 + - name: NEXT_PUBLIC_HOST_IPS + value: "192.168.0.110,192.168.0.112,192.168.0.120,192.168.0.188" + - name: NEXT_PUBLIC_K8S_VIP_INFO + value: "VIP 192.168.0.125 · kubectl :6443 · Web :32335 · API :32334" + - name: SENTRY_HOST + value: "http://192.168.0.110:9000" envFrom: - configMapRef: name: awoooi-config