fix: preserve global flags when sudo is sudo-rs

`sudo-rs`, the default sudo on Ubuntu 25.10 and later, does not support preserving the entire environment, so `--quiet`, `--trace`, `--force` and `--app` stopped taking effect once `dokku` re-executed itself as the `dokku` user, and a warning was printed on every invocation. Variables outside the set dokku forwards no longer cross that boundary and can be named in `DOKKU_PRESERVE_ENV` instead.
This commit is contained in:
Jose Diaz-Gonzalez
2026-09-20 02:32:41 -04:00
parent c04e74c668
commit 6b1b7bc6f6
14 changed files with 403 additions and 10 deletions

View File

@@ -219,6 +219,19 @@ jobs:
name: coverage.${{ matrix.index }}
path: test-results/coverage
sudo-preserve-env:
name: sudo-preserve-env
runs-on: ubuntu-26.04
needs: check-commit
if: ${{ needs.check-commit.outputs.skip != 'true' }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: check that the preserved environment survives sudo-rs
run: ./tests/ci/sudo_preserve_env.sh
publish-test-results:
name: publish-test-results
needs: unit-tests

View File

@@ -126,10 +126,10 @@ else
endif
plugin-dependencies: plugn procfile-util
sudo -E dokku plugin:install-dependencies --core
sudo dokku plugin:install-dependencies --core
plugins: plugn procfile-util docker
sudo -E dokku plugin:install --core
sudo dokku plugin:install --core
dependencies: apt-update jq docker-image-labeler docker-container-healthchecker lambda-builder netrc sshcommand plugn procfile-util docker help2man man-db sigil dos2unix parallel
$(MAKE) -e stack

View File

@@ -110,14 +110,14 @@ install-dokku() {
elif [[ "$use_plugin" == "true" ]]; then
install-dokku-from-package "$DOKKU_SEMVER"
echo "--> Running post-install dependency installation"
sudo -E dokku plugin:install-dependencies --core
sudo dokku plugin:install-dependencies --core
else
install-dokku-from-source "$DOKKU_TAG"
fi
else
install-dokku-from-package
echo "--> Running post-install dependency installation"
sudo -E dokku plugin:install-dependencies --core
sudo dokku plugin:install-dependencies --core
fi
}

View File

@@ -138,6 +138,9 @@ The following config variables have special meanings and can be set in a variety
| `DOKKU_EVENTS_LOGFILE` | `$DOKKU_LOGS_DIR/events.log` | `/etc/environment` <br /> `~dokku/.dokkurc` <br /> `~dokku/.dokkurc/*` | Where the events log file is written to. |
| `DOKKU_APP_NAME` | none | `--app APP` flag | Name of application to work on. Respected by core plugins. |
| `DOKKU_APPS_FORCE_DELETE` | none | `--force` flag | Whether to force delete an application. Also used by other plugins for destructive actions. |
| `DOKKU_DISABLE_TTY` | none | `dokku run --no-tty` <br /> environment | Forces `dokku run` to treat the session as non-interactive. |
| `DOKKU_FORCE_TTY` | none | `dokku run --force-tty` <br /> environment | Forces `dokku run` to treat the session as interactive. |
| `DOKKU_PRESERVE_ENV` | none | `/etc/default/dokku` <br /> `~dokku/.dokkurc` <br /> `~dokku/.dokkurc/*` <br /> environment | Comma-separated list of extra environment variable names to carry across the privilege drop to the `dokku` user. See [Forwarding environment variables](#forwarding-environment-variables). |
| `DOKKU_CHECKS_URL` | `https://dokku.com/docs/deployment/zero-downtime-deploys/` | `/etc/environment` <br /> `~dokku/.dokkurc` <br /> `~dokku/.dokkurc/*` | Url displayed during deployment when no CHECKS file exists. |
| `DOKKU_QUIET_OUTPUT` | none | `--quiet` flag | Silences certain header output for `dokku` commands. |
| `DOKKU_RM_CONTAINER` | none | `dokku config:set` <br /> | Deprecated: Whether to keep `dokku run` containers around or not. |
@@ -145,6 +148,40 @@ The following config variables have special meanings and can be set in a variety
| `DOKKU_SYSTEM_GROUP` | `dokku` | `/etc/environment` <br /> `~dokku/.dokkurc` <br /> `~dokku/.dokkurc/*` | System group to chown files as. |
| `DOKKU_SYSTEM_USER` | `dokku` | `/etc/environment` <br /> `~dokku/.dokkurc` <br /> `~dokku/.dokkurc/*` | System user to chown files as. |
## Forwarding environment variables
When `dokku` is invoked by any user other than `dokku` - `root`, or a member of the `sudo` group - it
re-executes itself as the `dokku` user. Most configuration does not need to survive that step, since
`/etc/default/dokku`, `~dokku/dokkurc` and `~dokku/.dokkurc/*` are read again by the re-executed
process, and `/etc/environment` is applied by PAM.
What cannot be recomputed is state that belongs to the invocation itself, so those variables are
named explicitly and forwarded:
| Variable | Source |
| --- | --- |
| `DOKKU_APP_NAME`, `DOKKU_APPS_FORCE_DELETE`, `DOKKU_QUIET_OUTPUT`, `DOKKU_TRACE` | the `--app`, `--force`, `--quiet` and `--trace` global flags, or the environment |
| `DOKKU_GLOBAL_FLAGS` | every global flag as given, replayed by plugins that need them |
| `DOKKU_DISABLE_TTY`, `DOKKU_FORCE_TTY` | the `dokku run` tty flags, or the environment |
| `DOKKU_PRESERVE_ENV` | the list below |
| `SSH_USER`, `SSH_NAME`, `NAME`, `FINGERPRINT`, `SSH_ORIGINAL_COMMAND` | the ssh session the command arrived on |
To forward anything else - a variable read by a third-party plugin, or a one-off override of a
setting that normally lives in `/etc/default/dokku` - name it in `DOKKU_PRESERVE_ENV`:
```shell
DOKKU_PRESERVE_ENV=DOCKER_BUILDKIT DOCKER_BUILDKIT=0 dokku ps:rebuild node-js-app
```
Set it persistently to forward a variable for every invocation:
```shell
echo "export DOKKU_PRESERVE_ENV=MY_PLUGIN_DEBUG" | sudo tee -a /etc/default/dokku
```
Variables sudo manages for the target user - `HOME`, `PATH`, `SHELL`, `USER`, `LOGNAME`, `MAIL` and
`SUDO_*` - are never forwarded, and naming them in `DOKKU_PRESERVE_ENV` is not supported.
## Properties
### Internal properties

View File

@@ -135,3 +135,9 @@ Use `visudo /etc/sudoers.d/dokku-users`, or `visudo /etc/sudoers` to add the fol
```
%dokku ALL=(ALL:ALL) NOPASSWD:SETENV: /usr/bin/dokku
```
The `SETENV:` tag is required. When a user other than `dokku` runs the `dokku` binary, it re-executes
itself as the `dokku` user and names the environment variables that have to survive that step via
`sudo --preserve-env=`. Without `SETENV:` those variables are filtered out, and global flags such as
`--quiet`, `--trace` and `--force` stop taking effect. See
[Forwarding environment variables](/docs/configuration/environment-variables.md#forwarding-environment-variables).

7
dokku
View File

@@ -67,6 +67,9 @@ export DOKKU_GLOBAL_RUN_ARGS="--label=org.label-schema.schema-version=1.0 --labe
source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions"
# the original argv is kept so the re-exec as the dokku user can replay it verbatim
# and let the child re-parse the global flags for itself. parse_args and the
# flag-stripping loop below must therefore stay pure functions of argv.
parse_args "$@"
args=("$@")
skip_arg=false
@@ -91,7 +94,9 @@ if [[ $(id -un) != "dokku" ]]; then
if [[ ! $1 =~ plugin:* ]] && [[ $1 != "ssh-keys:add" ]] && [[ $1 != "ssh-keys:remove" ]] && [[ $1 != "scheduler-k3s:initialize" ]] && [[ $1 != "scheduler-k3s:uninstall" ]]; then
unset DOKKU_PID TMP TMPDIR TEMP TEMPDIR
export SSH_USER=$(id -un)
sudo -u dokku -E -H "$0" "$@"
read -ra preserved_env_names <<<"$(fn-cli-preserved-env-names)"
preserve_env_flag="$(fn-sudo-preserve-env-flag "${preserved_env_names[@]}")"
sudo -u dokku "$preserve_env_flag" -H "$0" "${args[@]}"
exit $?
fi
fi

View File

@@ -476,6 +476,49 @@ is_abs_path() {
fi
}
fn-cli-preserved-env-names() {
declare desc="names the environment variables the re-exec as the dokku user must carry"
# anything sourced from /etc/default/dokku or ~dokku/.dokkurc/* is re-read by the
# child and does not belong here; DOKKU_PRESERVE_ENV carries anything else
echo "DOKKU_APP_NAME DOKKU_APPS_FORCE_DELETE DOKKU_DISABLE_TTY DOKKU_FORCE_TTY" \
"DOKKU_GLOBAL_FLAGS DOKKU_PRESERVE_ENV DOKKU_QUIET_OUTPUT DOKKU_TRACE" \
"FINGERPRINT NAME SSH_NAME SSH_ORIGINAL_COMMAND SSH_USER"
}
fn-sudo-preserve-env-flag() {
declare desc="prints the sudo flag that carries environment variables across a privilege drop"
local names=("$@")
local extra_names=()
local joined name
# sudo-rs, the default sudo on ubuntu 25.10 and later, ignores -E and bare
# --preserve-env, so every variable that has to survive must be named
if [[ "${#names[@]}" -eq 0 ]]; then
for name in $(compgen -e); do
# preserving these would defeat -H and override the sudoers secure_path
case "$name" in
HOME | LOGNAME | MAIL | PATH | SHELL | USER | _ | SUDO_*)
continue
;;
esac
names+=("$name")
done
fi
if [[ -n "$DOKKU_PRESERVE_ENV" ]]; then
read -ra extra_names <<<"${DOKKU_PRESERVE_ENV//,/ }"
names+=("${extra_names[@]}")
fi
if [[ "${#names[@]}" -eq 0 ]]; then
return 0
fi
printf -v joined "%s," "${names[@]}"
echo "--preserve-env=${joined%,}"
}
parse_args() {
declare desc="top-level cli arg parser"
local next_index=1

View File

@@ -1,13 +1,22 @@
#!/usr/bin/env bash
set -eo pipefail
[[ $DOKKU_TRACE ]] && set -x
source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions"
cmd-plugin-trigger() {
declare desc="trigger an arbitrary plugin hook"
declare cmd="plugin:trigger"
[[ "$1" == "$cmd" ]] && shift 1
sudo -u dokku -E -H plugn trigger "$@"
local sudo_args=(-u dokku)
local preserve_env_flag
preserve_env_flag="$(fn-sudo-preserve-env-flag)"
if [[ -n "$preserve_env_flag" ]]; then
sudo_args+=("$preserve_env_flag")
fi
sudo_args+=(-H)
sudo "${sudo_args[@]}" plugn trigger "$@"
}
cmd-plugin-trigger "$@"

112
tests/ci/sudo_preserve_env.sh Executable file
View File

@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# Checks that the environment dokku forwards across its privilege drop survives the
# local sudo implementation. sudo-rs, the default sudo on ubuntu 25.10 and later,
# ignores -E and bare --preserve-env, so dokku names every variable it needs.
set -eo pipefail
PLUGIN_CORE_AVAILABLE_PATH="${PLUGIN_CORE_AVAILABLE_PATH:-$(dirname "$0")/../../plugins}"
export PLUGIN_CORE_AVAILABLE_PATH
source "$PLUGIN_CORE_AVAILABLE_PATH/common/functions"
FAILURES=0
fail() {
declare desc="report a failed check"
echo "not ok - $1" 1>&2
FAILURES=$((FAILURES + 1))
}
pass() {
declare desc="report a passing check"
echo "ok - $1"
}
report-whole-environment-support() {
declare desc="record whether this sudo honours -E, for the log"
local warning
warning="$(DOKKU_TEST_CROSSING=1 sudo -E -u nobody true 2>&1 >/dev/null || true)"
if [[ -n "$warning" ]]; then
echo "# sudo -E is not supported here: $warning"
else
echo "# sudo -E is supported here"
fi
}
check-named-list-crosses() {
declare desc="the named variables reach the target user"
local flag output
flag="$(DOKKU_QUIET_OUTPUT=1 DOKKU_TRACE=1 SSH_USER=ci fn-sudo-preserve-env-flag DOKKU_QUIET_OUTPUT DOKKU_TRACE SSH_USER)"
output="$(DOKKU_QUIET_OUTPUT=1 DOKKU_TRACE=1 SSH_USER=ci sudo "$flag" -u nobody /usr/bin/env)"
local name
for name in DOKKU_QUIET_OUTPUT=1 DOKKU_TRACE=1 SSH_USER=ci; do
if grep -qxF "$name" <<<"$output"; then
pass "$name crossed the privilege drop"
else
fail "$name did not cross the privilege drop"
fi
done
}
check-named-list-is-silent() {
declare desc="no warning is written to stderr"
local flag stderr
flag="$(SSH_USER=ci fn-sudo-preserve-env-flag SSH_USER)"
stderr="$(SSH_USER=ci sudo "$flag" -u nobody true 2>&1 >/dev/null || true)"
if [[ -z "$stderr" ]]; then
pass "no warning on stderr"
else
fail "unexpected stderr: $stderr"
fi
}
check-absent-names-are-tolerated() {
declare desc="naming a variable that is not set is not an error"
local flag
flag="$(fn-sudo-preserve-env-flag DOKKU_DEFINITELY_NOT_SET_HERE SSH_USER)"
if sudo "$flag" -u nobody true; then
pass "unset names are ignored"
else
fail "sudo rejected a list naming an unset variable"
fi
}
check-home-still-wins() {
declare desc="-H is not defeated by the preserved list"
local flag output
local names=()
read -ra names <<<"$(fn-cli-preserved-env-names)"
flag="$(fn-sudo-preserve-env-flag "${names[@]}")"
output="$(sudo "$flag" -H -u nobody /usr/bin/env)"
if grep -qxF "HOME=$(getent passwd nobody | cut -d: -f6)" <<<"$output"; then
pass "-H still sets HOME for the target user"
else
fail "HOME was not reset for the target user"
fi
}
main() {
declare desc="run every check"
echo "# sudo version:"
sudo --version | head -1 | sed -e 's/^/# /'
report-whole-environment-support
check-named-list-crosses
check-named-list-is-silent
check-absent-names-are-tolerated
check-home-still-wins
if [[ "$FAILURES" -gt 0 ]]; then
echo "$FAILURES check(s) failed" 1>&2
exit 1
fi
echo "all checks passed"
}
main "$@"

View File

@@ -120,3 +120,54 @@ teardown() {
assert_line 0 "--label"
assert_line 1 "a b"
}
@test "(common) [preserve-env] fn-sudo-preserve-env-flag emits only the requested names" {
run /bin/bash -c "source '$PLUGIN_CORE_AVAILABLE_PATH/common/functions'; fn-sudo-preserve-env-flag DOKKU_TRACE DOKKU_APP_NAME"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "--preserve-env=DOKKU_TRACE,DOKKU_APP_NAME"
}
@test "(common) [preserve-env] fn-sudo-preserve-env-flag omits sudo managed names" {
run /bin/bash -c "source '$PLUGIN_CORE_AVAILABLE_PATH/common/functions'; export DOKKU_TEST_CROSSING=1; fn-sudo-preserve-env-flag | sed 's/^--preserve-env=//' | tr ',' '\\n' | grep -cx DOKKU_TEST_CROSSING"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "1"
run /bin/bash -c "source '$PLUGIN_CORE_AVAILABLE_PATH/common/functions'; fn-sudo-preserve-env-flag | sed 's/^--preserve-env=//' | tr ',' '\\n' | grep -cxE 'HOME|LOGNAME|MAIL|PATH|SHELL|USER|_|SUDO_.*'"
echo "output: $output"
echo "status: $status"
assert_output "0"
}
@test "(common) [preserve-env] fn-sudo-preserve-env-flag honors DOKKU_PRESERVE_ENV" {
run /bin/bash -c "source '$PLUGIN_CORE_AVAILABLE_PATH/common/functions'; DOKKU_PRESERVE_ENV=ALPHA,BETA fn-sudo-preserve-env-flag DOKKU_TRACE"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "--preserve-env=DOKKU_TRACE,ALPHA,BETA"
run /bin/bash -c "source '$PLUGIN_CORE_AVAILABLE_PATH/common/functions'; DOKKU_PRESERVE_ENV=ALPHA fn-sudo-preserve-env-flag | sed 's/^--preserve-env=//' | tr ',' '\\n' | grep -cx ALPHA"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "1"
}
@test "(common) [preserve-env] fn-cli-preserved-env-names covers every parse_args export" {
run /bin/bash -c "source '$PLUGIN_CORE_AVAILABLE_PATH/common/functions'; fn-cli-preserved-env-names | tr ' ' '\\n' | grep -cxE 'DOKKU_APP_NAME|DOKKU_APPS_FORCE_DELETE|DOKKU_GLOBAL_FLAGS|DOKKU_PRESERVE_ENV|DOKKU_QUIET_OUTPUT|DOKKU_TRACE|SSH_USER'"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "7"
}
@test "(common) [preserve-env] parse_args records global flags for replay" {
run /bin/bash -c "source '$PLUGIN_CORE_AVAILABLE_PATH/common/functions'; parse_args --label=a=b run foo env; echo \"\$DOKKU_GLOBAL_FLAGS\""
echo "output: $output"
echo "status: $status"
assert_success
assert_output "--label=a=b"
}

105
tests/unit/core_3.bats Normal file
View File

@@ -0,0 +1,105 @@
#!/usr/bin/env bats
load test_helper
setup() {
global_setup
create_app
}
teardown() {
destroy_app
global_teardown
}
@test "(core) [global-flags] --quiet crosses the privilege drop" {
run /bin/bash -c "dokku apps:list"
echo "output: $output"
echo "status: $status"
assert_success
assert_output_contains "My Apps"
run /bin/bash -c "dokku --quiet apps:list"
echo "output: $output"
echo "status: $status"
assert_success
assert_output_not_contains "My Apps"
assert_output_contains "$TEST_APP"
}
@test "(core) [global-flags] --trace crosses the privilege drop" {
run /bin/bash -c "dokku --trace apps:list 2>&1 >/dev/null"
echo "output: $output"
echo "status: $status"
assert_success
assert_output_contains "execute_dokku_cmd"
}
@test "(core) [global-flags] --force crosses the privilege drop" {
local force_app="${TEST_APP}-force"
run /bin/bash -c "dokku apps:create $force_app"
echo "output: $output"
echo "status: $status"
assert_success
run /bin/bash -c "dokku --force apps:destroy $force_app </dev/null"
echo "output: $output"
echo "status: $status"
assert_success
assert_output_not_contains "To proceed, type"
}
@test "(core) [global-flags] --app crosses the privilege drop" {
run /bin/bash -c "dokku --app $TEST_APP config:set --no-restart TEST_PRESERVED=crossed"
echo "output: $output"
echo "status: $status"
assert_success
run /bin/bash -c "dokku config:get $TEST_APP TEST_PRESERVED"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "crossed"
}
@test "(core) [global-flags] a clean invocation writes nothing to stderr" {
run /bin/bash -c "dokku apps:list 2>&1 >/dev/null"
echo "output: $output"
echo "status: $status"
assert_success
assert_output ""
}
@test "(core) [global-flags] the caller environment crosses the privilege drop" {
run /bin/bash -c "DOKKU_QUIET_OUTPUT=1 dokku apps:list"
echo "output: $output"
echo "status: $status"
assert_success
assert_output_not_contains "My Apps"
assert_output_contains "$TEST_APP"
}
@test "(core) [global-flags] an unlisted variable only crosses when DOKKU_PRESERVE_ENV names it" {
# DOKKU_IMAGE is not carried across the re-exec and is re-derived by the child, so the
# traced value shows whether the caller's value survived. The flag form of --trace is
# used deliberately: DOKKU_TRACE=1 would trace the parent as well as the child.
run /bin/bash -c "DOKKU_IMAGE=probe/image dokku --trace apps:list 2>&1 >/dev/null | grep -m1 'export DOKKU_IMAGE'"
echo "output: $output"
echo "status: $status"
assert_success
assert_output_not_contains "probe/image"
run /bin/bash -c "DOKKU_PRESERVE_ENV=DOKKU_IMAGE DOKKU_IMAGE=probe/image dokku --trace apps:list 2>&1 >/dev/null | grep -m1 'export DOKKU_IMAGE'"
echo "output: $output"
echo "status: $status"
assert_success
assert_output_contains "probe/image"
}
@test "(core) [global-flags] the system sudo supports a named preserve-env list" {
run /bin/bash -c "DOKKU_TEST_CROSSING=1 sudo -u dokku --preserve-env=DOKKU_TEST_CROSSING -H /usr/bin/env | grep -cxE 'DOKKU_TEST_CROSSING=1|HOME=/home/dokku'"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "2"
}

View File

@@ -116,7 +116,7 @@ teardown() {
echo "status: $status"
assert_success
run /bin/bash -c "sudo -E -u nobody dokku plugin:uninstall $TEST_PLUGIN_NAME"
run /bin/bash -c "sudo -u nobody dokku plugin:uninstall $TEST_PLUGIN_NAME"
echo "output: $output"
echo "status: $status"
assert_failure
@@ -158,7 +158,7 @@ teardown() {
echo "status: $status"
assert_success
run /bin/bash -c "sudo -E -u nobody dokku plugin:uninstall $TEST_PLUGIN_NAME"
run /bin/bash -c "sudo -u nobody dokku plugin:uninstall $TEST_PLUGIN_NAME"
echo "output: $output"
echo "status: $status"
assert_failure
@@ -212,7 +212,7 @@ teardown() {
}
@test "(plugin) plugin:install, plugin:disable, plugin:uninstall as non-root user failure" {
run /bin/bash -c "sudo -E -u nobody dokku plugin:install $TEST_PLUGIN_GIT_REPO"
run /bin/bash -c "sudo -u nobody dokku plugin:install $TEST_PLUGIN_GIT_REPO"
echo "output: $output"
echo "status: $status"
assert_failure
@@ -227,7 +227,7 @@ teardown() {
echo "status: $status"
assert_success
run /bin/bash -c "sudo -E -u nobody dokku plugin:disable $TEST_PLUGIN_NAME"
run /bin/bash -c "sudo -u nobody dokku plugin:disable $TEST_PLUGIN_NAME"
echo "output: $output"
echo "status: $status"
assert_failure
@@ -372,3 +372,10 @@ teardown() {
assert_output_contains "root" "0"
assert_output_contains "dokku" "3"
}
@test "(plugin:trigger) forwards the caller environment across the privilege drop" {
run /bin/bash -c "DOKKU_TEST_CROSSING=1 DOKKU_TRACE=1 dokku plugin:trigger app-list 2>&1 >/dev/null | grep -q -- '--preserve-env=[^ ]*DOKKU_TEST_CROSSING'"
echo "output: $output"
echo "status: $status"
assert_success
}

View File

@@ -101,6 +101,8 @@ teardown() {
echo "status: $status"
assert_success
# DOKKU_OMIT_RESOURCE_ARGS has to survive the privilege drop in
# plugins/plugin/subcommands/trigger, so this also covers that forwarding
run_plugn_trigger DOKKU_OMIT_RESOURCE_ARGS=1 docker-args-process-build "$TEST_APP" herokuish
echo "output: $output"
echo "status: $status"

View File

@@ -35,6 +35,9 @@ teardown() {
echo "status: $status"
assert_success
# the leading --label is replayed out of DOKKU_GLOBAL_FLAGS by
# plugins/scheduler-docker-local/scheduler-run, so this also covers that variable
# surviving the re-exec as the dokku user
RANDOM_RUN_CID="$(dokku --label=com.dokku.test-label=value run:detached $TEST_APP sleep 300)"
run /bin/bash -c "docker inspect -f '{{ .State.Status }}' $RANDOM_RUN_CID"
echo "output: $output"