diff --git a/docs/memory/claude_inventory_validation_20260513.md b/docs/memory/claude_inventory_validation_20260513.md index 37c8cd5..bbb400c 100644 --- a/docs/memory/claude_inventory_validation_20260513.md +++ b/docs/memory/claude_inventory_validation_20260513.md @@ -50,6 +50,7 @@ - `tests/test_pg_sync.py` 已改為 opt-in integration test:預設不再連 localhost PostgreSQL 或建立/刪除測試表,需 `RUN_PG_SYNC_INTEGRATION=1` 且提供 `POSTGRES_PASSWORD` 才執行。 - `services/pg_sync_service.py` 是顯式 opt-in legacy CLI,不是生產自動同步路徑;`tests/test_pg_sync_contract.py` 已守住預設 OFF 與 runtime paths 不自動 import。 - `qwen3:14b` 不是未使用 Ollama 模型:OpenClaw QA、NemoTron dispatch 與 LLM model router 仍有現役路徑;`tests/test_qwen3_runtime_usage.py` 已守住,不能只因體積大就三主機移除。 +- Ollama host env 已加白名單護欄:`OLLAMA_HOST*` / `EMBEDDING_HOST` 只接受 GCP-A、GCP-B、111 或 110 proxy,誤設 188/localhost 會回到核准主機。 - `routes/price_comparison_routes.py` 的 MOMO crawler TODO 已接到既有 `services.momo_crawler.search_momo_products()`;未手動上傳 MOMO 商品時會自動抓 MOMO,再交給比價服務。 - Telegram `momo:eig:` callback 已在 `routes/openclaw_bot_routes.py` 與 `services/telegram_bot_service.py` 實作並有 webhook 測試覆蓋,不是未實作缺口。 - Telegram `date_*` / `goal_*` 不是死 callback handler:按鈕先送 `await:*` 進入輸入等待狀態,使用者下一則文字才由 pending action 消費;`tests/test_openclaw_bot_menu_keyboards.py` 與 `tests/test_openclaw_bot_routes_webhook.py` 已覆蓋。 diff --git a/services/ollama_service.py b/services/ollama_service.py index 46a57a3..439d4e2 100644 --- a/services/ollama_service.py +++ b/services/ollama_service.py @@ -14,12 +14,43 @@ from dataclasses import dataclass logger = logging.getLogger(__name__) -# Ollama 設定 - 支援環境變數覆蓋 -OLLAMA_HOST_PRIMARY = os.getenv('OLLAMA_HOST_PRIMARY', 'http://34.143.170.20:11434') -OLLAMA_HOST_SECONDARY = os.getenv('OLLAMA_HOST_SECONDARY', 'http://34.21.145.224:11434') -OLLAMA_HOST_FALLBACK = os.getenv('OLLAMA_HOST_FALLBACK', 'http://192.168.0.111:11434') -# OLLAMA_HOST 優先使用舊環境變數(向下相容),若未設定則以 PRIMARY 為主 -OLLAMA_HOST = os.getenv('OLLAMA_HOST', OLLAMA_HOST_PRIMARY) +APPROVED_OLLAMA_HOST_SUBSTRINGS = ( + '34.143.170.20:11434', # GCP-A / Primary + '34.21.145.224:11434', # GCP-B / Secondary + '192.168.0.111:11434', # 111 / final fallback + '192.168.0.110:11435', # 110 proxy to GCP-A + '192.168.0.110:11436', # 110 proxy to GCP-B +) + + +def is_approved_ollama_host(host: str) -> bool: + """只允許 ADR-028 指定的 Ollama 主機或 110 轉發端口。""" + if not host: + return False + return any(approved in host for approved in APPROVED_OLLAMA_HOST_SUBSTRINGS) + + +def approved_ollama_env(name: str, default: str = '') -> str: + """讀取 Ollama host env,拒絕非 GCP-A/GCP-B/111 的舊值或誤設值。""" + value = os.getenv(name, '').strip() + if not value: + return default + if is_approved_ollama_host(value): + return value + logger.warning( + "[OllamaHost] 忽略未核准的 %s=%s;LLM 只能走 GCP-A/GCP-B/111", + name, + value, + ) + return default + + +# Ollama 設定 - 僅允許 GCP-A → GCP-B → 111 三主機 +OLLAMA_HOST_PRIMARY = approved_ollama_env('OLLAMA_HOST_PRIMARY', 'http://34.143.170.20:11434') +OLLAMA_HOST_SECONDARY = approved_ollama_env('OLLAMA_HOST_SECONDARY', 'http://34.21.145.224:11434') +OLLAMA_HOST_FALLBACK = approved_ollama_env('OLLAMA_HOST_FALLBACK', 'http://192.168.0.111:11434') +# 舊 OLLAMA_HOST 只接受核准主機;否則回到 primary,由 resolve_ollama_host() 管控級聯 +OLLAMA_HOST = approved_ollama_env('OLLAMA_HOST', OLLAMA_HOST_PRIMARY) DEFAULT_MODEL = os.getenv('OLLAMA_MODEL', 'llama3.1:8b') # 較快速的模型 TIMEOUT = int(os.getenv('OLLAMA_TIMEOUT', '120')) # 秒 - 2 分鐘 COPY_TIMEOUT = int(os.getenv('OLLAMA_COPY_TIMEOUT', '180')) # 文案生成專用超時 - 3 分鐘 @@ -768,7 +799,7 @@ class OllamaService: # HOTFIX 三主機 retry 鏈(與 generate() 同模式) attempted_hosts: List[str] = [] for attempt in range(3): - target_host = (os.getenv("EMBEDDING_HOST") or resolve_ollama_host()).rstrip("/") + target_host = (approved_ollama_env("EMBEDDING_HOST") or resolve_ollama_host()).rstrip("/") if target_host in attempted_hosts: break # cache 還沒過期或同主機,避免無限迴圈 attempted_hosts.append(target_host) diff --git a/tests/test_ollama_resolve.py b/tests/test_ollama_resolve.py index 72e1a11..d4abfda 100644 --- a/tests/test_ollama_resolve.py +++ b/tests/test_ollama_resolve.py @@ -171,11 +171,21 @@ def test_mark_unhealthy_ignores_empty(): # B1/B2 — config lazy getters # ═══════════════════════════════════════════════════════════════════════════ -def test_get_ollama_host_uses_env_when_set(monkeypatch): - monkeypatch.setenv('OLLAMA_HOST', 'http://override.example:11434') +def test_get_ollama_host_uses_approved_env_when_set(monkeypatch): + monkeypatch.setenv('OLLAMA_HOST', 'http://34.21.145.224:11434') import config importlib.reload(config) # 確保 env 變更生效 - assert config.get_ollama_host() == 'http://override.example:11434' + assert config.get_ollama_host() == 'http://34.21.145.224:11434' + + +def test_get_ollama_host_rejects_unapproved_env(monkeypatch): + monkeypatch.setenv('OLLAMA_HOST', 'http://192.168.0.188:11434') + fake_resp = MagicMock(status_code=200) + with patch('services.ollama_service.requests.get', return_value=fake_resp): + import config + importlib.reload(config) + host = config.get_ollama_host() + assert host == 'http://34.143.170.20:11434' def test_get_ollama_host_falls_back_to_resolve_without_env(monkeypatch): @@ -191,17 +201,17 @@ def test_get_ollama_host_falls_back_to_resolve_without_env(monkeypatch): def test_get_embedding_host_prefers_env(monkeypatch): - monkeypatch.setenv('EMBEDDING_HOST', 'http://embed.example:11434') + monkeypatch.setenv('EMBEDDING_HOST', 'http://192.168.0.111:11434') import config importlib.reload(config) - assert config.get_embedding_host() == 'http://embed.example:11434' + assert config.get_embedding_host() == 'http://192.168.0.111:11434' def test_get_hermes_url_prefers_env(monkeypatch): - monkeypatch.setenv('HERMES_URL', 'http://hermes.example:11434') + monkeypatch.setenv('HERMES_URL', 'http://34.143.170.20:11434') import config importlib.reload(config) - assert config.get_hermes_url() == 'http://hermes.example:11434' + assert config.get_hermes_url() == 'http://34.143.170.20:11434' # ═══════════════════════════════════════════════════════════════════════════