From 22a579a19c1f4db634c9bf516936b93b925df5de Mon Sep 17 00:00:00 2001 From: Nicolas Chan Date: Wed, 3 Mar 2021 22:12:28 -0800 Subject: [PATCH] Add rcon to backup script --- README.md | 5 +- backup.sh | 125 ++++++++++++++++++++++++++++-- rcon.sh | 188 ++++++++++++++++++++++++---------------------- test/mock_rcon.py | 31 ++++++++ test/test.sh | 24 +++++- 5 files changed, 272 insertions(+), 101 deletions(-) create mode 100644 test/mock_rcon.py diff --git a/README.md b/README.md index 1be40a6..b6bda51 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,6 @@ Supports servers running in [screen](https://en.wikipedia.org/wiki/GNU_Screen), - Manage deletion of old backups - "thin" - keep last 24 hourly, last 30 daily, and use remaining space for weekly backups - "sequential" - delete oldest backup -- Choose your own compression algorithm (tested with: `gzip`, `xz`, `zstd`) - Works on vanilla (no plugins required) - Print backup status to the Minecraft chat @@ -66,11 +65,11 @@ tar -xzvf /path/to/backups/2019-04-09_02-15-01.tar.gz Then you can move your restored world (`restored-world` in this case) to your Minecraft server folder and rename it (usually called `world`) so the Minecraft server uses it. ## Why not use `tar` directly? -If the Minecraft server is currently running, you need to disable world autosaving, or you will likely get an error like this: +If you use `tar` while the server is running, you will likely get an error like this because Minecraft autosaves the world periodically: ``` tar: /some/path/here/world/region/r.1.11.mca: file changed as we read it ``` -This script will take care of disabling and then re-enabling autosaving for you, and also alert players in the chat of successful backups or errors. This script also manages deleting old backups. +To fix this problem, the backup script disables autosaving with the `save-off` Minecraft command before running `tar` and then re-enables autosaving after `tar` is done. ## Help - Make sure the compression algorithm you specify is installed on your system. (zstd is not installed by default) diff --git a/backup.sh b/backup.sh index 9f2ad43..1b0a487 100755 --- a/backup.sh +++ b/backup.sh @@ -47,9 +47,9 @@ while getopts 'a:cd:e:f:hi:l:m:o:p:qs:vw:' FLAG; do echo "-o Output directory" echo "-p Prefix that shows in Minecraft chat (default: Backup)" echo "-q Suppress warnings" - echo "-s Minecraft server screen name" + echo "-s Screen name, tmux session name, or hostname:port:password for rcon" echo "-v Verbose mode" - echo "-w Window manager: screen (default) or tmux" + echo "-w Window manager: screen (default), tmux, rcon" exit 0 ;; i) SERVER_WORLD=$OPTARG ;; @@ -71,6 +71,116 @@ log-warning () { echo -e "\033[0;33mWARNING:\033[0m $*" } +rcon-command () { + HOST="$(echo $1 | cut -d: -f1)" + PORT="$(echo $1 | cut -d: -f2)" + PASSWORD="$(echo $1 | cut -d: -f3-)" + COMMAND="$2" + + reverse-hex-endian () { + # Given a 4-byte hex integer, reverse endianness + while read -r -d '' -N 8 INTEGER; do + echo "$INTEGER" | sed -E 's/(..)(..)(..)(..)/\4\3\2\1/' + done + } + + decode-hex-int () { + # decode little-endian hex integer + while read -r -d '' -N 8 INTEGER; do + BIG_ENDIAN_HEX=$(echo "$INTEGER" | reverse-hex-endian) + echo "$((16#$BIG_ENDIAN_HEX))" + done + } + + stream-to-hex () { + xxd -ps + } + + hex-to-stream () { + xxd -ps -r + } + + encode-int () { + # Encode an integer as 4 bytes in little endian and return as hex + INT="$1" + # Source: https://stackoverflow.com/a/9955198 + printf "%08x" "$INT" | sed -E 's/(..)(..)(..)(..)/\4\3\2\1/' + } + + encode () { + # Encode a packet type and payload for the rcon protocol + TYPE="$1" + PAYLOAD="$2" + REQUEST_ID="$3" + PAYLOAD_LENGTH="${#PAYLOAD}" + TOTAL_LENGTH="$((4 + 4 + PAYLOAD_LENGTH + 1 + 1))" + + OUTPUT="" + OUTPUT+=$(encode-int "$TOTAL_LENGTH") + OUTPUT+=$(encode-int "$REQUEST_ID") + OUTPUT+=$(encode-int "$TYPE") + OUTPUT+=$(echo -n "$PAYLOAD" | stream-to-hex) + OUTPUT+="0000" + + echo -n "$OUTPUT" | hex-to-stream + } + + read-response () { + # read next response packet and return the payload text + HEX_LENGTH=$(head -c4 <&3 | stream-to-hex | reverse-hex-endian) + LENGTH=$((16#$HEX_LENGTH)) + + RESPONSE_PAYLOAD=$(head -c $LENGTH <&3 | stream-to-hex) + echo -n "$RESPONSE_PAYLOAD" + } + + response-request-id () { + echo -n "${1:0:8}" | decode-hex-int + } + + response-type () { + echo -n "${1:8:8}" | decode-hex-int + } + + response-payload () { + echo -n "${1:16:-4}" | hex-to-stream + } + + login () { + PASSWORD="$1" + encode 3 "$PASSWORD" 12 >&3 + + RESPONSE=$(read-response "$IN_PIPE") + + RESPONSE_REQUEST_ID=$(response-request-id "$RESPONSE") + if [ "$RESPONSE_REQUEST_ID" -eq -1 ] || [ "$RESPONSE_REQUEST_ID" -eq 4294967295 ]; then + log-warning "RCON connection failed: Wrong RCON password" 1>&2 + return 1 + fi + } + + run-command () { + COMMAND="$1" + + # encode 2 "$COMMAND" 13 >> "$OUT_PIPE" + encode 2 "$COMMAND" 13 >&3 + + RESPONSE=$(read-response "$IN_PIPE") + response-payload "$RESPONSE" + } + + # Open a TCP socket + # Source: https://www.xmodulo.com/tcp-udp-socket-bash-shell.html + exec 3<>/dev/tcp/"$HOST"/"$PORT" + + login "$PASSWORD" || return 1 + run-command "$COMMAND" + + # Close the socket + exec 3<&- + exec 3>&- +} + if [[ $COMPRESSION_FILE_EXTENSION == "." ]]; then COMPRESSION_FILE_EXTENSION="" fi @@ -78,7 +188,7 @@ fi # Check for missing encouraged arguments if ! $SUPPRESS_WARNINGS; then if [[ $SCREEN_NAME == "" ]]; then - log-warning "Minecraft screen name not specified (use -s)" + log-warning "Minecraft screen/tmux/rcon location not specified (use -s)" fi fi # Check for required arguments @@ -109,9 +219,11 @@ execute-command () { local COMMAND=$1 if [[ $SCREEN_NAME != "" ]]; then case $WINDOW_MANAGER in - "screen") screen -S $SCREEN_NAME -p 0 -X stuff "$COMMAND$(printf \\r)" + "screen") screen -S "$SCREEN_NAME" -p 0 -X stuff "$COMMAND$(printf \\r)" ;; - "tmux") tmux send-keys -t $SCREEN_NAME "$COMMAND" ENTER + "tmux") tmux send-keys -t "$SCREEN_NAME" "$COMMAND" ENTER + ;; + "rcon") rcon-command "$SCREEN_NAME" "$COMMAND" ;; esac fi @@ -238,6 +350,9 @@ delete-thinning () { delete-sequentially } +# Ensure directory exists +mkdir -p "$(dirname $ARCHIVE_PATH)" + # Disable world autosaving execute-command "save-off" diff --git a/rcon.sh b/rcon.sh index bf5e42e..cc4f679 100755 --- a/rcon.sh +++ b/rcon.sh @@ -1,96 +1,102 @@ #!/usr/bin/env bash -reverse-hex-endian () { - # Given a 4-byte hex integer, reverse endianness - while read -r -d '' -N 8 INTEGER; do - echo "$INTEGER" | sed -E 's/(..)(..)(..)(..)/\4\3\2\1/' - done -} - -decode-hex-int () { - # decode little-endian hex integer - while read -r -d '' -N 8 INTEGER; do - BIG_ENDIAN_HEX=$(echo "$INTEGER" | reverse-hex-endian) - echo "$((16#$BIG_ENDIAN_HEX))" - done -} - -encode-int () { - # Encode an integer as 4 bytes in little endian and return as hex - INT="$1" - # Source: https://stackoverflow.com/a/9955198 - printf "%08x" "$INT" | sed -E 's/(..)(..)(..)(..)/\4\3\2\1/' -} - -encode () { - # Encode a packet type and payload for the rcon protocol - TYPE="$1" - PAYLOAD="$2" - REQUEST_ID="$3" - PAYLOAD_LENGTH="${#PAYLOAD}" - TOTAL_LENGTH="$((4 + 4 + PAYLOAD_LENGTH + 1 + 1))" - - OUTPUT="" - OUTPUT+=$(encode-int "$TOTAL_LENGTH") - OUTPUT+=$(encode-int "$REQUEST_ID") - OUTPUT+=$(encode-int "$TYPE") - OUTPUT+=$(echo -n "$PAYLOAD" | xxd -ps) - OUTPUT+="0000" - - echo -n "$OUTPUT" | xxd -ps -r -} - -read-response () { - # read next response packet and return the payload text - IN_PIPE="$1" - # HEX_LENGTH=$(head -c4 "$IN_PIPE" | xxd -ps | reverse-hex-endian) - HEX_LENGTH=$(head -c4 <&3 | xxd -ps | reverse-hex-endian) - LENGTH=$((16#$HEX_LENGTH)) - - RESPONSE_PAYLOAD=$(head -c $LENGTH <&3 | xxd -ps) - echo -n "$RESPONSE_PAYLOAD" -} - -response-request-id () { - echo -n "${1:0:8}" | decode-hex-int -} - -response-type () { - echo -n "${1:8:8}" | decode-hex-int -} - -response-payload () { - echo -n "${1:16:-4}" | xxd -r -ps -} - -login () { - PASSWORD="$1" - encode 3 "$PASSWORD" 12 >&3 - - RESPONSE=$(read-response "$IN_PIPE") - - RESPONSE_REQUEST_ID=$(response-request-id "$RESPONSE") - if [ "$RESPONSE_REQUEST_ID" -eq -1 ] || [ "$RESPONSE_REQUEST_ID" -eq 4294967295 ]; then - echo "Authentication failed: Wrong RCON password" 1>&2 - return 1 - fi -} - -run-command () { - COMMAND="$1" - - # encode 2 "$COMMAND" 13 >> "$OUT_PIPE" - encode 2 "$COMMAND" 13 >&3 - - RESPONSE=$(read-response "$IN_PIPE") - response-payload "$RESPONSE" -} - rcon-command () { - HOST="$1" - PORT="$2" - PASSWORD="$3" - COMMAND="$4" + HOST="$(echo $1 | cut -d: -f1)" + PORT="$(echo $1 | cut -d: -f2)" + PASSWORD="$(echo $1 | cut -d: -f3-)" + COMMAND="$2" + + reverse-hex-endian () { + # Given a 4-byte hex integer, reverse endianness + while read -r -d '' -N 8 INTEGER; do + echo "$INTEGER" | sed -E 's/(..)(..)(..)(..)/\4\3\2\1/' + done + } + + decode-hex-int () { + # decode little-endian hex integer + while read -r -d '' -N 8 INTEGER; do + BIG_ENDIAN_HEX=$(echo "$INTEGER" | reverse-hex-endian) + echo "$((16#$BIG_ENDIAN_HEX))" + done + } + + stream-to-hex () { + xxd -ps + } + + hex-to-stream () { + xxd -ps -r + } + + encode-int () { + # Encode an integer as 4 bytes in little endian and return as hex + INT="$1" + # Source: https://stackoverflow.com/a/9955198 + printf "%08x" "$INT" | sed -E 's/(..)(..)(..)(..)/\4\3\2\1/' + } + + encode () { + # Encode a packet type and payload for the rcon protocol + TYPE="$1" + PAYLOAD="$2" + REQUEST_ID="$3" + PAYLOAD_LENGTH="${#PAYLOAD}" + TOTAL_LENGTH="$((4 + 4 + PAYLOAD_LENGTH + 1 + 1))" + + OUTPUT="" + OUTPUT+=$(encode-int "$TOTAL_LENGTH") + OUTPUT+=$(encode-int "$REQUEST_ID") + OUTPUT+=$(encode-int "$TYPE") + OUTPUT+=$(echo -n "$PAYLOAD" | stream-to-hex) + OUTPUT+="0000" + + echo -n "$OUTPUT" | hex-to-stream + } + + read-response () { + # read next response packet and return the payload text + HEX_LENGTH=$(head -c4 <&3 | stream-to-hex | reverse-hex-endian) + LENGTH=$((16#$HEX_LENGTH)) + + RESPONSE_PAYLOAD=$(head -c $LENGTH <&3 | stream-to-hex) + echo -n "$RESPONSE_PAYLOAD" + } + + response-request-id () { + echo -n "${1:0:8}" | decode-hex-int + } + + response-type () { + echo -n "${1:8:8}" | decode-hex-int + } + + response-payload () { + echo -n "${1:16:-4}" | hex-to-stream + } + + login () { + PASSWORD="$1" + encode 3 "$PASSWORD" 12 >&3 + + RESPONSE=$(read-response "$IN_PIPE") + + RESPONSE_REQUEST_ID=$(response-request-id "$RESPONSE") + if [ "$RESPONSE_REQUEST_ID" -eq -1 ] || [ "$RESPONSE_REQUEST_ID" -eq 4294967295 ]; then + echo "Authentication failed: Wrong RCON password" 1>&2 + return 1 + fi + } + + run-command () { + COMMAND="$1" + + # encode 2 "$COMMAND" 13 >> "$OUT_PIPE" + encode 2 "$COMMAND" 13 >&3 + + RESPONSE=$(read-response "$IN_PIPE") + response-payload "$RESPONSE" + } # Open a TCP socket # Source: https://www.xmodulo.com/tcp-udp-socket-bash-shell.html @@ -110,4 +116,4 @@ PORT="$2" PASSWORD="$3" COMMAND="$4" -rcon-command "$HOST" "$PORT" "$PASSWORD" "$COMMAND" +rcon-command "$HOST:$PORT:$PASSWORD" "$COMMAND" diff --git a/test/mock_rcon.py b/test/mock_rcon.py new file mode 100644 index 0000000..c5f7c35 --- /dev/null +++ b/test/mock_rcon.py @@ -0,0 +1,31 @@ +# Reference: https://docs.python.org/3/library/socketserver.html +import codecs +import socketserver +import sys + +class Handler(socketserver.BaseRequestHandler): + def handle(self): + while True: + length = int.from_bytes(self.request.recv(4), 'little') + if not length: + continue + + self.request.recv(4) + type_ = int.from_bytes(self.request.recv(4), 'little') + self.data = self.request.recv(length - 8)[:-2].decode('utf-8') + if self.data: + if type_ == 2: + print(self.data) + sys.stdout.flush() + try: + if type_ == 3 and self.data != sys.argv[2]: + self.request.sendall(codecs.decode('0a000000ffffffff020000000000', 'hex')) + else: + self.request.sendall(codecs.decode('0a00000010000000020000000000', 'hex')) + except: + break + +if __name__ == "__main__": + HOST, PORT = "localhost", int(sys.argv[1]) + with socketserver.ThreadingTCPServer((HOST, PORT), Handler) as server: + server.serve_forever() diff --git a/test/test.sh b/test/test.sh index 7e3e0c9..ae1b0ce 100755 --- a/test/test.sh +++ b/test/test.sh @@ -5,6 +5,8 @@ TEST_DIR="test" TEST_TMP="$TEST_DIR/tmp" SCREEN_TMP="tmp-screen" +RCON_PORT="8088" +RCON_PASSWORD="supersecret" setUp () { rm -rf "$TEST_TMP" mkdir -p "$TEST_TMP/server/world" @@ -13,6 +15,8 @@ setUp () { echo "file2" > "$TEST_TMP/server/world/file2.txt" echo "file3" > "$TEST_TMP/server/world/file3.txt" + python test/mock_rcon.py "$RCON_PORT" "$RCON_PASSWORD" > "$TEST_TMP/rcon-output" & + echo "$!" > "$TEST_TMP/rcon-pid" screen -dmS "$SCREEN_TMP" bash screen -S "$SCREEN_TMP" -X stuff "cat > $TEST_TMP/screen-output\n" tmux new-session -d -s "$SCREEN_TMP" @@ -21,6 +25,8 @@ setUp () { } tearDown () { + RCON_PID="$(cat "$TEST_TMP/rcon-pid")" + kill "$RCON_PID" >/dev/null 2>&1 || true screen -S "$SCREEN_TMP" -X quit >/dev/null 2>&1 || true tmux kill-session -t "$SCREEN_TMP" >/dev/null 2>&1 || true } @@ -88,7 +94,7 @@ test-missing-options () { OUTPUT="$(./backup.sh 2>&1)" EXIT_CODE="$?" assertEquals 1 "$EXIT_CODE" - assertContains "$OUTPUT" "Minecraft screen name not specified" + assertContains "$OUTPUT" "Minecraft screen/tmux/rcon location not specified (use -s)" assertContains "$OUTPUT" "Server world not specified" assertContains "$OUTPUT" "Backup directory not specified" } @@ -97,7 +103,7 @@ test-missing-options-suppress-warnings () { OUTPUT="$(./backup.sh -q 2>&1)" EXIT_CODE="$?" assertEquals 1 "$EXIT_CODE" - assertNotContains "$OUTPUT" "Minecraft screen name not specified" + assertNotContains "$OUTPUT" "Minecraft screen/tmux/rcon location not specified (use -s)" } test-empty-world-warning () { @@ -129,6 +135,20 @@ test-tmux-interface () { assertEquals "$SCREEN_CONTENTS" "$EXPECTED_CONTENTS" } +test-rcon-interface () { + TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")" + ./backup.sh -w rcon -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "localhost:$RCON_PORT:$RCON_PASSWORD" -f "$TIMESTAMP" + EXPECTED_CONTENTS=$(echo -e "save-off\nsave-on\nsave-all") + SCREEN_CONTENTS="$(head -n3 "$TEST_TMP/rcon-output")" + assertEquals "$SCREEN_CONTENTS" "$EXPECTED_CONTENTS" +} + +test-rcon-interface-wrong-password () { + TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")" + OUTPUT="$(./backup.sh -w rcon -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "localhost:$RCON_PORT:wrong$RCON_PASSWORD" -f "$TIMESTAMP" 2>&1)" + assertContains "$OUTPUT" "Wrong RCON password" +} + test-sequential-delete () { for i in $(seq 0 99); do TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01 +$i hour")"