Hands-on examples of using Linx.Netlink against the live Linux kernel.

Read-only operations work in a plain iex -S mix session. Anything that changes network state — creating links, adding addresses or routes, entering another network namespace — needs root: start with ./sudorun.sh.

Quick start

alias Linx.Netlink.{Rtnl, Socket}
alias Linx.Netlink.Rtnl.Link

{:ok, sock} = Rtnl.open()
# => {:ok, %Linx.Netlink.Socket{netns: :host, protocol: 0, ...}}

{:ok, links} = Link.list(sock)
links
# => [#Linx.Netlink.Rtnl.Link<"lo" (1) UP MTU=65536>,
 #Linx.Netlink.Rtnl.Link<"eth0" (2) UP MTU=1500>,
 #Linx.Netlink.Rtnl.Link<"wlan0" (3) DOWN MTU=1500>]

Socket.close(sock)
# => :ok

Every verb takes a socket as its first argument; structs come back from reads, :ok or {:error, %Linx.Netlink.Error{}} from mutations.

Reading the network

{:ok, lo} = Link.get(sock, "lo")
# => {:ok, #Linx.Netlink.Rtnl.Link<"lo" (1) UP MTU=65536>}

Link.up?(lo)
# => true

Addresses

alias Linx.Netlink.Rtnl.Address

{:ok, addresses} = Address.list(sock)
addresses
# => [#Linx.Netlink.Rtnl.Address<127.0.0.1/8 ifindex=1>,
 #Linx.Netlink.Rtnl.Address<::1/128 ifindex=1>,
 #Linx.Netlink.Rtnl.Address<192.168.1.42/24 ifindex=2>, ...]

{:ok, lo_addrs} = Address.list(sock, "lo")
Enum.map(lo_addrs, & &1.address)
# => [~IP"127.0.0.1", ~IP"::1"]

Routes

alias Linx.Netlink.Rtnl.Route

{:ok, routes} = Route.list(sock)
routes
# => [#Linx.Netlink.Rtnl.Route<default via 192.168.1.1 oif=2>,
 #Linx.Netlink.Rtnl.Route<192.168.1.0/24 oif=2>, ...]

# resolve a destination — what route would the kernel use?
{:ok, route} = Route.get(sock, "1.1.1.1")
route
#Linx.Netlink.Rtnl.Route<1.1.1.1/32 via 192.168.1.1 oif=2>

Neighbours (ARP / NDP table)

alias Linx.Netlink.Rtnl.Neighbour

{:ok, neighbours} = Neighbour.list(sock)
neighbours
# => [#Linx.Netlink.Rtnl.Neighbour<192.168.1.1 -> aa:bb:cc:dd:ee:ff ifindex=2>, ...]

Interface statistics

alias Linx.Netlink.Rtnl.Stats

{:ok, stats} = Stats.get(sock, "eth0")
stats
#Linx.Netlink.Rtnl.Stats<ifindex=2 rx=128431p/189204771B tx=85912p/12504331B>

stats.link.rx_packets
# => 128431
stats.link.tx_dropped
# => 0

{:ok, all_stats} = Stats.list(sock)   # every interface's counters

The counters live on %Stats{}.link as a %Linx.Netlink.Rtnl.Stats.Link64{} — the 25 fields of the kernel's struct rtnl_link_stats64 (rx_packets, tx_bytes, multicast, rx_dropped, …; full list in the moduledoc).

Policy-routing rules

alias Linx.Netlink.Rtnl.Rule

{:ok, rules} = Rule.list(sock)
rules
# => [#Linx.Netlink.Rtnl.Rule<table=255>,
 #Linx.Netlink.Rtnl.Rule<priority=32766 table=254>,
 #Linx.Netlink.Rtnl.Rule<priority=32767 table=253>]

Creating virtual interfaces

These need ./sudorun.sh.

macvlan / ipvlan — a separate network identity riding a parent NIC

A macvlan is a first-class host on the LAN — its own MAC, its own IP, no NAT. The other container-networking model.

Link.create_macvlan(sock, "web0", "eth0", :bridge)
# => :ok
Link.create_ipvlan(sock, "app0", "eth0", :l3)
# => :ok

veth — a connected pair

The other container-networking model — two interfaces wired back-to-back, typically used with a bridge.

Link.create_veth(sock, "v0a", "v0b")
# => :ok

vlan — 802.1Q tagging

Link.create_vlan(sock, "eth0.42", "eth0", 42)
# => :ok
Link.create_bridge(sock, "br0")
# => :ok
Link.set_master(sock, "v0a", "br0")
# => :ok
Link.set_master(sock, "v0b", "br0")
# => :ok

dummy — a no-op interface

Useful as a stable address holder or test fixture.

Link.create_dummy(sock, "test0")
# => :ok

Configuring an interface

Link.set_up(sock, "eth0.42")
# => :ok
Link.set_mtu(sock, "eth0.42", 1400)
# => :ok
Link.set_address(sock, "eth0.42", "02:aa:bb:cc:dd:ee")
# => :ok
Link.set_name(sock, "eth0.42", "vlan42")
# => :ok
Link.set_down(sock, "vlan42")
# => :ok
Link.delete(sock, "vlan42")
# => :ok

Addresses

IPv4 and IPv6 alike — the family is detected from the address string:

Address.add(sock, "vlan42", "10.0.42.5", 24)
# => :ok
Address.add(sock, "vlan42", "fc00::42:5", 64)
# => :ok
Address.delete(sock, "vlan42", "10.0.42.5", 24)
# => :ok

Routes

Route.add(sock, "10.99.0.0", 24, "10.0.42.1")        # via a gateway
# => :ok
Route.add_default(sock, "10.0.42.1")                 # the default route
# => :ok
Route.delete(sock, "10.99.0.0", 24, "10.0.42.1")
# => :ok
Route.delete_default(sock, "10.0.42.1")
# => :ok

IPv6 works through the same API — Route.add(sock, "fd00::", 64, "fc00::1") or Route.add_default(sock, "fc00::1"); the family is taken from the gateway and destination, which must agree.

In-place updates and route options

replace/5 is create-or-replace: install the route if absent, overwrite it in place (e.g. a changed gateway) if present. It is idempotent, where add/5 is strict (:eexist on a duplicate).

Route.add(sock, "10.99.0.0", 24, "10.0.42.1")       # strict; errors if present
Route.replace(sock, "10.99.0.0", 24, "10.0.42.9")   # upsert; new gateway in place

add/5, replace/5 and delete/5 take :table, :protocol and :metric.

# A route in a custom table, tagged with a dedicated protocol, at a metric.
Route.add(sock, "10.50.0.0", 24, "10.0.42.1", table: 100, protocol: :static, metric: 50)
Route.delete(sock, "10.50.0.0", 24, "10.0.42.1", table: 100, metric: 50)

:protocol (an integer, or :kernel/:boot/:static/:ra/:dhcp) is the ownership tag a reconciler uses to manage only its own routes; :table accepts any value (tables above 255 ride RTA_TABLE automatically); :metric is the route's RTA_PRIORITY.

Neighbours

Static ARP (IPv4) or NDP (IPv6) entries — mapping an IP to a MAC on a specific link:

Neighbour.add(sock, "eth0", "10.0.0.10", "02:aa:bb:cc:dd:ee")
# => :ok
Neighbour.delete(sock, "eth0", "10.0.0.10")
# => :ok

Policy-routing rules

FIB rules choose which routing table to consult based on selectors. :table is required; the family is inferred from the address selectors (:from / :to), defaulting to IPv4 when only non-address selectors are used:

# route any packet from 10.0.0.0/24 via table 100
Rule.add(sock, from: "10.0.0.0/24", table: 100)
# => :ok

# match a firewall mark, set the rule's own priority
Rule.add(sock, fwmark: 0x1, table: 100, priority: 200)
# => :ok

Rule.delete(sock, from: "10.0.0.0/24", table: 100)
# => :ok

Declarative reconciliation

Linx.Netlink.Rtnl.Diff computes the minimal create / update / delete ops that converge observed kernel state onto a desired state — the diff half of declarative reconciliation (applying them, and observing, come together in the reconciler). The diff currency is the decoded structs themselves, so desired and observed are the same type.

Routes own by rtm_protocol (two-way): tag desired routes with a protocol, and the diff considers only observed routes carrying it — connected routes and other writers are invisible to it. A changed gateway is an :update (applied in place with Route.replace/5), not a delete+create.

desired = [build_route("10.50.0.0", 24, "10.0.0.1", protocol: :static)]
{:ok, observed} = Route.list(sock)

Diff.routes(desired, observed, :static)
# => [{:create, %Route{...}}, {:update, %Route{...}}, {:delete, %Route{...}}]

Everything else (addresses, links, rules, neighbours) has no kernel ownership field, so deletion is gated three-way by a last_applied key set — the keys this reconciler installed before. Foreign state that merely appeared is left alone; only keys you previously applied and no longer want are deleted.

owned = MapSet.new(Enum.map(previously_applied, &Diff.address_key/1))
Diff.addresses(desired_addrs, observed_addrs, owned)

See the Linx.Netlink.Rtnl.Diff moduledoc for the full key/ownership table.

Single-shot reconcile

Linx.Netlink.Rtnl.Reconcile.reconcile/4 composes the diffs into one ordered, converging pass for addresses and routes on existing interfaces. You author the desired state by interface name; indices are resolved each pass. It applies addresses before routes (so a gateway is reachable), is fail-fast, and returns a report whose :last_applied you thread into the next pass.

desired = %{
  addresses: [{"eth0", "10.0.0.2", 24}],
  routes: [{"10.50.0.0", 24, "10.0.0.1"}, {:default, "10.0.0.1"}]
}

{:ok, r} = Reconcile.reconcile(sock, desired)
r.converged?        # true once the kernel matches
r.applied           # the ops that ran this pass

# Idempotent — a second pass with the threaded ownership does nothing.
{:ok, r2} = Reconcile.reconcile(sock, desired, r.last_applied)
r2.applied == []

Run it on a timer and it self-heals: delete an address by hand and the next pass restores it; drop one from desired and the next pass removes it — but only addresses it installed, never a foreign one that merely appeared. Routes are owned by rtm_protocol (default 76, override with :protocol), so a route written by anything else is invisible to the reconciler and never touched. Link lifecycle, rules, and neighbours are out of this pass's scope.

Watching for change: the Monitor

Linx.Netlink.Rtnl.Monitor is the ip monitor equivalent — a GenServer that subscribes to the rtnetlink multicast groups and forwards each change to an owner. It is a latency layer over the timer-driven reconcile: a faster "look now" signal, not a source of truth.

{:ok, mon} = Linx.Netlink.Rtnl.Monitor.subscribe()

# the owner receives, for any change in the namespace:
#   {:linx_rtnl, :event, %Monitor.Event{op: :new_addr, resource: %Address{...}}}
#   {:linx_rtnl, :resync_needed}    (on ENOBUFS — the stream is lossy)

Linx.Netlink.Rtnl.Monitor.unsubscribe(mon)

Netlink multicast drops frames under load, so events are wake-up hints, not deltas: a level-triggered consumer re-lists and re-diffs on any event (or :resync_needed) rather than acting on the event's :resource. Because RTM_* notifications decode through the same codecs as list/1, the structs in an event are identical to what a re-read returns. Pair it with reconcile/4 to wake the loop faster than its timer; correctness still rests on the resync.

A long-lived loop (opt-in)

reconcile/4 and the Monitor are mechanism; wiring them into a continuous control loop is policy you can write yourself (a ~15-line timer that re-lists, re-diffs, and reconciles, woken early by the Monitor). When you'd rather not, the opt-in Linx.Reconcile loop does it, driven through the rtnl Source adapter. The scope is the namespace — the loop opens a short-lived socket per pass, so it owns no socket lifecycle:

children = [
  {Linx.Reconcile,
   source: Linx.Netlink.Rtnl.Reconcile.Source,
   scope: {:pid, container_pid},
   desired: %{
     addresses: [{"eth0", "10.0.0.2", 24}],
     routes: [{:default, "10.0.0.1"}]
   },
   interval: :timer.seconds(30)}
]
Supervisor.start_link(children, strategy: :one_for_one)

By default it subscribes to the Monitor for low-latency wakeups and re-syncs every interval as the safety net — delete an address by hand and it is restored, either on the Monitor wakeup or the next timer pass. It links to the Monitor: if the multicast socket dies the loop restarts and resynchronizes from scratch, which is correct (resync is truth). It drives one namespace; the cross-subsystem composite (a container's process + network + cgroup together) stays in the consumer, where it belongs.

IP addresses, subnets, and MAC addresses

IP addresses, subnets and MAC addresses are first-class values — Linx.IP, Linx.IP.Subnet and Linx.MAC structs. Decoded netlink fields carry them directly, the ~IP and ~MAC sigils build literals at compile time, and verbs accept either the struct or the equivalent string.

import Linx.IP
import Linx.MAC

# build values
~IP"10.0.0.5"
# => ~IP"10.0.0.5"
~IP"10.0.0.0/24"
# => ~IP"10.0.0.0/24"
~MAC"02:aa:bb:cc:dd:ee"
# => ~MAC"02:aa:bb:cc:dd:ee"

# subnet math
alias Linx.IP.Subnet
Subnet.contains?(~IP"10.0.0.0/8", ~IP"10.99.0.5")
# => true
Subnet.network(~IP"10.0.42.5/24")
# => ~IP"10.0.42.0"
Subnet.broadcast(~IP"10.0.42.5/24")
# => ~IP"10.0.42.255"

# verbs accept the structs directly (in addition to strings)
Address.add(sock, "eth0", ~IP"10.0.0.5", 24)
# => :ok
Link.set_address(sock, "eth0", ~MAC"02:aa:bb:cc:dd:ee")
# => :ok

Network namespaces

Rtnl.open/1 chooses the namespace the socket lives in:

# the host's own netns (default)
{:ok, host} = Rtnl.open()
{:ok, host} = Rtnl.open(:host)

# inside the netns of process 4242 — e.g. a running container
{:ok, ns} = Rtnl.open({:pid, 4242})

# by file path — anything that names a netns
{:ok, ns} = Rtnl.open({:path, "/var/run/netns/myns"})

Every verb then operates in that namespace. To hand a link from one namespace to another:

# host-side: move "web0" into pid 4242's netns
Link.move_to_netns(host, "web0", 4242)

# inside-side: bring loopback up, configure web0, install a default route
Link.set_up(ns, "lo")
Address.add(ns, "web0", "192.168.1.50", 24)
Link.set_up(ns, "web0")
Route.add_default(ns, "192.168.1.1")

Errors

Every netlink verb returns {:ok, _} | {:error, %Linx.Netlink.Error{}}:

Link.get(sock, "nope0")
# => {:error, %Linx.Netlink.Error{
#    errno: :enodev, code: 19,
#    message: "no such interface \"nope0\""
# }}

%Linx.Netlink.Error{} is an Exception, so it can be matched, formatted or raised:

{:error, err} = Link.get(sock, "nope0")
err.errno
# => :enodev
Exception.message(err)
# => "netlink ENODEV (19): no such interface \"nope0\""
raise err
# => ** (Linx.Netlink.Error) netlink ENODEV (19): no such interface "nope0"

When the kernel attaches a description through extended ack (NLMSGERR_ATTR_MSG), it surfaces in :message automatically. When it does not, the verb synthesizes a useful one — create_macvlan for example sharpens "no such interface" into "no such parent interface" so the caller knows which name was at fault.