Skip to content

Releases: openwrt-iac/uapi

v3.0.0

Choose a tag to compare

@github-actions github-actions released this 13 Aug 15:06
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>Request and <Name>Response schemas 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, managed and runtime stay tolerated so an apply can
    send a read straight back.
  • A PUT or PATCH carrying no body is refused; a PATCH writes only the keys it was given,
    neither materialising defaults nor rewriting an operator's spelling.
  • allowed_cidrs matches 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

v3.0.0-rc3 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 13 Aug 05:32
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-r1

Coming 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 /tokens violated its own schema: the metadata type was composed over the whoami response and inherited a required list demanding token_id and source_ip, neither of which the token endpoints return.
  • 105 response properties declared a type that forbade the null they 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/interfaces demanded key when encryption is a PSK variant, and key is write-only, so no round trip could satisfy it.
  • A type admitted a null its 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 PUT or PATCH body is refused instead of writing. An empty PATCH used to answer 200 and commit, materialising defaults into uci from a request that carried no instruction at all.
  • A PATCH no longer writes defaults for fields it was not asked to touch, and no longer rewrites keys it was not given. A stored enabled 'on' used to come back as '1' after a patch of some unrelated field.

Also new:

  • allowed_cidrs accepts 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 router 0.0.0.0/0 alone 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-Key and X-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

v3.0.0-rc2 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 12 Aug 13:29
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-r1

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. 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>Request and <Name>Response, with a Request emitted only
    where a write is possible
  • uci-backed list fields read back null rather than [] when the key is absent
  • firewall/redirects match fields are scalars, where they were arrays capped at one entry
  • network/interfaces.ipaddr is read-only, and ipaddrs is 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>.list verbatim, 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 add an operator runs, and it was invisible through the API: both read paths parse a
    single line, so GET reported only the first. A token scoped to packages:feeds alone
    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 for set_password
    and is now shared rather than duplicated.

  • GET /raw/uapi returned every token's salt and hash. 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 requires raw:uapi and
    uapi:tokens together, 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

Choose a tag to compare

@github-actions github-actions released this 12 Aug 13:30
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>.list verbatim, 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 next apk add an operator runs, and it was invisible through the
    API: both read paths parse a single line, so GET reported only the first. A token scoped to
    packages:feeds alone 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 now 422 where it was 200; the old behaviour wrote a
    file no caller could describe through the API.

  • GET /raw/uapi returned every token's salt and hash. 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 requires raw:uapi and
    uapi:tokens together, 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

v3.0.0-rc1 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 11 Aug 22:04
v3.0.0-rc1

Added

  • POST /batch reports its transaction outcome. The 207 now carries X-Reload-Status, X-Reload-Services, X-Kernel-Status and X-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 207 returns none of these headers, which was true when measured and is now the thing being changed.

  • docs/migration-v2-to-v3.md, and make lint-wire-names now fails on a waiver whose property no longer exists rather than only on one whose file does not.

  • network/interfaces models the IPv6 and broadcast fields a static interface needs: ip6addrs (uci list ip6addr), ip6gw, ip6prefix and broadcast. 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 neither ipaddr nor ip6addr required. Each field was observed taking effect on a real daemon: broadcast as brd on the netdev, ip6gw as the IPv6 default route, and ip6prefix in netifd's ipv6-prefix, delegating a /60 to lan from 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 same main.uc under two prefixes: a stale /api/v2 would 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 one del_list, one add_list and a single final prefix.

  • Every resource is now described by two schemas, <Name>Request and <Name>Response. One schema served both directions until now, which is why network/interfaces.ipaddr had to be described in prose rather than as readOnly, why dhcp/hosts.tag kept string in its type for writers although responses were always an array, and why runtime and managed needed a readOnly annotation 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: managed is absent from every request schema, network/interfaces.ipaddr is readOnly in the response half and absent from the request one, and dhcp/hosts.tag is array-only in both directions.

    make lint-openapi-shape gains a rule that a request schema may not carry managed, runtime, or any readOnly property, 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 four runtime arrays, which come from ubus rather than uci. Verified that the four runtime arrays 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, and as_list returned []. make lint-defaults accepts the new as_list_or_null(...) shape alongside section.X ?? null, and still rejects plain as_list, verified in both directions.

  • firewall/redirects match fields are scalars, where they were arrays capped at one entry: src_ip, src_dip, src_dport, dest_ip, dest_port and src_port. proto is 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_dport rather than match.src_dport[0].

  • A <Name>Request schema 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 by lint-openapi-shape and written down in docs/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_replace seam 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 from docs/adding-a-resource.md with it, along with the Mirrored field pairs section, 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 as readOnly in the response half.

  • dhcp/hosts.mac and dhcp/hosts.mac_aliases. macs is the only name, and it is the whole uci list mac as one array. Neither removed name was ever a uci option: mac was the list's first entry and mac_aliases the rest, so a client had to read two fields to learn what one reservation matched. Validation errors previously reported against mac now report against macs.

  • network/interfaces.name as a create input. Send id, the universal section-name input since 2.2.0. The 422 conflict for a disagreeing id and name goes 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/config and prometheus_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 a default: annotation an IaC client may have kept sticky. prometheus_node_exporter_lua/config loses 18 of its 20 fields and is now listen_interface and listen_port.

  • vnstat/interfaces, the whole endpoint. It modelled config interface sections, which vnstat never reads, so a POST answered 200 and changed nothing the daemon looked at. Use the interfaces array on vnstat/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_patch and resolve_for_replace on both dhcp/hosts and network/interfaces, equal_list, and the mirrored-pair conflict rules in both validates. 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-only ipaddr as an address, but only ipaddrs writes, so POST {"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 names ipaddrs, which is the only spelling a write can act on, so that body returns 422 ipaddrs required instead 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 PUT that 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 retired ipaddr and no ipaddrs is exactly that shape: measured on a test box, the interface went from 192.0.2.88 to no address with a silent 200. The comparison now treats an omitted field as a deletion on PUT and continues to treat it as "leave alone" on PATCH, and the retired ipaddr scalar 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 same PUT now answers X-Mgmt-Path-Warning: interface=loopback changed=disabled,ipaddrs,netmask, and the equivalent PATCH stays silent.

  • Address formats are validated whatever the proto says. The checks sat inside the static branch while the write path was unconditional, so a body naming another proto skipped them entirely: a dhcp interface accepted broadcast: "999.999.999.999" and ip6gw: "not-an-address" with a 200 and committed both. Whether an address is required depends on the proto; whether a value is an address does not. gateway gains the format check it never had.

v2.5.0

Choose a tag to compare

@github-actions github-actions released this 09 Aug 21:10
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 ...

Read more

v2.4.1

Choose a tag to compare

@github-actions github-actions released this 03 Aug 21:20
v2.4.1

Fixed

  • WireGuard peer writes now reach the kernel. A POST, PUT or PATCH on network/wireguard_peers committed the section to uci, answered 200, 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. DELETE had the matching hole: it answered 204 and 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 with wg 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 parent interface section unchanged, /etc/init.d/network reload finds 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 same network reload through a config.change event 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 set after 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 a PUT rotates public_key. This is a new external command alongside the reload, apk and passwd calls uapi already makes. It is deliberate: WireGuard exposes no ubus service for peers, so netifd shells out to wg and 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 unresolvable endpoint_host dropped a working tunnel and its healthy peers while the API answered 200. With wg set a 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_host is shell-quoted rather than newly validated, so no previously accepted payload starts being rejected; a preshared key is passed as a 0600 file and never as an argument.

    route_allowed_ips is applied too, with the routes spelled the same way netifd spells them and placed in ip4table / ip6table when the interface sets one; a prefix is withdrawn only once no remaining peer and no config route section still wants it. Peers on an interface that is down, or that netifd does not know, are written to uci and applied at the next ifup, as before. Closes openwrt-iac/uapi#51.

  • network/interfaces no longer discards half of a body that sets ipaddr and ipaddrs to different addresses. Both are wire names for the same uci list ipaddr, and the list won whenever it was non-empty, so the scalar was dropped and the write answered 200 with 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. POST and PATCH report 422 validation_failed with a conflict on ipaddr: naming both there is a choice, and PATCH can say which one it meant. PUT cannot. A full-replace caller sends every field it knows, the read mirrors the first list entry into ipaddr, 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 made ipaddrs unwritable through any such client. On PUT a differing ipaddr is 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): the POST and PATCH rejection 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 PATCH naming only ipaddr a silent no-op, since the merge folded the just-read ipaddrs into 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_peers accepts IPv6 and bare addresses in allowed_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 form wg show prints back and netifd turns into a host route. All four shapes wg accepts (10.0.0.0/24, 10.0.0.5, fd00::/64, fd00::1, and the 0.0.0.0/0 and ::/0 catch-alls) are now accepted, checked against wg set on a real interface, and what wg rejects is still rejected. This only widens what is accepted, so no payload that worked before stops working.

  • network/rules accepts a packet mark as the only selector. It required one of in/out/src/dest, but mark is 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 43 is valid, netifd writes exactly that from a rule carrying only mark, lookup and priority, and the kernel prints it back as from all fwmark 0x43. The check prevented nothing, since the workaround was to add src: "0.0.0.0/0", which is what a mark-only rule already means. Closes openwrt-iac/uapi#52.

v2.4.0

Choose a tag to compare

@github-actions github-actions released this 30 Jul 23:22
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/rules gains the DSCP target alongside the existing MARK, and the values they require: set_mark / set_xmark (value or value/mask, decimal or 0x hex, 32-bit) and set_dscp (a symbolic class such as CS0, AF11, EF, LE, case-insensitive, or a number 0-63). A target that needs a value and does not have one is now a 422 instead of a rule the router discards.

  • The HELPER target is deliberately not exposed. firewall4 accepts a set_helper naming any helper in its helpers file, but only emits the ct helper nftables object for helpers whose kernel module is loaded, and nft -f is 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 separate kmod-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/rules gains match.mark and match.dscp, each accepting a leading ! for negation the way firewall4 does. firewall/redirects gains match.mark, the one match option fw4 accepts on a config redirect.

  • New resource firewall/nat wrapping config nat, the only way to express MASQUERADE or exemption from source NAT. Targets are SNAT (with snat_ip / snat_port), MASQUERADE, and ACCEPT; the nested match block carries src_zone (the outbound, postrouting zone), device, addresses, ports, proto, and mark. Scope firewall:nat. Note match.family is deliberately not defaulted: firewall4 reads an absent family on a NAT section as IPv4-only for backwards compatibility, so reporting any would misdescribe the router; set it explicitly for dual-stack.

  • network/interfaces gains runtime.effective_proto, the protocol netifd is actually running for the interface. It differs from the configured proto when the device has no handler registered for that protocol: netifd silently discards the value, reports none, and the interface is inert, while the write returns 200, uci keeps the value and a read-back returns it. wwan is the case that arises in practice, since its handler ships in the separate wwan package. Comparing the two fields is the only way to see the gap from outside, so a client can now detect and report it. The proto field description names the package each protocol needs. Closes openwrt-iac/uapi#36.

    Deliberately not a validation error. netifd registers handlers by scanning /lib/netifd/proto at startup and caches the result, and a network reload does 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/redirects models src_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 dropped src_dip and took two live nftables rules with it. The same loss applied to a DNAT section using src_dip for 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 wildcard match.dest_zone, a missing match.src_dip, or a negated one. Source NAT on new configuration is better expressed with firewall/nat, which is where LuCI migrates these sections, and the target description says so. Closes openwrt-iac/uapi#23.

  • A redirect created through firewall/redirects was silently discarded by the router whenever it set src_ip, src_port, src_dport, dest_ip, or dest_port. firewall4 marks only proto, src_mac, and reflection_zone as list options on a config redirect; the rest are scalars, and its parse_opt refuses a list outright, dropping the whole section. uapi modelled all of them as arrays and uci writes an array as a list, 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 a 422 rather 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/rules treated match.src_zone / match.dest_zone value any as a wildcard synonym for *. firewall4 has exactly one wildcard, *; any resolves 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. any is now checked against real zones like any other name.

  • Ports and addresses are validated against what firewall4 a...

Read more

v2.3.0

Choose a tag to compare

@github-actions github-actions released this 24 Jun 12:42
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 scopes printing one scope path per line (sorted, greppable). Pair with --json for a JSON array suitable for piping into jq or any other consumer. The CLI is the durable cross-package interface; it works from any shell, Ansible playbook, or fleet inventory tool that can ssh to 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 how uapi-token itself 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 /tokens accepts optional rate and burst integer fields; uapi-token create accepts --rate <N> and --burst <N>. Both write option rate '<N>' / option burst '<N>' on the token's uci section. The request-path rate limiter (src/lib/ratelimit.uc's effective_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_apply that deleted any existing uci option not re-emitted by toUci, so a partial PATCH (e.g. changing unbound verbosity, or any field on firewall.rules) silently wiped hand-set or stock options the curated model omits (dns64_prefix, icmp_type, synflood_protect, cert key_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, not key), 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 any writeOnly field 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.devices accepts an empty country (read back as null) and the "00" world regulatory domain (stock 6 GHz default); wireless.interfaces accepts encryption='owe' (Opportunistic Wireless Encryption, keyless, the stock 6 GHz default) without demanding a key; network.devices no longer requires type, so a config device options-override section (name + macaddr, no type, as config_generate emits on some targets) round-trips.

Internal

  • New integration test tests/integration/44_stock_config_test.sh round-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: igmp accepted by firewall.rules/firewall.redirects; */any wildcards accepted by firewall.rules zone refs; dhcp.servers no longer requires the referenced network interface to exist (stock ships dhcp.wan against an absent network.wan on x86); is_valid_cidr_any accepts IPv6 CIDR (used by mwan3.rules once mwan3 coverage lands).

v2.3.0-rc1

v2.3.0-rc1 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 22 Jun 09:10
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 the X-Uapi-Confirm header 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 returns 202 Accepted with a confirm token; unless the client acks via POST /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 endpoints GET /confirm, GET|POST|DELETE /confirm/<token> and scope uapi:confirm (:ro for status/list, :rw for 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, returning 501 confirm_unavailable when apply-confirm is not installed. See docs/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 scopes printing one scope path per line (sorted, greppable). Pair with --json for a JSON array suitable for piping into jq or any other consumer. The CLI is the durable cross-package interface; it works from any shell, Ansible playbook, or fleet inventory tool that can ssh to 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 how uapi-token itself 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 /tokens accepts optional rate and burst integer fields; uapi-token create accepts --rate <N> and --burst <N>. Both write option rate '<N>' / option burst '<N>' on the token's uci section. The request-path rate limiter (src/lib/ratelimit.uc's effective_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_apply that deleted any existing uci option not re-emitted by toUci, so a partial PATCH (e.g. changing unbound verbosity, or any field on firewall.rules) silently wiped hand-set or stock options the curated model omits (dns64_prefix, icmp_type, synflood_protect, cert key_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, not key), 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 any writeOnly field 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.devices accepts an empty country (read back as null) and the "00" world regulatory domain (stock 6 GHz default); wireless.interfaces accepts encryption='owe' (Opportunistic Wireless Encryption, keyless, the stock 6 GHz default) without demanding a key; network.devices no longer requires type, so a config device options-override section (name + macaddr, no type, as config_generate emits on some targets) round-trips.

Internal

  • New integration test tests/integration/44_stock_config_test.sh round-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: igmp accepted by firewall.rules/firewall.redirects; */any wildcards accepted by firewall.rules zone refs; dhcp.servers no longer requires the referenced network interface to exist (stock ships dhcp.wan against an absent network.wan on x86); is_valid_cidr_any accepts IPv6 CIDR (used by mwan3.rules once mwan3 coverage lands).