Add rcon to backup script

This commit is contained in:
Nicolas Chan 2021-03-03 22:12:28 -08:00
parent a943f093a6
commit 22a579a19c
5 changed files with 272 additions and 101 deletions

View file

@ -10,7 +10,6 @@ Supports servers running in [screen](https://en.wikipedia.org/wiki/GNU_Screen),
- Manage deletion of old backups - Manage deletion of old backups
- "thin" - keep last 24 hourly, last 30 daily, and use remaining space for weekly backups - "thin" - keep last 24 hourly, last 30 daily, and use remaining space for weekly backups
- "sequential" - delete oldest backup - "sequential" - delete oldest backup
- Choose your own compression algorithm (tested with: `gzip`, `xz`, `zstd`)
- Works on vanilla (no plugins required) - Works on vanilla (no plugins required)
- Print backup status to the Minecraft chat - 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. 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? ## 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 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 ## Help
- Make sure the compression algorithm you specify is installed on your system. (zstd is not installed by default) - Make sure the compression algorithm you specify is installed on your system. (zstd is not installed by default)

125
backup.sh
View file

@ -47,9 +47,9 @@ while getopts 'a:cd:e:f:hi:l:m:o:p:qs:vw:' FLAG; do
echo "-o Output directory" echo "-o Output directory"
echo "-p Prefix that shows in Minecraft chat (default: Backup)" echo "-p Prefix that shows in Minecraft chat (default: Backup)"
echo "-q Suppress warnings" 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 "-v Verbose mode"
echo "-w Window manager: screen (default) or tmux" echo "-w Window manager: screen (default), tmux, rcon"
exit 0 exit 0
;; ;;
i) SERVER_WORLD=$OPTARG ;; i) SERVER_WORLD=$OPTARG ;;
@ -71,6 +71,116 @@ log-warning () {
echo -e "\033[0;33mWARNING:\033[0m $*" 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 if [[ $COMPRESSION_FILE_EXTENSION == "." ]]; then
COMPRESSION_FILE_EXTENSION="" COMPRESSION_FILE_EXTENSION=""
fi fi
@ -78,7 +188,7 @@ fi
# Check for missing encouraged arguments # Check for missing encouraged arguments
if ! $SUPPRESS_WARNINGS; then if ! $SUPPRESS_WARNINGS; then
if [[ $SCREEN_NAME == "" ]]; 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
fi fi
# Check for required arguments # Check for required arguments
@ -109,9 +219,11 @@ execute-command () {
local COMMAND=$1 local COMMAND=$1
if [[ $SCREEN_NAME != "" ]]; then if [[ $SCREEN_NAME != "" ]]; then
case $WINDOW_MANAGER in 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 esac
fi fi
@ -238,6 +350,9 @@ delete-thinning () {
delete-sequentially delete-sequentially
} }
# Ensure directory exists
mkdir -p "$(dirname $ARCHIVE_PATH)"
# Disable world autosaving # Disable world autosaving
execute-command "save-off" execute-command "save-off"

188
rcon.sh
View file

@ -1,96 +1,102 @@
#!/usr/bin/env bash #!/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 () { rcon-command () {
HOST="$1" HOST="$(echo $1 | cut -d: -f1)"
PORT="$2" PORT="$(echo $1 | cut -d: -f2)"
PASSWORD="$3" PASSWORD="$(echo $1 | cut -d: -f3-)"
COMMAND="$4" 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 # Open a TCP socket
# Source: https://www.xmodulo.com/tcp-udp-socket-bash-shell.html # Source: https://www.xmodulo.com/tcp-udp-socket-bash-shell.html
@ -110,4 +116,4 @@ PORT="$2"
PASSWORD="$3" PASSWORD="$3"
COMMAND="$4" COMMAND="$4"
rcon-command "$HOST" "$PORT" "$PASSWORD" "$COMMAND" rcon-command "$HOST:$PORT:$PASSWORD" "$COMMAND"

31
test/mock_rcon.py Normal file
View file

@ -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()

View file

@ -5,6 +5,8 @@
TEST_DIR="test" TEST_DIR="test"
TEST_TMP="$TEST_DIR/tmp" TEST_TMP="$TEST_DIR/tmp"
SCREEN_TMP="tmp-screen" SCREEN_TMP="tmp-screen"
RCON_PORT="8088"
RCON_PASSWORD="supersecret"
setUp () { setUp () {
rm -rf "$TEST_TMP" rm -rf "$TEST_TMP"
mkdir -p "$TEST_TMP/server/world" mkdir -p "$TEST_TMP/server/world"
@ -13,6 +15,8 @@ setUp () {
echo "file2" > "$TEST_TMP/server/world/file2.txt" echo "file2" > "$TEST_TMP/server/world/file2.txt"
echo "file3" > "$TEST_TMP/server/world/file3.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 -dmS "$SCREEN_TMP" bash
screen -S "$SCREEN_TMP" -X stuff "cat > $TEST_TMP/screen-output\n" screen -S "$SCREEN_TMP" -X stuff "cat > $TEST_TMP/screen-output\n"
tmux new-session -d -s "$SCREEN_TMP" tmux new-session -d -s "$SCREEN_TMP"
@ -21,6 +25,8 @@ setUp () {
} }
tearDown () { 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 screen -S "$SCREEN_TMP" -X quit >/dev/null 2>&1 || true
tmux kill-session -t "$SCREEN_TMP" >/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)" OUTPUT="$(./backup.sh 2>&1)"
EXIT_CODE="$?" EXIT_CODE="$?"
assertEquals 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" "Server world not specified"
assertContains "$OUTPUT" "Backup directory not specified" assertContains "$OUTPUT" "Backup directory not specified"
} }
@ -97,7 +103,7 @@ test-missing-options-suppress-warnings () {
OUTPUT="$(./backup.sh -q 2>&1)" OUTPUT="$(./backup.sh -q 2>&1)"
EXIT_CODE="$?" EXIT_CODE="$?"
assertEquals 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 () { test-empty-world-warning () {
@ -129,6 +135,20 @@ test-tmux-interface () {
assertEquals "$SCREEN_CONTENTS" "$EXPECTED_CONTENTS" 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 () { test-sequential-delete () {
for i in $(seq 0 99); do for i in $(seq 0 99); do
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01 +$i hour")" TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01 +$i hour")"