# Examples

Hands-on examples of `Linx.Netfilter` — the modern firewall surface
(nf_tables) via nfnetlink, plus live ruleset monitoring and NFLOG
packet events.

Most mutating operations need `CAP_NET_ADMIN` (root in practice).
Read-only operations against the kernel (`pull`, `get_gen`) also
need it on current kernels — `nf_tables_getgen` is gated on
`CAP_NET_ADMIN` for security reasons. Opening the netlink socket
itself is unprivileged.

> 🚧 **Skeleton.** Primitives are still in flight; sections fill
> in as milestones ship.

## Detecting nfnetlink support

```elixir
Linx.Netfilter.supported?()
# => true
```

`supported?/0` returns true iff a `NETLINK_NETFILTER` socket can
be opened in the current netns. Universal on any kernel built with
`CONFIG_NETFILTER_NETLINK=y` (the default on modern Linux). Doesn't
verify that the caller has `CAP_NET_ADMIN` — that surfaces from
the actual verb call (`{:error, %Linx.Netfilter.Error{errno: :eperm}}`).

## Opening the transport socket

```elixir
{:ok, sock} = Linx.Netlink.Nfnl.open()
sock.protocol
# => 12  # NETLINK_NETFILTER
sock.netns
# => :host
:ok = Linx.Netlink.Socket.close(sock)
```

For another netns (typically a `Linx.Process`-spawned workload):

```elixir
{:ok, sock} = Linx.Netlink.Nfnl.open({:pid, host_pid})
sock.netns
# => {:pid, 12345}
```

The socket is pinned to that netns for its lifetime — operations
through it land in the target's nftables instance, not the host's.

## Reading the generation counter

The kernel maintains a monotonic 32-bit counter that bumps on every
successful ruleset commit. It's the key to optimistic concurrency
(the `:reconcile` mode threads it through batches) and to
exactly-once-from-snapshot monitoring (the Monitor buckets multicast events
by gen id).

```elixir
{:ok, sock} = Linx.Netlink.Nfnl.open()
Linx.Netlink.Nfnl.Codec.get_gen(sock)
# => {:ok, %{id: 1247, proc_pid: 4583, proc_name: "nft"}}
```

`:id` is the generation counter; `:proc_pid` + `:proc_name`
attribute the most recent committer (free observability — kernel
fills these in itself).

## Building a ruleset with the pipeline DSL

Every `Linx.Netfilter` value is plain data — a `%Ruleset{}` is what
you push to the kernel, what you pull back, and what you diff
against. The value types and the validator-setter
pipeline DSL build the ruleset; the wire codec carries it to the kernel.

```elixir
alias Linx.Netfilter.{Expr, Rule, Ruleset, Set, Verdict, Vmap}

ruleset =
  Ruleset.new()
  |> Ruleset.add_table!(:inet, "myapp", flags: [:owner])
  |> Ruleset.add_chain!("myapp", "input",
       type: :filter, hook: :input, priority: 0, policy: :drop)
  |> Ruleset.add_chain!("myapp", "ssh_in")
  |> Ruleset.add_set!("myapp", Set.new!("blocklist", key_type: :ipv4_addr))
  |> Ruleset.add_map!("myapp",
       Vmap.new!("port_dispatch",
         key_type: :inet_service,
         elements: [{22, {:jump, "ssh_in"}}, {80, :accept}, {443, :accept}]))
  |> Ruleset.add_rule!("myapp", "input",
       [Expr.new(:counter), {:jump, "ssh_in"}],
       tag: :try_ssh)
  |> Ruleset.add_rule!("myapp", "ssh_in", [:accept])
```

Every mutator comes in two flavours — `add_table/4` returns
`{:ok, ruleset} | {:error, _}`, while `add_table!/4` returns the
ruleset or raises. Use the bang variant for inline pipeline
construction; the plain form composes through `with` blocks.

## Value validation

Validators reject malformed input at construction:

```elixir
Linx.Netfilter.Chain.new("c", type: :nat, hook: :prerouting, priority: 0)
|> elem(1)
|> Linx.Netfilter.Chain.validate_for_family(:arp)
# => {:error, {:bad_chain, {:type_not_valid_for_family, %{type: :nat, family: :arp}}}}

Linx.Netfilter.Vmap.new("dispatch",
  key_type: :inet_service,
  elements: [{22, :not_a_verdict}])
# => {:error, {:bad_map, {:bad_element, _, {:bad_verdict, :not_a_verdict}}}}

Linx.Netfilter.Chain.new("c", type: :filter, hook: :ingress, priority: 0)
# => {:error, {:bad_chain, {:device_required_for_hook, :ingress}}}
```

Family-aware checks (chain-type/family, chain-hook/family,
type+hook compatibility) run when the chain is added to a table —
`Linx.Netfilter.Ruleset.add_chain/4` propagates the family from
the table.

## Tag-as-converged-identity

Rules carry a `:tag` (atom) and a `:handle` (kernel-assigned,
`nil` until pushed). The `:reconcile` mode uses the tag as the
stable identity across pushes:

```elixir
ruleset =
  Ruleset.new()
  |> Ruleset.add_table!(:inet, "myapp")
  |> Ruleset.add_chain!("myapp", "input", type: :filter, hook: :input, priority: 0)
  |> Ruleset.add_rule!("myapp", "input", [:accept], tag: :allow_all)

# Re-adding the same tag is rejected at the value-type layer:
Ruleset.add_rule(ruleset, "myapp", "input", [:drop], tag: :allow_all)
# => {:error, {:bad_rule, {:duplicate_tag, :allow_all}}}
```

## Creating a table

Owner-flag is the default: when the socket closes, the kernel
atomically destroys the table.

```elixir
{:ok, sock} = Linx.Netlink.Nfnl.open()
{:ok, ruleset} = Linx.Netfilter.create_table(sock, "myapp", family: :inet)

# The returned ruleset has just the one table — chains and rules
# can be added with the pipeline DSL, then `push/2`-ed back.
```

Opt out of owner cleanup with `persist: true`:

```elixir
{:ok, ruleset} = Linx.Netfilter.create_table(sock, "myapp", persist: true)
# Table survives socket close; clean up with `nft delete table` or
# a future Linx helper, once destroy verbs land.
```

## Pushing a complete ruleset (`push/2 :replace`)

Build the ruleset with the pipeline DSL, then push it as one
atomic batch. The kernel sees `DESTROYTABLE` (silent if missing)
then `NEWTABLE` + all chains + all rules — old state for the named
table is replaced cleanly.

```elixir
alias Linx.Netfilter.{Expr, Rule, Ruleset, Verdict}

ruleset =
  Ruleset.new()
  |> Ruleset.add_table!(:inet, "myapp", flags: [:owner])
  |> Ruleset.add_chain!("myapp", "input",
       type: :filter, hook: :input, priority: 0, policy: :drop)
  |> Ruleset.add_chain!("myapp", "ssh_in")
  |> Ruleset.add_rule!("myapp", "input",
       Rule.build!([
         Expr.ct(:state),
         Expr.bitwise(<<6::big-32>>, <<0::big-32>>),  # mask: established | related
         Expr.cmp(:neq, <<0::big-32>>),
         Verdict.accept()
       ], tag: :ct_established))
  |> Ruleset.add_rule!("myapp", "input",
       Rule.build!([
         Expr.payload(:tcp_dport),
         Expr.cmp(:eq, <<22::big-16>>),
         Verdict.jump("ssh_in")
       ], tag: :try_ssh))
  |> Ruleset.add_rule!("myapp", "ssh_in", [Verdict.accept()])

:ok = Linx.Netfilter.push(sock, ruleset)
```

## Pulling a ruleset back

```elixir
# Pull the whole netns:
{:ok, %Ruleset{} = current} = Linx.Netfilter.pull(sock)
Ruleset.tables(current)
# => [{:inet, "myapp", %Table{...}}, ...]

# Pull just one table by {family, name}:
{:ok, %Ruleset{}} = Linx.Netfilter.pull(sock, {:inet, "myapp"})

# Nonexistent table:
{:error, %Linx.Netfilter.Error{errno: :enoent}} =
  Linx.Netfilter.pull(sock, {:inet, "ghost"})
```

Rules round-trip through `pull/2` with their expressions decoded
back into `%Expr{}` shape — `Expr.payload(:tcp_dport)` becomes
`%Expr{name: :payload, data: %{base: :transport, offset: 2, len: 2, dreg: 1}}`
after the wire trip. Kernel-assigned handles populate `:handle`.

## Owner-flag cleanup

```elixir
{:ok, sock} = Linx.Netlink.Nfnl.open()
{:ok, _} = Linx.Netfilter.create_table(sock, "ephemeral")
# … push chains and rules into it …

Linx.Netlink.Socket.close(sock)
# Kernel atomically destroys the table and everything inside it.
# No manual cleanup, no leaked rules.
```

Same shape as every Linx subsystem: BEAM owns the resource;
BEAM crash → kernel reaps it. The unique Linx shape that no other
firewall manager exposes naturally.

## DNAT port-forward

Forward incoming TCP/8080 to an internal host's TCP/80. The
`Expr.dnat_to/3` helper handles register allocation transparently
— it returns a list of `%Expr{}` (immediate-load of address,
immediate-load of port, the nat expression) which `Rule.build`
flattens into the rule's expression list.

```elixir
alias Linx.Netfilter.{Expr, Rule, Ruleset}

ruleset =
  Ruleset.new()
  |> Ruleset.add_table!(:inet, "fwd", flags: [:owner])
  |> Ruleset.add_chain!("fwd", "prerouting",
       type: :nat, hook: :prerouting, priority: :dstnat)
  |> Ruleset.add_rule!("fwd", "prerouting",
       Rule.build!([
         Expr.payload(:tcp_dport),
         Expr.cmp(:eq, <<8080::big-16>>),
         Expr.dnat_to({10, 0, 0, 5}, 80)
       ]))

:ok = Linx.Netfilter.push(sock, ruleset)
```

`dnat_to/3` accepts addresses as IPv4 4-tuples, IPv6 8-tuples,
raw binaries, strings (parsed via `Linx.IP.parse/1`), or
`%Linx.IP{}` structs.

## Masquerade

Source-NAT to the outgoing interface's primary address — the
right shape when the public IP isn't known at rule-write time
(DHCP-assigned WAN, PPP links). Only valid in postrouting chains.

```elixir
Ruleset.new()
|> Ruleset.add_table!(:inet, "nat", flags: [:owner])
|> Ruleset.add_chain!("nat", "postrouting",
     type: :nat, hook: :postrouting, priority: :srcnat)
|> Ruleset.add_rule!("nat", "postrouting",
     Rule.build!([Expr.masquerade()]))
```

Add `flags: [:random]` or `:fully_random` to randomize port
selection; `:persistent` to keep the same client on the same
outbound port for connection stability.

## Hairpin NAT

The DNAT-then-SNAT pattern for "talk to my public address from
inside the LAN and have it reach the internal service correctly".
Composes from primitives — two NAT rules in two chains:

```elixir
Ruleset.new()
|> Ruleset.add_table!(:inet, "hairpin", flags: [:owner])
|> Ruleset.add_chain!("hairpin", "prerouting",
     type: :nat, hook: :prerouting, priority: :dstnat)
|> Ruleset.add_chain!("hairpin", "postrouting",
     type: :nat, hook: :postrouting, priority: :srcnat)
|> Ruleset.add_rule!("hairpin", "prerouting",
     Rule.build!([
       Expr.payload(:tcp_dport),
       Expr.cmp(:eq, <<8080::big-16>>),
       Expr.dnat_to({10, 0, 0, 5}, 80)
     ]))
|> Ruleset.add_rule!("hairpin", "postrouting",
     Rule.build!([
       Expr.payload(:ip_daddr),
       Expr.cmp(:eq, <<10, 0, 0, 5>>),
       Expr.payload(:tcp_dport),
       Expr.cmp(:eq, <<80::big-16>>),
       Expr.snat_to({192, 168, 1, 1})
     ]))
```

## Redirect to local port

DNAT to the local machine on a different port — the right shape
for transparent proxies or port-shifting on a single host.

```elixir
Ruleset.new()
|> Ruleset.add_table!(:inet, "proxy", flags: [:owner])
|> Ruleset.add_chain!("proxy", "prerouting",
     type: :nat, hook: :prerouting, priority: :dstnat)
|> Ruleset.add_rule!("proxy", "prerouting",
     Rule.build!([
       Expr.payload(:tcp_dport),
       Expr.cmp(:eq, <<80::big-16>>),
       Expr.redirect(port: 8080)
     ]))
```

## Named sets

A named set declared on a table, then referenced from rules with
`Expr.lookup/2`. Elements round-trip through `push` and `pull/2`.

```elixir
alias Linx.Netfilter.{Expr, Rule, Ruleset, Set, Verdict}

Ruleset.new()
|> Ruleset.add_table!(:inet, "fw", flags: [:owner])
|> Ruleset.add_set!("fw",
     Set.new!("blocklist",
       key_type: :ipv4_addr,
       elements: [{10, 0, 0, 1}, {10, 0, 0, 2}, {192, 168, 1, 100}]))
|> Ruleset.add_chain!("fw", "input",
     type: :filter, hook: :input, priority: 0, policy: :accept)
|> Ruleset.add_rule!("fw", "input",
     Rule.build!([
       Expr.payload(:ip_saddr),
       Expr.lookup("blocklist"),
       Verdict.drop()
     ]))
```

Element types: `:ipv4_addr` (4-tuple or 4-byte binary),
`:ipv6_addr` (8-tuple or 16-byte), `:ether_addr`, `:inet_proto`,
`:inet_service` (port int), `:mark`, `:ifname`. The codec
normalises element shapes for you on encode and decode.

## Maps and vmaps

A typed map carries `key → value` associations. When the
`:data_type` is `:verdict`, the map is a **verdict map** (vmap) —
the kernel's rule-dispatch primitive (one lookup replaces N
individual rules).

```elixir
alias Linx.Netfilter.{Expr, Rule, Ruleset, Verdict, Vmap}

Ruleset.new()
|> Ruleset.add_table!(:inet, "fw", flags: [:owner])
|> Ruleset.add_chain!("fw", "ssh_in")
|> Ruleset.add_chain!("fw", "http_in")
|> Ruleset.add_map!("fw",
     Vmap.new!("services",
       key_type: :inet_service,
       elements: [
         {22, {:jump, "ssh_in"}},
         {80, {:jump, "http_in"}}
       ]))
|> Ruleset.add_chain!("fw", "input",
     type: :filter, hook: :input, priority: 0, policy: :drop)
|> Ruleset.add_rule!("fw", "input",
     Rule.build!([
       Expr.payload(:tcp_dport),
       Expr.lookup("services", dreg: 0)  # dreg: 0 loads into verdict register
     ]))
|> Ruleset.add_rule!("fw", "ssh_in", [Verdict.accept()])
|> Ruleset.add_rule!("fw", "http_in", [Verdict.accept()])
```

For plain (non-verdict) maps:

```elixir
alias Linx.Netfilter.Map, as: NMap

NMap.new!("dnat_pool",
  key_type: :inet_service,
  data_type: :ipv4_addr,
  elements: [{80, {10, 0, 0, 5}}, {443, {10, 0, 0, 6}}])
```

## Anonymous sets

Inline `{22, 80, 443}` literals in a rule — the encoder
auto-generates a `NFT_SET_F_ANONYMOUS | NFT_SET_F_CONSTANT` set
tied to the rule, no separate `add_set!` needed.

```elixir
Ruleset.new()
|> Ruleset.add_table!(:inet, "fw", flags: [:owner])
|> Ruleset.add_chain!("fw", "input",
     type: :filter, hook: :input, priority: 0, policy: :drop)
|> Ruleset.add_rule!("fw", "input",
     Rule.build!([
       Expr.payload(:tcp_dport),
       Expr.set_literal([22, 80, 443], :inet_service),
       Verdict.accept()
     ]))
```

The anonymous set lives and dies with the rule.

## Reconcile push — minimal-change updates

`mode: :reconcile` is the LiveView-of-firewalls form: instead of
rebuilding the entire table on every push, Linx pulls the
kernel's current state, computes the minimum patch, and sends
only the messages that change something.

```elixir
alias Linx.Netfilter.{Expr, Rule, Ruleset, Verdict}

# Build the desired state — same as :replace, but every rule that
# matters across pushes carries a stable :tag.
desired =
  Ruleset.new()
  |> Ruleset.add_table!(:inet, "fw", flags: [:owner])
  |> Ruleset.add_chain!("fw", "input",
       type: :filter, hook: :input, priority: 0, policy: :drop)
  |> Ruleset.add_rule!("fw", "input",
       Rule.build!([
         Expr.payload(:tcp_dport),
         Expr.cmp(:eq, <<22::big-16>>),
         Verdict.accept()
       ], tag: :allow_ssh))
  |> Ruleset.add_rule!("fw", "input",
       Rule.build!([
         Expr.payload(:tcp_dport),
         Expr.cmp(:eq, <<80::big-16>>),
         Verdict.accept()
       ], tag: :allow_http))

# First push — `:replace` is fine for the initial create.
:ok = Linx.Netfilter.push(sock, desired)

# Later: move ssh from port 22 to 2222. Rebuild the ruleset (the
# tag :allow_ssh marks the rule's identity across pushes), then
# reconcile.
desired_v2 = update_ssh_port(desired, 2222)

:ok = Linx.Netfilter.push(sock, desired_v2, mode: :reconcile)
# Wire payload: one BATCH_BEGIN with NFNL_BATCH_GENID, one NEWRULE
# with NLM_F_REPLACE for the :allow_ssh handle, one BATCH_END.
# The :allow_http rule is untouched — its connections survive.
```

The kernel's per-netns generation counter is read with `GETGEN`
and threaded through the batch via `NFNL_BATCH_GENID`. If another
writer (a separate `nft` invocation, firewalld, another Linx
process) commits between Linx's pull and Linx's push, the kernel
returns `-ERESTART` and Linx retries up to 3 times with
exponential backoff before surfacing the error.

Tables that exist in the kernel but aren't in `desired` are
**not deleted** — the reconcile diff is scoped to tables in
`desired`. This lets Linx coexist with Docker, firewalld, and
other ruleset writers in the same netns without stomping on them.

## Tag enforcement

Reconcile mode rejects rulesets where a multi-rule chain has any
untagged rule — without stable identity per rule, the diff has no
way to tell "what changed" from "what's still the same":

```elixir
Linx.Netfilter.push(sock, ruleset_with_untagged_rules, mode: :reconcile)
# => {:error, {:tag_required, {:inet, "fw", "input"}}}

# Single-rule chains are fine — there's nothing to be ambiguous about.
# Use `mode: :replace` (default) for chains you don't intend to tag.
```

## `dry_run/2`

`dry_run/2` is `diff/2` under a more readable name — get the
patch without sending it. Useful for "what would change?"
queries before a commit.

```elixir
{:ok, current} = Linx.Netfilter.pull(sock, {:inet, "fw"})
patch = Linx.Netfilter.dry_run(current, desired)
IO.inspect(patch)
# => #Linx.Netfilter.Patch<1 op: 1 replace>

case patch do
  %Linx.Netfilter.Patch{ops: []} -> IO.puts("Already up to date")
  _ -> Linx.Netfilter.push(sock, desired, mode: :reconcile)
end
```

## Monitor — live ruleset events

Subscribe to `NFNLGRP_NFTABLES` multicast events. The owner pid
receives `{:linx_netfilter, :event, %Event{}}` for every committed
change, with full provenance (`gen_id`, `proc_pid`, `proc_name`).

```elixir
alias Linx.Netfilter.{Event, Monitor}

{:ok, monitor} = Linx.Netfilter.subscribe(self())

# Now anyone (us, another nft client, firewalld, ...) committing
# to the netns triggers events:
receive do
  {:linx_netfilter, :event, %Event{op: :new_table, entity: t, proc_name: who}} ->
    IO.puts("#{who} created table #{t.family}/#{t.name}")
end

:ok = Linx.Netfilter.unsubscribe(monitor)
```

The kernel broadcasts entity events first, then a `NEW_GEN`
closing marker. The Monitor buffers entities until `NEW_GEN`
arrives and dispatches them all stamped with that gen — so each
`%Event{}` carries the full context of "who changed this and
when".

## Snapshot+tail (no race with the kernel)

The race-free "get current state, then watch for changes" pattern.
`subscribe_first:` captures the gen before pull and tells the
Monitor to drop events already in the snapshot:

```elixir
{:ok, monitor} = Linx.Netfilter.subscribe(self())
{:ok, snapshot} = Linx.Netfilter.pull(sock, subscribe_first: monitor)

# `snapshot` contains everything as of gen N.
# Subsequent {:linx_netfilter, :event, ...} messages cover gen > N.
# Apply them as deltas to `snapshot` for a perfectly-consistent
# live view.
```

## ENOBUFS recovery

If the multicast traffic outpaces the consumer, the kernel drops
messages and the Monitor emits a resync hint:

```elixir
receive do
  {:linx_netfilter, :resync_needed} ->
    # Drop the partial state; re-pull from scratch.
    {:ok, fresh} = Linx.Netfilter.pull(sock, subscribe_first: monitor)
    ...
end
```

Default `SO_RCVBUF` is 4 MiB; pass `:rcvbuf` to `subscribe/2` to
raise it further if your environment is heavily churned.

## NFLOG — per-packet observability

Subscribe to a NFLOG group via `log_listen/2`, then push rules
with `Expr.log/1` that route matching packets to that group. The
listener decodes each `NFULNL_MSG_PACKET` into a `%Log.Event{}`
with prefix, mark, timestamp, indev/outdev, hwaddr, payload, and
more.

```elixir
alias Linx.Netfilter.{Expr, Rule, Ruleset, Verdict}

# Open the listener. Linx convention: use group 5000 unless you
# have a reason to pick something else.
{:ok, listener} =
  Linx.Netfilter.log_listen(self(),
    group: 5000,
    copy_mode: {:packet, 256},
    flags: [:seq])

# A rule that logs and accepts every inbound packet.
ruleset =
  Ruleset.new()
  |> Ruleset.add_table!(:inet, "audit", flags: [:owner])
  |> Ruleset.add_chain!("audit", "input",
       type: :filter, hook: :input, priority: -200, policy: :accept)
  |> Ruleset.add_rule!("audit", "input",
       Rule.build!([
         Expr.log(group: 5000, prefix: "inbound"),
         Verdict.accept()
       ]))

:ok = Linx.Netfilter.push(sock, ruleset)

# Now any packet on the input chain triggers an event:
receive do
  {:linx_netfilter, :log, %{prefix: "inbound", payload: bytes, hwaddr: mac}} ->
    IO.puts("Saw #{byte_size(bytes)} bytes from #{inspect(mac)}")
end

:ok = Linx.Netfilter.unlog_listen(listener)
```

## Copy modes

  * `:none` — only the metadata attributes; no payload, no
    hwaddr. Cheapest.
  * `:meta` (default) — metadata including hwaddr but no packet
    payload.
  * `:packet` — full packet up to the kernel default snaplen.
  * `{:packet, snaplen}` — packet truncated to `snaplen` bytes.

For audit-only use cases, `:meta` is plenty and avoids the data
copy. For decoding the payload yourself (TCP headers, etc.),
`{:packet, snaplen}` lets you control the bandwidth.

## Multi-group routing

Each `log_listen/2` call binds one group. Multiple listeners on
different groups route disjoint event streams to different
owners:

```elixir
{:ok, audit} = Linx.Netfilter.log_listen(self(), group: 5001, copy_mode: :meta)
{:ok, debug} = Linx.Netfilter.log_listen(debug_pid, group: 5002, copy_mode: {:packet, 1500})
```

A rule with `Expr.log(group: 5001)` flows to `audit`; one with
`group: 5002` flows to `debug`.

## ENOBUFS

Same shape as the Monitor: if the multicast firehose outpaces
the consumer, the kernel drops events and the listener emits
`{:linx_netfilter, :resync_needed}` to the owner. Default
`SO_RCVBUF` is 4 MiB; tune with `:rcvbuf`.

## ~NFT sigil — inline nft syntax

The `~NFT` sigil parses nft syntax at compile time and produces
the same `%Linx.Netfilter.Ruleset{}` value the pipeline DSL
builds. Both authoring surfaces converge on the same value via
the same validator-setter functions — they're interchangeable.

```elixir
import Linx.NFT

ruleset =
  ~NFT"""
  table inet appliance {
    chain input {
      type filter hook input priority 0
      policy drop

      ct state established accept
      tcp dport 22 log prefix "ssh-attempt" group 5000 accept
      ip saddr 10.0.0.0/8 accept
    }

    chain forward {
      type filter hook forward priority 0
      policy drop
    }
  }
  """

{:ok, nfnl} = Linx.Netlink.Nfnl.open()
:ok = Linx.Netfilter.push(nfnl, ruleset)
```

Compile errors raise `Linx.NFT.ParseError` **at compile time**
with a caret diagnostic keyed off the surrounding `.ex` file's
line numbers:

```
** (Linx.NFT.ParseError) lib/myapp/firewall.ex:42:14: unexpected character '?'
|
|     tcp dport ? accept
|              ^
```

### Elixir interpolation

`~NFT` is uppercase, so Elixir's parser leaves `#{...}` alone —
our own tokenizer recognises it (same pattern Phoenix HEEx uses
for `~H`). When the sigil body contains any interpolations, the
sigil builds the Ruleset **at runtime**, evaluating each
expression in the caller's scope and encoding it for the field
kind the surrounding syntax expects (`{:int, _}` / `:ipv4` /
`:ipv6` / `:ifname`):

```elixir
def per_port_rule(port, allowed_addr) do
  ~NFT"""
  table inet myapp {
    chain input {
      type filter hook input priority 0
      policy drop

      ip saddr #{allowed_addr} tcp dport #{port} accept
    }
  }
  """
end
```

`port` must be a non-negative integer (encoded `<<n::big-16>>`).
`allowed_addr` accepts an IPv4 string, a 4-tuple, a 4-byte
binary, or a `%Linx.IP{family: :inet}`. Type mismatches raise
`ArgumentError` at runtime — the interpolation is type-checked
at the value position, not stringified blindly.

Bodies with **no** interpolations skip the runtime path entirely
and are compiled to a literal `%Ruleset{}` at macro-expansion
time. Interpolations in keyword positions (table names, chain
names, families, hooks, …) raise a `ParseError` for now — they'd
require per-validator wiring.

## File mode — Linx.NFT.parse_file/1

Same parser/compiler, file input. Useful for importing an
existing `nftables.conf` into Elixir as a value to inspect,
edit, or push:

```elixir
{:ok, ruleset} = Linx.NFT.parse_file("/etc/nftables.conf")

# Tweak: drop the SSH allow rule, swap in something stricter.
edited =
  ruleset
  |> Ruleset.delete_rule!(...)
  |> Ruleset.add_rule!(...)

# Push the edited version back atomically.
:ok = Linx.Netfilter.push(nfnl, edited, mode: :reconcile)
```

`parse_file/1` returns `{:error, posix}` for missing/unreadable
files and `{:error, %Linx.NFT.ParseError{}}` (with `file:` set
to the path) for syntax/compile errors.

## Canonical emit — Linx.NFT.format/1

`format/1` walks a Ruleset and emits canonical nft source —
useful for diffing, golden-test fixtures, or writing the result
of a programmatic edit back to disk:

```elixir
ruleset =
  Ruleset.new()
  |> Ruleset.add_table!(:inet, "myapp")
  |> Ruleset.add_chain!("myapp", "input",
       type: :filter, hook: :input, priority: 0, policy: :drop)
  |> Ruleset.add_rule!("myapp", "input",
       [Expr.payload(:tcp_dport), Expr.cmp(:eq, <<22::big-16>>),
        Expr.immediate(Verdict.accept())])

IO.puts Linx.NFT.format(ruleset)
# table inet myapp {
#   chain input {
#     type filter hook input priority 0
#     policy drop
#
#     tcp dport 22 accept
#   }
# }
```

Round-trip is structurally identical for the supported slice —
`parse(format(rs)) == {:ok, rs}`. Trivia (original comments,
blank lines, ordering) isn't preserved; that's a v2 enhancement.

## `mix format` plugin — Linx.NFT.Formatter

`Linx.NFT.Formatter` implements the `Mix.Tasks.Format` behaviour,
so `mix format` can reflow both `~NFT"…"` sigil bodies inside
`.ex` source AND standalone `.nft` files using the same canonical
emit path as `Linx.NFT.format/1`.

Wire it up in the project's `.formatter.exs`:

```elixir
# .formatter.exs
[
  plugins: [Linx.NFT.Formatter],
  inputs: [
    "{lib,test}/**/*.{ex,exs}",
    "**/*.nft"
  ]
]
```

After saving, `mix format` reflows things like:

```elixir
# Before
ruleset = ~NFT"table inet x{chain c{tcp dport 22 accept}}"
```

```elixir
# After mix format
ruleset = ~NFT"""
table inet x {
  chain c {
    tcp dport 22 accept
  }
}
"""
```

And standalone `firewall.nft`:

```
# Before
table inet x{chain c{tcp dport 22 accept}}
```

```
# After mix format firewall.nft
table inet x {
  chain c {
    tcp dport 22 accept
  }
}
```

### Idempotence

`format → parse → format` is byte-identical for the supported
slice — the same invariant the golden test corpus
(`test/linx/nft/fixtures/*.nft`) asserts on every fixture.
Running `mix format` twice on the same file produces no diff;
running it on a fresh file converges in one pass.

### Interpolation-bearing sigils

For now, `~NFT` sigil bodies that contain `#{…}` interpolations
are **left untouched** by `mix format` — preserving the
interpolation positions while reflowing the surrounding nft
syntax requires AST-aware re-emission that hasn't been built
yet. Static sigil bodies and `.nft` files reformat freely.

### Errors

A parse error in a `.nft` file raises `Linx.NFT.ParseError` from
`mix format`, surfacing visibly so the user fixes it. For
sigils, parse errors leave the body verbatim — the surrounding
compile run reports the same error with better stack context.
