nonet runs a command without access to the outside network, but with a functional loopback interface inside its own network namespace.
The intended result is close to unshare -c -n, except that nonet also brings lo up while still leaving the final command with the caller's visible UID/GID.
With plain unshare, the tradeoff is usually:
unshare -c -nkeeps your visible UID/GID, but the final command cannot bringloupunshare -r -nlets you bringloup, but changes the visible identity to namespace-root
nonet is meant to give you:
- no outside network access
- working isolated loopback
- the final command still running as your normal visible user
This is a convenience/testing tool, not a security boundary.
Run a command:
nonet <command> [args...]
Run a shell:
nonet
Stop option parsing:
nonet -- --test
That executes a command literally named --test.
Run the built-in runtime check:
nonet --self-test
Inside nonet:
- the process has its own network namespace
loexists and is brought up automatically127.0.0.1works inside that namespace- the loopback there is separate from the host loopback
- binding
127.0.0.1:1234insidenonetdoes not conflict with the host binding the same address and port - the final command still sees your visible UID/GID
For example, on a host with several normal interfaces:
$ ip -br -4 a
lo UNKNOWN 127.0.0.1/8
eth-br0 UP 192.168.10.55/24 192.168.10.56/24
tailscale0 UNKNOWN 100.64.10.20/32
br-97fe51b85c78 UP 172.19.0.1/16
br-c60fa28dbea0 UP 172.18.0.1/16
docker0 DOWN 172.17.0.1/16
$ nonet ip -br -4 a
lo UNKNOWN 127.0.0.1/8
Supplementary groups may display oddly, similar to unshare -c -n. In practice this shows up in tools such as id -G, which report the supplementary group list via getgroups(2). Across user namespaces that output can look strange or partially remapped even when actual filesystem permission checks through those groups still behave as expected. In testing, group-based access still worked despite the odd-looking id -G output.
nonet is a single binary. It does not invoke unshare, newuidmap, or newgidmap.
The implementation has two layers:
- normal Go control flow in cli.go, runner.go, probe.go, and netns.go
- a small in-binary cgo/C shim in spawn_linux.go
The basic sequence is:
- The parent process opens a pipe for synchronization.
- The parent calls the in-binary C shim.
- The shim uses
clone(CLONE_NEWUSER | SIGCHLD, ...)to start a child directly in a fresh user namespace. - The child blocks immediately on the sync pipe before doing any namespace work.
- The parent writes one-line identity mappings into:
/proc/<child-pid>/uid_map/proc/<child-pid>/gid_map
- Before writing
gid_map, the parent writesdenyto/proc/<child-pid>/setgroups, which is required for the unprivileged GID mapping path. - The parent releases the child by writing one byte to the pipe.
- The child calls
unshare(CLONE_NEWNET). - The child brings
loup usingioctl(SIOCGIFFLAGS)andioctl(SIOCSIFFLAGS)on a datagram socket. - The child
execve()s the resolved command path.
The important detail is step 9 happens before the final exec.
Here, <child-pid> means the PID of the just-cloned helper as seen by the parent in the parent namespace. The parent writes those procfs files from outside the child before releasing it to continue.
That is why this works while plain unshare -c -n <cmd> does not: the helper still has capabilities in the fresh user namespace at that point, so it can create the new network namespace and configure loopback before handing control to the final command.
The current design uses a simple identity map, not subordinate ID ranges.
The parent writes:
uid_map: <uid> <uid> 1
gid_map: <gid> <gid> 1
That keeps the final command's visible UID/GID unchanged.
Other IDs are not preserved. In particular, host-owned 0:0 objects such as / will usually appear as the overflow owner/group, just as they do under unshare with a simple current-user mapping. So behavior for owners other than the current user is intentionally on par with unshare, not a special remapping done by nonet.
This is enough because nonet does not try to preserve extra namespace-root identity after exec; it only needs the temporary privileges that exist before exec in the freshly created user namespace.
The user-namespace child is created with a raw clone(2) call from the small C layer.
That avoids relying on external helpers and keeps the low-level namespace creation step explicit and predictable. The Go side then handles the parent-side orchestration, mapping writes, and self-test logic.
The project uses cgo on purpose.
Go can call Linux syscalls, but it does not provide a clean public API for the exact process-creation sequence nonet needs.
The low-level part of nonet needs to:
- create a helper directly with
clone(CLONE_NEWUSER) - pause that helper immediately
- let the parent write UID/GID maps through procfs
- then continue the child into further namespace setup before the final
exec
That can in principle be attempted without cgo, but in practice this particular clone/synchronize/map/continue path is much more predictable with a very small C layer than through ordinary Go process APIs.
So the tradeoff chosen by this project is:
- keep almost all logic in Go
- keep the namespace-critical process creation step in a tiny C shim
- avoid external helper binaries
- accept that builds require cgo
nonet --self-test performs an end-to-end runtime probe of the actual execution path.
It checks:
- visible UID/GID
/proc/sys/kernel/unprivileged_userns_cloneif present- helper spawn and user-namespace setup
- successful network-namespace creation
- that only
lois present in the namespace - that
lois up - TCP loopback connectivity on
127.0.0.1 - access to the caller's home directory
If this passes, the host is a good candidate for nonet.
nonet is not a general-purpose sandbox.
It prevents normal network access by running the command in a separate network namespace, but it does not attempt to confine the process in other ways. In particular, it does not block:
- filesystem access available to your user
- Unix sockets
- inherited file descriptors
- other local IPC mechanisms
So it is appropriate for things like:
- testing builds without outside network access
- checking whether a process unexpectedly reaches out to the network
- running one command with isolated loopback
It should not be treated as a hardened security container.
- user namespaces available on the target host
- network namespaces available on the target host
- a runtime policy that allows this style of unprivileged namespace creation
- cgo enabled at build time
The produced binary targets Linux. Because the project uses cgo for the namespace helper, building a Linux binary on a non-Linux host requires a Linux-capable C cross-toolchain.
It may fail inside restricted containers even if it works on the host.
Build:
make build
Static build:
make static
On SteamDeck / SteamOS, the easiest way to avoid depending on host development packages is to build inside a Distrobox container based on Valve's Steam Runtime Sniper SDK:
distrobox create -i registry.gitlab.steamos.cloud/steamrt/sniper/sdk:latest sniper
distrobox enter sniper
Inside the Distrobox, install Go from the upstream tarball. This keeps the Go version aligned with this repo instead of relying on older distro packages:
GO_VERSION=1.26.3
curl -L "https://dl.google.com/go/go${GO_VERSION}.linux-amd64.tar.gz" -o /tmp/go.tgz
mkdir -p "$HOME/.local/opt"
rm -rf "$HOME/.local/opt/go"
tar -C "$HOME/.local/opt" -xzf /tmp/go.tgz
export PATH="$HOME/.local/opt/go/bin:$PATH"
Then build normally from the checked-out repo:
cd /path/to/nonet
make build
On SteamDeck, the binary is written to:
build/linux-amd64/bin/nonet
For cross-architecture cgo builds, provide a matching C cross-compiler. For example:
GOOS=linux GOARCH=arm64 CC=aarch64-linux-gnu-gcc make build
Run tests:
make test
Install:
make install
That installs to /usr/local/bin/nonet when run as root, or to $HOME/.local/bin/nonet otherwise.
Output binary:
build/<goos>-<goarch>/bin/nonet
Both normal and static builds require cgo, because the project uses the in-binary C shim for the namespace helper.