minecraft-backup/backup.sh

403 lines
12 KiB
Bash
Raw Normal View History

2021-03-03 17:50:04 -08:00
#!/usr/bin/env bash
2016-09-06 23:34:12 -07:00
# Minecraft server automatic backup management script
2021-03-03 17:50:04 -08:00
# https://github.com/nicolaschan/minecraft-backup
2016-09-06 23:34:12 -07:00
# MIT License
2017-06-30 14:29:44 -07:00
#
2016-09-06 23:34:12 -07:00
# For Minecraft servers running in a GNU screen.
# For most convenience, run automatically with cron.
2017-08-10 22:18:04 -07:00
# Default Configuration
SCREEN_NAME="" # Name of the GNU Screen or tmux pane your Minecraft server is running in
2017-08-10 22:18:04 -07:00
SERVER_WORLD="" # Server world directory
BACKUP_DIRECTORY="" # Directory to save backups in
MAX_BACKUPS=128 # -1 indicates unlimited
2016-09-06 23:34:12 -07:00
DELETE_METHOD="thin" # Choices: thin, sequential, none; sequential: delete oldest; thin: keep last 24 hourly, last 30 daily, and monthly (use with 1 hr cron interval)
2017-08-10 22:18:04 -07:00
COMPRESSION_ALGORITHM="gzip" # Leave empty for no compression
COMPRESSION_FILE_EXTENSION=".gz" # Leave empty for no compression; Precede with a . (for example: ".gz")
2016-09-06 23:34:12 -07:00
COMPRESSION_LEVEL=3 # Passed to the compression algorithm
2017-08-10 22:18:04 -07:00
ENABLE_CHAT_MESSAGES=false # Tell players in Minecraft chat about backup status
2016-09-06 23:34:12 -07:00
PREFIX="Backup" # Shows in the chat message
2017-08-10 22:18:04 -07:00
DEBUG=false # Enable debug messages
SUPPRESS_WARNINGS=false # Suppress warnings
WINDOW_MANAGER="screen" # Choices: screen, tmux
2016-09-06 23:34:12 -07:00
# Other Variables (do not modify)
DATE_FORMAT="%F_%H-%M-%S"
TIMESTAMP=$(date +$DATE_FORMAT)
2017-08-10 22:18:04 -07:00
while getopts 'a:cd:e:f:hi:l:m:o:p:qs:vw:' FLAG; do
2017-08-10 22:18:04 -07:00
case $FLAG in
a) COMPRESSION_ALGORITHM=$OPTARG ;;
c) ENABLE_CHAT_MESSAGES=true ;;
d) DELETE_METHOD=$OPTARG ;;
e) COMPRESSION_FILE_EXTENSION=".$OPTARG" ;;
f) TIMESTAMP=$OPTARG ;;
2021-03-03 17:50:04 -08:00
h) echo "Minecraft Backup"
echo "Repository: https://github.com/nicolaschan/minecraft-backup"
2017-08-10 22:18:04 -07:00
echo "-a Compression algorithm (default: gzip)"
echo "-c Enable chat messages"
echo "-d Delete method: thin (default), sequential, none"
echo "-e Compression file extension, exclude leading \".\" (default: gz)"
echo "-f Output file name (default is the timestamp)"
echo "-h Shows this help text"
echo "-i Input directory (path to world folder)"
echo "-l Compression level (default: 3)"
echo "-m Maximum backups to keep, use -1 for unlimited (default: 128)"
echo "-o Output directory"
echo "-p Prefix that shows in Minecraft chat (default: Backup)"
echo "-q Suppress warnings"
2021-03-03 22:14:57 -08:00
echo "-s Screen name, tmux session name, or hostname:port:password for RCON"
2017-08-10 22:18:04 -07:00
echo "-v Verbose mode"
2021-03-03 22:14:57 -08:00
echo "-w Window manager: screen (default), tmux, RCON"
2017-08-10 22:18:04 -07:00
exit 0
;;
i) SERVER_WORLD=$OPTARG ;;
l) COMPRESSION_LEVEL=$OPTARG ;;
m) MAX_BACKUPS=$OPTARG ;;
o) BACKUP_DIRECTORY=$OPTARG ;;
p) PREFIX=$OPTARG ;;
q) SUPPRESS_WARNINGS=true ;;
s) SCREEN_NAME=$OPTARG ;;
v) DEBUG=true ;;
w) WINDOW_MANAGER=$OPTARG ;;
2017-08-10 22:18:04 -07:00
esac
done
log-fatal () {
echo -e "\033[0;31mFATAL:\033[0m $*"
}
log-warning () {
echo -e "\033[0;33mWARNING:\033[0m $*"
}
2021-03-03 22:12:28 -08:00
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>&-
}
2021-03-03 17:50:04 -08:00
if [[ $COMPRESSION_FILE_EXTENSION == "." ]]; then
COMPRESSION_FILE_EXTENSION=""
fi
2017-08-10 22:18:04 -07:00
# Check for missing encouraged arguments
if ! $SUPPRESS_WARNINGS; then
if [[ $SCREEN_NAME == "" ]]; then
2021-03-03 22:12:28 -08:00
log-warning "Minecraft screen/tmux/rcon location not specified (use -s)"
2017-08-10 22:18:04 -07:00
fi
fi
# Check for required arguments
MISSING_CONFIGURATION=false
if [[ $SERVER_WORLD == "" ]]; then
log-fatal "Server world not specified (use -i)"
MISSING_CONFIGURATION=true
fi
if [[ $BACKUP_DIRECTORY == "" ]]; then
log-fatal "Backup directory not specified (use -o)"
MISSING_CONFIGURATION=true
fi
if $MISSING_CONFIGURATION; then
2021-03-03 17:50:04 -08:00
exit 1
2017-08-10 22:18:04 -07:00
fi
2016-09-06 23:34:12 -07:00
ARCHIVE_FILE_NAME=$TIMESTAMP.tar$COMPRESSION_FILE_EXTENSION
ARCHIVE_PATH=$BACKUP_DIRECTORY/$ARCHIVE_FILE_NAME
# Minecraft server screen interface functions
message-players () {
local MESSAGE=$1
local HOVER_MESSAGE=$2
2017-06-30 14:29:44 -07:00
message-players-color "$MESSAGE" "$HOVER_MESSAGE" "gray"
2016-09-06 23:34:12 -07:00
}
execute-command () {
local COMMAND=$1
2017-08-10 22:18:04 -07:00
if [[ $SCREEN_NAME != "" ]]; then
case $WINDOW_MANAGER in
2021-03-03 22:12:28 -08:00
"screen") screen -S "$SCREEN_NAME" -p 0 -X stuff "$COMMAND$(printf \\r)"
;;
"tmux") tmux send-keys -t "$SCREEN_NAME" "$COMMAND" ENTER
;;
2021-03-03 22:12:28 -08:00
"rcon") rcon-command "$SCREEN_NAME" "$COMMAND"
;;
esac
2017-08-10 22:18:04 -07:00
fi
2016-09-06 23:34:12 -07:00
}
2017-06-30 14:29:44 -07:00
message-players-error () {
local MESSAGE=$1
local HOVER_MESSAGE=$2
message-players-color "$MESSAGE" "$HOVER_MESSAGE" "red"
}
message-players-success () {
local MESSAGE=$1
local HOVER_MESSAGE=$2
message-players-color "$MESSAGE" "$HOVER_MESSAGE" "green"
}
message-players-color () {
local MESSAGE=$1
local HOVER_MESSAGE=$2
local COLOR=$3
2017-08-10 22:18:04 -07:00
if $DEBUG; then
echo "$MESSAGE ($HOVER_MESSAGE)"
fi
2017-06-30 14:29:44 -07:00
if $ENABLE_CHAT_MESSAGES; then
2017-06-30 21:18:34 -07:00
execute-command "tellraw @a [\"\",{\"text\":\"[$PREFIX] \",\"color\":\"gray\",\"italic\":true},{\"text\":\"$MESSAGE\",\"color\":\"$COLOR\",\"italic\":true,\"hoverEvent\":{\"action\":\"show_text\",\"value\":{\"text\":\"\",\"extra\":[{\"text\":\"$HOVER_MESSAGE\"}]}}}]"
2017-06-30 14:29:44 -07:00
fi
}
2016-09-06 23:34:12 -07:00
# Notify players of start
message-players "Starting backup..." "$ARCHIVE_FILE_NAME"
2017-08-10 22:18:04 -07:00
# Parse file timestamp to one readable by "date"
parse-file-timestamp () {
local DATE_STRING=$(echo $1 | awk -F_ '{gsub(/-/,":",$2); print $1" "$2}')
echo $DATE_STRING
}
# Delete a backup
delete-backup () {
local BACKUP=$1
rm $BACKUP_DIRECTORY/$BACKUP
message-players "Deleted old backup" "$BACKUP"
}
2016-09-06 23:34:12 -07:00
# Sequential delete method
delete-sequentially () {
local BACKUPS=($(ls $BACKUP_DIRECTORY))
2017-08-10 22:18:04 -07:00
while [[ $MAX_BACKUPS -ge 0 && ${#BACKUPS[@]} -gt $MAX_BACKUPS ]]; do
delete-backup ${BACKUPS[0]}
2016-09-06 23:34:12 -07:00
BACKUPS=($(ls $BACKUP_DIRECTORY))
done
}
2017-08-10 22:18:04 -07:00
# Functions to sort backups into correct categories based on timestamps
is-hourly-backup () {
local TIMESTAMP=$*
local MINUTE=$(date -d "$TIMESTAMP" +%M)
return $MINUTE
}
is-daily-backup () {
local TIMESTAMP=$*
local HOUR=$(date -d "$TIMESTAMP" +%H)
return $HOUR
}
is-weekly-backup () {
local TIMESTAMP=$*
local DAY=$(date -d "$TIMESTAMP" +%u)
return $((DAY - 1))
}
# Helper function to sum an array
array-sum () {
SUM=0
for NUMBER in $*; do
(( SUM += NUMBER ))
done
echo $SUM
}
2016-09-06 23:34:12 -07:00
# Thinning delete method
delete-thinning () {
2017-08-10 22:18:04 -07:00
# sub-hourly, hourly, daily, weekly is everything else
local BLOCK_SIZES=(16 24 30)
# First block is unconditional
# The next blocks will only accept files whose names cause these functions to return true (0)
local BLOCK_FUNCTIONS=("is-hourly-backup" "is-daily-backup" "is-weekly-backup")
2017-06-30 14:29:44 -07:00
2017-08-10 22:18:04 -07:00
# Warn if $MAX_BACKUPS does not have enough room for all the blocks
TOTAL_BLOCK_SIZE=$(array-sum ${BLOCK_SIZES[@]})
if [[ $MAX_BACKUPS != -1 ]] && [[ $TOTAL_BLOCK_SIZE -gt $MAX_BACKUPS ]]; then
2017-08-10 22:18:04 -07:00
if ! $SUPPRESS_WARNINGS; then
log-warning "MAX_BACKUPS ($MAX_BACKUPS) is smaller than TOTAL_BLOCK_SIZE ($TOTAL_BLOCK_SIZE)"
fi
2016-09-06 23:34:12 -07:00
fi
2017-08-10 22:18:04 -07:00
local CURRENT_INDEX=0
local BACKUPS=($(ls -r $BACKUP_DIRECTORY)) # List newest first
for BLOCK_INDEX in ${!BLOCK_SIZES[@]}; do
local BLOCK_SIZE=${BLOCK_SIZES[BLOCK_INDEX]}
local BLOCK_FUNCTION=${BLOCK_FUNCTIONS[BLOCK_INDEX]}
local OLDEST_BACKUP_IN_BLOCK_INDEX=$((BLOCK_SIZE + CURRENT_INDEX)) # Not an off-by-one error because a new backup was already saved
local OLDEST_BACKUP_IN_BLOCK=${BACKUPS[OLDEST_BACKUP_IN_BLOCK_INDEX]}
if [[ $OLDEST_BACKUP_IN_BLOCK == "" ]]; then
break
2016-09-06 23:34:12 -07:00
fi
2017-08-10 22:18:04 -07:00
local OLDEST_BACKUP_TIMESTAMP=$(parse-file-timestamp ${OLDEST_BACKUP_IN_BLOCK:0:19})
local BLOCK_COMMAND="$BLOCK_FUNCTION $OLDEST_BACKUP_TIMESTAMP"
if $BLOCK_COMMAND; then
# Oldest backup in this block satisfies the condition for placement in the next block
if $DEBUG; then
echo "$OLDEST_BACKUP_IN_BLOCK promoted to next block"
fi
2016-09-06 23:34:12 -07:00
else
2017-08-10 22:18:04 -07:00
# Oldest backup in this block does not satisfy the condition for placement in next block
delete-backup $OLDEST_BACKUP_IN_BLOCK
break
2016-09-06 23:34:12 -07:00
fi
2017-08-10 22:18:04 -07:00
((CURRENT_INDEX += BLOCK_SIZE))
done
delete-sequentially
2016-09-06 23:34:12 -07:00
}
2021-03-03 22:12:28 -08:00
# Ensure directory exists
mkdir -p "$(dirname $ARCHIVE_PATH)"
2016-09-06 23:34:12 -07:00
# Disable world autosaving
execute-command "save-off"
# Backup world
START_TIME=$(date +"%s")
case $COMPRESSION_ALGORITHM in
2021-03-03 17:50:04 -08:00
# No compression
"") tar -cf $ARCHIVE_PATH -C $SERVER_WORLD .
2016-09-06 23:34:12 -07:00
;;
2021-03-03 17:50:04 -08:00
# With compression
*) tar -cf - -C $SERVER_WORLD . | $COMPRESSION_ALGORITHM -cv -$COMPRESSION_LEVEL - > $ARCHIVE_PATH 2>> /dev/null
2016-09-06 23:34:12 -07:00
;;
esac
2017-08-10 22:18:04 -07:00
sync
2016-09-06 23:34:12 -07:00
END_TIME=$(date +"%s")
# Enable world autosaving
execute-command "save-on"
# Save the world
execute-command "save-all"
2017-06-30 14:29:44 -07:00
# Delete old backups
delete-old-backups () {
case $DELETE_METHOD in
"sequential") delete-sequentially
;;
"thin") delete-thinning
;;
esac
}
2016-09-06 23:34:12 -07:00
# Notify players of completion
2017-08-10 22:18:04 -07:00
WORLD_SIZE_BYTES=$(du -b --max-depth=0 $SERVER_WORLD | awk '{print $1}')
ARCHIVE_SIZE_BYTES=$(du -b $ARCHIVE_PATH | awk '{print $1}')
2016-09-06 23:34:12 -07:00
ARCHIVE_SIZE=$(du -h $ARCHIVE_PATH | awk '{print $1}')
BACKUP_DIRECTORY_SIZE=$(du -h --max-depth=0 $BACKUP_DIRECTORY | awk '{print $1}')
TIME_DELTA=$((END_TIME - START_TIME))
2021-03-03 17:50:04 -08:00
# Check that archive size is not null and at least 200 Bytes
if [[ "$WORLD_SIZE_BYTES" -gt 0 && "$ARCHIVE_SIZE" != "" && "$ARCHIVE_SIZE_BYTES" -gt 200 ]]; then
COMPRESSION_PERCENT=$(($ARCHIVE_SIZE_BYTES * 100 / $WORLD_SIZE_BYTES))
2017-06-30 14:29:44 -07:00
message-players-success "Backup complete!" "$TIME_DELTA s, $ARCHIVE_SIZE/$BACKUP_DIRECTORY_SIZE, $COMPRESSION_PERCENT%"
delete-old-backups
else
message-players-error "Backup was not saved!" "Please notify an administrator"
fi