Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/unit-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
58 changes: 57 additions & 1 deletion scripts/helper_functions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -255,13 +255,69 @@ 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[[:space:]]*=[[:space:]]*"([^"]*)"'
local line password=""

if [ ! -r "${config_file}" ]; then
return 1
fi

# 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

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 "$@"
}

# 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
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
Expand Down
9 changes: 8 additions & 1 deletion scripts/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -161,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 <<EOL
default:
address: "127.0.0.1:${RCON_PORT}"
password: "${ADMIN_PASSWORD}"
password: ${rcon_password}
EOL

CHILD_PIDS=()
Expand Down
123 changes: 123 additions & 0 deletions tests/test-admin-password.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#!/bin/bash

set -eo pipefail

repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=scripts/helper_functions.sh
source "${repo_root}/scripts/helper_functions.sh"

settings_file="$(mktemp)"
trap 'rm -f "${settings_file}"' EXIT

fail() {
echo "test failure: $*" >&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_whitespace_around_the_assignment_is_tolerated() {
printf 'OptionSettings=(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_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

echo "admin password tests passed"