diff --git a/apps/api/src/core/config.py b/apps/api/src/core/config.py index 57b1ff3a8..650758ae3 100644 --- a/apps/api/src/core/config.py +++ b/apps/api/src/core/config.py @@ -374,6 +374,27 @@ class Settings(BaseSettings): if not host: raise ValueError(f"OLLAMA URL 缺少 hostname:{v!r}") + try: + parsed_ip = ipaddress.ip_address(host) + except ValueError: + parsed_ip = None + canonical_ip = ( + parsed_ip.ipv4_mapped + if isinstance(parsed_ip, ipaddress.IPv6Address) + and parsed_ip.ipv4_mapped is not None + else parsed_ip + ) + + # Host110 的 Ollama transport/proxy 已退役。私網位址本身符合 SSRF + # allowlist 並不代表仍是合法資產;若舊 ConfigMap/env 漂移回 Host110, + # 必須在任何 health probe、fallback 或 Telegram 告警前 fail closed。 + # Canonical IP comparison also closes IPv4-mapped IPv6 aliases. + if canonical_ip == ipaddress.ip_address("192.168.0.110"): + raise ValueError( + "asset_identity_unresolved: Host110 Ollama transport is retired; " + f"received {v!r}" + ) + # Kubernetes Service hostname 白名單(K8s DNS + 開發別名) _ALLOWED_HOSTNAMES = { "localhost", @@ -397,14 +418,13 @@ class Settings(BaseSettings): return v # 否則必須是 private/loopback IP - try: - ip = ipaddress.ip_address(host) - except ValueError: + if parsed_ip is None: # hostname 不是 IP 也不在白名單 → 拒絕 raise ValueError( f"OLLAMA URL host 不允許的外部域名:{host!r}(完整 URL:{v!r})" ",必須使用私網 IP 或已知 K8s Service hostname" ) from None + ip = parsed_ip if not (ip.is_private or ip.is_loopback): raise ValueError( f"OLLAMA URL 必須是私網/loopback IP、已知 K8s SVC 或 GCP 白名單 IP," diff --git a/apps/api/tests/test_config_url_validation.py b/apps/api/tests/test_config_url_validation.py index 86269675b..a54a2bfc2 100644 --- a/apps/api/tests/test_config_url_validation.py +++ b/apps/api/tests/test_config_url_validation.py @@ -108,6 +108,21 @@ def test_private_ip_192_168_accepted(): assert s.OLLAMA_URL == "http://192.168.0.111:11434" +@pytest.mark.parametrize( + ("field", "url"), + ( + ("OLLAMA_URL", "http://192.168.0.110:11435"), + ("OLLAMA_SECONDARY_URL", "http://192.168.0.110:11436"), + ("OLLAMA_FALLBACK_URL", "http://192.168.0.110:11437"), + ("OLLAMA_URL", "http://[::ffff:192.168.0.110]:11435"), + ), +) +def test_retired_host110_ollama_transport_is_rejected(field: str, url: str): + """Retired Host110 must never re-enter any provider slot via stale env/config.""" + with pytest.raises(ValidationError, match="asset_identity_unresolved"): + _make_settings(**{field: url}) + + def test_private_ip_10_x_accepted(): """10.x.x.x 是 RFC1918 私網 IP,應通過""" s = _make_settings(OLLAMA_URL="http://10.0.0.5:11434") diff --git a/apps/api/tests/test_ollama_endpoint_resolver.py b/apps/api/tests/test_ollama_endpoint_resolver.py index 500a4d107..b2d0169e3 100644 --- a/apps/api/tests/test_ollama_endpoint_resolver.py +++ b/apps/api/tests/test_ollama_endpoint_resolver.py @@ -10,9 +10,9 @@ from src.services.ollama_endpoint_resolver import ( def _settings( *, - primary: str = "http://192.168.0.110:11435", - secondary: str = "http://192.168.0.110:11436", - fallback: str = "http://192.168.0.110:11437", + primary: str = "http://34.143.170.20:11434", + secondary: str = "http://34.21.145.224:11434", + fallback: str = "http://192.168.0.111:11434", ) -> SimpleNamespace: return SimpleNamespace( OLLAMA_URL=primary, @@ -42,7 +42,7 @@ def test_all_workloads_prefer_gcp_a_lane() -> None: "dr", ): selection = resolve_ollama_selection(workload, config=cfg) - assert selection.url == "http://192.168.0.110:11435" + assert selection.url == "http://34.143.170.20:11434" assert selection.provider_name == "ollama_gcp_a" assert selection.reason == "global_primary_gcp_a" @@ -53,9 +53,9 @@ def test_general_workloads_share_global_ollama_order() -> None: for workload in ("interactive", "deep_rca", "local_required", "privacy_sensitive", "dr"): order = resolve_ollama_order(workload, config=cfg) assert [selection.url for selection in order] == [ - "http://192.168.0.110:11435", - "http://192.168.0.110:11436", - "http://192.168.0.110:11437", + "http://34.143.170.20:11434", + "http://34.21.145.224:11434", + "http://192.168.0.111:11434", ] assert [selection.provider_name for selection in order] == [ "ollama_gcp_a", @@ -78,14 +78,14 @@ def test_alert_fast_uses_gcp_b_when_gcp_a_is_not_configured() -> None: selection = resolve_ollama_selection("alert_fast", config=_settings(primary="")) assert selection.provider_name == "ollama_gcp_b" - assert selection.url == "http://192.168.0.110:11436" + assert selection.url == "http://34.21.145.224:11434" def test_non_sensitive_workloads_fall_back_to_gcp_b_when_primary_missing() -> None: cfg = _settings(primary="") selection = resolve_ollama_selection("embedding", config=cfg) - assert selection.url == "http://192.168.0.110:11436" + assert selection.url == "http://34.21.145.224:11434" assert selection.provider_name == "ollama_gcp_b" assert selection.reason == "global_secondary_gcp_b" diff --git a/docs/operations/sre-k3s-controlled-automation-work-items.snapshot.json b/docs/operations/sre-k3s-controlled-automation-work-items.snapshot.json index 414ff80e9..d9512e06f 100644 --- a/docs/operations/sre-k3s-controlled-automation-work-items.snapshot.json +++ b/docs/operations/sre-k3s-controlled-automation-work-items.snapshot.json @@ -335,7 +335,10 @@ "docs/operations/portfolio-infrastructure-asset-reconciliation.snapshot.json", "docs/operations/portfolio-infrastructure-asset-reconciliation-handoff.md", "apps/api/src/services/portfolio_infrastructure_asset_reconciliation.py", + "apps/api/src/core/config.py", "apps/api/src/api/v1/agents.py", + "apps/api/tests/test_config_url_validation.py", + "apps/api/tests/test_ollama_endpoint_resolver.py", "apps/api/tests/test_portfolio_infrastructure_asset_reconciliation_api.py", "scripts/ops/retire-host110-ollama-proxy.sh", "scripts/ops/render-service-registry-configmap.py", @@ -348,16 +351,18 @@ "confirmed_truth": [ "Host110 Ollama has been removed and must remain a retired tombstone, never a fallback endpoint", "AIA-SRE-002 run-aia-sre-002-20260716-1752 removed the exact stale Nginx 11435 proxy with rollback backup and independently verified listeners 11435/11434=0, Ollama containers=0, stale config paths=0, nginx active/test pass and Prometheus host110 Ollama targets=0", + "The Settings asset gate rejects native and IPv4-mapped IPv6 Host110 Ollama URLs as asset_identity_unresolved before any health probe, fallback decision or Telegram alert; focused URL/order/manifest tests pass locally", "Host111 is the third provider hop and runs as a macOS LaunchAgent, not systemd", "GCP-A and GCP-B source targets are public HTTP sanitized candidates only", "gitea-native source target is not production runtime evidence while the legacy GitHub exporter remains visible" ], "runtime_gaps": [ + "The Host110 stale-endpoint asset gate has source and local-test evidence only; production startup/readback has not yet proven the retired endpoint cannot recur", "Host111 LaunchAgent lacks completed local plus host120/host121 origin readback and a fresh exact sensor series", "GCP-A/GCP-B exact Prometheus target series are missing in production", "gitea-native exact target is missing and legacy GitHub exporter runtime drift is unresolved" ], - "next_action": "deploy the reconciled tombstone/targets, preserve the verified Host110 absence receipt, then verify Host111 LaunchAgent from local and host120/121, GCP-A/GCP-B exact Prometheus series, gitea-native target freshness and legacy exporter absence under one source SHA" + "next_action": "deploy the Host110 asset gate with the reconciled tombstone/targets, preserve the verified Host110 absence receipt, then verify Host111 LaunchAgent from local and host120/121, GCP-A/GCP-B exact Prometheus series, gitea-native target freshness and legacy exporter absence under one source SHA" }, { "id": "AIA-SRE-003",