NeatOuk/citadel-helper

Python

1

12 commits

updated Sep 30, 2026

See the code

See what people are saying

SourceMessageScoreDate

I made a network monitoring and block/allow network connection. (r/linux)

Moving out from MacOS, I'm missing a few apps like LittleSnitch. So I created a plugin called Citadel with a similar purpose but better in many ways. I also include proxy per app and AdGuard or Pihole compatible import block list.…

1

Sep 30, 2026

README

citadel-helper

The root side of Citadel, the outbound firewall for Linux. It serves both editions: the bar plugin and the standalone app. It is a small, audited program that turns Citadel's policies into nftables rules. It installs a polkit rule so it can do that without asking for a password, and restores your policies at boot.

Citadel works without it, but then it only watches. You see every connection and can answer at the gate, but nothing is blocked. Install this package to make Citadel enforce what you decide.

Why a separate package?

Blocking traffic means changing the kernel firewall, and that needs root. Citadel itself never has root:

  • The bar plugin runs as you, inside your desktop shell, and installs into your home directory.
  • The app runs as you, as a user service.

So the one privileged part lives here, as a normal system package (pacman, .deb or .rpm) that you install on purpose and can remove the same way.

Keeping it separate also keeps it small enough to read. Everything that runs as root is one Python file, citadel-enforcer, that uses only the standard library.

Install

Build and install it from this repository (an AUR package is coming later):

git clone https://github.com/NeatOuk/citadel-helper.git
cd citadel-helper
makepkg -si

Then open Citadel (the bar plugin or the app) → Settings → Enforcement and switch it on. Citadel finds the helper within a few seconds.

Requirements:

  • Arch Linux, Debian/Ubuntu, or Fedora. Packages: the PKGBUILD here; .deb and .rpm from packaging/ (see Development)
  • python, nftables, iproute2 and polkit
  • a kernel with nft_socket, cgroup v2 and INET_DIAG_DESTROY; the stock Arch kernel has all three
  • python-maxminddb (optional), for Citadel's country lookup

What gets installed

PathPurpose
/usr/lib/citadel/citadel-enforcerThe helper. Root-owned; the only thing that ever runs as root.
/usr/share/polkit-1/actions/io.github.neatouk.citadel.policyA polkit action that covers exactly that one program.
/usr/share/polkit-1/rules.d/49-citadel.rulesLets the active, local member of the distro's admin group (wheel on Arch and Fedora, sudo on Debian and Ubuntu) run it without a password. Anyone else is asked for an admin password. Since 1.3.2 it also lets that user read which names apps look up (systemd-resolved's query monitor), so Citadel can match api.example.com instead of a bare address.
/usr/lib/systemd/system/citadel-restore.serviceRe-applies your last policies at boot, before the network comes up. Enabled on install.
/usr/bin/citadel-offEmergency off. Removes every Citadel rule immediately, from any terminal.

How it stays safe

A passwordless root helper is only acceptable if it can't be misused, so citadel-enforcer treats everything it receives as hostile:

  • One table only. It creates, replaces and deletes table inet citadel and never touches anything else in your firewall.
  • Only your own apps.
    • Rules only ever match traffic from the calling user's processes (meta skuid). System services such as WARP, NetworkManager and DNS always pass, so the tunnel and name resolution can't break.
    • App rules must name a cgroup inside the caller's own user slice. The caller's uid comes from pkexec, never from the input.
  • Strict validation.
    • Every address goes through Python's ipaddress, and every port must be 1–65535.
    • Cgroup paths are checked against an allow-list pattern, with no ...
    • Anything else, including injection attempts, is rejected before nftables is called.
    • Symlinked input files are refused, and inputs have a size limit.
  • No match-all mistakes. A rule entry with nothing to match is dropped, never turned into "accept or drop everything".
  • Optional connection log (1.2+). When Citadel asks for it ("logNew": true, and only a real true), one fixed rule logs new connections from your apps to the kernel log, rate-limited to 20 per second. Citadel reads those lines to catch connections that end before its next check. The rule text is fixed, so the spec can only switch it on or off.
  • Proxy redirects stay local (1.3+). The optional proxy section can only redirect your own TCP connections (meta skuid) to a port on this machine (1024–65535), never to another host. Its cgroups must also be in your user slice, and loopback plus the listed proxy addresses are always exempt, so the proxy's own traffic can't loop.
  • Atomic updates. Each apply replaces the whole table in a single nftables transaction, so there is never a half-applied state.
  • Never breaks what's already running.
    • Established connections are always accepted.
    • The helper only closes live connections when Citadel explicitly asks, after you block something. Every such request must name an app group (cgroup) inside your own user slice. The helper lists that group's sockets and closes only the ones owned by your uid, one exact connection at a time. Sockets of other users, and root-owned ones such as a sudo command started from your terminal, are never touched.

The polkit rule is limited to local, active sessions of wheel members. That's the group that can already become root with sudo on Arch, so the rule gives no new power, it only skips the password prompt for this one program. The same goes for the name lookups (1.3.2+): an admin could read them anyway, and the monitor is read-only.

Commands

Citadel calls these through pkexec. You normally never need them yourself.

citadel-enforcer apply <spec.json>   build the table from Citadel's spec and save it for boot
citadel-enforcer kill <kill.json>    close your own live connections ([{cgroup, ip?}]; cgroup required)
citadel-enforcer off                 delete the table and the saved spec
citadel-enforcer restore             re-apply the saved spec (used by the boot service)
citadel-enforcer status              JSON: {active, rules, drops, logging, proxy, version}

The spec is an ordered list, and the first match wins. Citadel sorts it most specific first:

{
  "silentDeny": false,
  "logNew": true,
  "blocklist": ["5.188.10.0/23"],
  "rules": [
    {"verdict": "drop",   "cgroup": "user.slice/user-1000.slice/…/app-chromium.scope",
                          "targets": [{"ip": "142.250.0.0/15", "port": 443}]},
    {"verdict": "accept", "targets": [{"ip": "93.184.216.34"}]},
    {"blocklist": true},
    {"verdict": "drop",   "cgroup": "user.slice/user-1000.slice/…/app-example.scope"}
  ]
}
  • cgroup only matches one app, via socket cgroupv2.
  • targets only matches destinations.
  • Both together match one app going to specific destinations.
  • silentDeny (Citadel's Lockdown mode) adds a final drop for anything not allowed earlier.
  • logNew adds the connection log rule described above.

Proxy routing (1.3+). An optional proxy section sends chosen apps through Citadel's local proxy process (citadel-proxy, shipped with the plugin and the app):

"proxy": {
  "rules": [
    {"verdict": "direct",   "cgroup": "user.slice/…/app-chromium.scope"},
    {"verdict": "redirect", "cgroup": "user.slice/…/app-mail.scope", "port": 47001},
    {"verdict": "redirect", "targets": [{"ip": "203.0.113.7", "port": 443}], "port": 47002}
  ],
  "defaultPort": 47001,
  "exclude": ["172.16.1.3/32"]
}
  • It builds a NAT chain (hook output priority dstnat) that, in order:
    • skips other users, non-TCP traffic, loopback and the exclude list
    • runs the rules, first match wins: direct returns, redirect goes to 127.0.0.1:<port>
    • redirects the rest to defaultPort, if one is set
  • Routed apps' UDP is dropped except DNS, so they can't bypass the proxy over QUIC.
  • The filter chain runs at priority mangle, before NAT, so blocks always see the real destination.
  • If nothing listens on the port, the connection is refused, so it never goes direct.

The last applied spec is kept in /var/lib/citadel/spec.json, which only root can read.

Uninstall

sudo pacman -R citadel-helper

Removing the package switches enforcement off first. Citadel then goes back to watching only.

Development

python3 tests/test_enforcer.py   # runs the real enforcer in a private user+network namespace
updpkgsums                       # after changing any file listed in PKGBUILD
makepkg -si                      # build and install from the checkout
packaging/build-in-docker.sh deb # .deb in an ubuntu:24.04 container (installs it there too)
packaging/build-in-docker.sh rpm # .rpm in a fedora container

make install DESTDIR=… ADMIN_GROUP=wheel|sudo installs the same files for other packaging; ADMIN_GROUP is the group whose members may run the helper without a password.

The tests need no root and never touch your firewall. They check the generated rules, and that hostile inputs are rejected.

License

MIT, see LICENSE.

NeatOuk/citadel-helper

Python

1

12 commits

updated Sep 30, 2026

See the code

See what people are saying

SourceMessageScoreDate

I made a network monitoring and block/allow network connection. (r/linux)

Moving out from MacOS, I'm missing a few apps like LittleSnitch. So I created a plugin called Citadel with a similar purpose but better in many ways. I also include proxy per app and AdGuard or Pihole compatible import block list.…

1

Sep 30, 2026

README

citadel-helper

The root side of Citadel, the outbound firewall for Linux. It serves both editions: the bar plugin and the standalone app. It is a small, audited program that turns Citadel's policies into nftables rules. It installs a polkit rule so it can do that without asking for a password, and restores your policies at boot.

Citadel works without it, but then it only watches. You see every connection and can answer at the gate, but nothing is blocked. Install this package to make Citadel enforce what you decide.

Why a separate package?

Blocking traffic means changing the kernel firewall, and that needs root. Citadel itself never has root:

  • The bar plugin runs as you, inside your desktop shell, and installs into your home directory.
  • The app runs as you, as a user service.

So the one privileged part lives here, as a normal system package (pacman, .deb or .rpm) that you install on purpose and can remove the same way.

Keeping it separate also keeps it small enough to read. Everything that runs as root is one Python file, citadel-enforcer, that uses only the standard library.

Install

Build and install it from this repository (an AUR package is coming later):

git clone https://github.com/NeatOuk/citadel-helper.git
cd citadel-helper
makepkg -si

Then open Citadel (the bar plugin or the app) → Settings → Enforcement and switch it on. Citadel finds the helper within a few seconds.

Requirements:

  • Arch Linux, Debian/Ubuntu, or Fedora. Packages: the PKGBUILD here; .deb and .rpm from packaging/ (see Development)
  • python, nftables, iproute2 and polkit
  • a kernel with nft_socket, cgroup v2 and INET_DIAG_DESTROY; the stock Arch kernel has all three
  • python-maxminddb (optional), for Citadel's country lookup

What gets installed

PathPurpose
/usr/lib/citadel/citadel-enforcerThe helper. Root-owned; the only thing that ever runs as root.
/usr/share/polkit-1/actions/io.github.neatouk.citadel.policyA polkit action that covers exactly that one program.
/usr/share/polkit-1/rules.d/49-citadel.rulesLets the active, local member of the distro's admin group (wheel on Arch and Fedora, sudo on Debian and Ubuntu) run it without a password. Anyone else is asked for an admin password. Since 1.3.2 it also lets that user read which names apps look up (systemd-resolved's query monitor), so Citadel can match api.example.com instead of a bare address.
/usr/lib/systemd/system/citadel-restore.serviceRe-applies your last policies at boot, before the network comes up. Enabled on install.
/usr/bin/citadel-offEmergency off. Removes every Citadel rule immediately, from any terminal.

How it stays safe

A passwordless root helper is only acceptable if it can't be misused, so citadel-enforcer treats everything it receives as hostile:

  • One table only. It creates, replaces and deletes table inet citadel and never touches anything else in your firewall.
  • Only your own apps.
    • Rules only ever match traffic from the calling user's processes (meta skuid). System services such as WARP, NetworkManager and DNS always pass, so the tunnel and name resolution can't break.
    • App rules must name a cgroup inside the caller's own user slice. The caller's uid comes from pkexec, never from the input.
  • Strict validation.
    • Every address goes through Python's ipaddress, and every port must be 1–65535.
    • Cgroup paths are checked against an allow-list pattern, with no ...
    • Anything else, including injection attempts, is rejected before nftables is called.
    • Symlinked input files are refused, and inputs have a size limit.
  • No match-all mistakes. A rule entry with nothing to match is dropped, never turned into "accept or drop everything".
  • Optional connection log (1.2+). When Citadel asks for it ("logNew": true, and only a real true), one fixed rule logs new connections from your apps to the kernel log, rate-limited to 20 per second. Citadel reads those lines to catch connections that end before its next check. The rule text is fixed, so the spec can only switch it on or off.
  • Proxy redirects stay local (1.3+). The optional proxy section can only redirect your own TCP connections (meta skuid) to a port on this machine (1024–65535), never to another host. Its cgroups must also be in your user slice, and loopback plus the listed proxy addresses are always exempt, so the proxy's own traffic can't loop.
  • Atomic updates. Each apply replaces the whole table in a single nftables transaction, so there is never a half-applied state.
  • Never breaks what's already running.
    • Established connections are always accepted.
    • The helper only closes live connections when Citadel explicitly asks, after you block something. Every such request must name an app group (cgroup) inside your own user slice. The helper lists that group's sockets and closes only the ones owned by your uid, one exact connection at a time. Sockets of other users, and root-owned ones such as a sudo command started from your terminal, are never touched.

The polkit rule is limited to local, active sessions of wheel members. That's the group that can already become root with sudo on Arch, so the rule gives no new power, it only skips the password prompt for this one program. The same goes for the name lookups (1.3.2+): an admin could read them anyway, and the monitor is read-only.

Commands

Citadel calls these through pkexec. You normally never need them yourself.

citadel-enforcer apply <spec.json>   build the table from Citadel's spec and save it for boot
citadel-enforcer kill <kill.json>    close your own live connections ([{cgroup, ip?}]; cgroup required)
citadel-enforcer off                 delete the table and the saved spec
citadel-enforcer restore             re-apply the saved spec (used by the boot service)
citadel-enforcer status              JSON: {active, rules, drops, logging, proxy, version}

The spec is an ordered list, and the first match wins. Citadel sorts it most specific first:

{
  "silentDeny": false,
  "logNew": true,
  "blocklist": ["5.188.10.0/23"],
  "rules": [
    {"verdict": "drop",   "cgroup": "user.slice/user-1000.slice/…/app-chromium.scope",
                          "targets": [{"ip": "142.250.0.0/15", "port": 443}]},
    {"verdict": "accept", "targets": [{"ip": "93.184.216.34"}]},
    {"blocklist": true},
    {"verdict": "drop",   "cgroup": "user.slice/user-1000.slice/…/app-example.scope"}
  ]
}
  • cgroup only matches one app, via socket cgroupv2.
  • targets only matches destinations.
  • Both together match one app going to specific destinations.
  • silentDeny (Citadel's Lockdown mode) adds a final drop for anything not allowed earlier.
  • logNew adds the connection log rule described above.

Proxy routing (1.3+). An optional proxy section sends chosen apps through Citadel's local proxy process (citadel-proxy, shipped with the plugin and the app):

"proxy": {
  "rules": [
    {"verdict": "direct",   "cgroup": "user.slice/…/app-chromium.scope"},
    {"verdict": "redirect", "cgroup": "user.slice/…/app-mail.scope", "port": 47001},
    {"verdict": "redirect", "targets": [{"ip": "203.0.113.7", "port": 443}], "port": 47002}
  ],
  "defaultPort": 47001,
  "exclude": ["172.16.1.3/32"]
}
  • It builds a NAT chain (hook output priority dstnat) that, in order:
    • skips other users, non-TCP traffic, loopback and the exclude list
    • runs the rules, first match wins: direct returns, redirect goes to 127.0.0.1:<port>
    • redirects the rest to defaultPort, if one is set
  • Routed apps' UDP is dropped except DNS, so they can't bypass the proxy over QUIC.
  • The filter chain runs at priority mangle, before NAT, so blocks always see the real destination.
  • If nothing listens on the port, the connection is refused, so it never goes direct.

The last applied spec is kept in /var/lib/citadel/spec.json, which only root can read.

Uninstall

sudo pacman -R citadel-helper

Removing the package switches enforcement off first. Citadel then goes back to watching only.

Development

python3 tests/test_enforcer.py   # runs the real enforcer in a private user+network namespace
updpkgsums                       # after changing any file listed in PKGBUILD
makepkg -si                      # build and install from the checkout
packaging/build-in-docker.sh deb # .deb in an ubuntu:24.04 container (installs it there too)
packaging/build-in-docker.sh rpm # .rpm in a fedora container

make install DESTDIR=… ADMIN_GROUP=wheel|sudo installs the same files for other packaging; ADMIN_GROUP is the group whose members may run the helper without a password.

The tests need no root and never touch your firewall. They check the generated rules, and that hostile inputs are rejected.

License

MIT, see LICENSE.

Languages

Python

84.0%

Shell

12.8%

Makefile

3.2%