Packages

Elixir library for working with Linux nftables rules. Provides high-level APIs for building tables, chains, rules, maps and sets. Works with NFTables.Port for communicating with the kernel firewall.

Current section

Files

Jump to
nftables usage-rules.md
Raw

usage-rules.md

# NFTables Usage Rules

## Overview

NFTables provides a pure functional API for building nftables firewall rules via Elixir.

---

## Core Principles

### 1. Pure Functional Expression API

Build expressions as **pure data** with **no side effects**.

```elixir
use NFTables

# Build expression (no side effects)
rule = tcp() |> dport(22) |> accept()

# Execute separately
{:ok, pid} = NFTables.Port.start_link()

NFTables.add(table: "filter", family: :inet)
|> NFTables.add(chain: "INPUT", type: :filter, hook: :input)
|> NFTables.add(rule: rule)
|> NFTables.submit(pid: pid)
```

### 2. Build/Submit Separation

- **NFTables.Expr and its submodules** - Build pure expression data (no execution)
- **NFTables** - Constructs nftables configurations (no execution)
- **NFTables.submit** - Sends configurations to kernel (execution only)

### 3. Dual-Arity Functions

```elixir
tcp() |> dport(22) |> accept()               # Start new
expr() |> tcp() |> dport(22) |> accept()     # Continue existing
```

### 4. Context Tracking

NFTables automatically tracks table/chain context:

```elixir
NFTables.add(table: "filter", chain: "INPUT")
|> NFTables.add(rule: tcp() |> dport(22) |> accept())
|> NFTables.add(rule: tcp() |> dport(80) |> accept())
```

---

## Key Modules

**NFTables** - Main API: `add/1-2`, `delete/1-2`, `insert/1-2`, `replace/1-2`,
`rename/1-2`, `flush/1-2`, `submit/1-2`, `to_json/1`, `to_map/1`

**NFTables.Expr** - The struct and the pipeline: `expr/0-1`, `to_list/1`,
`add_expr/2`, `comment/2`, `set_protocol/2`. It does **not** export the
matchers — those live in submodules, and `use NFTables` imports them:

| Module | Exports |
|--------|---------|
| `NFTables.Expr.TCP` | `tcp/0-1`, `tcp_flags/2-3` |
| `NFTables.Expr.UDP` | `udp/0-1` |
| `NFTables.Expr.Port` | `dport/2`, `sport/2`, `port/2` |
| `NFTables.Expr.IP` | `source/1-2`, `dest/1-2`, `ttl/2-3` |
| `NFTables.Expr.CT` | `state/1-2`, `ct_state/1-2`, `connmark/1-2` |
| `NFTables.Expr.Verdict` | `accept/0-1`, `drop/0-1`, `reject/0-2`, `jump/1-2`, `return_from_chain/0-1` |
| `NFTables.Expr.Actions` | `counter/0-1`, `log/1-3`, `limit/2-4`, `rate_limit/2-4`, `set_mark/1-2` |
| `NFTables.Expr.Sets` | `set/2-3` |

**NFTables.Port** - Process management: `start_link/0-1`, `stop/1`, `commit/2-3`

**NFTables.Policy** - Pre-built policies, each taking and returning a
builder: `accept_loopback/0-2`, `accept_established/0-2`, `allow_ssh/0-2`,
`allow_http/0-2`, `stateful/0-2`, `deny_all/0-2`. Note
`setup_basic_firewall/1-2` is the exception — it takes a **pid** and
submits.

---

## Expression Building

### Protocol and Port Matching

```elixir
use NFTables

tcp() |> dport(80) |> accept()               # Single port
udp() |> dport(53) |> accept()               # UDP
tcp() |> dport(8000..8999) |> accept()       # Port range
```

`dport/2` takes an integer or a range — nothing else. For a set of
unrelated ports, use a named set and match against it, which is also
what you want operationally since the set can be updated without
rebuilding the rule:

```elixir
NFTables.add(table: "filter", family: :inet)
|> NFTables.add(set: "web_ports", type: :inet_service)
|> NFTables.add(element: [80, 443, 8080], set: "web_ports")
|> NFTables.add(chain: "INPUT", type: :filter, hook: :input)
|> NFTables.add(rule: tcp() |> set("web_ports", :dport) |> accept())
```

### State Tracking

```elixir
state([:established, :related]) |> accept()   # Accept established
ct_state([:invalid]) |> drop()                 # Drop invalid
ct_state([:new]) |> log() |> accept()          # Log new
```

### Actions and Verdicts

```elixir
# Terminal verdicts (end processing)
accept()   # Accept packet
drop()     # Silently drop
reject()   # Reject with ICMP

# Non-terminal actions (continue processing)
counter() |> log("SSH: ") |> accept()         # Chain actions
limit(10, :minute) |> accept()                # Rate limiting
```

`log/1-3` takes the prefix as its first argument. Passing none now
raises `FunctionClauseError` rather than binding the expression struct
as the prefix and building a rule that logs nonsense.

A `limit` matches while the packet is **within** budget, so
`limit(10, :minute) |> accept()` admits the allowance and leaves the
excess to the chain policy. `limit |> drop()` is the inverse of what it
looks like: it drops the first ten a minute and passes the rest.

To match the excess instead, pass `over: true`:

```elixir
limit(10, :minute, over: true) |> drop()   # drops what exceeds the rate
```

---

## Common Patterns

### Basic Firewall

```elixir
use NFTables
{:ok, pid} = NFTables.Port.start_link()

NFTables.add(table: "filter", family: :inet)
|> NFTables.add(chain: "INPUT", type: :filter, hook: :input, policy: :drop)
|> NFTables.add(rule: state([:established, :related]) |> accept())
|> NFTables.add(rule: tcp() |> dport(22) |> accept())
|> NFTables.submit(pid: pid)
```

### SSH with Rate Limiting

```elixir
rule = tcp() |> dport(22) |> limit(10, :minute) |> accept()

NFTables.add(table: "filter", chain: "INPUT")
|> NFTables.add(rule: rule)
|> NFTables.submit(pid: pid)
```

### Multiple Rules in One Submission

Add each rule with its own `add(rule:)`. The context carries, so the
table and chain are named once, and the whole list is submitted as a
single atomic batch:

```elixir
NFTables.add(table: "filter", chain: "INPUT")
|> NFTables.add(rule: tcp() |> dport(22) |> accept())
|> NFTables.add(rule: tcp() |> dport(80) |> accept())
|> NFTables.add(rule: tcp() |> dport(443) |> accept())
|> NFTables.submit(pid: pid)
```

Do **not** use `add(rules: [...])`. It emits one rule whose expression
list contains the others nested inside it — a malformed ruleset rather
than three rules. Tracked in #28.

### Using Policy Module

```elixir
alias NFTables.Policy

NFTables.add(table: "filter")
|> NFTables.add(chain: "INPUT", type: :filter, hook: :input, policy: :drop)
|> Policy.accept_loopback()
|> Policy.accept_established()
|> Policy.allow_ssh(rate_limit: 10)
|> NFTables.submit(pid: pid)
```

---

## Best Practices

### 1. Use `use NFTables`

```elixir
use NFTables  # imports NFTables.Expr and every Expr.* matcher module
rule = tcp() |> dport(22) |> accept()
```

`import NFTables.Expr` alone is not enough: it brings in `expr/1`,
`to_list/1` and `add_expr/2`, but every matcher — `tcp/0`, `dport/2`,
`accept/0` — lives in a submodule. Importing only the parent gives
`CompileError: undefined function tcp/0`. Import the submodules you need
explicitly if you prefer a narrow scope:

```elixir
use NFTables
```

### 2. Use Context Tracking

```elixir
# Good - context tracked automatically
NFTables.add(table: "filter", chain: "INPUT")
|> NFTables.add(rule: rule1)
|> NFTables.add(rule: rule2)
```

### 3. Build Expressions as Data

```elixir
ssh_rule = tcp() |> dport(22) |> accept()
http_rule = tcp() |> dport(80) |> accept()

NFTables.add(table: "filter", chain: "INPUT")
|> NFTables.add(rule: ssh_rule)
|> NFTables.add(rule: http_rule)
|> NFTables.submit(pid: pid)
```

### 4. Test Without Side Effects

```elixir
rule = tcp() |> dport(22) |> accept()
assert %NFTables.Expr{} = rule  # Test without kernel access
NFTables.add(table: "filter", chain: "INPUT")
|> NFTables.add(rule: rule)
|> NFTables.submit(pid: pid)
```

### 5. Clean Up in Tests

```elixir
use ExUnit.Case
use NFTables

setup do
  {:ok, pid} = NFTables.Port.start_link()
  on_exit(fn ->
    NFTables.delete(table: "test") |> NFTables.submit(pid: pid)
    NFTables.Port.stop(pid)
  end)
  {:ok, pid: pid}
end
```

---

## Anti-Patterns

### ❌ Don't Use Builder Directly

```elixir
alias NFTables.Builder

Builder.new() |> Builder.apply_with_opts(:add, table: "filter")  # Bad
NFTables.add(table: "filter")                                    # Good
```

### ❌ Don't Repeat Context

```elixir
# Bad - context repeated on every call
NFTables.add(table: "filter", chain: "INPUT", rule: rule1)
NFTables.add(table: "filter", chain: "INPUT", rule: rule2)

# Good - context named once and carried
NFTables.add(table: "filter", chain: "INPUT")
|> NFTables.add(rule: rule1)
|> NFTables.add(rule: rule2)
```

### ❌ Don't Mix Side Effects with Building

```elixir
def build_rule(pid), do: NFTables.add(table: "f") |> NFTables.submit(pid: pid)  # Bad
def build_rule, do: tcp() |> dport(22) |> accept()              # Good
```

---

## See Also

- **README.md** - Main documentation
- **Module docs** - `h NFTables`, `h NFTables.Expr`, `h NFTables.Policy`
- **dev_docs/** - Architecture, quick reference, advanced features
- **examples/** - Working code examples