Skip to content

Allow UPF to run without privileged/root - #17

Merged
jpontongradiant merged 2 commits into
Gradiant:mainfrom
ninjab3s:fix_upf
Jul 27, 2026
Merged

Allow UPF to run without privileged/root#17
jpontongradiant merged 2 commits into
Gradiant:mainfrom
ninjab3s:fix_upf

Conversation

@ninjab3s

Copy link
Copy Markdown
Contributor

Summary

The UPF Deployment and its init container currently hardcode privileged: true + runAsUser: 0 with no way to override. This blocks running the operator on clusters/platforms that don't grant the privileged SCC by default — notably OpenShift, where that's a much bigger ask than the anyuid-style SCC the other Open5GS components already work under. This MR adds an opt-in spec.upf.unprivileged boolean field, default false — no behavior change for existing users unless they set it.

Design

  • The tun-create init container runs as root (not privileged) with only the NET_ADMIN capability. It creates a persistent TUN device owned by UID 1001 (ip tuntap add ... user 1001), sets its address, and adds the NAT rule.
  • The main open5gs-upf container runs fully non-root (UID 1001) with no added capabilities and invokes open5gs-upfd directly instead of the image's default entrypoint — that entrypoint redoes the same TUN/NAT/sysctl setup unconditionally on every start, which only works today because the main container already runs privileged. Skipping it means the main container does nothing beyond opening the already-configured device, which needs no capability at all.
  • Both containers mount /dev/net/tun via an explicit hostPath volume (type: CharDevice) — privileged: true's only real effect here was auto-mounting the whole host /dev.
  • net.ipv4.ip_forward is set via the pod's declarative securityContext.sysctls field rather than an in-container sysctl -w — /proc/sys stays read-only inside a container regardless of capabilities or UID. This requires the cluster's kubelet to allow it (--allowed-unsafe-sysctls) and, on OpenShift, the bound SCC too — a real platform prerequisite, not something this change can work around.
  • seLinuxOptions is deliberately left unset here: SELinux-enforcing nodes (RHCOS/OpenShift) need type spc_t for /dev/net/tun access (the default container_t domain has no policy for it), but the matching level is namespace-specific and can't be hardcoded — this is left to the bound SCC's own default instead.

Kubernetes does not set ambient/inheritable capabilities for non-root containers, so a plain capabilities.add on a non-root container isn't effective — only root gets its capability set applied automatically via the bounding set. That's why the init container stays root, but only for that short-lived step, never combined with privileged: true.

Checked against upstream open5gs's own docker/docker-compose.yml: it runs the exact same open5gs-upfd binary with only cap_add: [NET_ADMIN] and a /dev/net/tun device mount — no privileged anywhere. This brings the operator in line with that.

Testing

  • Live-tested on both a vanilla Kubernetes cluster and OpenShift (SCC-enforced, SELinux-enforcing): confirmed the main container runs with zero effective capabilities and non-root UID, PFCP/GTP-U/metrics all listening correctly.
  • Added unit tests (internal/controller/open5gs_resources_upf_test.go) covering both the unprivileged rendering and that the default (unset) path is byte-for-byte unchanged from before this change.
  • go build, go vet, gofmt, helm lint all clean.

Compatibility

Fully additive/opt-in. No behavior change for anyone not setting spec.upf.unprivileged: true.

ninjab3s added 2 commits July 25, 2026 14:18
Add an opt-in spec.upf.unprivileged field, default false with no change
in behavior unless set.

When enabled, an init container (root, not privileged, NET_ADMIN only)
creates a persistent TUN device owned by the main container's UID, sets
its address, and adds the NAT rule. The main container runs fully
non-root with no added capabilities and mounts the same device via a
hostPath volume, both containers sharing the pod's network namespace.

Kubernetes does not set ambient or inheritable capabilities for non-root
containers, so a plain capabilities.add on a non-root container is not
effective - only root gets its capability set applied automatically via
the bounding set. Root is therefore kept, but only for the short-lived
init step, and never combined with privileged: true.

net.ipv4.ip_forward is set via the pod's declarative
securityContext.sysctls field rather than an in-container sysctl -w,
since /proc/sys stays read-only inside a container regardless of
capabilities or UID - this requires the cluster's kubelet and the bound
SCC to both allow the sysctl.
The image's default entrypoint (invoked whenever no Command is set,
since Args just carries "open5gs-upfd") redoes the same TUN/NAT/sysctl
setup unconditionally on every start, in addition to what the init
container already did. That only works today because the main container
already runs privileged; it defeats the point of the non-root,
zero-capability main container the unprivileged mode is meant to give.

Set Command to invoke /opt/open5gs/bin/open5gs-upfd directly with its
config path, skipping that wrapper. The main container then does
nothing beyond opening the already-configured TUN device, which needs
no capability at all - only creating a TUN device does.

Also leave seLinuxOptions unset in the pod/container spec for the
unprivileged mode. SELinux-enforcing nodes (RHCOS/OpenShift) need type
spc_t to access /dev/net/tun - the default container_t domain has no
policy allowing it - but the matching level is namespace-specific and
can't be hardcoded here; setting only type without it fails SCC
validation. This is left entirely to the bound SCC's own seLinuxContext
default instead.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an opt-in unprivileged mode for the UPF component so it can run without privileged: true and without a root main container, aligning better with restricted platforms (e.g., OpenShift) while preserving the current default behavior.

Changes:

  • Introduces spec.*.unprivileged (intended for spec.upf.unprivileged) and wires it into UPF reconciliation/defaulting.
  • Updates UPF rendered resources to support an unprivileged mode (root + NET_ADMIN init container, non-root main container, explicit /dev/net/tun hostPath, pod sysctl for net.ipv4.ip_forward).
  • Adds unit tests for privileged vs unprivileged UPF rendering and entrypoint script differences.

Reviewed changes

Copilot reviewed 6 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
internal/controller/open5gs_resources.go Adds conditional UPF entrypoint script rendering and conditional UPF Deployment security/volume/sysctl behavior for unprivileged mode.
internal/controller/open5gs_resources_upf_test.go Adds unit tests covering unprivileged rendering and validating key default-path invariants.
internal/controller/open5gs_controller.go Wires unprivileged flag from the CR into UPF resource rendering and sets a default value.
api/v1/open5gs_types.go Adds Unprivileged field to the API type used by component specs.
api/v1/zz_generated.deepcopy.go Updates deepcopy generation for the new Unprivileged field.
config/crd/bases/net.gradiant.org_open5gses.yaml Extends CRD schema with unprivileged (currently replicated across all component blocks).
charts/open5gs-operator/templates/open5gs-crd.yaml Mirrors the CRD schema updates in the Helm chart template.
Files not reviewed (1)
  • api/v1/zz_generated.deepcopy.go: Generated file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread api/v1/open5gs_types.go
Comment on lines +56 to +57
// Unprivileged runs the UPF without privileged:true/root (UPF only).
Unprivileged *bool `json:"unprivileged,omitempty" default:"false"`
Comment on lines +67 to +70
unprivileged:
description: Unprivileged runs the UPF without privileged:true/root
(UPF only).
type: boolean
Comment on lines +68 to +71
unprivileged:
description: Unprivileged runs the UPF without privileged:true/root
(UPF only).
type: boolean
corev1 "k8s.io/api/core/v1"
)

func TestCreateUPFDeploymentDefaultUnchanged(t *testing.T) {
@jpontongradiant
jpontongradiant merged commit 0a3b375 into Gradiant:main Jul 27, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants