Hands-on examples of Linx.User — the user-namespace configuration
primitives.
Read-only operations (read_uid_map/1, read_gid_map/1,
supported?/0) work in a plain iex -S mix session against any
process's /proc/<pid>/.... Write operations need either
CAP_SETUID / CAP_SETGID in the parent user ns (typically root)
or a single-line identity map that the kernel allows for
unprivileged callers.
Detecting user-namespace support
Linx.User.supported?()
# => truesupported?/0 returns true iff /proc/self/uid_map exists — true
on any kernel ≥ 3.8. Linx targets modern Linux; on a supported
system this should always be true.
Writing uid/gid maps
The headline rootless flow: spawn a workload in a fresh :user
namespace, write maps from the host while the child is parked at
the checkpoint, then proceed.
alias Linx.Process, as: P
alias Linx.User
{:ok, c} =
P.spawn(
argv: ["/bin/sh"],
namespaces: [:user, :mount, :pid, :uts, :ipc],
stdio: :pty
)
# The :ready event's pid is the child's *own* view of itself --
# = 1 inside a fresh :pid namespace. For procfs writes we need
# the host's view: that's P.host_pid/1.
receive do {:linx_process, :ready, _child_view} -> :ok end
{:ok, host_pid} = P.host_pid(c)
# "root inside ↔ me outside" -- the canonical rootless mapping.
:ok = User.deny_setgroups(host_pid)
:ok = User.set_uid_map(host_pid, [{0, my_host_uid, 1}])
:ok = User.set_gid_map(host_pid, [{0, my_host_gid, 1}])
:ok = P.proceed(c)
:ok = Linx.Tty.attach(:controlling, c)Inside the attached bash:
[root@... /]$ whoami
rootWithout the maps the workload would still spawn — but the kernel
would default the inside identity to nobody (uid 65534), as in
the headline transcript from the project README.
The {inside, outside, length} shape
Each entry maps a contiguous range of IDs:
# A single uid (rootless idiom):
[{0, 1000, 1}] # uid 0 inside ↔ uid 1000 outside
# A range (privileged or via newuidmap; full identity for a 65k
# range starting at 100000):
[{0, 100_000, 65_536}] # 0..65535 inside ↔ 100000..165535 outside
# Multiple ranges in one map (allowed by the kernel, written
# atomically):
[{0, 1000, 1}, {1, 100_000, 65_535}]The kernel writes are write-once per user namespace — a
second call returns EPERM. Plan the whole map in one call.
Why deny_setgroups/1 first?
Per user_namespaces(7): an unprivileged caller (no
CAP_SETGID in the parent user ns) can't write gid_map while
the namespace still permits setgroups(2). Writing "deny" to
/proc/<pid>/setgroups first is the kernel-mandated dance.
Privileged callers can skip it, but the call is idempotent and
costless — so the canonical sequence (and the
setup_maps/2 convenience) always does the deny first.
# Skip the deny only if you're sure you have CAP_SETGID in the
# parent user ns. The Linx.User docs default to including it.
:ok = User.deny_setgroups(host_pid)
:ok = User.set_uid_map(host_pid, uid_maps)
:ok = User.set_gid_map(host_pid, gid_maps)Errors
Two distinct error shapes — caller mistakes vs kernel rejections:
# Caller-side input mistake -- caught before any /proc write:
User.set_uid_map(host_pid, [])
# => {:error, {:bad_map, :empty}}
User.set_uid_map(host_pid, [{0, 1000}])
# => {:error, {:bad_map, {:bad_entry, {0, 1000}}}}
User.set_uid_map(host_pid, [{-1, 1000, 1}])
# => {:error, {:bad_map, {:bad_entry, {-1, 1000, 1}}}}
# Kernel rejection -- structured Linx.User.Error:
User.set_uid_map(host_pid, [{0, 1000, 1}]) # second call
# => {:error,
# %Linx.User.Error{
# path: "/proc/.../uid_map",
# operation: :set_uid_map,
# errno: :eperm,
# code: 1
# }}
User.set_uid_map(9_999_999, [{0, 1000, 1}]) # dead pid
# => {:error,
# %Linx.User.Error{
# path: "/proc/9999999/uid_map",
# operation: :set_uid_map,
# errno: :enoent,
# code: 2
# }}Pattern-match on :errno and :operation to handle specific
failures:
case User.set_uid_map(pid, mappings) do
:ok ->
:mapped
{:error, %User.Error{errno: :eperm}} ->
# Either write-once already done, or the map was too broad
# for an unprivileged caller (needs CAP_SETUID or
# newuidmap(1) for multi-range subuid).
:no_perm
{:error, %User.Error{errno: :enoent}} ->
# Target pid is gone.
:pid_dead
{:error, {:bad_map, reason}} ->
# Input validation -- caller mistake, didn't hit the kernel.
{:invalid_input, reason}
endThe Exception impl makes raise and Exception.message/1 work
on %Linx.User.Error{} too:
err = Linx.User.Error.from_posix(:eperm, "/proc/1/uid_map", :set_uid_map)
Exception.message(err)
# => "user set_uid_map failed on /proc/1/uid_map: eperm (errno 1)"Reading uid/gid maps
read_uid_map/1 and read_gid_map/1 parse /proc/<pid>/{uid,gid}_map
into a list of %Linx.User.Map{} structs:
Linx.User.read_uid_map(host_pid)
# => {:ok, [#Linx.User.Map<0 -> 1000>]}
# Multi-range identity (the runc-style rootless layout):
Linx.User.read_uid_map(host_pid)
# => {:ok, [
#Linx.User.Map<0 -> 0>,
#Linx.User.Map<1..65535 -> 100000..165535>
# ]}
# A user ns whose maps haven't been written yet -- the file
# exists but is empty; the kernel defaults the workload's
# identity to "nobody".
Linx.User.read_uid_map(host_pid)
# => {:ok, []}The Inspect impl picks its format by length:
| Length | Renders as |
|---|---|
| 1 | #Linx.User.Map<0 -> 1000> (compact, no range syntax) |
| > 1 | #Linx.User.Map<0..65535 -> 100000..165535> (range form, inclusive end) |
The struct itself is just three fields — :inside, :outside,
:length — and a %Linx.User.Map{} round-trips cleanly back to a
{inside, outside, length} tuple if you want to hand it to
set_uid_map/2 on a different pid:
{:ok, maps} = Linx.User.read_uid_map(source_pid)
mappings = Enum.map(maps, &{&1.inside, &1.outside, &1.length})
:ok = Linx.User.set_uid_map(target_pid, mappings)Errors
Same shape as the write verbs — %Linx.User.Error{operation: :read_uid_map | :read_gid_map} for kernel-level failures:
Linx.User.read_uid_map(9_999_999)
# => {:error,
# %Linx.User.Error{
# path: "/proc/9999999/uid_map",
# operation: :read_uid_map,
# errno: :enoent,
# code: 2
# }}The parser silently drops malformed lines (forward-compatible
against any future kernel additions to the format) — so the
returned [%Map{}] is always well-formed.
The setup_maps/2 convenience
For the canonical rootless dance, setup_maps/2 does
deny_setgroups → set_uid_map → set_gid_map in one call:
:ok = Linx.User.setup_maps(host_pid,
uid: [{0, my_host_uid, 1}],
gid: [{0, my_host_gid, 1}]
)Equivalent to:
:ok = Linx.User.deny_setgroups(host_pid)
:ok = Linx.User.set_uid_map(host_pid, [{0, my_host_uid, 1}])
:ok = Linx.User.set_gid_map(host_pid, [{0, my_host_gid, 1}])Options
| Option | Required? | Meaning |
|---|---|---|
:uid | yes | mappings list, same shape as set_uid_map/2 |
:gid | yes | mappings list, same shape as set_gid_map/2 |
:setgroups | default :deny | :deny writes "deny" to setgroups; :skip leaves it alone (for privileged callers) |
Failure semantics
Returns the first error encountered, with the failing step's
:operation (or a :bad_setup / :bad_setgroups /
:bad_map shape for caller mistakes):
{:error, {:bad_setup, {:missing, :uid}}} # required opt missing
{:error, {:bad_setgroups, :sometimes}} # bad :setgroups value
{:error, {:bad_map, _}} # bad uid/gid input
{:error, %Linx.User.Error{operation: :deny_setgroups, ...}}
{:error, %Linx.User.Error{operation: :set_uid_map, ...}}
{:error, %Linx.User.Error{operation: :set_gid_map, ...}}Steps that ran successfully before a later step failed are not
rolled back — the kernel's write-once semantics on uid_map /
gid_map make rollback impossible anyway, and deny_setgroups is
idempotent. The error's :operation tells you exactly where the
sequence stopped.
Full end-to-end: rootless bash in a browser-ready container
Combining everything across Linx.Process + Linx.Mount +
Linx.User for the headline composition. The workload becomes
root inside its own user namespace, with /proc remounted so
ps shows container processes:
alias Linx.Process, as: P
alias Linx.{Mount, User, Tty}
{:ok, c} =
P.spawn(
argv: ["/bin/sh"],
namespaces: [:user, :mount, :pid, :uts, :ipc],
stdio: :pty
)
# With :pid in the namespaces list, the :ready event's pid is
# the child's *own* view (= 1). For procfs writes we need the
# host's view of the child -- P.host_pid/1 returns that.
receive do {:linx_process, :ready, _child_view} -> :ok end
{:ok, host_pid} = P.host_pid(c)
# Set up the rootless mapping at the checkpoint.
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}])
# Give the container its own /proc (also at the checkpoint).
#
# NOTE: this step requires the BEAM to have CAP_SYS_ADMIN in the
# child's user namespace -- i.e. the BEAM must be running as the
# system root, not just "root inside the new user ns". A rootless
# BEAM (uid 1000) will get EPERM here. The runc-style workaround
# in that case is to have the workload itself do the /proc
# remount after its execve (where it has full caps in its own
# user ns); see docs/mount/mount-examples.md for the rootless caveat.
:ok = Mount.mount("proc", "/proc", "proc", in: {:pid, host_pid})
# Release -- the workload execs already inside its own user ns
# with the right identity, and with a /proc that only shows
# container processes.
:ok = P.proceed(c)
Tty.attach(:controlling, c)Inside the attached bash:
[root@... /]# whoami
root
[root@... /]# ps
PID TTY TIME CMD
1 pts/0 00:00:00 bash
...The headline transcript from the project README, but now with
root inside and a container-only /proc view — both
fixes layered on top of the original via the same checkpoint
window that everything else composes through.