diff --git a/Cargo.lock b/Cargo.lock index 74ddee92f..aa3028498 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -959,6 +959,16 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -1750,9 +1760,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.4", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2222,6 +2234,7 @@ dependencies = [ "aws-sdk-secretsmanager", "aws-sigv4", "base64", + "bytes", "chrono", "chrono-tz", "clap", @@ -3032,7 +3045,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3369,6 +3382,27 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tar" version = "0.4.46" @@ -4080,6 +4114,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index 4ed8f5be7..9c3ca84cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,11 @@ tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] } clap = { version = "4", features = ["derive"] } anyhow = "1" axum = { version = "0.8", optional = true } -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +# `system-proxy` re-enables HTTPS_PROXY / HTTP_PROXY / NO_PROXY env-var auto-detection, +# which is a default reqwest feature disabled by `default-features = false`. Lets operators +# route reqwest-based HTTP calls (LLM APIs, Slack Web API, remote config, hooks, STT, media) +# through an HTTP CONNECT proxy by setting the env vars. +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "system-proxy"] } serde = { version = "1", features = ["derive"] } serde_json = "1" async-trait = "0.1" diff --git a/README.md b/README.md index a97a527aa..d8a0f81e8 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,11 @@ kubectl create secret generic openab-secret \ kubectl apply -f k8s/configmap.yaml kubectl apply -f k8s/pvc.yaml kubectl apply -f k8s/deployment.yaml + +# Optional: opt-in pod-level network isolation (deny-all ingress, +# DNS + HTTPS egress with cloud-metadata exclusion). See the file +# header for tunable CIDRs / IPv6 exclusions per cloud. +kubectl apply -f k8s/networkpolicy.yaml ``` | Manifest | Purpose | @@ -280,6 +285,7 @@ kubectl apply -f k8s/deployment.yaml | `k8s/configmap.yaml` | `config.toml` mounted at `/etc/openab/` | | `k8s/secret.yaml` | `DISCORD_BOT_TOKEN` injected as env var | | `k8s/pvc.yaml` | Persistent storage for auth + settings | +| `k8s/networkpolicy.yaml` | **Optional** pod-level network isolation — deny-all ingress + DNS/HTTPS egress with cloud-metadata (`169.254.169.254`, AWS `fd00:ec2::254`) excluded. Requires a CNI that enforces NetworkPolicy (Calico, Cilium, etc.). | ## AWS ECS Deployment diff --git a/README.zh-TW.md b/README.zh-TW.md index ed6c2330a..2d29eeab5 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -272,6 +272,11 @@ kubectl create secret generic openab-secret \ kubectl apply -f k8s/configmap.yaml kubectl apply -f k8s/pvc.yaml kubectl apply -f k8s/deployment.yaml + +# 選用:pod 層網路隔離(deny-all ingress、DNS + HTTPS egress,並排除 +# cloud metadata service)。若需要調整 CIDR 或不同雲的 IPv6 metadata +# 位址,請直接修改該 manifest 檔案開頭的註解。 +kubectl apply -f k8s/networkpolicy.yaml ``` | Manifest | 用途 | @@ -280,6 +285,7 @@ kubectl apply -f k8s/deployment.yaml | `k8s/configmap.yaml` | `config.toml` 掛載至 `/etc/openab/` | | `k8s/secret.yaml` | 透過 env 注入 `DISCORD_BOT_TOKEN` | | `k8s/pvc.yaml` | 持久保存驗證資訊與設定 | +| `k8s/networkpolicy.yaml` | **選用**:pod 層網路隔離 — deny-all ingress + DNS/HTTPS egress,並排除 cloud metadata(`169.254.169.254`、AWS `fd00:ec2::254`)。需要有 CNI 執行 NetworkPolicy(Calico、Cilium 等)。 | ## AWS ECS 部署 diff --git a/charts/openab/README.md b/charts/openab/README.md index dfac33a38..3ef08b09f 100644 --- a/charts/openab/README.md +++ b/charts/openab/README.md @@ -14,6 +14,16 @@ This page highlights commonly used values and deployment patterns. For the compl | `fullnameOverride` | Override the full generated release name for chart resources. Useful when deploying multiple instances with predictable names. | `""` | | `serviceAccountName` | Chart-global ServiceAccount name attached to every agent pod that doesn't define its own. Empty = cluster `default` SA. Per-agent `agents..serviceAccountName` fully overrides this. Chart references an existing SA only — does not create one. Required for workload identity and pod-level RBAC. | `""` | | `imagePullSecrets` | Chart-global image pull secrets attached to every agent pod that doesn't define its own. Per-agent `agents..imagePullSecrets` fully overrides this. | `[]` | +| `networkPolicy.enabled` | Master switch for the chart-managed NetworkPolicy. Off by default — no `NetworkPolicy` resource is rendered until this flips to `true`, keeping existing releases unaffected. When on: one policy per agent (deny-all ingress + DNS/HTTPS egress + TCP 8080 to the chart-managed gateway pod when `gateway.enabled=true` and `gateway.deploy` is not `false`), plus one policy per deployed gateway (port 8080 ingress + same egress). ⚠️ **Prerequisite: your cluster's CNI must enforce NetworkPolicy** (Calico, Cilium, Antrea, EKS with NetworkPolicy add-on, GKE with Dataplane V2 / Calico, AKS with Azure NPM / Calico, etc.). Stock KIND, Docker Desktop, or bare-EKS clusters without a NetworkPolicy-capable CNI silently ignore the resource — verify enforcement with a scratch namespace + deny-all test before relying on this. ⚠️ **Direct egress only for now** — proxy-mode egress (routing through an operator-supplied HTTP CONNECT proxy) was explored in early rounds of [RFC #1394](https://github.com/openabdev/openab/issues/1394) but requires a proxy-aware networking refactor across `openab-core` / `openab-gateway` (see `docs/openshell.md`). Follow-up RFC to come. | `false` | +| `networkPolicy.ingress.extraRules` | Raw `NetworkPolicyIngressRule` entries appended verbatim to every rendered policy. Use for cluster-specific allow-lists (Prometheus scrape, mesh sidecars, etc.). | `[]` | +| `networkPolicy.egress.allowDns` | Allow DNS egress on UDP+TCP 53 to pods labelled `k8s-app=kube-dns`. | `true` | +| `networkPolicy.egress.dnsNamespace` | Trusted namespace hosting the DNS pods. Default `"kube-system"` matches kubeadm / CoreDNS / kube-dns defaults and prevents a co-tenant labelling their own pod `k8s-app=kube-dns` from becoming an approved port-53 destination. Empty string reverts to the legacy any-namespace selector — use only if your DNS runs outside kube-system. | `"kube-system"` | +| `networkPolicy.egress.allowHttps` | Allow HTTPS (TCP 443) to `0.0.0.0/0` (and `::/0` when `allowIpv6=true`), except CIDRs listed in `metadataExclusions`. | `true` | +| `networkPolicy.egress.allowHttp` | Allow HTTP (TCP 80) with the same metadata exclusion. Off by default. ⚠️ Enable if any of these fetch over HTTP: `agents..configUrl`, lifecycle hooks that download over HTTP (see `docs/hooks.md`), or STT base URLs on HTTP. Prefer HTTPS wherever possible. | `false` | +| `networkPolicy.egress.allowIpv6` | Add `::/0` to the HTTPS/HTTP rules alongside `0.0.0.0/0`, excluding CIDRs from `metadataExclusions.ipv6`. Turn off on IPv4-only clusters. | `true` | +| `networkPolicy.egress.metadataExclusions.ipv4` | CIDRs excluded from the IPv4 HTTPS/HTTP allow rule. Defaults to `169.254.169.254/32` (AWS/GCP/Azure IMDS). Add IBM Cloud's `161.26.0.0/16` here if applicable. **NetworkPolicy allow rules combine additively — you cannot subtract via `extraRules`; edit this list directly.** ⚠️ **Defense-in-depth only, not a portable IMDS guarantee**: pods can always reach services on their resident node, and CNI implementations differ in whether `ipBlock` matches pre- or post-NAT addresses. Use cloud-provider hardening (IMDSv2 mandatory + hop-limit 1, GCP metadata concealment, Azure metadata endpoint policy) as the primary control. | `["169.254.169.254/32"]` | +| `networkPolicy.egress.metadataExclusions.ipv6` | CIDRs excluded from the IPv6 HTTPS/HTTP allow rule. Defaults to `fd00:ec2::254/128` (AWS IPv6 IMDS). Non-AWS IPv6 metadata addresses differ — override this list per cloud. **Same additive-only semantics as `ipv4` above, and same defense-in-depth caveat.** | `["fd00:ec2::254/128"]` | +| `networkPolicy.egress.extraRules` | Raw `NetworkPolicyEgressRule` entries appended verbatim. Use to open extra ports or add cluster-specific carve-outs. **Cannot subtract from existing allows — for tighter metadata exclusions use `metadataExclusions` above.** ⚠️ **Non-80/443 destinations require an explicit entry here** — enabling `networkPolicy.enabled=true` silently breaks: (a) external config-only gateway (`gateway.deploy=false` + URL supplied via `configToml`'s `[gateway]` section on TCP 8080), (b) self-hosted STT on non-standard ports (e.g. `http://192.168.1.100:8080/v1`), (c) internal LLM proxies (LiteLLM on 4000, etc.). See `values.yaml` for a complete `extraRules` + `configToml` recipe for the external-gateway case. | `[]` | ### Agent values @@ -47,7 +57,9 @@ Each agent lives under `agents.`. | `stt.model` | STT model name. | `"whisper-large-v3-turbo"` | | `stt.baseUrl` | STT API base URL. | `"https://api.groq.com/openai/v1"` | | `gateway.enabled` | Enable the gateway config block for webhook-based platforms. | `false` | -| `gateway.deploy` | Deploy the gateway Deployment and Service. | `true` | +| `gateway.deploy` | Deploy the gateway Deployment and Service. ⚠️ When `false` (config-only / external gateway), you must (a) supply the gateway URL via `configToml` (a `[gateway]` section — the standalone `gateway.url` value is not consumed by the ConfigMap since chart v0.10), and (b) if `networkPolicy.enabled=true`, add a matching `networkPolicy.egress.extraRules` entry targeting your external gateway pod/service, otherwise the agent→external-gateway WSS is blocked. See `values.yaml` for the complete recipe. | `true` | +| `gateway.env` | Additional environment variables for the gateway container (map, `{name: value}`). Same shape as agent-level `env`. Useful for feature flags, `RUST_LOG` overrides, or HTTP proxy env vars if the gateway runs behind a transparent proxy. | `{}` | +| `gateway.envFrom` | Load env vars for the gateway from existing Secrets or ConfigMaps (list of `secretRef`/`configMapRef` entries). Same shape as agent-level `envFrom`. | `[]` | | `cron.usercronEnabled` | Enable user-provided cron configuration. | `false` | | `cronjobs` | Config-driven scheduled messages for an agent. | `[]` | | `persistence.enabled` | Enable persistent storage for auth and settings. | `true` | diff --git a/charts/openab/templates/gateway.yaml b/charts/openab/templates/gateway.yaml index 2a89dc79a..3dda39b7b 100644 --- a/charts/openab/templates/gateway.yaml +++ b/charts/openab/templates/gateway.yaml @@ -218,8 +218,19 @@ spec: value: {{ ($cfg.gateway).wecom.debounceSecs | quote }} {{- end }} {{- end }} + {{- $gwEnv := ($cfg.gateway).env | default dict }} + {{- if not (hasKey $gwEnv "RUST_LOG") }} - name: RUST_LOG value: {{ ($cfg.gateway).rustLog | default "info" | quote }} + {{- end }} + {{- range $k, $v := $gwEnv }} + - name: {{ $k }} + value: {{ $v | quote }} + {{- end }} + {{- with ($cfg.gateway).envFrom }} + envFrom: + {{- toYaml . | nindent 12 }} + {{- end }} livenessProbe: httpGet: path: /health diff --git a/charts/openab/templates/networkpolicy.yaml b/charts/openab/templates/networkpolicy.yaml new file mode 100644 index 000000000..f89af9737 --- /dev/null +++ b/charts/openab/templates/networkpolicy.yaml @@ -0,0 +1,132 @@ +{{- if .Values.networkPolicy.enabled }} +{{- $np := .Values.networkPolicy }} +{{- range $name, $cfg := .Values.agents }} +{{- if ne (include "openab.agentEnabled" $cfg) "false" }} +{{- $d := dict "ctx" $ "agent" $name "cfg" $cfg }} +{{- $gwDeployed := and (($cfg.gateway).enabled) (ne (($cfg.gateway).deploy | toString) "false") }} +{{- $gwD := dict "ctx" $ "agent" (printf "%s-gateway" $name) "cfg" (omit $cfg "nameOverride") }} +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "openab.agentFullname" $d }} + labels: + {{- include "openab.labels" $d | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "openab.selectorLabels" $d | nindent 6 }} + policyTypes: + - Ingress + - Egress + ingress: + {{- with $np.ingress.extraRules }} + {{- toYaml . | nindent 4 }} + {{- else }} + [] + {{- end }} + egress: +{{ include "openab.networkPolicyEgress" (dict "np" $np) | indent 4 }} +{{- if $gwDeployed }} + - to: + - podSelector: + matchLabels: + {{- include "openab.selectorLabels" $gwD | nindent 14 }} + ports: + - protocol: TCP + port: 8080 +{{- end }} +{{- if $gwDeployed }} +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "openab.agentFullname" $gwD }} + labels: + {{- include "openab.labels" $gwD | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "openab.selectorLabels" $gwD | nindent 6 }} + policyTypes: + - Ingress + - Egress + ingress: + - ports: + - protocol: TCP + port: 8080 + {{- with $np.ingress.extraRules }} + {{- toYaml . | nindent 4 }} + {{- end }} + egress: +{{ include "openab.networkPolicyEgress" (dict "np" $np) | indent 4 }} +{{- end }} +{{- end }} +{{- end }} +{{- end }} + +{{/* Egress rule set shared by agent and gateway NetworkPolicies. + Call with: dict "np" .Values.networkPolicy */}} +{{- define "openab.networkPolicyEgress" -}} +{{- $np := .np -}} +{{- if $np.egress.allowDns -}} +{{/* Default DNS destination scoped to a trusted namespace (usually kube-system). + Empty dnsNamespace preserves the legacy any-namespace behaviour for exotic DNS setups. */}} +- to: +{{- if $np.egress.dnsNamespace }} + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ $np.egress.dnsNamespace | quote }} + podSelector: + matchLabels: + k8s-app: kube-dns +{{- else }} + - namespaceSelector: {} + podSelector: + matchLabels: + k8s-app: kube-dns +{{- end }} + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 +{{- end }} +{{- $mdxV4 := $np.egress.metadataExclusions.ipv4 | default (list "169.254.169.254/32") }} +{{- $mdxV6 := $np.egress.metadataExclusions.ipv6 | default (list "fd00:ec2::254/128") }} +{{- if $np.egress.allowHttps }} +- to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + {{- toYaml $mdxV4 | nindent 10 }} +{{- if $np.egress.allowIpv6 }} + - ipBlock: + cidr: ::/0 + except: + {{- toYaml $mdxV6 | nindent 10 }} +{{- end }} + ports: + - protocol: TCP + port: 443 +{{- end }} +{{- if $np.egress.allowHttp }} +- to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + {{- toYaml $mdxV4 | nindent 10 }} +{{- if $np.egress.allowIpv6 }} + - ipBlock: + cidr: ::/0 + except: + {{- toYaml $mdxV6 | nindent 10 }} +{{- end }} + ports: + - protocol: TCP + port: 80 +{{- end }} +{{- with $np.egress.extraRules }} +{{ toYaml . }} +{{- end }} +{{- end }} diff --git a/charts/openab/tests/networkpolicy_external_gateway_test.yaml b/charts/openab/tests/networkpolicy_external_gateway_test.yaml new file mode 100644 index 000000000..58cd3cf9a --- /dev/null +++ b/charts/openab/tests/networkpolicy_external_gateway_test.yaml @@ -0,0 +1,94 @@ +suite: NetworkPolicy — external-gateway recipe full-chart guard (round-7 F2) +# This suite renders the ConfigMap AND NetworkPolicy together for the +# documented external-gateway extraRules recipe (chart README + values.yaml). +# It exists to catch config-schema migrations (e.g. the v0.10 configToml +# requirement) that would silently invalidate the recipe if the two +# templates were tested in isolation. +templates: + - templates/configmap.yaml + - templates/networkpolicy.yaml + +tests: + - it: ConfigMap accepts the recipe's configToml containing a [gateway] section + template: templates/configmap.yaml + set: + networkPolicy.enabled: true + networkPolicy.egress.extraRules: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: gateway-ns + podSelector: + matchLabels: + app: openab-gateway + ports: + - protocol: TCP + port: 8080 + agents.kiro.gateway.enabled: true + agents.kiro.gateway.deploy: false + agents.kiro.configToml: | + [gateway] + url = "ws://openab-gateway.gateway-ns.svc:8080/ws" + platform = "telegram" + allow_all_channels = true + asserts: + - hasDocuments: + count: 1 + - equal: + path: kind + value: ConfigMap + - matchRegex: + path: data["config.toml"] + pattern: 'url = "ws://openab-gateway\.gateway-ns\.svc:8080/ws"' + - matchRegex: + path: data["config.toml"] + pattern: 'platform = "telegram"' + + - it: NetworkPolicy renders the recipe's extraRules TCP 8080 entry and does NOT emit a chart-managed agent→gateway rule (gateway.deploy=false) + template: templates/networkpolicy.yaml + set: + networkPolicy.enabled: true + networkPolicy.egress.extraRules: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: gateway-ns + podSelector: + matchLabels: + app: openab-gateway + ports: + - protocol: TCP + port: 8080 + agents.kiro.gateway.enabled: true + agents.kiro.gateway.deploy: false + agents.kiro.configToml: | + [gateway] + url = "ws://openab-gateway.gateway-ns.svc:8080/ws" + platform = "telegram" + allow_all_channels = true + asserts: + - hasDocuments: + count: 1 + - contains: + path: spec.egress + content: + to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: gateway-ns + podSelector: + matchLabels: + app: openab-gateway + ports: + - protocol: TCP + port: 8080 + - notContains: + path: spec.egress + content: + to: + - podSelector: + matchLabels: + app.kubernetes.io/name: openab + app.kubernetes.io/instance: RELEASE-NAME + app.kubernetes.io/component: kiro-gateway + any: true diff --git a/charts/openab/tests/networkpolicy_test.yaml b/charts/openab/tests/networkpolicy_test.yaml new file mode 100644 index 000000000..ed786fb46 --- /dev/null +++ b/charts/openab/tests/networkpolicy_test.yaml @@ -0,0 +1,413 @@ +suite: NetworkPolicy support (opt-in, direct egress only) +templates: + - templates/networkpolicy.yaml + - templates/gateway.yaml + +tests: + - it: renders no NetworkPolicy resource when networkPolicy.enabled is false (default) + template: templates/networkpolicy.yaml + asserts: + - hasDocuments: + count: 0 + + - it: renders a single agent NetworkPolicy (gateway off) with deny-all ingress, DNS scoped to kube-system, and IPv4+IPv6 HTTPS egress + template: templates/networkpolicy.yaml + set: + networkPolicy.enabled: true + asserts: + - hasDocuments: + count: 1 + - equal: + path: kind + value: NetworkPolicy + - equal: + path: metadata.name + value: RELEASE-NAME-openab-kiro + - equal: + path: spec.podSelector.matchLabels["app.kubernetes.io/component"] + value: kiro + - equal: + path: spec.policyTypes + value: [Ingress, Egress] + - equal: + path: spec.ingress + value: [] + - contains: + path: spec.egress + content: + to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + - contains: + path: spec.egress + content: + to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 169.254.169.254/32 + - ipBlock: + cidr: ::/0 + except: + - fd00:ec2::254/128 + ports: + - protocol: TCP + port: 443 + + - it: renders both agent and gateway NetworkPolicies when gateway is deployed, and the agent policy authorizes TCP 8080 to the gateway pod + template: templates/networkpolicy.yaml + set: + networkPolicy.enabled: true + agents.kiro.gateway.enabled: true + asserts: + - hasDocuments: + count: 2 + - equal: + path: metadata.name + value: RELEASE-NAME-openab-kiro + documentIndex: 0 + - equal: + path: metadata.name + value: RELEASE-NAME-openab-kiro-gateway + documentIndex: 1 + - equal: + path: spec.podSelector.matchLabels["app.kubernetes.io/component"] + value: kiro-gateway + documentIndex: 1 + - contains: + path: spec.ingress + content: + ports: + - protocol: TCP + port: 8080 + documentIndex: 1 + - contains: + path: spec.egress + content: + to: + - podSelector: + matchLabels: + app.kubernetes.io/name: openab + app.kubernetes.io/instance: RELEASE-NAME + app.kubernetes.io/component: kiro-gateway + ports: + - protocol: TCP + port: 8080 + documentIndex: 0 + + - it: does NOT render a gateway NetworkPolicy or an agent→gateway egress rule when gateway.enabled=true but gateway.deploy=false (matches templates/gateway.yaml condition) + template: templates/networkpolicy.yaml + set: + networkPolicy.enabled: true + agents.kiro.gateway.enabled: true + agents.kiro.gateway.deploy: false + asserts: + - hasDocuments: + count: 1 + - equal: + path: metadata.name + value: RELEASE-NAME-openab-kiro + - notContains: + path: spec.egress + content: + to: + - podSelector: + matchLabels: + app.kubernetes.io/name: openab + app.kubernetes.io/instance: RELEASE-NAME + app.kubernetes.io/component: kiro-gateway + ports: + - protocol: TCP + port: 8080 + any: true + + - it: renders HTTP egress rule (IPv4+IPv6) when egress.allowHttp is true + template: templates/networkpolicy.yaml + set: + networkPolicy.enabled: true + networkPolicy.egress.allowHttp: true + asserts: + - contains: + path: spec.egress + content: + to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 169.254.169.254/32 + - ipBlock: + cidr: ::/0 + except: + - fd00:ec2::254/128 + ports: + - protocol: TCP + port: 80 + + - it: omits IPv6 ipBlock when allowIpv6=false (IPv4-only cluster) + template: templates/networkpolicy.yaml + set: + networkPolicy.enabled: true + networkPolicy.egress.allowIpv6: false + asserts: + - contains: + path: spec.egress + content: + to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 169.254.169.254/32 + ports: + - protocol: TCP + port: 443 + - notContains: + path: spec.egress + content: + to: + - ipBlock: + cidr: ::/0 + except: + - fd00:ec2::254/128 + any: true + + - it: falls back to any-namespace DNS when egress.dnsNamespace is empty string (legacy escape hatch) + template: templates/networkpolicy.yaml + set: + networkPolicy.enabled: true + networkPolicy.egress.dnsNamespace: "" + asserts: + - contains: + path: spec.egress + content: + to: + - namespaceSelector: {} + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + + - it: appends ingress.extraRules verbatim to the agent policy + template: templates/networkpolicy.yaml + set: + networkPolicy.enabled: true + networkPolicy.ingress.extraRules: + - from: + - namespaceSelector: + matchLabels: + name: monitoring + ports: + - protocol: TCP + port: 9090 + asserts: + - contains: + path: spec.ingress + content: + from: + - namespaceSelector: + matchLabels: + name: monitoring + ports: + - protocol: TCP + port: 9090 + + - it: locks egress down to only extraRules when allowDns and allowHttps are false + template: templates/networkpolicy.yaml + set: + networkPolicy.enabled: true + networkPolicy.egress.allowDns: false + networkPolicy.egress.allowHttps: false + networkPolicy.egress.extraRules: + - to: + - ipBlock: + cidr: 10.0.0.0/8 + ports: + - protocol: TCP + port: 443 + asserts: + - notContains: + path: spec.egress + content: + to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + any: true + - notContains: + path: spec.egress + content: + to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 169.254.169.254/32 + - ipBlock: + cidr: ::/0 + except: + - fd00:ec2::254/128 + any: true + - contains: + path: spec.egress + content: + to: + - ipBlock: + cidr: 10.0.0.0/8 + ports: + - protocol: TCP + port: 443 + + # Round-7 F2: full-chart render for the external-gateway recipe — ensures + # both the ConfigMap (configToml with [gateway] section) AND the + # NetworkPolicy (extraRules for TCP 8080) render successfully together. + # A future config-schema migration that breaks the recipe would fail these + # two tests instead of silently invalidating documented guidance. + + - it: external-gateway recipe — NetworkPolicy renders the extraRules TCP 8080 entry and does NOT emit a chart-managed agent→gateway rule (round-6 F1 + round-7 F2) + template: templates/networkpolicy.yaml + set: + networkPolicy.enabled: true + networkPolicy.egress.extraRules: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: gateway-ns + podSelector: + matchLabels: + app: openab-gateway + ports: + - protocol: TCP + port: 8080 + agents.kiro.gateway.enabled: true + agents.kiro.gateway.deploy: false + agents.kiro.configToml: | + [gateway] + url = "ws://openab-gateway.gateway-ns.svc:8080/ws" + platform = "telegram" + allow_all_channels = true + asserts: + - hasDocuments: + count: 1 + - contains: + path: spec.egress + content: + to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: gateway-ns + podSelector: + matchLabels: + app: openab-gateway + ports: + - protocol: TCP + port: 8080 + - notContains: + path: spec.egress + content: + to: + - podSelector: + matchLabels: + app.kubernetes.io/name: openab + app.kubernetes.io/instance: RELEASE-NAME + app.kubernetes.io/component: kiro-gateway + any: true + + - it: uses configurable metadataExclusions for both IPv4 and IPv6 in the HTTPS rule + template: templates/networkpolicy.yaml + set: + networkPolicy.enabled: true + networkPolicy.egress.metadataExclusions.ipv4: + - 169.254.169.254/32 + - 169.254.170.2/32 + networkPolicy.egress.metadataExclusions.ipv6: + - fd00:beef::254/128 + asserts: + - contains: + path: spec.egress + content: + to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 169.254.169.254/32 + - 169.254.170.2/32 + - ipBlock: + cidr: ::/0 + except: + - fd00:beef::254/128 + ports: + - protocol: TCP + port: 443 + + # ── Gateway env / envFrom (general-purpose env injection) ───────────────── + + - it: renders gateway.env into the gateway Deployment; user-supplied RUST_LOG wins over built-in and appears exactly once (round-7 F1) + template: templates/gateway.yaml + set: + agents.kiro.gateway.enabled: true + agents.kiro.gateway.env: + MY_FLAG: "on" + RUST_LOG: "openab_gateway=debug" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: MY_FLAG + value: "on" + documentIndex: 0 + - contains: + path: spec.template.spec.containers[0].env + content: + name: RUST_LOG + value: "openab_gateway=debug" + documentIndex: 0 + # RUST_LOG must appear exactly once — no built-in duplicate + - notContains: + path: spec.template.spec.containers[0].env + content: + name: RUST_LOG + value: "info" + documentIndex: 0 + + - it: emits built-in RUST_LOG when gateway.env does NOT override it (round-7 F1 default path) + template: templates/gateway.yaml + set: + agents.kiro.gateway.enabled: true + agents.kiro.gateway.env: + MY_FLAG: "on" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: RUST_LOG + value: "info" + documentIndex: 0 + + - it: renders gateway.envFrom on the gateway Deployment + template: templates/gateway.yaml + set: + agents.kiro.gateway.enabled: true + agents.kiro.gateway.envFrom: + - secretRef: + name: gateway-extras + asserts: + - equal: + path: spec.template.spec.containers[0].envFrom + value: + - secretRef: + name: gateway-extras + documentIndex: 0 diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index 34c99c9ab..a74a39585 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -43,6 +43,132 @@ containerSecurityContext: drop: - ALL +# NetworkPolicy for pod-level network isolation. Opt-in and backwards-compatible: +# no NetworkPolicy resource is rendered unless networkPolicy.enabled=true. +# See RFC: https://github.com/openabdev/openab/issues/1394 +# +# ⚠️ PREREQUISITE — CNI enforcement +# A NetworkPolicy has NO EFFECT unless the cluster's CNI enforces it. Verify +# your cluster runs a supported CNI (Calico, Cilium, Antrea, EKS with the +# VPC CNI + NetworkPolicy add-on, GKE with Dataplane V2 or Calico, AKS with +# Azure NPM or Calico, etc.). On stock KIND, Docker Desktop, or a bare-EKS +# cluster without NetworkPolicy support, enabling this flag renders a +# resource that the CNI simply ignores — you get a false sense of isolation. +# Test with a deny-all policy in a scratch namespace before relying on it. +networkPolicy: + enabled: false + + # Ingress rules. Default = deny-all ingress to agent pods (they only + # initiate outbound Socket Mode / websocket connections). Gateway pod + # gets a separate policy allowing port 8080 when gateway.enabled=true. + ingress: + # extraRules: raw NetworkPolicyIngressRule entries appended verbatim. + # Use for cluster-specific allow-lists (e.g. Prometheus scrape, mesh sidecars). + extraRules: [] + + egress: + # ⚠️ Egress topology is DIRECT only for now. + # Proxy-mode egress (routing all traffic through an operator-supplied + # HTTP CONNECT proxy — the OpenSHELL / Squid / Envoy pattern) was + # explored in early rounds of RFC #1394 but requires a proxy-aware + # networking refactor across openab-core / openab-gateway (see + # docs/openshell.md). Discord, Slack Socket Mode, external gateway, + # and Feishu default connections all bypass reqwest and open WSS + # directly — they cannot be authorized through HTTP CONNECT today. + # The chart therefore only ships direct egress. A follow-up RFC will + # cover proxy egress once the Rust networking refactor lands. + + # Allow DNS egress on UDP/TCP 53 to pods labelled k8s-app=kube-dns. + allowDns: true + # Trusted namespace hosting the DNS pods. Default "kube-system" matches + # kubeadm/CoreDNS/kube-dns defaults and prevents a co-tenant from labelling + # their own pod k8s-app=kube-dns to become an approved port-53 destination. + # Empty string reverts to "any namespace" (legacy behaviour) — use only if + # your DNS runs outside kube-system, or set to your DNS namespace directly. + dnsNamespace: "kube-system" + + # Allow HTTPS (TCP 443). Excludes cloud metadata (see metadataExclusions + # below) — blocks a common IAM-credential-theft path from a compromised + # agent pod. + allowHttps: true + # Allow HTTP (TCP 80) with the same metadata exclusion. Off by default. + # ⚠️ Enable if any of these fetch over HTTP: `agents..configUrl`, + # lifecycle hooks that download over HTTP (see docs/hooks.md), or STT + # base URLs on HTTP. Prefer HTTPS wherever possible. + allowHttp: false + # Add ::/0 to HTTPS/HTTP rules alongside 0.0.0.0/0 for IPv6 egress. + # Turn off on IPv4-only clusters if you prefer explicit denial to + # allowing unused v6 ranges. + allowIpv6: true + # Cloud metadata CIDRs excluded from the HTTPS/HTTP rules. + # NetworkPolicy allow rules combine additively — you CANNOT add another + # exclusion via extraRules; you must edit these lists directly (or set + # allowHttps=false + supply a complete replacement rule via extraRules). + # + # ⚠️ Treat this as defense-in-depth, NOT a portable IMDS guarantee: + # - Kubernetes NetworkPolicy always permits traffic between a pod and + # its own resident node — a compromised pod scheduled on the node + # running metadata-service DNAT can still reach IMDS. + # - The order of address rewriting (SNAT/DNAT) vs. `ipBlock` matching + # is CNI-implementation-dependent — some CNIs match on pre-NAT + # addresses, others on post-NAT. + # - Cloud-provider hardening (IMDSv2 mandatory / hop-limit 1, disable + # IMDSv1, GCP metadata concealment, Azure metadata endpoint policy) + # is the primary control. This exclusion is a secondary net. + # + # Defaults cover AWS (169.254.169.254 works for GCP and Azure too; + # IPv6 IMDS varies by cloud). + # AWS IPv6 IMDS: fd00:ec2::254/128 + # GCP: fd00::/8 metadata routing varies — check your setup + # Azure: IPv4 only (169.254.169.254) + # IBM Cloud: 161.26.0.0/16 (IPv4) + metadataExclusions: + ipv4: + - 169.254.169.254/32 + ipv6: + - fd00:ec2::254/128 + + # extraRules: raw NetworkPolicyEgressRule entries appended verbatim. + # Use to open extra ports or add cluster-specific carve-outs. + # + # ⚠️ Non-80/443 destinations REQUIRE an explicit entry here. + # allowHttps only opens TCP 443, and allowHttp only opens TCP 80. If any + # of your supported openab configurations reach services on other ports + # you MUST add a rule for them, otherwise enabling NetworkPolicy silently + # breaks that traffic. Common cases: + # + # 1. External (config-only) gateway — agents..gateway.deploy=false + # does NOT get the chart-managed agent→gateway TCP 8080 exception + # (that only fires when gateway.deploy is true). Since chart v0.10 the + # gateway URL must be supplied via configToml (or configUrl) — the + # standalone gateway.url value is no longer consumed by the ConfigMap. + # Complete recipe (agent-side values): + # + # agents..gateway.enabled: true + # agents..gateway.deploy: false + # agents..configToml: | + # [gateway] + # url = "ws://openab-gateway.gateway-ns.svc:8080/ws" + # platform = "telegram" + # allow_all_channels = true + # networkPolicy.egress.extraRules: + # - to: + # - namespaceSelector: + # matchLabels: + # kubernetes.io/metadata.name: gateway-ns + # podSelector: + # matchLabels: + # app: openab-gateway + # ports: + # - protocol: TCP + # port: 8080 + # + # 2. Self-hosted STT on a non-standard port (e.g. http://192.168.1.100:8080/v1) + # — add an ipBlock + port entry (see docs/stt.md for hostname/CIDR examples). + # + # 3. Internal LLM proxy on a custom port (e.g. LiteLLM on 4000) — same shape. + extraRules: [] + agents: kiro: enabled: true # set to false to skip creating resources for this agent @@ -407,6 +533,14 @@ agents: strategy: "Recreate" # Recreate (default, prevents concurrent WS conflicts) or RollingUpdate resources: {} # e.g. { requests: { cpu: 50m, memory: 64Mi }, limits: { memory: 128Mi } } rustLog: "info" # RUST_LOG for gateway container (e.g. "openab_gateway=debug") + # Additional environment variables for the gateway container (map, + # {name: value}). Same shape as agent-level `env`. Useful for feature + # flags, RUST_LOG overrides, HTTP proxy env vars if you're running the + # gateway behind a transparent proxy, etc. + env: {} + # Load additional env vars into the gateway from existing Secrets or ConfigMaps. + # Same shape as agent-level `envFrom` (list of secretRef/configMapRef entries). + envFrom: [] nodeSelector: {} tolerations: [] affinity: {} diff --git a/crates/openab-core/Cargo.toml b/crates/openab-core/Cargo.toml index d375a7f4b..55fbc09a0 100644 --- a/crates/openab-core/Cargo.toml +++ b/crates/openab-core/Cargo.toml @@ -21,7 +21,12 @@ futures-util = "0.3" rand = "0.8" clap = { version = "4", features = ["derive"] } rpassword = "7" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "multipart", "json", "blocking", "stream"] } +# `system-proxy` re-enables HTTPS_PROXY / HTTP_PROXY / NO_PROXY env-var auto-detection +# (default reqwest feature disabled by `default-features = false`). Note: WebSocket +# transports in this crate (Slack Socket Mode, external gateway WSS) use +# tokio_tungstenite directly and do NOT honor these env vars — proxy-aware WSS +# tunneling requires the openab networking refactor tracked in docs/openshell.md. +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "multipart", "json", "blocking", "stream", "system-proxy"] } bytes = "1" base64 = "0.22" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } diff --git a/crates/openab-gateway/Cargo.toml b/crates/openab-gateway/Cargo.toml index 26ee00db9..8791a4729 100644 --- a/crates/openab-gateway/Cargo.toml +++ b/crates/openab-gateway/Cargo.toml @@ -11,7 +11,14 @@ tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"] } futures-util = "0.3" serde = { version = "1", features = ["derive"] } serde_json = "1" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +# `system-proxy` re-enables HTTPS_PROXY / HTTP_PROXY / NO_PROXY env-var auto-detection +# (default reqwest feature disabled by `default-features = false`). Lets operators route +# reqwest-based gateway HTTP calls (Telegram/Teams/Feishu/WeCom/GoogleChat/LINE APIs) +# through an HTTP CONNECT proxy by setting the env vars on the pod. +# Note: WebSocket transports (Feishu WSS) use tokio_tungstenite directly and do NOT +# honor these env vars — that requires the openab networking refactor tracked in +# docs/openshell.md. +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "system-proxy"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } anyhow = "1" diff --git a/k8s/networkpolicy.yaml b/k8s/networkpolicy.yaml new file mode 100644 index 000000000..76f886c1f --- /dev/null +++ b/k8s/networkpolicy.yaml @@ -0,0 +1,46 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: openab + labels: + app: openab +spec: + podSelector: + matchLabels: + app: openab + policyTypes: + - Ingress + - Egress + ingress: [] + egress: + # DNS scoped to kube-system to prevent a co-tenant from labelling + # their own pod k8s-app=kube-dns to become an approved port-53 destination. + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: "kube-system" + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + # HTTPS to the open internet, IPv4 and IPv6. Cloud metadata service + # excluded (169.254.169.254/32 for IPv4, AWS pattern fd00:ec2::254/128 + # for IPv6) to block a common IAM-credential-theft path from a compromised + # pod. Non-AWS IPv6 metadata addresses may need a tighter exclusion — + # edit this manifest for your environment. + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 169.254.169.254/32 + - ipBlock: + cidr: ::/0 + except: + - fd00:ec2::254/128 + ports: + - protocol: TCP + port: 443 diff --git a/openab-agent/Cargo.lock b/openab-agent/Cargo.lock index 3f23465d2..1639209ae 100644 --- a/openab-agent/Cargo.lock +++ b/openab-agent/Cargo.lock @@ -319,6 +319,16 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -784,9 +794,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1826,7 +1838,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni", "log", @@ -1902,7 +1914,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -2137,6 +2149,27 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "temp-env" version = "0.3.6" @@ -2782,6 +2815,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" diff --git a/openab-agent/Cargo.toml b/openab-agent/Cargo.toml index 5f146ca21..d99bcd0cb 100644 --- a/openab-agent/Cargo.toml +++ b/openab-agent/Cargo.toml @@ -9,11 +9,16 @@ description = "Native Rust coding agent with built-in ACP support" tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +# `system-proxy` re-enables HTTPS_PROXY / HTTP_PROXY / NO_PROXY env-var auto-detection +# (default reqwest feature disabled by `default-features = false`). Lets operators route +# the native agent's LLM API calls (Anthropic / OpenAI / Bedrock-over-reqwest / etc.) +# and other reqwest HTTP calls through an HTTP CONNECT proxy by setting the env vars. +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "system-proxy"] } # rmcp 1.7 is on reqwest 0.13; its `StreamableHttpClient`/`AuthClient` impls are # for the 0.13 `Client`, so the OAuth transport must hold that type. The rest of -# the crate stays on 0.12. -reqwest013 = { package = "reqwest", version = "0.13", default-features = false, features = ["rustls", "json", "stream"] } +# the crate stays on 0.12. `system-proxy` re-enabled for the same reason as above so +# the OAuth transport also honors proxy env vars. +reqwest013 = { package = "reqwest", version = "0.13", default-features = false, features = ["rustls", "json", "stream", "system-proxy"] } anyhow = "1" async-trait = "0.1" uuid = { version = "1", features = ["v4"] }