Add rcon to backup script
This commit is contained in:
parent
a943f093a6
commit
22a579a19c
5 changed files with 272 additions and 101 deletions
|
@ -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
125
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"
|
||||
|
||||
|
|
34
rcon.sh
34
rcon.sh
|
@ -1,5 +1,11 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
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
|
||||
|
@ -15,6 +21,14 @@ decode-hex-int () {
|
|||
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"
|
||||
|
@ -34,20 +48,18 @@ encode () {
|
|||
OUTPUT+=$(encode-int "$TOTAL_LENGTH")
|
||||
OUTPUT+=$(encode-int "$REQUEST_ID")
|
||||
OUTPUT+=$(encode-int "$TYPE")
|
||||
OUTPUT+=$(echo -n "$PAYLOAD" | xxd -ps)
|
||||
OUTPUT+=$(echo -n "$PAYLOAD" | stream-to-hex)
|
||||
OUTPUT+="0000"
|
||||
|
||||
echo -n "$OUTPUT" | xxd -ps -r
|
||||
echo -n "$OUTPUT" | hex-to-stream
|
||||
}
|
||||
|
||||
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)
|
||||
HEX_LENGTH=$(head -c4 <&3 | stream-to-hex | reverse-hex-endian)
|
||||
LENGTH=$((16#$HEX_LENGTH))
|
||||
|
||||
RESPONSE_PAYLOAD=$(head -c $LENGTH <&3 | xxd -ps)
|
||||
RESPONSE_PAYLOAD=$(head -c $LENGTH <&3 | stream-to-hex)
|
||||
echo -n "$RESPONSE_PAYLOAD"
|
||||
}
|
||||
|
||||
|
@ -60,7 +72,7 @@ response-type () {
|
|||
}
|
||||
|
||||
response-payload () {
|
||||
echo -n "${1:16:-4}" | xxd -r -ps
|
||||
echo -n "${1:16:-4}" | hex-to-stream
|
||||
}
|
||||
|
||||
login () {
|
||||
|
@ -86,12 +98,6 @@ run-command () {
|
|||
response-payload "$RESPONSE"
|
||||
}
|
||||
|
||||
rcon-command () {
|
||||
HOST="$1"
|
||||
PORT="$2"
|
||||
PASSWORD="$3"
|
||||
COMMAND="$4"
|
||||
|
||||
# Open a TCP socket
|
||||
# Source: https://www.xmodulo.com/tcp-udp-socket-bash-shell.html
|
||||
exec 3<>/dev/tcp/"$HOST"/"$PORT"
|
||||
|
@ -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
31
test/mock_rcon.py
Normal 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()
|
24
test/test.sh
24
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")"
|
||||
|
|
Loading…
Add table
Reference in a new issue