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
- "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)

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 "-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"

188
rcon.sh
View file

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

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_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")"