An osquery extension that adds an nftables table to a
vanilla osqueryd on Linux, written in Go.
Core osquery's built-in iptables table reads /proc/net/ip_tables_names,
which the modern nf_tables kernel backend does not populate. On nftables
systems that table returns nothing. This extension fills the gap: it shells out
to nft -j list ruleset (read-only, structured JSON) and flattens the result
into a queryable table.
SELECT * FROM nftables;The extension never modifies firewall state. The only external command it
ever runs is nft -j list ruleset, invoked with a fixed argument slice — no
shell, no string building, no injection surface.
All columns are TEXT (osquery extension columns are strings; integers are
rendered as text).
| Column | Meaning |
|---|---|
kind |
table, chain, rule, set, map, … — what the row describes |
family |
ip, ip6, inet, arp, bridge, netdev |
table_name |
nftables table name |
chain |
chain name (empty for table/set/map rows) |
handle |
rule / chain / table handle |
type |
base-chain type: filter/nat/route (else empty) |
hook |
input/output/forward/prerouting/postrouting (base chains) |
priority |
base-chain priority (number, or compact JSON if name/offset form) |
policy |
accept/drop (base chains) |
rule_json |
the raw JSON object for this row — nothing is lost |
nft -j list ruleset returns one JSON array under the nftables key, where
each element is a single-key object (table, chain, rule, set, map,
metainfo, …). We emit one row per object, skipping metainfo:
kind=table— one row per table.kind=chain— one row per chain; base chains filltype/hook/priority/policy.kind=rule— one row per rule; the owning chain is inchain, the full rule (including its match/verdict expression list) is preserved inrule_json.kind=set/kind=map— one row each.
rule_json always holds the raw object, so any detail the dedicated columns
don't expose can be recovered by re-parsing it (e.g. with osquery's
json_extract).
Builds run through just and the GoReleaser
Docker image, so Docker is the only host requirement (Go runs inside the
container).
# one-time: resolve modules and populate go.sum
just deps # == go mod tidy
# produce the static binary under ./dist
just buildjust build sets CGO_ENABLED=0, producing a single fully static binary named
nftables.ext with no runtime dependencies. Run just test for the unit tests
and just clean to remove the artifacts. See just --list for all recipes.
osquery refuses to load an extension that is group- or world-writable, so ownership and mode matter.
sudo install -o root -g root -m 0755 nftables.ext /usr/local/bin/nftables.extCreate the autoload manifest listing the binary path:
# /etc/osquery/extensions.load
/usr/local/bin/nftables.extsudo install -o root -g root -m 0644 /dev/stdin /etc/osquery/extensions.load <<'EOF'
/usr/local/bin/nftables.ext
EOFAs command-line flags:
sudo osqueryd \
--extensions_autoload=/etc/osquery/extensions.load \
--extensions_timeout=10 \
--extensions_socket=/var/osquery/osquery.emEquivalently, as lines in /etc/osquery/osquery.flags:
--extensions_autoload=/etc/osquery/extensions.load
--extensions_timeout=10
--extensions_socket=/var/osquery/osquery.em
Reading the full ruleset requires root (
CAP_NET_ADMIN).osquerydnormally runs as root, so this is fine in production. Ifnftexits non-zero for lack of privilege, the extension logs a clear message and returns zero rows rather than crashing.
{
"schedule": {
"nftables_ruleset": {
"query": "SELECT * FROM nftables;",
"interval": 300
}
}
}Each GitHub release
ships a static linux/amd64 and linux/arm64 tarball plus a checksums.txt.
No Docker, Go, or build step is needed on the target host — the binary has no
runtime dependencies (it only shells out to the system nft).
Download the tarball for your architecture, verify it, and extract the binary:
VERSION=0.3.0
case "$(uname -m)" in
x86_64) ARCH=amd64 ;;
aarch64) ARCH=arm64 ;;
*) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;;
esac
BASE="https://github.com/codingCoffee/osquery-nftables-ext/releases/download/v${VERSION}"
curl -fSLO "${BASE}/osquery-nftables-ext_${VERSION}_linux_${ARCH}.tar.gz"
curl -fSLO "${BASE}/checksums.txt"
# verify integrity (only checks the file you downloaded)
sha256sum --check --ignore-missing checksums.txt
tar -xzf "osquery-nftables-ext_${VERSION}_linux_${ARCH}.tar.gz"
# -> nftables.ext LICENSE README.mdosquery refuses to load an extension that is group- or world-writable, so the
binary must be installed root-owned, mode 0755:
sudo install -o root -g root -m 0755 nftables.ext /usr/local/bin/nftables.ext
# autoload manifest listing the binary path
sudo install -d -o root -g root -m 0755 /etc/osquery
sudo install -o root -g root -m 0644 /dev/stdin /etc/osquery/extensions.load <<'EOF'
/usr/local/bin/nftables.ext
EOF-
Install
osquerydfrom osquery's official apt repository (the binary itself is not in Ubuntu's repos), and ensurenftis present:# nftables CLI (the extension shells out to it) sudo apt-get update && sudo apt-get install -y nftables # osquery apt repo, per https://osquery.io/downloads (TLS-pinned key) export OSQUERY_KEY=1484120AC4E9F8A1A577AEEE97A80C63C9D8B80B sudo mkdir -p /etc/apt/keyrings curl -fsSL https://pkg.osquery.io/deb/pubkey.gpg \ | sudo gpg --dearmor -o /etc/apt/keyrings/osquery.gpg echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/osquery.gpg] https://pkg.osquery.io/deb deb main" \ | sudo tee /etc/apt/sources.list.d/osquery.list sudo apt-get update && sudo apt-get install -y osquery
-
Install the extension binary and
extensions.loadas shown above. The osquery package already creates/var/osquery, so the default--extensions_socket=/var/osquery/osquery.emworks unchanged. -
Point
osquerydat the extension via/etc/osquery/osquery.flags:--extensions_autoload=/etc/osquery/extensions.load --extensions_timeout=10 --extensions_socket=/var/osquery/osquery.em -
Enable the daemon (it runs as root, so it has the
CAP_NET_ADMINneeded to read the full ruleset):sudo systemctl enable --now osqueryd journalctl -u osqueryd -f | grep -i 'Registering extension'
The root filesystem is read-only, but /usr/local is a symlink to the writable,
persistent /var/usrlocal, so the install commands above work as-is and
survive upgrades. nft ships in the base image.
osqueryd is not in the base image; layer it with rpm-ostree and reboot:
# layer the official osquery rpm (from osquery's yum repo)
sudo rpm-ostree install \
https://pkg.osquery.io/rpm/osquery-5.13.1-1.linux.x86_64.rpm
sudo systemctl rebootCheck https://osquery.io/downloads for the current rpm version/URL. You can also add the osquery yum repo and
rpm-ostree install osquery, but pinning the rpm URL avoids a repo definition on an immutable host.
After reboot, install the extension binary and extensions.load (the generic
steps above), then configure flags. The osquery rpm does not pre-create
/var/osquery, so either create it or point the socket at a directory the unit
manages. Creating it is simplest:
sudo install -d -o root -g root -m 0755 /var/osquery/etc/osquery/osquery.flags:
--extensions_autoload=/etc/osquery/extensions.load
--extensions_timeout=10
--extensions_socket=/var/osquery/osquery.em
Enable and verify:
sudo systemctl enable --now osqueryd
journalctl -u osqueryd -f | grep -i 'Registering extension'If you'd rather not layer packages, run osquery inside a
toolbox/distroboxcontainer — but to read the host ruleset that container needs host networking andCAP_NET_ADMIN, exactly like the Docker setup below. Layering keeps it on the host whereosquerydruns as root.
After the daemon is up on either distro, confirm the table is live:
sudo osqueryi --extensions_autoload=/etc/osquery/extensions.load --extensions_timeout=10osquery> SELECT kind, family, table_name, chain, handle FROM nftables;You can run the extension by hand against a running osqueryi without
installing anything. Start osqueryi and let it advertise an extensions
socket, then point the extension at that socket:
# Terminal 1: start an interactive shell with a known socket path.
osqueryi --nodisable_extensions --extensions_socket=/tmp/osq.em# Terminal 2: run the extension against that socket (root to read the ruleset).
sudo ./nftables.ext --socket=/tmp/osq.emBack in the osqueryi prompt:
osquery> SELECT kind, family, table_name, chain, handle FROM nftables;
osquery> SELECT * FROM nftables WHERE kind = 'rule';On a host that has nftables rules loaded, these return rows. On a host with an
empty ruleset (or where nft is missing) you get zero rows and a log line —
never a crash.
By default the extension finds nft on PATH via exec.LookPath. Set
NFT_BIN to use a specific binary (useful for testing or non-standard
installs):
sudo NFT_BIN=/usr/sbin/nft ./nftables.ext --socket=/tmp/osq.emEach of these is logged with a clear message and yields an empty result set (the extension keeps running):
nftbinary not found onPATH(andNFT_BINunset),nftexits non-zero (e.g. permission denied without root),- empty or malformed JSON output.
Unit tests feed a committed fixture (testdata/ruleset.json, a captured
nft -j list ruleset sample) into the flattening logic and assert the rows are
correct. They never shell out to a real nft.
just testThe repo ships a flake.nix that packages the extension with
buildGoModule (unit tests run in the build's checkPhase, so a broken build
never installs) and provides a NixOS module that wires it into the upstream
services.osquery daemon. This is the idiomatic path: the binary lives in
/nix/store — already root-owned and non-writable, exactly what osquery's
extension safety check wants — so there is nothing to chown/chmod.
buildGoModule needs the hash of the vendored Go dependencies; flake.nix
pins it, so nix build works out of the box. The hash changes whenever
go.mod/go.sum change — when it does, regenerate it:
# set `vendorHash = pkgs.lib.fakeHash;` in flake.nix, then:
nix build .#osquery-nftables-ext
# error: hash mismatch ... got: sha256-XXXX...
# -> paste that sha256-XXXX... back as vendorHash and rebuildA nix build .#default step in CI catches a stale hash before it lands.
nix build .#osquery-nftables-ext
./result/bin/nftables.ext --help # the static extension binaryIn a flake-based NixOS config, add this flake as an input and import its module:
{
inputs.osquery-nftables.url = "github:codingoffee/osquery-nftables-ext";
outputs = { nixpkgs, osquery-nftables, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
osquery-nftables.nixosModules.default
{
# Builds the extension, enables services.osquery, autoloads the
# extension, schedules `SELECT * FROM nftables;`, and grants osqueryd
# CAP_NET_ADMIN so it can read the full ruleset.
services.osqueryNftables.enable = true;
# services.osqueryNftables.interval = 300; # optional, seconds
}
];
};
};
}Rebuild and verify:
sudo nixos-rebuild switch
journalctl -u osqueryd -f | grep -i 'Registering extension'The module is self-contained: services.osqueryNftables.enable = true; builds the
extension, autoloads it, schedules the query, grants CAP_NET_ADMIN, and
applies the two NixOS-specific fixes below — no extra config is required.
Two defaults that work on a traditional FHS distro fail on NixOS. The module
fixes both; they are documented here so the symptoms are self-diagnosable (and
so you know what to override if you wire services.osquery yourself).
-
Extensions socket directory must already exist.
osqueryddefaults--extensions_socketto/var/osquery/osquery.em, but on NixOS that directory does not exist — the unit only manages/var/lib/osquery(StateDirectory) and/run/osquery(RuntimeDirectory). With the default you'll see:Extension socket directory missing: /var/osquery/osquery.emand the extension loops on
waiting for unix socket to be available … context deadline exceeded, so the table never registers and queries fail withno such table: nftables. The module setsextensions_socket = "/run/osquery/osquery.em"(vialib.mkDefault, so an explicit value of yours still wins) — a directory the unit already creates. -
nftmust be on the daemon's PATH (or pinned viaNFT_BIN). The extension shells out tonft, but theosquerydsystemd unit has a minimal Nix PATH without nftables, so you'll see:nft binary not found on PATH (set NFT_BIN to override): exec: "nft": executable file not found in $PATH vtable constructor failed: nftablesThe module both sets
NFT_BIN=${pkgs.nftables}/bin/nftin the unit's environment and addspkgs.nftablesto the unit'spath, so the lookup always resolves.
Diagnosing on a host:
vtable constructor failed: nftablesmeans the table's constructor threw — almost always the missingnft(fix #2).no such table: nftablesmeans the extension never registered at all — almost always the missing socket directory (fix #1). Checkjournalctl -u osquerydfor the underlyingExtension socket directory missing/nft … not foundlines.
If you'd rather drive services.osquery directly:
{ config, pkgs, ... }:
let
ext = (builtins.getFlake "github:codingcoffee/osquery-nftables-ext")
.packages.${pkgs.stdenv.hostPlatform.system}.default;
in {
services.osquery = {
enable = true;
flags = {
extensions_autoload =
toString (pkgs.writeText "extensions.load" "${ext}/bin/nftables.ext");
extensions_timeout = "10";
# /var/osquery doesn't exist on NixOS; use a dir the unit manages.
extensions_socket = "/run/osquery/osquery.em";
};
settings.schedule.nftables_ruleset = {
query = "SELECT * FROM nftables;";
interval = 300;
};
};
systemd.services.osqueryd = {
# nft isn't on the daemon's minimal PATH; pin it so the extension finds it.
serviceConfig.Environment = [ "NFT_BIN=${pkgs.nftables}/bin/nft" ];
path = [ pkgs.nftables ];
# Reading the full ruleset over netlink needs this capability.
serviceConfig.AmbientCapabilities = [ "CAP_NET_ADMIN" ];
};
}If osqueryd logs that the extension path is "not safe": that is osquery's permission check, not NixOS. Store paths are normally fine (root-owned, mode
0555). As a last resort addallow_unsafe = "true";toservices.osquery.flags, but understand it relaxes osquery's extension trust model — prefer fixing the path's ownership/mode instead.
A multi-stage Dockerfile builds the static binary and bakes it
into a debian:bookworm-slim image that ships osqueryd + nft, with the
extension autoloaded.
docker compose build
docker compose up -d
docker compose logs -f osquery-nftablesTo read the host's nftables ruleset, the daemon must run nft inside the
host network namespace and hold CAP_NET_ADMIN. The compose file does exactly
that:
network_mode: host # see the host's ruleset, not the container's
cap_add: [ NET_ADMIN ] # capability nft needs to read it (narrower than privileged)Without network_mode: host the extension only sees the container's own (empty)
ruleset.
Run an ad-hoc query against the loaded extension by execing into the container.
Use interactive osqueryi (no trailing query argument):
docker exec -it osquery-nftables \
osqueryi --extensions_autoload=/etc/osquery/extensions.load --extensions_timeout=10osquery> SELECT kind, family, table_name, chain, handle FROM nftables;Don't pass the SQL as a one-shot argument (
osqueryi "SELECT ... FROM nftables;"). One-shot mode can execute the query before the autoloaded extension finishes registering, giving a spuriousno such table: nftables. Interactive mode (above) waits for registration, and the scheduledosquerydpath is unaffected. The container name isosquery-nftables-ext-osquery-nftables-1unless you setcontainer_name.
Override the osquery version at build time:
docker compose build --build-arg OSQUERY_VERSION=5.13.1The bundled
.debURL targets amd64. On arm64 hosts, adjust the filename in theDockerfile(...linux_arm64.deb).
Standard library plus the official github.com/osquery/osquery-go
SDK only. The SDK pulls in Apache Thrift transitively; go mod tidy resolves
everything.
Apache-2.0. See LICENSE.