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 actually parses, across all three firewall resources. Previously
firewall/rulesvalidated neither, sodest_port: ["70000"]or a typo'd address returned 200 and the router discarded the rule;firewall/redirectsbounded neither the magnitude nor the ordering of a range while rejecting fw4's!negation and:range separator, and accepted only bare IPv4 indest_ip, rejecting IPv6, prefixes, ranges, and uci network names it resolves happily. -
The
protoenum across the firewall resources rejected protocols firewall4 supports, includinggre,sctp,ipv6-icmp, numeric values,*, andtcpudp, which is fw4's own default token and what LuCI writes. Accepted values are now exactly the tokens nftables can resolve, checked case-insensitively as fw4 does, with protocol numbers bounded at 255. The distinction matters more than it looks: fw4 renders the token verbatim intometa l4proto, nft resolves it against its own built-in table rather than/etc/protocols, and becausenft -fis atomic an unresolvable token rejects the entire ruleset rather than one section.ipcomp,l2tp, andvrrpare in/etc/protocolsbut not resolvable by nft, so they are refused. -
firewall/redirectsrejectedmatch.dest_zone: "*", which firewall4 permits on a DNAT (only the source side forbids the wildcard), and never checkedreflection_zoneagainst real zones, where a misspelling discards the entire port forward rather than just its loopback rules. -
firewall/rulesno longer requiresmatch.src_zoneon every rule. firewall4 requires a source zone only forNOTRACK, whose chain name is derived from it; a rule without one is valid and lands in theoutputormangle_outputchain. Rules that omit it were previously rejected with a422uapi had no basis for. -
Conversely,
NOTRACKnow requires a named source zone:match.src_zoneabsent or set to the*/anywildcard is rejected. This is deliberate: firewall4 discards those sections outright (must specify a source zone for target ...), so the only configurations affected are ones that were already silently dead on the router. -
A port matched alongside a protocol that cannot carry one was silently widened into a rule matching the whole protocol. firewall4 assigns
src_port/dest_portonly inside itscase "tcp": case "udp":branch, so for any other protocol the ports are dropped and the rule is still emitted. The wildcard is the worst case rather than an exemption:{proto: ["all"], dest_port: ["22"]}on anACCEPTrule renders a rule accepting everything, because fw4 emits neither a protocol match nor a port match for it. Unlike the other defects in this release this is a widening rather than a no-op, so it fails validation onfirewall/rules,firewall/redirectsandfirewall/nat.firewall/natkeeps one carve-out that the other two cannot have: a list of nothing but wildcards is accepted there, becauseconfig natis the only section type fw4 runsensure_tcpudpover, rewriting it to tcp+udp before the ports are read. A protocol list with no ports is unaffected, as is an absentproto, which fw4 defaults for itself. Closes openwrt-iac/uapi#24 in part. -
openvpn/instancespublished a malformedenumfordev_typeandproto, and validated neither field. The schemas built the value withkeys(VALID_X) + [null], and ucode's+on two arrays does not concatenate: it coerces, yielding NaN, which the JSON encoder wrote as the string"NaN". Both fields therefore shipped"enum": "NaN"where an array is required, so a code generator reading the published spec saw a broken enum on the only two fields of that resource with a closed value set. It also disabled the check at runtime, becausecheck_schema_typesskips a non-array enum, so any value at all was accepted. Nothing caught it:openapi-checkonly diffs the generated file against the committed one, and both were malformed identically. Found by running a real OpenAPI 3.1 validator over the document for the first time. Two new gates now stand behind it,make lint'slint-openapi-shapeand CI'smake openapi-validate. Closes openwrt-iac/uapi#27. -
Repairing that enum makes
openvpn/instancesvalidateprotoanddev_typefor the first time, so a value outside the accepted set is now a422where it was previously stored unchecked. The acceptedprotoset was widened to everything openvpn takes before switching the check on: it had listed onlyudp,tcp, and the numbered variants, omitting thetcp-clientandtcp-serverspellings that luci-app-openvpn actually writes. Enforcing the narrower list would have rejected configuration the frontend most operators use produces. -
A read-modify-write of any section holding a write-only secret either destroyed the secret or was impossible.
private_key,key,preshared_key,tls_authandpkcs12are masked on read, surfacing only ahas_*boolean, so a client that GETs a section cannot send the secret back. Each resource restored it inside its ownmerge_for_patch, which only the PATCH paths call; PUT is full-replace and had no equivalent, so it deleted the stored value. Where the field is optional the write returned 200 and silently erased the secret, the same shape as thesrc_diploss above; where it is required (a wireguard interface, or a wireless interface whose encryption needs a key) validation rejected the body and the section could not be written through PUT at all. The carry-forward now lives in the handler, keyed on thewriteOnlyannotation the schemas already carried, and applies to every write path. An omitted and an explicit null secret both mean "keep": an IaC client that emits null for unset optional attributes must not destroy a working key, so clearing a secret remains deliberately inexpressible. Verified against a router's real wireguard interfaces, which previously could not be written back at all. Closes openwrt-iac/uapi#30. -
Non-contiguous netmasks are refused on the three addresses firewall4 rewrites to rather than matches on:
snat_iponfirewall/nat,match.src_dipon an SNAT redirect, andmatch.dest_ipon a DNAT redirect. fw4 discards the whole section over one there. They remain accepted on every match address, where fw4 deliberately supports them and renders them assaddr & <mask> == <addr>; validating them uniformly would have rejected working configuration. -
A negated
match.dest_ipis refused on a DNAT redirect, matching the guard firewall4 applies just before it gives up on the section. It stays valid on an SNAT redirect, wheredest_ipis an ordinary match. -
Zero-padded IPv4 octets such as
010.0.0.1are rejected.inet_ptonparses an octet with base 0, so firewall4 cannot read the address at all, falls through to a uci network-name lookup, resolves nothing and discards the section. Same reasoning as the protocol-number spelling already enforced, and confirmed againstiptoarron a router. -
IPv6 validation was both too loose and too strict, in ways that each had a consequence. It accepted addresses
inet_ptonrejects, such as:::::, which reach the router and discard a section; and it refused the embedded-IPv4 form::ffff:192.168.1.1, which the platform parses and applies. Validation now follows the real grammar in both directions: at most one::, at most four hex digits per group, exactly eight groups once expanded, and an embedded IPv4 tail permitted only in the final 32 bits. This affects every resource that validates an address, not only the firewall ones. -
runtimeis now annotatedreadOnlyon every schema that carries it, not just the three that document its shape. It is derived from ubus andtoUciignores it, so it is never writable on any resource, but 42 of the 45 emitted a bare{"type": "object"}with no annotation, which a code generator reads as an ordinary writable free-form map. Regenerate any client that derives writability from the spec.lint-openapi-shapenow asserts the annotation so it cannot go missing again. Reported from downstream while building provider support for this release, found by diffing the spec. Closes openwrt-iac/uapi#40. -
firewall/rulesno longer listsmatchas required. It was accurate at v2.0.0, when every rule needed a source zone and therefore amatchobject to hold it; relaxingsrc_zoneto NOTRACK-only earlier in this release made a match-less rule valid, and the spec kept advertising the old constraint. The server has always accepted such a rule, so this only stops the document overstating.firewall/redirectskeeps the requirement, where it is real becausesrc_zoneis mandatory there, andfirewall/natstill has none. Closes openwrt-iac/uapi#42. -
/firewall/natstays singular, deliberately:natsreads badly andnat_ruleswould diverge from theconfig natsection type and from what LuCI calls it. The exception is now recorded at the endpoint declaration and allowlisted in the plural-collection lint rule, so it is enforced rather than remembered. Closes openwrt-iac/uapi#41.
Internal
-
BatchOperation.bodydeclarestype: objectinstead of being left untyped. Its shape is whatever the target resource accepts, so the spec cannot say more, but an untyped schema is precisely where the malformedenumabove hid from every check that walks the document. -
lint-openapi-shapegains two rules, both prompted by spec defects a downstream consumer found by diffing rather than by any gate here:runtimemust bereadOnlywherever it appears, and a collection path segment must be plural unless allowlisted with a reason. A collection is identified structurally, by having a sibling{id}path, so neither rule guesses at naming. Closes openwrt-iac/uapi#43. -
values.ucgainsMARK_RE/MARK_MATCH_RE/MARK_MAXandmasked_value_exceeds(), shared by the three resources that now expose a mark so the accepted syntax cannot drift between them. The schemapatternconstrains shape;masked_value_exceedscatches the bound a pattern cannot express, since a 10-digit decimal still overflows 32 bits and a 2-digit DSCP still exceeds 63. -
Where fw4 and LuCI disagree on accepted values, uapi follows fw4. LuCI's DSCP validation omits
LEand is case-sensitive; fw4 accepts both, and fw4 is what applies the configuration, so a value the router honours is never rejected at the API. -
firewall/natmodelsmatch.src_ip,match.src_port,match.dest_ip, andmatch.dest_portas scalars, not arrays. firewall4 marks onlyprotoas a list option on aconfig natsection; the others are scalars, and itsparse_optrefuses a list outright ("option must not be a list") and discards the whole section. The siblingconfig ruleandconfig redirecttypes do mark them as lists, so the arity genuinely differs per section type. -
Address and port fields on
firewall/natare validated against what firewall4 actually parses, in both directions. Addresses (snat_ip,match.src_ip,match.dest_ip) are typednetworkby fw4, which resolves a bare address, a prefix in either family, an address range, or a uci network name; uapi accepts exactly those forms, in fw4's own parse order, and additionally refuses the negation fw4 forbids onsnat_ip. Ports accept fw4's full grammar (80,1000-2000,1000:2000, and a leading!on match ports but not onsnat_port) and are bounded at 65535 with an ordered range, because a port fw4 cannot parse means it discards the whole section.