Skip to main content
7 of 10
add exec to remove need for better signal propagation

Note: as bwrap is available in more places than util-linux > 2.39.1 right now, this answer using bwrap is probably best.

[EDIT: With updates below]

You can't change the UID but you can start off as non-root and then use nsenter to get root and make the mount.

# get a new mount and user namespace in background
unshare --mount --user --map-user=$(id -u) --map-group=$(id -g) --map-users=auto --map-groups=auto --keep-caps --setgroups allow /bin/bash <&0 &

# now user nsenter without --preserve-credentials to do the mount
nsenter -U -m --target $! mount $SRC $DST -o rbind

# wait for unshare to complete
wait $!

This actually works, but needs error handling and something to cause the main namespace process to wait until the mount is done

Maybe just have another nsenter with the --preserve-credentials to run the process that needs the mount. There needs to be some way to keep the initial unshare active until the mount is done and the main process has started, and then close it so the whole thing can neatly collapse when the process finishes.

When I can find a way that doesn't use bash's coproc, I'll update my answer


As promised, the update

The need is to start a names-space, wait until it is ready, perform some mounts as fake-root, and when they are ready, let the namespace run its tasks.

This takes two lots of synchronization, to wait until the namespace is ready before performing mounts, and to wait until mounts are ready before running the process.

This solution uses anonymous-ish pipes (using helper mk2pipe), and preserves stdio for the invoked process.

#! /bin/bash

# open an anonymous pipe, put the read handle in variable named $1 and the write handle in variable named $2
# WARNING: Be careful about closing write handles for read commands/subshells, or the read-handle may never get eof
# e.g. do this:
# good:   mk2pipe r w ; ( fd-close $w  ; cat <&${r} ; ) & echo hello >${w}
# better: mk2pipe r w ; ( fd-close $w  ; cat <&${r} ; ) & fd-close $r ; echo hello >${w}
mk2pipe() {
  mkfifo $(mktemp -u) && local header_pipe="$_" || return $?
  # the <> keeps it open both ways long enough for us to open a read and a write handle without blocking
  eval exec "{_}<>'$header_pipe' {$1}<'$header_pipe' {$2}>'$header_pipe' {_}<&-"
  set $?
  rm -f "$header_pipe"
  return $?
}

in-mount() (
  local session session_pid mounts
  while test $# != 0
  do case "$1" in
     *=*) mounts+=("$1") ; shift ;;
     --) shift ; break ;;
     *) break;
     esac     
  done

  # 2 pipes for synchronization
  mk2pipe r1 w1 || return $?
  mk2pipe r2 w2 || return $?

  unshare --mount --user --map-user=$(id -u) --map-group=$(id -g) --map-users=auto --map-groups=auto --keep-caps --setgroups allow /bin/bash --noprofile --norc -c "exec ${r1}<&- ${w2}>&- && echo \$\$ >&${w1} && exec ${w1}>&- || exit \$? ; read -u ${r2} && exec ${r2}<&- && exec \"\$@\"" unshare "$@" <&0 &
  session_pid=$!

  # close the ends we don't need
  eval "exec ${w1}>&- ${r2}<&-"
  # wait until namespaces are setup
  read -r -u ${r1} || return $?
  eval "exec ${r1}<&-"

  # mount as "root" via nsenter
  for mount in "${mounts[@]}"
  do nsenter -U -m --target $session_pid mount "${mount#*=}" "${mount%=*}" -o rbind || return $?
  done
  # signal that mounts are setup
  echo >&${w2} # trigger to start
  eval "exec ${w2}>&-"

  # wait for unshare to complete
  wait $session_pid
)

use as:

in-mount [bind-mount] ... [--] command [arg] ...

e.g.

in-mount /build-dir=$PWD/build -- make build

You may prefer this in-mount function which runs the main process in the foreground with different signal handling effects:

in-mount() (
  local session session_pid mounts
  while test $# != 0
  do case "$1" in
     *=*) mounts+=("$1") ; shift ;;
     --) shift ; break ;;
     *) break;
     esac
  done

  # 2 pipes for synchronization
  mk2pipe r1 w1 || return $?
  mk2pipe r2 w2 || return $?

  (
    # close the ends we don't need
    eval "exec ${w1}>&- ${r2}<&-"
    # wait until namespaces are setup
    read -r -u ${r1} session_pid || return $?
    eval "exec ${r1}<&-"

    # mount as "root" via nsenter
    for mount in "${mounts[@]}"
    do nsenter -U -m --target $session_pid mount "${mount#*=}" "${mount%=*}" -o rbind || return $?
    done
    # signal that mounts are setup
    echo >&${w2} # trigger to start
    eval "exec ${w2}>&-"
  ) &

  exec unshare --mount --user --map-user=$(id -u) --map-group=$(id -g) --map-users=auto --map-groups=auto --keep-caps --setgroups allow /bin/bash --noprofile --norc -c "exec ${r1}<&- ${w2}>&- && echo \$\$ >&${w1} && exec ${w1}>&- || exit \$? ; read -u ${r2} && exec ${r2}<&- && exec \"\$@\"" unshare "$@"
)

Here's a simpler version using coproc which doesn't need the m2pipe helper, using tips from https://stackoverflow.com/a/47213971. Tested on bash 5.1.15 and unshare from util-linux 2.39.1 under ubuntu 23.04

#! /bin/bash

in-mount() (
  local session session_pid mounts
  while test $# != 0
  do case "$1" in
     *=*) mounts+=("$1") ; shift ;;
     --) shift ; break ;;
     *) break;
     esac
  done

  coproc mounter {
    # session_pid will be the same as PPID but we need to wait until namespaces are setup
    read -r session_pid || return $?
    exec 0<&-

    # mount as "root" via nsenter
    for mount in "${mounts[@]}"
    do # quit on error without writing to stdout
       nsenter -U -m --target $session_pid mount "${mount#*=}" "${mount%=*}" -o rbind || return $?
    done
    # signal that mounts are setup
    echo $?
  }

  exec {out}>>/dev/fd/${mounter[1]} {in}</dev/fd/${mounter[0]}
  exec unshare --mount --user --map-user=$(id -u) --map-group=$(id -g) --map-users=auto --map-groups=auto --keep-caps --setgroups allow /bin/bash --noprofile --norc -c "echo \$\$ >&${out} && exec ${out}>&- || exit \$? ; read -u ${in} && exec ${in}<&- && exec \"\$@\"" unshare "$@"
  exit $?
)