From 148401d8ab4d8c05a3a5a2143fe641ad885b4130 Mon Sep 17 00:00:00 2001 From: Valter Silva Date: Tue, 11 Aug 2026 17:30:15 +0100 Subject: [PATCH 1/2] fix: fall back to AdminPassword from PalWorldSettings.ini Container side REST API calls authenticate with ADMIN_PASSWORD. With DISABLE_GENERATE_SETTINGS=true the .ini is the source of truth and nothing populates ADMIN_PASSWORD from it, so auto reboot, backups and the graceful shutdown in term_handler fail with "Unauthorized" for anyone who sets the admin password only in the file. Read AdminPassword from PalWorldSettings.ini when ADMIN_PASSWORD is unset. ADMIN_PASSWORD keeps precedence, so nothing changes for existing setups. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/unit-test.yml | 3 + .../configuration/game-settings.md | 4 + scripts/helper_functions.sh | 40 ++++++++- scripts/start.sh | 6 +- tests/test-admin-password.sh | 90 +++++++++++++++++++ 5 files changed, 141 insertions(+), 2 deletions(-) create mode 100755 tests/test-admin-password.sh diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index d30171298..24d137e98 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -19,6 +19,9 @@ jobs: - name: Test negative delta recovery setting run: ./tests/test-negative-delta-recovery.sh + - name: Test admin password resolution + run: ./tests/test-admin-password.sh + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 diff --git a/docusaurus/docs/getting-started/configuration/game-settings.md b/docusaurus/docs/getting-started/configuration/game-settings.md index 2ecf01807..4cec08f11 100644 --- a/docusaurus/docs/getting-started/configuration/game-settings.md +++ b/docusaurus/docs/getting-started/configuration/game-settings.md @@ -160,6 +160,10 @@ Changes can only be made to `PalWorldSettings.ini` while the server is off. Any changes made while the server is live will be overwritten when the server stops. ::: +When `ADMIN_PASSWORD` is not set, the `AdminPassword` from `PalWorldSettings.ini` is used to authenticate +the container against the REST API, so auto reboots, backups and graceful shutdowns keep working when the +admin password is only set in the file. `ADMIN_PASSWORD` still takes precedence whenever it is set. + For a more detailed list of server settings go to: [Palworld Wiki](https://palworld.wiki.gg/wiki/PalWorldSettings.ini) For more detailed server settings explanations go to: [shockbyte](https://shockbyte.com/billing/knowledgebase/1189/How-to-Configure-your-Palworld-server.html) diff --git a/scripts/helper_functions.sh b/scripts/helper_functions.sh index 62e5a6761..7a7f9dde2 100644 --- a/scripts/helper_functions.sh +++ b/scripts/helper_functions.sh @@ -255,13 +255,51 @@ DiscordMessage() { fi } +# Reads AdminPassword from the given settings file, defaulting to PalWorldSettings.ini +# Only the quoted form written by the server and by compile-settings.sh is matched +# Returns 0 and prints the password if one is set +# Returns 1 if the file is unreadable or holds no AdminPassword +get_admin_password_from_settings() { + local -r config_file="${1:-/palworld/Pal/Saved/Config/LinuxServer/PalWorldSettings.ini}" + local -r pattern='AdminPassword="([^"]*)"' + local settings + + if [ ! -r "${config_file}" ]; then + return 1 + fi + + settings="$(tr -d '\r' < "${config_file}")" + if [[ "${settings}" =~ $pattern ]] && [ -n "${BASH_REMATCH[1]}" ]; then + printf '%s' "${BASH_REMATCH[1]}" + return 0 + fi + + return 1 +} + +# Returns the password used to authenticate against the server's own REST API +# ADMIN_PASSWORD wins when it is set, so this changes nothing for existing setups +# With DISABLE_GENERATE_SETTINGS=true the .ini is the source of truth and ADMIN_PASSWORD +# is commonly left unset, which makes every container side call (auto reboot, backup, +# graceful shutdown) fail with "Unauthorized"; fall back to the .ini in that case +# Takes an optional settings file path, only used by the tests +# shellcheck disable=SC2120 +get_admin_password() { + if [ -n "${ADMIN_PASSWORD:-}" ]; then + printf '%s' "${ADMIN_PASSWORD}" + return 0 + fi + + get_admin_password_from_settings "$@" +} + # REST API Call REST_API() { autopause resume "REST_API ${1}" > /dev/null local -r api="${1}" local -r data="${2}" local -r url="http://localhost:${REST_API_PORT}/v1/api/${api}" - local -r userpass="admin:${ADMIN_PASSWORD}" + local -r userpass="admin:$(get_admin_password)" local -r post_api="save|stop" local -r down_api="shutdown|stop" local -i result=0 diff --git a/scripts/start.sh b/scripts/start.sh index 9cde31311..f7668ec55 100644 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -118,6 +118,10 @@ if [ "${DISABLE_GENERATE_SETTINGS,,}" = true ]; then fileExists "/palworld/DefaultPalWorldSettings.ini" || exit cp "/palworld/DefaultPalWorldSettings.ini" "/palworld/Pal/Saved/Config/LinuxServer/PalWorldSettings.ini" || exit fi + + if [ -z "${ADMIN_PASSWORD}" ] && get_admin_password_from_settings > /dev/null; then + LogInfo "ADMIN_PASSWORD is not set, using AdminPassword from PalWorldSettings.ini to authenticate against the REST API" + fi else LogAction "GENERATING CONFIG" LogInfo "Using Env vars to create PalWorldSettings.ini" @@ -164,7 +168,7 @@ fi cat >/home/steam/server/rcon.yaml <&2 + exit 1 +} + +# Writes a single line settings file, the same shape compile-settings.sh produces +write_settings() { + printf '[/Script/Pal.PalGameWorldSettings]\nOptionSettings=(Difficulty=None,ServerPassword="%s",AdminPassword=%s,PublicPort=8211)\n' "$1" "$2" > "${settings_file}" +} + +assert_password_from_settings() { + write_settings "serverPass" '"adminPass"' + local password + password="$(get_admin_password_from_settings "${settings_file}")" || fail "no password read from the settings file" + [ "${password}" = "adminPass" ] || fail "expected adminPass, got ${password}" +} + +assert_special_characters_are_kept() { + write_settings "serverPass" '"p@ss w0rd$&=,"' + local password + password="$(get_admin_password_from_settings "${settings_file}")" || fail "no password read from the settings file" + [ "${password}" = 'p@ss w0rd$&=,' ] || fail "expected p@ss w0rd\$&=, got ${password}" +} + +assert_carriage_returns_are_stripped() { + printf 'OptionSettings=(AdminPassword="adminPass")\r\n' > "${settings_file}" + local password + password="$(get_admin_password_from_settings "${settings_file}")" || fail "no password read from the settings file" + [ "${password}" = "adminPass" ] || fail "carriage return was not stripped from ${password}" +} + +assert_empty_password_is_rejected() { + write_settings "serverPass" '""' + if get_admin_password_from_settings "${settings_file}"; then + fail "an empty AdminPassword was accepted" + fi +} + +assert_missing_file_is_rejected() { + if get_admin_password_from_settings "${settings_file}.missing"; then + fail "a missing settings file was accepted" + fi +} + +assert_environment_variable_wins() { + write_settings "serverPass" '"adminPass"' + ADMIN_PASSWORD="fromEnvironment" + local password + password="$(get_admin_password "${settings_file}")" || fail "no password returned" + [ "${password}" = "fromEnvironment" ] || fail "expected fromEnvironment, got ${password}" + unset ADMIN_PASSWORD +} + +assert_settings_are_used_when_environment_variable_is_empty() { + write_settings "serverPass" '"adminPass"' + ADMIN_PASSWORD="" + local password + password="$(get_admin_password "${settings_file}")" || fail "no password returned" + [ "${password}" = "adminPass" ] || fail "expected adminPass, got ${password}" + unset ADMIN_PASSWORD +} + +assert_unset_environment_variable_is_tolerated() { + unset ADMIN_PASSWORD + if get_admin_password "${settings_file}.missing" > /dev/null; then + fail "a password was returned without ADMIN_PASSWORD and without a settings file" + fi +} + +assert_password_from_settings +assert_special_characters_are_kept +assert_carriage_returns_are_stripped +assert_empty_password_is_rejected +assert_missing_file_is_rejected +assert_environment_variable_wins +assert_settings_are_used_when_environment_variable_is_empty +assert_unset_environment_variable_is_tolerated + +echo "admin password tests passed" From 52e2184071f80f621c0daa542f4c9173969fb447 Mon Sep 17 00:00:00 2001 From: Valter Silva Date: Fri, 21 Aug 2026 15:27:41 +0100 Subject: [PATCH 2/2] fix: harden the AdminPassword fallback and quote it safely in rcon.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow up on the review of #931, four issues in the code this PR adds: * rcon.yaml took the password in a YAML double quoted scalar, so a backslash makes the file unparseable for rcon-cli and a \t or \n is silently rewritten. A single quoted scalar processes no escapes, only a literal quote is doubled. This also closes the same hole on the ADMIN_PASSWORD path, which predates this PR. * AdminPassword = "value", with spaces around the assignment, was not matched. A hand edited .ini is exactly the case this fallback exists for. * A commented out line was matched, so an old ;OptionSettings=(AdminPassword=…) kept above the live one won. * The bash regex only ever matched the first occurrence, so an empty AdminPassword="" ahead of the real one made the function give up and no fallback happened at all. Reading the file line by line, skipping comments and keeping the last non empty value covers the last three. Tests cover each case and fail without this change. ShellCheck clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GuKEQvBLi2mqFTDbxVARF7 --- scripts/helper_functions.sh | 28 +++++++++++++++++++++++----- scripts/start.sh | 5 ++++- tests/test-admin-password.sh | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/scripts/helper_functions.sh b/scripts/helper_functions.sh index 7a7f9dde2..c73c1e29e 100644 --- a/scripts/helper_functions.sh +++ b/scripts/helper_functions.sh @@ -261,16 +261,27 @@ DiscordMessage() { # Returns 1 if the file is unreadable or holds no AdminPassword get_admin_password_from_settings() { local -r config_file="${1:-/palworld/Pal/Saved/Config/LinuxServer/PalWorldSettings.ini}" - local -r pattern='AdminPassword="([^"]*)"' - local settings + local -r pattern='AdminPassword[[:space:]]*=[[:space:]]*"([^"]*)"' + local line password="" if [ ! -r "${config_file}" ]; then return 1 fi - settings="$(tr -d '\r' < "${config_file}")" - if [[ "${settings}" =~ $pattern ]] && [ -n "${BASH_REMATCH[1]}" ]; then - printf '%s' "${BASH_REMATCH[1]}" + # Commented out lines are skipped and the last value wins, so a file that keeps + # an old or empty AdminPassword above the live one still resolves correctly + while IFS= read -r line; do + line="${line//$'\r'/}" + if [[ "${line}" =~ ^[[:space:]]*[\;#] ]]; then + continue + fi + if [[ "${line}" =~ $pattern ]] && [ -n "${BASH_REMATCH[1]}" ]; then + password="${BASH_REMATCH[1]}" + fi + done < "${config_file}" + + if [ -n "${password}" ]; then + printf '%s' "${password}" return 0 fi @@ -293,6 +304,13 @@ get_admin_password() { get_admin_password_from_settings "$@" } +# Renders the given value as a YAML single quoted scalar +# Single quoted scalars process no escape sequences, only a literal quote is doubled, +# so a password holding a backslash or a double quote survives unchanged +yaml_single_quoted() { + printf "'%s'" "${1//\'/\'\'}" +} + # REST API Call REST_API() { autopause resume "REST_API ${1}" > /dev/null diff --git a/scripts/start.sh b/scripts/start.sh index f7668ec55..8d2fe58c1 100644 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -165,10 +165,13 @@ fi # Configure RCON settings. # DEPRECATED: RCON will be removed in a future release. +# The password is written as a YAML single quoted scalar so that a backslash or a +# double quote in it does not get re-interpreted or break the file for rcon-cli +rcon_password="$(yaml_single_quoted "$(get_admin_password)")" cat >/home/steam/server/rcon.yaml < "${settings_file}" + local password + password="$(get_admin_password_from_settings "${settings_file}")" || fail "no password read from the settings file" + [ "${password}" = "adminPass" ] || fail "expected adminPass, got ${password}" +} + +assert_commented_lines_are_ignored() { + printf ';OptionSettings=(AdminPassword="commentedOut")\nOptionSettings=(AdminPassword="adminPass")\n' > "${settings_file}" + local password + password="$(get_admin_password_from_settings "${settings_file}")" || fail "no password read from the settings file" + [ "${password}" = "adminPass" ] || fail "expected adminPass, got ${password}" +} + +assert_an_empty_value_does_not_hide_a_later_one() { + printf 'OptionSettings=(AdminPassword="")\nOptionSettings=(AdminPassword="adminPass")\n' > "${settings_file}" + local password + password="$(get_admin_password_from_settings "${settings_file}")" || fail "no password read from the settings file" + [ "${password}" = "adminPass" ] || fail "expected adminPass, got ${password}" +} + +assert_yaml_scalar_survives_backslashes_and_quotes() { + local quoted + quoted="$(yaml_single_quoted 'pa\ss"w0rd')" + [ "${quoted}" = "'pa\\ss\"w0rd'" ] || fail "expected 'pa\\ss\"w0rd', got ${quoted}" + quoted="$(yaml_single_quoted "it's a p@ss")" + [ "${quoted}" = "'it''s a p@ss'" ] || fail "expected 'it''s a p@ss', got ${quoted}" +} + assert_password_from_settings assert_special_characters_are_kept assert_carriage_returns_are_stripped assert_empty_password_is_rejected assert_missing_file_is_rejected +assert_whitespace_around_the_assignment_is_tolerated +assert_commented_lines_are_ignored +assert_an_empty_value_does_not_hide_a_later_one +assert_yaml_scalar_survives_backslashes_and_quotes assert_environment_variable_wins assert_settings_are_used_when_environment_variable_is_empty assert_unset_environment_variable_is_tolerated