Skip to content

Repository files navigation

osquery-nftables-ext

An osquery extension that adds an nftables table to a vanilla osqueryd on Linux, written in Go.

Why

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.

Table schema

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

Row model

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 fill type/hook/priority/policy.
  • kind=rule — one row per rule; the owning chain is in chain, the full rule (including its match/verdict expression list) is preserved in rule_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).

Build

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 build

just 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.

Install

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.ext

Create the autoload manifest listing the binary path:

# /etc/osquery/extensions.load
/usr/local/bin/nftables.ext
sudo install -o root -g root -m 0644 /dev/stdin /etc/osquery/extensions.load <<'EOF'
/usr/local/bin/nftables.ext
EOF

osqueryd flags

As command-line flags:

sudo osqueryd \
  --extensions_autoload=/etc/osquery/extensions.load \
  --extensions_timeout=10 \
  --extensions_socket=/var/osquery/osquery.em

Equivalently, 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). osqueryd normally runs as root, so this is fine in production. If nft exits non-zero for lack of privilege, the extension logs a clear message and returns zero rows rather than crashing.

Sample osquery.conf schedule

{
  "schedule": {
    "nftables_ruleset": {
      "query": "SELECT * FROM nftables;",
      "interval": 300
    }
  }
}

Install from a release (prebuilt binary)

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.md

osquery 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

Ubuntu

  1. Install osqueryd from osquery's official apt repository (the binary itself is not in Ubuntu's repos), and ensure nft is 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
  2. Install the extension binary and extensions.load as shown above. The osquery package already creates /var/osquery, so the default --extensions_socket=/var/osquery/osquery.em works unchanged.

  3. Point osqueryd at the extension via /etc/osquery/osquery.flags:

    --extensions_autoload=/etc/osquery/extensions.load
    --extensions_timeout=10
    --extensions_socket=/var/osquery/osquery.em
    
  4. Enable the daemon (it runs as root, so it has the CAP_NET_ADMIN needed to read the full ruleset):

    sudo systemctl enable --now osqueryd
    journalctl -u osqueryd -f | grep -i 'Registering extension'

Fedora Silverblue (and other rpm-ostree / immutable variants)

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 reboot

Check 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/distrobox container — but to read the host ruleset that container needs host networking and CAP_NET_ADMIN, exactly like the Docker setup below. Layering keeps it on the host where osqueryd runs 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=10
osquery> SELECT kind, family, table_name, chain, handle FROM nftables;

Standalone / test mode

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.em

Back 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.

NFT_BIN override

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.em

Failure handling

Each of these is logged with a clear message and yields an empty result set (the extension keeps running):

  • nft binary not found on PATH (and NFT_BIN unset),
  • nft exits non-zero (e.g. permission denied without root),
  • empty or malformed JSON output.

Testing

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 test

Install on NixOS (declarative)

The 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.

Updating vendorHash

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 rebuild

A nix build .#default step in CI catches a stale hash before it lands.

Try it without installing

nix build .#osquery-nftables-ext
./result/bin/nftables.ext --help     # the static extension binary

Add to your system configuration

In 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.

NixOS gotchas the module handles for you

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).

  1. Extensions socket directory must already exist. osqueryd defaults --extensions_socket to /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.em
    

    and the extension loops on waiting for unix socket to be available … context deadline exceeded, so the table never registers and queries fail with no such table: nftables. The module sets extensions_socket = "/run/osquery/osquery.em" (via lib.mkDefault, so an explicit value of yours still wins) — a directory the unit already creates.

  2. nft must be on the daemon's PATH (or pinned via NFT_BIN). The extension shells out to nft, but the osqueryd systemd 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: nftables
    

    The module both sets NFT_BIN=${pkgs.nftables}/bin/nft in the unit's environment and adds pkgs.nftables to the unit's path, so the lookup always resolves.

Diagnosing on a host: vtable constructor failed: nftables means the table's constructor threw — almost always the missing nft (fix #2). no such table: nftables means the extension never registered at all — almost always the missing socket directory (fix #1). Check journalctl -u osqueryd for the underlying Extension socket directory missing / nft … not found lines.

Wiring it yourself (without the bundled module)

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 add allow_unsafe = "true"; to services.osquery.flags, but understand it relaxes osquery's extension trust model — prefer fixing the path's ownership/mode instead.

Docker

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-nftables

To 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=10
osquery> 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 spurious no such table: nftables. Interactive mode (above) waits for registration, and the scheduled osqueryd path is unaffected. The container name is osquery-nftables-ext-osquery-nftables-1 unless you set container_name.

Override the osquery version at build time:

docker compose build --build-arg OSQUERY_VERSION=5.13.1

The bundled .deb URL targets amd64. On arm64 hosts, adjust the filename in the Dockerfile (...linux_arm64.deb).

Dependencies

Standard library plus the official github.com/osquery/osquery-go SDK only. The SDK pulls in Apache Thrift transitively; go mod tidy resolves everything.

License

Apache-2.0. See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages