ADR-001: Device-controlled agent mode via UCI config¶
Status: Accepted
Date: 2026-02-28
Context: saltext-ubus v0.2.2 change management design
Last reviewed against: v0.3.0
Context¶
Salt extensions traditionally operate under the assumption that the Salt master has full authority over managed devices. The master decides what to read, what to write, and when to apply. The managed device has no say in the matter.
For production OpenWrt routers this model is unacceptable:
Unattended writes are dangerous – a misconfigured firewall rule or interface change can brick a remote device that is only reachable over the network it manages.
Onboarding requires a read-only phase – when a device is first enrolled, the operator needs to observe what Salt would change before granting write access.
Change management workflows – some environments require human approval before configuration changes take effect.
The question is: who controls the extension’s behavior?
Decision¶
The managed device controls Salt’s behavior through a UCI config file
(/etc/config/salt-openwrt), not the Salt master.
config salt-openwrt 'global'
option enabled '1'
option mode 'audit'
option rollback_timeout '120'
The state module reads this config via uci_ubus.get("salt-openwrt", "global") at the start of every managed() call and enforces the mode
before any write operations.
Mode semantics¶
Mode |
Reads config |
Computes drift |
Stages writes |
Applies |
Confirms |
|---|---|---|---|---|---|
|
yes |
yes |
no |
no |
no |
|
yes |
yes |
yes |
no |
no |
|
yes |
yes |
yes |
no |
no |
|
yes |
yes |
yes |
yes |
yes |
audit – Salt reports what is different between desired and actual state. No UCI writes occur. Safe default for newly enrolled devices.
autoverified – Salt stages UCI changes (calls
uci set) but does not calluci applyoruci confirm. Theapplied()state activates staged changes with rollback protection. The staging behavior is transport-aware:SSH: changes stage to
/tmp/.uci/, visible touci changes.JSON-RPC: changes stage in the rpcd session, kept alive by the proxy minion.
humanreviewed – Reserved for a future LuCI approval gate. Currently behaves identically to autoverified.
oneshot – Salt stages, applies with rollback safety, verifies, and confirms in a single run. Full automation.
Setting enabled to 0 causes Salt to skip the device entirely.
Backward compatibility¶
When /etc/config/salt-openwrt does not exist (package not installed),
_get_agent_mode() catches the exception and returns
(True, "oneshot", 120). Existing devices continue to work without
any changes.
Rationale¶
Inversion of control¶
The conventional approach would be to control the mode from pillar data on the Salt master:
# Conventional: master controls device
proxy:
proxytype: uci_ubus_jsonrpc
mode: audit
This was rejected because:
The master can override device intent. A pillar change on the master can silently re-enable writes on a device the operator locked down.
The device cannot protect itself. If the master is compromised or misconfigured, the device has no defense.
Visibility is split. The device operator must check two places (device config + master pillar) to understand behavior.
With the device-side config:
The device is self-governing. A sysadmin sets
mode auditon the router and knows Salt cannot write, regardless of what the master sends.Configuration is visible in LuCI. No SSH or Salt knowledge needed to check or change the mode.
Standard UCI tooling applies.
uci set,uci commit, backup/ restore, sysupgrade config preservation all work normally.
UCI as the config format¶
The agent config uses UCI (not a Salt-specific file format) because:
It is the native config format on OpenWrt –
uci show,uci set, LuCI, and backup/restore all work out of the box.The Salt extension already reads UCI via
uci_ubus.get()– no new parsing code is needed.The
conffilesmechanism in opkg preserves user edits across package upgrades.
Default to audit¶
New installs default to mode audit rather than mode oneshot because:
Enrolling a device should be a read-only operation until the operator explicitly opts in to writes.
Audit mode lets the operator observe drift reports and verify that pillar data is correct before granting write access.
Switching from audit to oneshot is a single
uci setcommand – low friction when the operator is ready.
Consequences¶
A new opkg package
salt-openwrtships the default config file. It has no dependencies and can be installed alongside eithersalt-agent-ubus(JSON-RPC) orsalt-agent-ssh.The state module’s
managed()function has code paths for disabled, audit, autoverified, humanreviewed, and oneshot modes.Drift reporting in audit mode uses the same diff logic as the normal path – no separate code is needed.
Autoverified and humanreviewed modes reuse the existing
apply_rollback=Nonecode path. Both are transport-aware: SSH stages to/tmp/.uci/, JSON-RPC stages in the rpcd session.The mode check adds one extra
uci getcall permanaged()run. On a 128 MB device over JSON-RPC this is sub-millisecond overhead.
Alternatives considered¶
Pillar-controlled mode¶
Control the mode from Salt pillar data on the master. Simpler to implement (no opkg package needed) but the device cannot protect itself from the master. Rejected: violates the inversion-of-control principle.
Grain-based mode¶
Cache the agent config in grains during proxy init. Faster (no extra
uci get per run) but stale until grains_refresh. Mode changes
would require proxy restart. Rejected: live reads are simple and
the overhead is negligible.
Separate config file outside UCI¶
Use a plain file like /etc/salt-agent.conf instead of UCI. Would
work but loses LuCI visibility, uci tooling, and opkg conffile
protection. Rejected: UCI is the right abstraction on OpenWrt.