Skip to content

v2.4.0

Choose a tag to compare

@github-actions github-actions released this 30 Jul 23:22
· 135 commits to main since this release
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 actually parses, across all three firewall resources. Previously firewall/rules validated neither, so dest_port: ["70000"] or a typo'd address returned 200 and the router discarded the rule; firewall/redirects bounded neither the magnitude nor the ordering of a range while rejecting fw4's ! negation and : range separator, and accepted only bare IPv4 in dest_ip, rejecting IPv6, prefixes, ranges, and uci network names it resolves happily.

  • The proto enum across the firewall resources rejected protocols firewall4 supports, including gre, sctp, ipv6-icmp, numeric values, *, and tcpudp, 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 into meta l4proto, nft resolves it against its own built-in table rather than /etc/protocols, and because nft -f is atomic an unresolvable token rejects the entire ruleset rather than one section. ipcomp, l2tp, and vrrp are in /etc/protocols but not resolvable by nft, so they are refused.

  • firewall/redirects rejected match.dest_zone: "*", which firewall4 permits on a DNAT (only the source side forbids the wildcard), and never checked reflection_zone against real zones, where a misspelling discards the entire port forward rather than just its loopback rules.

  • firewall/rules no longer requires match.src_zone on every rule. firewall4 requires a source zone only for NOTRACK, whose chain name is derived from it; a rule without one is valid and lands in the output or mangle_output chain. Rules that omit it were previously rejected with a 422 uapi had no basis for.

  • Conversely, NOTRACK now requires a named source zone: match.src_zone absent or set to the * / any wildcard 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_port only inside its case "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 an ACCEPT rule 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 on firewall/rules, firewall/redirects and firewall/nat. firewall/nat keeps one carve-out that the other two cannot have: a list of nothing but wildcards is accepted there, because config nat is the only section type fw4 runs ensure_tcpudp over, rewriting it to tcp+udp before the ports are read. A protocol list with no ports is unaffected, as is an absent proto, which fw4 defaults for itself. Closes openwrt-iac/uapi#24 in part.

  • openvpn/instances published a malformed enum for dev_type and proto, and validated neither field. The schemas built the value with keys(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, because check_schema_types skips a non-array enum, so any value at all was accepted. Nothing caught it: openapi-check only 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's lint-openapi-shape and CI's make openapi-validate. Closes openwrt-iac/uapi#27.

  • Repairing that enum makes openvpn/instances validate proto and dev_type for the first time, so a value outside the accepted set is now a 422 where it was previously stored unchecked. The accepted proto set was widened to everything openvpn takes before switching the check on: it had listed only udp, tcp, and the numbered variants, omitting the tcp-client and tcp-server spellings 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_auth and pkcs12 are masked on read, surfacing only a has_* boolean, so a client that GETs a section cannot send the secret back. Each resource restored it inside its own merge_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 the src_dip loss 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 the writeOnly annotation 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_ip on firewall/nat, match.src_dip on an SNAT redirect, and match.dest_ip on 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 as saddr & <mask> == <addr>; validating them uniformly would have rejected working configuration.

  • A negated match.dest_ip is 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, where dest_ip is an ordinary match.

  • Zero-padded IPv4 octets such as 010.0.0.1 are rejected. inet_pton parses 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 against iptoarr on a router.

  • IPv6 validation was both too loose and too strict, in ways that each had a consequence. It accepted addresses inet_pton rejects, 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.

  • runtime is now annotated readOnly on every schema that carries it, not just the three that document its shape. It is derived from ubus and toUci ignores 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-shape now 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/rules no longer lists match as required. It was accurate at v2.0.0, when every rule needed a source zone and therefore a match object to hold it; relaxing src_zone to 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/redirects keeps the requirement, where it is real because src_zone is mandatory there, and firewall/nat still has none. Closes openwrt-iac/uapi#42.

  • /firewall/nat stays singular, deliberately: nats reads badly and nat_rules would diverge from the config nat section 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.body declares type: object instead 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 malformed enum above hid from every check that walks the document.

  • lint-openapi-shape gains two rules, both prompted by spec defects a downstream consumer found by diffing rather than by any gate here: runtime must be readOnly wherever 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.uc gains MARK_RE / MARK_MATCH_RE / MARK_MAX and masked_value_exceeds(), shared by the three resources that now expose a mark so the accepted syntax cannot drift between them. The schema pattern constrains shape; masked_value_exceeds catches 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 LE and 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/nat models match.src_ip, match.src_port, match.dest_ip, and match.dest_port as scalars, not arrays. firewall4 marks only proto as a list option on a config nat section; the others are scalars, and its parse_opt refuses a list outright ("option must not be a list") and discards the whole section. The sibling config rule and config redirect types do mark them as lists, so the arity genuinely differs per section type.

  • Address and port fields on firewall/nat are validated against what firewall4 actually parses, in both directions. Addresses (snat_ip, match.src_ip, match.dest_ip) are typed network by 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 on snat_ip. Ports accept fw4's full grammar (80, 1000-2000, 1000:2000, and a leading ! on match ports but not on snat_port) and are bounded at 65535 with an ordered range, because a port fw4 cannot parse means it discards the whole section.