Linx
Linux kernel interfaces for Elixir.
A library of low-level Linux primitives — netlink sockets, process and namespace lifecycle, terminal/PTY control, cgroup v2 resource limits, filesystem mounts, user-namespace identity mappings, per-process capability sets, per-thread seccomp filters, kernel-tunable parameters, modern firewalling via nf_tables — exposed as idiomatic Elixir. The aim is to make these feel as natural to drive from the BEAM as anything in the standard library.
Linx is a library of primitives, not a runtime. A container engine, a network orchestrator, or an observability tool is a consumer of Linx; the runtime concepts (images, supervision policies, request routing) live in those projects.
⚠️ 0.x. The API is still settling; minor releases may include breaking changes until 1.0.
Installation
Add linx to your dependencies:
def deps do
[
{:linx, "~> 0.1"}
]
end
Requirements. Linux only — the underlying kernel interfaces don't exist on macOS, BSD, or Windows. Elixir 1.15+ on Erlang/OTP 26+ (the Linx.Tty group-leader attach mode depends on the OTP-26 prim_tty driver). Kernel 6.6 LTS or newer; the nf_tables paths target 6.12 LTS.
Build prerequisites. The kernel-interface NIFs and the process Port are compiled from C (c_src/) at install time, so a C compiler and the relevant headers must be present:
- Debian / Ubuntu:
sudo apt install build-essential(adderlang-devif you installed Erlang from apt rather than asdf/precompiled) - Arch:
sudo pacman -S base-devel(theerlang/erlang-noxpackage already ships the Erlang headers)
build-essential / base-devel also pull in the libc and Linux UAPI headers the sources include.
The headline composition
Linx's value isn't any single subsystem — it's that they all hook into the same Linx.Processcheckpoint, the window between clone(2) and execve(2) where the child is parked. Inside that window a workload's identity, resource ceiling, network, privileges, and syscall surface are all decided at once, before its first instruction.
alias Linx.{Process, User, Cgroup, Capabilities, Seccomp}
alias Linx.Netlink.Rtnl
{:ok, c} =
Process.spawn(
argv: ["/usr/sbin/nginx"],
namespaces: [:net, :pid, :user],
no_new_privs: true
)
receive do {:linx_process, :ready, _} -> :ok end
{:ok, host_pid} = Process.host_pid(c)
# Identity: root inside ↔ this uid outside.
my_uid = System.cmd("id", ["-u"]) |> elem(0) |> String.trim() |> String.to_integer()
my_gid = System.cmd("id", ["-g"]) |> elem(0) |> String.trim() |> String.to_integer()
:ok = User.setup_maps(host_pid,
uid: [{0, my_uid, 1}], gid: [{0, my_gid, 1}])
# Resources: 256 MiB / half a CPU.
{:ok, cg} = Cgroup.create("/sys/fs/cgroup/myorg/nginx-42")
:ok = Cgroup.set_memory_max(cg, 256 * 1024 * 1024)
:ok = Cgroup.set_cpu_max(cg, {50_000, 100_000})
:ok = Cgroup.add_process(cg, host_pid)
# Network: a macvlan with an address and a default route.
{:ok, host_sock} = Rtnl.open()
:ok = Rtnl.Link.create_macvlan(host_sock, "ct0", "eth0", :bridge)
:ok = Rtnl.Link.move_to_netns(host_sock, "ct0", host_pid)
{:ok, ns} = Rtnl.open({:pid, host_pid})
:ok = Rtnl.Link.set_up(ns, "ct0")
:ok = Rtnl.Address.add(ns, "ct0", "10.0.0.5", 24)
:ok = Rtnl.Route.add_default(ns, "10.0.0.1")
# Firewall: default drop, allow established + ssh; rules vanish when we do.
{:ok, ct_nfnl} = Linx.Netlink.Nfnl.open({:pid, host_pid})
:ok = Linx.Netfilter.push(ct_nfnl, ~NFT"""
table inet guard {
chain input {
type filter hook input priority 0
policy drop
ct state established accept
tcp dport 22 accept
}
}
""")
# Privilege: only cap_net_bind_service.
all = Linx.Capabilities.Constants.all()
:ok = Capabilities.drop_bounding(c,
MapSet.difference(all, MapSet.new([:cap_net_bind_service])))
# Syscalls: only what nginx actually needs.
nginx_syscalls = ~w(read write openat close fstat brk mmap munmap mprotect
socket bind listen accept4 setsockopt getsockopt
rt_sigaction rt_sigprocmask rt_sigreturn exit_group
epoll_pwait epoll_ctl epoll_create1 clock_gettime futex)a
{:ok, filter} = Seccomp.allow_list(nginx_syscalls, default: :kill_process)
:ok = Seccomp.install(c, filter)
# Release the workload. Every constraint above is in force from
# the moment execve(2) runs.
:ok = Process.proceed(c)
The subsystems are independent — you can spawn without namespaces, use netlink without spawning, drop caps without seccomp. They compose cleanly because they share one primitive (the checkpoint), not because there's a framework holding them together. Each subsystem's module doc carries the standalone walkthroughs and the progressively-richer composition recipes; docs/<subsystem>/<subsystem>-examples.md has runnable, copy-paste transcripts.
Subsystems
Linx.Process—clone(2)with namespace flags,setns(2), signal delivery,waitpid(2), and stdio plumbing (inherit //dev/null/ AF_UNIX / PTY). The syscalls run in a small external C agent — a Port, not a NIF — becauseclone()/fork()/unshare()inside the multithreaded BEAM corrupts the VM. The checkpoint (the parked window betweenclone()andexecve()) is the seam every other subsystem hooks into. Seedocs/process/process-overview.md.Linx.Tty— the terminal surface:/dev/tty,termios(3)(raw / save / restore), window-size ioctls, andattach/2, which pumps bytes between a:ptyworkload and the caller's terminal —:controllingfor a local terminal,:group_leaderfor SSH /:remsh— restoring all transient terminal state unconditionally on return. Seedocs/tty/tty-overview.md.Linx.Cgroup— cgroup v2 resource control via direct/sys/fs/cgroupfile I/O (no NIF, nocgcreate). The path is the handle; typed setters for memory / pids / cpu; live counters as%Linx.Cgroup.Stats{}; errors as%Linx.Cgroup.Error{}. Seedocs/cgroup/cgroup-overview.md.Linx.Mount—mount(2),umount2(2),pivot_root(2), convenience verbs (bind/remount/move), a pure-Elixir/proc/<pid>/mountinfoparser, and a cross-namespace:inoption that targets any process's mount namespace, not just the BEAM's. Seedocs/mount/mount-overview.md.Linx.User— user-namespace identity mapping. Writes/proc/<pid>/{uid_map,gid_map,setgroups}to turn a:user-namespaced workload from a kernel-defaultnobodyinto a mapped identity — typically the rootless "root inside ↔ me outside" trick. Pure Elixir; maps are write-once per namespace. Seedocs/user/user-overview.md.Linx.Capabilities— the five per-thread capability sets (effective / permitted / inheritable / bounding / ambient) asMapSets of:cap_*atoms. Pure-Elixir read from/proc/<pid>/status; checkpoint-window write verbs (drop_bounding/set_thread_sets/set_ambient). Root-only for writes. Seedocs/capabilities/capabilities-overview.md.Linx.Seccomp— per-thread cBPF syscall filters compiled in pure Elixir (nolibseccomp).allow_list/2,deny_list/2, and theLinx.Seccomp.BuilderDSL produce a%Linx.Seccomp.Filter{}, installed at the checkpoint just beforeexecve;from_rules/1/to_rules/1is the data seam external policy adapters (e.g. a Dockerseccomp.jsonparser in a consumer) plug into. Seedocs/seccomp/seccomp-overview.md.Linx.Sysctl— the/proc/sys/knobssysctl(8)reads and writes, with dot-form keys, per-namespace routing, and the same:inoption asLinx.Mount. Pure-Elixir host path; a small NIF handles the cross-namespace case. Seedocs/sysctl/sysctl-overview.md.Linx.Netlink— anAF_NETLINKclient with rtnetlink (links / addresses / routes / neighbours / rules / stats — full CRUD across IPv4 and IPv6) and nfnetlink (surfaced separately asLinx.Netfilter). Pure-Elixir encode/decode; a NIF only for entering another netns on a throwaway thread.Rtnl.open({:pid, n})binds a socket to a child's network namespace for its whole life. Seedocs/netlink/netlink-overview.md.Linx.Netfilter— nf_tables (the iptables / ip6tables / ebtables successor) overNETLINK_NETFILTER. A%Linx.Netfilter.Ruleset{}is plain data; build it with the pipeline DSL or the compile-time~NFTsigil (real nft syntax), thenpush/pull/diff. Tables are socket-owned by default — when the supervisor that opened the socket dies, the kernel atomically destroys the rules. Livesubscribe/1monitor + NFLOGlog_listen/2, plus amix formatplugin for~NFTbodies and.nftfiles. Seedocs/netfilter/netfilter-overview.md.Value types —
Linx.IP(withLinx.IP.Subnet) andLinx.MAC. Each has a compile-time sigil (~IP,~MAC) thatInspectround-trips. Decoded netlink fields carry these structs directly; verbs accept either the struct or the equivalent string.
How Linx is organized
Three kinds of top-level module, named for what they organize:
| Kind | When | Examples |
|---|---|---|
| Mechanism layer | A coherent transport with shared infrastructure (codec, framing, error handling, …). | Linx.Netlink |
| Subsystem concept | A grouping of kernel operations that work together for one purpose. Mirrors how Linux man-page section 7 names things. | Linx.Process, Linx.Tty, Linx.Cgroup, Linx.Mount, Linx.User, Linx.Capabilities, Linx.Seccomp, Linx.Sysctl, Linx.Netfilter |
| Value type | A domain primitive that flows through the mechanisms. Top level. | Linx.IP, Linx.MAC |
Name a module after a mechanism only when the mechanism has shared shape worth factoring out; otherwise name it after the kernel subsystem or concept. Namespace isn't a subsystem — it's a cross-cutting flag on clone(2) — so it doesn't get its own module; the operations live where they belong.
Each subsystem owns its living docs under docs/<subsystem>/: an overview, runnable examples, and external references. Roadmap and forward-compatibility notes live in each subsystem's module doc.
Docs
Generated docs are hosted at hexdocs.pm/linx. Locally, mix docs builds HexDocs-style HTML under _build/docs/; the per-subsystem overview, examples, and references pages are surfaced there alongside the module docs.
License
Linx is released under the MIT License.