From 3135162fa65942325d15aa98a472cfb8975bf7f0 Mon Sep 17 00:00:00 2001 From: Martin Matyas Date: Fri, 16 Oct 2026 20:21:06 +0200 Subject: [PATCH] feat(docker): file capabilities on the eupf binary, so it can run as a non-root user eUPF needs no root: BPF and PERFMON load the programs (SYS_ADMIN on kernels before CAP_BPF), NET_ADMIN attaches XDP and sets routes, SYS_RESOURCE raises RLIMIT_MEMLOCK. The one thing uid 0 did that the capabilities don't is creating the map pin directory under /sys/fs/bpf, whose root directory belongs to root; DAC_OVERRIDE covers that. The runtime stage now sets these as file capabilities (=ep) on /app/bin/eupf. The container's bounding set still decides which of them the process can have, so nothing changes for a container that doesn't grant them, and the image keeps running as root by default. The entrypoint mounts bpffs and debugfs only when it runs as root: a non-root process cannot, and expects the node's bpffs at /sys/fs/bpf, as systemd hosts provide. The chart documents the non-root security context and the bpffs prerequisite; its default stays root until an image release carries the file capabilities. Verified on a fork with the 0.7.1 source built this way: eupf runs as uid 1000 with exactly the intended effective capabilities, attaches XDP, and the CNTi suite's non_root_containers test passes. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Martin Matyas --- .deploy/helm/eupf/Chart.yaml | 2 +- .deploy/helm/eupf/README.md | 15 ++++++++++++++- .deploy/helm/eupf/values.yaml | 17 +++++++++++++++-- Dockerfile | 12 ++++++++++++ entrypoint.sh | 16 ++++++++++------ 5 files changed, 52 insertions(+), 10 deletions(-) diff --git a/.deploy/helm/eupf/Chart.yaml b/.deploy/helm/eupf/Chart.yaml index 1fe12237..7866fccf 100644 --- a/.deploy/helm/eupf/Chart.yaml +++ b/.deploy/helm/eupf/Chart.yaml @@ -1,6 +1,6 @@ apiVersion: v2 name: eupf -version: 0.7.1 +version: 0.7.2 appVersion: 0.7.1 description: Helm chart for deploy eUPF to Kubernetes icon: https://avatars.githubusercontent.com/u/119618995?s=200&v=4 diff --git a/.deploy/helm/eupf/README.md b/.deploy/helm/eupf/README.md index 0b1a20d6..b5aac652 100644 --- a/.deploy/helm/eupf/README.md +++ b/.deploy/helm/eupf/README.md @@ -34,7 +34,20 @@ The default values produce a working pod: eUPF is configured via `UPF_*` environ privileged: true ``` -- The `/sys` hostPath is mounted read-only into the pod (required by the entrypoint to mount bpffs/debugfs). +- **Non-root** (optional): the image carries `NET_ADMIN`, `SYS_RESOURCE`, `BPF` and `PERFMON` as file capabilities on the `eupf` binary, plus `DAC_OVERRIDE` so it can create its map pin directory under `/sys/fs/bpf`, which root owns. With them the container runs as a non-root user, with less privilege than root plus capabilities: `SYS_ADMIN` is not needed, since a non-root entrypoint mounts nothing and the kernels the chart supports have `CAP_BPF`: + + ```yaml + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + runAsNonRoot: true + capabilities: + add: [NET_ADMIN, SYS_RESOURCE, BPF, PERFMON, DAC_OVERRIDE] + ``` + + Grant exactly that set, as root or not: a binary with file capabilities fails to exec (`Operation not permitted`) when one of them is missing from the container's bounding set. The chart's defaults do; `docker run` needs `--privileged` or `--cap-add` for each. Leave `allowPrivilegeEscalation` unset: file capabilities need it. A non-root process cannot mount bpffs itself, so the **node must have bpffs mounted at `/sys/fs/bpf`**, as systemd hosts do (`sys-fs-bpf.mount`). kind nodes don't; bind the host's in with `extraMounts: [{hostPath: /sys/fs/bpf, containerPath: /sys/fs/bpf}]` on every node. + +- The `/sys` hostPath is mounted read-only into the pod: as root the entrypoint mounts bpffs/debugfs under it, as non-root eUPF reaches the node's bpffs through it. ## Configuration diff --git a/.deploy/helm/eupf/values.yaml b/.deploy/helm/eupf/values.yaml index 740a17b7..5aed6b4a 100644 --- a/.deploy/helm/eupf/values.yaml +++ b/.deploy/helm/eupf/values.yaml @@ -95,8 +95,9 @@ configMaps: {} # foo: bar # volumes. -# The /sys hostPath volume is required: the container entrypoint mounts -# bpffs and debugfs under /sys to load the eBPF data plane. +# The /sys hostPath volume is required: as root, the container entrypoint +# mounts bpffs and debugfs under /sys to load the eBPF data plane; as a +# non-root user (see securityContext) eUPF uses the node's bpffs through it. volumes: - name: sys hostPath: @@ -232,6 +233,18 @@ podSecurityContext: # blocks mount or bpf, a runtime that does not know CAP_BPF on a kernel # before 5.8), fall back to a privileged container: # privileged: true +# The image carries NET_ADMIN, SYS_RESOURCE, BPF and PERFMON as file +# capabilities on the eupf binary, plus DAC_OVERRIDE to create its map pin +# directory under /sys/fs/bpf, so the container can run as a non-root user +# wherever the node has bpffs mounted at /sys/fs/bpf (systemd hosts do; the +# README covers kind). Grant exactly that set: with one missing, exec fails +# with EPERM. SYS_ADMIN is not needed without root (no mounts, CAP_BPF on the +# kernels the chart supports): +# runAsUser: 1000 +# runAsGroup: 1000 +# runAsNonRoot: true +# capabilities: +# add: [NET_ADMIN, SYS_RESOURCE, BPF, PERFMON, DAC_OVERRIDE] securityContext: capabilities: add: [NET_ADMIN, SYS_ADMIN, SYS_RESOURCE, BPF, PERFMON] diff --git a/Dockerfile b/Dockerfile index 753f0709..beeeacab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,5 +28,17 @@ COPY --from=builder /app/cmd/docs/swagger.* /app/ COPY --from=builder /app/cmd/ebpf/zeroentrypoint_bpf.o /app/ COPY ./entrypoint.sh /app/bin/entrypoint.sh +# File capabilities, so eupf gets what it needs without running as root: +# BPF and PERFMON load the eBPF programs (kernel 5.8+, the chart requires +# 5.15), NET_ADMIN attaches XDP and sets routes, SYS_RESOURCE raises +# RLIMIT_MEMLOCK, and DAC_OVERRIDE creates the pin directory under +# /sys/fs/bpf, which is owned by root. Whoever runs the image, as root or +# not, must grant all of them (the chart does; docker needs --privileged or +# --cap-add): exec of a binary with the effective bit set fails with EPERM +# when one is missing from the bounding set. +RUN apk add --no-cache libcap \ + && setcap cap_net_admin,cap_sys_resource,cap_bpf,cap_perfmon,cap_dac_override=ep /app/bin/eupf \ + && apk del libcap + # CMD is overridden if arguments are passed. ENTRYPOINT [ "sh", "/app/bin/entrypoint.sh" ] diff --git a/entrypoint.sh b/entrypoint.sh index 87fd60df..e2f1dff4 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,11 +1,15 @@ #!/bin/env sh -# Mount bpffs and debugfs if not present already -if [[ $(/bin/mount | /bin/grep /sys/fs/bpf -c) -eq 0 ]]; then - /bin/mount bpffs /sys/fs/bpf -t bpf; -fi -if [[ $(/bin/mount | /bin/grep /sys/kernel/debug -c) -eq 0 ]]; then - /bin/mount debugfs /sys/kernel/debug -t debugfs; +# Mount bpffs and debugfs if not present already. Only root can mount: as a +# non-root user the node has to provide bpffs at /sys/fs/bpf (the chart's +# README says how). +if [ "$(id -u)" -eq 0 ]; then + if [[ $(/bin/mount | /bin/grep /sys/fs/bpf -c) -eq 0 ]]; then + /bin/mount bpffs /sys/fs/bpf -t bpf; + fi + if [[ $(/bin/mount | /bin/grep /sys/kernel/debug -c) -eq 0 ]]; then + /bin/mount debugfs /sys/kernel/debug -t debugfs; + fi fi # Run app replacing current shell (to preserve signal handling) and forward cmd arguments