diff --git a/.changeset/hostexec-container-user.md b/.changeset/hostexec-container-user.md new file mode 100644 index 0000000..6302643 --- /dev/null +++ b/.changeset/hostexec-container-user.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Run Docker host command bridge commands as the PI WEB container user by default, with `hostexec --root` for administrative commands. diff --git a/docker/README.md b/docker/README.md index 68e1f4b..f66606f 100644 --- a/docker/README.md +++ b/docker/README.md @@ -16,7 +16,7 @@ By design, the runtime containers get deliberate host access so PI WEB agents ca - `/var/run/docker.sock` is mounted into the containers. The Docker socket is root-equivalent on the host. - `/srv`, `/opt`, and `/home` are mounted read/write. - `/` is mounted read-only at `/host` for inspection. -- `hostexec` can start a temporary privileged helper container and run explicit commands in the host namespaces. +- `hostexec` can start a temporary privileged helper container and run explicit commands in the host namespaces. Commands run as the container user by default, and `hostexec --root` can still run explicit administrative commands as root. Only install this on machines where the PI WEB user, the selected workspaces, and the browser/API clients are trusted. Review scripts before piping them to `sh` if you do not already trust this repository. @@ -150,16 +150,16 @@ If you use a reverse proxy, keep the container bound to localhost or a private a ## `hostexec` examples -`hostexec ` is the only host command bridge provided by this Docker setup. It intentionally does not abstract package managers or detect distributions. +`hostexec [--root] ` is the only host command bridge provided by this Docker setup. It intentionally does not abstract package managers or detect distributions. By default, commands run as the same numeric user/group as the PI WEB container. Use `--root` only for administrative host commands. Run it from a PI WEB session, a PI WEB terminal, or by execing into the runtime container: ```bash hostexec uname -a hostexec systemctl status docker -hostexec zypper refresh -hostexec sh -lc 'zypper refresh && zypper dup -y' -hostexec apt-get update +hostexec --root zypper refresh +hostexec --root sh -lc 'zypper refresh && zypper dup -y' +hostexec --root apt-get update ``` From the host shell, for a quick smoke test: @@ -169,7 +169,7 @@ cd ~/.local/share/pi-web-docker docker compose exec web hostexec uname -a ``` -`hostexec` starts a temporary privileged helper container through the mounted Docker socket, enters the host namespaces with `nsenter`, and runs exactly the command you passed. Treat it like running a privileged host command. +`hostexec` starts a temporary privileged helper container through the mounted Docker socket, enters the host namespaces with `nsenter`, and runs exactly the command you passed. Treat it like privileged host access even when the final command drops back to the container user. ## Development Docker setup diff --git a/docker/bin/hostexec b/docker/bin/hostexec index f70cc61..c46a3d0 100755 --- a/docker/bin/hostexec +++ b/docker/bin/hostexec @@ -3,13 +3,36 @@ set -euo pipefail usage() { cat >&2 <<'EOF' -Usage: hostexec +Usage: hostexec [--root] [--] Run a command on the Docker host by starting a temporary privileged helper container through the mounted Docker socket and entering the host namespaces. +Commands run as the current container UID/GID by default. Use --root to keep +root privileges for administrative host commands. EOF } +run_as_root=false +while [ "$#" -gt 0 ]; do + case "$1" in + --root) + run_as_root=true + shift + ;; + --help|-h) + usage + exit 0 + ;; + --) + shift + break + ;; + *) + break + ;; + esac +done + if [ "$#" -eq 0 ]; then usage exit 64 @@ -30,19 +53,71 @@ if [[ "$docker_host" == unix://* ]]; then fi helper_image="${HOSTEXEC_IMAGE:-alpine:3.22}" +target_uid="$(id -u)" +target_gid="$(id -g)" tty_args=(--interactive) if [ -t 0 ] && [ -t 1 ]; then tty_args+=(--tty) fi +docker_args=( + --rm + "${tty_args[@]}" + --pull=missing + --privileged + --security-opt label=disable + --pid=host + --network=host + --volume /:/host:rw +) + +if [ "$run_as_root" = true ] || { [ "$target_uid" = 0 ] && [ "$target_gid" = 0 ]; }; then + exec docker run \ + "${docker_args[@]}" \ + "$helper_image" \ + nsenter -t 1 -m -u -i -n -p -- "$@" +fi + +run_as_container_user='target_uid="${HOSTEXEC_TARGET_UID:?}" +target_gid="${HOSTEXEC_TARGET_GID:?}" + +target_user="" +if command -v getent >/dev/null 2>&1; then + passwd_entry="$(getent passwd "$target_uid" || true)" + if [ -n "$passwd_entry" ]; then + target_user="${passwd_entry%%:*}" + fi +fi + +if [ -n "$target_user" ]; then + if command -v runuser >/dev/null 2>&1; then + exec runuser -u "$target_user" -- "$@" + fi + + if command -v su >/dev/null 2>&1; then + exec su -s /bin/sh -c '\''exec "$@"'\'' -- "$target_user" hostexec-su "$@" + fi +fi + +if command -v setpriv >/dev/null 2>&1; then + if [ -n "$target_user" ]; then + exec setpriv --reuid "$target_uid" --regid "$target_gid" --init-groups -- "$@" + fi + + exec setpriv --reuid "$target_uid" --regid "$target_gid" --clear-groups -- "$@" +fi + +if command -v nsenter >/dev/null 2>&1; then + exec nsenter -t 1 -m -u -i -n -p -S "$target_uid" -G "$target_gid" -- "$@" +fi + +echo "hostexec: unable to switch to host uid:gid $target_uid:$target_gid" >&2 +exit 69 +' + exec docker run \ - --rm \ - "${tty_args[@]}" \ - --pull=missing \ - --privileged \ - --security-opt label=disable \ - --pid=host \ - --network=host \ - --volume /:/host:rw \ + "${docker_args[@]}" \ + --env HOSTEXEC_TARGET_UID="$target_uid" \ + --env HOSTEXEC_TARGET_GID="$target_gid" \ "$helper_image" \ - nsenter -t 1 -m -u -i -n -p -- "$@" + nsenter -t 1 -m -u -i -n -p -- /bin/sh -c "$run_as_container_user" hostexec-user "$@"