From 85159f25a0e7775909db5340c0387502e5da4435 Mon Sep 17 00:00:00 2001 From: ogt Date: Sat, 11 Jul 2026 01:42:00 +0800 Subject: [PATCH] fix(security): restrict executor SSH egress --- .gitea/workflows/cd.yaml | 141 +++++++++++++++++- apps/api/src/main.py | 2 +- .../awoooi_priority_work_order_readback.py | 16 +- .../executor_trust_boundary_readback.py | 8 +- ...awoooi_priority_work_order_readback_api.py | 4 +- .../test_executor_network_policy_boundary.py | 77 ++++++++++ .../test_executor_trust_boundary_readback.py | 8 +- ..._signal_worker_ansible_executor_binding.py | 5 +- k8s/awoooi-prod/02-network-policy.yaml | 65 ++++---- 9 files changed, 284 insertions(+), 42 deletions(-) create mode 100644 apps/api/tests/test_executor_network_policy_boundary.py diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index e5a4f46d7..6dd6f378a 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -2186,7 +2186,30 @@ jobs: export IMAGE_TAG="${{ github.sha }}" HARBOR=192.168.0.110:5000 - # ─── Step 1: Apply ConfigMap + ServiceRegistry (ArgoCD 管的是 Deployment,ConfigMap 仍直接 apply) ─── + # ─── Step 1: Apply NetworkPolicy + ConfigMap + ServiceRegistry ─── + # NetworkPolicy is intentionally outside kustomize because commonLabels + # mutates nested selectors. Keep a source rollback artifact until the + # live socket and public receipt verifiers have both passed. + NETWORK_POLICY_ROLLBACK_FILE="/tmp/awoooi-network-policy-rollback.yaml" + git show HEAD^:k8s/awoooi-prod/02-network-policy.yaml \ + > "$NETWORK_POLICY_ROLLBACK_FILE" + restore_network_policy() { + echo "⏪ restoring previous AWOOOI NetworkPolicy contract" + cat "$NETWORK_POLICY_ROLLBACK_FILE" | \ + ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \ + "KUBECTL='sudo kubectl --kubeconfig=/etc/rancher/k3s/k3s.yaml --server=${{ env.K8S_API_SERVER }}'; \$KUBECTL apply -f -" + } + cat k8s/awoooi-prod/02-network-policy.yaml | \ + ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \ + "KUBECTL='sudo kubectl --kubeconfig=/etc/rancher/k3s/k3s.yaml --server=${{ env.K8S_API_SERVER }}'; \$KUBECTL apply --dry-run=server -f - >/dev/null" + if ! cat k8s/awoooi-prod/02-network-policy.yaml | \ + ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \ + "KUBECTL='sudo kubectl --kubeconfig=/etc/rancher/k3s/k3s.yaml --server=${{ env.K8S_API_SERVER }}'; \$KUBECTL apply -f -"; then + restore_network_policy + exit 1 + fi + echo "✅ NetworkPolicy 已受控更新" + cat k8s/awoooi-prod/04-configmap.yaml | \ ssh $SSH_OPTS "wooo@${{ env.K8S_SSH_HOST }}" \ "KUBECTL='sudo kubectl --kubeconfig=/etc/rancher/k3s/k3s.yaml --server=${{ env.K8S_API_SERVER }}'; \$KUBECTL apply -f -" @@ -2483,6 +2506,25 @@ jobs: BROKER_CAN_DELETE=$($KUBECTL auth can-i \ --as=system:serviceaccount:awoooi-prod:awoooi-executor-broker \ delete pods --all-namespaces) + COMMON_SSH_PORT_COUNT=$($KUBECTL get networkpolicy \ + allow-required-egress -n awoooi-prod \ + -o jsonpath='{range .spec.egress[*].ports[*]}{.port}{"\n"}{end}' \ + | grep -c '^22$' || true) + BROKER_SSH_SELECTOR=$($KUBECTL get networkpolicy \ + allow-ansible-executor-broker-ssh-egress -n awoooi-prod \ + -o jsonpath='{.spec.podSelector.matchLabels.app}') + BROKER_SSH_COMPONENT=$($KUBECTL get networkpolicy \ + allow-ansible-executor-broker-ssh-egress -n awoooi-prod \ + -o jsonpath='{.spec.podSelector.matchLabels.component}') + BROKER_SSH_PORTS=$($KUBECTL get networkpolicy \ + allow-ansible-executor-broker-ssh-egress -n awoooi-prod \ + -o jsonpath='{range .spec.egress[*].ports[*]}{.port}{"\n"}{end}') + BROKER_SSH_CIDRS=$($KUBECTL get networkpolicy \ + allow-ansible-executor-broker-ssh-egress -n awoooi-prod \ + -o jsonpath='{range .spec.egress[*].to[*]}{.ipBlock.cidr}{"\n"}{end}') + LEGACY_EGRESS_RULE_COUNT=$($KUBECTL get networkpolicy \ + deny-legacy-access -n awoooi-prod -o json \ + | python3 -c 'import json,sys; print(len(json.load(sys.stdin)["spec"].get("egress") or []))') test "$API_SA" = "awoooi-api" || { echo "❌ API ServiceAccount drift: $API_SA"; exit 1; @@ -2546,11 +2588,88 @@ jobs: echo "❌ legacy cluster-wide executor binding still exists" exit 1 fi + test "$COMMON_SSH_PORT_COUNT" = "0" || { + echo "❌ common workload egress still grants SSH"; exit 1; + } + test "$BROKER_SSH_SELECTOR" = "awoooi-ansible-executor-broker" || { + echo "❌ broker SSH policy selector drift: $BROKER_SSH_SELECTOR"; exit 1; + } + test "$BROKER_SSH_COMPONENT" = "execution-broker" || { + echo "❌ broker SSH component selector drift: $BROKER_SSH_COMPONENT"; exit 1; + } + test "$BROKER_SSH_PORTS" = "22" || { + echo "❌ broker SSH policy contains unexpected ports: $BROKER_SSH_PORTS"; exit 1; + } + for expected_cidr in \ + 192.168.0.110/32 192.168.0.120/32 \ + 192.168.0.121/32 192.168.0.188/32; do + printf '%s\n' "$BROKER_SSH_CIDRS" | grep -qx "$expected_cidr" || { + echo "❌ broker SSH allowlist missing $expected_cidr"; exit 1; + } + done + test "$(printf '%s\n' "$BROKER_SSH_CIDRS" | sed '/^$/d' | wc -l)" = "4" || { + echo "❌ broker SSH allowlist has unexpected destinations"; exit 1; + } + test "$LEGACY_EGRESS_RULE_COUNT" = "0" || { + echo "❌ legacy namespace policy still creates an allow rule"; exit 1; + } + + verify_ssh_denied() { + workload="$1" + container="$2" + $KUBECTL exec -i "deployment/$workload" -n awoooi-prod \ + -c "$container" -- python - \ + 192.168.0.110 192.168.0.120 192.168.0.121 192.168.0.188 <<'PY' + import socket + import sys + + reachable = [] + for host in sys.argv[1:]: + try: + connection = socket.create_connection((host, 22), timeout=0.75) + except OSError: + continue + else: + connection.close() + reachable.append(host) + print("unexpected_ssh_reachable=" + ",".join(reachable)) + raise SystemExit(1 if reachable else 0) + PY + } + verify_broker_ssh_allowed() { + $KUBECTL exec -i deployment/awoooi-ansible-executor-broker \ + -n awoooi-prod -c broker -- python - \ + 192.168.0.110 192.168.0.120 192.168.0.121 192.168.0.188 <<'PY' + import socket + import sys + + reachable = [] + for host in sys.argv[1:]: + try: + connection = socket.create_connection((host, 22), timeout=1.0) + except OSError: + continue + else: + connection.close() + reachable.append(host) + print("broker_ssh_reachable=" + ",".join(reachable)) + raise SystemExit(0 if reachable else 1) + PY + } + verify_ssh_denied awoooi-api api || { + echo "❌ API can still establish host SSH connections"; exit 1; + } + verify_ssh_denied awoooi-worker worker || { + echo "❌ signal worker can still establish host SSH connections"; exit 1; + } + verify_broker_ssh_allowed || { + echo "❌ execution broker cannot reach any allowlisted SSH endpoint"; exit 1; + } BOUNDARY_VERIFIED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ") $KUBECTL create configmap awoooi-executor-boundary-verification \ -n awoooi-prod \ - --from-literal=schema_version=awoooi_executor_boundary_verification_v1 \ + --from-literal=schema_version=awoooi_executor_boundary_verification_v2 \ --from-literal=verified_source_sha="$SOURCE_REVISION" \ --from-literal=verified_at="$BOUNDARY_VERIFIED_AT" \ --from-literal=workflow_run_id="$WORKFLOW_RUN_ID" \ @@ -2565,6 +2684,10 @@ jobs: --from-literal=broker_kubernetes_token_absent=true \ --from-literal=broker_ssh_mount_present=true \ --from-literal=legacy_executor_binding_absent=true \ + --from-literal=api_ssh_egress_denied=true \ + --from-literal=worker_ssh_egress_denied=true \ + --from-literal=broker_ssh_egress_allowlisted=true \ + --from-literal=legacy_namespace_egress_allow_absent=true \ --dry-run=client -o yaml \ | $KUBECTL apply -f - $KUBECTL annotate configmap \ @@ -2635,10 +2758,24 @@ jobs: "executor boundary public readback failed: " + last_status ) PY + echo "AWOOOI_SECURITY_BOUNDARY_VERIFIED=1" ARGOCD_WAIT ROLLOUT_EXIT=${PIPESTATUS[0]} set -e + SECURITY_BOUNDARY_VERIFIED="0" + if grep -q '^AWOOOI_SECURITY_BOUNDARY_VERIFIED=1$' "$ROLLOUT_LOG"; then + SECURITY_BOUNDARY_VERIFIED="1" + fi + if [ "$SECURITY_BOUNDARY_VERIFIED" != "1" ]; then + restore_network_policy + echo "❌ executor security boundary did not reach its terminal verifier" + if [ "$ROLLOUT_EXIT" -eq 0 ]; then + exit 1 + fi + exit "$ROLLOUT_EXIT" + fi + ROLLOUT_RISK="0" ROLLOUT_SUMMARY="" if grep -q '^AWOOOI_ROLLOUT_RISK=1$' "$ROLLOUT_LOG"; then diff --git a/apps/api/src/main.py b/apps/api/src/main.py index 316abe65a..9e203f95e 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -665,7 +665,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: "awooop_ansible_check_mode_worker_schedule_evaluated", enabled=settings.ENABLE_AWOOOP_ANSIBLE_CHECK_MODE_WORKER, scheduled_in_api_process=scheduled_task is not None, - production_process_owner="awoooi-worker", + production_process_owner="awoooi-ansible-executor-broker", interval_seconds=settings.AWOOOP_ANSIBLE_CHECK_MODE_INTERVAL_SECONDS, ) except Exception as e: diff --git a/apps/api/src/services/awoooi_priority_work_order_readback.py b/apps/api/src/services/awoooi_priority_work_order_readback.py index d79c076ea..950312e8a 100644 --- a/apps/api/src/services/awoooi_priority_work_order_readback.py +++ b/apps/api/src/services/awoooi_priority_work_order_readback.py @@ -158,14 +158,18 @@ _AI_AUTOMATION_CODEBASE_REVIEW = { "findings": [ { "id": "ACR-P0-001", - "severity": "critical", - "title": "Public API and worker share cluster-wide executor identity and SSH mounts", + "severity": "high", + "title": "Executor identity split landed; egress, DB identity, and successful canary closure remain", "source_refs": [ + "k8s/awoooi-prod/02-network-policy.yaml", "k8s/awoooi-prod/06-deployment-api.yaml", "k8s/awoooi-prod/07-rbac.yaml", + "k8s/awoooi-prod/08-deployment-ansible-executor-broker.yaml", "k8s/awoooi-prod/08-deployment-worker.yaml", ], "mapped_work_item_ids": ["AIA-P0-011"], + "source_remediation_state": "partial", + "next_action": "deploy broker-only SSH egress, split workload DB identities, and close one allowlisted returncode=0 capability lifecycle", }, { "id": "ACR-P0-002", @@ -545,9 +549,9 @@ _AI_AUTOMATION_PROGRAM_WORK_ITEMS: list[dict[str, Any]] = [ "id": "AIA-P0-011", "priority": "P0", "order": 2, - "status": "pending", + "status": "in_progress", "title": "收斂 API、Worker 與 executor 的最小權限信任邊界", - "problem": "public API 與 worker 共用 cluster-wide executor ServiceAccount、workload patch/delete 權限及 SSH key mounts,任一 API compromise 都可擴大為叢集與主機 side effect。", + "problem": "API/Worker 的 RBAC 與 SSH mount 已拆分並由 production receipt 證明;剩餘缺口是 SSH egress policy、每 workload DB role/secret 隔離,以及首條成功 capability lifecycle 尚未同 run 關閉。", "professional_practice": "zero trust workload identity、least privilege RBAC、namespace-scoped broker、capability allowlist、short-lived credentials", "asset_scope_ids": ["hosts", "services", "products", "tools", "observability_and_security"], "acceptance": [ @@ -555,8 +559,10 @@ _AI_AUTOMATION_PROGRAM_WORK_ITEMS: list[dict[str, Any]] = [ "worker 只經 namespace-scoped broker 與 action allowlist 執行,不能持有 cluster-wide raw capability", "每次 capability issuance、apply、expiry 與 revoke 共用 run_id 並具 durable receipt", "RBAC 與 mount verifier 在 production 證明 API 無 executor 權限、worker 無越界權限", + "API/Worker 到 host:22 的 socket verifier 必須 denied,只有 broker 可連 allowlisted SSH endpoints", + "API、Worker 與 broker 使用分離的 DB identity/secret projection 與可驗證 connection budget", ], - "next_action": "先拆分 API/Worker ServiceAccount 與 mounts,再把 worker raw SSH capability 遷到 allowlisted execution broker。", + "next_action": "部署 broker-only SSH NetworkPolicy 與 v2 public verifier;再修復 allowlisted canary 的 returncode=2,完成同 run capability issue/check/apply/verify/revoke,並拆分 workload DB role/secret。", }, { "id": "AIA-P0-002", diff --git a/apps/api/src/services/executor_trust_boundary_readback.py b/apps/api/src/services/executor_trust_boundary_readback.py index 63e09f0ed..d9d0fa604 100644 --- a/apps/api/src/services/executor_trust_boundary_readback.py +++ b/apps/api/src/services/executor_trust_boundary_readback.py @@ -13,8 +13,8 @@ import structlog logger = structlog.get_logger(__name__) -SCHEMA_VERSION = "awoooi_executor_trust_boundary_readback_v1" -RECEIPT_SCHEMA_VERSION = "awoooi_executor_boundary_verification_v1" +SCHEMA_VERSION = "awoooi_executor_trust_boundary_readback_v2" +RECEIPT_SCHEMA_VERSION = "awoooi_executor_boundary_verification_v2" DEFAULT_NAMESPACE = "awoooi-prod" DEFAULT_CONFIGMAP_NAME = "awoooi-executor-boundary-verification" _CACHE_TTL_SECONDS = 20.0 @@ -27,6 +27,10 @@ _REQUIRED_TRUE_FIELDS = ( "broker_kubernetes_token_absent", "broker_ssh_mount_present", "legacy_executor_binding_absent", + "api_ssh_egress_denied", + "worker_ssh_egress_denied", + "broker_ssh_egress_allowlisted", + "legacy_namespace_egress_allow_absent", ) _cache: tuple[float, dict[str, Any]] | None = None diff --git a/apps/api/tests/test_awoooi_priority_work_order_readback_api.py b/apps/api/tests/test_awoooi_priority_work_order_readback_api.py index 271d85fa9..01d11cd13 100644 --- a/apps/api/tests/test_awoooi_priority_work_order_readback_api.py +++ b/apps/api/tests/test_awoooi_priority_work_order_readback_api.py @@ -982,7 +982,7 @@ def test_ai_automation_program_ledger_is_authoritative_and_runtime_strict(): for item_id in runtime_critical_ids ) assert work_item_by_id["AIA-P0-011"]["order"] == 2 - assert work_item_by_id["AIA-P0-011"]["status"] == "pending" + assert work_item_by_id["AIA-P0-011"]["status"] == "in_progress" assert work_item_by_id["AIA-P0-002"]["order"] == 3 assert work_item_by_id["AIA-P0-006"]["order"] == 7 assert work_item_by_id["AIA-P0-008"]["order"] == 8 @@ -1003,7 +1003,7 @@ def test_ai_automation_program_ledger_is_authoritative_and_runtime_strict(): "broad_exception_handler_count" ] == 1371 assert program_summary["codebase_review_finding_count"] == 8 - assert program_summary["codebase_review_critical_finding_count"] == 3 + assert program_summary["codebase_review_critical_finding_count"] == 2 tenant_finding = next( row for row in program["codebase_review"]["findings"] diff --git a/apps/api/tests/test_executor_network_policy_boundary.py b/apps/api/tests/test_executor_network_policy_boundary.py new file mode 100644 index 000000000..29f772255 --- /dev/null +++ b/apps/api/tests/test_executor_network_policy_boundary.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml + + +def _network_policies() -> dict[str, dict[str, Any]]: + repo_root = Path(__file__).resolve().parents[3] + path = repo_root / "k8s/awoooi-prod/02-network-policy.yaml" + return { + document["metadata"]["name"]: document + for document in yaml.safe_load_all(path.read_text()) + if document + } + + +def _ports(policy: dict[str, Any]) -> set[int]: + return { + int(port["port"]) + for rule in policy["spec"].get("egress", []) + for port in rule.get("ports", []) + } + + +def test_common_egress_does_not_grant_ssh() -> None: + policies = _network_policies() + + common = policies["allow-required-egress"] + assert common["spec"]["podSelector"] == { + "matchLabels": {"system": "awoooi"} + } + assert 22 not in _ports(common) + + +def test_only_execution_broker_receives_allowlisted_ssh_egress() -> None: + policies = _network_policies() + + broker = policies["allow-ansible-executor-broker-ssh-egress"] + assert broker["spec"]["podSelector"] == { + "matchLabels": { + "app": "awoooi-ansible-executor-broker", + "component": "execution-broker", + } + } + assert _ports(broker) == {22} + assert { + target["ipBlock"]["cidr"] + for rule in broker["spec"]["egress"] + for target in rule["to"] + } == { + "192.168.0.110/32", + "192.168.0.120/32", + "192.168.0.121/32", + "192.168.0.188/32", + } + + +def test_legacy_marker_does_not_accidentally_allow_egress() -> None: + legacy = _network_policies()["deny-legacy-access"] + + assert legacy["spec"]["policyTypes"] == ["Egress"] + assert legacy["spec"]["egress"] == [] + + +def test_cd_applies_rolls_back_and_verifies_network_boundary() -> None: + repo_root = Path(__file__).resolve().parents[3] + workflow = (repo_root / ".gitea/workflows/cd.yaml").read_text() + + assert "02-network-policy.yaml" in workflow + assert "NETWORK_POLICY_ROLLBACK_FILE" in workflow + assert "allow-ansible-executor-broker-ssh-egress" in workflow + assert "api_ssh_egress_denied=true" in workflow + assert "worker_ssh_egress_denied=true" in workflow + assert "broker_ssh_egress_allowlisted=true" in workflow + assert "legacy_namespace_egress_allow_absent=true" in workflow diff --git a/apps/api/tests/test_executor_trust_boundary_readback.py b/apps/api/tests/test_executor_trust_boundary_readback.py index e57cc38ff..09a56e4a4 100644 --- a/apps/api/tests/test_executor_trust_boundary_readback.py +++ b/apps/api/tests/test_executor_trust_boundary_readback.py @@ -21,6 +21,10 @@ def _verified_receipt(source_sha: str = "abc123") -> dict[str, str]: "broker_kubernetes_token_absent": "true", "broker_ssh_mount_present": "true", "legacy_executor_binding_absent": "true", + "api_ssh_egress_denied": "true", + "worker_ssh_egress_denied": "true", + "broker_ssh_egress_allowlisted": "true", + "legacy_namespace_egress_allow_absent": "true", } @@ -46,10 +50,10 @@ def test_executor_trust_boundary_rejects_stale_or_incomplete_receipt() -> None: assert "verified_source_sha_mismatch" in stale["active_blockers"] incomplete_receipt = _verified_receipt() - incomplete_receipt["worker_ssh_mount_absent"] = "false" + incomplete_receipt["worker_ssh_egress_denied"] = "false" incomplete = build_executor_trust_boundary_readback( incomplete_receipt, deployed_source_sha="abc123", ) assert incomplete["production_boundary_verified"] is False - assert "worker_ssh_mount_absent_not_verified" in incomplete["active_blockers"] + assert "worker_ssh_egress_denied_not_verified" in incomplete["active_blockers"] diff --git a/apps/api/tests/test_signal_worker_ansible_executor_binding.py b/apps/api/tests/test_signal_worker_ansible_executor_binding.py index b6a544000..685b98533 100644 --- a/apps/api/tests/test_signal_worker_ansible_executor_binding.py +++ b/apps/api/tests/test_signal_worker_ansible_executor_binding.py @@ -154,7 +154,7 @@ def test_cd_prunes_and_verifies_api_executor_boundary_in_production() -> None: assert "repair-ssh-key|repair-known-hosts|ssh-mcp-key" in workflow assert "legacy cluster-wide executor binding still exists" in workflow assert "awoooi-executor-boundary-verification" in workflow - assert "awoooi_executor_boundary_verification_v1" in workflow + assert "awoooi_executor_boundary_verification_v2" in workflow assert "executor_boundary_public_readback=verified_ready" in workflow assert "verified_source_sha" in workflow assert "argocd.argoproj.io/sync-options=Prune=false" in workflow @@ -196,4 +196,7 @@ def test_api_log_does_not_claim_skipped_executor_was_scheduled() -> None: assert "awooop_ansible_check_mode_worker_schedule_evaluated" in main_source assert "scheduled_in_api_process=scheduled_task is not None" in main_source assert 'production_process_owner="awoooi-worker"' in main_source + assert ( + 'production_process_owner="awoooi-ansible-executor-broker"' in main_source + ) assert "awooop_ansible_candidate_backfill_worker_schedule_evaluated" in main_source diff --git a/k8s/awoooi-prod/02-network-policy.yaml b/k8s/awoooi-prod/02-network-policy.yaml index 2836aa5bc..38884fcc0 100644 --- a/k8s/awoooi-prod/02-network-policy.yaml +++ b/k8s/awoooi-prod/02-network-policy.yaml @@ -1,8 +1,9 @@ # AWOOOI 正式環境零信任網路策略 # 負責人: CIO -# 版本: v1.7 -# 日期: 2026-05-03 +# 版本: v1.8 +# 日期: 2026-07-11 # 變更: +# - v1.8: SSH egress 僅允許 dedicated Ansible executor broker;修正 legacy allow 規則 # - v1.7: ADR-110 — 新增 GCP-A/GCP-B Ollama egress(GCP 三層容災) # - v1.6: 新增 K3s node 120/121 SSH egress,供 SSH MCP 主機診斷/修復 # - v1.5: 新增 keepalived VIP 192.168.0.125/32 ArgoCD NodePort 30443 egress(修復 heartbeat probe) @@ -92,10 +93,6 @@ spec: - ipBlock: cidr: 192.168.0.188/32 ports: - # SSH — repair-bot-188.sh 自動修復 (momo-app/tsenyang/signoz/minio/openclaw) - # (2026-04-09 Claude Sonnet 4.6 Asia/Taipei, docker-188 修復路徑) - - protocol: TCP - port: 22 # PostgreSQL (Host 直裝) - protocol: TCP port: 5432 @@ -152,10 +149,6 @@ spec: # Langfuse LLMOps (Phase 15.1) - protocol: TCP port: 3100 - # SSH — repair-bot-110.sh 自動修復執行路徑 - # ADR-062: SSH_COMMAND Playbook 需要 K8s Pod → 110:22 的 egress - - protocol: TCP - port: 22 # Grafana — monitoring probe + 監控工具卡片 - protocol: TCP port: 3002 @@ -193,7 +186,7 @@ spec: - protocol: TCP port: 8787 - # 允許訪問 K8s API + K3s master SSH (Executor 執行 kubectl/host diagnosis) + # 允許訪問 K8s API 與 K3s control-plane services # 2026-03-23 修復: Y 按鈕執行超時 # 重要: ClusterIP (10.43.0.1:443) 會路由到實際端點 (192.168.0.120:6443) # 必須同時允許兩者,否則流量會被 192.168.0.0/16 排除規則阻擋 @@ -205,11 +198,8 @@ spec: port: 443 - to: - ipBlock: - cidr: 192.168.0.120/32 # K3s Master 實際 API Server 端點 + ArgoCD NodePort + SSH MCP + cidr: 192.168.0.120/32 # K3s Master 實際 API Server 端點 + ArgoCD NodePort ports: - # SSH MCP — K3s master host diagnosis/repair path - - protocol: TCP - port: 22 - protocol: TCP port: 6443 # ArgoCD MCP NodePort (2026-04-11): ClusterIP DNAT 跨 namespace 不穩定,改用 NodePort @@ -249,9 +239,6 @@ spec: - ipBlock: cidr: 192.168.0.121/32 ports: - # SSH MCP — K3s worker host diagnosis/repair path - - protocol: TCP - port: 22 - protocol: TCP port: 6443 - protocol: TCP @@ -296,7 +283,38 @@ spec: port: 443 --- -# 4. 明確禁止訪問 Legacy Namespace +# 4. 只有 dedicated broker 可以連到 allowlisted host SSH endpoints。 +# 其他 workload 即使帶 system=awoooi,也不會從 allow-required-egress 取得 port 22。 +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-ansible-executor-broker-ssh-egress + namespace: awoooi-prod +spec: + podSelector: + matchLabels: + app: awoooi-ansible-executor-broker + component: execution-broker + policyTypes: + - Egress + egress: + - to: + - ipBlock: + cidr: 192.168.0.110/32 + - ipBlock: + cidr: 192.168.0.120/32 + - ipBlock: + cidr: 192.168.0.121/32 + - ipBlock: + cidr: 192.168.0.188/32 + ports: + - protocol: TCP + port: 22 + +--- +# 5. Legacy namespace 沒有 allow 規則,因此由 default-deny 保持封鎖。 +# Kubernetes NetworkPolicy 是 additive allow model;帶 namespaceSelector 且省略 +# ports 會允許全部連線,不能用來表達 deny。 apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: @@ -306,11 +324,4 @@ spec: podSelector: {} policyTypes: - Egress - egress: - # 這條規則會被上面的 allow 規則覆蓋 - # 但明確表示禁止訪問 Legacy - - to: - - namespaceSelector: - matchLabels: - name: wooo-aiops - # 沒有 ports = 全部拒絕 (但此規則實際上不會生效因為 default-deny) + egress: []