[Module] ZFW v1.0.10 — host firewall as a ZimaOS dashboard tile

Hi all,

Some of you know the situation: ZimaOS doesn’t ship a host firewall. iptables is empty, Samba and NFS are on the LAN, every app you install opens its port directly on 0.0.0.0. For a private appliance behind a NAT router that’s been acceptable; for anyone whose LAN has guests, IoT gadgets or a virtualization host it’s a real exposure.

ZFW is a small module I’ve been writing to fix that without changing how ZimaOS feels to use.

  • Installs as a sysext module + a tile in the ZimaOS dashboard.
  • Allowlist editor for native ports, blocklist for Docker-published ports (filters at DOCKER-USER, not INPUT — Docker traffic doesn’t go through INPUT, this is the most common mistake in homelab firewall how-tos).
  • Live status: every listening TCP port classified as LAN-reachable / blocked / loopback. So you can actually see what’s exposed.
  • Safe-Apply with a 120-second dead-man revert. Apply something wrong, walk away, the rules come back automatically. The current SSH session is kept alive.
  • localhost, the host’s own IP, and tailscale0/ZeroTier are always allowed. Tailscale and Pangolin/Newt access keeps working.

“One thing that stood out during testing was how responsive you were to feedback. We identified issues, discussed them, and in many cases there was a fix available almost immediately. That’s not something you see very often and it gives a lot of confidence in the direction of the project.”
— gelbuilding, after testing ZFW on a ZimaBoard

Install (amd64 for ZimaBoard/ZimaCube, arm64 for Lattepanda/Pi-class):

scp dist/zfw-1.0.10-amd64.tar.gz root@<host>:/tmp/
ssh root@<host> 'cd /tmp && tar xzf zfw-1.0.10-amd64.tar.gz && cd zfw-* && sh install.sh'

Releases, threat model, security report, and bug-bounty policy:

**

About me**

I’m Lintux (Holger). Inside the ZimaOS ecosystem I also maintain zima-linux-client (desktop client with integrated ZeroTier and SMB), Cron (the task-scheduler module), the Tailscale sysext, and a handful of other modules. ZFW grew out of the same itch: things the OS doesn’t ship that the community keeps asking for.

40+ years in IT, a lot of it spent shipping production systems where “don’t break the user” was the actual job description. That’s the lens ZFW is built through — Safe-Apply exists for a reason.

Looking specifically for:

  • Reports from arm64 hosts (I have less mileage there).
  • Anything that breaks a Docker app you use — please open an issue with the app name and its published ports.
  • Which would you rather see next: IPv6, rule-set backup/restore, per-container rule binding, or multi-host management?

Cheers,
Lintux

8 Likes

That’s awesome! Thanks for your hard work. I will install it and check it out.

1 Like

This is amazing Lintux thank you.

1 Like

If something goes wrong, how do I rollback the installation (uninstall)? I don’t have much technical knowledge. Is it possible to use a script to remove what was done?

1 Like

Yes — and for most cases you don’t even need a script.

If something goes wrong, you have three levels of “undo”, from easiest to most thorough:

1. During setup, you almost can’t break anything. When you apply rules with Safe-Apply, ZFW starts a 120-second countdown. If you don’t click Confirm in that time — for example because a rule locked you out — the firewall automatically rolls back to how it was. So a bad rule fixes itself.

2. To turn the firewall off (the usual “something’s wrong” fix). Open the ZFW tile in your ZimaOS dashboard and click the “Remove firewall” button. That instantly removes all the firewall rules and puts your system back to its normal state — no commands, no typing. ZFW is now doing nothing. (The app tile stays installed, but it isn’t affecting your network anymore.)

3. To remove ZFW completely from the system. This deletes the app itself, not just the rules. It needs one command in a terminal. Copy the uninstall.sh file onto your ZimaOS box and run it as root:

ssh root@<your-zimaos-ip> 'cd /tmp && sh uninstall.sh'

It cleans up everything and turns the firewall off in the process. If you’d rather not use a terminal at all, just use option 2 above — that’s enough to make ZFW stop affecting your system.

In short: if you’re worried, click “Remove firewall”. That’s the safe, no-technical-knowledge way to undo it. The script (option 3) is only needed if you want ZFW gone entirely

download_uninstall

1 Like

Thank you very much and congratulations on your work and dedication.

1 Like

This is a very good addition for ZimaOS, and I can say that from actually testing it on a ZimaBoard over the last few releases.

ZFW is not just a basic firewall page. It is a host firewall module for ZimaOS, packaged properly as a dashboard tile, which makes it feel like it belongs inside the system rather than being a random CLI workaround.

The big point here is that stock ZimaOS does not really give users a clear host firewall layer, and many Docker apps published to 0.0.0.0 can be reachable across the LAN unless something else is controlling that access.

What stands out to me is that it understands Docker properly. It is not just filtering normal INPUT traffic. The use of DOCKER-USER is important because Docker-published ports do not behave the same as normal host services. If a firewall only looks at INPUT, users can get a false sense of security while Docker services are still exposed.

From my testing, installation was straightforward, the dashboard was easy to understand, and rule management made sense. I was able to create custom rules, verify they worked, and after the latest updates I also confirmed the firewall rules survived reboot correctly.

The Safe Apply feature is probably the most important part for normal users. A 120-second rollback timer makes a lot of sense on a headless NAS-style system, because one wrong firewall rule can lock a user out. Having the rule automatically revert unless confirmed gives users a safety net.

I also like that it fits the ZimaOS design. From what I can see, it installs as a sysext module, appears as a dashboard tile, uses its own daemon and web UI, and checks the ZimaOS session token so the firewall UI itself is not left open.

The live diagnostics are another strong point. Showing listening TCP ports and classifying what is LAN-reachable, blocked, or loopback-only gives users the visibility they need before making changes.

During testing I also found a few issues, including a reboot persistence problem. Each time I reported something, Holger investigated it, reproduced it, and had a fix available quickly. Watching it improve version by version has been impressive.

For me, this is the right direction for ZimaOS modules:

collect the facts firstshow what is actually exposedseparate host services from Docker-published servicesapply changes safelyprovide a rollback path

This would be very useful for users running apps, reverse proxies, tunnels, or anything that may be exposed outside the local network. The technical implementation is solid, but just as important, it feels designed for normal ZimaOS users rather than only advanced Linux users.

2 Likes

@Lintux Thanks for this firewall –really great – installs easily and the two-minute test function allows you to make adjustments safely.

The Cron app is really well made and pleasant to use.

Will casadrop be published on the GitHub? File and folder sharing is really a lack on ZimaOS…

Thank you for this great work

Merci pour ce pare feu – vraiment génial – s’installe facilement et la fonction de test pendant deux minutes permet d’effectuer les réglages en toute sécurité.

L’application Cron est vraiment bien faite et agréable à utiliser.

Est ce que casadrop sera édité sur le GitHub ? Le partage de fichiers et dossiers est vraiment un manque sur ZimaOS …

En merci pour ce super boulot

1 Like

The firewall looks good. But I do have a question. I see that the installation is placed on the ZimaBoard itself. I have all my apps on an SSD connected to the SATA port. Is it a deliberate choice to install it to /DATA/zfw and not to /DATA/AppData? I imagine a firewall needs to be up as early as possible in the boot process, without having to wait for a SATA drive to be mounted.
I found out that my port 7681 which is the default terminal port has been blocked. So that’s quite inconvenient. I cannot work in my terminal anymore. So I stopped the firewall until this has been solved.

You need to allow the listening ports of your applications in the Rules section

Il te faut autoriser les ports d’écoute de tes applications dans la section Rules

1 Like

Yes — that’s deliberate, and your reasoning is spot on. Two reasons:

  1. Boot timing. ZFW is a host-level firewall (netfilter/iptables rules + systemd units + the zfwd daemon), not a Docker app. zfw.service runs zfw apply early in boot to load the ruleset. So it has to live on a device that’s guaranteed mounted at that point. /DATA is the internal ZimaOS system disk (always available early); /DATA/AppData on your box is redirected to the SATA SSD, which mounts later. Installing there would leave a window at every boot where the firewall isn’t loaded yet — exactly what you don’t want from a firewall.
  2. It’s not an “app”. /DATA/AppData/ is the convention for containerized AppStore apps (Docker volumes, per-app data, app lifecycle). ZFW manipulates the host directly, so keeping it in /DATA/zfw keeps it out of the app lifecycle (it won’t get swept up when you install/remove apps) and next to the other host-level state.

Re: port 7681 (terminal) getting blocked

Good catch — that’s a gap in the default whitelist, not intended. ZFW ships with a LAN whitelist of TCP 22, 80, 443, 445, 139, 8200, 9527, 1910 (+ some UDP for mDNS/SMB discovery). The ttyd terminal on 7681 isn’t in that list, so it gets dropped. SSH (22) and the WebUI (80/443) stay open — that’s why only the terminal broke.

Since you’ve stopped ZFW you’ve got your terminal back, so just add the rule and re-enable:

Easiest — in the ZFW UI: add a rule allow TCP 7681 from 192.168.1.0/24 (your LAN), then Apply + Commit.

Or from a shell:

add the rule in the UI or in /DATA/zfw/rules.json, then:

sudo systemctl restart zfw-ui # zfwd recompiles the ruleset
sudo /DATA/zfw/zfw apply # load into live iptables
sudo /DATA/zfw/zfw commit # make it boot-persistent
The apply step is the one people miss — without it the rule sits in the config but never reaches iptables. commit makes it survive reboot and cancels the deadman rollback timer.

I’ll add 7681 to the default whitelist (or at least flag it prominently in the README) so nobody else locks themselves out of their terminal. Thanks for reporting it!

1 Like

I am still exited about this. Espaecially, it looks so clean(and it is!) I made a small mistake and I don’t understand what must come first! I accidentally pressed the little arrow for the order of usage on the first line. And then afterwards I looked into the JSON and I think the SSH must be on top and the web UI at second place. I’ve put my terminal on third place. I can’t reason what should be first or not. Is it not of those three. Again thanks for the effort you put in this to help us all.

1 Like


Groeten naar Nederland :soccer_ball:
Holger

1 Like

Heads-up for anyone on ZimaOS 1.6.2

That update flips the default iptables backend from legacy to nf_tables, and Docker now installs its FORWARD/DOCKER-USER chains under nft. Older ZFW builds resolved iptables-legacy unconditionally, so after updating they write every rule into the now-unused legacy table — the tile stays green and reports “firewall applied”, but nothing is actually filtered. Quick check: iptables-nft -S DOCKER-USER — if it’s empty, you’re affected.

The latest build fixes this by detecting the backend Docker actually uses (nft on ≥1.6.2, legacy on ≤1.6.1). Two related hardenings ship with it: ZFW now re-applies automatically after a dockerd restart (PartOf=docker.service, since Docker flushes DOCKER-USER), and the DOCKER-USER default-deny is scoped per published port instead of by source subnet — so a non-LAN origin (a second VLAN, a port-forwarded service) no longer slips past to your containers. If you’re on 1.6.2, grab the update and re-run the installer Release v1.0.16 — ZimaOS 1.6.2 (nf_tables) compatibility + DOCKER-USER port-scoped default-deny · chicohaager/zfw · GitHub

Hi @chicohaager,

I think I may have found two issues in v1.0.16 on ZimaOS 1.6.2.

1. IPv6 protection

The dashboard reports IPv6 protection ✗, but ZFW-IN6 is installed and attached to the INPUT chain:

ip6tables -S

shows:

-A INPUT -j ZFW-IN6
...
-A ZFW-IN6 ...

So the IPv6 host firewall appears to be active.


2. DOCKER-USER

IPv4:

iptables-nft -S DOCKER-USER

ends with:

-A DOCKER-USER -j RETURN

There is no default-deny rule.

IPv6:

ip6tables -S DOCKER-USER

returns only:

-N DOCKER-USER

The chain is completely empty.

According to the v1.0.16 release notes, I expected the new port-scoped default-deny rules to be installed.

Is this expected, or could this be a regression?

Hi @isanto1306 — both are real, neither is expected. Thanks for the precise output, it made them easy to pin down.

  1. IPv6 protection ✗ — a reporting bug. ZFW picked its ip6tables binary by looking at the name of the IPv4 one (strings.Contains(iptBin, “nft”)). When the probe for Docker’s FORWARD chain misses — typically when zfwd starts before dockerd — it falls back to the plain iptables symlink. On ZimaOS 1.6.2 that symlink drives nf_tables despite having no nft in its name, so IPv6 got pinned to ip6tables-legacy, read an empty table, and reported ✗ while ZFW-IN6 was live in the nft table. Exactly what you saw. ZFW now asks the binary (iptables -V prints (nf_tables) or (legacy)) instead of guessing from its name.

One thing worth checking on your host: if the apply itself ran through that fallback path, ZFW-IN6 would have been written into the unused legacy table. ip6tables-nft -S ZFW-IN6 tells you which table actually holds it.

  1. IPv4 DOCKER-USER with no default-deny — a genuine fail-open, and the more serious of the two. The deny is emitted once per published port, and the port inventory came solely from docker-proxy listening sockets. If your Docker daemon runs with “userland-proxy”: false, there is no such process, the inventory comes up empty, and zero deny rules are emitted — the chain falls straight through to -j RETURN while the dashboard tile stays green. The inventory is now the union of docker-proxy sockets and docker ps, and an empty inventory under default_policy=deny with containers running is logged as an error instead of passing silently. Published UDP ports were being skipped entirely too; they now get both an allow and a deny rule.

If you can, check cat /etc/docker/daemon.json for userland-proxy — I’d like to confirm that was your trigger rather than something else.

  1. IPv6 DOCKER-USER empty — you’re right that the release notes implied otherwise; they should have said “IPv4”. ZFW never wrote to that chain. On a stock ZimaOS it’s dead code: Docker publishes no IPv6 DNAT and containers get no global IPv6 address, so a v6 connection to a published port terminates on the host’s docker-proxy listener and is filtered by ZFW-IN6 in INPUT — it never reaches FORWARD. But enable Docker IPv6 (“ipv6”: true + fixed-cidr-v6) and that empty chain becomes the way in. It is now populated, with its own ZFW-DOCK6-DROP log prefix.

Fixed across v1.0.17 and v1.0.18 — please go straight to v1.0.19. v1.0.17 introduced a bug of its own: revert did not empty the newly-filled IPv6 DOCKER-USER, so a reverted firewall kept dropping IPv6 traffic to published ports while reporting itself off. v1.0.18 fixes that.

Important: re-run the installer, don’t just refresh the sysext. The engine script lives at /DATA/zfw/zfw, outside the sysext image, so systemd-sysext refresh alone will not update it.

After upgrading, these should hold on 1.6.2:

iptables-nft -S DOCKER-USER | grep -c ctorigdstport # > 0 with published ports + policy deny
ip6tables-nft -S ZFW-IN6 | grep -c ‘^-A’ # > 0, and empty under ip6tables-legacy

All of this was reproduced and verified on a live ZimaOS 1.6.2 host rather than inferred from the code.

Hi @Lintux,

I upgraded to v1.0.19 using the installer.

The IPv6 reporting issue is fixed. Thanks!

However, the DOCKER-USER issue is still present.

Results:

iptables-nft -S DOCKER-USER | grep -c ctorigdstport

returns:

0

The chain still ends with:

-A DOCKER-USER -j RETURN

The interesting part is that my system does use docker-proxy. ps aux | grep docker-proxy shows many active docker-proxy processes (TCP and UDP), and the Exposure page correctly detects 37 exposed ports / 24 blocked.

So this doesn’t seem to be the userland-proxy=false case.

Is there another condition that prevents the per-port deny rules from being generated?

Hi Isanto1306,
I couldn’t reproduce it. On a ZimaOS host running the same v1.0.19, with 31 containers and 36 docker-proxy sockets, the chain comes out correct: iptables-nft -S DOCKER-USER | grep -c ctorigdstport → 57, default_policy: deny, and the compiled script carries 93 of those rules. So the path from the port inventory to the live chain works end-to-end on a healthy host — which means something on your box is different, not the rule generation itself.

The most likely explanation: you may be looking at the wrong iptables backend. ZFW deliberately follows whichever backend Docker itself uses. On my test host, Docker’s FORWARD jumps (-j DOCKER-USER, -j DOCKER-FORWARD) live in nft, so ZFW writes there. On an older ZimaOS, Docker uses legacy — and then ZFW writes the legacy chain, while iptables-nft -S DOCKER-USER shows you the untouched stock chain, which is exactly a bare -j RETURN with zero ctorigdstport. That also fits your Exposure page reporting the firewall as active with 24 blocked ports: it reads the chain from the backend ZFW actually uses.

Two commands settle it:

sudo iptables-legacy -S DOCKER-USER | grep -c ctorigdstport
sudo /DATA/zfw/zfw status

If the first one is > 0, everything is working — the rules are just in the legacy table. zfw status always prints the chain from the backend ZFW selected, so it’s the reliable place to look.

If both come back empty, please also send:

sudo grep -c ctorigdstport /DATA/zfw/compiled.sh
sudo grep default_policy /DATA/zfw/rules.json

That splits the problem cleanly: if compiled.sh is 0, the rules were never generated (inventory or policy); if it’s > 0, they were generated but never reached the live table (failed apply, dead-man revert, or backend mismatch). Either way I’ll know exactly where to dig.

Hi @Lintux,

Just an update.

Everything is working correctly now.

After reinstalling v1.0.19, reapplying the firewall, and verifying the live rules, the issues are resolved.

Results:

  • :white_check_mark: IPv6 protection is now detected correctly.

  • :white_check_mark: DOCKER-USER is populated with the expected ctorigdstport rules.

  • :white_check_mark: Port-scoped default-deny is active.

  • :white_check_mark: UDP rules are also present.

For example:

iptables-nft -S DOCKER-USER | grep -c ctorigdstport

now returns:

78

and iptables-nft -L DOCKER-USER -v -n shows the expected LOG/DROP rules for all published ports.

Thanks for the quick investigation and the fixes. Great work!

1 Like

Hi,

I found another issue.

The Open button (the small icon in the bottom-right corner of the Network panel) opens a local web page on a random TCP port.

Every time I click it, a different localhost/host port is used.

With ZFW enabled, the page does not load.

With ZFW disabled, it works immediately.

Since the port changes every time, it is impossible to create a static firewall rule.

Is this expected? Should ZFW automatically allow these dynamically assigned ZimaOS local service ports?