better/compute

bcd helper

How bcd helper runs as root beside an unprivileged bcd node, which callers it serves, the versioned request format, every operation family and what root checks before it acts.

bcd helper is the root half of bcd's privilege separation (PLAN.md §6, v0.4; 28dc2ae). With [helper] socket and [helper] user set in config.toml, bcd node runs as an unprivileged account and sends the few things that need root to bcd helper, which runs as root beside it and serves typed operations over a local unix socket. The node parses everything that comes from the network: the session, commands, specs, tunnels. The helper parses only its own small requests, and trusts nothing in them.

This page is the reference for the helper itself: who it serves, the wire format, each operation family and the checks root makes before it acts. The daemon overview gives the summary, and running bcd node unprivileged covers the install script that sets it up.

Tested end to end, not on a production host

The helper is tested in three layers. Go unit tests in internal/helper run each operation against fakes (a fake virsh, fake systemd-run, tpmsim, a fake host for config units). tests/e2e/bcd.test.ts runs an unprivileged fakehw bcd node beside a real bcd helper binary, which writes and removes a GPU hold's CDI spec and swaps in a signed self-update. The root end-to-end test (node scripts/helper-e2e.mjs, da8751e) runs the install script's --unprivileged mode and a real bcd helper as root, inside private mount and network namespaces in WSL, and drives it as the bcd user through bcd helper-call. Its steps cover signed host units, libvirt definitions against a fake virsh, cloud-hypervisor taps on a real bridge (b9667d5) and self-update swap and rollback as root (035fc41). No hardware TPM, real libvirtd or real VMM has run under the helper.

Why a helper

Without [helper], bcd node runs as root under systemd hardening (see the systemd unit). Everything the agent parses is then parsed by root: the session protocol, desired state, signed commands, tunnel frames, plugin traffic. A parser bug there gives an attacker root on the machine.

With the helper, root's attack surface is one socket that takes one JSON request per connection. Each operation is small and typed: a file is named by a kind and an id rather than a path, a GPU by a UUID the host has, a config unit by its name and settings, a domain by an instance id. Root rebuilds each privileged action from those values and its own view of the host, so a compromised node can ask only for what the node could already legitimately ask for.

What the node keeps doing with its own rights: the journal, the identity and key record in data_dir, the status socket, reading /dev/kmsg for XIDs, libvirt's read-only socket (virsh -r for list, domstate, dumpxml and domstats), the cloud-hypervisor runtime's files (shared with each VMM's uid through ACL entries) and each VMM's API socket.

An unprivileged bcd node without [helper] on a real host cannot write /etc/cdi or create hold slices. Its Hello then claims no GPU binding capability, and a hold that arrives anyway fails at once with reason node-unprivileged instead of retrying every 30 seconds (da60bbd).

The socket and the peer check

For every connection, the helper reads the caller's pid, uid and gid from the kernel with SO_PEERCRED (peer_linux.go). The helper is Linux only for this reason. A caller is served only when both of these hold:

CheckRule
AccountThe uid and gid are both [helper] user's (its uid and primary group), or the uid is 0
Executable/proc/<pid>/exe is the same file (os.SameFile) as an installed bcd: [update] entrypoint, bcd-a or bcd-b in the slots directory, or the helper's own binary

A refused caller gets only not allowed. The helper logs helper: connection refused with the pid, uid and reason (uid <n> gid <n> is not <user>, or the peer does not run an installed bcd binary). A copy of bcd run from elsewhere by the bcd user is refused, and so is any other account.

The socket is created so that root never changes a path the node could swap for a symlink (2d67b25):

  1. The socket's directory is created 0755 if missing. As root, the helper refuses to start unless that directory is root's and closed to group and other writes (must be a directory root owns that no one else can write).
  2. It binds the socket inside a private .bcd-helper-* directory made with mkdtemp, chmods it 0660, chowns it to root and the node account's primary group, then renames it into place. A stale socket is replaced atomically by that rename.

The install script uses /run/bcd-helper/helper.sock, in the helper unit's own RuntimeDirectory. It never uses /run/bcd, which the bcd user owns. The package default helper.DefaultSocket is /run/bcd/helper.sock, but the configuration must name the socket explicitly: without [helper] socket the helper exits 1 ([helper] socket and user are not configured).

Requests

One connection carries one request and one answer, as newline-delimited JSON:

Request (host.unit)
{"v":1,"op":"host.unit","unit":"sysctl","settings":{"net.ipv4.tcp_keepalive_time":600},"intent":{"body":"...","signature":"..."}}
Response
{"v":1,"ok":false,"error":"host.unit: the settings of sysctl are not the signed spec's"}
RuleValue
Versionv is helper.Version, 1. A request of another version is answered protocol version <n>; this helper speaks 1, and the client refuses an answer of another version
DecodingStrict: unknown fields are refused on both sides (malformed request), so a node and a helper of different builds refuse each other rather than guess
SizeA request is at most 4 MiB (MaxRequest), an answer at most 1 MiB, a written file at most 1 MiB (MaxFile)
TimeEach side gives a request 2 minutes; host.unit, libvirt.* and ch.* get 30 minutes plus one on the connection
ConcurrencyAt most 32 requests at once (maxConns); a connection over the cap is answered busy: the helper is serving its maximum of requests; retry
Terminal refusalsrefused: true marks a self-update refusal no retry can fix (selfupdate.Refusal); the node then reports the update as failed instead of retrying
Resultsresult carries an operation's JSON answer (update.*, dmi.read, hw.read, tpm.*). libvirt.console answers with a file descriptor (SCM_RIGHTS) beside the JSON

Every operation is logged as helper: operation with the peer's pid, op, the file kind and name, the gpu, the update version, the config unit, the instance (for ch.*) and the outcome. The one exception is a successful tpm.sign: every TLS handshake and node statement signs, so only its refusals are logged.

A self-update replaces only the node's binary. The helper keeps running the binary it started with until it restarts, which works while both speak protocol version 1. A rerun of the install script restarts both units.

Operations

The table lists every operation Server.do dispatches. Anything else is unknown operation "<op>". An operation whose backend this helper was not configured with answers ... not configured on this helper.

FamilyOperationsSent by the node for
Filesfile.write, file.removeCDI specs and allocation env files (devices), sshd files (access), the kubeadm bootstrap token
Firewallfirewall.apply, firewall.checkThe instances' nftables tables and their 5-second check
Host actionsgpu.reset, reboot, ssh.reloadgpu.reset, node.reboot, a changed sshd drop-in
Config unitshost.unitEach NodeSpec.host unit that is not converged
Hold slicesslice.apply, slice.removeGPU holds with limits
Self-updateupdate.status, update.prepare, update.swap, update.confirm, update.rollback, update.settleThe daemon section and daemon.upgrade
libvirtlibvirt.define, .start, .shutdown, .destroy, .suspend, .resume, .undefine, .blockresize, .setmem, .attach-disk, .detach-disk, .save, .restore, .consoleThe libvirt-kvm runtime
cloud-hypervisorch.taps, ch.untaps, ch.vfio, ch.unvfio, ch.launch, ch.stopThe cloud-hypervisor runtime
TPMtpm.nodekey, tpm.sign, tpm.ak, tpm.certify, tpm.releaseThe node key in root's TPM
Inventory readsdmi.read, hw.readRoot-only DMI values, DIMMs, NVMe health and the BMC

PLAN.md also names runtime.* and mig.apply operations. They do not exist: runtimes go through libvirt.* and ch.*, and a MIG layout is the mig-layout config unit, applied through host.unit.

Files

The node never names a path. helper.Client.WriteFile maps the absolute path the node would have written to a kind and an id (Classify) and refuses any path outside the table before it sends anything. The helper builds the path again from its own table:

KindPathIdMode
cdi/etc/cdi/bcd-<id>.json^[A-Za-z0-9_-]{1,128}$0644
allocation-env/run/bcd/allocations/<id>.env^[A-Za-z0-9_-]{1,128}$0644
ssh-dropin/etc/ssh/sshd_config.d/10-better-compute.confnone0644
ssh-ca/etc/ssh/bc_ca.pubnone0644
ssh-krl/etc/ssh/bc_krlnone0644
ssh-principals/etc/ssh/bc_principals/<id>^[A-Za-z0-9._@-]{1,64}$0644
k8s-token/var/lib/bcd-hostconfig/k8s/bootstrap-tokennone0600

What root checks before it writes:

  • The kind and id. An unknown kind is "<kind>" is not a kind of file bcd owns. An id that does not match is refused, and a kind without ids refuses any name (<kind> takes no name). Data over 1 MiB is refused.
  • The directory walk. The helper opens / as an os.Root and walks each component of the kind's directory with Lstat. A symlink or anything that is not a directory is refused (... is not a plain directory), and a missing one is created 0755. Running as root on the real filesystem, every directory on the way must also be root's and closed to group and other writes (02485b3), or the write fails with ... is not a directory only root can write.
  • The write. It creates a temporary .<name>.bcd-helper exclusively (O_EXCL) in that directory, writes it, sets the mode and renames it over the target. A symlink planted at the target is replaced, never followed. file.remove removes the file and treats a missing one as done.

Firewall

firewall.apply loads the ruleset the instances converger renders (nft -f -). Root accepts only that ruleset's shape (CheckRuleset):

  • at the top level, only table, delete table and flush table lines, and table … { blocks, of exactly three tables: bridge bcd_instances, inet bcd_taps (933c51b) and arp bcd_taps (b12c217). The same name in another family is refused;
  • inside a block, no nested table;
  • no line starting with include or define, and no line containing ruleset;
  • balanced braces.

firewall.check takes a rule count from 0 to 1,000,000. Root lists the three tables with nft -j and compares; an absent inet or arp bcd_taps counts as no rules. What the rules do is on instances and bcd's nftables tables.

gpu.reset, reboot and sshd

OperationRoot checksRoot does
gpu.resetThe UUID matches ^GPU-[0-9A-Fa-f-]{8,64}$ and is a GPU in the host's own inventory, read at each requestnvidia-smi --gpu-reset -i <uuid>
rebootThe reason is cut to 200 bytesThe host's reboot through systemd
ssh.reloadNothing: it takes no argumentsystemctl reload-or-restart ssh, then sshd

The node still refuses a gpu.reset command for a GPU with a live hold before it asks (see gpu.reset and live holds). The helper's own check is only that the GPU is the host's.

host.unit

host.unit applies one NodeSpec.host config unit as root. The node plans every unit itself, unprivileged, and sends only the units that are not converged: the unit name, its settings (none when the unit left the spec and should be cleaned) and the signed spec that asks for it. The full rules are on host configuration under bcd helper. In short, root checks:

CheckRefusal
Root's own [host] units is not false (3fa9021)host.unit: this host's configuration is managed outside bcd ([host] units = false in root's config.toml)
The unit name matches ^[a-z0-9-]{1,64}$"<name>" is not a config unit name
At most 1024 busy GPUs in the requesttoo many busy GPUs
The signed spec: pinned or recorded command-signing roots, a worker signature on a DesiredStateBody, this node, a generation not older than the last, settings byte for byte the signed ones (03c023d, 2d18327, a966cd4, 71ccf71)host.unit: ..., see signed specs
The unit's own validation, run again as root (PlanUnit), and the sysctl denylistThe unit's own error

Root ignores the node's list of busy GPUs (a894cd2) and works it out itself (rootBusyGPUs): the GPUs with compute processes (nvidia-smi --query-compute-apps=gpu_uuid; a MIG- UUID marks every GPU busy) and the GPUs named by the live holds' CDI specs in /etc/cdi/bcd-*.json. A k8s-join under the helper also needs root's own [kubernetes] allowed_clusters (RequireAllowed: true).

Root keeps two records for this operation in /var/lib/bcd-hostconfig:

FileWritten byHolds
join-response.pb (0600)bcd join run as root on a node with [helper], before the identity is writtenThe verified JoinResponse as signed. The helper reads the node id and command-signing roots from it, re-verified against root's join_signing_key_pin
helper-intent.jsonThe helpernodeId, the newest accepted generation and bodySha256, the newest trustVersion, and signed once a trust bundle with roots was accepted

Slices

slice.apply and slice.remove create and remove a GPU hold's cgroup slice (260e33c). Root checks the slice name against ^bcd-alloc-[A-Za-z0-9_]{1,80}\.slice$ and every cpulist against the cpulist grammar (slice.Check), so only such names and lists reach systemctl and cgroupfs. slice.apply needs limits. slice.remove stops a slice only once its cgroup.events shows populated 0, and otherwise answers the slice still runs processes. Both are bounded to 30 seconds (SliceTimeout). See device assignments.

Self-update

The update.* operations are self-update's privileged half, selfupdate.Manager, with the slots and records in root's [update] slots_dir (default /usr/local/lib/bcd under the helper). The node only downloads into <data_dir>/staging and restarts. Root decides from its own configuration and files, never from the node's claims:

OperationRoot checks and does
update.statusAnswers the pending update, the release record and the slots
update.prepareVerifies the target: the manifest under root's [update] release_pins and threshold, the product, version, release floor and expiry, and the artifact for this platform. Answers the artifact to download, or nothing when the other slot already holds it
update.swapVerifies again, opens bcd-<version> beneath the staging directory without following a symlink or blocking on a FIFO, takes only a regular file with one link owned by the node's uid (StagedUID), copies it into the other slot hashing as it writes, records upgrade.json and swaps current
update.confirmMoves the release floor up, only while current is the pending slot
update.rollbackSwaps back a pending update to the version named
update.settleDecides what a starting node's start means from where current points, never from the version the node claims (c139b9c)

The helper arms its own watchdog after each swap, and again at start when an update is pending. An update still unconfirmed 30 seconds after its deadline is swapped back by the helper, which then runs systemctl restart bcd.service. Self-update has the whole flow.

libvirt

Under the helper, the bcd user is not in the root-equivalent libvirt group (ed3c7d2, d418820). The libvirt-kvm runtime sends every privileged virsh verb to the helper, which runs virsh -c qemu:///system as root:

OperationRoot checks
libvirt.defineThe XML passes the default-deny domain validator (ValidateDomainXML, c305a05, fea4d2e), and no domain of that name exists that bcd did not define. The definition is staged in root's own directory before virsh define
start, shutdown, destroy, suspend, resume, undefineThe domain name matches ^[a-z]{2,8}_[0-9a-z]{10,52}$ and its dumpxml carries bcd's metadata namespace naming it. start may add --paused; undefine adds --nvram --managed-save
libvirt.blockresizeA ^vd[a-z]{1,2}$ target and a non-zero size
libvirt.setmemAt least 1 MiB, applied --live (the balloon)
libvirt.attach-disk, libvirt.detach-diskOnly target vdz (the identity rebind disk), live; the attached file passes the same file checks as a domain's disks
libvirt.saveThe destination is in the instances' state directories; root saves into its own staging directory first, then copies through an os.Root and hands the file to the node's uid
libvirt.restoreA definition is required (b6df6f1), checked like a define and for the same domain, and always passed as --xml; the state file is checked as the node's own regular file
libvirt.consoleA bcd domain with a pty console; root opens it (O_NOCTTY) and passes the descriptor back

Since 5bb9f22 the helper also turns NVIDIA persistence mode off for a domain's passthrough GPUs before libvirt.start and back on after libvirt.destroy, because nvidia-persistenced holds the GPUs open and libvirt's managed hostdevs cannot unbind them while it does. The validator's admitted elements are listed on libvirt through the helper.

cloud-hypervisor

Since 7fe74a1 the cloud-hypervisor runtime runs under the helper too. Each ch.* request names an instance id (the domain-name pattern above), and root reads everything else from its own checks:

OperationRoot checks and does
ch.tapsAt most 16 NICs; each bridge must be in root's [helper] instance_bridges; creates exactly runtime.TapName(instance, i), owned by the node
ch.untapsDeletes only those names, and refuses an interface of that name that is not a tap
ch.vfioTakes the GPUs from the instance's vm.json; only this host's GPUs, none root sees holding work, none whose /dev/vfio group a running VMM unit's user owns (94843ec), and only groups whose other devices are functions of this host's GPUs or PCI bridges. Binds them to vfio-pci and gives /dev/vfio/<group> (0600) to the VMM's uid
ch.unvfioGives groups back to root and unbinds them, never a group a running VMM unit's user owns
ch.launchReads and checks the instance record as root (runtime.CHRecord), then runs systemd-run with the confined unit it builds itself (runtime.CHLaunch) and root's own cloud-hypervisor binary, one launch at a time
ch.stopStops bcd-ch-<id>.service, answers its state, and gives back exactly the VFIO groups its user owned

Without a cloud-hypervisor binary on root's PATH, every ch.* is refused. The details are on cloud-hypervisor through the helper.

TPM

Since 8870b8d (security review 3) an unprivileged bcd node never opens /dev/tpmrm0. The kernel's resource manager filters no command, so a user in the tss group could change the hierarchies' authorization, clear the TPM or trip its lockout. The helper opens the TPM as root and serves five operations on node keys named by a 32-byte unique:

OperationDoesRefuses
tpm.nodekeyDerives the node key from tpm.NodeKeyTemplate and answers its TPMT_PUBLICA unique that is not 32 bytes
tpm.signSigns a digest (ASN.1 ECDSA)A digest that is not 32, 48 or 64 bytes
tpm.akAnswers the AK's public area
tpm.certifyTPM2_Certify of the key by the AK over 32 bytes of qualifying data (a renewal's sha256(csr))Qualifying data that is not 32 bytes
tpm.releaseUnloads the keyA unique that is not 32 bytes

Root builds every TPM command, always with the template's empty password. The node chooses no template, handle, hierarchy or authorization. At most 8 node keys stay loaded; the least recently used is unloaded and derived again on its next use. A helper on a host without a TPM refuses all five (node keys in the TPM are not served by this helper (the host has no TPM)). The node checks every answer: the key's attributes must be tpm.NodeKeyAttributes, every signature must verify against the key's public half, and a certification must come from the AK the helper announced. See node identity.

Inventory reads

Two read-only operations give an unprivileged node the inventory details sysfs keeps root-only. Both take no arguments: a request with any field besides v and op is refused (dmi.read takes no arguments).

OperationReadsBounds
dmi.read (29ed48d)product_uuid, product_serial, board_serial, chassis_serial, board_asset_tag and chassis_asset_tag under /sys/class/dmi/id, the values the hardware key is built fromEach file opened without following a symlink, regular files only, at most 128 bytes, printable ASCII only, trimmed; no SMBIOS answers an empty map
hw.read (32beca7, 45bc17f)DIMMs parsed from SMBIOS type 17 entries (/sys/firmware/dmi/entries/17-*/raw; only parsed values go back, never the raw table), each NVMe controller's SMART / Health log page, and the BMC as it answers in-band IPMIAt most 64 entries and 64 controllers, 4 KiB per SMBIOS entry, 30 seconds overall; a read that overruns is left out, as for an unprivileged read

The node asks for dmi.read with a 10-second timeout and hw.read with 45 seconds. Either failing reads as "no values": the inventory then carries what the unprivileged read saw. The IPMI BMC read is inventory only, never an attestation input, and its command set is allowlisted in the device layer.

Starting the helper

bcd helper --config /etc/bcd/config.toml exits 1 unless all of these hold:

CheckError
[helper] socket is set[helper] socket and user are not configured
It runs as root (a fakehw build excepted)runs as root
[helper] user names an accounthelper.user: ...
[kubernetes] allowed_clusters parsesThe parse error
command_root_sha256 parses (at most 16 pins)The parse error
As root: config.toml is a regular file (not a symlink) root owns, writable by no one else, in directories only root can write (3fa9021)<file> must be a regular file root owns that no one else can write
As root: every existing directory from / to the slots directory, the socket's directory and the entrypoint's directory is root's and closed to group and other writes (c139b9c, 9e71b1f)<dir> must be a directory root owns that no one else can write ...
The self-update manager builds from [update]The error
The socket bindsThe error

The configuration check (config.Validate) also refuses a relative socket path, a user of root, and, under the helper, an [update] slots_dir or [update] entrypoint inside data_dir: the node owns that directory and could plant the binary root trusts (9e71b1f).

That root-owned config.toml decides what root does for the node, so these keys are read from root's copy, never from the node:

KeyWhat root uses it for
[helper] socket, userWhere it listens and whom it serves
[helper] instance_bridgesThe bridges libvirt.define and ch.taps admit; empty admits no NIC
[host] unitsfalse refuses every host.unit
command_root_sha256Pins for host units' signed specs
join_signing_key_pinVerifies root's join record and presented trust bundles
[kubernetes] allowed_clustersThe only clusters k8s-join may join
[update]Release pins, threshold, sequence, slots and entrypoint for update.*

bcd doctor, run as root on a helper node, checks the same conditions: the helper check fails for each directory on the way to the slots, socket or entrypoint that is not root's alone, says what host units bind to, and fails when no command-signing roots are pinned or recorded. The libvirt, cloud-hypervisor and tpm checks cover the group memberships (see bcd doctor).

Install layout

daemon install-script --unprivileged (169c459) sets up this layout; running bcd node unprivileged shows the units and the steps.

PathOwner and modeWhat
/etc/bcdroot:bcd 0750The configuration directory
/etc/bcd/config.tomlroot:bcd 0640Read by both processes; only root may write it
/etc/systemd/system/bcd-helper.serviceroot 0644bcd helper --config /etc/bcd/config.toml, Before=bcd.service, UMask=0077, RuntimeDirectory=bcd-helper (0755)
/etc/systemd/system/bcd.service.d/unprivileged.confroot 0644User=bcd, Group=bcd, SupplementaryGroups=systemd-journal adm, Requires= and After=bcd-helper.service
/run/bcd-helper/helper.sockroot:bcd 0660The socket
/usr/local/lib/bcdroot 0755The self-update slots and records ([update] slots_dir)
/var/lib/bcdbcd:bcddata_dir: journal, identity, key record, staging, instance files
/var/lib/bcd-hostconfigrootRoot's records: join-response.pb, helper-intent.json, the config units' records, the libvirt staging directory

The bcd account is in neither the libvirt group (since d418820) nor the tss group (since 8870b8d). On a host with /etc/libvirt/qemu.conf the script sets dynamic_ownership = 0 and puts QEMU's user in the bcd group, so QEMU reaches the node's disk files through that group. --unprivileged with --plugins is a usage error, since bcx plugins need root to start. Only bcd node uses the helper: bcd site ignores [helper], and the ramdisk has no configuration file.

Testing the helper

TestCovers
internal/helper unit testsThe file table and walk, the ruleset check, host.unit re-planning and refusals (TestHelperHostUnit, TestHostUnitNeedsTheSignedSpec, intent_bundle_test.go), the domain validator (domain_test.go), libvirt against a fake virsh, ch.* (ch_test.go), TestHelperTPMKeys against tpmsim, dmi.read and hw.read
tests/e2e/bcd.test.tsAn unprivileged fakehw bcd node beside a real bcd helper: a GPU hold's CDI spec written and removed (the helper log shows file.write and file.remove), and a signed update delivered through update.*
internal/helper/roote2e (node scripts/helper-e2e.mjs, build tags nonvml,helpere2e)As root in private namespaces: the install script moves a root-mode node under the helper; a copy of bcd and another user are refused; signed sysctls applied and unsigned, tampered, foreign, older or unpinned specs refused; libvirt definitions with foreign disks, root-owned disks, qemu:commandline, foreign PCI devices or bridges refused; real taps for cloud-hypervisor; self-update swap, rollback and failure record

bcd helper-call exists only in helpere2e builds, never in a release: it reads one request from standard input and sends it to [helper] socket as the installed bcd binary would.

Was this page helpful?

Better Compute is created by Sean Filimon

Last updated

On this page