# 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: 1. **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. 2. **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. 3. **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 | |------------------|-------------|----------------|---------------|---------|----------| | `audit` | yes | yes | no | no | no | | `autoverified` | yes | yes | yes | no | no | | `humanreviewed` | yes | yes | yes | no | no | | `oneshot` | 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 call `uci apply` or `uci confirm`. The `applied()` state activates staged changes with rollback protection. The staging behavior is transport-aware: - *SSH*: changes stage to `/tmp/.uci/`, visible to `uci 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: ```yaml # 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 audit` on 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 `conffiles` mechanism 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 set` command -- low friction when the operator is ready. ## Consequences - A new opkg package `salt-openwrt` ships the default config file. It has no dependencies and can be installed alongside either `salt-agent-ubus` (JSON-RPC) or `salt-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=None` code path. Both are transport-aware: SSH stages to `/tmp/.uci/`, JSON-RPC stages in the rpcd session. - The mode check adds one extra `uci get` call per `managed()` 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.