Skip to content

Network trust boundaries

What Dreadnode assumes about your cluster network, which credentials cross it in the clear, and how to segment it with NetworkPolicies you control.

The chart creates no NetworkPolicy resources. On a fresh install:

Terminal window
kubectl get networkpolicy -n <namespace>
# No resources found in <namespace> namespace.

Dreadnode expects your ingress controller to terminate TLS, and treats the pod network behind it as a trusted transport domain. Every hop between platform services is plain HTTP. A default install is plain HTTP at the edge too — global.scheme is http until you set it to https alongside global.tls.secretName, which TLS certificates covers.

Segmentation of the pod network is yours to define, because only you know what else runs in the cluster, which CNI enforces policy, and which internal hosts your agents are supposed to reach.

Platform services are one trusted zone. The API, frontend, docs site, sandbox server, sandbox gateway, sandbox controller, the three data stores, and the LiteLLM proxy when you enable it all share the pod network and talk over plain HTTP. The control is that only first-party workloads run there. Dreadnode ships no service mesh and no in-cluster mTLS, and adding one is not required for a supported install.

Sandbox pods are outside that zone. Sandboxes run agent code that is untrusted by design. The runtime image grants passwordless sudo, so agent code takes uid 0 on demand. Where egress enforcement is on it gives up CAP_NET_ADMIN and CAP_NET_RAW; where it is off, agent code holds the container’s full default capability set. Treat a sandbox pod as fully compromised either way and put your controls at the pod boundary rather than inside it.

What crosses the cluster network in the clear

Section titled “What crosses the cluster network in the clear”

These are the hops a security review asks about. Each carries a credential over plain HTTP between pods, so anything that can sniff the pod network or reach the service directly sees it.

HopWhat rides it
Ingress controller → APIEvery request, including the login password
Ingress controller → frontendThe session cookie on every page load
Frontend → APIThe same session cookie, on server-rendered requests
API → PostgreSQLDatabase credentials (config.database.useSsl is false by default)
API → ClickHouseDatabase credentials (config.clickhouse.protocol is http by default)
API → MinIOThe MinIO root account, not a scoped user
Bucket bootstrap Job → MinIOThe MinIO root account again, at install time
API → sandbox serverThe sandbox server’s shared API key
API → LiteLLMLITELLM_MASTER_KEY, the key that mints every per-sandbox model key
LiteLLM → PostgreSQLDatabase credentials, for its own schema
Sandbox → APIA full platform API key, plus the runtime token
Sandbox → LiteLLMA per-sandbox model proxy key
Ingress controller → MinIOScoped object storage credentials, for browser and sandbox reads

The LiteLLM rows exist only when you enable the bundled proxy. Treat the table as the set worth reviewing rather than a proof of completeness — the section below derives the real one from your own cluster.

Confirm your cluster enforces NetworkPolicy

Section titled “Confirm your cluster enforces NetworkPolicy”

A NetworkPolicy on a cluster whose CNI does not implement it is silently inert. The API server accepts the object, kubectl get networkpolicy lists it, no event or error is produced, and nothing is filtered. Verify enforcement first, or every policy below is decoration.

Terminal window
kubectl create namespace netpol-check
kubectl label namespace netpol-check pod-security.kubernetes.io/enforce=privileged
kubectl -n netpol-check run server --image=nginx:alpine --port=80
kubectl -n netpol-check expose pod server --port=80
kubectl -n netpol-check wait --for=condition=Ready pod/server --timeout=60s
kubectl -n netpol-check apply -f - <<'EOF'
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-all-ingress
spec:
podSelector: {}
policyTypes:
- Ingress
EOF
kubectl -n netpol-check run probe --rm -i --restart=Never --image=busybox \
-- wget -qO- -T 5 http://server
# Enforcing: wget: download timed out, or "can't connect to remote host: Connection refused"
# on a CNI that rejects rather than drops
# Not enforcing: the nginx welcome page
kubectl delete namespace netpol-check

Embedded Cluster installs run k0s with Calico, which enforces NetworkPolicy. On your own cluster it depends on the CNI: Calico and Cilium enforce by default, Amazon’s VPC CNI needs the network policy agent enabled on the addon, and a plain flannel install does not enforce at all. Substitute images already present in your registry if the cluster has no public egress.

Two rules that govern every policy you write

Section titled “Two rules that govern every policy you write”

A pod matched by no policy is unrestricted. Selecting a pod is what constrains it. Until a policy’s podSelector matches a pod, that pod accepts and originates anything.

Every rule is an allow rule. There is no deny. Once a pod is selected for a policy type, only traffic matching some rule is permitted, and any additional policy can only widen what is allowed. To block one destination you must write a rule that allows everything else, and that block survives only while no other policy selects the same pods for the same direction.

Derive the client set from your own cluster

Section titled “Derive the client set from your own cluster”

The policies below name the clients a default install has. Yours differs — you may run the LiteLLM proxy, external data stores, a backup tool, or your own workloads against the same services. Read the live connections before you write a rule, rather than trusting the tables on this page:

Terminal window
# Peer addresses of every established connection into PostgreSQL.
# If the image has no `ss`, attach one that does:
# kubectl debug -n <namespace> -it <release>-postgresql-0 \
# --image=nicolaka/netshoot --target=postgresql -- ss -Hnt state established
kubectl exec -n <namespace> <release>-postgresql-0 -- \
sh -c "ss -Hnt state established '( sport = :5432 )'" | awk '{print $4}'
# Map those peer IPs back to pods across every namespace
kubectl get pods --all-namespaces -o wide

Repeat for ClickHouse (:8123, :9000) and MinIO (:9000), and do it while the system is doing real work — a running evaluation, an agent task reading a dataset, a backup — not on an idle cluster. Anything you see and do not allow, you are about to break.

Restrict the data stores to their real clients

Section titled “Restrict the data stores to their real clients”

Only ClickHouse has a single client. The other two do not, and the difference is what makes a data-store policy break an install:

StorePortsClients
ClickHouse8123, 9000The API
PostgreSQL5432The API, including its migration init container; the LiteLLM proxy when enabled, which keeps its own schema
MinIO9000, and 9001 with the console enabledThe API; the bucket bootstrap Job; your ingress controller

MinIO is the one to get right, and the surprise is that sandboxes are not on that list. Object storage credentials and presigned URLs both carry the external storage.<your-domain> address, so agent code reading a dataset, pulling a model, or installing a capability bundle leaves the pod, reaches your ingress controller, and arrives at MinIO from there. Browsers downloading artifacts take the same path. A policy that omits the ingress controller breaks every one of them.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: clickhouse-platform-only
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: clickhouse
app.kubernetes.io/instance: <release>
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: dreadnode-api
app.kubernetes.io/instance: <release>
ports:
- { port: 8123, protocol: TCP }
- { port: 9000, protocol: TCP }
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: postgresql-platform-only
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: postgresql
app.kubernetes.io/instance: <release>
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/instance: <release>
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values: [dreadnode-api, dreadnode-litellm]
ports:
- { port: 5432, protocol: TCP }
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: minio-platform-and-sandboxes
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: minio
app.kubernetes.io/instance: <release>
app.kubernetes.io/component: storage
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: dreadnode-api
app.kubernetes.io/instance: <release>
# The bucket bootstrap Job also carries name=minio, so match its component
- podSelector:
matchLabels:
app.kubernetes.io/name: minio
app.kubernetes.io/instance: <release>
app.kubernetes.io/component: bootstrap
# Browser downloads and sandbox object-store reads both arrive here.
# Replace with the namespace and labels your controller actually uses.
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: traefik
ports:
- { port: 9000, protocol: TCP }

Apply them with -n <namespace>; they carry no namespace field so the same file works against any release.

Two more clients the chart cannot know about: your backup tooling, which usually reaches PostgreSQL and MinIO directly, and anything of your own you have pointed at these services. On strict CNIs, note that MinIO is the only data store with an httpGet readiness probe — kubelet probe traffic originates from the node rather than a pod, and is permitted only because Calico and Cilium special-case it. If you point the platform at an external data store, these policies stop applying to it; the traffic leaves the cluster and your network controls take over.

Sandbox pods are created at runtime by the sandbox server rather than rendered by the chart, so they carry their own labels. Every sandbox pod has opensandbox.io/id, with a value unique per sandbox, so select on key existence:

podSelector:
matchExpressions:
- key: opensandbox.io/id
operator: Exists

The sandbox server reserves the opensandbox.io/ prefix and rejects it in request metadata, so agent code cannot relabel its way out of a policy.

Blocking the cloud metadata service is the cheapest restriction worth making. Agent code with root can otherwise query it for the node’s instance credentials:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: sandbox-deny-cloud-metadata
spec:
podSelector:
matchExpressions:
- key: opensandbox.io/id
operator: Exists
policyTypes:
- Egress
egress:
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 169.254.169.254/32

The single rule permits every destination and port except the metadata endpoint, DNS and pod-to-pod traffic included. Three things to know before relying on it:

  • It holds only while no other Egress policy selects sandbox pods. Add one that allows everything, and metadata reach comes back with no error and no signal.
  • ipBlock handling of in-cluster destinations varies by CNI. Run a real agent task against it before you trust it, as the enforcement check above notes.
  • Skip it if your agents are meant to assess cloud metadata services as targets.

Keeping sandboxes off the data stores is better done from the other side, with the ingress policies above — none of the three takes a direct connection from a sandbox pod, so restricting them cannot break agent code. What a sandbox does need is the route to storage.<your-domain>, which runs through your ingress controller rather than the pod network.

Sandboxes land in the release namespace by default. dreadnode-sandbox-server.kubernetes.namespace pins them elsewhere, which makes the boundary easier to name — but a from: podSelector peer only matches pods in the policy’s own namespace, so pinning means adding a namespaceSelector to the MinIO policy above or object storage access stops working.

NetworkPolicy operates on IP addresses and ports. It cannot express a hostname, inspect a TLS SNI, or distinguish two sites behind the same CDN address. Any rule that tries to allow “the model provider” or “the package index” collapses into allowing all outbound HTTPS, which restricts nothing.

Destination-level egress control for agent code belongs somewhere that can resolve a name. For sandbox pods, that is the egress sidecar: it filters by hostname and wildcard as well as by address, and it runs inside the sandbox pod’s own network namespace, so it does not depend on your CNI implementing NetworkPolicy at all. See Restrict sandbox egress.

That leaves NetworkPolicy the coarse question of which pods can reach which services. The two compose: the sidecar governs what a sandbox can reach outbound, and the policies on this page govern what can reach your data stores inbound. Neither replaces the other, and a sandbox pod is covered by both.

For the service topology these policies assume, see Architecture. For the controls that constrain a sandbox from the inside, see Sandbox runtime.