Releases: openwrt-iac/uapi
Release list
v3.0.0
First stable v3 release. Promotion of v3.0.0-rc3 after the provider integration window closed
with nothing filed. No code or spec changes since rc3; only the VERSION bump and this
changelog promotion.
The cumulative v3 changes vs. 2.5.1 are listed across the three RC entries below. As a contract
summary:
/api/v3/mount; 48 curated resource endpoints plus/raw/passthrough,/batch, and the
ops endpoints (/healthz,/openapi.json,/schema,/metrics,/tokens,/auth/whoami,
/diagnostics).- Separate
<Name>Requestand<Name>Responseschemas for every resource. A read answers null
for an unset uci option and both halves say so, which is what makes a read-modify-write
round trip valid against the published document. - A request naming a field the resource does not declare is refused with
422 unknown_field
rather than dropped in silence.id,managedandruntimestay tolerated so an apply can
send a read straight back. - A
PUTorPATCHcarrying no body is refused; aPATCHwrites only the keys it was given,
neither materialising defaults nor rewriting an operator's spelling. allowed_cidrsmatches IPv6 callers, against entries of its own family only.- 1230 unit tests, property-fuzz at 1000 iterations per resource per CI run, an integration
suite against a real OpenWrt 25.12 VM including live response-conformance in both directions,
soak with RSS/fd-leak watch, and eleven mutation-probed gates.
Upgrading from 2.x moves the mount from /api/v2 to /api/v3; one installation serves one API
major. Read docs/migration-v2-to-v3.md before installing rather than after.
v3.0.0-rc3
Release candidate
This is a prerelease. It does not reach the apk feed, and apk upgrade uapi will not find it: the feed serves the latest stable, currently 2.5.1. Installing an RC is deliberate.
# on the router
wget https://github.com/openwrt-iac/uapi/releases/download/v3.0.0-rc3/uapi-3.0.0_rc3-r1.apk -O /tmp/uapi-rc3.apk
apk add --allow-untrusted /tmp/uapi-rc3.apk
# rollback
apk add uapi=2.5.1-r1Coming from 2.5.x, upgrading moves the mount from /api/v2 to /api/v3. One installation serves one API major, so a v2 client stops working until its base URL changes, and docs/migration-v2-to-v3.md is the upgrade path. Coming from rc1 or rc2, the mount does not move and the read below is the interesting part instead.
If you are running rc2, read this first
A request naming a field the resource does not declare is now refused. It used to be dropped in silence, so a body that named the wrong thing answered 200 and wrote nothing. PATCH {"dest_port": ["9999"]} on a firewall rule is the real example that prompted it: the field lives under match, and at the top level it reported success and changed nothing. Such a request now answers 422 with field code unknown_field, naming the path.
Nothing that was ever honoured changes. The payloads that start failing are exactly the ones that silently did nothing, so a client relying on a field being ignored will now hear about it. id, managed and runtime stay tolerated at the top level, because every apply is a read-modify-write and those three appear in every response and in no request schema.
What else changed since rc2
Five defects in the published schema, all found by validating live response bodies against the document rather than checking the document against itself:
- Every entry of
GET /tokensviolated its own schema: the metadata type was composed over the whoami response and inherited arequiredlist demandingtoken_idandsource_ip, neither of which the token endpoints return. - 105 response properties declared a type that forbade the
nullthey return for an unset uci option. rc1 widened the list-valued ones; the scalars were never swept. - Request schemas rejected the read-modify-write bodies the server accepts. A live firewall rule reads back with ten null-valued keys, and an apply sends that view straight back.
- A conditional required a masked field:
wireless/interfacesdemandedkeywhen encryption is a PSK variant, andkeyis write-only, so no round trip could satisfy it. - A type admitted a
nullits own enum refused, describing a value nothing could hold.
Two write-path fixes, both cases of uapi writing when it was not asked to:
- An empty
PUTorPATCHbody is refused instead of writing. An emptyPATCHused to answer 200 and commit, materialising defaults into uci from a request that carried no instruction at all. - A
PATCHno longer writes defaults for fields it was not asked to touch, and no longer rewrites keys it was not given. A storedenabled 'on'used to come back as'1'after a patch of some unrelated field.
Also new:
allowed_cidrsaccepts IPv6 prefixes. The allowlist compared IPv4 only, so a token carrying one rejected every IPv6 caller and no v6 prefix could be expressed. A caller matches entries of its own family only, so on a dual-stack router0.0.0.0/0alone still denies IPv6: "any address" means listing both.- The query-string fallbacks are declared in the spec. uhttpd's CGI layer strips
If-Match,If-None-Match,Idempotency-KeyandX-Request-Id, so each has a?if_match=style fallback that previously lived only in prose. - A revoked token's rate-limit bucket is now reaped by both revoke paths, rather than outliving the token and being inherited by the next one minted under the same name.
Why this exists before 3.0.0
So that shape changes can still be corrected. After v3.0.0 is tagged the next chance is v4, and a wire surface is expensive to have and cheap to get wrong.
rc2 is superseded rather than promoted for exactly that reason: sixteen commits landed after it, one breaking and five correcting the document, so rc2 described an API that no longer exists.
Feedback on anything schema-shaped is the most useful kind, since that is where every defect in this cycle turned up. No closing date on the window.
Verification
Installed on a device from this release's APK, upgrading over rc2: conffile preserved, served document byte-identical to the tagged one, 28 live behaviour checks, and a conformance sweep clean in both directions across 52 response bodies and 96 read-modify-write bodies.
v3.0.0-rc2
Release candidate
This is a prerelease. It does not reach the apk feed, and apk upgrade uapi will not find
it: the feed serves the latest stable, currently 2.5.1. Installing an RC is deliberate.
# on the router
wget https://github.com/openwrt-iac/uapi/releases/download/v3.0.0-rc2/uapi-3.0.0_rc2-r1.apk -O /tmp/uapi-rc2.apk
apk add --allow-untrusted /tmp/uapi-rc2.apk
# rollback
apk add uapi=2.5.1-r1Upgrading moves the mount from /api/v2 to /api/v3. One installation serves one API major, so
a v2 client stops working until its base URL changes. docs/migration-v2-to-v3.md is the
upgrade path, and it is worth reading before installing rather than after.
Why this exists before 3.0.0
So that shape changes can still be corrected. After v3.0.0 is tagged the next chance is v4, and
that is the whole reason for a candidate rather than a deadline: there is no fixed end date and
v3.0.0 gets tagged when nothing fundamental is outstanding.
The shape changes below are the part a generated client feels, and each is only correctable
inside this major:
- every resource split into
<Name>Requestand<Name>Response, with aRequestemitted only
where a write is possible - uci-backed list fields read back
nullrather than[]when the key is absent firewall/redirectsmatch fields are scalars, where they were arrays capped at one entrynetwork/interfaces.ipaddris read-only, andipaddrsis the only write name
Two guarantees are now stated in docs/versioning.md rather than left to be inferred: a missing
Request schema means the resource is not writable, and a curated resource's response half
contains every field of its request half at any depth. Both are enforced by lint-openapi-shape.
A client may rely on them; if either is inconvenient to depend on, that is worth saying now.
Do not test against rc1
v3.0.0-rc1 shipped two vulnerabilities, both found by reviewing it and both fixed here. Its
artifact should not be installed anywhere.
Reporting
Anything exploitable goes through
private vulnerability reporting,
which is new since rc1: during that window there was no private channel, so two findings went
into public issues with working reproductions. SECURITY.md sets out what counts as a
vulnerability in a daemon that is root-privileged by design. Everything else is an ordinary issue.
Security
-
A feed URL carrying a newline appended a second, hidden apk repository.
create_feed
anchored its pattern at the start only and wrote the value into
/etc/apk/repositories.d/<name>.listverbatim, so
{"url": "http://legit/feed\nhttp://attacker/evil"}produced two repository lines. apk trusts
every line, so the injected one is arbitrary package installation as root on the next
apk addan operator runs, and it was invisible through the API: both read paths parse a
single line, soGETreported only the first. A token scoped topackages:feedsalone
therefore reached code execution well outside that scope. Control characters are now rejected
and the pattern is anchored at both ends. The same guard already existed forset_password
and is now shared rather than duplicated. -
GET /raw/uapireturned every token'ssaltandhash. The curated endpoints gate both
behind a flag only the internal auth path sets; the passthrough normalised the section
verbatim and handed back exactly what they mask. Reaching it requiresraw:uapiand
uapi:tokenstogether, so this was disclosure rather than escalation, and bearers are random
and salted, but the material flowed into anything built on a raw read, a config export or a
backup being the obvious ones. Stripped now. A replace carries the stored values forward
rather than deleting them, since a stripped field cannot come back in a request body and a
plain read-modify-write would otherwise have destroyed the credential.Both were found by adversarially reviewing 3.0.0-rc1 and are fixed before 3.0.0 final.
v2.5.1
Security-only patch on the v2 line. Both issues were found by adversarially reviewing the v3
release candidate and are present in every earlier release, so they are fixed here too for
operators pinned to the v2 wire contract.
Security
-
A feed URL carrying a newline appended a second, hidden apk repository.
create_feed
anchored its pattern at the start only and wrote the value into
/etc/apk/repositories.d/<name>.listverbatim, so a URL containing a newline produced two
repository lines. apk trusts every line, making the injected one arbitrary package
installation as root on the nextapk addan operator runs, and it was invisible through the
API: both read paths parse a single line, soGETreported only the first. A token scoped to
packages:feedsalone therefore reached code execution well outside that scope. Control
characters are now rejected and the pattern is anchored at both ends. A feed URL containing
control characters or whitespace is now422where it was200; the old behaviour wrote a
file no caller could describe through the API. -
GET /raw/uapireturned every token'ssaltandhash. The curated token endpoints gate
both behind a flag only the internal auth path sets; the passthrough normalised the section
verbatim and returned exactly what they mask. Reaching it requiresraw:uapiand
uapi:tokenstogether, so this is disclosure rather than escalation, and bearers are random
and stored salted, but the material flowed into anything built on a raw read. Stripped now,
and a replace carries the stored values forward rather than deleting them, since a stripped
field cannot come back in a request body and a plain read-modify-write would otherwise have
destroyed the credential.
v3.0.0-rc1
Added
-
POST /batchreports its transaction outcome. The 207 now carriesX-Reload-Status,X-Reload-Services,X-Kernel-StatusandX-Kernel-Applied, aggregated over the sub-writes. A batch commits and reloads once for the whole set, so there is one outcome to report, and the 207 is the only place it can be reported: the results array carries{status, body}and drops sub-response headers. A pure-read batch takes no lock and runs no transaction, so it emits nothing, matching a read anywhere else.This corrects a documented measurement rather than adding a new capability: the generator recorded that
/batch 207returns none of these headers, which was true when measured and is now the thing being changed. -
docs/migration-v2-to-v3.md, andmake lint-wire-namesnow fails on a waiver whose property no longer exists rather than only on one whose file does not. -
network/interfacesmodels the IPv6 and broadcast fields a static interface needs:ip6addrs(ucilist ip6addr),ip6gw,ip6prefixandbroadcast. An interface addressed only over IPv6 previously read back with no addresses and nothing to say a value had been seen and dropped, and echoing that body back was refused. The static-proto rule now asks for at least one address family rather than for IPv4, matching LuCI, whose static form marks neitheripaddrnorip6addrrequired. Each field was observed taking effect on a real daemon:broadcastasbrdon the netdev,ip6gwas the IPv6 default route, andip6prefixin netifd'sipv6-prefix, delegating a /60 tolanfrom the /56 given.
Changed
-
The URL prefix moves from
/api/v2/to/api/v3/, as it did across the v1 boundary. Upgrading the package strips every older mount and adds the new one, so uhttpd never serves the samemain.ucunder two prefixes: a stale/api/v2would otherwise look like the removed v2 surface while answering with v3 semantics. Verified by replaying the install hook against a box holding a v2 mount, which produced onedel_list, oneadd_listand a single final prefix. -
Every resource is now described by two schemas,
<Name>Requestand<Name>Response. One schema served both directions until now, which is whynetwork/interfaces.ipaddrhad to be described in prose rather than asreadOnly, whydhcp/hosts.tagkeptstringin its type for writers although responses were always an array, and whyruntimeandmanagedneeded areadOnlyannotation to stay out of a generated request model. Generated model names change for every resource. 46 pairs, and the 13 singletons gain a real request schema in place of the untyped{"type": "object"}patch body they carried before.Three announced changes ride on it, none of them expressible before:
managedis absent from every request schema,network/interfaces.ipaddrisreadOnlyin the response half and absent from the request one, anddhcp/hosts.tagis array-only in both directions.make lint-openapi-shapegains a rule that a request schema may not carrymanaged,runtime, or anyreadOnlyproperty, so the split cannot silently regrow.GET /schema/<package>/<resource>still serves the module's declared set as one object, and now says so. -
List-valued fields read back
null, not[], when the uci key is absent. uci cannot store an empty list, so[]already meant "absent" and distinguished nothing; a client could not tell the two apart because there were never two states. 46 properties across 22 resources widen to["array", "null"]. The request and response envelopes are untouched, where[]means empty rather than absent, as are the fourruntimearrays, which come from ubus rather than uci. Verified that the fourruntimearrays kept[]rather than being swept along with the rest.The payoff is that a list field can now carry
x-uapi-clear-on-omit, which was impossible before: the flag requires a shape that reads absent as null, andas_listreturned[].make lint-defaultsaccepts the newas_list_or_null(...)shape alongsidesection.X ?? null, and still rejects plainas_list, verified in both directions. -
firewall/redirectsmatch fields are scalars, where they were arrays capped at one entry:src_ip,src_dip,src_dport,dest_ip,dest_portandsrc_port.protois genuinely a list and does not change. firewall4 refuses a list on these and discards the whole section, so the cap existed to stop uapi writing one; narrowing the type needed a major. What changes is when a mistake is caught, since a second value was accepted by the schema and refused at apply, after a declarative client had committed to a plan. Validation errors on them lose their index:match.src_dportrather thanmatch.src_dport[0]. -
A
<Name>Requestschema is emitted only where a write is possible. The read-only lease endpoints carried one that nothing referenced, which blunts a signal a generated client reads: a missing request half means not writable, or removed. Two guarantees of the split are now asserted bylint-openapi-shapeand written down indocs/versioning.md, the second being that a curated resource's response half contains every field of its request half, at any depth, which is what lets a client derive writability by membership.
Removed
-
The
resolve_for_replaceseam in the handler. Both implementors went with the mirrored names, and an extension point with no implementor is a permanent tax on every future change to the code around it. Its contract is gone fromdocs/adding-a-resource.mdwith it, along with theMirrored field pairssection, which documented a hazard that no longer exists: a uci option now gets exactly one writable name, and a field that reads differently from how it writes is expressible asreadOnlyin the response half. -
dhcp/hosts.macanddhcp/hosts.mac_aliases.macsis the only name, and it is the whole ucilist macas one array. Neither removed name was ever a uci option:macwas the list's first entry andmac_aliasesthe rest, so a client had to read two fields to learn what one reservation matched. Validation errors previously reported againstmacnow report againstmacs. -
network/interfaces.nameas a create input. Sendid, the universal section-name input since 2.2.0. The422 conflictfor a disagreeingidandnamegoes with the field. -
The 27 fields that wrote a uci option no OpenWrt component reads, across
mwan3/globals,vnstat/config,lldpd/config,unbound/server,usteer/configandprometheus_node_exporter_lua/config. Writes carrying them were already ignored, so nothing on the write side migrates; what changes is that they no longer appear in responses, where each carried adefault:annotation an IaC client may have kept sticky.prometheus_node_exporter_lua/configloses 18 of its 20 fields and is nowlisten_interfaceandlisten_port. -
vnstat/interfaces, the whole endpoint. It modelledconfig interfacesections, which vnstat never reads, so aPOSTanswered 200 and changed nothing the daemon looked at. Use theinterfacesarray onvnstat/config, remembering the values differ in kind: the removed endpoint took uci interface names (lan), vnstat wants device names (br-lan). -
The compatibility machinery the above unlocks:
merge_for_patchandresolve_for_replaceon bothdhcp/hostsandnetwork/interfaces,equal_list, and the mirrored-pair conflict rules in bothvalidates. Each existed only to decide which of two names for one uci option the caller meant.
Fixed
-
A static interface could be created with no address at all, and reported
200. The static-proto rule counted the read-onlyipaddras an address, but onlyipaddrswrites, soPOST {"proto": "static", "ipaddr": "192.0.2.99"}passed validation, wrote nothing, and answered success; the error message for the empty case even recommended the field that does nothing. The rule now namesipaddrs, which is the only spelling a write can act on, so that body returns422 ipaddrs requiredinstead of silently producing an interface the daemon cannot bring up. Verified on hardware, and the tightening was checked against a real/etc/config: 44 stock sections still round-trip, the four static interfaces among them included. -
The management-path warning was blind to a
PUTthat strips the caller's own addresses. It compared only the keys the body named, so a deletion, which a replace expresses by leaving a field out, produced no warning at all. A client still sending the retiredipaddrand noipaddrsis exactly that shape: measured on a test box, the interface went from192.0.2.88to no address with a silent200. The comparison now treats an omitted field as a deletion onPUTand continues to treat it as "leave alone" onPATCH, and the retiredipaddrscalar left the watched set, since a read-only field can only describe a write that never happens. Verified against the interface carrying the request: the samePUTnow answersX-Mgmt-Path-Warning: interface=loopback changed=disabled,ipaddrs,netmask, and the equivalentPATCHstays silent. -
Address formats are validated whatever the proto says. The checks sat inside the
staticbranch while the write path was unconditional, so a body naming another proto skipped them entirely: a dhcp interface acceptedbroadcast: "999.999.999.999"andip6gw: "not-an-address"with a200and committed both. Whether an address is required depends on the proto; whether a value is an address does not.gatewaygains the format check it never had.
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 readsmanaged: false, writes to it are refused with 409, andPOST .../adoptrenames it and hands back the new id. Verified end to end on hardware against a hand-writtenconfig 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.shmakes 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 leftfirewall/*, most ofnetwork/*,dhcp/odhcpd,usteer/configandsqm/queuesunverified 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_stais 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.enabledread the word spellings of true as enabled while usteer did not. The init reads the option withuci -q getand then compares[ "$ENABLED" -gt 0 ], which is numeric rather than a bool parse, soenabled 'true'makes the shell bail with "out of range", andstart_servicereturns without registering a procd instance, while uapi reported the daemon enabled. The read now mirrors that comparison exactly, includingenabled '2'counting as enabled and an absent option defaulting to enabled. -
disabledonnetwork/interfaces,network/routesandnetwork/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 aGETto 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_peershas modelled the same flag since it shipped. Verified on hardware: a route created withdisabled: trueis absent fromip route, reads backdisabled: true, and aPATCHclearing it installs the route; the same holds for a rule inip ruleand 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 against1, while route and rule go through the boolean blob converter which also takestrue. 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/configandvnstat/confignow have integration coverage. Both packages sit outside the bare OpenWrt image, so every call to those resources answered 503init_script_missingand neither was ever exercised against a real daemon, which is howvnstat/interfacesshipped modelling a section type vnstat has never read: an uninstalled package makes a wrong model look exactly like a right one.install_uapinow 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_descriptionis checked in theconfigure system descriptionline lldpd compiles into its config, andvnstat/config.interfacesin vnstat's own database viavnstat --dbiflist. Both resources also joined the stock-config round-trip. Verified on hardware in both directions, including that the section-type assertion rejects aconfig interfacesection holding the right device, which is the shape of the original bug. -
Eight deprecated fields now say why they are deprecated.
deprecated: truetells 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.enabledis read only onconfig zone,vnstat/config.database_diris a key of a file that ships from upstream, and so on.make lint-openapi-shapenow requires a description opening withDeprecatedon any property carrying the flag, matched case-insensitively becausenetwork/interfaces.namepredates the convention and opensDEPRECATED in 2.2.0. Documentation only, no behaviour change. -
vnstat/config.interfaces: the devices vnstat tracks, mapping to thelist interfaceinsideconfig vnstatthat 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"]}putsbr-lanin the list and, after a service restart, vnstat is trackingbr-lanin its database. -
Three response headers uapi emits are now declared in the OpenAPI document:
X-Kernel-Status,X-Kernel-AppliedandX-Mgmt-Path-Warning. All three were added to the code and todocs/errors.mdwithout 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-Warningis per-resource and per-verb, so it is declared on exactly three responses.make lint-openapi-shapenow enforces both, deriving the guarded-resource count fromsrc/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 ucilist macunder 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 intomac, the first entry, andmac_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.macswins over both when sent, both old names keep working and keep reading back, andPUTresolves a stale split against the list the waynetwork/interfacesalready does foripaddrsrather 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.ucruns 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 sixlintsub-targets, each oflint-doc-refs' five checks separately,openapi-checkandcoverage. 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, amodule.exporta module does not export, a backtickedmake <target>the Makefile does not define, and an error code documented as returned that nothing insrc/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 ...
v2.4.1
Fixed
-
WireGuard peer writes now reach the kernel. A
POST,PUTorPATCHonnetwork/wireguard_peerscommitted the section to uci, answered200, and left the running tunnel untouched, so the peer did not exist as far as the kernel was concerned until something else restarted the interface.DELETEhad the matching hole: it answered204and the peer stayed live, so revoking a peer through the API did not revoke its access. Operators who have deleted a peer through a release before this one and need that revocation to have taken effect should confirm withwg show <interface> peers.The cause is a platform-wide limitation rather than anything specific to uapi. netifd reads peer sections with
config_foreach wireguard_<iface>inside the proto setup step, so a peer edit leaves the parentinterfacesection unchanged,/etc/init.d/network reloadfinds nothing to converge, and does nothing at all. LuCI is affected too and documents the workaround in its own peer form ("Restart wireguard interface to apply changes"), because its Save and Apply resolves to that samenetwork reloadthrough aconfig.changeevent that carries only a package name and so cannot express which interface changed. uapi knows which resource was written, and therefore which interface is affected, so it applies the change itself.Each peer write is now pushed to the kernel with
wg setafter the commit: a set for a create or update, a remove for a delete, a remove for a peer being disabled, and a remove of the previous key before the set when aPUTrotatespublic_key. This is a new external command alongside the reload,apkandpasswdcalls uapi already makes. It is deliberate: WireGuard exposes no ubus service for peers, so netifd shells out towgand so does LuCI's own backend. Asking netifd to re-apply instead was implemented and rejected on measurement, because the ubus call returns before the work happens and a failure takes the whole interface down: a single peer with an unresolvableendpoint_hostdropped a working tunnel and its healthy peers while the API answered200. Withwg seta bad peer fails alone, synchronously, and the write rolls back with the reason reported. uci remains the only config writer and the applied state is derived from committed uci, never from the request.endpoint_hostis shell-quoted rather than newly validated, so no previously accepted payload starts being rejected; a preshared key is passed as a0600file and never as an argument.route_allowed_ipsis applied too, with the routes spelled the same way netifd spells them and placed inip4table/ip6tablewhen the interface sets one; a prefix is withdrawn only once no remaining peer and noconfig routesection still wants it. Peers on an interface that is down, or that netifd does not know, are written to uci and applied at the nextifup, as before. Closes openwrt-iac/uapi#51. -
network/interfacesno longer discards half of a body that setsipaddrandipaddrsto different addresses. Both are wire names for the same ucilist ipaddr, and the list won whenever it was non-empty, so the scalar was dropped and the write answered200with the old address read back: a caller re-reading saw its own change vanish rather than fail.The answer differs by method, because the methods differ in what a caller can express.
POSTandPATCHreport422 validation_failedwith aconflictonipaddr: naming both there is a choice, andPATCHcan say which one it meant.PUTcannot. A full-replace caller sends every field it knows, the read mirrors the first list entry intoipaddr, so the scalar sits in its state even when its own config named only the list, and one of the two is stale by construction on every apply. Rejecting that body madeipaddrsunwritable through any such client. OnPUTa differingipaddris therefore dropped in favour of the list, which is the precedence the write path already applied, so no uci outcome changes.Carve-out (
docs/versioning.md): thePOSTandPATCHrejection refuses a payload earlier releases accepted, and ships in a patch because the state it produced was one no caller could rely on. The request said two different things about one option and was told neither had been ignored.The same collision made
PATCHnaming onlyipaddra silent no-op, since the merge folded the just-readipaddrsinto the body and that won. Whichever of the two the caller actually names now wins, and the other is dropped rather than resurrected from the server's own read. Closes openwrt-iac/uapi#60 and openwrt-iac/uapi#65. -
network/wireguard_peersaccepts IPv6 and bare addresses inallowed_ips. The field required IPv4 CIDR notation, so every IPv6 peer was refused and a dual-stack tunnel could not be configured through the API at all, and a bare address was refused even though it is the formwg showprints back and netifd turns into a host route. All four shapeswgaccepts (10.0.0.0/24,10.0.0.5,fd00::/64,fd00::1, and the0.0.0.0/0and::/0catch-alls) are now accepted, checked againstwg seton a real interface, and whatwgrejects is still rejected. This only widens what is accepted, so no payload that worked before stops working. -
network/rulesaccepts a packet mark as the only selector. It required one ofin/out/src/dest, butmarkis a selector in its own right and the one policy routing of reply traffic depends on: firewall4 marks in mangle prerouting and the rule sends the mark to a table, with neither source nor destination knowable in advance.ip rule add fwmark 0x43 lookup 43is valid, netifd writes exactly that from a rule carrying onlymark,lookupandpriority, and the kernel prints it back asfrom all fwmark 0x43. The check prevented nothing, since the workaround was to addsrc: "0.0.0.0/0", which is what a mark-only rule already means. Closes openwrt-iac/uapi#52.
v2.4.0
Closes the gap between what the firewall resources advertise and what firewall4 actually applies. target: "MARK" was accepted but had no field to carry the mark value, so fw4 warned must specify option 'set_mark' or 'set_xmark' for target 'mark' and skipped the section: the write returned 200 and the rule silently never existed. Auditing the rest of the surface against fw4 found the same class repeatedly, plus the inverse (uapi rejecting configurations fw4 accepts). Closes openwrt-iac/uapi#20.
One item is a different and more serious shape than the rest, and is worth reading before upgrading: a port matched alongside a protocol that cannot carry one was not a no-op but a widening. firewall4 dropped the port and emitted the rule anyway, so it matched more traffic than asked for, and with the all wildcard it matched everything. Verified on hardware: proto: ["all"] with dest_port: ["22"] on an ACCEPT rule renders a bare counter accept. Such payloads are now rejected. See the entry under Fixed, and openwrt-iac/uapi#24.
Upgrade note: payloads that were accepted and are now rejected
This release starts refusing configuration it previously wrote. Every shape below was already broken on the router before the upgrade: firewall4 was discarding the section, or emitting a rule matching more traffic than asked for, or the value was never validated at all. The 422 is not new breakage, it is the first time uapi says so.
Nothing here needs action on a router whose configuration uapi wrote and that has not been hand-edited. The risk is concentrated in sections adopted from an existing config, written by LuCI, or edited by hand, because those never passed through this validation.
| Payload | Result before | Why it was already broken |
|---|---|---|
A port matched alongside a protocol that cannot carry one, on firewall/rules, firewall/redirects or firewall/nat |
200 | firewall4 dropped the port and emitted the rule anyway, so it matched the whole protocol. With proto: ["all"] it matched everything. |
More than one value in a firewall/redirects match field (src_ip, src_port, src_dport, dest_ip, dest_port) |
200 | uci wrote a list, firewall4 refuses a list on those options and discarded the entire redirect. |
match.src_zone or match.dest_zone set to any on firewall/rules |
200 | firewall4 has exactly one wildcard, *. any resolved against zone names, matched nothing, and the section was discarded. |
target: "NOTRACK" with no source zone, or a wildcard one |
200 | firewall4 derives the chain name from the zone and discards the section without a named one. |
A protocol token nftables cannot resolve (ipcomp, l2tp, vrrp), or a negated one |
200 | firewall4 renders the token verbatim and nft -f is atomic, so one unresolvable token rejected the entire ruleset and the router kept its previous firewall. A negated protocol had its negation silently dropped. |
| A port or address firewall4 cannot parse: out of range, a descending range, malformed | 200 | firewall/rules validated neither, so the section was discarded. |
A zero-padded IPv4 octet such as 010.0.0.1 |
200 | inet_pton cannot read it, so firewall4 fell through to a network-name lookup, resolved nothing, and discarded the section. |
A malformed IPv6 address such as ::::: |
200 | Accepted by a character-class check, rejected by inet_pton, section discarded. |
A non-contiguous netmask in an address firewall4 rewrites to: snat_ip, an SNAT redirect's src_dip, a DNAT redirect's dest_ip |
200 | firewall4 discards the section over one. Still accepted on match addresses, where it renders correctly. |
A negated match.dest_ip on a DNAT redirect |
200 | firewall4 returns before emitting anything. Still accepted on an SNAT redirect, where dest_ip is an ordinary match. |
proto or dev_type outside the accepted set on openvpn/instances |
200 | The enum reached the spec as the string "NaN", which the type checker skips, so neither field was validated at all. |
Two of these were also widened while being enforced, so the new check is not simply stricter: the openvpn proto set gained the tcp-client and tcp-server spellings that luci-app-openvpn actually writes, and IPv6 validation now accepts the embedded-IPv4 form ::ffff:192.168.1.1 that the platform parses.
There is currently no way to ask a router which of its sections will be refused before writing to them; that gap is tracked in openwrt-iac/uapi#47.
Added
-
firewall/rulesgains theDSCPtarget alongside the existingMARK, and the values they require:set_mark/set_xmark(value or value/mask, decimal or0xhex, 32-bit) andset_dscp(a symbolic class such asCS0,AF11,EF,LE, case-insensitive, or a number 0-63). A target that needs a value and does not have one is now a422instead of a rule the router discards. -
The
HELPERtarget is deliberately not exposed. firewall4 accepts aset_helpernaming any helper in its helpers file, but only emits thect helpernftables object for helpers whose kernel module is loaded, andnft -fis atomic: a rule naming an unavailable helper makes the entire ruleset fail to load, leaving the router on its previous firewall. Helper modules ship as separatekmod-nf-conntrack-*packages and are absent by default, and uapi cannot verify availability from the resource layer (nor would a check hold, since the module can be removed later). Tracked as a follow-up. -
firewall/rulesgainsmatch.markandmatch.dscp, each accepting a leading!for negation the way firewall4 does.firewall/redirectsgainsmatch.mark, the one match option fw4 accepts on aconfig redirect. -
New resource
firewall/natwrappingconfig nat, the only way to express MASQUERADE or exemption from source NAT. Targets areSNAT(withsnat_ip/snat_port),MASQUERADE, andACCEPT; the nestedmatchblock carriessrc_zone(the outbound, postrouting zone),device, addresses, ports,proto, andmark. Scopefirewall:nat. Notematch.familyis deliberately not defaulted: firewall4 reads an absent family on a NAT section as IPv4-only for backwards compatibility, so reportinganywould misdescribe the router; set it explicitly for dual-stack. -
network/interfacesgainsruntime.effective_proto, the protocol netifd is actually running for the interface. It differs from the configuredprotowhen the device has no handler registered for that protocol: netifd silently discards the value, reportsnone, and the interface is inert, while the write returns 200, uci keeps the value and a read-back returns it.wwanis the case that arises in practice, since its handler ships in the separatewwanpackage. Comparing the two fields is the only way to see the gap from outside, so a client can now detect and report it. Theprotofield description names the package each protocol needs. Closes openwrt-iac/uapi#36.Deliberately not a validation error. netifd registers handlers by scanning
/lib/netifd/protoat startup and caches the result, and anetwork reloaddoes not rescan, only a restart does. So a write refused for a missing handler could not be fixed by installing the package, which is the remedy such an error would have to recommend. The configuration is legitimate and only the runtime is behind it, which is what a runtime field is for.
Fixed
-
firewall/redirectsmodelssrc_dip, the address firewall4 rewrites the source to on an SNAT redirect and matches the external destination against on a DNAT one. It was the only mandatory option of an SNAT redirect that uapi did not model, and because PUT is full-replace, leaving it out was destructive rather than merely limiting: verified on hardware that a plain read-modify-write of a working section returned 200, silently droppedsrc_dipand took two live nftables rules with it. The same loss applied to a DNAT section usingsrc_dipfor NAT reflection. With the field modelled it round-trips, and SNAT redirects are writable for the first time. Writes are refused exactly where firewall4 would discard the section: a missing or wildcardmatch.dest_zone, a missingmatch.src_dip, or a negated one. Source NAT on new configuration is better expressed withfirewall/nat, which is where LuCI migrates these sections, and thetargetdescription says so. Closes openwrt-iac/uapi#23. -
A redirect created through
firewall/redirectswas silently discarded by the router whenever it setsrc_ip,src_port,src_dport,dest_ip, ordest_port. firewall4 marks onlyproto,src_mac, andreflection_zoneas list options on aconfig redirect; the rest are scalars, and itsparse_optrefuses a list outright, dropping the whole section. uapi modelled all of them as arrays and uci writes an array as alist, even for a single element, so the write returned 200 and the port forward never existed. The wire type stays an array for compatibility, but at most one value is accepted (a second is now a422rather than a dead rule) and uci receives a scalar. Sections adopted from an existing config were unaffected, which is why the failure went unnoticed. -
firewall/rulestreatedmatch.src_zone/match.dest_zonevalueanyas a wildcard synonym for*. firewall4 has exactly one wildcard,*;anyresolves against zone names, matches nothing, and the section is discarded. uapi additionally suppressed the "zone does not exist" error for it, so the operator got a stronger signal that the value was valid.anyis now checked against real zones like any other name. -
Ports and addresses are validated against what firewall4 a...
v2.3.0
Surfaces the known scope tree through a sanctioned interface so external consumers (the upcoming luci-app-uapi LuCI frontend, fleet inventory tools, anything that wants to render a scope picker) can enumerate valid scopes without parsing src/lib/scope.uc or hardcoding a copy. Closes openwrt-iac/uapi#5. Also plumbs per-token rate / burst overrides through the mint surfaces, closing a "planned for v2.x" gap that has been carried in docs/tokens.md since 2.0.
Commit-confirmed apply (the ?confirm / /confirm surface) was present in the 2.3.0-rc1 pre-release and has since been deferred; it is not part of the 2.3.0 stable surface. The confirm wire contract is intentionally not frozen into v2 until its authz model is settled and a consumer needs it, so it does not lock in a contract that could only be changed with a major bump. See docs/commit-confirm.md and docs/roadmap.md.
Added
-
New CLI subcommand
uapi-token scopesprinting one scope path per line (sorted, greppable). Pair with--jsonfor a JSON array suitable for piping intojqor any other consumer. The CLI is the durable cross-package interface; it works from any shell, Ansible playbook, or fleet inventory tool that cansshto the router. -
New
scope.known_paths()module export returning a sorted array of scope paths. ucode consumers on the same box (the LuCI frontend in particular)require('scope')and call this directly, matching howuapi-tokenitself imports the module.
Both surfaces enumerate the same internal KNOWN_PATHS const; the accessor (rather than a direct const export) lets the underlying representation change without breaking consumers.
POST /tokensaccepts optionalrateandburstinteger fields;uapi-token createaccepts--rate <N>and--burst <N>. Both writeoption rate '<N>'/option burst '<N>'on the token's uci section. The request-path rate limiter (src/lib/ratelimit.uc'seffective_limits()) has been reading these uci options since 2.0.0; only the mint side was missing.uapi-token show <name>also now surfaces the configured rate/burst when set.
The OpenAPI vendor-extension option from the issue thread is deferred until a code-generated client surfaces with a concrete need; premature spec annotations without consumer evidence is what 2.2.2 / 2.2.3 corrected.
Fixed
-
PATCH no longer deletes uci options the resource does not model. Previously every write verb shared one
diff_applythat deleted any existing uci option not re-emitted bytoUci, so a partial PATCH (e.g. changingunboundverbosity, or any field onfirewall.rules) silently wiped hand-set or stock options the curated model omits (dns64_prefix,icmp_type,synflood_protect, certkey_type/ec_curve,ttylogin, and many more). PATCH now preserves them (RFC 7396 merge-patch: only options inside the resource's own modeled footprint are touched). PUT keeps full-replace semantics: it normalizes the section to the modeled set, so unmodeled options are intentionally dropped on a PUT. An audit of all curated resources against their stock OpenWrt configs found this affected 13 resources. -
A JSON Patch (RFC 6902) that does not touch a write-only secret no longer drops it. The JSON Patch post-image is built from the masked read view (which exposes
has_key, notkey), so a patch that left the secret alone previously produced a post-image without it and tripped conditional-required validation ("key is required when encryption is ..."). The write path now carries forward anywriteOnlyfield the patch did not set (key,preshared_key,private_key,tls_auth,pkcs12), matching what the merge-patch path already did; a patch that explicitly sets a new secret is left intact. -
Three validation rules relaxed to match what stock OpenWrt ships (found by the same audit):
wireless.devicesaccepts an emptycountry(read back as null) and the"00"world regulatory domain (stock 6 GHz default);wireless.interfacesacceptsencryption='owe'(Opportunistic Wireless Encryption, keyless, the stock 6 GHz default) without demanding a key;network.devicesno longer requirestype, so aconfig deviceoptions-override section (name + macaddr, no type, asconfig_generateemits on some targets) round-trips.
Internal
- New integration test
tests/integration/44_stock_config_test.shround-trips every curated CRUD resource and singleton whose package ships in the bare OpenWrt 25.12.4 image (firewall, network, dhcp, dropbear, system): GET the section, adopt if unmanaged, PUT-self (or PATCH-self for singletons), then GET again and assert the persistable shape did not drift. A 422 or a diff surfaces a regression where uapi rejects (or silently mutates) what the platform itself ships. Resources from optional packages (snmpd, lldpd, vnstat, mwan3, etc.) are deferred to a follow-up that wires their install at VM-setup time. First run forced four schema relaxations to bring API rules in line with the platform:igmpaccepted byfirewall.rules/firewall.redirects;*/anywildcards accepted byfirewall.ruleszone refs;dhcp.serversno longer requires the referenced network interface to exist (stock shipsdhcp.wanagainst an absentnetwork.wanon x86);is_valid_cidr_anyaccepts IPv6 CIDR (used bymwan3.rulesonce mwan3 coverage lands).
v2.3.0-rc1
Surfaces the known scope tree through a sanctioned interface so external consumers (the upcoming luci-app-uapi LuCI frontend, fleet inventory tools, anything that wants to render a scope picker) can enumerate valid scopes without parsing src/lib/scope.uc or hardcoding a copy. Closes openwrt-iac/uapi#5. Also plumbs per-token rate / burst overrides through the mint surfaces, closing a "planned for v2.x" gap that has been carried in docs/tokens.md since 2.0.
Added
-
Commit-confirmed apply (safe apply). A config write can arm a rollback deadline via
?confirm=<seconds>(or theX-Uapi-Confirmheader behind a proxy that forwards it; uhttpd's CGI strips custom headers, so the query form is the portable interface): uapi snapshots the affected uci packages, commits, and returns202 Acceptedwith aconfirmtoken; unless the client acks viaPOST /confirm/<token>before the deadline, the pre-change snapshot is restored automatically (surviving reboot / process kill / a dead management path). The ack is client-driven, so a network blip that hides the response also prevents the ack, keeping the auto-revert and the client's view consistent. New endpointsGET /confirm,GET|POST|DELETE /confirm/<token>and scopeuapi:confirm(:rofor status/list,:rwfor ack/rollback; arming needs no extra scope). The rollback timer and durable state live in a separate package,apply-confirm(uapi invokes its CLI, runs no daemon of its own); the integration is optional and feature-detected, returning501 confirm_unavailablewhen apply-confirm is not installed. Seedocs/commit-confirm.md. (This RC ships to GitHub Releases only; the stable 2.3.0 tag and apk-feed publication wait until apply-confirm reaches a stable feed release.) -
New CLI subcommand
uapi-token scopesprinting one scope path per line (sorted, greppable). Pair with--jsonfor a JSON array suitable for piping intojqor any other consumer. The CLI is the durable cross-package interface; it works from any shell, Ansible playbook, or fleet inventory tool that cansshto the router. -
New
scope.known_paths()module export returning a sorted array of scope paths. ucode consumers on the same box (the LuCI frontend in particular)require('scope')and call this directly, matching howuapi-tokenitself imports the module.
Both surfaces enumerate the same internal KNOWN_PATHS const; the accessor (rather than a direct const export) lets the underlying representation change without breaking consumers.
POST /tokensaccepts optionalrateandburstinteger fields;uapi-token createaccepts--rate <N>and--burst <N>. Both writeoption rate '<N>'/option burst '<N>'on the token's uci section. The request-path rate limiter (src/lib/ratelimit.uc'seffective_limits()) has been reading these uci options since 2.0.0; only the mint side was missing.uapi-token show <name>also now surfaces the configured rate/burst when set.
The OpenAPI vendor-extension option from the issue thread is deferred until a code-generated client surfaces with a concrete need; premature spec annotations without consumer evidence is what 2.2.2 / 2.2.3 corrected.
Fixed
-
PATCH no longer deletes uci options the resource does not model. Previously every write verb shared one
diff_applythat deleted any existing uci option not re-emitted bytoUci, so a partial PATCH (e.g. changingunboundverbosity, or any field onfirewall.rules) silently wiped hand-set or stock options the curated model omits (dns64_prefix,icmp_type,synflood_protect, certkey_type/ec_curve,ttylogin, and many more). PATCH now preserves them (RFC 7396 merge-patch: only options inside the resource's own modeled footprint are touched). PUT keeps full-replace semantics: it normalizes the section to the modeled set, so unmodeled options are intentionally dropped on a PUT. An audit of all curated resources against their stock OpenWrt configs found this affected 13 resources. -
A JSON Patch (RFC 6902) that does not touch a write-only secret no longer drops it. The JSON Patch post-image is built from the masked read view (which exposes
has_key, notkey), so a patch that left the secret alone previously produced a post-image without it and tripped conditional-required validation ("key is required when encryption is ..."). The write path now carries forward anywriteOnlyfield the patch did not set (key,preshared_key,private_key,tls_auth,pkcs12), matching what the merge-patch path already did; a patch that explicitly sets a new secret is left intact. -
Three validation rules relaxed to match what stock OpenWrt ships (found by the same audit):
wireless.devicesaccepts an emptycountry(read back as null) and the"00"world regulatory domain (stock 6 GHz default);wireless.interfacesacceptsencryption='owe'(Opportunistic Wireless Encryption, keyless, the stock 6 GHz default) without demanding a key;network.devicesno longer requirestype, so aconfig deviceoptions-override section (name + macaddr, no type, asconfig_generateemits on some targets) round-trips.
Internal
- New integration test
tests/integration/44_stock_config_test.shround-trips every curated CRUD resource and singleton whose package ships in the bare OpenWrt 25.12.4 image (firewall, network, dhcp, dropbear, system): GET the section, adopt if unmanaged, PUT-self (or PATCH-self for singletons), then GET again and assert the persistable shape did not drift. A 422 or a diff surfaces a regression where uapi rejects (or silently mutates) what the platform itself ships. Resources from optional packages (snmpd, lldpd, vnstat, mwan3, etc.) are deferred to a follow-up that wires their install at VM-setup time. First run forced four schema relaxations to bring API rules in line with the platform:igmpaccepted byfirewall.rules/firewall.redirects;*/anywildcards accepted byfirewall.ruleszone refs;dhcp.serversno longer requires the referenced network interface to exist (stock shipsdhcp.wanagainst an absentnetwork.wanon x86);is_valid_cidr_anyaccepts IPv6 CIDR (used bymwan3.rulesonce mwan3 coverage lands).