Current release: v1.0.31 — an expired ZimaOS session is now shown on the page instead of only in the browser console. A ZimaOS access token lives three hours. When the one the browser holds has run out, every ZFW call answers
401— and before this release the only trace wasHTTP 401in the browser console while the tabs stayed empty. Now a notice above the tabs says the session has expired, names the daemon's reason (for exampleinvalid session: token expired) and offers a Reload button; the next successful call hides it again, so a session renewed in another dashboard tab clears it by itself. A refresh also stops after the first401instead of sending every tab's request into the same refusal, and error texts the daemon sends as plain text now reach the status line. Tested in Chrome on 2026-10-31 against a stand-in server that answers401in plain text like the daemon: the old page sent 11 requests and showedError in versions: HTTP 401; the new page sent 2 and showed the notice with the reason, which disappeared after the next successful refresh.Previous release: v1.0.30 — the Nebula overlay joins the VPN bypass, and the daemon is built with Go 1.27.3 (GO-2026-6809 in crypto/x509 fixed). ZFW lets traffic that arrives over a VPN interface through — Tailscale, ZeroTier, WireGuard (
wg+), Zima Net (tun0) — but the list had no entry for Nebula, so a device in a Nebula network fell to the default-deny like an unknown LAN host. Nownebula+(thenebulasysext module names its devicenebula1) is in the bypass list ofZFW-IN,ZFW-IN6,DOCKER-USERand the restore path; Nebula's UDP listen port still needs a rule (the Nebula module's tile adds it when Nebula is turned on). Measured on a ZimaBoard 2 (ZimaOS 1.8.0) on 2026-10-23: a device signed by the Nebula module's tile reached a service on the host over the overlay with no rule beyond the tile's listen-port rule.Previous release: v1.0.29 — four gaps in the new-app question are closed, and the daemon is built with Go 1.27.2 (ten reachable standard-library vulnerabilities fixed). One rule for all ports — a country or feed deny, or an allow for one admin PC — silenced the question for every new app; now such a rule counts only when it speaks for your whole LAN. Deleting the rule an answer became now withdraws the answer (before, the port stayed open after the next apply). Everyone now also opens a container port over IPv6 when Docker filters IPv6 itself (new chain
ZFW-APPS6in the IPv6DOCKER-USER). And Block — like every narrowing — now ends connections that are already open instead of only stopping new ones. Measured on a ZimaBoard 2 (ZimaOS 1.8.0-beta2) on 2026-10-09: with an all-ports rule for one address in place a new container port was still asked about; an open keep-alive connection survived LAN only and died at Block; deleting the answer's rule and applying closed the port; an IPv6 client reached a container on an IPv6 Docker network after Everyone and timed out again with the new jump removed by hand. The device test also caught a regression in the test build itself — the daemon rewrote every app chain once a minute until the first apply after the update — fixed before this release.Previous release: v1.0.28 — a new app's port no longer just stays closed: ZFW asks who may reach it — LAN only, everyone, or nobody — and the answer works at once, without an apply. Under default-deny a port an app opens after the last apply was dropped (v1.0.27): safe, but the app looked broken. Now ZFW notices such ports — from store apps and hand-started containers, published ports and
network_mode: hostapps — keeps them reachable from your LAN until you answer (or blocked, if you choose that mode) and asks in the Exposure tab, in a banner above the tabs and as a card on the ZimaOS dashboard. Each answer becomes an ordinary rule and goes live through dedicated chains (ZFW-APPS,ZFW-APPS-IN,ZFW-APPS-IN6) that onlyzfw appsrewrites — never a full apply, so no other saved-but-untested edit goes live with it, and a rule you wrote for the port always wins. "New" is narrow so an update opens nothing you had left closed: a published port only if it was not in the last apply's inventory, a host-network app only if it started after this version first ran. Measured on a ZimaBoard 2 (ZimaOS 1.8.0-beta2) on 2026-10-08: a hand-started container's port was reachable from the LAN 2 s after it started; the dashboard card appeared and opened ZFW; Block / LAN only / Everyone took effect at once (timeout / 200 / 200 plus IPv6); a host-network app and an app title from ZimaOS' compose file were picked up; the answers survived a re-apply and a dockerd restart. Also: the Exposure tab refreshes after Save rules (the not yet applied tag needed a reload before), and the rule dialog no longer says "Rule applied" for a rule only added to the list.Previous release: v1.0.27 — four ways the firewall was more open than it looked are closed: container ports published after the last apply, IPv6 link-local access, untested rules at boot, and an Exposure tab that showed saved rules as if they were live. A container that publishes a port after the last apply is now dropped by a guard at the end of both
DOCKER-USERchains (IPv4 and IPv6) until the next apply — before, it was open to everyone while Exposure called it blocked.ZFW-IN6no longer waves everyfe80::/10packet through; link-local traffic is filtered like any other, with ICMPv6, the DHCPv6 client port and mDNS still passing. Boot replays only the ruleset you confirmed (committed.sh), not whatever was last saved;install.shmigrates an existing unit. Exposure judges from a record of what was actually applied and gains arestrictedbadge for ports open only to a few addresses. Behaviour changes: apply after installing an app that publishes a port; LAN-scoped services are no longer reachable overfe80::. Measured on a ZimaBoard 2 (ZimaOS 1.8.0-beta2, kernel 6.18.9, iptables 1.8.11 nf_tables) on 2026-10-08: a port published after the apply went from HTTP 200 to a timeout from the LAN, withZFW-DOCK-DROPin the kernel log; SMB and port 9527 overfe80::went from open to closed while IPv4 stayed open; a rule saved but not applied stayed off across adockerdrestart. The docs also stop promising a log rate limit that never existed and putsudoin front of host commands.Previous release: v1.0.26 — a rule can now take a blocklist feed as its source, and the Firewall tab says where ZFW sits in
INPUT. A feed is a third-party list of networks known for abuse, kept in/DATA/zfw/feeds/and matched as an ipset. The catalogue is fixed in the daemon (FireHOL Level 1, Spamhaus DROP) and there is deliberately no URL field: a feed decides what your firewall drops, and an editable URL would hand that decision to whoever controls the far end. Said plainly, because it is easy to oversell: on a default-deny host chain a feed adds nothing — everything not explicitly allowed is dropped anyway. Feeds earn their keep on ports you open to the world, on published container ports, and outbound, where a deny rule with a feed source stops a compromised container from reaching known-bad infrastructure. Behind a Cloudflare or Pangolin tunnel a feed sees only the tunnel's address, exactly like geo-blocking. Guardrails, measured not assumed: FireHOL Level 1 ships0.0.0.0/8,10.0.0.0/8,100.64.0.0/10(Tailscale),127.0.0.0/8,169.254.0.0/16,172.16.0.0/12,192.168.0.0/16and224.0.0.0/3— loaded verbatim it would drop every LAN and Tailscale packet, so special-use ranges are removed unconditionally, and so are the LAN and host address fromrules.jsonand every sync peer. Measured on a ZimaCube on 2026-09-07: 4691 raw lines, 8 special-use ranges removed, 4650 networks loaded into the kernel in 10 ms. A refresh runs in the background every 12 hours (ZFW_FEEDS_REFRESH,0disables) and never touches the rules: the new content goes into a temporary set and is swapped in, socompiled.shand the live chains stay byte for byte what they were. The swap is not taste — loading into the live set leaves it half-filled for a moment (measured: 111 000 of 200 000 entries mid-load). The Firewall tab gains a status card with entry counts, packets matched, next refresh and Update now. Second half of the release: the status grid now reports whereZFW-INsits inINPUTand what runs ahead of it. ZFW inserts itself first and never shoves back, so a tool installed later can end up in front — harmless if it is stricter, a silent bypass if it accepts. ZFW reports it and carries it as audit finding M9; it does not fight for the slot. And a bug the feed work uncovered, present since v0.5.6: deleting the last outbound rule left the outbound chain running. Both emitters skipped the whole outbound block when no outbound rule existed — correct for a host that never had one, wrong for a host where one was just removed:ZFW-OUTstayed populated and stayed hooked intoOUTPUT, so the kernel kept dropping traffic that no rule asked for and nothing in the UI or inrules.jsonsaid so. Measured on a ZimaCube on 2026-09-07 with an outbound feed rule; both paths — the line-by-line script and the atomiciptables-restoreone the engine prefers — now unhook, flush and delete the chain when it is no longer needed, each with a test that fails without it.Previous release: v1.0.25 — The Exposure and Audit tabs were judging from a file the UI no longer writes. Both read the legacy
allowlist.conf. Since the Rules tab exists nothing writes that file, so on every v1.x install it is absent — and the empty config it fell back to made every LAN-facing socket read blocked and every port-based audit finding mitigated the moment the firewall was active, whateverrules.jsonsaid. Both dashboards now judge fromrules.jsonwith the same first-match semantics the compiler uses, and each has a test that fails if the two ever disagree again. The Events tab also shows what Log when this rule fires has promised since v0.4.4: per-rule LOG lines (ZFW-RULE-<id>) and IPv6 container drops (ZFW-DOCK6-DROP) were emitted by the compiler and parsed to nothing. Toolchain: the shipped v1.0.24 daemon was a go1.22.2 build — govulncheck lists 38 reachable standard-library CVEs against it. v1.0.25 is built with Go 1.27.1, pinned ingo.modand used by every CI job, and scans clean on both the source and the built binary. The SBOM step inbuild.shhad failed on every run since it was added in v0.2.19 (wrong module directory, stderr discarded); this is the first release whose tarball carriessbom.json. Housekeeping: the maintainer's own host addresses and username are gone from the docs, the API examples and a shipped audit finding (#20–#22), and the repository now carries its ISCLICENSEfile (#19).Previous release: v1.0.24 — ZimaOS' own remote access was being dropped by ZFW. ZimaOS ships
znet/ Zima Net, IceWhale's built-in remote-access mesh (an embedded EasyTier), and it owns the interfacetun0. ZFW bypassed every other mesh —tailscale0, ZeroTier'szt+, WireGuard'swg+— but not that one, so every inbound packet arriving through Zima Net hit the catch-all DROP. Measured on a ZimaCube:ZFW-IN-DROP IN=tun0 SRC=<zimanet-peer> DST=<zimanet-host> PROTO=TCP DPT=9527, 125 dropped SYNs in a single connection attempt, while the desktop client hung on "connecting" indefinitely andtun0's tx counter sat at 668 bytes for days. Adding the bypass moved it within two seconds. Fixed:tun0is now in the bypass list of all four emitted chains —ZFW-IN,ZFW-IN6,DOCKER-USERand its IPv6 counterpart. The first fix, applied by hand, covered onlyZFW-INand was therefore incomplete; the test now asserts all four chain outputs so a partial addition cannot pass again. Scope, measured rather than assumed: Zima Net is not new in v1.7.1-beta1. The official ZimaOS 1.7.0 image — downloaded and checksum-verified against the publishedchecksums.txt— already carries/usr/bin/znet, its unit,usr/share/zimanet/VERSION=0.1.0-2.6.4, a CasaOS module descriptor and a systemd preset readingenable znet.service. Zima Net is therefore shipped and enabled by default on stock 1.7.0, and every such host running ZFW ≤ v1.0.23 is dropping its traffic.Previous release: v1.0.23 — ZimaOS v1.7.1-beta1 renamed the session-token issuer, and the whole UI went dark. Every tab reported
HTTP 401and the dashboard stayed empty on hosts that had just taken the update. Nothing about ZFW had changed: it verifies the ZimaOS session token itself (the gateway proxies module routes without authenticating them), and part of that check is the token'sissclaim — pinned tocasaosso that the refresh token, which the user-service signs with the same key, cannot be replayed as a firewall session. In v1.7.1-beta1 the access token's issuer becamezimaos, so ZFW rejected every genuine login withinvalid session: token issuer "zimaos" not accepted (want "casaos"). Fixed: bothcasaosandzimaosare accepted, so ZFW works on v1.7.1+ and on v1.7.0 and older hosts. The refresh token kept its own issuer (refresh) through the rename and is still refused, along with any other issuer — the scoping that the check exists for is intact, and each half now has its own test. Measured against a live login on a v1.7.1-beta1 host, not inferred. And made findable next time: a refused session used to produce no log line at all — the reason went into the 401 body and nowhere else, so an outage that broke every request leftjournalctl -u zfw-uilooking normal. Rejections are now logged atWARNwith the reason, the path and the client, rate-limited to one line per 30 s with a count of what was suppressed in between. The token is never logged.Previous release: v1.0.22 — container ports were unreachable over IPv6, and nothing said so. Reported from the field: a CMS published on 443 worked from the LAN and was dead from the internet, while every rule in the table read "Allow" and had been checked one by one. Cause: with Docker's ip6tables support off — the ZimaOS default — containers hold no IPv6 address and a v6 connection to a published port terminates on the host's docker-proxy listener, so it is filtered in
INPUT(ZFW-IN6), never inFORWARD/DOCKER-USER.ZFW-IN6only ever received the host half of a rule's ports, so no Docker-published port had an allow there and every IPv6 client hit the catch-all DROP. IPv4 was fine the whole time viaDOCKER-USER— which is exactly why testing from inside the LAN could not see it. The gap was invisible before v1.0.17, whenZFW-IN6was still written to the legacy table while the live path ran through nft and the chain was inert; upgrading across that line switches IPv6 filtering on for the first time. Fixed: rules now mirror their full port set intoZFW-IN6regardless of zone (ports=allon the Docker zone excepted — widening that would expose host services too, and it is reported instead). Made visible: a rule whose source is an IPv4 address or range cannot be matched on ip6tables at all — that is unavoidable, but it used to happen in silence, andrules.Defaultsseeds every starter rule with the LAN CIDR as source. Such rules are now badged IPv4 only in the rule table with the reason and the fix, served by the new read-onlyGET /api/rules/v6. Warned about: a deny-by-default rule set on a host with a public IPv6 address where not one rule reachesZFW-IN6now raises a banner on the Rules tab — that state drops every inbound IPv6 connection while the table reads green.Previous release: v1.0.21 — Apply now takes effect on already-open connections, not just new ones. A conntrack-based firewall accepts
ESTABLISHED,RELATEDbefore it ever consults a user rule, so blocking a port that already had a live connection left that connection flowing until it closed on its own — the block bit only new connections, and operators reached for a full disable/enable to force it. After a successful apply ZFW now flushes the kernel connection-tracking entries for exactly the ports that switched from allowed to denied, over ctnetlinkIPCTNL_MSG_CT_DELETE— the same netlink interface the Connections tab reads, since ZimaOS ships noconntrackbinary — so an established connection to a freshly blocked port drops at once instead of surviving the apply. The flush is targeted (only newly-denied ports, computed by diffing the applied rule set against the live one; the whole table is never touched, so unrelated long-lived connections are left alone) and best-effort (the rules are already live, so a flush failure is logged, not surfaced as an apply failure). The first apply after a daemon start flushes nothing, since there is no prior state to diff against.Previous release: v1.0.20 — a full code + security review, and the bugs it turned up. The headline: the safety net reinstated what it caught. The Safe-Apply dead-man (and the failed-apply rollback) flushed the live rules but left
zfw.serviceenabled and pointing at the verycompiled.shit had just rolled back — so the lockout ruleset returned on the next reboot or the nextdockerdrestart, that time with no dead-man armed. Both paths now tear boot-persistence down together with the rules. Next to it,arm_deadmandiscarded the exit status ofsystemd-runand logged "DEADMAN ARMED" unconditionally, so Safe-Apply could run with no rollback timer at all while promising one; it fails closed now. The root-exec guard (root-owned, not world-writable) thatapplyenforced was missing oncommitandrevert, which run the same file with the same privileges — every engine invocation is checked.compiled.shwas written with a truncatingos.WriteFilewhilezfw.service(PartOf=docker.service) could be reading it, so a routinedockerdrestart could hand the engine a half-written root script; it is now published atomically. InDOCKER-USERthe docker-bridge RETURNs sat after the user rules, so an inbound-looking "deny port 443" also killed every container's outbound HTTPS (--ctorigdstportcarries no direction) — the bypasses are hoisted above the rules. Container-bound rules substituted only TCP ports, silently dropping a bound container's published UDP ports. Andjournalctl --sincewas given a UTC-formatted timestamp, which journalctl parses in local time: west of UTC the Events tab was permanently empty while the kernel was actively logging drops. Also: rate-limiting on/api/system/containers, SSRF-redirect hardening on the peer-push client, a geo cache that a 200-with-HTML captive-portal page can no longer overwrite, loopback detection across all of127.0.0.0/8(systemd-resolved's127.0.0.53was listed as LAN-reachable), a schedule validator that rejects+9:30(it compiled to an iptables error that took the entire firewall down), and an admin UI that no longer calls Google Fonts on every load.Previous release: v1.0.19 — the Connections tab finally works on ZimaOS (#1). ZFW read the connection table from
/proc/net/nf_conntrackand, failing that, fromconntrack(8). A stock ZimaOS kernel builds with# CONFIG_NF_CONNTRACK_PROCFS is not setand the image ships noconntrackbinary, so both sources were unavailable and the tab was permanently empty — on a host that was tracking hundreds of flows. ZFW now reads ctnetlink (CONFIG_NF_CT_NETLINK=y), the kernel's netlink interface, in pure Go with no new dependency. Second,/api/conntrackno longer answers200 []when the table cannot be read: an unreadable table is a503naming the source that failed, and the UI shows that reason instead of wrongly blaming a missing kernel module.Previous release: v1.0.18 — follow-up to v1.0.17.
revert(and therefore the Safe-Apply dead-man) never emptied the IPv6DOCKER-USERchain, which v1.0.17 started filling — so a "reverted" firewall kept silently dropping IPv6 traffic to published container ports while every status surface reported it was off.revertnow restores that chain's stock-j RETURN, andzfw statusshows it. The engine script also carried the same name-based backend guess fixed elsewhere in v1.0.17 (case "$IPT" in *nft*)), so on ZimaOS 1.6.2 itsrevert/statusoperated on the wrong IPv6 table. Finally, the UI, README and engine logs now say plainly that the dead-man removes the firewall entirely rather than restoring the previous rules.Previous release: v1.0.17 — two ZimaOS 1.6.2 fixes. (1) The iptables backend is now identified by asking the binary (
iptables -V→nf_tables/legacy) instead of pattern-matching its name. On 1.6.2 the plainiptablessymlink drives nf_tables despite having nonftin its name, so whenever the Docker-FORWARD probe missed — e.g.zfwdstarting beforedockerd— IPv6 was pinned to the empty legacy table and the dashboard reported "IPv6 protection ✗" whileZFW-IN6was live. (2) The published-port inventory that scopes theDOCKER-USERdefault-deny now unions docker-proxy sockets withdocker ps. With"userland-proxy": falsethere is no docker-proxy, the inventory came up empty, and the per-port default-deny was silently not emitted at all — the firewall failed open behind a green tile. (3) The inventory is now protocol-aware:parseDockerPortsused to discard UDP mappings outright, so a container publishing e.g.8181/udpgot neither an allow rule nor a deny rule and stayed reachable from any source while its TCP sibling was filtered. Published UDP ports now get both. Also new: the IPv6DOCKER-USERchain is populated (it matters the moment Docker IPv6 is enabled), and an empty inventory underdefault_policy=denyis now logged as an error instead of passing silently.
ZFW is a standalone ZimaOS module that adds the one thing ZimaOS does not ship: a host firewall — with a web UI and a live security dashboard.
ZimaOS ships with no host firewall at all. On a stock install every iptables
chain — INPUT, FORWARD, OUTPUT — has a default policy of ACCEPT and carries
no filtering rules, and there is no nftables ruleset either. This is not one host
misconfigured; it is the out-of-the-box state of the operating system.
The direct consequence: every service listening on 0.0.0.0 is reachable from the
entire local network, and nothing in the OS can stop it. That already covers
services ZimaOS itself ships and enables by default:
- Samba/SMB (445/139) and the NFS + rpcbind stack (2049/111) — file-sharing
daemons open to the whole subnet.
rpcbindon port 111 is a well-known reflection/amplification and information-disclosure vector. - Discovery services — mDNS, WS-Discovery, SSDP/UPnP, LLMNR — broadcast services enabled by default.
- The built-in VM console. Virtual machines created through ZimaOS's built-in virtualization module expose their VNC console (port 5900 and up) with no password, bound to all interfaces. Any device on the LAN can point a VNC viewer at it and take full keyboard/mouse/screen control of the running VM. This is a shipped default — it affects every ZimaOS user who runs a VM.
- The ZimaOS web UI on port 80.
Then there is the app store. ZimaOS is built around one-click Docker apps, and a
published container port goes straight onto 0.0.0.0 — Docker's port mapping
bypasses the host entirely and is LAN-wide the instant the app starts. Many
widely-used self-hosted images default to no authentication: log viewers,
metrics dashboards, browser-desktop / noVNC images, admin panels. With no host
firewall, each such app is an open door on the network the moment it is installed —
and a ZimaOS user is expected to install apps freely; that is the product.
Finally, the LAN itself is no longer a trust boundary. A normal home network carries smart TVs, IoT gadgets, a games console, guests' phones — any one of which can be compromised and is then a direct peer of the NAS that holds all your data.
ZimaOS is marketed as a plug-and-play homelab/NAS appliance for people who are explicitly not network engineers. Expecting every owner to hand-audit service bindings is unrealistic. A firewall is the single systemic control that closes this entire class of exposure at once. That is what ZFW provides.
ZFW grew out of a hands-on security audit. That audit ran on a heavily customised host with many self-installed apps — those host-specific findings are deliberately not the argument here. The argument is the stock ZimaOS baseline described above, which is identical on every install.
A standalone ZimaOS module — a tile in the ZimaOS dashboard — with seven tabs:
- Firewall — live status, a card for the blocklist feeds your rules use (live entry
count, packets matched, next refresh, Update now); Safe-Apply with a 120-second dead-man switch: if you do
not Confirm in time, ZFW removes the firewall entirely (all ZFW rules dropped, host
back to its unprotected stock state — not a restore of the previously committed rules).
A bad rule can never lock you out, but an unattended Safe-Apply leaves the host
unprotected; Commit; Revert. The status grid also shows where ZFW's chain sits
in
INPUT: ZFW inserts itself first and never shoves back, so a tool installed later (a blocklist module, a VPN's own chain) can end up ahead of it — harmless if it is stricter, a silent bypass if it accepts. The tab names what runs ahead, and the Audit tab carries it as finding M9. ZFW reports; it does not fight for the slot. - Rules — the rule list, evaluated top to bottom, first match wins. A rule is allow/deny on a source (any / IP / CIDR range / ISO-3166 country codes, max 32 / a blocklist feed from a fixed catalogue — see below), a port list or range, TCP/UDP/both, and a zone (auto / host / docker, or bound to a specific container). Optional per rule: inbound or outbound direction, a time schedule (from–to, weekdays), a connection rate limit (n connections per window), and logging. Edited by clicking — no SSH, no file editing.
- Exposure — every listening TCP port, live, classified against the ruleset ZFW last applied: reachable from the LAN / restricted to a few sources / blocked by ZFW / unverified (no record of what was applied) / loopback-only, with a marker where the saved rules differ from what is live.
- Events — packets ZFW dropped,
host(chainZFW-IN) anddocker(DOCKER-USER) separately, with top sources and top targeted ports for the last hour. A source is taggedport_scanafter 10 distinct destination ports within a minute, andbrute_forceafter 20 hits on one credential port (22, 445, 3389, 8888) — the tags flag, they do not block. Only the first packet of each new connection that reaches a chain's final DROP is logged; there is no fixed per-minute cap (xt_limitis not shipped on the ZimaOS kernel), so a fast scan logs one line per probe. - Connections — the live kernel conntrack table: which flows are open right now, original direction only, read over ctnetlink.
- Audit — a catalogue of security findings, each re-evaluated live against the current firewall configuration (open / LAN-blocked / fixed).
- Versions — the host's key components with their known-CVE status.
ZFW filters at two hook points, because traffic on ZimaOS takes two separate paths:
| Hook | Filters | Mode |
|---|---|---|
INPUT (chain ZFW-IN) |
host-native daemons (SSH, web UI, SMB, NFS, …) | default-drop allowlist |
DOCKER-USER |
published Docker container ports | blocklist |
A plain INPUT firewall is not enough: Docker-published ports never traverse
INPUT — they are DNAT'd and routed through FORWARD. DOCKER-USER is Docker's
official, guaranteed-untouched user hook, so ZFW filters container ports there.
Under the Deny default each port published at compile time gets its own drop, and
since v1.0.27 a final guard drops any other new connection Docker DNATs towards a
container (--ctstate DNAT, leaving on docker0/br-+) — so an app installed after
the last apply is closed until a rule allows it, instead of open until the next apply.
New app ports (v1.0.28). Closed is safe, but to someone who just installed an app
it looks broken. So when an app — from the ZimaOS store or started by hand, published
port or network_mode: host — opens a port no rule covers, ZFW asks: LAN only,
Everyone or Block, in the Exposure tab, in a banner above the tabs and as a
card on the ZimaOS dashboard that opens ZFW. Until you answer, the port is reachable from your LAN
only (or stays blocked, if you choose that mode). Each answer is saved as an ordinary
rule and takes effect at once through dedicated chains (ZFW-APPS in DOCKER-USER,
ZFW-APPS-IN in ZFW-IN, ZFW-APPS-IN6 in ZFW-IN6, and ZFW-APPS6 in the IPv6
DOCKER-USER when Docker's own IPv6 filtering is on) — without an apply, so no
other saved-but-untested edit goes live with it, and adding one ACCEPT for one port
cannot lock you out. A rule you wrote for the port always wins: the app chains sit
after your rules. "Covers" means the rule names the port, or applies to all ports for
your whole LAN (source any or a range containing the LAN, no schedule) — a country or
feed deny or an admin-PC allow over all ports does not stop the question. "New" is deliberately narrow, so an update never opens what you had
left closed: a published port counts only if it was not in the inventory of the last
apply, a host-network app only if its container started after this version first ran.
localhost, the host's own IP and the mesh interfaces (tailscale0, ZeroTier,
WireGuard, Nebula (nebula+) and tun0 for ZimaOS' own Zima Net) are always allowed — so VPN access
and tunnel clients (e.g. Pangolin/Newt) are never affected.
ZFW governs the LAN boundary only.
IPv6 takes a third path, and it is not the one the table above suggests. With
Docker's ip6tables support off — the ZimaOS default — containers hold no IPv6
address and Docker publishes no IPv6 DNAT rule, so an inbound IPv6 connection to a
published port terminates on the host's userland docker-proxy listener. It is
therefore filtered in INPUT (chain ZFW-IN6), not in FORWARD/DOCKER-USER.
Since v1.0.22 rules mirror their ports into ZFW-IN6 accordingly. One limit is
structural: a rule whose source is an IPv4 address or range cannot be matched on
ip6tables at all, so it does not apply to IPv6 — the Rules tab badges those
IPv4 only and warns when a deny-by-default rule set has no IPv6 coverage on a
host with a public IPv6 address. Link-local sources (fe80::/10) are filtered like
any other since v1.0.27; only ICMPv6 (neighbour discovery, MLD), the DHCPv6 client
port and mDNS from the link pass without a rule. See BEST-PRACTICES.md §8.
ZFW is two layers:
- Engine —
/DATA/zfw/zfw, a shell script that applies the rule script the daemon compiles fromrules.json. It runs as root from a systemd unit and supports a dead-man--safemode. - Module (this repository) — a Go daemon (
zfwd) and the web UI. The daemon binds127.0.0.1only; the ZimaOS gateway proxies the route/v2/zfwso the UI is reachable same-origin via port 80. Because the gateway forwards module routes without authenticating them, the daemon verifies a valid ZimaOS session token (an ES256 JWT, checked against the platform JWKS) on every API request — the firewall's own control panel must not be an unauthenticated hole in the firewall.
cmd/zfwd daemon entry point
internal/firewall control plane: wraps the engine + allowlist.conf, reads live state
internal/system listening-port scan, component versions
internal/audit finding catalogue, scored live against the firewall config
internal/gateway ZimaOS gateway route registration
internal/watchdog boot watchdog (ZimaOS sysext units can lose the boot race)
raw/ the sysext file tree (binary, systemd unit, manifest, static UI)
sh build.sh # -> dist/zfw-<version>-<arch>.tar.gz (per arch)Default arches: amd64 (ZimaBoard 1/2, ZimaCube) and arm64 (Lattepanda/Pi-class
hosts). Override with ARCHES="amd64" sh build.sh to build a single arch.
Requires go 1.27.3 — the exact toolchain is pinned by the go directive in go.mod,
and any Go ≥ 1.21 on the build host downloads and switches to it on its own
(GOTOOLCHAIN=auto) — plus squashfs-tools (mksquashfs). The image is packed with
gzip — the ZimaOS kernel is built without zstd/xz squashfs support.
Since v1.0.32 a workflow does this whenever a release is published
(.github/workflows/docker.yml): it downloads the release's tarballs,
tools/stage-docker-payload.sh verifies every checksum and stages the modules,
and it pushes chicohaager/zfw:<version> for amd64 and arm64 — plus :latest
when that tag is the latest release — then pulls each architecture back from
the registry to compare its payload with the release. Re-push any release with
gh workflow run docker.yml -f tag=v<version>. It needs the repository secrets
DOCKERHUB_USERNAME and DOCKERHUB_TOKEN; without them the job fails instead
of leaving a release without its image. The manual way below stays for a re-push or a fork.
The Docker Hub image is built from the artifacts build.sh leaves in dist/ — the
Dockerfile copies dist/zfw-<arch>.raw straight in — so run a full sh build.sh
first, without ARCHES, or the architecture you skipped will not have a payload to copy.
Two prerequisites that are easy to trip over:
# 1. A builder with the docker-container driver. The default "docker" builder
# cannot produce multi-platform images at all.
docker buildx create --name zfw-multi --driver docker-container --bootstrap
# 2. arm64 emulation. The Dockerfile has RUN steps, so the arm64 layer is
# executed under qemu. This registration lives in the kernel's binfmt_misc
# and is LOST ON REBOOT — if buildx suddenly offers no linux/arm64, this is
# why, and it is not a broken builder.
docker run --privileged --rm tonistiigi/binfmt --install arm64
docker buildx inspect zfw-multi | grep Platforms # must list linux/arm64A builder that was already running when you (re-)registered binfmt keeps the
platform list it detected at start, so step 2 alone is not enough — the inspect
above will still show no linux/arm64. Restart it, then re-check:
docker buildx stop zfw-multi
docker buildx inspect --bootstrap zfw-multi | grep PlatformsThen build and push both architectures under one manifest list:
docker buildx build --builder zfw-multi \
--platform linux/amd64,linux/arm64 \
-t chicohaager/zfw:<version> -t chicohaager/zfw:latest \
--push .Afterwards, check what was actually published rather than what was built locally — pull each architecture back out of the registry and compare its payload against the released module:
for a in amd64 arm64; do
docker run --rm --pull=always --platform linux/$a \
--entrypoint sh chicohaager/zfw:<version> -c 'sha256sum /payload/zfw.raw'
cat dist/zfw-$a.raw.sha256
doneThe two checksums must match per architecture. A local docker images listing proves
nothing here: it shows what the build produced, not what the registry now serves.
build.sh writes one release package per arch — dist/zfw-<version>-<arch>.tar.gz
contains the zfw.raw module, the zfw engine script, install.sh and the docs.
Copy the matching arch to the ZimaOS host and run the installer as root (sudo
asks for your ZimaOS password — the login user is not root):
scp dist/zfw-<version>-amd64.tar.gz <user>@<host>:/tmp/ # ZimaBoard / ZimaCube
ssh -t <user>@<host> 'cd /tmp && tar xzf zfw-<version>-amd64.tar.gz && cd zfw-* && sudo sh install.sh'install.sh places the sysext module in /var/lib/extensions/, installs the
engine script to /DATA/zfw/zfw (root:root, 0700), verifies the module
checksum, merges the sysext and (re)starts zfw-ui.service. Re-run it any
time to update an install in place.
Open it from the ZimaOS dashboard (tile ZFW Firewall), or directly at
http://<host>/modules/zfw/index.html.
If you would rather not copy a tarball around, the same release is published as
an installer image (chicohaager/zfw,
multi-arch: amd64 + arm64). Run it on the ZimaOS host:
sudo docker run --rm --privileged --pid=host -v /:/host chicohaager/zfw:1.0.31sudo is not optional: on a stock ZimaOS the login user is not in the docker
group, so a plain docker run ends in permission denied on the Docker socket.
The container is a delivery vehicle, not a runtime. It stages the payload and
runs the very same install.sh inside the host's namespaces, then exits — ZFW
ends up on the host as a systemd-sysext module, exactly as with the tarball.
That distinction is not cosmetic. ZFW's engine arms the Safe-Apply dead-man with
systemd-run and manages boot-persistence with systemctl. A ZFW running
inside a container would have no host systemd to do either with, so the one
promise the whole design rests on — a bad rule can never lock you out —
would silently not exist. So we do not ship a runtime container, and you should
be suspicious of any firewall that does.
The flags are what they look like: --privileged and --pid=host let the
installer enter the host's namespaces, -v /:/host is the filesystem it installs
into.
Updating is the same command. It installs the newest release over the existing
one; your rules in /DATA/zfw/ are not touched:
sudo docker run --rm --pull always --privileged --pid=host -v /:/host chicohaager/zfw:latest--pull always matters with latest: without it Docker runs whatever latest it
pulled last time and reinstalls the old version. With an exact version tag
(chicohaager/zfw:<version>) it is not needed.
Uninstalling is sudo /DATA/zfw/zfw revert on the host, then removing
/var/lib/extensions/zfw.raw (as root) and running sudo systemd-sysext refresh.
ZFW verifies the ZimaOS session token itself — the gateway proxies module routes
without authenticating them — so a change to that token lands on ZFW. It has
happened: v1.7.1-beta1 renamed the access token's iss claim from casaos to
zimaos, and every tab answered 401 until v1.0.23 accepted both. The check is
one command, and it tests the half that must pass and the half that must fail:
python3 tools/check-session-auth.py <host> --user <name> --password-file <file>A genuine session token must be accepted; the refresh token, a missing header and a tampered signature must each be refused. Exit code 1 if any of that is untrue. The password is read from the file and never printed, and neither is any token.
Since v1.0.23 a refused request also says so in the journal, which is where you would look first:
sudo journalctl -u zfw-ui | grep 'session rejected'The line names the reason, the path and the client. Rejections are triggerable
from outside, so they are rate-limited to one line per 30 s — whatever is
suppressed in between is counted and reported on the next line
(suppressed_since_last=). The token is never logged.
The rule set lives in /DATA/zfw/rules.json — the single source of truth
the daemon compiles into /DATA/zfw/compiled.sh. It is edited on the Rules tab
(or by POST /api/rules, see docs/openapi.yaml); the file carries the LAN
CIDR, the host IP, the default policy (deny = allowlist, allow = blocklist)
and the ordered rules. The Exposure and Audit tabs judge reachability from this
file too.
/DATA/zfw/allowlist.conf is the legacy v0.1 format. It is read exactly
once: a host that still has it and no rules.json gets it migrated into
rules.json on the daemon's next start (a backup of the original stays). Nothing
writes it any more.
A rule can take a blocklist feed as its source: a third-party list of
networks known for abuse, kept in /DATA/zfw/feeds/ and loaded as an ipset
the rule matches. The catalogue is fixed in the daemon — currently
FireHOL Level 1 and
Spamhaus DROP — and there is deliberately no
URL field: a feed decides what your firewall drops, and a URL you can edit
would hand that decision to whoever controls the far end.
Three things to know before you use one:
- A feed adds nothing on a default-deny host chain. Everything not explicitly allowed is dropped anyway. Feeds earn their keep on ports you open to the world, on published container ports, and outbound — a deny rule with direction outbound and a feed source stops a compromised container from reaching known bad infrastructure.
- Special-use ranges are always removed before a feed is loaded — RFC 1918,
loopback, link-local, CGNAT (
100.64.0.0/10, which Tailscale uses), multicast and0.0.0.0/8. Measured on 2026-09-05, FireHOL Level 1 ships all of them; loaded verbatim it would drop every LAN and Tailscale packet. On top of that, the LAN and host address fromrules.jsonand every sync peer are removed too — a feed that lists your own public range cannot lock you out.GET /api/feedsreports how many entries were removed on each account. - Behind a tunnel a feed sees only the tunnel's address, exactly like geo-blocking. It works with direct port-forwarding.
A fetched list must parse to a plausible number of entries before it may replace the cached one (a captive-portal page or a truncated download is refused, the cache stays); if the network is down, the cached list is used.
Feeds are refreshed in the background every 12 hours (ZFW_FEEDS_REFRESH,
0 disables) and on demand via POST /api/feeds/refresh. A refresh never
touches the rules: the new content is loaded into a temporary set and
swapped in, so compiled.sh and the live iptables chains stay byte for byte
what they were — the same line dockerwatch follows, which recompiles but
never applies. The swap matters: loading into the live set directly leaves it
half-filled for a moment (measured: 111 000 of 200 000 entries mid-load).
After any change, run Safe-Apply from the Firewall tab (or sudo /DATA/zfw/zfw apply on the host).
Applying firewall rules over the network is risky — one wrong rule can lock you out.
ZFW's Safe-Apply applies the rules and arms a 120-second timer; unless you click
Confirm (or run sudo /DATA/zfw/zfw commit) within that window, the rules are reverted
automatically. The current SSH session is never dropped — established connections are
accepted first.
For a full operating guide — staying reachable, rule ordering, geo-blocking limits and recovery — see BEST-PRACTICES.md.
ISC — see LICENSE. You may use, modify and redistribute ZFW, including
in derived or commercial work, as long as the copyright and permission notice stay
in place. Contributions are welcome as pull requests against master; for anything
touching the rule model or the apply path, please open an issue first.
If this project saves you time, you can buy me a coffee — it keeps the side projects going.
… or scan the code:



