CI

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 (add erlang-dev if you installed Erlang from apt rather than asdf/precompiled)
  • Arch: sudo pacman -S base-devel (the erlang / erlang-nox package 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.Process checkpoint, 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.Processclone(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 — because clone()/fork()/unshare() inside the multithreaded BEAM corrupts the VM. The checkpoint (the parked window between clone() and execve()) is the seam every other subsystem hooks into. See docs/process/process-overview.md.

  • Linx.Tty — the terminal surface: /dev/tty, termios(3) (raw / save / restore), window-size ioctls, and attach/2, which pumps bytes between a :pty workload and the caller's terminal — :controlling for a local terminal, :group_leader for SSH / :remsh — restoring all transient terminal state unconditionally on return. See docs/tty/tty-overview.md.

  • Linx.Cgroup — cgroup v2 resource control via direct /sys/fs/cgroup file I/O (no NIF, no cgcreate). The path is the handle; typed setters for memory / pids / cpu; live counters as %Linx.Cgroup.Stats{}; errors as %Linx.Cgroup.Error{}. See docs/cgroup/cgroup-overview.md.

  • Linx.Mountmount(2), umount2(2), pivot_root(2), convenience verbs (bind / remount / move), a pure-Elixir /proc/<pid>/mountinfo parser, and a cross-namespace :in option that targets any process's mount namespace, not just the BEAM's. See docs/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-default nobody into a mapped identity — typically the rootless "root inside ↔ me outside" trick. Pure Elixir; maps are write-once per namespace. See docs/user/user-overview.md.

  • Linx.Capabilities — the five per-thread capability sets (effective / permitted / inheritable / bounding / ambient) as MapSets 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. See docs/capabilities/capabilities-overview.md.

  • Linx.Seccomp — per-thread cBPF syscall filters compiled in pure Elixir (no libseccomp). allow_list/2, deny_list/2, and the Linx.Seccomp.Builder DSL produce a %Linx.Seccomp.Filter{}, installed at the checkpoint just before execve; from_rules/1 / to_rules/1 is the data seam external policy adapters (e.g. a Docker seccomp.json parser in a consumer) plug into. See docs/seccomp/seccomp-overview.md.

  • Linx.Sysctl — the /proc/sys/ knobs sysctl(8) reads and writes, with dot-form keys, per-namespace routing, and the same :in option as Linx.Mount. Pure-Elixir host path; a small NIF handles the cross-namespace case. See docs/sysctl/sysctl-overview.md.

  • Linx.Netlink — an AF_NETLINK client with rtnetlink (links / addresses / routes / neighbours / rules / stats — full CRUD across IPv4 and IPv6) and nfnetlink (surfaced separately as Linx.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. See docs/netlink/netlink-overview.md.

  • Linx.Netfilter — nf_tables (the iptables / ip6tables / ebtables successor) over NETLINK_NETFILTER. A %Linx.Netfilter.Ruleset{} is plain data; build it with the pipeline DSL or the compile-time ~NFT sigil (real nft syntax), then push / pull / diff. Tables are socket-owned by default — when the supervisor that opened the socket dies, the kernel atomically destroys the rules. Live subscribe/1 monitor + NFLOG log_listen/2, plus a mix format plugin for ~NFT bodies and .nft files. See docs/netfilter/netfilter-overview.md.

  • Value typesLinx.IP (with Linx.IP.Subnet) and Linx.MAC. Each has a compile-time sigil (~IP, ~MAC) that Inspect round-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:

KindWhenExamples
Mechanism layerA coherent transport with shared infrastructure (codec, framing, error handling, …).Linx.Netlink
Subsystem conceptA 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 typeA 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.