Agent internals¶
This page describes how bor-agent works inside: start-up, enrollment and
renewal, the streaming loop, how each policy type is synced and merged,
compliance reporting, user notifications, tamper protection with the file
watcher, and the system tools the agent calls. Read it before adding or
changing a policy type; the step-by-step checklist for that is in
Adding a policy type.
Layout¶
| Path | Contents |
|---|---|
agent/cmd/agent/main.go |
main, the streaming loop, policy caches, every syncAll* function, file watcher hooks |
agent/cmd/agent/disk_encryption.go |
Disk-encryption sync, inventory reporting and the periodic task ticker |
agent/cmd/agent/luks_cmd.go |
The bor-agent luks subcommand |
agent/cmd/gen-dconf-schemas, agent/cmd/gen-polkit-actions |
Generators for the server's built-in dconf schema and polkit action catalogues |
agent/internal/config |
YAML configuration loader and defaults |
agent/internal/policyclient |
gRPC client, enrollment (token and Kerberos), certificate renewal |
agent/internal/policy |
Per-type rendering, merging, writing and compliance checks |
agent/internal/filewatcher |
inotify-based tamper detection |
agent/internal/luks |
LUKS2 inventory, keyslots, recovery keys, TPM2 and Tang via Clevis, initramfs |
agent/internal/sessionaccess, agent/internal/logind |
Session access enforcer and logind lock watching |
agent/internal/notify |
Desktop notifications and KDE reconfigure over D-Bus |
agent/internal/procinfo |
Find processes holding a file open (tamper attribution) |
agent/internal/sysinfo |
Node metadata for heartbeats |
Configuration¶
config.Load(path) (agent/internal/config/config.go) starts from
DefaultConfig(), reads the YAML file (default /etc/bor/config.yaml,
override with --config) and overlays it. A missing or unparsable file is
fatal. If agent.client_id is empty, the host name is used; it becomes the
requested node name at enrollment.
The defaults hold every managed path, for example
/etc/firefox/policies/policies.json, /etc/opt/chrome/policies/managed,
/etc/firewalld/zones, /etc/bor/xdg for KConfig, /etc/bor/flatpak and
/var/lib/bor/agent as enrollment.data_dir. Code should always read
paths from cfg, never hard-code them, so tests and unusual distributions
can override them.
Start-up¶
main() runs in this order:
- Parse
--config,--token,--token-file. If the first argument isluks, run the subcommand and exit (see below). - Resolve the enrollment token:
--token-file, thenBOR_ENROLLMENT_TOKEN, then the deprecated--token. - Load the configuration.
- Enrollment. If a token is given and the agent is already enrolled,
delete the old credentials first (re-enrollment). If not enrolled, try
Kerberos enrollment when
kerberos.enabledis set and the keytab exists, otherwise token enrollment. On success print the next steps andos.Exit(0): enrollment is a one-shot command, the daemon is started by systemd afterwards. - Renewal check. If
agent.crtexpires within 30 days, callpolicyclient.RenewCertificateon the policy port. A failure is logged and the agent continues with the current certificate. - Create the mTLS client with
ca.crt,agent.crtandagent.keyfrom the data directory (TLS 1.3, verification always on). - Install
SIGINT/SIGTERMhandling, start the file watcher, the session access enforcer (ifloginctlexists), detect the package manager, connect to the system D-Bus for PackageKit, and probeflatpakand the disk-encryption tools so the journal explains laterINAPPLICABLEresults. - Enter
runStreamingLoop.
The streaming loop¶
runStreamingLoop keeps lastRevision in memory and loops until the
context is cancelled. On each connection attempt it:
- Fetches
GetAgentConfigand rebuilds the notification settings. - Starts background goroutines that report the LUKS inventory and send a heartbeat, and, once per process, scan GSettings schemas and polkit actions and report the catalogues.
- Calls
client.SubscribePolicyUpdates(ctx, lastRevision, callback). The callback stores the revision of every event exceptMETADATA_REQUESTandDISK_ENCRYPTION_TASK, then callshandlePolicyUpdate. - When the stream ends, waits and reconnects:
// Exponential backoff capped at 60 s.
backoff *= 2
if backoff > 60*time.Second {
backoff = 60 * time.Second
}
The first retry waits 1 second. The backoff is not reset after a successful connection, so once a few disconnects have happened, later reconnects wait up to 60 seconds.
Handling updates¶
handlePolicyUpdate keeps one cache per policy type (firefoxCache,
dconfCache, polkitCache, ...), keyed by policy ID:
SNAPSHOT: entries are added to per-type staging maps. When the message withsnapshot_completearrives, each staging map replaces its cache, and every type is re-synced. An empty snapshot clears all caches and syncs, which removes everything Bor manages. For the browser and KConfig types the agent compares the new cache with the old one so that a resync without changes does not trigger user notifications.CREATED/UPDATED: update one cache entry and re-sync that type.DELETED: remove the entry from whichever cache holds it and re-sync that type.METADATA_REQUEST: send a heartbeat.DISK_ENCRYPTION_TASK: fetch and run disk-encryption tasks.- An unknown policy type is reported as non-compliant with
unsupported policy type: <type>.
Types whose apply step can take long (packages, Flatpak, disk encryption)
run in goroutines through triggerPackageSync, triggerFlatpakSync and
triggerDiskEncryptionSync, serialized by their own mutex, so they never
block the stream.
Per-type sync functions¶
Each type has a syncAll* function in main.go that turns the whole cache
for that type into the desired state on disk. They are idempotent and are
called after every change, after every snapshot and by tamper restore.
| Function | Writes / does | Merge |
|---|---|---|
syncAllFirefox, syncAllThunderbird |
policies.json (system and Flatpak extension paths); original file backed up on first write and restored when no policies remain |
proto.Merge of all cached policies |
syncAllChrome, syncAllEdge |
bor_managed.json in each managed policy directory |
proto merge |
syncAllKConfig |
INI files under /etc/bor/xdg with [$i] immutability markers, plus /etc/kde5rc / /etc/kde6rc |
entries grouped by file and group |
syncAllDConf |
keyfile 00-bor and a locks file in /etc/dconf/db/<db>.d, then dconf update |
sorted by priority ascending, last writer wins per (schema, path, key) |
syncAllPolkit |
one rules file per policy in /etc/polkit-1/rules.d, file name derived from priority and policy ID |
separate files, ordered by priority |
syncAllFirewalld |
zone XML in /etc/firewalld/zones, firewall-cmd --check-config and --reload |
rules grouped per target zone (SyncFirewalldFromProto) |
syncAllSessionAccess |
/etc/security/time.conf lines and the PAM integration; feeds the logind enforcer |
rules from all policies, merged into the existing file (MergeTimeConf) |
syncAllPackages |
bor-* repository and key files, then installs / removes through PackageKit |
sorted by priority |
syncAllFlatpak |
remote definitions in /etc/bor/flatpak, then flatpak CLI operations; optional auto-update ticker |
MergeFlatpakEntries |
disk encryption (disk_encryption.go) |
LUKS keyslots, Clevis bindings, crypttab options, dracut drop-in and initramfs rebuilds | luks package |
Merge order for map-based caches
The Firefox, Thunderbird, Chrome, Edge and KConfig caches store only the typed policy, not its priority, and are merged in Go map iteration order. When two such policies set the same key, the winner is not defined. dconf, polkit and packages carry the priority and sort by it. New types should store the priority in the cache entry and sort before merging.
Compliance reporting¶
Every syncAll* ends by reporting compliance for each policy it handled:
client.ReportComplianceWithStatus(ctx, id, status, message, items)for the four-state status and optional per-item results (dconf keys, polkit rules, packages, Flatpak apps).client.ReportCompliance(ctx, id, compliant, message)for the simple boolean case (the browser types reportDeployedafter a successful write).
Use INAPPLICABLE when the feature is not present on the node (no
PackageKit, no flatpak, GSettings schema not installed) and ERROR when
it is present but enforcement or the check failed. Both calls use a
10-second timeout and are best effort; a failed report is not retried
until the next sync.
Notifications¶
When content actually changes, the agent can tell logged-in users. Each
type has a notify.Notifier (kdeNotifier, firefoxNotifier,
chromeNotifier, ...) and a notify.Config built from GetAgentConfig
(notify_users, cooldown, per-browser messages). ScheduleNotification
debounces for 10 seconds (notify.NotifyDebounce) so a batch of admin
edits produces one notification.
Because the agent runs as root and the session bus rejects root, D-Bus calls
are executed as subprocesses with the target user's UID and GID. For
KConfig the notifier also sends the KDE reconfigure signal so running
applications pick up the new settings. The session access enforcer uses the
same mechanism (notify/alert.go) for per-user schedule warnings.
Tamper protection: the file watcher¶
Every policy type that writes files must be protected against local edits.
agent/internal/filewatcher watches the parent directories of managed
files with fsnotify, so atomic renames are seen, and calls the tamper
callback on write, create (rename into place) and remove events for a
managed path. Events for paths that were recently suppressed are ignored.
main.go wires the watcher through four hooks. All four are required for a
file-based type:
getManagedPaths(cfg)returns the absolute paths currently managed, based on the caches (or files that exist on disk). It is used by the next two hooks.suppressManagedWrites(cfg, extraPaths...)must be called at the top of everysyncAll*function, with every path the sync is about to write or delete. It suppresses watcher events for those paths and all managed paths for 2 seconds, so the agent's own writes do not trigger a restore loop.defer updateWatcher(cfg)right after the suppression call. It refreshes the watched set after the sync, so new files are watched and removed files are dropped.onTamperedFilemust have acasethat recognizes the type's paths (for examplestrings.HasPrefix(path, policy.PolkitRulesDir+"/")) and calls the type'ssyncAll*(ortrigger*for long-running types) to restore the file. After the switch, the agent reports the event withReportTamperEvent, including the processes that held the file open (procinfo.FindFileHolders).
The reference pattern is syncAllPolkit:
func syncAllWidget(ctx context.Context, client *policyclient.Client, cfg *config.Config) {
// 1. Compute desired paths and discover stale existing paths.
desiredPaths := ...
existingFiles, _ := policy.ListBorManagedWidgetFiles()
// 2. Suppress watcher for all involved paths.
suppressManagedWrites(cfg, append(desiredPaths, existingFiles...)...)
defer updateWatcher(cfg)
// 3. Remove stale files not in desiredPaths.
// 4. Write new / updated files.
// 5. Check compliance and report.
}
Currently watched locations: Firefox, Thunderbird, Chrome and Edge policy
JSON; KConfig files under /etc/bor/xdg and /etc/kde5rc, /etc/kde6rc;
/etc/dconf/; /etc/polkit-1/rules.d; Bor package repository and key
files; firewalld zones; /etc/security/time.conf; the Flatpak state
directory /etc/bor/flatpak; the disk-encryption dracut drop-in and
/etc/crypttab. Flatpak's own /var/lib/flatpak/repo/config is
deliberately not watched.
Concurrency
The watcher invokes the callback in a new goroutine, and package,
Flatpak and disk-encryption syncs also run in goroutines. Only session
access, packages and Flatpak guard their state with mutexes; the other
caches are plain maps shared with the stream goroutine. Keep this in
mind when adding code that reads caches outside the stream callback,
and run tests with go test -race.
Atomic file writes¶
Write managed files with policy.WriteFileAtomically(path, data)
(agent/internal/policy/firefox.go): it creates the parent directory,
writes a temporary file .bor-tmp-* in the same directory, sets mode 0644
and renames it over the target. Readers never see a half-written file, and
the rename is what the file watcher expects. For files that must not be
world readable, follow the same pattern with a stricter mode.
Server-supplied values that end up in file names are sanitized (for example
sanitizeAlphanumeric for polkit file names) so they can never introduce a
path separator.
Privileged operations and external tools¶
The agent runs as root because it writes system configuration. It calls
these external programs and services; each is probed so that a missing
tool turns into an INAPPLICABLE or ERROR result instead of a crash:
| Tool | Used by |
|---|---|
dconf update |
dconf |
gsettings |
dconf compliance checks |
pkaction |
polkit action discovery |
firewall-cmd |
Firewalld (--state, --get-default-zone, --check-config, --reload) |
flatpak (path from flatpak.binary) |
Flatpak remotes and apps |
| PackageKit over the system D-Bus | package install / remove |
loginctl, logind D-Bus |
session access enforcement |
pam-auth-update or authselect, getent |
session access PAM integration, user and group resolution |
cryptsetup, systemd-cryptenroll, clevis |
disk encryption |
dracut, update-initramfs, mkinitcpio |
initramfs rebuilds after disk-encryption changes |
hostname --fqdn, plasmashell --version, gnome-shell --version |
heartbeat metadata |
Commands are run with a fixed binary name and constant or validated
arguments (exec.Command(name, args...), never through a shell). Each
call site documents this with its gosec exception. Keep that property
when adding new calls.
The luks subcommand¶
bor-agent luks <command> (agent/cmd/agent/luks_cmd.go) runs before the
daemon path and exits:
| Command | Purpose |
|---|---|
status |
Print the LUKS volumes, keyslots and platform facts (TPM2, Secure Boot, initramfs tool, cryptsetup and clevis versions) |
adopt [--device DEV] |
Ask for an existing passphrase once so Bor can escrow a recovery key for volumes it has no other credential for; --device limits it to one device or LUKS UUID |
adopt uses the enrolled mTLS credentials to talk to the policy port.