From e42129d70eb767a3e8bf4782bd875321339a4ee9 Mon Sep 17 00:00:00 2001 From: Muscle Date: Wed, 1 May 2024 20:46:16 +0900 Subject: [PATCH] add Auto Pause feature --- Dockerfile | 23 +- README.md | 78 +++- .../configuration/server-settings.md | 16 +- .../docs/guides/automatic-server-pausing.md | 67 ++++ examples/docker-compose-autopause.yml | 18 + scripts/PalIntercept.py | 33 ++ scripts/autopause.sh | 29 ++ scripts/autopaused-ctl.sh | 51 +++ scripts/helper_autopause.sh | 362 ++++++++++++++++++ scripts/helper_functions.sh | 70 ++-- scripts/init.sh | 33 ++ scripts/is_safe_timing.sh | 164 ++++++++ scripts/player_logging.sh | 32 +- scripts/rcon.sh | 31 ++ scripts/rest_api.sh | 115 +++--- tests/autopause/docker-compose.yml | 20 + tests/autopause/test-manually.sh | 18 + tests/common.yml | 16 + 18 files changed, 1079 insertions(+), 97 deletions(-) create mode 100644 docusaurus/docs/guides/automatic-server-pausing.md create mode 100644 examples/docker-compose-autopause.yml create mode 100644 scripts/PalIntercept.py create mode 100644 scripts/autopause.sh create mode 100644 scripts/autopaused-ctl.sh create mode 100755 scripts/helper_autopause.sh create mode 100755 scripts/is_safe_timing.sh create mode 100644 scripts/rcon.sh create mode 100644 tests/autopause/docker-compose.yml create mode 100755 tests/autopause/test-manually.sh create mode 100644 tests/common.yml diff --git a/Dockerfile b/Dockerfile index b41346409..8242531d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,6 +53,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ jo=1.9-1 \ jq=1.6-2.1 \ netcat-traditional=1.10-47 \ + iputils-ping libpcap0.8 \ + mitmproxy \ + iproute2 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* @@ -71,6 +74,13 @@ RUN case ${TARGETARCH} in \ && chmod +x supercronic \ && mv supercronic /usr/local/bin/supercronic +# install patched knockd (as same as https://github.com/itzg/docker-minecraft-server/blob/master/build/ubuntu/install-packages.sh) +RUN wget --progress=dot:giga https://github.com/Metalcape/knock/releases/download/0.8.1/knock-0.8.1-${TARGETARCH}.tar.gz -O /tmp/knock.tar.gz && \ + tar -xf /tmp/knock.tar.gz -C /usr/local/ && rm /tmp/knock.tar.gz && \ + ln -s /usr/local/sbin/knockd /usr/sbin/knockd && \ + setcap cap_net_raw=ep /usr/local/sbin/knockd && \ + find /usr/lib -name 'libpcap.so.0.8' -execdir cp '{}' libpcap.so.1 \; + # hadolint ignore=DL3044 ENV HOME=/home/steam \ PORT= \ @@ -102,6 +112,9 @@ ENV HOME=/home/steam \ AUTO_REBOOT_WARN_MINUTES=5 \ AUTO_REBOOT_EVEN_IF_PLAYERS_ONLINE=false \ AUTO_REBOOT_CRON_EXPRESSION="0 0 * * *" \ + AUTO_PAUSE_ENABLED=false \ + AUTO_PAUSE_TIMEOUT_EST=180 \ + AUTO_PAUSE_KNOCK_INTERFACE=eth0 \ DISCORD_SUPPRESS_NOTIFICATIONS= \ DISCORD_WEBHOOK_URL= \ DISCORD_CONNECT_TIMEOUT=30 \ @@ -156,7 +169,15 @@ RUN chmod +x /home/steam/server/*.sh && \ mv /home/steam/server/backup.sh /usr/local/bin/backup && \ mv /home/steam/server/update.sh /usr/local/bin/update && \ mv /home/steam/server/restore.sh /usr/local/bin/restore && \ - ln -sf /home/steam/server/rest_api.sh /usr/local/bin/rest-cli + ln -sf /home/steam/server/rest_api.sh /usr/local/bin/rest-cli && \ + ln -sf /home/steam/server/rcon.sh /usr/local/bin/rcon-cli && \ + ln -sf /home/steam/server/is_safe_timing.sh /usr/local/bin/is_safe_timing && \ + ln -sf /home/steam/server/autopause.sh /usr/local/bin/autopause && \ + ln -sf /home/steam/server/autopaused-ctl.sh /usr/local/sbin/autopaused-ctl + +# install mitmproxy addons +RUN mkdir -p /home/steam/autopause/addons && \ + mv /home/steam/server/PalIntercept.py ../autopause/addons/ WORKDIR /home/steam/server diff --git a/README.md b/README.md index 3a6bf7058..94bcbf432 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,11 @@ It is highly recommended you set the following environment values before startin | AUTO_REBOOT_ENABLED | Enables automatic reboots | false | true/false | 0.21.0 | | AUTO_REBOOT_WARN_MINUTES | How long to wait to reboot the server, after the player were informed. | 5 | Integer | 0.21.0 | | AUTO_REBOOT_EVEN_IF_PLAYERS_ONLINE | Restart the Server even if there are players online. | false | true/false | 0.21.0 | +| AUTO_PAUSE_ENABLED | Enables automatic pause (with ENABLE_PLAYER_LOGGING=true required.) | false | true/false | 0.36.0 | +| AUTO_PAUSE_TIMEOUT_EST | default 180 (seconds) describes the time between the last client disconnect and the pausing of the process (read as timeout established) | 180 | Integer | 0.36.0 | +| AUTO_PAUSE_KNOCK_INTERFACE | The docker default is eth0. Select another NIC if necessary. | eth0 | "string" | 0.36.0 | +| AUTO_PAUSE_LOG | Enable auto-pause logging | true | true/false | 0.36.0 | +| AUTO_PAUSE_DEBUG | Enable auto-pause debug logging | false | true/false | 0.36.0 | | TARGET_MANIFEST_ID | Locks game version to corespond with Manifest ID from Steam Download Depot. | | See [Manifest ID Table](#locking-specific-game-version) | 0.27.0 | | DISCORD_WEBHOOK_URL | Discord webhook url found after creating a webhook on a discord server. | | `https://discord.com/api/webhooks/` | 0.22.0 | | DISCORD_SUPPRESS_NOTIFICATIONS | Enables/Disables `@silent` messages for the server messages. | false | boolean | 0.34.0 | @@ -292,11 +297,12 @@ It is highly recommended you set the following environment values before startin ### Game Ports -| Port | Info | -|-------|------------------| -| 8211 | Game Port (UDP) | -| 27015 | Query Port (UDP) | -| 25575 | RCON Port (TCP) | +| Port | Info | +|-------|---------------------| +| 8211 | Game Port (UDP) | +| 27015 | Query Port (UDP) | +| 25575 | RCON Port (TCP) | +| 8212 | REST API Port (TCP) | ## Using RCON @@ -529,6 +535,68 @@ AUTO_REBOOT_CRON_EXPRESSION is a cron expression, in a Cron-Expression you defin Set AUTO_REBOOT_CRON_EXPRESSION to change the set the schedule, default is everynight at midnight according to the timezone set with TZ +## Configuring Automatic Pause + +An auto-pause functionality is provided that monitors whether clients are connected to the server. + +If a client is not connected for a specified time, the PalServer process will try to put into a sleep state to save power. + +The auto-pause service will retry several times to avoid the timing of writing save data. + +When a client attempts to connect while the process is paused, then process will be restored to a running state. + +The experience for the client does not change. + +In the paused state, world time is stopped. + +This feature can be enabled by setting the environment variable `AUTO_PAUSE_ENABLED` to "true". + +A starting, example compose file has been provided in `examples/docker-compose-autopause.yml`. + +| Variable | Info | Default Values | Allowed Values | +|-----------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------|----------------| +| AUTO_PAUSE_ENABLED | Enables automatic pause (Puts the server to sleep to save power when there are no online players) | false | true/false | +| AUTO_PAUSE_TIMEOUT_EST | default 180 (seconds) describes the time between the last client disconnect and the pausing of the process (read as timeout established) | 180 | Integer | +| AUTO_PAUSE_KNOCK_INTERFACE | If the default interface doesn't work, check the interface using the "ip a" command inside the container. Using the loopback interface (lo) may not produce the desired results. | eth0 | "string" | +| AUTO_PAUSE_LOG | Enable auto-pause logging | true | true/false | +| AUTO_PAUSE_DEBUG | Enable auto-pause debug logging | false | true/false | + +### Resume manually + +A file called `.paused` is created in `/palworld` directory when the server is paused and removed when the server is resumed. + +Other services may check for this file's existence before waking the server. + +Alternatively, resume with the following command: + +```shell +docker exec -it palworld-server autopause resume +``` + +### Service control manually + +A `.skip-pause` file can be created in the `/palworld` directory to make the server skip autopausing, +for as long as the file is present. + +Alternatively, you can control with the following command: + +```shell +docker exec -it palworld-server autopause stop +docker exec -it palworld-server autopause continue +``` + +This `autopause stop` command is also used during automatic reboots, automatic updates, and container stops. +It is also used to shutdown command via REST API/RCON. + +### With Community Server + +If the environment variable `COMMUNITY` is true, A proxy server is started within the container to +maintain registration on the community server list. + +The proxy server captures communication with `api.palworldgames.com`. + +The auto-pause service will replay captured data in the paused state. + ## Editing Server Settings ### With Environment Variables diff --git a/docusaurus/docs/getting-started/configuration/server-settings.md b/docusaurus/docs/getting-started/configuration/server-settings.md index bb0d2c76c..eb45e25e0 100644 --- a/docusaurus/docs/getting-started/configuration/server-settings.md +++ b/docusaurus/docs/getting-started/configuration/server-settings.md @@ -55,6 +55,11 @@ It is highly recommended you set the following environment values before startin | AUTO_REBOOT_ENABLED | Enables automatic reboots | false | true/false | 0.21.0 | | AUTO_REBOOT_WARN_MINUTES | How long to wait to reboot the server, after the player were informed. | 5 | Integer | 0.21.0 | | AUTO_REBOOT_EVEN_IF_PLAYERS_ONLINE | Restart the Server even if there are players online. | false | true/false | 0.21.0 | +| AUTO_PAUSE_ENABLED | Enables automatic pause (with ENABLE_PLAYER_LOGGING=true required.) | false | true/false | 0.36.0 | +| AUTO_PAUSE_TIMEOUT_EST | default 180 (seconds) describes the time between the last client disconnect and the pausing of the process (read as timeout established) | 180 | Integer | 0.36.0 | +| AUTO_PAUSE_KNOCK_INTERFACE | The docker default is eth0. Select another NIC if necessary. | eth0 | "string" | 0.36.0 | +| AUTO_PAUSE_LOG | Enable auto-pause logging | true | true/false | 0.36.0 | +| AUTO_PAUSE_DEBUG | Enable auto-pause debug logging | false | true/false | 0.36.0 | | TARGET_MANIFEST_ID | Locks game version to corespond with Manfiest ID from Steam Download Depot. | | See [Manifest ID Table](https://palworld-server-docker.loef.dev/guides/pinning-game-version#version-to-manifest-id-table) | 0.27.0 | | DISCORD_WEBHOOK_URL | Discord webhook url found after creating a webhook on a discord server | | `https://discord.com/api/webhooks/` | 0.22.0 | | DISCORD_SUPPRESS_NOTIFICATIONS | Enables/Disables `@silent` messages for the server messages. | false | boolean | 0.34.0 | @@ -112,8 +117,9 @@ It is highly recommended you set the following environment values before startin The server needs the following ports by default. -| Port | Info | -|-------|------------------| -| 8211 | Game Port (UDP) | -| 27015 | Query Port (UDP) | -| 25575 | RCON Port (TCP) | +| Port | Info | +|-------|---------------------| +| 8211 | Game Port (UDP) | +| 27015 | Query Port (UDP) | +| 25575 | RCON Port (TCP) | +| 8212 | REST API Port (TCP) | diff --git a/docusaurus/docs/guides/automatic-server-pausing.md b/docusaurus/docs/guides/automatic-server-pausing.md new file mode 100644 index 000000000..40be08aaf --- /dev/null +++ b/docusaurus/docs/guides/automatic-server-pausing.md @@ -0,0 +1,67 @@ +--- +sidebar_position: 8 +--- + +# Automatic pause the server when no players are connected + +## Configuring Automatic Pause + +An auto-pause functionality is provided that monitors whether clients are connected to the server. + +If a client is not connected for a specified time, the PalServer process will try to put into a sleep state to save power. + +The auto-pause service will retry several times to avoid the timing of writing save data. + +When a client attempts to connect while the process is paused, then process will be restored to a running state. + +The experience for the client does not change. + +In the paused state, world time is stopped. + +This feature can be enabled by setting the environment variable `AUTO_PAUSE_ENABLED` to "true". + +A starting, example compose file has been provided in `examples/docker-compose-autopause.yml`. + +| Variable | Info | Default Values | Allowed Values | +|-----------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------|----------------| +| AUTO_PAUSE_ENABLED | Enables automatic pause (Puts the server to sleep to save power when there are no online players) | false | true/false | +| AUTO_PAUSE_TIMEOUT_EST | default 180 (seconds) describes the time between the last client disconnect and the pausing of the process (read as timeout established) | 180 | Integer | +| AUTO_PAUSE_KNOCK_INTERFACE | If the default interface doesn't work, check the interface using the "ip a" command inside the container. Using the loopback interface (lo) may not produce the desired results. | eth0 | "string" | +| AUTO_PAUSE_LOG | Enable auto-pause logging | true | true/false | +| AUTO_PAUSE_DEBUG | Enable auto-pause debug logging | false | true/false | + +### Resume manually + +A file called `.paused` is created in `/palworld` directory when the server is paused and removed when the server is resumed. + +Other services may check for this file's existence before waking the server. + +Alternatively, resume with the following command: + +```shell +docker exec -it palworld-server autopause resume +``` + +### Service control manually + +A `.skip-pause` file can be created in the `/palworld` directory to make the server skip autopausing, +for as long as the file is present. + +Alternatively, you can control with the following command: + +```shell +docker exec -it palworld-server autopause stop +docker exec -it palworld-server autopause continue +``` + +This `autopause stop` command is also used during automatic reboots, automatic updates, and container stops. +It is also used to shutdown command via REST API/RCON. + +### With Community Server + +If the environment variable `COMMUNITY` is true, A proxy server is started within the container to +maintain registration on the community server list. + +The proxy server captures communication with `api.palworldgames.com`. + +The auto-pause service will replay captured data in the paused state. diff --git a/examples/docker-compose-autopause.yml b/examples/docker-compose-autopause.yml new file mode 100644 index 000000000..b3b60b82b --- /dev/null +++ b/examples/docker-compose-autopause.yml @@ -0,0 +1,18 @@ +--- +services: + palworld: + image: thijsvanloef/palworld-server-docker:latest + restart: unless-stopped + container_name: palworld-server + stop_grace_period: 30s + ports: + - 8211:8211/udp + environment: + SERVER_NAME: "palworld-server-docker by Thijs van Loef" + SERVER_DESCRIPTION: "palworld-server-docker by Thijs van Loef" + SERVER_PASSWORD: "worldofpals" + ADMIN_PASSWORD: "adminPasswordHere" + AUTO_PAUSE_ENABLED: true + AUTO_PAUSE_TIMEOUT_EST: 180 # Time to pause when absent. (sec) + volumes: + - ./palworld:/palworld/ diff --git a/scripts/PalIntercept.py b/scripts/PalIntercept.py new file mode 100644 index 000000000..b9a6e5ddc --- /dev/null +++ b/scripts/PalIntercept.py @@ -0,0 +1,33 @@ +""" +This script intercepts the communication content with "api.palworldgame.com". +""" +import os +import stat +import logging + +from mitmproxy import http +from mitmproxy.http import Headers + +BASEDIR = "/home/steam/autopause" +REGISTER_JSON_PATH = BASEDIR + "/register.json" +UPDATE_JSON_PATH = BASEDIR + "/update.json" + +class PalIntercept: + def __init__(self): + st = os.stat(BASEDIR) + self.uid = st.st_uid + self.gid = st.st_gid + + def response(self, flow: http.HTTPFlow): + if flow.request.host == "api.palworldgame.com" and flow.request.is_http11 and flow.response.status_code == 200: + if flow.request.path == "/server/register": + with open(REGISTER_JSON_PATH, "wb") as f: + f.write(flow.request.content) + os.chown(REGISTER_JSON_PATH, self.uid, self.gid) + if flow.request.path == "/server/update": + with open(UPDATE_JSON_PATH, "wb") as f: + f.write(flow.request.content) + os.chown(UPDATE_JSON_PATH, self.uid, self.gid) + + +addons = [PalIntercept()] diff --git a/scripts/autopause.sh b/scripts/autopause.sh new file mode 100644 index 000000000..7156cfba9 --- /dev/null +++ b/scripts/autopause.sh @@ -0,0 +1,29 @@ +#!/bin/bash +SCRIPT_DIR=${SCRIPT_DIR:-$(dirname "$(readlink -fn "${0}")")} +#shellcheck source=scripts/helper_functions.sh +source "${SCRIPT_DIR}/helper_functions.sh" +#shellcheck source=scripts/helper_autopause.sh +source "${SCRIPT_DIR}/helper_autopause.sh" + +if ! AutoPauseEx_isEnabled; then + echo "An autopause service has not started yet." + return 1; +fi + +case "${1}" in +"resume") + AutoPauseEx_resume "${2}" + ;; +"stop"|"skip") + AutoPauseEx_stopService on "${2}" + ;; +"continue") + AutoPauseEx_stopService off "${2}" + ;; +*) + echo "Usage: $(basename "${0}") [reason]" + echo "command:" + echo " resume ... resume from paused state" + echo " stop ... stop service" + echo " continue ... continue service" +esac diff --git a/scripts/autopaused-ctl.sh b/scripts/autopaused-ctl.sh new file mode 100644 index 000000000..14ca8cb81 --- /dev/null +++ b/scripts/autopaused-ctl.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# Control the port knock daemon. +# Called only on the server side. + +basedir="/home/steam/autopause" +config="${basedir}/knockd.cfg" + +case "${1}" in +"start") + if [ ! -f "${config}" ]; then + cat - << EOF > "${config}" +[options] + logfile = ${basedir}/knockd.log + interface = ${AUTO_PAUSE_KNOCK_INTERFACE:-eth0} +[resume-by-player] + sequence = ${PORT:-8211}:udp + seq_cooldown = 5 + command = autopause resume "LOGIN from %IP%" +[resume-by-rcon] + sequence = ${RCON_PORT:-25575} + seq_timeout = 1 + command = autopause resume "RCON from %IP%" + tcpflags = syn +[resume-by-rest] + sequence = ${REST_API_PORT:-8212} + seq_timeout = 1 + command = autopause resume "REST_API from %IP%" + tcpflags = syn +EOF + fi + pid=$(pidof knockd) + if [ -n "${pid}" ]; then + echo "Already started knockd ${pid}" + return + fi + knockd -d -c "${config}" -p "${basedir}/knockd.pid" + ;; +"stop") + pid=$(pidof knockd) + if [ -z "${pid}" ]; then + echo "Already stopped knockd ${pid}" + return + fi + kill -KILL "${pid}" + ;; +*) + echo "Usage: $(basename "${0}") " + echo "command:" + echo " start ... launch knockd" + echo " stop ... kill knockd" +esac diff --git a/scripts/helper_autopause.sh b/scripts/helper_autopause.sh new file mode 100755 index 000000000..db75ef87c --- /dev/null +++ b/scripts/helper_autopause.sh @@ -0,0 +1,362 @@ +#!/bin/bash +# This file contains functions which can be used in multiple scripts + +#------------------------------- +# Env vars +#------------------------------- +AUTO_PAUSE_LOG=${AUTO_PAUSE_LOG:-true} +AUTO_PAUSE_TIMEOUT_EST=${AUTO_PAUSE_TIMEOUT_EST:-30} +AUTO_PAUSE_KNOCK_INTERFACE=${AUTO_PAUSE_KNOCK_INTERFACE:-eth0} +AUTO_PAUSE_DEBUG=${AUTO_PAUSE_DEBUG:-false} + +#------------------------------- +# PalConfig vars +#------------------------------- +declare -r DATA_DIR="${DATA_DIR:-/palworld}" +SAVE_DIR="" +SERVER_ID="" + +#------------------------------- +# AutoPause vars +#------------------------------- +declare -r AP_pause_file="${DATA_DIR}/.paused" +declare -r AP_skip_file="${DATA_DIR}/.skip-pause" # for shutdown and reboot +declare -r AP_basedir="/home/steam/autopause" +declare -i AP_no_player_sec=0 +declare -r APLog_pipe="${AP_basedir}/.logpipe" +declare -r APLog_pid="${AP_basedir}/.pid" +AP_is_service_side=false + +#------------------------------- +# AutoPause Community vars +#------------------------------- +APComm_jsonRegister="" +APComm_jsonUpdate="" +declare -r APComm_datadir="${AP_basedir}" +declare -i APComm_seq=0 # 0:register / 1:update +declare -i APComm_timer=0 + +#------------------------------- +# PalConfig +#------------------------------- + +PalConfig_init() { + # The PalServer configuration file must already be generated. + # fetch SERVER_ID from GameUserSettings.ini + SERVER_ID="$(sed -n -re 's/DedicatedServerName=(.*)/\1/p' "${DATA_DIR}/Pal/Saved/Config/LinuxServer/GameUserSettings.ini")" + # update SAVE_DIR + SAVE_DIR="${DATA_DIR}/Pal/Saved/SaveGames/0/${SERVER_ID}" +} + +#------------------------------- +# AutoPause Log +#------------------------------- + +APLog() { + isTrue "${AUTO_PAUSE_LOG:-true}" && LogInfo "[AUTO PAUSE] ${1}" | APLog_send +} + +APLog_debug() { + isTrue "${AUTO_PAUSE_DEBUG:-false}" && LogInfo "[AUTO PAUSE DEBUG] ${1}" | APLog_send +} + +APLog_send() { + if "${AP_is_service_side}"; then + cat - + return 0 + fi + # send log from application to server via named pipe. + local pid + pid="$(cat "${APLog_pid}")" + if [ -n "${pid}" ]; then + kill -USR1 "${pid}" && cat - > "${APLog_pipe}" + return 0 + fi + return 1 +} + +APLog_receive() { + cat "${APLog_pipe}" +} + +APLog_init() { + test -p "${APLog_pipe}" || mkfifo "${APLog_pipe}" + trap 'APLog_receive' USR1 + echo -n "$$" > ${APLog_pid} +} + +#------------------------------- +# AutoPause Core +#------------------------------- + +AP_startDaemon() { + autopaused-ctl start + pid=$(pidof knockd) + APLog_debug "Start knockd pid:${pid}" +} + +AP_stopDaemon() { + local pid + pid=$(pidof knockd) + if [ ! "${pid}" = "" ]; then + APLog_debug "Stop knockd pid:${pid}" + autopaused-ctl stop + fi +} + +AP_skip() { + if isTrue "${1:-on}"; then + su steam -c "touch ${AP_skip_file}" + else + rm -f "${AP_skip_file}" + fi +} + +AP_isSkipped() { + test -e "${AP_skip_file}" +} + +AP_isEnabled() { + isTrue "${AUTO_PAUSE_ENABLED}" +} + +AP_isRunning() { + pidof "" +} + +AP_isPaused() { + test -e "${AP_pause_file}" +} + +# is realy paused +AP_isSleep() { + test -n "$(pgrep -r T 'PalServer-Linux')" +} + +AP_pause() { + local on="${1:-on}" + local pid + pid=$(pidof PalServer-Linux-Shipping) + if isTrue "${on}"; then + if AP_isSleep; then + APLog "[WARNING] Already sleeped..." + return 0 + fi + APLog "Paused." + kill -STOP "${pid}" + touch "${AP_pause_file}" + else + if ! AP_isSleep; then + APLog "[WARNING] Already wakeuped..." + return 0 + fi + APLog "Wakeup!!!" + kill -CONT "${pid}" + rm -f "${AP_pause_file}" + fi + return 0 +} + +#------------------------------- +# AutoPause Community API +#------------------------------- + +# api.palworldgame.com/server Call +APComm_API() { + local api="${1}" + local data="${2}" + local url="https://api.palworldgame.com/${api}" + local accept="Accept: application/json" + local agent="X-UnrealEngine-Agent" + curl -s -L -X POST "${url}" -H "${accept}" -A "${agent}" --json "${data}" +} + +APComm_loadJSON() { + local -i result=0 delta + APComm_jsonRegister="$(jq -c < "${APComm_datadir}/register.json")" + result=$? + APComm_jsonUpdate="$(jq -c < "${APComm_datadir}/update.json")" + ((result=result+$?)) + if [ ${result} -eq 0 ]; then + # It's not fresh after 120 seconds. + ((delta=$(date +%s)-$(date +%s -r "${APComm_datadir}/update.json"))) + if [ ${delta} -gt 120 ]; then + APLog_debug "update.json is not fresh." + return 1 + fi + fi + return ${result} +} + +APComm_register() { + local data response + data=$(echo -n "${APComm_jsonRegister//\"/\"}" | jq ".name|=\"${SERVER_NAME} (paused)\"") + response=$(APComm_API "server/register" "${data}") + local -i result=$? + if [ ${result} -eq 0 ] && [ -n "${response}" ]; then + id=$(echo -n "${response//\"/\"}" | jq -r '.server_id') + key=$(echo -n "${response//\"/\"}" | jq -r '.update_key') + APComm_jsonUpdate=$(echo -n "${APComm_jsonUpdate//\"/\"}" | jq ".server_id|=\"${id}\"" | jq ".update_key|=\"${key}\"") + return 0 + fi + APLog "${response}" + return 1 +} + +APComm_update() { + response=$(APComm_API "server/update" "${APComm_jsonUpdate}") + local -i result=$? + if [ ${result} -eq 0 ] && [ -n "${response}" ]; then + local message status + message=$(echo "${response//\"/\"}" | jq -r '.error_message') + status=$(echo "${response//\"/\"}" | jq -r '.status') + if [ "${status}" = "ok" ]; then + return 0 + fi + APLog "status:\"${status}\" message:\"${message}\"" + return 1 + fi + APLog "${response}" + return 1 +} + +APComm_init() { + if ! isTrue "${COMMUNITY}"; then return; fi + + if APComm_loadJSON; then + APComm_seq=1 # keep continue same update data + else + APComm_seq=0 # Start over from registration + fi + APComm_timer=0 +} + +APComm_proc() { + if ! isTrue "${COMMUNITY}"; then return; fi + + local -i now out + now=$(date +%s) + out=$((APComm_timer+30)) + if [ ${now} -gt ${out} ]; then + APComm_timer=${now} + case ${APComm_seq} in + 0) + if APComm_register && APComm_update; then + APComm_seq=1 + fi + ;; + 1) + if ! APComm_update; then + APComm_seq=0 + fi + ;; + esac + fi +} + +#------------------------------- +# AutoPause Service API +#------------------------------- + +AutoPause_init() { + AP_is_service_side=true + APLog_init + PalConfig_init + AP_skip off + rm -f "${AP_pause_file}" + AutoPause_resetTimer +} + +AutoPause_resetTimer() { + AP_no_player_sec=0 +} + +AutoPause_addTimer() { + if AP_isSkipped; then return; fi + local -i delta="${1}" + ((AP_no_player_sec+=delta)) +} + +AutoPause_checkTimer() { + AP_isEnabled && ! AP_isSkipped && test "${AP_no_player_sec}" -gt "${AUTO_PAUSE_TIMEOUT_EST}" +} + +AutoPause_challengeToPause() { + local result + result=$(is_safe_timing "${SAVE_DIR}") + APLog "Challenge to pause ... ${result}" + if [ "${result}" = "OK" ]; then + if AP_pause on; then + return 0 + fi + fi + return 1 +} + +AutoPause_waitWakeup() { + AP_startDaemon + APComm_init + while true; do + sleep 0.5 + # resumed by "autopause resume" command + if ! AP_isSleep; then + break + fi + if ! AP_isPaused; then + APLog "Detected remove of ${AP_pause_file} and will resume it." + AP_pause off + break + fi + if AP_isSkipped; then + APLog "Detected create of ${AP_skip_file} and will resume it." + AP_pause off + break + fi + # During PAUSE, + # it will continue to register and update + # the community server list as a dummy. + APComm_proc + done + AP_stopDaemon +} + +#------------------------------- +# AutoPause External API +#------------------------------- + +AutoPauseEx_isEnabled() { + AP_isEnabled && test -e "${APLog_pid}" +} + +AutoPauseEx_resume() { + if ! AutoPauseEx_isEnabled; then return; fi + if AP_isPaused; then + if [ -n "${1}" ]; then + APLog "Resumed by \"${1}\"" + fi + AP_pause off + fi +} + +AutoPauseEx_stopService() { + if ! AutoPauseEx_isEnabled; then return; fi + local on="${1:-on}" + if isTrue "${on}"; then + if AP_isPaused; then + AP_pause off + fi + if AP_isSkipped; then + APLog_debug "Service has already disabled. \"${2}\"" + else + APLog "Service has been disabled. \"${2}\"" + fi + else + if AP_isSkipped; then + APLog "Service has been enabled. \"${2}\"" + else + APLog_debug "Service has already enabled. \"${2}\"" + fi + fi + + AP_skip "${on}" +} diff --git a/scripts/helper_functions.sh b/scripts/helper_functions.sh index 0019c31a2..c3e8279f1 100644 --- a/scripts/helper_functions.sh +++ b/scripts/helper_functions.sh @@ -76,8 +76,18 @@ isExecutable() { return "$return_val" } +isTrue() { + if [[ "${1,,}" =~ (true|on|1) ]]; then return 0; fi + return 1 +} + +#isFalse() { +# if [[ "${1,,}" =~ (false|off|0) ]]; then return 0; fi +# return 1 +#} + # Convert player list from JSON format -convert_JSON_to_CSV_players(){ +convert_JSON_to_CSV_players() { echo 'name,playeruid,steamid' echo -n "${1}" | \ jq -r '.players[] | [ .name, .playerId, .userId ] | @csv' | \ @@ -90,18 +100,16 @@ convert_JSON_to_CSV_players(){ # Outputs nothing if REST API or RCON is not enabled and returns 1 # Outputs player list if REST API or RCON is enabled and returns 0 get_players_list() { - local return_val=0 # Prefer REST API - if [ "${REST_API_ENABLED,,}" != true ]; then - if [ "${RCON_ENABLED,,}" != true ]; then - return_val=1 - fi - + if isTrue "${REST_API_ENABLED}"; then + convert_JSON_to_CSV_players "$(REST_API players)" + return 0 + fi + if isTrue "${RCON_ENABLED}"; then RCON "ShowPlayers" - return "$return_val" + return 0 fi - convert_JSON_to_CSV_players "$(REST_API players)" - return "$return_val" + return 1 } # Checks how many players are currently connected @@ -171,17 +179,27 @@ DiscordMessage() { } # REST API Call -REST_API(){ - local DATA="${2}" - local URL="http://localhost:${REST_API_PORT}/v1/api/${1}" - local ACCEPT="Accept: application/json" - local USERPASS="admin:${ADMIN_PASSWORD}" - local post_api="save|stop" - if [ "${DATA}" = "" ] && [[ ! ${1} =~ ${post_api} ]]; then - curl -s -L -X GET "${URL}" -H "${ACCEPT}" -u "${USERPASS}" - else - curl -s -L -X POST "${URL}" -H "${ACCEPT}" -u "${USERPASS}" --json "${DATA}" - fi +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 accept="Accept: application/json" + local -r userpass="admin:${ADMIN_PASSWORD}" + local -r post_api="save|stop" + local -r down_api="shutdown|stop" + local -i result=0 + if [ "${data}" = "" ] && [[ ! ${api} =~ ${post_api} ]]; then + curl -s -L -X GET "${url}" -H "${accept}" -u "${userpass}" + result=$? + else + curl -s -L -X POST "${url}" -H "${accept}" -u "${userpass}" --json "${data}" + result=$? + fi + if [ ${result} -eq 0 ] && [[ ${api} =~ ${down_api} ]]; then + autopause abort > /dev/null + fi + return ${result} } # RCON Call @@ -196,10 +214,11 @@ RCON() { # Returns 1 if not able to broadcast broadcast_command() { local return_val=0 - if [ "${REST_API_ENABLED,,}" = true ]; then - local json="{\"message\":\"${1}\"}" - if ! REST_API announce "${json}"; then - return_val=1 + if isTrue "${REST_API_ENABLED}"; then + local json="{\"message\":\"${1}\"}" result + result="$(REST_API announce "${json}")" + if [ ! "${result}" = "OK" ]; then + return_val=1 fi return "$return_val" fi @@ -254,6 +273,7 @@ countdown_message() { # Only do countdown if there are players if [ "$(get_player_count)" -gt 0 ]; then if [[ "${mtime}" =~ ^[0-9]+$ ]]; then + autopause stop "countdown_message" for ((i = "${mtime}" ; i > 0 ; i--)); do case "$i" in 1 ) diff --git a/scripts/init.sh b/scripts/init.sh index d1f3634e3..e43004545 100644 --- a/scripts/init.sh +++ b/scripts/init.sh @@ -22,10 +22,43 @@ if ! [ -w "/palworld" ]; then exit 1 fi +# launch proxy for keep community server list in paused. +if isTrue "${AUTO_PAUSE_ENABLED}" && isTrue "${COMMUNITY}" && isTrue "${ENABLE_PLAYER_LOGGING}"; then + LogAction "AUTO PAUSE with Community" + LogInfo "Launch proxy." + if isTrue "${AUTO_PAUSE_DEBUG}"; then + mitmweb --web-host 0.0.0.0 --set block_global=false --ssl-insecure -s /home/steam/autopause/addons/PalIntercept.py & + LogInfo "Web Interface URL: http://localhost:8081/" + else + mitmdump --set block_global=false --ssl-insecure -s /home/steam/autopause/addons/PalIntercept.py > /var/log/mitmdump.log & + fi + + trap 'exit 1' SIGTERM + echo -n "Wait until proxy is initialized..." + while [ ! -f "/home/steam/.mitmproxy/mitmproxy-ca-cert.pem" ]; do + echo -n "." + sleep 0.5 + done + echo "done." + chown -R "${PUID}:${PGID}" "/home/steam/.mitmproxy" + chown -R "${PUID}:${PGID}" "/home/steam/autopause/addons/__pycache__" + + LogInfo "Update ca-certificates." + cp /home/steam/.mitmproxy/mitmproxy-ca-cert.pem /usr/local/share/ca-certificates/mitmproxy.crt + update-ca-certificates + + LogInfo "Using proxy now." + export http_proxy="localhost:8080" + export https_proxy="localhost:8080" + export no_proxy="localhost,127.0.0.1,192.168.0.0/16,172.16.0.0/12,10.0.0.0/8" +fi + mkdir -p /palworld/backups # shellcheck disable=SC2317 term_handler() { + autopause stop "term_handler" + DiscordMessage "Shutdown" "${DISCORD_PRE_SHUTDOWN_MESSAGE}" "in-progress" "${DISCORD_PRE_SHUTDOWN_MESSAGE_ENABLED}" "${DISCORD_PRE_SHUTDOWN_MESSAGE_URL}" if ! shutdown_server; then diff --git a/scripts/is_safe_timing.sh b/scripts/is_safe_timing.sh new file mode 100755 index 000000000..7c5943528 --- /dev/null +++ b/scripts/is_safe_timing.sh @@ -0,0 +1,164 @@ +#!/bin/bash +# Save data is written every 30 seconds and takes a few seconds to complete. +# This command determines safe timing. +# exit-code: +# 0 ... safe timing (Between 5 and 25 seconds since it was last written.) +# 1 ... risky timing (Writing is in progress or inconsistent data remains.) + +#---------------------- +# SaveGames privates +#---------------------- + +SG_getLatestBackupDir() { + local basedir="${1}" list + if [ -n "${1}" ] && [[ ! "${1}" =~ \/$ ]]; then + basedir="${1}/" + fi + mapfile -t list < <(ls -1td "${basedir}"backup/world/????.??.??-??.??.??) + if [ "${#list}" -lt 1 ]; then + return 1 # no files + fi + echo -n "${list[0]}" + return 0 +} + +SG_callback() { + # ${1}:"" + # ${2}:"