|
| 1 | +# mTLS datapath checks for Kind E2E. Sourced from kind.sh when the mtls scenario |
| 2 | +# is selected. Requires test-mode setup (managed mcp-runtime-ca issuer). |
| 3 | + |
| 4 | +MTLS_SERVER_NAME="${MTLS_SERVER_NAME:-mtls-mcp-server}" |
| 5 | +MTLS_TRUST_DOMAIN="${MTLS_TRUST_DOMAIN:-cluster.local}" |
| 6 | +TRAEFIK_TLS_PORT="${TRAEFIK_TLS_PORT:-18443}" |
| 7 | +MTLS_AGENT_ID="${MTLS_AGENT_ID:-e2e-mtls-agent}" |
| 8 | + |
| 9 | +run_e2e_mtls_scenario() { |
| 10 | + log_line mtls "verifying test-mode workload PKI" |
| 11 | + if ! kubectl get clusterissuer mcp-runtime-ca >/dev/null 2>&1; then |
| 12 | + echo "expected ClusterIssuer mcp-runtime-ca from test-mode setup" >&2 |
| 13 | + exit 1 |
| 14 | + fi |
| 15 | + kubectl wait --for=condition=Ready clusterissuer/mcp-runtime-ca --timeout=120s >/dev/null |
| 16 | + |
| 17 | + operator_issuer="$(kubectl get deploy/mcp-runtime-operator-controller-manager -n mcp-runtime \ |
| 18 | + -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="MCP_MTLS_CLUSTER_ISSUER")].value}')" |
| 19 | + if [[ "${operator_issuer}" != "mcp-runtime-ca" ]]; then |
| 20 | + echo "operator MCP_MTLS_CLUSTER_ISSUER = ${operator_issuer:-<empty>}, want mcp-runtime-ca" >&2 |
| 21 | + exit 1 |
| 22 | + fi |
| 23 | + |
| 24 | + runtime_issuer="$(kubectl get deploy/mcp-runtime-api -n mcp-sentinel \ |
| 25 | + -o jsonpath='{.spec.template.spec.containers[?(@.name=="runtime-api")].env[?(@.name=="MCP_MTLS_CLUSTER_ISSUER")].value}' 2>/dev/null || true)" |
| 26 | + if [[ -n "${runtime_issuer}" && "${runtime_issuer}" != "mcp-runtime-ca" ]]; then |
| 27 | + echo "runtime-api MCP_MTLS_CLUSTER_ISSUER = ${runtime_issuer}, want mcp-runtime-ca" >&2 |
| 28 | + exit 1 |
| 29 | + fi |
| 30 | + |
| 31 | + log_line mtls "deploying auth.mode mtls MCPServer ${MTLS_SERVER_NAME}" |
| 32 | + MTLS_METADATA="${WORKDIR}/mtls-metadata.yaml" |
| 33 | + MTLS_MANIFEST="${WORKDIR}/mtls-manifest.yaml" |
| 34 | + MTLS_SECRET_NAME="${MTLS_SERVER_NAME}-analytics-creds" |
| 35 | + MTLS_IMAGE="registry.registry.svc.cluster.local:5000/${MTLS_SERVER_NAME}:${E2E_WORKLOAD_TAG}" |
| 36 | + kubectl create secret generic "${MTLS_SECRET_NAME}" \ |
| 37 | + -n mcp-servers \ |
| 38 | + --from-literal=api-key="${INGEST_API_KEY}" \ |
| 39 | + --dry-run=client -o yaml | kubectl apply -f - |
| 40 | + cat > "${MTLS_METADATA}" <<EOF |
| 41 | +version: v1 |
| 42 | +servers: |
| 43 | + - name: ${MTLS_SERVER_NAME} |
| 44 | + image: ${MTLS_IMAGE%:*} |
| 45 | + imageTag: ${MTLS_IMAGE##*:} |
| 46 | + route: /${MTLS_SERVER_NAME}/mcp |
| 47 | + publicPathPrefix: ${MTLS_SERVER_NAME} |
| 48 | + port: 8090 |
| 49 | + namespace: mcp-servers |
| 50 | + resources: |
| 51 | + requests: |
| 52 | + cpu: 1m |
| 53 | + memory: 32Mi |
| 54 | + envVars: |
| 55 | + - name: PORT |
| 56 | + value: "8090" |
| 57 | + - name: MCP_PATH |
| 58 | + value: "/${MTLS_SERVER_NAME}/mcp" |
| 59 | + tools: |
| 60 | + - name: aaa-ping |
| 61 | + requiredTrust: low |
| 62 | + sideEffect: read |
| 63 | + auth: |
| 64 | + mode: mtls |
| 65 | + trustDomain: ${MTLS_TRUST_DOMAIN} |
| 66 | + policy: |
| 67 | + mode: allow-list |
| 68 | + defaultDecision: deny |
| 69 | + policyVersion: v1 |
| 70 | + session: |
| 71 | + required: true |
| 72 | + gateway: |
| 73 | + enabled: true |
| 74 | + resources: |
| 75 | + requests: |
| 76 | + cpu: 1m |
| 77 | + memory: 32Mi |
| 78 | + analytics: |
| 79 | + ingestURL: "http://mcp-sentinel-ingest.mcp-sentinel.svc.cluster.local:8081/events" |
| 80 | + apiKeySecretRef: |
| 81 | + name: ${MTLS_SECRET_NAME} |
| 82 | + key: api-key |
| 83 | +EOF |
| 84 | + |
| 85 | + if ! docker image inspect "${MTLS_IMAGE}" >/dev/null 2>&1; then |
| 86 | + run_logged_stage "build mtls MCP server image" ./bin/mcp-runtime server build image "${MTLS_SERVER_NAME}" \ |
| 87 | + --metadata-file "${MTLS_METADATA}" \ |
| 88 | + --dockerfile "${GO_EXAMPLE_SOURCE_DIR}/Dockerfile" \ |
| 89 | + --registry registry.registry.svc.cluster.local:5000 \ |
| 90 | + --tag "${E2E_WORKLOAD_TAG}" \ |
| 91 | + --context "${GO_EXAMPLE_SOURCE_DIR}" |
| 92 | + fi |
| 93 | + prune_kind_image "${MTLS_IMAGE}" |
| 94 | + load_image_into_kind "${MTLS_IMAGE}" |
| 95 | + |
| 96 | + run_logged_stage "deploy mtls MCP server manifests" bash -lc \ |
| 97 | + "MCP_RUNTIME_CONFIG_DIR=\"${E2E_PIPELINE_CONFIG_DIR}\" ./bin/mcp-runtime server generate --metadata-file \"${MTLS_METADATA}\" --output \"${WORKDIR}/mtls-manifests\" && ./bin/mcp-runtime server --use-kube apply --file \"${WORKDIR}/mtls-manifests/${MTLS_SERVER_NAME}.yaml\"" |
| 98 | + wait_for_named_server_ready "${MTLS_SERVER_NAME}" "mcp-servers" 240 |
| 99 | + |
| 100 | + log_line mtls "waiting for gateway and trust-bundle certificates" |
| 101 | + deadline=$((SECONDS + 240)) |
| 102 | + while (( SECONDS < deadline )); do |
| 103 | + if kubectl get secret "${MTLS_SERVER_NAME}-gateway-mtls" -n mcp-servers >/dev/null 2>&1 \ |
| 104 | + && kubectl get secret "${MTLS_SERVER_NAME}-mtls-ca" -n mcp-servers >/dev/null 2>&1; then |
| 105 | + break |
| 106 | + fi |
| 107 | + sleep 2 |
| 108 | + done |
| 109 | + if ! kubectl get secret "${MTLS_SERVER_NAME}-gateway-mtls" -n mcp-servers >/dev/null 2>&1; then |
| 110 | + echo "timed out waiting for gateway TLS secret ${MTLS_SERVER_NAME}-gateway-mtls" >&2 |
| 111 | + exit 1 |
| 112 | + fi |
| 113 | + |
| 114 | + ensure_traefik_port_forward |
| 115 | + if [[ -z "${TRAEFIK_TLS_PORT_FORWARD_PID:-}" ]]; then |
| 116 | + echo "[port-forward] exposing traefik websecure on localhost:${TRAEFIK_TLS_PORT}" |
| 117 | + port_forward_bg traefik traefik "${TRAEFIK_TLS_PORT}" 8443 "${WORKDIR}/traefik-tls-port-forward.log" |
| 118 | + TRAEFIK_TLS_PORT_FORWARD_PID="${LAST_MANAGED_PID}" |
| 119 | + fi |
| 120 | + wait_port "${TRAEFIK_TLS_PORT}" |
| 121 | + |
| 122 | + MTLS_INGRESS_PATH="/${MTLS_SERVER_NAME}/mcp" |
| 123 | + MTLS_URL="https://127.0.0.1:${TRAEFIK_TLS_PORT}${MTLS_INGRESS_PATH}" |
| 124 | + MTLS_CA_FILE="${WORKDIR}/mtls-ca.crt" |
| 125 | + kubectl get secret "${MTLS_SERVER_NAME}-mtls-ca" -n mcp-servers -o jsonpath='{.data.ca\.crt}' | decode_base64 >"${MTLS_CA_FILE}" |
| 126 | + |
| 127 | + log_line mtls "rejecting initialize without a client certificate" |
| 128 | + if curl -fsS --cacert "${MTLS_CA_FILE}" \ |
| 129 | + -H "Host: ${SERVER_HOST}" \ |
| 130 | + -H "content-type: application/json" \ |
| 131 | + -H "accept: application/json, text/event-stream" \ |
| 132 | + -H "Mcp-Protocol-Version: ${MCP_PROTOCOL_VERSION}" \ |
| 133 | + --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"'"${MCP_PROTOCOL_VERSION}"'","capabilities":{},"clientInfo":{"name":"e2e","version":"1"}}}' \ |
| 134 | + "${MTLS_URL}" >/dev/null 2>&1; then |
| 135 | + echo "expected initialize without client certificate to fail" >&2 |
| 136 | + exit 1 |
| 137 | + fi |
| 138 | + |
| 139 | + ensure_gateway_port_forward |
| 140 | + ensure_api_port_forward |
| 141 | + if [[ -z "${ADAPTER_PLATFORM_TOKEN:-}" ]]; then |
| 142 | + ADAPTER_PLATFORM_TOKEN="$(PLATFORM_ADMIN_EMAIL="${PLATFORM_ADMIN_EMAIL}" PLATFORM_ADMIN_PASSWORD="${PLATFORM_ADMIN_PASSWORD}" python3 -c ' |
| 143 | +import json, os |
| 144 | +print(json.dumps({"email": os.environ["PLATFORM_ADMIN_EMAIL"], "password": os.environ["PLATFORM_ADMIN_PASSWORD"]})) |
| 145 | +' | curl -fsS -X POST \ |
| 146 | + -H "content-type: application/json" \ |
| 147 | + --data-binary @- \ |
| 148 | + "http://127.0.0.1:${API_SERVICE_PORT}/api/v1/auth/login" | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')" |
| 149 | + fi |
| 150 | + |
| 151 | + log_line mtls "applying grant and adapter session for mtls datapath" |
| 152 | + cat >"${WORKDIR}/mtls-grant.yaml" <<EOF |
| 153 | +apiVersion: mcpruntime.org/v1alpha1 |
| 154 | +kind: MCPAccessGrant |
| 155 | +metadata: |
| 156 | + name: ${MTLS_SERVER_NAME}-grant |
| 157 | + namespace: mcp-servers |
| 158 | +spec: |
| 159 | + serverRef: |
| 160 | + name: ${MTLS_SERVER_NAME} |
| 161 | + subject: |
| 162 | + agentID: ${MTLS_AGENT_ID} |
| 163 | + maxTrust: low |
| 164 | + allowedSideEffects: [read] |
| 165 | + policyVersion: v1 |
| 166 | + toolRules: |
| 167 | + - name: aaa-ping |
| 168 | + decision: allow |
| 169 | +EOF |
| 170 | + (cd "${WORKDIR}" && "${PROJECT_ROOT}/bin/mcp-runtime" access --use-kube grant apply --file mtls-grant.yaml) |
| 171 | + wait_for_policy_text "\"name\": \"${MTLS_SERVER_NAME}-grant\"" |
| 172 | + |
| 173 | + MTLS_CERT_DIR="${WORKDIR}/mtls-certs" |
| 174 | + mkdir -p "${MTLS_CERT_DIR}" |
| 175 | + MTLS_ENROLL_OUT="$(MCP_PLATFORM_API_URL="http://127.0.0.1:${SENTINEL_PORT}" \ |
| 176 | + MCP_PLATFORM_API_TOKEN="${ADAPTER_PLATFORM_TOKEN}" \ |
| 177 | + ./bin/mcp-runtime adapter enroll \ |
| 178 | + --server "${MTLS_SERVER_NAME}" \ |
| 179 | + --namespace mcp-servers \ |
| 180 | + --agent "${MTLS_AGENT_ID}" \ |
| 181 | + --trust-domain "${MTLS_TRUST_DOMAIN}" \ |
| 182 | + --output-dir "${MTLS_CERT_DIR}")" |
| 183 | + MTLS_SESSION_NAME="$(printf '%s\n' "${MTLS_ENROLL_OUT}" | python3 -c 'import re,sys; m=re.search(r"/session/([^\\s]+)", sys.stdin.read()); print(m.group(1) if m else ""); sys.exit(0 if m else 1)')" |
| 184 | + wait_for_policy_text "\"name\": \"${MTLS_SESSION_NAME}\"" |
| 185 | + |
| 186 | + MTLS_CLIENT_CERT="${MTLS_CERT_DIR}/client.crt" |
| 187 | + MTLS_CLIENT_KEY="${MTLS_CERT_DIR}/client.key" |
| 188 | + MTLS_CLIENT_CA="${MTLS_CERT_DIR}/ca.crt" |
| 189 | + for f in "${MTLS_CLIENT_CERT}" "${MTLS_CLIENT_KEY}" "${MTLS_CLIENT_CA}"; do |
| 190 | + if [[ ! -s "${f}" ]]; then |
| 191 | + echo "adapter enroll did not write ${f}" >&2 |
| 192 | + exit 1 |
| 193 | + fi |
| 194 | + done |
| 195 | + |
| 196 | + log_line mtls "accepting initialize with session-bound client certificate" |
| 197 | + init_body="$(curl -fsS \ |
| 198 | + --cert "${MTLS_CLIENT_CERT}" \ |
| 199 | + --key "${MTLS_CLIENT_KEY}" \ |
| 200 | + --cacert "${MTLS_CLIENT_CA}" \ |
| 201 | + -H "Host: ${SERVER_HOST}" \ |
| 202 | + -H "content-type: application/json" \ |
| 203 | + -H "accept: application/json, text/event-stream" \ |
| 204 | + -H "Mcp-Protocol-Version: ${MCP_PROTOCOL_VERSION}" \ |
| 205 | + --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"'"${MCP_PROTOCOL_VERSION}"'","capabilities":{},"clientInfo":{"name":"e2e-mtls","version":"1"}}}' \ |
| 206 | + "${MTLS_URL}")" |
| 207 | + echo "${init_body}" | python3 -c ' |
| 208 | +import json, sys |
| 209 | +doc = json.load(sys.stdin) |
| 210 | +if doc.get("error"): |
| 211 | + raise SystemExit(f"initialize failed: {doc}") |
| 212 | +if "result" not in doc: |
| 213 | + raise SystemExit(f"missing initialize result: {doc}") |
| 214 | +print("mtls initialize ok") |
| 215 | +' |
| 216 | + |
| 217 | + log_line mtls "ignoring spoofed governance headers on mtls path" |
| 218 | + spoof_body="$(curl -fsS \ |
| 219 | + --cert "${MTLS_CLIENT_CERT}" \ |
| 220 | + --key "${MTLS_CLIENT_KEY}" \ |
| 221 | + --cacert "${MTLS_CLIENT_CA}" \ |
| 222 | + -H "Host: ${SERVER_HOST}" \ |
| 223 | + -H "content-type: application/json" \ |
| 224 | + -H "accept: application/json, text/event-stream" \ |
| 225 | + -H "Mcp-Protocol-Version: ${MCP_PROTOCOL_VERSION}" \ |
| 226 | + -H "X-MCP-Human-ID: spoofed-human" \ |
| 227 | + -H "X-MCP-Agent-ID: spoofed-agent" \ |
| 228 | + -H "X-MCP-Agent-Session: definitely-not-${MTLS_SESSION_NAME}" \ |
| 229 | + --data '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"aaa-ping","arguments":{}}}' \ |
| 230 | + "${MTLS_URL}" 2>/dev/null || true)" |
| 231 | + if [[ -n "${spoof_body}" ]]; then |
| 232 | + echo "${spoof_body}" | python3 -c ' |
| 233 | +import json, sys |
| 234 | +doc = json.load(sys.stdin) |
| 235 | +err = doc.get("error") or {} |
| 236 | +msg = str(err.get("message", "")).lower() |
| 237 | +if doc.get("result") and "pong" in json.dumps(doc.get("result")).lower(): |
| 238 | + raise SystemExit("spoofed governance headers must not bypass mtls auth") |
| 239 | +if err and any(token in msg for token in ("session", "denied", "unauthorized", "forbidden", "grant", "trust")): |
| 240 | + raise SystemExit(0) |
| 241 | +if err: |
| 242 | + raise SystemExit(0) |
| 243 | +raise SystemExit(f"unexpected tools/call response with spoofed headers: {doc}") |
| 244 | +' |
| 245 | + fi |
| 246 | +} |
0 commit comments