Skip to content

Pre-release hardening: secret access, user grants, rolling-upgrade heartbeats, removed-driver reporting - #1133

Merged
cvaroqui merged 13 commits into
opensvc:mainfrom
cvaroqui:main
Sep 28, 2026
Merged

cvaroqui merged 13 commits into
opensvc:mainfrom
cvaroqui:main

Conversation

@cvaroqui

Copy link
Copy Markdown
Member

This PR fixes the critical and high findings of the pre-release audit, and a few smaller issues found along the way. Each breaking change is recorded in CHANGELOG.md.

Security

  • Secrets kept from guests (879897e)

    • A guest of the root namespace could read cluster.secret, the key every sec and usr value is encrypted with, through the cluster keyword and file endpoints.
    • A guest of any namespace could read the keys of its usr objects in clear: passwords and certificate private keys.
    • Fix: a new configReadAccess check now gates every configuration read. The cluster configuration needs root, or the join grant of a joining node. Other configurations are readable by the namespace guests, with secret keywords and sec/usr keys shown as ******** to anyone who is not a namespace admin, root, or a joining node. A usr key can be read by root only.
  • User grants (eb8cfb4)

    • An admin of the system namespace could make itself root: create a root user, add root to a user, repoint a user's cn, or reset a root user's password.
    • Fix: a usr write now refuses to add grants the writer does not hold, and a cn change needs root. Writing a usr key (password or certificate) needs every grant of that user. A new helper, rbac.Grants.Covers, checks these.
  • Container and task rbac (751f73a): the v2 rules are back. Without root, a user may not set on a container or a task:

    • privileged to true
    • netns=host
    • devices
    • a volume_mounts source that is a host path

    The same goes for netns=host on an ip resource. Tasks are now held to the host path mounts rule too.

Rolling upgrades

  • Unknown enum values (5fd9144)
    • Before: a heartbeat message holding a value a node didn't know (for example, a monitor state added by a later version) failed to decode as a whole. The older nodes then found the upgraded peer dead, and the split action or a failover followed.
    • Now: an unknown value decodes as unknown, undef or invalid. A peer counts as alive once its message decrypts, and a message that doesn't decode is logged once per peer.
    • imon and nmon don't adopt an orchestration they don't know; they read as done and leave it to the nodes that know it.

Heartbeats

  • hb.disk and hb.relay staleness (cd59290, 468eab3)
    • Before: the receiver kept a single last-read time for all peers, so with two or more peers a stale slot or message was only ruled out by its age. That age was measured against a timestamp from the peer's clock (disk) or the relay's clock (relay), so a clock running ahead kept a dead peer looking alive.
    • Now: the receiver keeps the last stored time per peer, and a peer counts as alive only when that time changed. The age check applies only to the first read.
  • hb.relay client rebuild (e47778e): the client is now rebuilt when the password comes back after a decode error, even if it is unchanged. Before, the relay stream stayed silent until the password changed or the daemon restarted.
  • Log fixes (ff00e96): "password unchanged" is logged at debug level, and the misspelled "reajust timeout" warning is fixed in the ucast, relay and disk drivers.

Drivers

  • Removed drivers are reported (e95f219)
    • Before: a section using a v2 driver that v3 does not have (for example disk.vxdg) was skipped silently, and the object reported up without its storage.
    • Now: the instance status lists such a section as an optional, n/a resource with a "driver not supported by this agent" warning. That warning raises the instance's overall status only. Its availability is untouched, so no monitor action (a node crash, for example) is triggered for a resource that never ran, and no action is blocked.
    • CHANGELOG.md now lists the v2.1 drivers that have no v3 counterpart, including the envoy ingress sections.
  • task.docker, task.podman and task.oci (a6e0b20): the read_only, sysctl and stop_timeout keywords are declared again and passed to the task container. Before, a v2 task asking for a read-only root filesystem ran with a writable one.
  • ip.netns (f3ddd9c): no address is drawn from the network when the resource name already sets one. The next stop releases the addresses reserved by mistake before.

Dependencies

  • ebpf v0.22 (82626d1)
    • Upgrades cilium/ebpf to v0.22.
    • containerd/cgroups v1.1.0 does not build against it, so util/pg and core/osagentservice move to containerd/cgroups/v3, which was already a dependency.
    • Pulled along: cgroups/v3 3.1.3, runtime-spec 1.3.0, cobra 1.10.2 and pflag 1.0.10.

Testing

  • go build ./... and go test ./... pass.
  • Deployed on a 3-node dev cluster (dev2):
    • Throwaway users checked the secret redaction and the user grant rules.
    • All heartbeat streams are beating after a restart.
    • A throwaway service checked pg create, cap and delete on the unified cgroup hierarchy.
  • Not tested: the cgroup v1 path, which the el7 QA nodes would cover.

…objects from the guests

A guest of the root namespace read cluster.secret through the keyword
endpoint of the cluster configuration, and through the object and
instance file endpoints, which only asserted the guest role: only the
cluster file endpoint required root. That secret is the key every sec
and usr value is encrypted with.

A guest of a namespace read the keys of its usr objects in clear:
GetObjectDataKey required admin for a sec object and guest for every
other kind, and a usr key is the password or the certificate private key
of a user, which authenticate as that user. And the configuration
endpoints served a guest the encrypted keys of the sec objects the key
endpoint refused it, the ca private key the api signs its tokens with
among them.

configReadAccess now gates every configuration read, keyword, object
file and instance file alike. A cluster configuration needs root, or the
join grant of a node joining the cluster. Any other configuration is
read by the guests of its namespace, with its secrets redacted: the
keywords declared RedactSecret and the keys of the sec and usr objects
show as asterisks, raw and evaluated, to a reader who is neither an
admin of the namespace, root, nor joining. The admins keep the exact
file, so an edit does not write the placeholders back, and a joining
node gets the ca, certificate and heartbeat secrets it installs.

A usr key is read by root only.

object.IsSecretKey is the check RedactSecrets makes per key, shared with
the keyword endpoint. It looks up the base option of a key, so a secret
keyword set for one node is redacted too.
…mes one

A start reserved an address from the network of the resource whether or
not its name set the address to use. The resource plumbed the named
address, and the reservation held another one nobody used, counted
against the network claim of the namespace, until the resource stopped.

The address is drawn only when the name is empty. A stop still releases
whatever the resource holds, so the reservations earlier starts took by
mistake go at the next stop.
…dentials only to who holds its grants

An administrator of the system namespace, where the users live, could
make itself root: create a user granted root, add root to a user, point
the cn of a user at another's certificate, or reset the password of a
root user. v2 refused the first three, and let the last one through.

A write of a usr configuration now refuses the grants it adds that the
writer does not hold, and a change of what the cn of a user evaluates
to, unless the writer is root; a new user's cn must be its own name. The
grants a user already holds are not asked for again, so an administrator
still edits a user it could not have made, and takes grants away.

A write of a key of a usr object, put, post, patch or delete, needs
holding every grant of that user: the keys are the password and the
certificate the user authenticates with, and setting them is taking its
grants. It is asked on the node holding the user, where a proxied
request keeps the identity of its caller.

rbac.Grants.Covers says whether a set of grants holds a grant: root
holds all, a role granted with no scope holds it on every namespace, a
cluster role holds itself only.
…d document the drivers removed

A section naming a driver this agent does not have, or a driver group it
does not know, was skipped with a trace log. The object started and
reported up without it: a v2 service holding its data on a disk.vxdg
resource ran its applications without their storage, and nothing said
so but a validation warning.

The actor now records these sections as it configures the resources,
and the instance status lists each one as an optional resource, not
applicable, warning that its driver is not supported by this agent. The
warning raises the overall status of the instance, and the availability
is left alone: an instance going warn over it could trigger its monitor
action, a node crash among them, for a resource that never ran. The
sections are still not configured, so no action touches them and none
is blocked. A subset section is no resource and is not reported.

The CHANGELOG lists the drivers of v2.1 that have no v3 counterpart,
the envoy ingress sections among them, and says how an upgraded object
reports them. It also stops saying the vdisk, vmdg, vxdg and vxvol
driver group names are replaced by disk drivers v3 does not have.
…the task container again

The v2 container tasks took the keywords of the container drivers. The
v3 ones declared no read_only, sysctl or stop_timeout, so a v2 task
asking for a read-only root filesystem ran with a writable one, and only
the configuration validation said so, as an unknown keyword.

The three keywords are declared again on the task drivers, and passed to
the container the task runs in. task.oci runs a docker or a podman task,
so it has them too.

umask and group stay on task.host: the v2 container tasks accepted them
and applied neither.
…t, as v2 did

The v2 policy kept for root the container keywords deciding what of the
node a container reaches: a privileged container, the host network
namespace, host devices, host path mounts, and the host network
namespace of an ip resource. The v3 policy only kept the host path
mounts of a container resource.

The container and task groups now require the root grant for a true
privileged, a netns of host, any devices, and a volume_mounts source
that is a path of the node; the ip group for a netns of host. A task
runs in a container like a container resource does, and is held to the
same rules. The keyword documentation reads the same table, so it says
so too.
…f a later version as unknown

A heartbeat message holding one enum value the receiver did not know,
as a monitor state added by a later version, failed to decode as a
whole, and the receivers counted a peer alive only once its message
decoded. During a rolling upgrade, the first such value an upgraded node
published had every link drop it on the older nodes, which found it
dead: the split action, or a failover of what it still ran, followed.

The instance and node monitor states, local and global expects decode a
value they do not know as a new unknown value, which marshals back.
Status, provisioned and placement state decode it as undef, and the
placement policy as invalid, which places nothing. A node in an unknown
state is not ranked for placement.

The unicast, multicast, relay and disk receivers count a peer alive once
its message decrypts, from the sender they verified, and skip a message
that does not decode, logging it once per peer and error, and again
when it decodes again. The unicast and multicast receivers count only a
peer of the heartbeat.

imon and nmon do not adopt a global expect they do not know: they could
not end their part of it, and the peers would wait for them for good. A
node holding no orchestration id reads as done to them, so the nodes
that know it finish it.

The deep copy test fillers must round trip as themselves, now that an
unknown value decodes rather than fails.
…d, not by the age its clock stamped

The disk rx kept one last read time for all the peers, and read the slot
of each in turn, so with two peers or more a slot unchanged since its
last read was never seen as unchanged. What stopped a stale slot from
counting was its age, measured as the time of this node since the
update time the peer stamped with its own clock: a dead peer whose
clock ran an hour ahead looked alive to the others for that hour.

The rx keeps the update time last read of each peer, and counts a peer
alive when its slot holds another one: a new time is a new write,
whatever the clocks. The age of the write is judged on the first read
only, which has nothing to compare with.
…ince the last read, not by the age the relay clock stamped

The relay rx kept one last read time for all the peers, and read the
message of each in turn, so with two peers or more a message unchanged
since its last read was never seen as unchanged. What stopped a stale
message from counting was its age, measured as the time of this node
since the time the relay stored it, stamped by the relay clock: a relay
whose clock ran ahead kept a dead peer looking alive.

The rx keeps the store time last read of each peer, and counts a peer
alive when the relay holds another one: the tx posts at every interval,
changed or not, so a live peer is stored again before each read. The
age of the store is judged on the first read only, which has nothing to
compare with.
A process can't be matched by its env for a few milliseconds while it
execve's: its /proc/<pid>/environ reads empty until the kernel sets the
env_end bound of the new program memory, and, when the execve is called
from a non-leader thread as Go programs do, the environ and stat opens
fail with ESRCH or ENOENT while the kernel makes the exec'ing thread the
thread group leader.

The app.simple driver launches its start command through "om exec",
which execve's in turn "env" then the application, and evaluates the
status right after. When the status read hit one of these windows, the
start reported the instance down with the application running, as seen
in the QA "restart --rid app#0" test.

Retry the environ read, with a 1ms delay and a 100ms deadline, while the
stat of the process is consistent with an execve in progress. Kernel
threads, zombies and processes rewriting their env area (nginx, redis)
are not retried, so a process list walk keeps its cost.
@cvaroqui
cvaroqui merged commit 9fb6417 into opensvc:main Sep 28, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant