NFTables.Port

NFTables.Port is the low-level communication layer that bridges Elixir and the Linux kernel's nftables firewall.

Port component for NFTables. Provides a Zig-based native port executable for communicating with Linux nftables via the official libnftables JSON API.

Overview

NFTables.Port provides:

Architecture

flowchart TD
    API[NFTables<br/>High-level Elixir API]
    GenServer[NFTables.Port<br/>GenServer]
    Port[Erlang Port<br/>Zig executable]
    Lib[libnftables<br/>C library]
    Kernel[Linux Kernel<br/>nftables]

    API --> GenServer
    GenServer --> Port
    Port --> Lib
    Lib --> Kernel

Installation

Add nftables_port to your dependencies in mix.exs:

def deps do
  [
    {:nftables_port, "~> 0.5"}
  ]
end

Requirements

Setting Capabilities

The port executable requires CAP_NET_ADMIN to communicate with the kernel firewall:

# After compilation
sudo setcap cap_net_admin=ep _build/dev/lib/nftables_port/priv/port_nftables

mix compile does not set it; it checks with getcap and warns if it is missing. mix nftables_port.install (below) installs the executable and sets the capability in one step.

Usage

Direct Usage

# Start the port. name: is optional; the nftables library looks the port up
# as NFTables.Port when it is not given a pid.
{:ok, pid} = NFTables.Port.start_link(name: NFTables.Port)

# Send a request to list tables
request = ~s({"nftables": [{"list": {"tables": {}}}]})

# The reply is returned unmodified, and is not always JSON: libnftables' JSON
# output on success, "" for a success with no output (add, delete, flush),
# or libnftables' error text, which is plain text, on failure.
case NFTables.Port.commit(pid, request) do
  {:ok, ""} -> :ok
  {:ok, reply} ->
    case JSON.decode(reply) do
      {:ok, data} -> {:ok, data}
      {:error, _} -> {:error, reply}
    end
  {:error, {:port_exit, status}} -> {:error, {:port_exit, status}}
end

# Stop the port
NFTables.Port.stop(pid)

Any number of processes may call commit/3 at once; each receives the response to its own request. Start failures are returned from start_link/1 as {:error, reason}. See the NFTables.Port documentation for the reasons.

NFTables.Port.info/1 reports which executable a port is running and what it announced at start: its path and OS pid, the protocol version, the nftables_port version it was built from, and whether it holds CAP_NET_ADMIN:

%{path: _, os_pid: _, protocol: 1, version: "0.5.0", net_admin: true} =
  NFTables.Port.info(pid)

With NFTables

Most applications use NFTables.Port through the NFTables library, which builds requests and decodes responses. Its documentation covers that API. This package is only the transport.

Port Executable Location

The port executable is located using this resolution order:

  1. PORT_NFTABLES_PATH, if set. It is then the only candidate: a missing file is {:error, {:port_not_found, path}}, never a fallback to another binary.
  2. /usr/local/sbin/port_nftables (system-wide installation)
  3. /usr/sbin/port_nftables (system-wide installation)
  4. port_nftables in the application's priv directory

For production deployments, either:

Building from Source

The Zig port executable is built automatically during mix compile:

mix deps.get
mix compile

The build process:

  1. Compiles the Zig source code in native/src/
  2. Installs the executable in the build: _build/<env>/lib/nftables_port/priv/port_nftables, rebuilt whenever a file under native/, the Zig version or nftables_port's own version changes, so upgrading the dependency rebuilds it too. A rebuilt file has no capability: set it again (dev_docs/capabilities.md).
  3. Checks the capability with getcap and warns if it is missing (it never sets it)

Installing to System Location

See dev_docs/security.md for how to keep your system secure.

For production deployments, install the port executable to a system location:

# Install to default location (/usr/local/sbin/port_nftables)
sudo mix nftables_port.install

# Install to custom location
sudo mix nftables_port.install /usr/sbin/port_nftables

# Install to custom directory (will create port_nftables in that directory)
sudo mix nftables_port.install /opt/nftables/bin/

The install task:

After installation to a standard location (/usr/local/sbin or /usr/sbin), NFTables.Port will automatically find the executable. For custom locations, set the PORT_NFTABLES_PATH environment variable:

export PORT_NFTABLES_PATH=/opt/nftables/bin/port_nftables

An installed executable is a copy, and it does not change when the dependency does: re-run the install after upgrading nftables_port. A port whose executable was built from a different nftables_port version starts, but logs a warning naming both versions; one speaking a different protocol version is refused.

Development

Toolchain

Testing

mix test                         # Elixir suite
(cd native && zig build test)    # the executable's Zig unit tests

Most of the suite drives a fake executable and needs nothing special. The :kernel tests run the real port_nftables against libnftables, so they need Linux with nftables, the libnftables headers and Zig to build it, and CAP_NET_ADMIN (root, in practice). They only touch tables named unified_test_*. The :netns tests write sysctls, so they run only when NFTABLES_PORT_NETNS=1 says the suite is inside a network namespace made for it. To keep everything off the host's firewall and sysctls, run as CI does: as root, in a fresh network namespace:

sudo unshare --net -- env NFTABLES_PORT_NETNS=1 mix test

Continuous integration

Every branch push runs .gitlab-ci.yml (spec 08, REQ-TEST-005):

Job Checks
format mix format --check-formatted, zig fmt --check native/
compile mix compile --force --warnings-as-errors in dev and prod; zig build test; mix docs --warnings-as-errors
audit mix hex.audit
test the whole suite, :kernel and :netns included, with --warnings-as-errors, on a privileged runner inside unshare --net; this includes the check of the Hex package's contents
surfex the spec, tests and code held to each other: every requirement verified by a test that passed in this pipeline, nothing unvalidated

Every job first checks that its Zig is the ZIG_VERSION pinned in .tool-versions. The test job refuses to run unless its network namespace starts with an empty ruleset. Merges require a green pipeline.

Protocol

Each message is a 4-byte big-endian length followed by that many bytes:

Request:  [4 bytes: length][N bytes: the request]
Response: [4 bytes: length][N bytes: the reply]

Erlang's {:packet, 4} adds and strips the framing. A request is nftables JSON or a sysctl request; the reply is libnftables' output, "", its error text, or a sysctl JSON reply (spec 02, spec 03).

License

MIT License - see LICENSE for details.

Documentation

Full documentation is available at HexDocs.