diff --git a/charts/openbot/templates/networkpolicy.yaml b/charts/openbot/templates/networkpolicy.yaml index d188a428..ef80cb8f 100644 --- a/charts/openbot/templates/networkpolicy.yaml +++ b/charts/openbot/templates/networkpolicy.yaml @@ -180,3 +180,104 @@ spec: {{ toYaml . | indent 4 }} {{- end }} {{- end }} + +{{- if and .Values.networkPolicy.enabled (eq .Values.computers.mode "sandbox") .Values.computers.sandbox.culler.enabled }} +--- +{{- $culler := "culler" -}} +{{/* A pod no policy selects keeps the cluster default, so this one was the release's only unfenced one. */}} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "openbot.componentName" (dict "root" . "component" $culler) }} + labels: +{{ include "openbot.componentLabels" (dict "root" . "component" $culler) | indent 4 }} +spec: + podSelector: + matchLabels: +{{ include "openbot.componentSelectorLabels" (dict "root" . "component" $culler) | indent 6 }} + policyTypes: + - Ingress + - Egress + # No ingress rules, which under an Ingress policyType denies all of it. + ingress: [] + egress: + # DNS, or the database hostname does not resolve and the sweep reads as the database being down. + - ports: + - port: 53 + protocol: UDP + - port: 53 + protocol: TCP + {{- if .Values.postgresql.enabled }} + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: postgresql + ports: + - port: 5432 + protocol: TCP + {{- end }} + {{- /* Empty kubernetesApiCidr permits these ports to any address, as the server's rule above does. */}} + - {{- with .Values.networkPolicy.kubernetesApiCidr }} + to: + - ipBlock: + cidr: {{ . }} + {{- end }} + ports: + - port: 443 + protocol: TCP + - port: 6443 + protocol: TCP + {{- /* An external database, which extraEgress already had to name; cullerExtraEgress narrows it. */}} + {{- with (default .Values.networkPolicy.extraEgress .Values.networkPolicy.cullerExtraEgress) }} +{{ toYaml . | indent 4 }} + {{- end }} +{{- end }} + +{{- if and .Values.networkPolicy.enabled .Values.routines.enabled }} +--- +{{- $routines := "routines" -}} +{{/* Same reason as the culler; this pod holds no token and reaches only the database and the API server. */}} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "openbot.componentName" (dict "root" . "component" $routines) }} + labels: +{{ include "openbot.componentLabels" (dict "root" . "component" $routines) | indent 4 }} +spec: + podSelector: + matchLabels: +{{ include "openbot.componentSelectorLabels" (dict "root" . "component" $routines) | indent 6 }} + policyTypes: + - Ingress + - Egress + # No ingress rules, which under an Ingress policyType denies all of it. + ingress: [] + egress: + # DNS, or neither the database nor the server resolves and the sweep reads as both being down. + - ports: + - port: 53 + protocol: UDP + - port: 53 + protocol: TCP + {{- if .Values.postgresql.enabled }} + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: postgresql + ports: + - port: 5432 + protocol: TCP + {{- end }} + {{- /* The API server, where SERVER_INTERNAL_URL points and the only thing this pod calls. */}} + - to: + - podSelector: + matchLabels: +{{ include "openbot.componentSelectorLabels" (dict "root" . "component" "server") | indent 14 }} + ports: + - port: {{ .Values.server.service.port }} + protocol: TCP + {{- /* An external database, for the reason given on the culler's policy above. */}} + {{- with (default .Values.networkPolicy.extraEgress .Values.networkPolicy.routinesExtraEgress) }} +{{ toYaml . | indent 4 }} + {{- end }} +{{- end }} diff --git a/charts/openbot/values.yaml b/charts/openbot/values.yaml index 717bbb46..7bd6f2cc 100644 --- a/charts/openbot/values.yaml +++ b/charts/openbot/values.yaml @@ -421,6 +421,11 @@ networkPolicy: # Where a Bot's computer may reach beyond the public internet. A deployment whose Bots must reach # an internal site adds it here, one address at a time, rather than reopening the private ranges. computerExtraEgress: [] + # Extra egress for the culler. Empty falls back to `extraEgress`, where an external database is + # already named; set it to give the culler a narrower list. + cullerExtraEgress: [] + # The same, for the routines sweep. + routinesExtraEgress: [] podSecurityContext: runAsNonRoot: false diff --git a/scripts/check-new-values-keys.ts b/scripts/check-new-values-keys.ts index f87538c1..f9e950e3 100644 --- a/scripts/check-new-values-keys.ts +++ b/scripts/check-new-values-keys.ts @@ -357,11 +357,21 @@ for (const { path, component, field } of fieldFallbacks) { /* * Found by its component label rather than by name, because a name is the release name plus a * suffix and this check would then be pinned to both. + * + * Narrowed to pod-carrying kinds: a NetworkPolicy naming the same component carries the label too. */ + const WORKLOAD_KINDS = new Set([ + "CronJob", + "DaemonSet", + "Deployment", + "Job", + "StatefulSet", + ]); const carriers = parseAllDocuments(attempt.out) .map((document) => document.toJS() as unknown) .filter( (resource) => + WORKLOAD_KINDS.has(String(valueAt(resource, ["kind"]))) && valueAt(resource, [ "metadata", "labels", diff --git a/scripts/check-rendered-chart.ts b/scripts/check-rendered-chart.ts index dc5de112..171f899f 100644 --- a/scripts/check-rendered-chart.ts +++ b/scripts/check-rendered-chart.ts @@ -154,6 +154,41 @@ if (serverPolicy) { } } +// A pod no policy selects keeps the cluster default, so on a release that has policies it is the +// only unfenced one. Asked of rendered objects, because the question is which pods came out. +const policyComponents = new Set( + documents + .filter((document) => /^kind:\s*NetworkPolicy\s*$/m.test(document)) + .flatMap((document) => { + const selector = document.split(/^\s{2}podSelector:\s*$/m)[1] ?? ""; + const found = selector.match( + /app\.kubernetes\.io\/component:\s*([\w-]+)/, + ); + return found?.[1] ? [found[1]] : []; + }), +); +if (policyComponents.size > 0) { + const workloads = documents.filter((document) => + /^kind:\s*(Deployment|StatefulSet|CronJob|Job|DaemonSet)\s*$/m.test( + document, + ), + ); + for (const workload of workloads) { + const component = workload.match( + /app\.kubernetes\.io\/component:\s*([\w-]+)/, + )?.[1]; + const name = workload.match(/^\s{2}name:\s*(\S+)/m)?.[1] ?? "a workload"; + if (!component) continue; + // The migrations Job runs once at install and is torn down; it is not a standing surface. + if (component === "migrations") continue; + if (!policyComponents.has(component)) { + problems.push( + `This release has NetworkPolicies but none selects ${name} (component: ${component}), so it is the one pod left unfenced while everything around it is restricted. Give it a policy or say in the chart why it needs none.`, + ); + } + } +} + if (problems.length > 0) { for (const problem of problems) console.error(`::error::${problem}`); process.exit(1);