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.
Blocking traffic means changing the kernel firewall, and that needs root. Citadel itself never has root:
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.
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:
.deb and .rpm from packaging/ (see Development)python, nftables, iproute2 and polkitnft_socket, cgroup v2 and INET_DIAG_DESTROY; the stock Arch kernel has all threepython-maxminddb (optional), for Citadel's country lookup| Path | Purpose |
|---|---|
/usr/lib/citadel/citadel-enforcer | The helper. Root-owned; the only thing that ever runs as root. |
/usr/share/polkit-1/actions/io.github.neatouk.citadel.policy | A polkit action that covers exactly that one program. |
/usr/share/polkit-1/rules.d/49-citadel.rules | Lets 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.service | Re-applies your last policies at boot, before the network comes up. Enabled on install. |
/usr/bin/citadel-off | Emergency off. Removes every Citadel rule immediately, from any terminal. |
A passwordless root helper is only acceptable if it can't be misused, so
citadel-enforcer treats everything it receives as hostile:
table inet citadel and never touches anything else in your firewall.meta skuid). System services such as WARP, NetworkManager and DNS always pass, so the tunnel and name resolution can't break.ipaddress, and every port must be 1–65535...."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 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.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.
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.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"]
}
hook output priority dstnat) that, in order:
exclude listdirect returns, redirect goes to 127.0.0.1:<port>defaultPort, if one is setmangle, before NAT, so blocks always see the real destination.The last applied spec is kept in /var/lib/citadel/spec.json, which only root can read.
sudo pacman -R citadel-helper
Removing the package switches enforcement off first. Citadel then goes back to watching only.
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.
MIT, see LICENSE.
Python
84.0%
Shell
12.8%
Makefile
3.2%
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.
Blocking traffic means changing the kernel firewall, and that needs root. Citadel itself never has root:
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.
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:
.deb and .rpm from packaging/ (see Development)python, nftables, iproute2 and polkitnft_socket, cgroup v2 and INET_DIAG_DESTROY; the stock Arch kernel has all threepython-maxminddb (optional), for Citadel's country lookup| Path | Purpose |
|---|---|
/usr/lib/citadel/citadel-enforcer | The helper. Root-owned; the only thing that ever runs as root. |
/usr/share/polkit-1/actions/io.github.neatouk.citadel.policy | A polkit action that covers exactly that one program. |
/usr/share/polkit-1/rules.d/49-citadel.rules | Lets 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.service | Re-applies your last policies at boot, before the network comes up. Enabled on install. |
/usr/bin/citadel-off | Emergency off. Removes every Citadel rule immediately, from any terminal. |
A passwordless root helper is only acceptable if it can't be misused, so
citadel-enforcer treats everything it receives as hostile:
table inet citadel and never touches anything else in your firewall.meta skuid). System services such as WARP, NetworkManager and DNS always pass, so the tunnel and name resolution can't break.ipaddress, and every port must be 1–65535...."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 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.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.
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.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"]
}
hook output priority dstnat) that, in order:
exclude listdirect returns, redirect goes to 127.0.0.1:<port>defaultPort, if one is setmangle, before NAT, so blocks always see the real destination.The last applied spec is kept in /var/lib/citadel/spec.json, which only root can read.
sudo pacman -R citadel-helper
Removing the package switches enforcement off first. Citadel then goes back to watching only.
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.
MIT, see LICENSE.
Python
84.0%
Shell
12.8%
Makefile
3.2%