NFTables Library Architecture
View SourceThis document provides a deep dive into the architecture of the NFTables library, explaining the design decisions, component interactions, and composition patterns that make the library work.
Table of Contents
- High-Level Overview
- NFTables.Port Separation
- Core Architecture Components
- Builder Architecture
- Match and Rule Building
- Composition Patterns
- Data Flow and Execution Pipeline
- Requestor Pattern
- Design Principles
High-Level Overview
The NFTables library is built on a layered architecture that separates concerns and provides multiple levels of abstraction for working with Linux nftables:
┌─────────────────────────────────────────────────────────┐
│ High-Level API (Policy, NAT, convenience functions) │
├─────────────────────────────────────────────────────────┤
│ Builder API (tables, chains, sets, flowtables) │
│ Match/Rule API (rule expressions) │
├─────────────────────────────────────────────────────────┤
│ Core Layer (Query, Local, Requestor, Decoder, Expr) │
├─────────────────────────────────────────────────────────┤
│ NFTables.Port (GenServer managing Zig port process) │
├─────────────────────────────────────────────────────────┤
│ Port Executable (Zig binary with CAP_NET_ADMIN) │
├─────────────────────────────────────────────────────────┤
│ libnftables (Official C library, JSON API) │
├─────────────────────────────────────────────────────────┤
│ Linux Kernel (nftables netfilter subsystem) │
└─────────────────────────────────────────────────────────┘Each layer builds upon the one below it, with clear boundaries and responsibilities.
NFTables.Port Separation
Why Separate NFTables.Port?
The nftables_port package is maintained as a separate repository and Hex package from the main nftables library. This separation provides several critical benefits:
1. Fault Isolation
The port process runs as a separate OS process. If the native code crashes (due to C library issues, memory corruption, etc.), it doesn't bring down the Elixir VM:
# Port crashes are isolated - BEAM continues running
{:ok, pid} = NFTables.Port.start_link()
# If port crashes, only this GenServer crashes
# Supervisor can restart it without affecting the rest of the application2. Security Boundary
The port executable requires CAP_NET_ADMIN capability to communicate with the kernel firewall. By isolating this in a separate process:
- Only the port binary needs elevated privileges
- The Elixir VM runs with normal user permissions
- Attack surface is minimized to a small, auditable Zig program
- Principle of least privilege is enforced
# Only the port needs capabilities
sudo setcap cap_net_admin=ep priv/port_nftables
# Elixir app runs as regular user
mix run --no-halt
3. Technology Isolation
Native dependencies (Zig compiler, libnftables) are isolated to the port package:
- Main library has zero native dependencies
- Can be developed/tested without Zig toolchain
- Updates to port implementation don't require library changes
- Different deployment scenarios (local vs. remote execution)
4. Independent Versioning
Port and library can version independently:
# mix.exs
def deps do
[
{:nftables_port, "~> 0.4.0"}, # Port protocol version
{:nftables, "~> 0.4.2"} # API version
]
endThis allows:
- Bug fixes to port without API changes
- API improvements without native code changes
- Easier maintenance and testing
5. Alternative Implementations
The separation enables alternative execution backends:
# Local execution via port
{:ok, pid} = NFTables.Port.start_link()
NFTables.add(table: "filter") |> NFTables.submit(pid: pid)
# Could implement remote execution without port
defmodule MyApp.RemoteRequestor do
@behaviour NFTables.Requestor
def submit(command, opts) do
node = Keyword.fetch!(opts, :node)
:rpc.call(node, NFTables.Local, :submit, [command, opts])
end
endPort Architecture
NFTables.Port (GenServer)
│
├─ State: %{port: port_pid, pending: %{}}
│
├─ Manages: Zig port process lifecycle
│ ├─ Spawns port with: {:spawn_executable, port_path}
│ ├─ Packet framing: {:packet, 4} (4-byte length prefix)
│ └─ Bidirectional communication
│
└─ API:
├─ commit(pid, json_string, timeout) → {:ok, response} | {:error, reason}
└─ Request/response correlation via message passingThe port uses a simple protocol:
Request: [4-byte length][JSON string]
Response: [4-byte length][JSON string]Example interaction:
# 1. Builder creates Elixir data structures
builder = NFTables.add(table: "filter", family: :inet)
# 2. Local requestor converts to JSON and sends to port
json = Jason.encode!(%{nftables: [%{add: %{table: %{family: :inet, name: "filter"}}}]})
{:ok, response_json} = NFTables.Port.commit(pid, json, 5000)
# 3. Port forwards to libnftables
# [Zig port] → [libnftables] → [kernel netlink] → [nftables subsystem]
# 4. Response flows back
# [kernel] → [libnftables JSON] → [Zig port] → [GenServer] → [Local]Core Architecture Components
1. NFTables (Main Module)
The entry point providing convenience functions and delegating to specialized modules:
defmodule NFTables do
# Process management
defdelegate start_link(opts \\ []), to: NFTables.Port
defdelegate stop(pid), to: GenServer
# Dual-arity Builder API
def add(opts), do: Builder.new(opts) |> add(opts)
def add(%Builder{} = builder, opts), do: NFTables.add(builder, opts)
# Policy helpers
defdelegate allow_ssh(pid, opts \\ []), to: NFTables.Policy
defdelegate setup_basic_firewall(pid, opts \\ []), to: NFTables.Policy
end2. NFTables.Requestor (Behaviour)
Responsibility: Define the interface for submission handlers.
defmodule NFTables.Requestor do
@callback submit(builder :: term(), opts :: keyword()) ::
:ok | {:ok, term()} | {:error, term()}
endThe Requestor behaviour allows you to define custom handlers for submitting Builder configurations. This enables use cases beyond local execution:
- Remote execution: Submit configurations to remote nodes
- Audit logging: Log all firewall changes before applying
- Testing: Capture configurations without applying
- Batching: Accumulate multiple configs before submission
3. NFTables.Local (Default Requestor)
Responsibility: Local execution requestor - the only place where JSON encoding/decoding happens for local execution.
defmodule NFTables.Local do
@behaviour NFTables.Requestor
@doc """
Submit command (Builder or map) for local execution by:
1. Converting to JSON (ONLY place encoding happens)
2. Sending to Port
3. Receiving response JSON
4. Decoding JSON (ONLY place decoding happens)
5. Returning Elixir structures
"""
@impl true
def submit(builder_or_command, opts) do
command = case builder_or_command do
%{__struct__: Builder} -> Builder.to_map(builder_or_command)
map when is_map(map) -> map
end
command
|> Jason.encode!() # → JSON string
|> send_to_port(opts) # → Port
|> receive_response() # ← JSON string
|> Jason.decode!(keys: :atoms) # → Elixir map
|> check_errors()
end
endKey principle: All other modules work with pure Elixir data structures (maps, lists, atoms, strings). JSON is an implementation detail of Local.
4. NFTables.Query
Responsibility: Build read-operation command maps (pure functions).
defmodule NFTables.Query do
# Pure functions that return command maps
def list_tables(opts \\ []) do
%{nftables: [%{list: %{tables: build_filter(opts)}}]}
end
def list_rules(table, chain, opts \\ []) do
%{nftables: [%{list: %{chain: %{
family: opts[:family] || :inet,
table: table,
name: chain
}}}]}
end
endUsage pattern (pipeline):
{:ok, data} = Query.list_tables(family: :inet)
|> Local.submit(pid: pid)
|> Decoder.decode()5. NFTables.Decoder
Responsibility: Transform nftables JSON responses into idiomatic Elixir structures.
defmodule NFTables.Decoder do
def decode({:ok, %{nftables: items}}) do
case detect_response_type(items) do
:write_only -> :ok
:read_only -> decode_read_only(items)
:mixed -> decode_mixed(items)
end
end
# Transforms this:
# %{nftables: [%{table: %{name: "filter", family: "inet"}}]}
#
# Into this:
# {:ok, %{tables: [%{name: "filter", family: :inet}]}}
end6. NFTables.Expr
Responsibility: Low-level expression builders for nftables JSON structures.
defmodule NFTables.Expr do
# Build match expressions
def payload_match(protocol, field, value, op \\ "==") do
%{match: %{
left: %{payload: %{protocol: protocol, field: field}},
right: normalize_value(value),
op: op
}}
end
# Build statements
def limit(rate, per, opts \\ []) do
%{limit: %{rate: rate, per: per, burst: opts[:burst] || 0}}
end
# Build verdicts
def verdict("accept"), do: %{accept: nil}
def verdict("drop"), do: %{drop: nil}
endBuilder Architecture
The Builder provides a unified, functional API for constructing nftables configurations.
Design Philosophy
# Key principles:
# 1. Pure building - immutable, no side effects
# 2. Explicit execution - commands only run when execute/2 is called
# 3. Atom keys - all internal data uses atoms (converted to strings for JSON)
# 4. Context tracking - automatically remembers table/chain/collection
# 5. Unified API - same functions (add/delete/flush) for all object typesCore Structure
defmodule NFTables.Builder do
defstruct [
family: :inet, # Address family
table: nil, # Current table (context)
chain: nil, # Current chain (context)
collection: nil, # Current set/map (context)
type: nil, # Type metadata
spec: nil, # Current spec being built
commands: [] # Accumulated command list
]
endPriority-Based Object Detection
Builder automatically detects which object type you're operating on using a priority map:
@object_priority_map %{
table: 0, # Lowest priority = context
chain: 1, # Context for rules
rule: 2, # Main object
rules: 2, # (same priority as rule)
set: 3, # Main object
map: 3, # Main object
flowtable: 3, # Main object
element: 4 # Highest priority
}How it works:
# When you write:
NFTables.add(builder, table: "filter", chain: "INPUT", rule: [...])
# Builder detects:
# - table: priority 0 (context)
# - chain: priority 1 (context)
# - rule: priority 2 (MAIN OBJECT - highest priority)
#
# Result: Adds a rule to "filter/INPUT", updating builder context for next operationContext Chaining
Builder tracks context so you don't repeat yourself:
builder
|> NFTables.add(table: "filter", chain: "INPUT") # Sets context
|> NFTables.add(rule: ssh_rule) # Uses filter/INPUT
|> NFTables.add(rule: http_rule) # Still uses filter/INPUT
|> NFTables.add(chain: "OUTPUT") # Changes chain context
|> NFTables.add(rule: outbound_rule) # Uses filter/OUTPUTAutomatic Rule Conversion
Builder automatically converts NFTables.Expr structs to expression lists:
# You write:
ssh_rule = tcp() |> dport(22) |> accept()
NFTables.add(builder, rule: ssh_rule)
# Builder automatically calls:
NFTables.Expr.to_list(ssh_rule) # → [%{match: ...}, %{accept: nil}]
# No need to call to_list() manually!Command Building Pipeline
When you call NFTables.add(builder, opts), this happens:
# Step 1: Detect main object type
{:rule, rule_value} = find_highest_priority(opts)
# Step 2: Extract context (lower priority objects)
context = extract_context(opts, :rule)
# → %{table: "filter", chain: "INPUT"}
# Step 3: Update builder with context
builder = update_builder_context(builder, context)
# Step 4: Build base spec
spec = build_spec(builder, :add, :rule, opts)
# → %{family: :inet, table: "filter", chain: "INPUT", expr: [...]}
# Step 5: Add optional fields
spec = update_spec(:rule, :add, spec, opts)
# → Adds :comment, :index, :handle if present
# Step 6: Wrap in command structure
command = %{add: %{rule: spec}}
# Step 7: Add to builder.commands list
builder = %{builder | commands: builder.commands ++ [command]}Unified API Pattern
All object types use the same functions:
# Tables
builder |> add(table: "filter")
builder |> delete(table: "filter")
builder |> flush(table: "filter")
# Chains
builder |> add(chain: "INPUT", type: :filter, hook: :input)
builder |> delete(chain: "INPUT")
builder |> flush(chain: "INPUT")
builder |> rename(chain: "INPUT", newname: "NEW_INPUT")
# Rules
builder |> add(rule: [...])
builder |> insert(rule: [...], index: 0)
builder |> replace(rule: [...], handle: 123)
builder |> delete(rule: 123) # Just pass handle
# Sets
builder |> add(set: "blocklist", type: :ipv4_addr)
builder |> delete(set: "blocklist")
builder |> flush(set: "blocklist")
# Elements
builder |> add(element: ["192.168.1.1"], set: "blocklist")
builder |> delete(element: ["192.168.1.1"], set: "blocklist")Execution
# Build up commands (pure)
builder = Builder.new(family: :inet)
|> NFTables.add(table: "filter")
|> NFTables.add(chain: "INPUT", type: :filter, hook: :input)
|> NFTables.add(rule: accept_established)
# Execute all at once (side effect)
NFTables.submit(builder, pid: pid)
# Internally:
# 1. Wraps commands: %{nftables: builder.commands}
# 2. Calls Local.submit/2
# 3. Local handles JSON encoding and Port communicationMatch and Rule Building
The library provides two complementary APIs for building rule expressions:
1. NFTables.Expr (Pure Functional API)
Design:
- Pure functions returning updated struct
- Delegation to specialized sub-modules
- Protocol-agnostic port matching
- Expression list building
Structure:
defmodule NFTables.Expr do
defstruct [
family: :inet,
comment: nil,
protocol: nil, # Tracks current protocol context
expr_list: [] # List of expression maps
]
# Core entry point
def rule(opts \\ []), do: %__MODULE__{family: opts[:family] || :inet}
# Delegates to sub-modules:
defdelegate source_ip(builder, ip), to: Match.IP
defdelegate dport(builder, port), to: Match.Port
defdelegate tcp_flags(builder, flags, mask), to: Match.TCP
defdelegate ct_state(builder, states), to: Match.CT
defdelegate payload_raw(builder, base, offset, length, value), to: Match.Advanced
defdelegate accept(builder), to: Match.Verdicts
defdelegate snat_to(builder, ip, opts), to: Match.NAT
defdelegate meter_update(builder, key, set, rate, per, opts), to: Match.Meter
endSub-Module Organization:
NFTables.Expr
├── Match.IP - IP address matching
├── Match.Port - Port matching (protocol-aware)
├── Match.TCP - TCP-specific (flags, options)
├── Match.Layer2 - Interface, MAC, VLAN
├── Match.CT - Connection tracking
├── Match.Advanced - Mark, DSCP, raw payload, socket, OSF
├── Match.Protocols - SCTP, DCCP, GRE
├── Match.Actions - Counter, log, limit, mark operations
├── Match.Verdicts - Accept, drop, reject, jump, etc.
├── Match.NAT - SNAT, DNAT, masquerade, redirect
└── Match.Meter - Dynamic sets, per-key rate limitingProtocol-Aware Port Matching:
# Match.Port automatically uses protocol context
expr()
|> tcp() # Sets protocol: :tcp
|> dport(22) # Uses TCP protocol for port match
|> accept()
# Internally:
def tcp(builder), do: %{builder | protocol: :tcp}
def dport(builder, port) do
protocol = case builder.protocol do
:tcp -> "tcp"
:udp -> "udp"
:sctp -> "sctp"
_ -> "tcp" # default
end
expr = Expr.payload_match(protocol, "dport", port)
add_expr(builder, expr)
endExpression List Building:
import NFTables.Expr
# Building a rule
ssh_rule = expr()
|> tcp() # protocol: :tcp
|> dport(22) # expr_list: [match tcp.dport]
|> ct_state([:new]) # expr_list: [match tcp.dport, match ct.state]
|> limit(10, :minute, burst: 5) # expr_list: [match, match, limit]
|> log("SSH: ") # expr_list: [match, match, limit, log]
|> accept() # expr_list: [match, match, limit, log, accept]
# Each function adds to expr_list:
def add_expr(builder, expr) when is_map(expr) do
%{builder | expr_list: builder.expr_list ++ [expr]}
end
# Extract expressions:
to_expr(ssh_rule) # → [%{match: ...}, %{match: ...}, %{limit: ...}, %{log: ...}, %{accept: nil}]2. NFTables.Expr (High-Level Fluent API)
Design:
- Simpler, more concise function names
- All functionality in one module
- No sub-module delegation
- Same expression list pattern
Structure:
defmodule NFTables.Expr do
defstruct [
family: :inet,
table: nil, # Optional table context
chain: nil, # Optional chain context
expr_list: [], # Expression list
comment: nil
]
# Shorter, simpler names
def new(opts \\ []), do: %__MODULE__{...}
def protocol(rule, proto), do: add_expr(rule, Expr.meta_match("l4proto", proto))
def source(rule, ip), do: add_expr(rule, Expr.payload_match("ip", "saddr", ip))
def port(rule, port), do: dport(rule, port)
def state(rule, states), do: add_expr(rule, Expr.ct_match("state", states))
def accept(rule), do: add_expr(rule, Expr.verdict("accept"))
endComparison:
# NFTables.Expr (verbose, explicit)
import NFTables.Expr
source_ip("10.0.0.1") |> dest_ip("192.168.1.1") |> ct_state([:new])
# NFTables.Expr (concise)
source_ip("10.0.0.1") |> dest_ip("192.168.1.1") |> ct_state([:new])
# Both produce same expr_listWhen to use each:
- Match: More organized for large codebases, explicit naming, sub-module namespacing
- Rule: Quicker for small scripts, less import clutter, simpler names
Composition Patterns
Both Builder and Match/Rule use functional composition patterns extensively.
1. Pipe-Based Composition
Core principle: Every function returns an updated struct, enabling chaining via |>.
# Builder composition
config = Builder.new(family: :inet)
|> NFTables.add(table: "filter")
|> NFTables.add(chain: "INPUT", type: :filter, hook: :input)
|> NFTables.add(chain: "FORWARD", type: :filter, hook: :forward)
|> NFTables.add(chain: "OUTPUT", type: :filter, hook: :output)
# Match composition
ssh_rule = expr()
|> tcp()
|> dport(22)
|> ct_state([:new])
|> limit(10, :minute)
|> accept()
# Combining both
NFTables.add(table: "filter")
|> NFTables.add(chain: "INPUT")
|> NFTables.add(rule: ssh_rule)
|> NFTables.submit(pid: pid)2. Functional Transformation
Immutability: Structs are never mutated, only transformed:
# Bad (mutation - not used in library)
builder.table = "filter"
# Good (transformation - used everywhere)
builder = %{builder | table: "filter"}
# Better (helper function)
defp update_table(builder, table), do: %{builder | table: table}3. List Accumulation
Both Builder and Match accumulate lists functionally:
# Builder accumulates commands
defp add_command(builder, command) do
%{builder | commands: builder.commands ++ [command]}
end
# Match accumulates expressions
defp add_expr(builder, expr) do
%{builder | expr_list: builder.expr_list ++ [expr]}
end4. Higher-Order Composition
Rules can be composed with Enum functions:
# Generate multiple similar rules
ports = [80, 443, 8080, 8443]
rules = Enum.map(ports, fn port ->
tcp() |> dport(port) |> accept()
end)
# Add all rules to builder
builder = Enum.reduce(rules, builder, fn rule, acc ->
NFTables.add(acc, rule: rule)
end)
# Or use batch rules
builder |> NFTables.add(rules: rules)5. Partial Application Patterns
Create reusable rule fragments:
# Create base rule builder
defmodule MyFirewall.Rules do
import NFTables.Expr
# Partial rule - returns fn
def with_rate_limit(rate, per) do
fn rule -> rule |> limit(rate, per, burst: rate * 2) end
end
# Partial rule - returns fn
def with_logging(prefix) do
fn rule -> rule |> log(prefix) end
end
# Compose partials
def ssh_rule do
expr()
|> tcp()
|> dport(22)
|> ct_state([:new])
|> with_rate_limit(10, :minute).()
|> with_logging("SSH: ").()
|> accept()
end
end6. Module-Based Composition
Sub-modules compose through delegation:
# Match.IP is a separate module
defmodule NFTables.Expr.IP do
def source_ip(builder, ip) do
expr = Expr.payload_match("ip", "saddr", ip)
Match.add_expr(builder, expr)
end
end
# Match delegates to it
defmodule NFTables.Expr do
defdelegate source_ip(builder, ip), to: Match.IP
end
# User composes naturally
source_ip("10.0.0.1") |> dest_ip("192.168.1.1")Data Flow and Execution Pipeline
Write Operation Flow
User Code
↓
NFTables.add(table: "filter")
↓ (accumulates Elixir maps)
Builder{commands: [%{add: %{table: %{...}}}]}
↓
NFTables.submit(pid: pid)
↓
Local.submit(builder, pid)
↓ (converts to JSON)
Jason.encode!(%{nftables: [...]})
↓
NFTables.Port.commit(pid, json, timeout)
↓ (sends length-prefixed packet)
Zig Port Process
↓ (calls libnftables)
libnftables.nft_run_cmd_from_buffer()
↓ (generates netlink messages)
Linux Kernel Netlink
↓ (applies changes)
nftables Subsystem
↓ (response flows back)
Local.submit/2
↓ (decodes JSON)
{:ok, response_map}
↓ (returns to user)
:okRead Operation Flow
User Code
↓
Query.list_tables(family: :inet)
↓ (pure function returns map)
%{nftables: [%{list: %{tables: %{family: :inet}}}]}
↓
|> Local.submit(pid: pid)
↓ (encodes JSON, sends to port)
Port → libnftables → Kernel
↓ (kernel returns data)
Port → Local
↓ (decodes JSON)
{:ok, %{nftables: [%{table: %{...}}, ...]}}
↓
|> Decoder.decode()
↓ (transforms to idiomatic Elixir)
{:ok, %{tables: [%{name: "filter", family: :inet}]}}
↓
User CodeComplete Example
# 1. Build configuration (pure, no side effects)
import NFTables.Expr
alias NFTables.Builder
ssh_rule = tcp() |> dport(22) |> accept()
http_rule = tcp() |> dport(80) |> accept()
config = Builder.new(family: :inet)
|> NFTables.add(table: "filter")
|> NFTables.add(chain: "INPUT", type: :filter, hook: :input, policy: :drop)
|> NFTables.add(rule: ssh_rule)
|> NFTables.add(rule: http_rule)
# At this point:
# config.commands = [
# %{add: %{table: %{family: :inet, name: "filter"}}},
# %{add: %{chain: %{family: :inet, table: "filter", name: "INPUT", ...}}},
# %{add: %{rule: %{family: :inet, table: "filter", chain: "INPUT", expr: [...]}}},
# %{add: %{rule: %{family: :inet, table: "filter", chain: "INPUT", expr: [...]}}}
# ]
# 2. Execute (side effect - applies to kernel)
{:ok, pid} = NFTables.Port.start_link()
NFTables.submit(config, pid: pid)
# Internally:
# 1. Local.submit(%{nftables: config.commands}, pid)
# 2. Jason.encode!(...) → JSON string
# 3. NFTables.Port.commit(pid, json, 5000)
# 4. Port sends to libnftables
# 5. libnftables applies via netlink
# 6. Response flows back
# 7. Local returns :ok or {:error, reason}
# 3. Query state (read operation)
{:ok, rules} = Query.list_rules("filter", "INPUT")
|> Local.submit(pid: pid)
|> Decoder.decode()
# rules = %{rules: [
# %{table: "filter", chain: "INPUT", handle: 1, expr: [...]},
# %{table: "filter", chain: "INPUT", handle: 2, expr: [...]}
# ]}Requestor Pattern
The Requestor pattern provides a flexible, behaviour-based mechanism for submitting Builder configurations to custom handlers. This enables use cases beyond local execution via NFTables.Port.
Overview
Instead of always executing locally via NFTables.submit(builder, pid: pid), you can define custom "requestors" that handle submission in different ways:
# Traditional local execution
NFTables. NFTables.add(table: "filter")
|> NFTables.submit(pid: pid) # Goes to NFTables.Port
# Custom requestor submission
Builder.new(requestor: MyApp.RemoteRequestor)
|> NFTables.add(table: "filter")
|> NFTables.submit(node: :firewall@server) # Goes to custom handlerThe NFTables.Requestor Behaviour
Requestors implement a simple behaviour with one callback:
@callback submit(builder :: Builder.t(), opts :: keyword()) ::
:ok | {:ok, term()} | {:error, term()}Use Cases
1. Remote Execution
Submit configurations to remote nodes:
defmodule MyApp.RemoteRequestor do
@behaviour NFTables.Requestor
@impl true
def submit(builder, opts) do
node = Keyword.fetch!(opts, :node)
commands = Builder.to_map(builder)
case :rpc.call(node, NFTables.Local, :execute, [commands, opts]) do
{:ok, result} -> {:ok, result}
{:error, reason} -> {:error, {:remote_failure, reason}}
{:badrpc, reason} -> {:error, {:rpc_error, reason}}
end
end
end
# Usage
builder = Builder.new(requestor: MyApp.RemoteRequestor)
|> NFTables.add(table: "filter")
|> NFTables.submit(node: :firewall01@datacenter)2. Audit Logging
Log all firewall changes before applying:
defmodule MyApp.AuditRequestor do
@behaviour NFTables.Requestor
@impl true
def submit(builder, opts) do
audit_id = Keyword.fetch!(opts, :audit_id)
user = Keyword.fetch!(opts, :user)
# Log the change
MyApp.AuditLog.record(%{
id: audit_id,
user: user,
commands: Builder.to_map(builder),
timestamp: DateTime.utc_now()
})
# Then execute locally
pid = Keyword.get(opts, :pid) || Process.whereis(NFTables.Port)
NFTables.Local.submit(Builder.to_map(builder), pid: pid)
end
end
# Usage
builder = Builder.new(requestor: MyApp.AuditRequestor)
|> NFTables.add(table: "filter")
|> NFTables.submit(audit_id: UUID.generate(), user: "admin")3. Testing/Capture
Capture configurations without applying:
defmodule MyApp.CaptureRequestor do
@behaviour NFTables.Requestor
@impl true
def submit(builder, _opts) do
# Send to test process for inspection
send(self(), {:nftables_config, builder})
:ok
end
end
# In tests
test "builds correct firewall config" do
builder = Builder.new(requestor: MyApp.CaptureRequestor)
|> NFTables.add(table: "filter")
|> NFTables.add(chain: "INPUT")
|> NFTables.submit()
assert_received {:nftables_config, builder}
assert length(builder.commands) == 2
end4. Conditional/Environment-Based Execution
Different strategies per environment:
defmodule MyApp.SmartRequestor do
@behaviour NFTables.Requestor
@impl true
def submit(builder, opts) do
case Application.get_env(:my_app, :env) do
:prod -> execute_with_approval(builder, opts)
:staging -> execute_with_logging(builder, opts)
:dev -> log_only(builder, opts)
end
end
defp execute_with_approval(builder, opts) do
# Require manual approval in production
MyApp.ApprovalSystem.request_approval(builder)
|> case do
:approved -> execute_locally(builder, opts)
:denied -> {:error, :approval_denied}
end
end
defp execute_with_logging(builder, opts) do
Logger.info("Applying firewall changes: #{inspect(builder)}")
execute_locally(builder, opts)
end
defp log_only(builder, _opts) do
IO.inspect(builder, label: "Would apply")
:ok
end
defp execute_locally(builder, opts) do
pid = Keyword.get(opts, :pid) || Process.whereis(NFTables.Port)
NFTables.Local.submit(Builder.to_map(builder), pid: pid)
end
endBuilder Integration
The requestor field is integrated into the Builder struct:
defstruct family: :inet,
requestor: nil, # New field
table: nil,
chain: nil,
collection: nil,
type: nil,
spec: nil,
commands: []Three Ways to Set Requestor
1. At Builder Creation
builder = Builder.new(family: :inet, requestor: MyApp.RemoteRequestor)2. Via set_requestor/2
builder = NFTables.add(table: "filter")
|> Builder.set_requestor(MyApp.AuditRequestor)3. Override at Submit Time
builder = Builder.new(requestor: MyApp.DefaultRequestor)
|> NFTables.add(table: "filter")
|> NFTables.submit(requestor: MyApp.SpecialRequestor, priority: :high)Submit Functions
submit/1 - Use Builder's Requestor
builder = Builder.new(requestor: MyApp.RemoteRequestor)
|> NFTables.add(table: "filter")
|> NFTables.submit() # Uses MyApp.RemoteRequestor with empty optsRaises ArgumentError if no requestor is configured.
submit/2 - With Options or Override
# Pass options to requestor
builder |> NFTables.submit(node: :remote_host, timeout: 10_000)
# Override requestor
builder |> NFTables.submit(requestor: MyApp.SpecialRequestor, opt: "value")
# Use without pre-configured requestor
NFTables. NFTables.add(table: "filter")
|> NFTables.submit(requestor: MyApp.TestRequestor)Validation
The submit/2 function validates that the requestor module:
- Is an atom (module name)
- Exports
submit/2function
# This will raise ArgumentError
NFTables.submit(builder, requestor: NonExistentModule)
# => "Module NonExistentModule does not implement NFTables.Requestor behaviour"Comparison: execute/2 vs submit/2
| Feature | execute/2 | submit/2 |
|---|---|---|
| Target | Local NFTables.Port (pid required) | Custom requestor module |
| Flexibility | Fixed: always calls libnftables | Fully customizable handler |
| Configuration | Pass pid | Pass requestor module |
| Use Cases | Direct local firewall changes | Remote, testing, audit, conditional |
| Options | pid:, timeout: | Requestor-specific (any opts) |
| Return | :ok | {:error, reason} | :ok | {:ok, result} | {:error, reason} |
Both approaches can coexist in the same codebase:
# Local execution for immediate changes
NFTables. NFTables.add(table: "filter")
|> NFTables.submit(pid: pid)
# Remote execution for distributed deployments
Builder.new(requestor: MyApp.RemoteRequestor)
|> NFTables.add(table: "filter")
|> NFTables.submit(node: :firewall@remote)Design Rationale
- Behaviour-Based: Uses Elixir behaviours for compile-time contract checking
- Optional: Requestor field defaults to
nil, maintaining backward compatibility - Runtime Validation: Validates
submit/2export at runtime for flexibility - Mirrors execute/2: Familiar pattern for users
- Options Passthrough: Opts go directly to requestor for maximum flexibility
Example: Multi-Node Firewall Deployment
defmodule MyApp.ClusterRequestor do
@behaviour NFTables.Requestor
@impl true
def submit(builder, opts) do
nodes = Keyword.get(opts, :nodes, [:firewall01, :firewall02, :firewall03])
strategy = Keyword.get(opts, :strategy, :parallel)
case strategy do
:parallel -> apply_parallel(builder, nodes)
:serial -> apply_serial(builder, nodes)
:canary -> apply_canary(builder, nodes)
end
end
defp apply_parallel(builder, nodes) do
commands = Builder.to_map(builder)
results = Task.async_stream(nodes, fn node ->
:rpc.call(node, NFTables.Local, :execute, [commands, []])
end)
|> Enum.to_list()
case Enum.all?(results, fn {:ok, {:ok, _}} -> true; _ -> false end) do
true -> {:ok, :all_nodes_updated}
false -> {:error, :some_nodes_failed}
end
end
# ... other strategies
end
# Usage
builder = Builder.new(requestor: MyApp.ClusterRequestor)
|> NFTables.add(table: "filter")
|> NFTables.add(chain: "INPUT")
|> NFTables.add(rule: block_rule)
|> NFTables.submit(strategy: :canary, nodes: [:fw01, :fw02, :fw03])Design Principles
1. Separation of Concerns
Each module has a single, well-defined responsibility:
- Builder: Accumulate configuration commands
- Match/Rule: Build rule expressions
- Local, Requestor: Handle JSON and Port communication
- Query: Build read commands
- Decoder: Transform responses
- Expr: Low-level expression builders
- Port: Manage native process lifecycle
2. Pure Functions by Default
Most functions are pure (no side effects):
# Pure - returns new struct
NFTables.add(builder, table: "filter")
# Pure - returns new struct
tcp() |> dport(22)
# Pure - returns command map
Query.list_tables(family: :inet)
# Side effect - only when explicitly called
NFTables.submit(builder, pid: pid)3. Composition Over Inheritance
The library uses functional composition instead of OOP inheritance:
# Not classes with inheritance
# But functions that compose
expr()
|> tcp() # Adds protocol context
|> dport(22) # Adds port match
|> ct_state([:new]) # Adds state match
|> accept() # Adds verdict4. Explicit Over Implicit
Behavior is explicit and predictable:
# Explicit execution
NFTables.submit(builder, pid: pid) # Clear when side effects occur
# Explicit conversion (though now automatic)
to_expr(rule) # Clear when format changes
# Explicit family
Builder.new(family: :inet6) # No hidden defaults5. Data-Driven Architecture
Configuration is just data until executed:
# Just data structures
config = NFTables.add(table: "filter")
# Can be inspected
IO.inspect(config.commands)
# Can be serialized
json = Builder.to_json(config)
# Can be tested without side effects
assert length(config.commands) == 1
# Only becomes "real" when executed
NFTables.submit(config, pid: pid)6. Progressive Disclosure
Multiple API levels for different needs:
# Level 1: High-level convenience (easiest)
NFTables.allow_ssh(pid)
NFTables.setup_basic_firewall(pid)
# Level 2: Builder + Match (flexible)
ssh_rule = tcp() |> dport(22) |> accept()
NFTables.add(rule: ssh_rule) |> NFTables.submit(pid: pid)
# Level 3: Direct expression building (full control)
expr = Expr.payload_match("tcp", "dport", 22)
NFTables.add(builder, rule: [expr, Expr.verdict("accept")])
# Level 4: Raw JSON (maximum control)
json = ~s({"nftables":[{"add":{"rule":{...}}}]})
Local.submit(Jason.decode!(json), pid: pid)7. Fail Fast, Fail Clearly
Errors are caught early with clear messages:
# Invalid priority combination
NFTables.add(builder, set: "s1", map: "m1")
# ** (ArgumentError) Ambiguous object: only use one of [:set, :map, ...]
# Missing required field
NFTables.add(builder, rule: [...]) # No table/chain context
# ** (ArgumentError) table must be specified as an option or set via set_table/2
# Invalid command/object combination
NFTables.flush(builder, element: [...])
# ** (ArgumentError) Command :flush is not valid for :element. Valid commands: add, deleteSummary
The NFTables library architecture is built on:
- Isolated Port Process - Fault isolation, security boundary, technology isolation
- Layered Design - Clear boundaries between native/Elixir, pure/effectful code
- Functional Composition - Immutable data structures, pure functions, pipe operators
- Unified APIs - Builder for all objects, Match/Rule for expressions
- Data-Driven - Configuration is data until explicitly executed
- Progressive Disclosure - Multiple abstraction levels for different needs
This architecture provides a robust, maintainable, and user-friendly interface to Linux nftables while maintaining safety, testability, and flexibility.