Packages

NFTables.Port is an Elixir module that bridges Elixir and the Linux kernel's nftables firewall via an Elixir port.

Current section

Files

Jump to
nftables_port README.md
Raw

README.md

# NFTables.Port

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

Port component for [NFTables](https://github.com/dcoai/nftables). Provides a Zig-based native port executable for communicating with Linux nftables via the official libnftables JSON API.

## Overview

NFTables.Port provides:

- **Native Zig Port Executable** - High-performance port process with `CAP_NET_ADMIN` capability
- **libnftables JSON API** - Requests go to the official nftables JSON API unchanged, and replies come back unchanged
- **Automatic Framing** - 4-byte length-prefixed packets for reliable communication
- **Process Isolation** - Port process crashes don't affect the Elixir VM
- **Synchronous API** - Simple request/response pattern with timeout support

## Architecture

```mermaid
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`:

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

## Requirements

- **Linux kernel** with nftables support (kernel 3.13+)
- **libnftables** library installed (`nftables` package on most distros)
- **Zig compiler** (for building from source)
- **CAP_NET_ADMIN** capability on the port executable

### Setting Capabilities

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

```bash
# 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

```elixir
# 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`:

```elixir
%{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](https://github.com/dcoai/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:
- Set `PORT_NFTABLES_PATH` to specify a custom location
- Install to `/usr/local/sbin/port_nftables`

## Building from Source

The Zig port executable is built automatically during `mix compile`:

```bash
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](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](dev_docs/security.md) for how to keep your system secure.

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

```bash
# 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:
- Copies the compiled executable next to the target, as `<target>.new`
- Sets executable permissions (750) and the `CAP_NET_ADMIN` capability on it
- Renames it over the target, so a running port is untouched and its next
  start runs the new file: no need to stop the application to upgrade
- Leaves the target unchanged if the copy or the mode fails, and installs but
  says the capability is NOT set (with the command to set it) if `setcap` is
  missing or refused

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:

```bash
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

- **Zig 0.15.2**, pinned in `.tool-versions` (`asdf install`). Zig 0.16 does
  not build the port.
- System packages (Debian/Ubuntu): `libnftables-dev libcap-dev pkg-config
  build-essential`.

### Testing

```bash
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:

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

### Continuous integration

Every branch push runs `.gitlab-ci.yml`
([spec 08](spec/08-testing-ci.md), 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/02-wire-protocol.md),
[spec 03](spec/03-responses.md)).

## License

MIT License - see [LICENSE](LICENSE) for details.

## Related Projects

- [NFTables](https://github.com/dcoai/nftables) - High-level Elixir API for nftables
- [nftables](https://netfilter.org/projects/nftables/) - Linux kernel firewall

## Documentation

Full documentation is available at [HexDocs](https://hexdocs.pm/nftables_port).