Skip to content

v2.5.0

Choose a tag to compare

@github-actions github-actions released this 09 Aug 21:10
· 56 commits to main since this release
v2.5.0

Added

  • examples/curl/adopt.sh. Adoption is the operation an operator meets on any router configured before uapi arrived, and it had no runnable example: a section uci named anonymously reads managed: false, writes to it are refused with 409, and POST .../adopt renames it and hands back the new id. Verified end to end on hardware against a hand-written config host.

  • make lint-wire-names, a gate over the second category a major has to announce: an API field whose name is not the uci option it writes. A rename is one API name for one key and stays; an alias is two API names for one key, which makes a round-trip ambiguous and cannot survive the request/response schema split. All three current aliases are announced for v3, and a fourth can no longer arrive quietly. Two further categories were built and rejected as too imprecise to gate on, recorded in the testing docs so nobody rebuilds them: section-type reachability reports thirteen false positives for every true one because netifd, odhcpd and mwan3 parse uci in C, and the boolean-as-free-text check cannot tell a type error from an ordinary raw comparison.

  • scripts/audit-dead-fields.sh makes the dead-field audit re-runnable instead of a one-off claim: it checks all 426 uci options across every curated resource against the reader that consumes them, self-checks that it can see each package's option table before reporting anything, and fails on any option that is neither announced nor a recorded decision. The corpus is the part that goes wrong, and it fails in the direction of looking productive, so three wrong corpora on the first pass reported 63 live firewall4 options as dead. The audit now covers every curated resource. Five packages ship only a Makefile in the SDK feed, which left firewall/*, most of network/*, dhcp/odhcpd, usteer/config and sqm/queues unverified rather than verified-clean, and 2.5.0 is the last release that can announce a removal for v3. All 174 uci options those modules write were checked against the reader that actually consumes them, using the readers installed on a running device rather than extracted sources. One field was dead: usteer/config.max_assoc_sta is now flagged deprecated and announced for removal, because usteer's init forwards a fixed list of uci options to the daemon and this is not on it.

Fixed

  • usteer/config.enabled read the word spellings of true as enabled while usteer did not. The init reads the option with uci -q get and then compares [ "$ENABLED" -gt 0 ], which is numeric rather than a bool parse, so enabled 'true' makes the shell bail with "out of range", and start_service returns without registering a procd instance, while uapi reported the daemon enabled. The read now mirrors that comparison exactly, including enabled '2' counting as enabled and an absent option defaulting to enabled.

  • disabled on network/interfaces, network/routes and network/rules. netifd omits a disabled section entirely, so a section that does not exist on the box read back as ordinary active configuration and a declarative client saw a fully converged resource set with nothing to apply: no netdev, no route in the table, no matching ip rule, and nothing in a GET to say so. It also could not be cleared through the API, because a write cannot unset a field the model does not have, which left destroy-and-recreate as the only way back. network/wireguard_peers has modelled the same flag since it shipped. Verified on hardware: a route created with disabled: true is absent from ip route, reads back disabled: true, and a PATCH clearing it installs the route; the same holds for a rule in ip rule and for an interface, which netifd does not register at all while disabled. netifd does not parse the option the same way on all three: the interface flag is compared literally against 1, while route and rule go through the boolean blob converter which also takes true. Each resource therefore reads it with the helper matching its own parser, measured three times against a reset baseline, because sharing one helper reports an interface as disabled while netifd has it registered and running.

  • lldpd/config and vnstat/config now have integration coverage. Both packages sit outside the bare OpenWrt image, so every call to those resources answered 503 init_script_missing and neither was ever exercised against a real daemon, which is how vnstat/interfaces shipped modelling a section type vnstat has never read: an uninstalled package makes a wrong model look exactly like a right one. install_uapi now installs lldpd and vnstat2 at bootstrap, about 560 KiB together, and a new test asserts both of this release's daemon-boundary changes reach the daemon rather than merely reaching uci: lldp_description is checked in the configure system description line lldpd compiles into its config, and vnstat/config.interfaces in vnstat's own database via vnstat --dbiflist. Both resources also joined the stock-config round-trip. Verified on hardware in both directions, including that the section-type assertion rejects a config interface section holding the right device, which is the shape of the original bug.

  • Eight deprecated fields now say why they are deprecated. deprecated: true tells a code generator that a field is going away; the description is the only place an operator learns the reason, and it is what a provider prints in a plan warning. Twenty-one fields carried that text and eight did not, two of them carrying no description at all, so anything downstream had to substitute a generic message that could not name the specific reason. Each now states it: unbound/server.enabled is read only on config zone, vnstat/config.database_dir is a key of a file that ships from upstream, and so on. make lint-openapi-shape now requires a description opening with Deprecated on any property carrying the flag, matched case-insensitively because network/interfaces.name predates the convention and opens DEPRECATED in 2.2.0. Documentation only, no behaviour change.

  • vnstat/config.interfaces: the devices vnstat tracks, mapping to the list interface inside config vnstat that vnstat's init actually reads. This is the first time that setting has been reachable through the API. Values are device names as the kernel shows them (br-lan, eth0), which is what vnstat wants. Verified on hardware: PATCH {"interfaces":["br-lan"]} puts br-lan in the list and, after a service restart, vnstat is tracking br-lan in its database.

  • Three response headers uapi emits are now declared in the OpenAPI document: X-Kernel-Status, X-Kernel-Applied and X-Mgmt-Path-Warning. All three were added to the code and to docs/errors.md without reaching the spec, so a generated client had no way to know they exist; the first two shipped earlier in this same release. Scoped to the responses that can actually carry them rather than to every write: the kernel pair comes from the uci transaction, so it is declared on the 163 curated-resource write responses and not on raw, non-uci or batch writes, which never run that path. X-Mgmt-Path-Warning is per-resource and per-verb, so it is declared on exactly three responses. make lint-openapi-shape now enforces both, deriving the guarded-resource count from src/resources/ instead of a hand-kept list, and each rule ships with a self-test probe. Documentation only, no behaviour change.

  • dhcp/hosts.macs: the whole uci list mac under one name, as an array. Every other uci list option in the API surfaces as a JSON array; this one did not. It was split positionally into mac, the first entry, and mac_aliases, the rest, so no single field answered what a reservation actually matched and a client had to concatenate two of them to find out. macs wins over both when sent, both old names keep working and keep reading back, and PUT resolves a stale split against the list the way network/interfaces already does for ipaddrs rather than refusing the body a full-replace client cannot avoid sending. Purely additive.

  • The first architectural principle is now a test. "No partial-failure states, no config drift" was asserted in ten documentation files and covered by one unit test on the transaction module, never per resource, which is where it can go wrong: the transaction restores a snapshot, but whether a resource's write falls entirely inside that snapshot depends on the resource. tests/unit/no_partial_state_test.uc runs over the same 45 fixtures as the read-honesty property and injects the failure at reload, the one point where uci is already committed and the daemon then refuses. Validated by neutering the restore, which fails 33 of the 45 cases with the before-and-after uci printed.

  • make gate-selftest: every gate now ships with a demonstrated failure. Thirteen probes break each gate on purpose and assert it says so, covering the six lint sub-targets, each of lint-doc-refs' five checks separately, openapi-check and coverage. A gate added without a probe fails the self-test, because the gate list is derived from the Makefile rather than hand-kept. Probes run in a throwaway git worktree that mirrors the working tree, so a mutation cannot reach your checkout and a gate can be validated in the same commit that adds it; a probe that changes nothing is reported as a broken probe rather than as a blind gate, since the two are indistinguishable from an exit code alone.

  • make lint-doc-refs, a new lint that fails on a documentation reference which does not resolve: a repo path that is not there, a module.export a module does not export, a backticked make <target> the Makefile does not define, and an error code documented as returned that nothing in src/ emits, plus the reverse of that last one. Four false claims about this project were found by hand within a few days, each corrected individually with nothing to stop the next; this catches the two shapes that are mechanically checkable, and its own header states the larger half it cannot reach ("this job proves X" where the job exists and does something adjacent, and modal invariants like never or atomic). Waivers are keyed by the reference with the reason as the value, because some references are deliberately unresolvable: bench/baseline.json is cited precisely because it does not exist.

  • Advisory management-path guard. GET /diagnostics now reports management_path, naming the interface the request arrived through ({address, device, interface}), and a network/interfaces write that moves that interface's disabled, proto, ipaddr or netmask, or deletes it, returns an X-Mgmt-Path-Warning response header. This is the lockout class an atomic write cannot help with: a change that reloads cleanly and then severs the only path to the box. Advisory rather than a refusal, because renumbering the management path is a legitimate operation and LuCI warns rather than blocking on the same four field names; no firewall analysis, also matching LuCI, because predicting a firewall lockout means modelling fw4 zone and rule ordering. The interface is derived from the kernel's own route lookup rather than by comparing the caller's address against local prefixes, which is what makes it right for an operator arriving from another network, the case that matters most, and what makes it answer for IPv6 where uapi's prefix helpers are IPv4-only. Structural limit, documented: if the write really does strand the caller the response never arrives, which is why management_path exists as the pre-flight half. Purely additive.

  • GET /diagnostics?validate=1 sweeps every section a token may read through the same validation a write performs and reports the ones that would be rejected, with the reason. It answers "which sections on this router will stop being accepted" before an upgrade, instead of one 422 at a time part-way through an apply. Every section it reports is already broken on the router: a port match on proto all is already matching the whole protocol, and a multi-value redirect is already discarded by fw4, so the sweep is the first time anyone is told rather than new breakage. Opt-in, because it walks the whole configuration and /diagnostics is polled on an interval; without the parameter the response is unchanged. Each resource is included only if the token permits :ro on it as well as uapi:diagnostics:ro, since findings name sections and quote configured values, and skipped_for_scope reports what was left out so an empty result cannot be mistaken for a clean one. Read-only and side-effect free. Closes openwrt-iac/uapi#47.

  • X-Kernel-Status and X-Kernel-Applied response headers on writes, saying whether a write reached the kernel rather than only uci. The kernel apply added in 2.4.1 skips an interface that is down or that netifd does not know, which is correct because ifup reads the peers from uci, but it left a client unable to tell that 200 apart from one whose peer is live. X-Kernel-Status is ok when every targeted interface was applied, partial when some were skipped, skipped when none was, and no_kernel for a resource with no kernel path, mirroring how X-Reload-Status reports no_reload. X-Kernel-Applied names the interfaces actually changed. Documented in docs/errors.md § Response headers.

  • If-None-Match is now evaluated on writes. It was parsed for every method and then dropped for anything but GET, so a caller asking for "only if this has not changed" or "only if absent" had its condition silently discarded and the write performed anyway. RFC 9110 13.1.2 gives 304 for GET/HEAD and 412 for every other method; 13.2.2 requires the precondition to be evaluated before the method runs, so the check goes in the existing precondition_check seam, which already runs inside the transaction before uci_commit. A 412 therefore means nothing was written, and that is what the tests assert rather than the status alone. If-Match is still evaluated first, per 13.2.2's ordering.

    This turns previously-successful writes into refusals, which is the breaking clause in docs/versioning.md, and the carve-out argument is worth stating because it is arguable either way: the server accepted a request whose conditional semantics it then threw away, so the 200 was never a contract a caller could deliberately rely on. Anyone appending ?if_none_match= to a write, which the integration suite proves is a real pattern, now gets 412 where they got 200.

    Measured on hardware: PATCH ...?if_none_match=* on an existing resource returns 412 with uci unchanged; a matching etag likewise; a non-matching etag still writes and still carries its three transaction headers and its audit line; and conditional GET is untouched, returning 304 for both a matching etag and *.

    Out of scope, deliberately: collection POST still ignores If-None-Match: *, since there is no target resource whose etag could be tested, and the existing 409-before-412 ordering is unchanged because reshuffling it would move If-Match responses too.

  • /raw let a client write option managed into uci, where it then shadowed the derived value. managed comes from uci's .anonymous flag and is never stored, but raw has no toUci to drop unknown keys: it copies the request body wholesale. A read-modify-write client therefore wrote the field back, and normalize_section let the stored option override the derived one, so the response came back as the string "0" where the schema promises a boolean. Measured on hardware, before and after: PATCH /raw/firewall/<id> {"managed": false} used to leave option managed '0' in the section and answer "managed": "0"; it now writes nothing and answers "managed": true. A section that already carries the stale option is handled too, which the first version of this fix missed: normalize_section derives managed after copying the section rather than before, so a stored value can no longer shadow it, and a patch sweeps the residue. Previously only a full replace could remove it, and the [] clear idiom could not reach it either, because the guard keeps the key out of the write set.

  • managed is annotated readOnly across all 46 schemas that carry it. No toUci reads it and the write path hardcodes .anonymous = false, so a PUT sending managed: false has always answered 200 with managed: true; management state moves only through the adopt endpoint. Emitted as a bare boolean it read to a code generator as an ordinary writable field, so it landed in the request model of every generated client, which then sent a field the server ignores. The annotation is applied generator-side, mirroring how runtime is handled: adding it to each module's schema_properties instead would also add it to the runtime type checker and turn managed: "true" from 200 into 422, which is a tightening nobody asked for. lint-openapi-shape now enforces it, with a self-test probe.

  • Three fields wrote a uci key their daemon never reads, so the value never reached the device. lldpd/config.lldp_capabilities wrote lldp_capabilities while lldpd reads lldp_capability_advertisements; unbound/server.dnssec_enabled wrote dnssec_enabled while unbound reads validator; snmpd/system.sys_services wrote sysServices while snmpd reads sysService, singular, a typo uapi inherited from upstream's own sample config. Each now writes the key its daemon reads, and still reads the old one when the new is absent, so an upgrade does not silently drop a value an operator set and report the default in its place. Every write also clears the legacy key. That is not tidiness: without it the legacy key never enters the footprint a PATCH deletes from, so clearing one of these fields removed the real key and left the old one behind for the fallback to resurrect, and the API answered false to the write and true to the very next read while the daemon had it off.

    These settings start taking effect, which is the point and also the risk. A value stored months ago and quietly ignored becomes live on the next write to that section. For sys_services that is inert SNMP metadata. For lldp_capabilities it can stop a TLV being advertised to neighbours. For dnssec_enabled it turns on DNSSEC validation, which will break resolution on a network whose upstream DNSSEC is broken. Check what those three options hold on your boxes before upgrading if that matters to you. Carve-out (docs/versioning.md): the writes being corrected produced state no caller could rely on, because they never reached the daemon at all.

    Verified on hardware rather than by reading: PATCH sys_services:76 now produces sysService 76 in the generated snmpd.conf, and lldp_capabilities:false on a box carrying the old key produces unconfigure lldp capabilities-advertisements in lldpd's generated config.

  • dhcp/hosts.tag now always reads back as an array. The same reservation answered with "guest iot" on a box storing option tag 'guest iot' and ["guest","iot"] on one storing list tag, though dnsmasq word-splits the scalar and treats them identically, so a client had to handle two shapes to learn one thing. A stored scalar is split on the way out; a read never touches storage, and the first write-back converges it, which is invisible on the wire because both compile to set:guest,set:iot. Verified on hardware, including a GET fed straight back as a PUT leaving the view unchanged. A space-separated string is still accepted on write, because the 2.4.1 spec declared one and clients generated against it send one; v3 removes that and the field becomes array-only in both directions. Carve-out (docs/versioning.md, branch b): a response type changed in a minor, because the 2.4.1 declaration ["string", "null"] was violated by the server's own responses for any section storing list tag, the ordinary uci spelling for more than one tag. A scalar option tag 'red' did read back correctly under the old type, so branch (a) does not apply and is not being claimed. This was previously announced as a v3 change to the read shape, which deferred a year of two-shape handling for no benefit: the objection was to normalizing storage, and splitting on read settles the wire shape without that.

  • The read-honesty property was blind to most of the fields it was supposed to protect. Its default-masking guard compared each seed against the schema default, but seeds are keyed by uci option name and schema_properties by wire name, so every field whose names differ failed the lookup and was skipped in silence. It inspected 15 of 140 seeded keys. The consequence, measured rather than argued: deleting the PasswordAuth write from dropbear.instances.uc, which silently re-enables SSH password authentication across a GET-then-PUT, passed all 1126 tests. Dropping masq from firewall.zones, which silently disables NAT, did too. The guard now compares the read-back value against the default, which needs no name mapping because it asks the question the property actually depends on: if toUci dropped this field, would the re-read refill it and hide the loss? That exposed 101 blind pairs across 34 of the 46 cases, every one of which is now seeded off its default. Both deletions above are caught.

  • The /diagnostics surface added in this release never reached build/openapi.json. The ?validate=1 parameter was undeclared, and DiagnosticsResponse still listed the six properties it had at 2.4.1 while the endpoint returns four more: management_path, invalid_sections, swept_resources and skipped_for_scope. A client generated from the spec could not see the sweep at all. All five are declared now, including the two error codes that appear only inside a sweep result, unreadable and sweep_failed, which are documented in docs/errors.md for the first time. Checked against a live response rather than against a reading of the source: the declared property set matches the emitted keys exactly, and a planted invalid section confirms the item shape down to the slash-form resource and the {field, code, message} errors.

  • The management-path warning was blind to the field the deprecation steers clients toward. ipaddrs is the same uci option as ipaddr under the name that replaces it, but only the scalar was watched, and merge_for_patch deletes ipaddr from the merged body exactly when the caller sends the list. So PATCH /network/interfaces/<inbound> {"ipaddrs": [...]} renumbered the interface carrying the request and warned about nothing, as did a PUT naming only the list, whose resolve_for_replace returns early. Both are reported now. Also fixed the same omission in docs/errors.md, docs/operations.md, docs/security.md and the published header description, all of which named four fields.

  • The DELETE arm of that warning had no test. It is not unreachable, contrary to the review that raised it: the delete path hardcodes changed=removed rather than going through the field comparison. The integration test can only assert the negative half, since exercising the real one would strand the box running it, so this is covered by a unit test instead: the handler now threads mgmt.uc's existing route-lookup seam through from the request context, which production leaves unset.

  • PATCH {"mac": null} on a dhcp/hosts reservation with more than one MAC returned 422 against mac_aliases, a field the caller never sent. merge_for_patch collapsed mac and mac_aliases into a single "did the body name the split" flag, so naming mac dropped the merged macs but kept the read view's mac_aliases, producing a list with no head, which is exactly the shape validate rejects. Clearing mac now clears the tail with it, since there is no uci list left to hold it. Dropping the DHCPv4 MACs while keeping a DHCPv6 reservation is a legitimate request and was the one shape that could not express it.

  • Clearing a list option answered 200 and changed nothing. uci cannot store an empty list, so [] means "no value"; the uci binding refuses a zero-length array and bus.uc discarded that answer. Because the key was still present in the write set, neither diff loop took its delete arm, so PATCH {"tag": []} returned 200 with the field reported cleared while dnsmasq kept applying option tag 'guest' to the reservation. An empty array now takes the delete arm, The rule lives in bus.uc, which turns a set of [] into a delete: [] is the absence of a value, so setting one removes the option. Putting it there rather than in each write loop matters, because there are six across handler.uc and raw.uc, and it fixes raw passthrough as a side effect, which had the same silent drop. The test stub accepted [] and stored it, which is why no unit test could see this; it now refuses exactly what the real binding refuses. Affects dhcp/hosts.tag, openvpn/instances remote, push and route, usteer event_log_types and ssid_list, and mwan3/interfaces track_ip and flush_conntrack.

  • A write could be answered 304, losing its headers and its audit line. maybe_304 ran on every response rather than only on reads, so appending the documented ?if_none_match= fallback to a PUT or PATCH rewrote the response of a write that had already committed and reloaded. The caller got no X-Reload-Status, no X-Kernel-Status and no X-Mgmt-Path-Warning, and because the audit branch logs 2xx writes only, the write left no audit line at all: one query parameter kept a configuration change out of the trail that exists to record it. A 304'd POST was also never cached for idempotency, so a retry with the same key created a second section. Measured on hardware: with the defect, PATCH ...?if_none_match=* returned 304 while uci moved from 192.168.222.1/24 to 192.168.222.77/24; with the fix the same request returns 200, three transaction headers and one audit line. Conditional GET is unaffected. RFC 9110 13.1.2 allows 304 only for GET and HEAD; the strictly conforming 412 for writes needs the precondition evaluated before the method runs (13.2.2) and is tracked separately.

  • An operator-disabled WireGuard peer could be re-enabled and pushed into the kernel. disabled and route_allowed_ips are read by wireguard.sh with config_get_bool, which accepts on, yes and enabled; 2.5.0 moved them to the netifd-strict helper, which accepts only 1 and true. A peer spelled option disabled 'yes' therefore read back as enabled, and because a write persists the read view, any unrelated PATCH on that peer rewrote uci to disabled='0' and the kernel apply emitted a set that installed the peer live. Same defect on route_allowed_ips, where it also skipped route installation and skipped the ip route del on delete.

  • lint-defaults was anchored on the literal name normalize_bool, so reclassifying fields onto the new helpers would have silently removed them from that gate: it went from seeing 75 boolean fields to 25 while still reporting OK, and a dropped default: annotation on any of the 50 would have shipped unnoticed. That annotation is what an IaC client reads to keep a field sticky, so losing it produces a perpetually non-converging plan. The lint now matches every helper that takes a default (79 fields), and the self-test gained a probe for the boolean shape, having previously probed only the string one, which is why the loss would have gone unreported.

  • mwan3/interfaces.enabled and openvpn/instances.enabled declared a default of true while both daemons default them to off: mwan3's init reads config_get_bool enabled $interface 'enabled' '0' and openvpn's section_enabled() defaults both spellings to 0. A section with the option absent therefore read back as enabled, and because a write persists the read view, any unrelated PATCH wrote enabled='1' and switched it on. Same failure chain as the WireGuard peer above, reaching the opposite conclusion on adjacent lines, which is what an audit of the accepted set without the default looks like.

  • Boolean readers, everywhere. The rule in the docs was "the netifd helper for netifd-owned fields and the wide one for the rest", and there is no "rest": OpenWrt has at least five readers with three different accepted sets, plus one this project cannot verify because ubox is not in the SDK. Auditing all 101 call sites against the actual reader in the OpenWrt sources moved 54 of them to a new shell_bool (the get_bool set, which the previous helper was short by enabled/disabled), 4 wireless fields to the strict netifd helper they had been reading too widely, and 4 raw-compare fields to a new strict_bool. The reader table in docs/ucode-quirks.md now lists every class with its helper, because that table is what produced the bug.

  • Two fields were not booleans at all, and every write destroyed their value. system.urandom_seed is the filesystem path the entropy seed is saved to (/sbin/urandom_seed tests that it starts with /), and lldpd's lldp_description is the free-text system description emitted verbatim into LLDP frames. Both were typed boolean, so a round trip replaced the operator's value with "1" and, for the seed, silently turned the feature off. Both are now strings. Carve-out (docs/versioning.md): a type change on the wire. It is earned by the write path, not the read path, and the earlier wording of this entry got that wrong. For urandom_seed the reader only acts on a value beginning with / (sbin/urandom_seed), so setting a real path was unreachable through a boolean, and asking for true wrote '1', which leaves the feature off while every later read reports true: a lie that persists in uci. For lldp_description every write replaced the advertised system description with "1" or "0". No uci value made either boolean a correct answer, which is the test the carve-out now states. What a caller does lose is the false a stock box returned for urandom_seed='0'; that reading was correct, but it was the only correct one the type could produce and it could not be acted on. How loudly this lands depends on the client, and it is silent for a whole class of them. A client sending a real JSON boolean is told: {"lldp_description": true} answers 422 invalid_type. A client whose configuration language coerces booleans to strings never sends one. HCL does, so an unedited Terraform config carrying lldp_description = true serialises to "true", which is a valid string and is accepted: verified on hardware, the advertised system description becomes the literal text true, and urandom_seed = "true" leaves the seed feature off while every read reports a plausible string. So check for the literal "true" or "false" in these two fields after upgrading rather than relying on an error. dhcp/hosts.tag is unaffected, because no such layer coerces a string into a list, which is why that correction does fail loudly.

  • ETag was declared on 18 responses that never carry one and missing from 43 that always do. The same defect as the reload headers, in both directions at once: raw passthrough, /packages, /tokens, /system/authorized_keys, /auth/whoami, /diagnostics and the read-only lease views all declared an ETag they never send, while every 304 emitted one and declared nothing. set_etag_header has four call sites, all in the curated CRUD and singleton handlers, and make_collection.get_one returns bare, which is why "curated" alone is the wrong test: the lease views are curated and still carry none. The generator now takes explicit intent instead of guessing from the response schema shape, and lint-openapi-shape derives the expected set from the catalog's kind field. Measured on a real box: a curated item GET, a singleton GET and a curated PATCH each return one, while whoami, diagnostics, raw collection and item GET, a raw PATCH, the lease list and POST /tokens return none, a genuine 304 on both a singleton and a CRUD item (reached through ?if_none_match=, since uhttpd's CGI env drops the header) carries the ETag it is now declared with, and a collection-kind item GET returns a real lease body with no ETag, which is the case that makes "curated" the wrong test on its own.

  • X-Reload-Status: no_reload was documented with system as its example, which reloads system and log. All 43 writable resources declare at least one reload service, so no_reload describes a resource shape that does not currently exist and no shipped response carries it. The value stays defined, since the transaction still handles that shape, but the docs and the published enum description now say it is not reachable today instead of illustrating it with a resource that disproves it.

  • X-Reload-Status and X-Reload-Services were declared on 15 response bodies that never emit them: every raw passthrough write, POST /batch, and the non-uci writes under /packages, /tokens, /system/password and /system/authorized_keys. None of those paths reaches attach_reload_headers, so a client generated from the spec was told to expect a header that could not arrive. Measured on a real box rather than inferred: curated writes return the pair on POST, PUT, PATCH, DELETE 204 and singleton PATCH, while raw returns neither on any of its four verbs, and POST /batch, packages/feeds POST and DELETE, POST /tokens and authorized_keys POST and DELETE return neither. The handful not exercised by hand (DELETE /tokens/{id}, POST /system/password, packages/installed) share the same response path as the ones that were, which is the thing the code check below actually pins. The declarations now follow the same uci_tx gate the kernel pair uses, leaving all four transaction headers on the 163 curated-resource write responses and nowhere else. lint-openapi-shape enforces both directions, deriving the curated path set from the generator's own ENDPOINTS catalog so a new resource needs no lint change, and both directions ship with a self-test probe. This removes a declaration rather than adding one, but nothing that was ever sent stops being sent: the header was already absent from these responses, so no client can have depended on receiving it, and OpenAPI response headers are never required.

  • dhcp/hosts no longer accepts mac_aliases without mac, which silently discarded the MACs. The two are one uci list mac: the scalar is its first entry and the array the rest, so aliases with no primary describe a list with no head. toUci could not write it and wrote nothing at all, while validate passed the body because the identifier requirement is written against mac and duid; a reservation carrying a duid therefore answered 200 with its MACs gone. It now returns 422 conflict on mac_aliases. Reported against that field rather than mac so it cannot collide with the identifier error under the field-and-code dedup. Writing the aliases as the list instead was rejected as a fix: mac would come back non-null, answering a different request than the one sent. Carve-out (docs/versioning.md): a payload accepted before now returns 422, earned because the old behaviour wrote no MACs, which is state no caller could rely on. The response body was already honest about the result, since it is rebuilt from fromUci; the defect was accepting a body uapi could not honour without saying so.

  • coverage's dead-export gate could never fire. used_internally scanned each module for the export's name as a token, and a module's own export block names every one of its exports, so every export counted as internally used and the exit_code = 1 on dead exports was unreachable. A genuinely dead export passed cleanly. The export block is now excluded from that scan, verified in both directions: a planted dead export is reported and exits non-zero, and all 131 real exports still pass. Found by the gate self-test on its first complete run, which is the case for having it.

  • lint-emdash had never run in CI. It lists tracked files with git ls-files, no CI job installed git, and || true turned the resulting failure into a pass: the lint job logged /bin/sh: git: not found and reported success without reading a file. The em-dash rule was enforced on developer machines only. git is installed in the lint container now, and the recipe fails loudly if git is missing or if the checkout has no .git (an actions/checkout tarball export has no tracked-file list), so the next container change cannot silently disable the rule again.

  • Seven false statements in the documentation, found by inventorying every checkable reference. docs/packaging.md cited .github/allowed-signers, which has never existed, for .github/release-signers.asc. The verify-arch-build "proves arch-neutrality" claim, corrected in the workflow comment in 2.5.0, survived in docs/packaging.md and docs/release-process.md. docs/migration-v1-to-v2.md documented dependency-aware ETags as current behaviour, though they were removed during the 2.x line. docs/concurrency.md said lock granularity for different packages is "asserted live" when the only assertion is a unit test over real flocks. Three docs claimed the concurrency test observes "5 distinct PIDs" when it asserts at least 2 and uhttpd caps concurrent CGI children at 3. docs/operations.md said 43 resources and docs/testing.md said the harness is ~100 lines. And docs/lock-state-audit.md claimed "17 sites audited" while its own reproduction command finds 36, with nine modules absent from the table entirely, including one added in this release; it now states its real coverage and the completion is tracked on the roadmap.

  • A PATCH whose body is not a JSON object is rejected with 422 invalid_type instead of answering 200 and changing nothing. The merge folded the body into the read view before validating, and the merge yields an object whatever the body was, so a scalar or a bare array reached the write path as a no-op and the response was indistinguishable from a successful partial update. A client sending a malformed body was told it had succeeded. This is a tightening under the docs/versioning.md carve-out: the payloads it now rejects could never have applied, so no caller can have been relying on the old answer, and reporting that a write landed when nothing was written is the one thing a write must not do. Both the collection and singleton PATCH paths are covered. Two shapes are deliberately unaffected: an empty request body, which arrives as null and has always meant "a request naming no fields", and a JSON Patch array under application/json-patch+json, whose body is an array of operations by definition. Closes openwrt-iac/uapi#83.

  • CI no longer advertises checks it does not perform. The perf bench step was named a regression gate and the release process listed it as one, but the comparison it relies on runs only when bench/baseline.json exists; no such file has ever been committed and no step produces one, so the check has never executed and a latency regression of any size shipped green. It is now named and documented as measurement, which is what it does, and the threshold is marked uncalibrated rather than left looking tuned. verify-arch-build's comment claimed it proved the APK is byte-identical across host arches and that a release should be held on divergence; it compares no digests and its matrix has no x86_64 leg to compare against. What it does enforce, and what 2.4.1 actually relied on, is that every arch's SDK tarball is pinned by checksum and that the package cross-builds under all three; the comment now says that. 415 unsupported_media_type was documented as returned when a body is not JSON, but Content-Type is never inspected: text/plain and no header at all are both accepted and the write lands. It is now documented as reserved, since enforcing it would reject bodies that work today.

  • Booleans owned by netifd are read the way netifd reads them. netifd converts uci strings through uci's own converter, which accepts only 1/true and 0/false and drops the option for anything else, falling back to its own default; uapi read them with a helper that also accepts on/yes/off/no, so it reported the operator's intent instead of the daemon's behaviour. Concretely option auto 'no' on an interface read back as auto: false while netifd, having dropped the value, autostarted the interface. The affected reads are auto, nohostroute, peerdns, defaultroute and delegate on network/interfaces, disabled and route_allowed_ips on network/wireguard_peers, invert on network/rules and ipv6 on network/devices. Only netifd-owned fields changed: fw4 and the shell init helpers do accept the wider set, so reading those the strict way would introduce the same bug mirrored, and docs/ucode-quirks.md now carries the per-daemon table. Writes are unchanged and still emit "1"/"0", which every reader accepts.

  • A wireguard peer's route_allowed_ips routes are withdrawn even when the option was spelled true in uci. The kernel apply learns which routes the previous configuration installed from the peer's existing uci section, and that read accepted only "1", so a section written by hand or by another tool with true was treated as having installed none: shrinking such a peer's allowed_ips, or deleting the peer, left its routes in the kernel directing traffic into the tunnel for prefixes it no longer had. Sections written through uapi were never affected, since uapi emits "1"/"0".

  • network/interfaces accepts IPv6 and dual-stack addresses on a wireguard interface. The addresses list was validated with the IPv4-only CIDR check, so fd00::1/64 was refused with "must be a valid IPv4 CIDR" and no IPv6-only or dual-stack tunnel could be configured through the API at all, even though the published schema declares the array unrestricted and netifd's own handler parses both families. This is the same class as the allowed_ips fix in 2.4.1, which corrected the peer field and missed the interface's own addresses. Verified on hardware: a tunnel created with ["fd00:99::1/64", "10.99.0.1/24"] comes up with both addresses present in the kernel. ipaddr and ipaddrs stay IPv4-only, since those are the static-proto v4 fields.

  • dhcp/hosts no longer writes the inverse of a dns request. name, tag and dns were written by toUci but absent from schema_properties, so the central type gate never saw them: dns: "0" is a truthy string in ucode and wrote dns=1, enabling DNS for a reservation whose request asked to disable it. name and dns now carry schema properties, and the false lint waiver that excused dns as "not surfaced in schema_properties" is gone, its stated reason having been contradicted by toUci writing the field.

    Carve-out (docs/versioning.md): these payloads were accepted before and now return 422, and ship in a minor because the state they produced was one no caller could rely on. dns earns it outright: the write did the opposite of what was asked, and name: 123 was silently coerced into uci and rides the same carve-out. tag is deliberately left undeclared: dnsmasq reads it with a scalar config_get and then word-splits it, so a uci list tag is working configuration that the ubus API surfaces as an array, and a string schema would reject it. Declaring tag needs a decision about which shape the wire uses rather than just a type. Published types are unchanged, since the spec already inferred all three from a read sample; dns gains its default: false. dnsmasq reads dns with the shell config_get_bool, which accepts the wider spelling set, so the reader stays normalize_bool rather than the stricter netifd form.

  • wireless/interfaces.has_key is always present. It was set only when a key existed, so the member was absent on a keyless section while the published schema declares a non-nullable boolean, meaning a plain GET of an open or OWE network violated the spec the server itself ships. Every sibling write-only flag already spelled it the documented way. Verified on hardware: two real encryption: owe interfaces now read has_key: false where the field was previously missing. An empty key also counts as no key, which is defensive rather than reachable, since uci does not store an empty option value.

  • Two unbound/server validation messages named enum sets their own validator contradicts. protocol advertised auto, which is rejected, and omitted default, ip6_local and ip6_prefer, which are accepted; resource_limits omitted the accepted default. Both messages are now derived from the constant they validate against, as domain_type already was, so a new enum value cannot leave the message behind. Reachable on the PATCH delta path, where the central schema gate does not fire for a field the request did not name, which is why nobody had reported it.

Deprecated

  • Roughly thirty fields that write a uci option no OpenWrt component reads are announced for removal in v3, found by auditing every curated field against its reader in the OpenWrt sources. Each has never had any effect: the write is accepted and stored, and the daemon carries on as before. They are removed rather than corrected because, unlike the three repointed in this release, there is nothing to point them at: mwan3rtmon has no polling interval, vnstat's real settings live in a file that ships from upstream, LLDP-MED is a build-time switch, unbound is enabled through procd, and the prometheus exporter enumerates its collectors from disk. Seven of that exporter's seventeen collector toggles name collectors that do not exist in the package at all. Requests carrying these keys are already ignored, so nothing on the write side needs migrating; the read side does, since each is currently returned with a default: annotation, which is what an IaC client reads to keep an attribute sticky. Each is flagged deprecated: true on the property, which is what docs/deprecations.md requires and what actually reaches a client: a prose bullet in the spec description is read by a human, the flag is read by codegen.

  • managed leaves the request half of every resource schema in v3. It is annotated readOnly from this release, which is the notice: a regenerated client stops putting it in request models.

  • Each resource gains a separate request schema and response schema in v3. One schema serving both directions is what forces dhcp/hosts.tag to keep string in its type for writers while responses are always an array, and network/interfaces.ipaddr to be described in prose rather than as readOnly. The ledger already promised that consequence while the cause went unannounced.

  • vnstat/interfaces is deprecated for removal in v3; use vnstat/config.interfaces. The endpoint has never worked. It models config interface sections, and vnstat only ever reads a list interface inside config vnstat (vnstat.init:21,28), so a POST returned 200, wrote a section, and vnstat carried on tracking exactly what it tracked before. Confirmed on a real router carrying both shapes at once: vnstat.@vnstat[0].interface='br-lan' 'eth0' was being tracked while two uapi-created config interface sections for vlan30 and lan were ignored. Migration is a translation, not a copy: the dead endpoint took uci interface names (lan), the working field takes device names (br-lan). All seven of its operations carry deprecated: true in the spec, so a generated client surfaces the warning; prose in the ledger reaches a human reading the document and nothing else.

  • dhcp/hosts.mac and dhcp/hosts.mac_aliases are deprecated in favour of macs, for removal in v3. All three name one uci list mac. Both are flagged deprecated: true on the property, unlike network/interfaces.ipaddr above, and the difference is not an oversight: ipaddr corresponds to a real scalar uci option and survives as a read, whereas uci has no scalar mac option on a host, so mac was never a uci field at all, only uapi's positional half of a list. Nothing is left for it to mean once macs exists, so both names go entirely rather than becoming read-only.

  • dhcp/hosts.tag will read back as an array of strings, not a space-separated string, from v3. Both shapes are accepted on write today and the field reads back whichever uci holds, which is the inconsistency being retired: dnsmasq's tag construct is multi-valued and LuCI has always written the list form. Not flagged deprecated: true on the property, because the field itself survives and only the string form goes away; the notice lives in the field's description and in docs/deprecations.md, alongside the other five v3 changes now listed under "Upcoming in v3" in the OpenAPI document's own description.

  • network/interfaces.ipaddr is deprecated as a write input; send ipaddrs instead. Both name the same uci list ipaddr and the list already wins on write, so migrating means dropping the scalar from request bodies rather than changing any value. Reads are unaffected now and after removal: ipaddr keeps carrying the first entry of the list, and v3 marks it readOnly rather than deleting it. That read half is why the property is not flagged deprecated: true in the spec, since the flag has no read/write split and would tell a generator the field is disappearing; the announcement is in the field's description and in docs/deprecations.md instead. Announced now because a full-replace client cannot avoid sending both names, which is what #60 and #65 each cost a release to work around; one writable name per uci option removes the cause rather than resolving it per method.

  • List-valued fields will read back null instead of [] when the underlying uci key is absent, targeted at v3 (#39). uci cannot store an empty list, so [] already means "absent" and distinguishes nothing. This is a convention change across every curated resource and it breaks response validation for clients generated against the current {"type": "array"} schema, so it is announced here a major ahead rather than staged per field. Nothing changes in this release.