ns: Spawn namespaced sandboxes - scripts - various script and utils
HTML git clone git://z3bra.org/scripts
DIR Log
DIR Files
DIR Refs
---
DIR commit 1e86c2eaa87b6bfa7ae9c138d8c97ffc895f6990
DIR parent 6ca2b5c87e6b9b8603995b3db4968f6e67f3b2f0
HTML Author: Willy Goiffon <dev@z3bra.org>
Date: Thu, 20 Aug 2026 23:06:50 +0000
ns: Spawn namespaced sandboxes
Diffstat:
A ns | 261 +++++++++++++++++++++++++++++++
A ns-install | 135 +++++++++++++++++++++++++++++++
A ns-mount | 42 +++++++++++++++++++++++++++++++
3 files changed, 438 insertions(+), 0 deletions(-)
---
DIR diff --git a/ns b/ns
@@ -0,0 +1,261 @@
+#!/bin/sh
+
+set -e
+
+verbose=0
+create=0
+detach=0
+nsdir=${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/ns
+nstmpl="ns-XXXX"
+nsuser="wgs"
+nsnet="172.16.0.0/12"
+
+usage() {
+ echo "usage: $(basename $0) [-vclk] [-n name] [cmd [arg…]]" >&2
+}
+
+logset() {
+ [ "${verbose:-0}" -gt 0 ] && printf '[ ] %s ' "$*" >&2 || true
+}
+
+logchk() {
+ [ "${verbose:-0}" -gt 0 ] && printf '\r[[1;32mx[0m]\n' >&2 || true
+}
+
+logdie() {
+ [ "${verbose:-0}" -gt 0 ] && printf '\r[[1;31m![0m]\n' >&2
+ ns_cleanup $nsname
+ exit 1
+}
+
+nslist() {
+ [ -d /run/netns ] && ls -1 /run/netns
+}
+
+nspid() { # 2nd PID is unshare(1)'s first child, aka PID 1
+ ip netns pids $nsname|head -n2|tail -n1
+}
+
+nscwd() {
+ readlink -f /proc/$(nspid)/cwd
+}
+
+nsip() {
+ ns_exec ip -4 -o addr show "if"|awk '{print $4}'
+}
+
+ns_cleanup() {
+ nsname="$1"
+ if=$(ip -o link show|awk "/netns $nsname/{print \$2}"|cut -d@ -f1)
+ if [ -n "$if" ]; then
+ net=$(ip -o -4 addr show dev "$if"|awk '{print $4}')
+ doas iptables -t nat -D POSTROUTING -s "$net" -j MASQUERADE
+ fi
+ ip netns pids $nsname | xargs -r kill -9
+ doas ip netns del $nsname
+ doas umount -Rq $nsdir/$nsname
+ rmdir "$nsdir/$nsname"
+}
+
+ns_mkfs() {
+ dir=$1
+ logset "Create rootfs structure"
+
+ mkdir -p $dir
+ doas mount -t tmpfs tmpfs "$dir"
+ for d in bin etc lib home/$nsuser tmp mnt dev dev/pts dev/shm sys proc; do
+ mkdir -p $dir/$d
+ done
+ for d in null zero full random urandom tty; do
+ touch "$dir/dev/$d"
+ doas mount --bind "/dev/$d" "$dir/dev/$d"
+ done
+ logchk
+
+ logset "Generate system files"
+ cp -r /usr/share/terminfo $dir/etc/
+ install -D /etc/ssl/certs/ca-certificates.crt $dir/etc/ssl/certs/ca-certificates.crt
+ echo "$nsname" > $dir/etc/hostname
+ echo "hosts: files dns" > $dir/etc/nsswitch.conf
+ echo "127.0.0.1 localhost" > $dir/etc/hosts
+ printf 'nameserver %s\n' $(resolvectl dns|cut -d: -f2)|sort|uniq > $dir/etc/resolv.conf
+ printf "export PS1='(%s) $ '" $nsname > $dir/etc/profile
+ printf "%s:x:0:0::/home/%s:/bin/sh" $nsuser $nsuser > $dir/etc/passwd
+ printf "%s:*::0:::::" $nsuser > $dir/etc/shadow
+ printf "world:x:0:%s\n" $nsuser > $dir/etc/group
+ ln -s ../proc/mounts "$dir/etc/mtab"
+ ln -sf pts/ptmx "$dir/dev/ptmx"
+ ln -sf / "$dir/usr"
+ logchk
+
+ cat <<-EOF > "$dir/etc/rc"
+ #!/bin/sh
+ /bin/mount -t sysfs sys /sys
+ /bin/mount -t tmpfs tmpfs /dev/shm
+ /bin/mount -t devpts devpts /dev/pts -o newinstance,ptmxmode=0666
+ /bin/hostname $nsname
+ EOF
+ chmod +x $dir/etc/rc
+
+ logset "Compile /bin/init"
+ cc -x c - -o $dir/bin/init <<-EOF
+ #include <signal.h>
+ #include <unistd.h>
+ #include <sys/wait.h>
+ int main() {
+ sigset_t set; int signum;
+ if (getpid()!=1) return 1;
+ sigfillset(&set); sigprocmask(SIG_BLOCK, &set, 0);
+ if (fork()>0) while(!sigwait(&set, &signum)) wait(NULL);
+ sigprocmask(SIG_UNBLOCK, &set, 0); setsid(); setpgid(0, 0);
+ return execve("/etc/rc", (char *[]){"rc", 0}, (char *[]){0});
+ }
+ EOF
+ logchk
+}
+
+ns_install() {
+ dir=$1
+ logset "Boostrap utilities"
+ # https://pubs.opengroup.org/onlinepubs/9799919799/idx/utilities.html
+ exe="admin alias ar asa at awk basename batch bc bg c17 cal"
+ exe="$exe cat cd cflow chgrp chmod chown cksum cmp comm command"
+ exe="$exe compress cp crontab csplit ctags cut cxref date dd"
+ exe="$exe delta df diff dirname du echo ed env ex expand expr"
+ exe="$exe false fc fg file find fold fuser gencat get getconf"
+ exe="$exe getopts gettext grep hash head iconv id ipcrm ipcs"
+ exe="$exe jobs join kill lex link ln locale localedef logger"
+ exe="$exe logname lp ls m4 mailx make man mesg mkdir mkfifo more"
+ exe="$exe msgfmt mv newgrp ngettext nice nl nm nohup od paste"
+ exe="$exe patch pathchk pax pr printf prs ps pwd read readlink"
+ exe="$exe realpath renice rm rmdel rmdir sact sccs sed sh sleep"
+ exe="$exe sort split strings strip stty tabs tail talk tee test"
+ exe="$exe time timeout touch tput tr true tsort tty type ulimit"
+ exe="$exe umask unalias uname uncompress unexpand unget uniq"
+ exe="$exe unlink uucp uudecode uuencode uustat uux val vi wait"
+ exe="$exe wc what who write xargs xgettext yacc zcat"
+
+ # Missing from the above list
+ exe="$exe mksh bash busybox mount umount mountpoint"
+ exe="$exe tar gzip bzip2 xz zstd less"
+
+ # Network utils
+ exe="$exe hostname ip ping host dig curl ssh tcpdump"
+
+ which $exe|xargs cp -t $dir/bin
+ ldd $(readlink -f `which $exe`) 2>/dev/null \
+ |awk '/=>/{print $3}/^\s+\//{print $1}' \
+ |sed 's,^/usr,,'|sort|uniq|cpio --quiet -updL $dir
+ logchk
+}
+
+ns_boot() {
+ ns=$1
+ logset "Start init process"
+ unshare -rf -Uuimpn --mount-proc --kill-child -R $ns -- /bin/init &
+ doas ip netns attach $nsname $!
+
+ [ -z "$(nspid)" ] && logdie "Init process failed to start"
+ logchk
+}
+
+ns_network() {
+ ns=$1
+ pid=$(nspid)
+ nsif="$nsname"
+ logset "Setup network"
+
+ # Grab a random prefix from the given netblock to handle
+ # communication between host and namespace (through NAT)
+ pfx=20
+ net=$(ipcalc -nb "$nsnet" $pfx|awk '/Network/{print $2}'|shuf|head -n1)
+ min=$(ipcalc -nb $net|awk '/HostMin/{print $2}')
+ max=$(ipcalc -nb $net|awk '/HostMax/{print $2}')
+ doas ip link add $nsif type veth peer ${nsif}-peer
+ doas ip link set $nsif up
+ doas ip addr add "$min/$pfx" dev $nsif
+ doas sysctl -qw net.ipv4.ip_forward=1
+ doas iptables -t nat -A POSTROUTING -s $net -j MASQUERADE
+
+ # Move 2nd pair of veth inside the namespace
+ doas ip link set ${nsif}-peer netns $nsname
+ nsenter -t "$(nspid)" -r --wd=$ns --preserve-credentials -U -u -p -i -n -m -- env -i /bin/sh <<-EOF
+ /bin/ip link set lo up
+ /bin/ip link set ${nsif}-peer name if
+ /bin/ip link set if up
+ /bin/ip address add ${max}/${pfx} dev if
+ /bin/ip route add default via ${min}
+ EOF
+ logchk
+}
+
+ns_pop() {
+ # Generate random nsname if not provided
+ : ${nsname:=ns-$(tr -cd a-z </dev/urandom|dd status=none bs=1 count=5)}
+
+ mkdir -p "$nsdir/$nsname"
+ trap "ns_cleanup $nsname" EXIT INT TERM QUIT
+
+ ns_mkfs "$nsdir/$nsname"
+ ns_install "$nsdir/$nsname"
+ ns_boot "$nsdir/$nsname"
+ ns_network "$nsdir/$nsname"
+ trap - EXIT
+}
+
+ns_exec() {
+ pid=$(nspid)
+ cwd=$(nscwd)
+ nshome=$(head -n1 $cwd/etc/passwd|cut -d: -f 6)
+ nsenter -t $pid -r --wd=$cwd --preserve-credentials -U -u -p -i -n -m -- \
+ env -i -C $nshome \
+ PATH=/bin \
+ USER=$nsuser \
+ HOME=$nshome \
+ TERM="${TERM:-vt100}" \
+ LESS="${LESS:-R}" \
+ PAGER="${PAGER:-less}" \
+ EDITOR="${EDITOR:-vi}" \
+ VISUAL="${VISUAL:-vi}" \
+ "$@"
+}
+
+ns_info() {
+ nsname="$1"
+ [ -z "$(nspid)" ] && exit 1
+ cat <<-EOF
+ name: $nsname
+ user: $(cut -d: -f1 < $(nscwd)/etc/passwd)
+ root: $(nscwd)
+ init: $(nspid)
+ ipv4: $(nsip)
+ EOF
+}
+
+while getopts "hvcdli:k:n:u:" OPT; do
+ case $OPT in
+ d) detach=1;;
+ n) nsname="$OPTARG";;
+ u) nsuser="$OPTARG";;
+ v) verbose=1;;
+ c) create=1;;
+ i) ns_info "$OPTARG"; exit 0;;
+ k) ns_cleanup "$OPTARG"; exit 0;;
+ l) nslist; exit 0;;
+ h) usage; exit 0;;
+ *) usage; exit 1;;
+ esac
+done
+shift $((OPTIND-1))
+
+# Calling ns(1) without argument will attach to the first available
+# namespace. If no namespace is available, a new one will be spun up.
+if [ "$create" -eq 1 -o -z "$(nslist)" ]; then
+ ns_pop
+ [ "$detach" -eq 1 ] && { echo $nsname; exit 0; }
+fi
+
+[ -z "$nsname" ] && nsname=$(nslist|head -n1)
+
+# Pop a shell or whatever args provided
+ns_exec ${@:-mksh -l}
DIR diff --git a/ns-install b/ns-install
@@ -0,0 +1,135 @@
+#!/bin/sh
+
+usage() {
+ echo "usage: $(basename $0) [-al] [-n ns] [pkg...]" >&2
+}
+
+# Fix missing shared libs
+shlibfix() {
+ find $1/bin $1/libexec -executable -type f -exec ldd {} + 2>/dev/null \
+ |awk '/=>/{print $3}/^\s+\//{print $1}' \
+ |sed 's,/usr,,'|sort|uniq|cpio --quiet -updL $1
+}
+
+# https://claude.ai/install.sh
+install_claude() {
+ d="$1"
+ u="https://downloads.claude.ai/claude-code-releases"
+ a="linux-x64"
+ v=$(curl -sSfL "$u/latest")
+ flock $l curl -sSfL "$u/$v/$a/claude" -o $root/bin/claude &
+ spinner -p "Install claude ($v)" $l
+ chmod +x $d/bin/claude
+}
+
+# https://developer.hashicorp.com/terraform/install
+install_terraform() {
+ z=$(mktemp --suffix .zip)
+ u="https://releases.hashicorp.com/terraform"
+ a="linux_amd64"
+ v=$(curl -s "https://developer.hashicorp.com/terraform/install"|sed -rn 's/.* ([0-9.]*) \(latest\).*/\1/p')
+ flock $l curl -sSfL "$u/$v/terraform_${v}_${a}.zip" -o $z &
+ spinner -p "Install terraform ($v)" $l
+ unzip -q -o -d $root/bin $z terraform
+ rm -f $z
+}
+
+# https://gameoftrees.org/portable.html
+install_got() {
+ d="$1"
+ tmp=$(mktemp -d)
+ u="https://gameoftrees.org/releases/portable"
+ v=$(curl -sSfL "https://gameoftrees.org/portable.html"|sed -rn '/Latest Release/s/.*>([0-9.]+).*/\1/p')
+ flock $l sh <<-EOF &
+ curl -sSfL "$u/got-portable-${v}.tar.gz" | tar -xz -C $tmp
+ cd "$tmp/got-portable-${v}"
+ ./configure -q CPPFLAGS='-DGOT_DIAL_PATH_SSH=\"/bin/ssh\"' --prefix='' >/dev/null
+ make -j $(nproc) CC=clang DESTDIR="$d" install >/dev/null 2>&1
+ EOF
+ spinner -p "Install got (${v})" $l
+ rm -rf $tmp
+}
+
+# https://www.brain-dump.org/projects/abduco
+install_abduco() {
+ d="$1"
+ tmp=$(mktemp -d)
+ u="https://github.com/martanne/abduco/releases/download"
+ v="0.6"
+ flock $l sh <<-EOF &
+ curl -sSfL "$u/v${v}/abduco-${v}.tar.gz" | tar -xz -C $tmp
+ cd "$tmp/abduco-${v}"
+ make -j $(nproc) CC=clang DESTDIR="$d" PREFIX= install >/dev/null 2>&1
+ EOF
+ spinner -p "Install abduco (${v})" $l
+ rm -rf $tmp
+}
+
+# https://github.com/martanne/vis
+install_vis() {
+ d="$1"
+ tmp=$(mktemp -d)
+ u="https://github.com/martanne/vis"
+ a="https://api.github.com/repos/martanne/vis"
+ v=$(curl -sSfL "$a/commits" 2>/dev/null|grep -m1 -Eo '"sha": "[0-9a-f]+"'|cut -b9-48)
+ flock $l sh <<-EOF &
+ curl -sSfL "$u/archive/${v}.tar.gz" | tar -xz -C $tmp
+ cd "$tmp/vis-${v}"
+ ./configure --enable-static >/dev/null
+ make -j $(nproc) DESTDIR="$d" PREFIX= install >/dev/null 2>&1
+ EOF
+ sha=$(echo $v|cut -c 1-7)
+ spinner -p "Install vis ($sha)" $l
+ rm -rf $tmp
+}
+
+install_nodejs() {
+ d="$1"
+ tmp=$(mktemp -d)
+ v=
+ u="https://nodejs.org/dist"
+ v=$(curl -sSfL $u/latest/SHASUMS256.txt|sed -rn '/linux-x64.tar.gz/s/.*(v[0-9.]+).*/\1/p')
+ flock $l sh <<-EOF &
+ curl -sSfL "$u/$v/node-$v-linux-x64.tar.gz" | tar -xz -C $tmp
+ rsync -a $tmp/node-${v}-linux-x64/bin $tmp/node-${v}-linux-x64/lib $root/
+ EOF
+ spinner -p "Install nodejs (${v})" $l
+ rm -rf $tmp
+}
+
+list_pkg() {
+ sed -rn 's/^install_(.*)\(\) \{/\1/p' "$0"|sort
+}
+
+pkg=
+nsname=$(ns -l|head -n1)
+while getopts "ahln:" OPT; do
+ case $OPT in
+ a) pkg=$(list_pkg);;
+ l) list_pkg; exit 0;;
+ n) nsname="$OPTARG";;
+ h) usage; exit 0;;
+ *) usage; exit 1;;
+ esac
+done
+shift $((OPTIND-1))
+
+: ${pkg:=$@}
+
+if [ -z "$pkg" ] && [ $# -lt 1 ]; then
+ usage
+ exit 1
+fi
+
+root=$(ns -i $nsname|awk '/^root:/{print $2}')
+for p in $pkg; do
+ if ! list_pkg|grep -q "^${p}\$"; then
+ printf '%s: Not in package list\n' "$p" >&2
+ exit 1
+ fi
+done
+
+l=$(mktemp)
+trap "rm -f $l" EXIT INT TERM QUIT
+for p in $pkg; do install_$p $root; done
+shlibfix $root
DIR diff --git a/ns-mount b/ns-mount
@@ -0,0 +1,42 @@
+#!/bin/sh
+
+ro=""
+rw=""
+
+usage() {
+ echo "usage: $(basename $0) -n name [-rw dir] mountpoint" >&2
+}
+
+while getopts "hn:r:w:" OPT; do
+ case $OPT in
+ n) nsname="$OPTARG";;
+ r) ro="$OPTARG";;
+ w) rw="$OPTARG";;
+ h) usage; exit 0;;
+ *) usage; exit 1;;
+ esac
+done
+shift $((OPTIND-1))
+
+if [ -z "$nsname" ] || [ $# -lt 1 ]; then
+ usage
+ exit 1
+fi
+
+mp="$1"
+ns=$(ns -i $nsname|awk '/root/{print $2}')
+pid=$(ns -i $nsname|awk '/init/{print $2}')
+
+opt="lowerdir=${ns}/${mp}"
+if [ -n "$ro" ]; then
+ opt="${opt}:$(readlink -f $ro)"
+elif [ -n "$rw" ]; then
+ wd="$HOME/.cache/overlayfs/work/$nsname"
+ mkdir -p "$wd" "${ns}/${mp}"
+ opt="${opt},workdir=$wd"
+ opt="${opt},upperdir=$(readlink -f $rw)"
+fi
+
+mkdir -p "${ns}/${mp}"
+nsenter -t $pid -m -U --preserve-credentials -- \
+ mount --onlyonce -t overlay overlay -o "$opt" "$ns/$mp"