diff --git a/docs/rootless.md b/docs/rootless.md index ae24512..0393e83 100644 --- a/docs/rootless.md +++ b/docs/rootless.md @@ -169,10 +169,14 @@ root and several users, `brig-ctl rootless` against one shared install is the better trade. The host prerequisites above still apply, and `install.sh --user` checks them -before it downloads anything. When sudo runs without a password, it goes on and -the rootless setup makes them with sudo at the end. When it does not, the -installer stops there. It prints the commands that prepare the host for this -user and this prefix, as one block for root: +before it downloads anything. When sudo runs without a password, as on a cloud +image, the installer makes them itself: it installs `uidmap` and `acl` and adds +a subuid range before the download, and the rootless setup makes the rest at +the end. When sudo needs a password, a user in the `sudo`, `admin` or `wheel` group is +asked for it once on the terminal, and the install goes on the same way. Any +other user, or a run with no terminal, stops there. The installer prints the +commands that prepare the host for this user and this prefix, as one block for +root: ```console $ curl -fsSL https://raw.githubusercontent.com/NOFireAI/brig-standalone-linux/main/install.sh | sh - diff --git a/install.sh b/install.sh index 6a3f1d1..99ae0ce 100755 --- a/install.sh +++ b/install.sh @@ -178,6 +178,8 @@ setup_env() { case "${INSTALL_BRIG_VERBOSE:-false}" in true|1) VERBOSE=true ;; esac # A user install is the rootless path, so it implies the rootless bundle. WANT_ROOTLESS="${INSTALL_BRIG_ROOTLESS:-false}" + # Where sudo can ask for a password. A test points it at a file. + TTY="${INSTALL_BRIG_TTY:-/dev/tty}" [ "$INSTALL_MODE" = "user" ] && WANT_ROOTLESS=true SKIP_START="${INSTALL_BRIG_SKIP_START:-false}" @@ -237,7 +239,10 @@ verify_system() { command -v "$tool" >/dev/null 2>&1 || fatal "'$tool' is required and was not found" done - command -v iptables >/dev/null 2>&1 || warn "iptables not found, urunc cannot set up NAT for guests" + # A user install makes it with the other packages: see verify_rootless_prereqs. + if [ "$INSTALL_MODE" != "user" ]; then + have_iptables || warn "iptables not found, so a sandbox network cannot start: the CNI firewall plugin runs it" + fi # Probe for devmapper thin-pool support so the pool can be provisioned (and # devmapper made switchable at runtime). Missing tools or a kernel without @@ -263,23 +268,87 @@ verify_system() { # # - No root can fix a missing login session or systemd, so those stop the # install. -# - Packages and a subuid range are root's, and brig-rootless-setup.sh does -# not make them, so those stop the install too. +# - Packages and a subuid range brig-rootless-setup.sh does not make. With a +# sudo that runs without a password, the installer makes them here, before +# the download. A fresh Ubuntu cloud image lacks uidmap and acl, and +# stopping on them stopped a user who had that sudo. # - The rest brig-rootless-setup.sh makes with sudo at the end of the -# install. Without a sudo that runs without a password, it would stop at a -# prompt after the whole bundle is downloaded and unpacked. So the install -# stops here instead. +# install. +# +# Without a sudo that runs without a password, any of these would stop at a +# prompt after the whole bundle is downloaded and unpacked. So the install +# stops here instead. # # Whatever needs root is printed as one block of commands for this user and # this prefix. An admin runs it once, and the next install needs no sudo. The # checks and the commands mirror brig-rootless-setup.sh; keep them in step. +# Debian keeps it in /usr/sbin, which a user's PATH does not have, and the CNI +# plugins find it there all the same. +have_iptables() { + command -v iptables >/dev/null 2>&1 || [ -x /usr/sbin/iptables ] || [ -x /sbin/iptables ] +} + +# install_tools_cmd ...: the root command that installs the tools, with the +# package manager this host has and the names it gives them. newuidmap is in +# uidmap on Debian and Ubuntu and in shadow elsewhere; setfacl is in acl +# everywhere; iptables is iptables-nft on the RHEL family and iptables elsewhere. A host with none of these gets a comment, which installs nothing, +# so the check that follows still stops on the missing tool. +install_tools_cmd() { + names="" + for tool in "$@"; do + case "$tool" in + newuidmap) if command -v apt-get >/dev/null 2>&1; then names="$names uidmap" + elif command -v dnf >/dev/null 2>&1 || command -v yum >/dev/null 2>&1; then names="$names shadow-utils" + else names="$names shadow"; fi ;; + setfacl) names="$names acl" ;; + iptables) if command -v dnf >/dev/null 2>&1 || command -v yum >/dev/null 2>&1; then names="$names iptables-nft" + else names="$names iptables"; fi ;; + esac + done + if command -v apt-get >/dev/null 2>&1; then + echo "DEBIAN_FRONTEND=noninteractive apt-get install -y$names /dev/null 2>&1; then + echo "dnf install -y$names" + elif command -v yum >/dev/null 2>&1; then + echo "yum install -y$names" + elif command -v zypper >/dev/null 2>&1; then + echo "zypper --non-interactive install$names" + elif command -v pacman >/dev/null 2>&1; then + echo "pacman -S --noconfirm --needed$names" + else + echo "# install$names with this host's package manager: none that this installer knows is here" + fi +} + +# Whether sudo runs without a password now. Under `curl ... | sh`, stdin is the +# script, so a user whose sudo needs a password is asked for it here, once, on +# the terminal: a user in a sudo group, and only when there is a terminal. +sudo_ready() { + sudo -n true 2>/dev/null && return 0 + [ -z "${SUDO_ASKED:-}" ] || return 1 + SUDO_ASKED=true + case " $(id -nG 2>/dev/null) " in + *" sudo "* | *" admin "* | *" wheel "*) ;; + *) return 1 ;; + esac + ( : < "$TTY" ) 2>/dev/null || return 1 + warn "this host needs root's setup, so sudo asks for your password once" + # The terminal is where the password comes from, not a file sudo reads. + # shellcheck disable=SC2024 + sudo -v < "$TTY" || return 1 + sudo -n true 2>/dev/null +} + verify_rootless_prereqs() { me="$(id -un)" miss="" need="" setup_can=true fix="$TMP_DIR/root-commands.sh" + # The part of $fix that brig-rootless-setup.sh does not make. + own="$TMP_DIR/root-own.sh" : > "$fix" + : > "$own" [ -d "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" ] \ || miss="$miss\n no ${XDG_RUNTIME_DIR:-/run/user/$(id -u)}: log in as this user first" @@ -288,12 +357,17 @@ verify_rootless_prereqs() { pkgs="" command -v newuidmap >/dev/null 2>&1 \ - || { need="$need\n newuidmap is missing"; pkgs="$pkgs uidmap"; } + || { need="$need\n newuidmap is missing"; pkgs="$pkgs newuidmap"; } command -v setfacl >/dev/null 2>&1 \ - || { need="$need\n setfacl is missing"; pkgs="$pkgs acl"; } + || { need="$need\n setfacl is missing"; pkgs="$pkgs setfacl"; } + # The CNI firewall plugin of every sandbox network runs it. openSUSE Leap 16 + # ships nftables alone, and there every run failed in the network setup. + have_iptables \ + || { need="$need\n iptables is missing"; pkgs="$pkgs iptables"; } if [ -n "$pkgs" ]; then setup_can=false - echo "DEBIAN_FRONTEND=noninteractive apt-get install -y$pkgs > "$fix" + # shellcheck disable=SC2086 + install_tools_cmd $pkgs | tee -a "$own" >> "$fix" fi # A range that overlaps another user's maps both users' containers onto the @@ -304,7 +378,7 @@ verify_rootless_prereqs() { first=$(cat /etc/subuid /etc/subgid 2>/dev/null \ | awk -F: '$2 + $3 > m {m = $2 + $3} END {print (m > 100000 ? m : 100000)}') last=$((first + 65535)) - echo "usermod --add-subuids $first-$last --add-subgids $first-$last $me" >> "$fix" + echo "usermod --add-subuids $first-$last --add-subgids $first-$last $me" | tee -a "$own" >> "$fix" fi # The profile names the binary by path, so the one a /var/lib install left @@ -400,7 +474,19 @@ FIX if [ -z "$miss" ] && [ -z "$need" ]; then return 0 fi - if [ -z "$miss" ] && [ "$setup_can" = true ] && sudo -n true 2>/dev/null; then + if [ -z "$miss" ] && [ "$setup_can" = false ] && [ -z "${OWN_MADE:-}" ] \ + && sudo_ready; then + OWN_MADE=true + warn "making what brig-rootless-setup.sh does not, with sudo:" + sed 's/^/ /' "$own" >&2 + # A distribution without apt-get fails here, and the host is then one the + # commands below are for. Otherwise the check runs again on the new host. + if sudo -n sh -eu "$own" >&2; then + verify_rootless_prereqs + return 0 + fi + fi + if [ -z "$miss" ] && [ "$setup_can" = true ] && sudo_ready; then # Counted, not assumed: this said "two" whatever it had found, which # reads as a second thing the reader has missed. n=$(printf '%b' "$need" | grep -c '^ ') @@ -415,10 +501,10 @@ FIX # shellcheck disable=SC2059 printf "[brig-install] ERROR: this host is not set up for a rootless brig:$miss$need\n\n" >&2 if [ -n "$need" ]; then - if [ "$setup_can" = true ]; then - why="sudo cannot run here without a password" + if [ -n "${OWN_MADE:-}" ]; then + why="making them with sudo failed, above" else - why="the installer does not install packages or add subuid ranges" + why="sudo cannot run here without a password" fi cat >&2 <&2 echo " makes these itself." >&2 fi diff --git a/tests/install-preflight.sh b/tests/install-preflight.sh index 244c331..2f06426 100755 --- a/tests/install-preflight.sh +++ b/tests/install-preflight.sh @@ -24,14 +24,40 @@ T="$(mktemp -d)" trap 'rm -rf "$T"' EXIT mkdir -p "$T/stub" "$T/home" "$T/run" -# sudo: only -n true can succeed, and only when the test says sudo works. +# sudo: only -n true can succeed, and only when the test says sudo works. With +# that, -n sh -eu is the installer making packages: it keeps the file, +# and "installs" them as stubs unless the test says the install fails. cat > "$T/stub/sudo" <> "$T/sudo.log" -[ "\$*" = "-n true" ] && [ -f "$T/sudo-ok" ] && exit 0 +# -v is the password prompt, which the test answers right or not at all. +if [ "\$1" = -v ]; then + [ -f "$T/sudo-pass" ] || exit 1 + touch "$T/sudo-ok"; exit 0 +fi +[ -f "$T/sudo-ok" ] || exit 1 +[ "\$*" = "-n true" ] && exit 0 +if [ "\$1 \$2 \$3" = "-n sh -eu" ]; then + cp "\$4" "$T/sudo-ran.sh" + [ -f "$T/apt-fails" ] && exit 100 + for b in newuidmap setfacl iptables; do + printf '#!/bin/sh\nexit 0\n' > "$T/stub/\$b"; chmod 0755 "$T/stub/\$b" + done + exit 0 +fi exit 1 STUB +# id -nG names a sudo group only when the test says this user is in one. +cat > "$T/stub/id" < "$T/stub/getfacl" < "$T/stub/modinfo" -for b in newuidmap setfacl systemctl; do +for b in newuidmap setfacl iptables systemctl; do printf '#!/bin/sh\nexit 0\n' > "$T/stub/$b" done chmod 0755 "$T/stub"/* @@ -66,7 +92,7 @@ run_install() { set +e env -u XDG_CONFIG_HOME -u XDG_DATA_HOME \ HOME="$T/home" XDG_RUNTIME_DIR="$T/run" PATH="$T/stub:$PATH" \ - INSTALL_BRIG_BUNDLE="$T/no-such-bundle.tar.gz" \ + INSTALL_BRIG_BUNDLE="$T/no-such-bundle.tar.gz" INSTALL_BRIG_TTY="$T/tty" \ sh "$here/install.sh" > "$T/out.log" 2>&1 rc=$? set -e @@ -110,6 +136,93 @@ grep -q "no-such-bundle" "$T/out.log" || { cat "$T/out.log" >&2; fail "the insta ok "with sudo the install goes on to the download" rm -f "$T/sudo-ok" +# 2b. A fresh Ubuntu cloud image has neither uidmap nor acl. Without sudo: stop, +# with the packages in the root commands and the way to let it use sudo. +if command -v newuidmap >/dev/null 2>&1 \ + || command -v setfacl >/dev/null 2>&1; then + echo "SKIP: this host has uidmap or acl, so their absence cannot be staged" +else + rm -f "$T/stub/newuidmap" "$T/stub/setfacl" + run_install + [ "$rc" -ne 0 ] || fail "the install went on without uidmap and acl" + grep -q "apt-get install -y uidmap acl" "$T/out.log" || { cat "$T/out.log" >&2; fail "the packages are not in the root commands"; } + grep -q "sudo -v" "$T/out.log" || { cat "$T/out.log" >&2; fail "no word that sudo -v lets the installer do it"; } + ok "without sudo, missing packages stop the install with the root commands" + + # With sudo: install them before the download, check again, and go on. + touch "$T/sudo-ok" + run_install + grep -q "apt-get install -y uidmap acl" "$T/sudo-ran.sh" 2>/dev/null \ + || { cat "$T/out.log" >&2; fail "the packages were not installed with sudo"; } + if grep -q "^usermod\|udev\|apparmor" "$T/sudo-ran.sh"; then + cat "$T/sudo-ran.sh" >&2 + fail "the early sudo ran what brig-rootless-setup.sh makes" + fi + grep -q "no-such-bundle" "$T/out.log" || { cat "$T/out.log" >&2; fail "the install did not go on after the packages"; } + ok "with sudo, missing packages are installed and the install goes on" + + # With sudo, and an install that fails: stop, and say so. + rm -f "$T/stub/newuidmap" "$T/stub/setfacl" "$T/sudo-ran.sh" + touch "$T/apt-fails" + run_install + [ "$rc" -ne 0 ] || fail "the install went on after the packages failed" + grep -q "making them with sudo failed" "$T/out.log" || { cat "$T/out.log" >&2; fail "no word that sudo failed"; } + if grep -q "sudo -v" "$T/out.log"; then + fail "a user whose sudo works was told to run sudo -v" + fi + ok "a failed package install stops the install" + rm -f "$T/apt-fails" "$T/sudo-ok" + + # sudo with a password, from a terminal, for a user in a sudo group: asked + # once, and then the same as sudo without one. + rm -f "$T/stub/newuidmap" "$T/stub/setfacl" "$T/sudo-ran.sh" + : > "$T/tty" + touch "$T/in-sudo" "$T/sudo-pass" + run_install + [ "$(grep -c '^-v$' "$T/sudo.log")" = 1 ] || { cat "$T/sudo.log" >&2; fail "sudo was not asked for the password once"; } + grep -q "apt-get install -y uidmap acl" "$T/sudo-ran.sh" 2>/dev/null \ + || { cat "$T/out.log" >&2; fail "the packages were not installed after the password"; } + grep -q "no-such-bundle" "$T/out.log" || { cat "$T/out.log" >&2; fail "the install did not go on after the password"; } + ok "a user in a sudo group is asked for the password and the install goes on" + + # No terminal, or not in a sudo group: no prompt, and the stop as before. + rm -f "$T/stub/newuidmap" "$T/stub/setfacl" "$T/sudo-ok" "$T/tty" + run_install + if grep -q '^-v$' "$T/sudo.log"; then fail "sudo was asked for a password with no terminal"; fi + grep -q "Nothing was downloaded" "$T/out.log" || { cat "$T/out.log" >&2; fail "no stop without a terminal"; } + : > "$T/tty" + rm -f "$T/in-sudo" + run_install + if grep -q '^-v$' "$T/sudo.log"; then fail "a user in no sudo group was asked for a password"; fi + ok "no terminal, or no sudo group, means no password prompt" + rm -f "$T/sudo-pass" "$T/tty" + for b in newuidmap setfacl iptables; do + printf '#!/bin/sh\nexit 0\n' > "$T/stub/$b"; chmod 0755 "$T/stub/$b" + done +fi + +# 2c. The packages come from the package manager the host has, by its names. +sed -n '/^install_tools_cmd()/,/^}/p' "$here/install.sh" > "$T/tools-fn.sh" +for pm in apt-get dnf yum zypper pacman none; do + mkdir -p "$T/pm-$pm" + [ "$pm" = none ] || { printf '#!/bin/sh\n' > "$T/pm-$pm/$pm"; chmod 0755 "$T/pm-$pm/$pm"; } + got="$(PATH="$T/pm-$pm" /bin/sh -c ". '$T/tools-fn.sh'; install_tools_cmd newuidmap setfacl")" + case "$pm" in + apt-get) want="DEBIAN_FRONTEND=noninteractive apt-get install -y uidmap acl "$T/stub/sysctl" printf '#!/bin/sh\n[ "$*" = "-n true" ]\n' > "$T/stub/sudo" printf '#!/bin/sh\nexit 1\n' > "$T/stub/modinfo" -for b in newuidmap setfacl systemctl cosign; do +for b in newuidmap setfacl iptables systemctl cosign; do printf '#!/bin/sh\nexit 0\n' > "$T/stub/$b" done chmod 0755 "$T/stub"/* diff --git a/tests/install-summary.sh b/tests/install-summary.sh index b86435d..65b5470 100644 --- a/tests/install-summary.sh +++ b/tests/install-summary.sh @@ -48,7 +48,7 @@ STUB printf '#!/bin/sh\necho 0\n' > "$T/stub/sysctl" printf '#!/bin/sh\n[ "$*" = "-n true" ]\n' > "$T/stub/sudo" printf '#!/bin/sh\nexit 1\n' > "$T/stub/modinfo" -for b in newuidmap setfacl systemctl; do +for b in newuidmap setfacl iptables systemctl; do printf '#!/bin/sh\nexit 0\n' > "$T/stub/$b" done chmod 0755 "$T/stub"/*