Compare commits

...
Sign in to create a new pull request.

115 commits
dev ... master

Author SHA1 Message Date
9e6ee0ac63
change entrypoint to sh
Some checks failed
CI / test (push) Has been cancelled
CI / coverage (push) Has been cancelled
CI / shellcheck (push) Has been cancelled
CI / release (push) Has been cancelled
CI / docker (push) Has been cancelled
2025-03-01 20:20:20 +01:00
Nicolas Chan
8ba2a8c14e Update nix flake 2024-09-11 01:23:50 -07:00
Nicolas Chan
4a4c09e293
Merge pull request #27 from quulah/patch-1
Add rclone to container image
2024-09-11 01:16:41 -07:00
Nicolas Chan
2428e9e940
Merge pull request #30 from rainbowdashlabs/feature/docker-minecraft-support
Feature/docker minecraft support
2024-08-11 01:50:04 -07:00
Nicolas Chan
eea736a762 Add test for docker-rcon 2024-08-11 01:37:26 -07:00
Nicolas Chan
e8254d402c Merge branch 'master' of github.com:nicolaschan/minecraft-backup into feature/docker-minecraft-support 2024-08-11 01:22:37 -07:00
Nicolas Chan
dfb1a1c27d
Merge pull request #32 from nicolaschan/flake
Add flake.nix and update tests
2024-08-11 01:01:10 -07:00
Nicolas Chan
7d5e6533d4 Revert change to test 2024-08-11 00:52:26 -07:00
Nicolas Chan
09acfeaf68 Address shellcheck warning 2024-08-11 00:43:00 -07:00
Nicolas Chan
d6a521950f Add flake.nix and update tests 2024-08-11 00:41:20 -07:00
Lilly Tempest
a7318b0de5
Update backup.sh 2023-02-05 11:37:49 +01:00
Lilly Tempest
c3f57f43df
Update README.md 2023-02-05 11:35:44 +01:00
Lilly Tempest
3b055013dd
Update backup.sh 2023-02-05 11:32:08 +01:00
Miika Kankare
12f1fd4b8d
Add rclone to container image 2022-02-12 15:03:23 +02:00
Nicolas Chan
8a04318f93
Update ci.yml 2021-07-29 23:58:25 -07:00
Nicolas Chan
44b3b4851f
Update ci.yml 2021-07-29 23:58:08 -07:00
Nicolas Chan
13dc39729f
Update ci.yml 2021-07-29 23:57:41 -07:00
Nicolas Chan
8350b3bf70 Add option for explicit restic hostname 2021-06-14 23:12:19 -07:00
Nicolas Chan
e5eb486a93 Update chat messages for restic 2021-06-14 22:05:11 -07:00
Nicolas Chan
3db395932e Add ssh to Docker 2021-06-09 23:47:05 -07:00
Nicolas Chan
05cc162bd3 Fix flock version in Docker 2021-06-09 23:34:09 -07:00
Nicolas Chan
0f4eb40a6e Check tests with shellcheck 2021-06-04 00:26:37 -07:00
Nicolas Chan
5dc9a9829d
Delete release.yml 2021-06-04 00:17:30 -07:00
Nicolas Chan
33587c5e92 Update CI to include release 2021-06-04 00:07:55 -07:00
Nicolas Chan
7867da3707 Update CI stages 2021-06-03 23:50:57 -07:00
Nicolas Chan
4a0ba3cfc5
Update README.md 2021-06-03 23:36:57 -07:00
Nicolas Chan
745c8e1baa Fix tag name 2021-06-03 23:17:31 -07:00
Nicolas Chan
aa8ae6277a Add release.yml 2021-06-03 22:27:51 -07:00
Nicolas Chan
c90fad4b31
Update docker.yml 2021-06-03 21:51:01 -07:00
Nicolas Chan
3da8900743
Update docker.yml 2021-06-03 21:48:59 -07:00
Nicolas Chan
f5a2c4ac50
Update docker.yml 2021-06-03 21:46:43 -07:00
Nicolas Chan
7a2e25ef5a
Update Dockerfile 2021-06-03 21:31:43 -07:00
Nicolas Chan
7824c8c694
Merge pull request #22 from tleibert/spigot-paper-compatibility
spigot/paper worldnaming compatibility
2021-06-03 21:00:42 -07:00
Nicolas Chan
299e67722d Add multiple world info to help text 2021-06-02 20:51:24 -07:00
Nicolas Chan
17a5eb51d9 More general worlds option and pass tests 2021-06-02 20:31:44 -07:00
Nicolas Chan
ab6ef93f3f Merge branch 'master' of github.com:nicolaschan/minecraft-backup into spigot-paper-compatibility 2021-06-02 16:55:06 -07:00
Nicolas Chan
673303ebda Only run docker on push to master 2021-06-02 16:33:55 -07:00
tleibert
c81098e6d6 unquote list 2021-06-02 22:58:28 +00:00
tleibert
c899f95476 use the three world folders in thae tar args 2021-06-02 22:50:35 +00:00
tleibert
51001efd56 add new world vars 2021-06-02 22:39:45 +00:00
tleibert
681901ec77 fix arg and fix path name construction 2021-06-02 22:39:45 +00:00
tleibert
3e68146cf7 add to list of allowed commands 2021-06-02 22:19:26 +00:00
tleibert
476ce5800f inital commit for adding spigot-paper worldnaming compatibility 2021-06-02 22:09:17 +00:00
Nicolas Chan
34503cfbd8 Fix shellcheck globbing warning 2021-05-03 22:11:19 -07:00
Nicolas Chan
ca2e82815a Add lockfile support 2021-05-03 21:20:03 -07:00
Nicolas Chan
ac2bf6ccb6 Rename workflow 2021-05-03 15:46:55 -07:00
Nicolas Chan
ee1c16660d
Merge pull request #21 from nicolaschan/restic
Add restic support
2021-05-03 15:37:58 -07:00
Nicolas Chan
e05eab3a92 Mark restic executable 2021-05-03 15:23:13 -07:00
Nicolas Chan
af04534ac5 Update restic version 2021-05-03 15:18:32 -07:00
Nicolas Chan
ac509153ea No docker image on pull requests 2021-05-03 15:04:30 -07:00
Nicolas Chan
81f84d8aa4 Update README 2021-05-03 14:41:44 -07:00
Nicolas Chan
5fca26542d Check for snapshot instead of warning message 2021-05-03 14:13:08 -07:00
Nicolas Chan
2c86dae9ae Handle chmod when file does not exist 2021-05-03 13:32:44 -07:00
Nicolas Chan
8a50adce5c Add restic to CI 2021-05-03 13:31:02 -07:00
Nicolas Chan
90c382b312 Add restic support 2021-05-03 13:26:54 -07:00
Nicolas Chan
7edc6ec8c7 Add xxd to Dockerfile 2021-03-20 02:39:58 -07:00
Nicolas Chan
6c1ea21ad2 Push latest 2021-03-20 02:28:55 -07:00
Nicolas Chan
07130df35d Add latest tag 2021-03-20 02:07:57 -07:00
Nicolas Chan
505589e9e9 Update actions 2021-03-20 02:04:24 -07:00
Nicolas Chan
eb410e1e0f
Create docker-image.yml 2021-03-20 01:54:43 -07:00
Nicolas Chan
eb3ced8fbe Fix bug with sequential deletion block order 2021-03-18 13:39:08 -07:00
Nicolas Chan
930e0b0b3b Compare by string instead of number 2021-03-17 18:17:43 -07:00
Nicolas Chan
56096d05ca Add more test waits 2021-03-09 17:58:56 -08:00
Nicolas Chan
ddf1e177db Add no such world test 2021-03-09 17:57:45 -08:00
Nicolas Chan
e6ca59cd5f Add test delay 2021-03-09 17:45:45 -08:00
Nicolas Chan
12a1b7b84d Better handle tar error 2021-03-09 17:41:20 -08:00
Nicolas Chan
ccaf734df2 Do not delete failed backups because it might still contain valuable info 2021-03-06 15:14:16 -08:00
Nicolas Chan
1d24a58899 Change CI badge 2021-03-06 14:59:30 -08:00
Nicolas Chan
9c34bd1fb6 Delete corrupt backups 2021-03-06 14:55:47 -08:00
Nicolas Chan
1a19740b2e Decrease test wait time 2021-03-04 14:32:54 -08:00
Nicolas Chan
24b9f6e85a Increase test wait time 2021-03-04 14:29:05 -08:00
Nicolas Chan
52e3538c2d Fix exit code check 2021-03-04 14:25:42 -08:00
Nicolas Chan
f7829e5500 Better error handling 2021-03-04 14:11:11 -08:00
Nicolas Chan
d4d75a63e5 Add test delay 2021-03-04 13:30:40 -08:00
Nicolas Chan
e058250a33
Merge pull request #15 from nicolaschan/rcon
Add RCON support
2021-03-04 13:25:46 -08:00
Nicolas Chan
84df6e7561 Mention bug 2021-03-04 13:24:08 -08:00
Nicolas Chan
858ae2357c Add dependency info to README 2021-03-04 13:18:31 -08:00
Nicolas Chan
468ef0bd49 Add spaces backup test 2021-03-04 13:14:53 -08:00
Nicolas Chan
c079e0f1f5 Touch up readme 2021-03-04 13:05:51 -08:00
Nicolas Chan
38251cf010 Update examples 2021-03-04 13:03:40 -08:00
Nicolas Chan
ade562aeeb Fix shellcheck 2021-03-04 11:50:10 -08:00
Nicolas Chan
8756a0e614 Check tar status 2021-03-04 11:37:38 -08:00
Nicolas Chan
b1e744d1db Add alert for when backup has nonzero exit code 2021-03-04 11:23:20 -08:00
Nicolas Chan
6495870e1a Improve debug logging 2021-03-03 23:19:55 -08:00
Nicolas Chan
1402cfd86a Update comments 2021-03-03 23:10:18 -08:00
Nicolas Chan
57fe77cb59 Rename job to test 2021-03-03 23:01:58 -08:00
Nicolas Chan
0340f64b23 Add shellcheck check 2021-03-03 23:01:39 -08:00
Nicolas Chan
80abd684ba Pass shellcheck 2021-03-03 22:59:31 -08:00
Nicolas Chan
71311e2321 Reorder setUp 2021-03-03 22:23:12 -08:00
Nicolas Chan
ad1dc772b6 Allow capital RCON 2021-03-03 22:19:00 -08:00
Nicolas Chan
a2ff9b0b0b Update help 2021-03-03 22:14:57 -08:00
Nicolas Chan
22a579a19c Add rcon to backup script 2021-03-03 22:12:28 -08:00
Nicolas Chan
a943f093a6 Update README 2021-03-03 19:52:10 -08:00
Nicolas Chan
b8094d758e Reorganize readme 2021-03-03 19:50:47 -08:00
Nicolas Chan
1c4162455f Touch up typos 2021-03-03 18:44:50 -08:00
Nicolas Chan
11aa6bfe53 Add rcon 2021-03-03 18:39:57 -08:00
Nicolas Chan
904e6c3887
Merge pull request #14 from nicolaschan/testing
Testing
2021-03-03 18:25:02 -08:00
Nicolas Chan
48503cf44f
Update ci.yml 2021-03-03 18:23:29 -08:00
Nicolas Chan
3d46b23463
Update ci.yml 2021-03-03 18:21:56 -08:00
Nicolas Chan
9e1221c450
Update ci.yml 2021-03-03 18:20:37 -08:00
Nicolas Chan
55784ad61e Enable on all push 2021-03-03 18:18:26 -08:00
Nicolas Chan
d1e7a373b7
Update ci.yml 2021-03-03 18:12:33 -08:00
Nicolas Chan
aff9a5b365
Update ci.yml 2021-03-03 18:02:32 -08:00
Nicolas Chan
2d7687390b
Update ci.yml 2021-03-03 18:01:52 -08:00
Nicolas Chan
4ac65b19c6 Add sudo for apt-get 2021-03-03 17:59:40 -08:00
Nicolas Chan
75b0f9aad8 Add ci 2021-03-03 17:57:25 -08:00
Nicolas Chan
e1aa25db96 Add unit tests 2021-03-03 17:50:04 -08:00
Nicolas Chan
c1ff08bece
Merge pull request #10 from tofran/feature/smarter-warnings
Prevent the output of illogical warnings
2020-10-04 16:08:40 -07:00
tofran
082130a008 Reverted warning suppression when -s not specified 2020-10-04 23:55:12 +01:00
tofran
cee49fe968 Prevent the output of illogical warnings 2020-10-04 20:29:33 +01:00
Nicolas Chan
af46b7f3d5
Merge pull request #8 from tofran/docker-all-the-things
Created Dockerfile
2020-09-10 17:31:59 -07:00
tofran
a695ded09d Created Dockerfile 2020-08-07 23:18:14 +01:00
Nicolas Chan
8f69f332af
Update README.md 2020-06-14 13:48:40 -07:00
Nicolas Chan
51c63bc98c
Merge pull request #6 from jaypmueller/master
Add support for using tmux instead of screen
2020-06-14 13:45:54 -07:00
Jay Mueller
9c010c8661 Add support for using tmux instead of screen
* Add new variable WINDOW_MANAGER that can be set via -w. Values are
screen (default) and tmux.
2020-06-14 15:26:50 -05:00
17 changed files with 1449 additions and 168 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

102
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,102 @@
# This is a basic workflow to help you get started with Actions
name: CI
# Controls when the action will run.
on:
push:
pull_request:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
submodules: 'recursive'
- name: Install kcov
run: sudo apt-get install -y kcov
- name: Install restic
run: |
wget https://github.com/restic/restic/releases/download/v0.12.0/restic_0.12.0_linux_amd64.bz2
bzip2 -d restic_0.12.0_linux_amd64.bz2
sudo cp restic_0.12.0_linux_amd64 /usr/local/bin/restic
sudo chmod +x /usr/local/bin/restic
- name: Run tests
run: test/test.sh
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
submodules: 'recursive'
- name: Install kcov
run: sudo apt-get install -y kcov
- name: Install restic
run: |
wget https://github.com/restic/restic/releases/download/v0.12.0/restic_0.12.0_linux_amd64.bz2
bzip2 -d restic_0.12.0_linux_amd64.bz2
sudo cp restic_0.12.0_linux_amd64 /usr/local/bin/restic
sudo chmod +x /usr/local/bin/restic
- name: Run coverage
run: kcov --include-pattern=backup.sh "$(pwd)"/coverage test/test.sh
- name: Codecov
uses: codecov/codecov-action@v1.2.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
file: ./coverage/test.sh/cov.xml
shellcheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
submodules: 'recursive'
- name: Install shellcheck
run: sudo apt-get install -y shellcheck
- name: shellcheck backup.sh
run: shellcheck backup.sh
- name: shellcheck test.sh
run: shellcheck test/test.sh
release:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
needs: [test, coverage, shellcheck]
steps:
- uses: actions/checkout@v2
with:
submodules: 'recursive'
- name: Create release
uses: ncipollo/release-action@v1.8.6
with:
artifacts: "backup.sh"
token: ${{ secrets.GITHUB_TOKEN }}
docker:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
needs: [test, coverage, shellcheck]
steps:
- uses: actions/checkout@v2
with:
submodules: 'recursive'
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
with:
platforms: all
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
with:
version: latest
- name: ghcr.io login
run: |
echo ${{ secrets.CR_PAT }} | docker login ghcr.io -u $GITHUB_ACTOR --password-stdin
- name: Run Buildx
run: |
docker buildx build \
--pull \
--push \
--platform linux/386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64 \
--tag ghcr.io/nicolaschan/minecraft-backup:${GITHUB_REF#refs/*/} \
--tag ghcr.io/nicolaschan/minecraft-backup:latest .

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
coverage
.direnv

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "test/shunit2"]
path = test/shunit2
url = https://github.com/kward/shunit2.git

10
Dockerfile Normal file
View file

@ -0,0 +1,10 @@
FROM alpine
LABEL org.opencontainers.image.source=https://forgejo.nevy.xyz/nev/minecraft-backup
RUN apk add bash coreutils xxd restic util-linux openssh rclone
WORKDIR /code
COPY ./backup.sh .
ENTRYPOINT ["/bin/sh", "-c"]

107
README.md
View file

@ -1,28 +1,55 @@
# Minecraft Backup
Backup script for Linux servers running a Minecraft server in a GNU Screen
[![CI](https://github.com/nicolaschan/minecraft-backup/actions/workflows/ci.yml/badge.svg)](https://github.com/nicolaschan/minecraft-backup/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/nicolaschan/minecraft-backup/branch/master/graph/badge.svg?token=LCbVC4TbYJ)](https://codecov.io/gh/nicolaschan/minecraft-backup)
### Disclaimer
Backups are essential to the integrity of your Minecraft world. You should automate regular backups and **check that your backups work**. While this script has been used in production for several years, it is up to you to make sure that your backups work and that you have a reliable backup policy.
Please refer to the LICENSE (MIT License) for the full legal disclaimer.
Backup script for Minecraft servers on Linux.
Supports servers running in [screen](https://en.wikipedia.org/wiki/GNU_Screen), [tmux](https://en.wikipedia.org/wiki/Tmux), or with [RCON](https://wiki.vg/RCON) enabled. Supports `tar` file or [`restic`](https://restic.net/) backup backends.
## Features
- Create backups of your world folder
- Manage deletion of old backups
- "thin" - keep last 24 hourly, last 30 daily, and use remaining space for monthly 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`)
- Able to print backup status and info to the Minecraft chat
- Works on vanilla (no plugins required)
- Print backup status to the Minecraft chat
## Requirements
- Linux computer (tested on Ubuntu)
- GNU Screen (running your Minecraft server)
- Minecraft server (tested with Vanilla 1.10.2 only)
## Install
```bash
wget https://github.com/nicolaschan/minecraft-backup/releases/latest/download/backup.sh
chmod +x backup.sh
```
## Installation
1. Download the script: `$ wget https://raw.githubusercontent.com/nicolaschan/minecraft-backup/master/backup.sh`
2. Mark as executable: `$ chmod +x backup.sh`
3. Use the command line options or configure default values at the top of `backup.sh`:
Make sure your system has `tar` and your chosen compression algorithm (`gzip` by default) installed.
If using RCON, you will also need to have the [`xxd`](https://linux.die.net/man/1/xxd) command.
## Usage
```bash
# If connecting with RCON:
./backup.sh -c -i /home/user/server/world -o /mnt/storage/backups -s localhost:25575:secret -w rcon
# If running on screen called "minecraft":
./backup.sh -c -i /home/user/server/world -o /mnt/storage/backups -s minecraft
# If running on tmux session 0:
./backup.sh -c -i /home/user/server/world -o /mnt/storage/backups -s 0 -w tmux
# Using restic (and RCON)
export RESTIC_PASSWORD="restic-pass-secret" # your password here
./backup.sh -c -i /home/user/server/world -r /mnt/storage/backups-restic -s localhost:25575:secret -w rcon
# Using Docker and RCON
# You will have to set up networking so that this Docker image can access the RCON server
# In this example, the RCON server hostname is `server-host`
docker run \
-v /home/user/server/world:/mnt/server \
-v /mnt/storage/backups:/mnt/backups \
ghcr.io/nicolaschan/minecraft-backup -c -i /mnt/server -o /mnt/backups -s server-host:25575:secret -w rcon
# Using itzg/docker-minecraft-server container and rcon cli
./backup.sh -c -i /home/user/server/world -o /mnt/storage/backups -s container-name -w docker-rcon
```
This will show chat messages (`-c`) and save a backup of `/home/user/server/world` into `/mnt/storage/backups` using the default thinning deletion policy for old backups.
Command line options:
```text
@ -32,25 +59,27 @@ Command line options:
-e Compression file extension, exclude leading "." (default: gz)
-f Output file name (default is the timestamp)
-h Shows this help text
-i Input directory (path to world folder)
-H Set hostname for restic backup (restic only)
-i Input directory (path to world folder, use -i once for each world)
-l Compression level (default: 3)
-m Maximum backups to keep, use -1 for unlimited (default: 128)
-o Output directory
-p Prefix that shows in Minecraft chat (default: Backup)
-q Suppress warnings
-s Minecraft server screen name
-r Restic repo name (if using restic)
-s Screen name, tmux session name, hostname:port:password for RCON or [container name](https://github.com/itzg/docker-minecraft-server) for docker-rcon
-t Enable lock file (lock file not used by default)
-u Lock file timeout seconds (empty = unlimited)
-v Verbose mode
-w Window manager: screen (default), tmux, RCON
```
Example usage of command line options:
```bash
./backup.sh -c -i /home/server/minecraft-server/world -o /mnt/external-storage/minecraft-backups -s minecraft
### Automate backups with cron
- Edit the crontab with `crontab -e`
- Example for hourly backups:
```
00 * * * * /path/to/backup.sh -c -i /home/user/server/world -o /mnt/storage/backups -s minecraft
```
This will use show chat messages (`-c`) in the screen called "minecraft" and save a backup of `/home/server/minecraft-server/world` into `/mnt/external-storage/minecraft-backups` using the default thinning delete policy for old backups.
4. Create a cron job to automatically backup:
- Edit the crontab: `$ crontab -e`
- Example for hourly backups: `00 * * * * /path/to/backup.sh`
## Retrieving Backups
Always test your backups! Backups are in the `tar` format and compressed depending on the option you choose. To restore, first decompress if necessary and then extract using tar. You may be able to do this in one command if `tar` supports your compression option, as is the case with `gzip`:
@ -62,13 +91,35 @@ cd restored-world
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.
The restored worlds should be inside the `restored-world` directory, possibly nested under the parent directories. Then you can move your restored world to your Minecraft server folder under the proper name and path so the Minecraft server uses it.
### With `restic`
Use [`restic restore`](https://restic.readthedocs.io/en/latest/050_restore.html) to restore from backup.
## Why not use `tar` directly?
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
```
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)
- Make sure your compression algorithm is in the crontab's PATH
- Make sure cron has permissions for all the files involved and access to the Minecraft server's GNU Screen
- It's surprising how much space backups can take--make sure you have enough empty space
- `SERVER_DIRECTORY` should be the server directory, not the `world` directory
- Do not put trailing `/` in the `SERVER_DIRECTORY` or `BACKUP_DIRECTORY`
- If "thin" delete method is behaving weirdly, try emptying your backup directory or switch to "sequential"
## Known Issues
There is a Minecraft bug [MC-217729](https://bugs.mojang.com/projects/MC/issues/MC-217729) in recent Minecraft server versions that cause them to automatically save the world even after receiving the `save-off` command. Until this is fixed, there is a chance that the backup will fail because the world files are modified by Minecraft in the process of creating the backup. This script will try to detect and report this problem if it does occur.
## Disclaimer
Backups are essential to the integrity of your Minecraft world. You should automate regular backups and **check that your backups work**. It is up to you to make sure that your backups work and that you have a reliable backup policy.
Some backup tips:
- Drives get corrupted or fail! Backup to a _different_ drive than the one your server is running on, so if your main drive fails then you have backups.
- _Automate_ backups so you never lose too much progress.
- Check that your backups work from time to time.
Please refer to the LICENSE (MIT License) for the full legal disclaimer.

537
backup.sh
View file

@ -1,15 +1,15 @@
#!/bin/bash
#!/usr/bin/env bash
# Minecraft server automatic backup management script
# by Nicolas Chan
# https://github.com/nicolaschan/minecraft-backup
# MIT License
#
# For Minecraft servers running in a GNU screen.
# For Minecraft servers running in a GNU screen, tmux, or RCON.
# For most convenience, run automatically with cron.
# Default Configuration
SCREEN_NAME="" # Name of the GNU Screen your Minecraft server is running in
SERVER_WORLD="" # Server world directory
SCREEN_NAME="" # Name of the GNU Screen, tmux session, or hostname:port:password for RCON
SERVER_WORLDS=() # Server world directory
BACKUP_DIRECTORY="" # Directory to save backups in
MAX_BACKUPS=128 # -1 indicates unlimited
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)
@ -20,76 +20,235 @@ ENABLE_CHAT_MESSAGES=false # Tell players in Minecraft chat about backup status
PREFIX="Backup" # Shows in the chat message
DEBUG=false # Enable debug messages
SUPPRESS_WARNINGS=false # Suppress warnings
RESTIC_HOSTNAME="" # Leave empty to use system hostname
LOCK_FILE="" # Optional lock file to acquire to ensure two backups don't run at once
LOCK_FILE_TIMEOUT="" # Optional lock file wait timeout (in seconds)
WINDOW_MANAGER="screen" # Choices: screen, tmux, RCON
# Other Variables (do not modify)
DATE_FORMAT="%F_%H-%M-%S"
TIMESTAMP=$(date +$DATE_FORMAT)
while getopts 'a:cd:e:f:hi:l:m:o:p:qs:v' FLAG; do
case $FLAG in
a) COMPRESSION_ALGORITHM=$OPTARG ;;
c) ENABLE_CHAT_MESSAGES=true ;;
d) DELETE_METHOD=$OPTARG ;;
e) COMPRESSION_FILE_EXTENSION=".$OPTARG" ;;
f) TIMESTAMP=$OPTARG ;;
h) echo "Minecraft Backup (by Nicolas Chan)"
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"
echo "-s Minecraft server screen name"
echo "-v Verbose mode"
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 ;;
esac
done
log-fatal () {
echo -e "\033[0;31mFATAL:\033[0m $*"
}
log-warning () {
echo -e "\033[0;33mWARNING:\033[0m $*"
}
debug-log () {
if "$DEBUG"; then
echo "$1"
fi
}
while getopts 'a:cd:e:f:hH:i:l:m:o:p:qr:s:t:u:vw:x' FLAG; do
case $FLAG in
a) COMPRESSION_ALGORITHM=$OPTARG ;;
c) ENABLE_CHAT_MESSAGES=true ;;
d) DELETE_METHOD=$OPTARG ;;
e) COMPRESSION_FILE_EXTENSION=".$OPTARG" ;;
f) TIMESTAMP=$OPTARG ;;
h) echo "Minecraft Backup"
echo "Repository: https://github.com/nicolaschan/minecraft-backup"
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 "-H Set hostname for restic backup (restic only)"
echo "-i Input directory (path to world folder, use -i once for each world)"
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"
echo "-r Restic repo name (if using restic)"
echo "-s Screen name, tmux session name, or hostname:port:password for RCON"
echo "-t Enable lock file (lock file not used by default)"
echo "-u Lock file timeout seconds (empty = unlimited)"
echo "-v Verbose mode"
echo "-w Window manager: screen (default), tmux, RCON"
exit 0
;;
H) RESTIC_HOSTNAME=$OPTARG ;;
i) SERVER_WORLDS+=("$OPTARG") ;;
l) COMPRESSION_LEVEL=$OPTARG ;;
m) MAX_BACKUPS=$OPTARG ;;
o) BACKUP_DIRECTORY=$OPTARG ;;
p) PREFIX=$OPTARG ;;
q) SUPPRESS_WARNINGS=true ;;
r) RESTIC_REPO=$OPTARG ;;
s) SCREEN_NAME=$OPTARG ;;
t) LOCK_FILE=$OPTARG ;;
u) LOCK_FILE_TIMEOUT=$OPTARG ;;
v) DEBUG=true ;;
w) WINDOW_MANAGER=$OPTARG ;;
*) log-fatal "Invalid option -$FLAG"; exit 1 ;;
esac
done
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" == "-1" ]] || [[ "$RESPONSE_REQUEST_ID" == "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
if ! exec 3<>/dev/tcp/"$HOST"/"$PORT"; then
log-warning "RCON connection failed: Could not connect to $HOST:$PORT"
return 1
fi
login "$PASSWORD" || return 1
debug-log "$(run-command "$COMMAND")"
# Close the socket
exec 3<&-
exec 3>&-
}
if ! "$DEBUG"; then
QUIET="-q"
else
QUIET=""
fi
if [[ "$COMPRESSION_FILE_EXTENSION" == "." ]]; then
COMPRESSION_FILE_EXTENSION=""
fi
# Check for missing encouraged arguments
if ! $SUPPRESS_WARNINGS; then
if [[ $SCREEN_NAME == "" ]]; then
log-warning "Minecraft screen name not specified (use -s)"
if [[ "$SCREEN_NAME" == "" ]]; then
log-warning "Minecraft screen/tmux/rcon location not specified (use -s)"
fi
fi
# Check for required arguments
MISSING_CONFIGURATION=false
if [[ $SERVER_WORLD == "" ]]; then
if [[ "${#SERVER_WORLDS[@]}" == "0" ]]; 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)"
if [[ "$BACKUP_DIRECTORY" == "" ]] && [[ "$RESTIC_REPO" == "" ]]; then
log-fatal "Backup location not specified (use -o or -r)"
MISSING_CONFIGURATION=true
fi
if [[ "$RESTIC_REPO" != "" ]]; then
if [[ "$BACKUP_DIRECTORY" != "" ]]; then
log-fatal "Both output directory (-o) and restic repo (-r) specified but only one may be used at a time"
MISSING_CONFIGURATION=true
fi
if [[ $MAX_BACKUPS -ge 0 ]] && [[ $MAX_BACKUPS -lt 70 ]] && [[ $DELETE_METHOD == "thin" ]]; then
log-fatal "Thinning delete with restic requires at least 70 snapshots to be kept. If you need to keep fewer than 70, use sequential delete."
MISSING_CONFIGURATION=true
fi
fi
if $MISSING_CONFIGURATION; then
exit 0
exit 1
fi
ARCHIVE_FILE_NAME=$TIMESTAMP.tar$COMPRESSION_FILE_EXTENSION
ARCHIVE_PATH=$BACKUP_DIRECTORY/$ARCHIVE_FILE_NAME
if [[ "$BACKUP_DIRECTORY" != "" ]]; then
ARCHIVE_FILE_NAME="$TIMESTAMP.tar$COMPRESSION_FILE_EXTENSION"
ARCHIVE_PATH="$BACKUP_DIRECTORY/$ARCHIVE_FILE_NAME"
fi
if [[ "$RESTIC_REPO" != "" ]]; then
ARCHIVE_PATH="$RESTIC_REPO $TIMESTAMP"
fi
# Minecraft server screen interface functions
message-players () {
@ -100,7 +259,16 @@ message-players () {
execute-command () {
local COMMAND=$1
if [[ $SCREEN_NAME != "" ]]; then
screen -S $SCREEN_NAME -p 0 -X stuff "$COMMAND$(printf \\r)"
case $WINDOW_MANAGER in
"screen") screen -S "$SCREEN_NAME" -p 0 -X stuff "$COMMAND$(printf \\r)"
;;
"tmux") tmux send-keys -t "$SCREEN_NAME" "$COMMAND" ENTER
;;
"RCON"|"rcon") rcon-command "$SCREEN_NAME" "$COMMAND"
;;
"docker-rcon") docker exec "$SCREEN_NAME" rcon-cli "$COMMAND"
;;
esac
fi
}
message-players-error () {
@ -117,63 +285,75 @@ message-players-color () {
local MESSAGE=$1
local HOVER_MESSAGE=$2
local COLOR=$3
if $DEBUG; then
echo "$MESSAGE ($HOVER_MESSAGE)"
fi
debug-log "$MESSAGE ($HOVER_MESSAGE)"
if $ENABLE_CHAT_MESSAGES; then
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\"}]}}}]"
fi
}
# Notify players of start
message-players "Starting backup..." "$ARCHIVE_FILE_NAME"
# 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
local DATE_STRING
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
rm "$BACKUP_DIRECTORY"/"$BACKUP"
message-players "Deleted old backup" "$BACKUP"
}
# Sequential delete method
delete-sequentially () {
local BACKUPS=($(ls $BACKUP_DIRECTORY))
local BACKUPS=("$BACKUP_DIRECTORY"/*) # List oldest first
while [[ $MAX_BACKUPS -ge 0 && ${#BACKUPS[@]} -gt $MAX_BACKUPS ]]; do
delete-backup ${BACKUPS[0]}
BACKUPS=($(ls $BACKUP_DIRECTORY))
delete-backup "$(basename "${BACKUPS[0]}")"
BACKUPS=("$BACKUP_DIRECTORY"/*)
done
}
# Functions to sort backups into correct categories based on timestamps
is-hourly-backup () {
local TIMESTAMP=$*
local MINUTE=$(date -d "$TIMESTAMP" +%M)
return $MINUTE
local MINUTE
MINUTE=$(date -d "$TIMESTAMP" +%M)
return "$MINUTE"
}
is-daily-backup () {
local TIMESTAMP=$*
local HOUR=$(date -d "$TIMESTAMP" +%H)
return $HOUR
local HOUR
HOUR=$(date -d "$TIMESTAMP" +%H)
return "$HOUR"
}
is-weekly-backup () {
local TIMESTAMP=$*
local DAY=$(date -d "$TIMESTAMP" +%u)
return $((DAY - 1))
local DAY
DAY=$(date -d "$TIMESTAMP" +%u)
return "$((DAY - 1))"
}
# Helper function to sum an array
array-sum () {
SUM=0
for NUMBER in $*; do
for NUMBER in "$@"; do
(( SUM += NUMBER ))
done
echo $SUM
echo "$SUM"
}
# Given two exit codes, print a nonzero one if there is one
exit-code () {
if [[ "$1" != "0" ]]; then
echo "$1"
else
if [[ "$2" == "" ]]; then
echo 0
else
echo "$2"
fi
fi
}
# Thinning delete method
@ -185,37 +365,37 @@ delete-thinning () {
local BLOCK_FUNCTIONS=("is-hourly-backup" "is-daily-backup" "is-weekly-backup")
# Warn if $MAX_BACKUPS does not have enough room for all the blocks
TOTAL_BLOCK_SIZE=$(array-sum ${BLOCK_SIZES[@]})
if [[ $TOTAL_BLOCK_SIZE -gt $MAX_BACKUPS ]]; then
TOTAL_BLOCK_SIZE=$(array-sum "${BLOCK_SIZES[@]}")
if [[ $MAX_BACKUPS != -1 ]] && [[ $TOTAL_BLOCK_SIZE -gt $MAX_BACKUPS ]]; then
if ! $SUPPRESS_WARNINGS; then
log-warning "MAX_BACKUPS ($MAX_BACKUPS) is smaller than TOTAL_BLOCK_SIZE ($TOTAL_BLOCK_SIZE)"
fi
fi
local CURRENT_INDEX=0
local BACKUPS=($(ls -r $BACKUP_DIRECTORY)) # List newest first
local BACKUPS=("$BACKUP_DIRECTORY"/*) # Oldest first
local NUM_BACKUPS="${#BACKUPS[@]}"
for BLOCK_INDEX in ${!BLOCK_SIZES[@]}; do
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
local OLDEST_BACKUP_IN_BLOCK_INDEX=$((NUM_BACKUPS - 1 - (BLOCK_SIZE + CURRENT_INDEX))) # Not an off-by-one error because a new backup was already saved
if [ "$OLDEST_BACKUP_IN_BLOCK_INDEX" -lt 0 ]; then
break;
fi
local OLDEST_BACKUP_IN_BLOCK
OLDEST_BACKUP_IN_BLOCK="$(basename "${BACKUPS[OLDEST_BACKUP_IN_BLOCK_INDEX]}")"
local OLDEST_BACKUP_TIMESTAMP=$(parse-file-timestamp ${OLDEST_BACKUP_IN_BLOCK:0:19})
local OLDEST_BACKUP_TIMESTAMP
OLDEST_BACKUP_TIMESTAMP=$(parse-file-timestamp "${OLDEST_BACKUP_IN_BLOCK:0:19}")
local BLOCK_COMMAND="$BLOCK_FUNCTION $OLDEST_BACKUP_TIMESTAMP"
if $BLOCK_COMMAND; then
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
debug-log "$OLDEST_BACKUP_IN_BLOCK promoted to next block"
else
# Oldest backup in this block does not satisfy the condition for placement in next block
delete-backup $OLDEST_BACKUP_IN_BLOCK
delete-backup "$OLDEST_BACKUP_IN_BLOCK"
break
fi
@ -225,50 +405,163 @@ delete-thinning () {
delete-sequentially
}
# Disable world autosaving
execute-command "save-off"
delete-restic-sequential () {
if [ "$MAX_BACKUPS" -ge 0 ]; then
restic forget -r "$RESTIC_REPO" --keep-last "$MAX_BACKUPS" "$QUIET"
fi
}
# Backup world
START_TIME=$(date +"%s")
case $COMPRESSION_ALGORITHM in
"") # No compression
tar -cf $ARCHIVE_PATH -C $SERVER_WORLD .
;;
*) # With compression
tar -cf - -C $SERVER_WORLD . | $COMPRESSION_ALGORITHM -cv -$COMPRESSION_LEVEL - > $ARCHIVE_PATH 2>> /dev/null
;;
esac
sync
END_TIME=$(date +"%s")
# Enable world autosaving
execute-command "save-on"
# Save the world
execute-command "save-all"
delete-restic-thinning () {
if [ "$MAX_BACKUPS" -ge 70 ]; then
# MAX_BACKUPS >= 70
restic forget -r "$RESTIC_REPO" --keep-last 16 --keep-hourly 24 --keep-daily 30 --keep-weekly $((MAX_BACKUPS - 70)) "$QUIET"
else
# We have a check that MAX_BACKUPS is not 70 > MAX_BACKUPS >= 0, so we can assume here it is negative
# Negative means don't delete old snapshots
restic forget -r "$RESTIC_REPO" --keep-last 16 --keep-hourly 24 --keep-daily 30 --keep-weekly 9999999 "$QUIET"
fi
}
# Delete old backups
delete-old-backups () {
case $DELETE_METHOD in
"sequential") delete-sequentially
;;
"thin") delete-thinning
;;
esac
if [[ "$BACKUP_DIRECTORY" != "" ]]; then
case $DELETE_METHOD in
"sequential") delete-sequentially
;;
"thin") delete-thinning
;;
esac
fi
if [[ "$RESTIC_REPO" != "" ]]; then
case $DELETE_METHOD in
"sequential") delete-restic-sequential
;;
"thin") delete-restic-thinning
;;
esac
fi
}
# Notify players of completion
WORLD_SIZE_BYTES=$(du -b --max-depth=0 $SERVER_WORLD | awk '{print $1}')
ARCHIVE_SIZE_BYTES=$(du -b $ARCHIVE_PATH | awk '{print $1}')
COMPRESSION_PERCENT=$(($ARCHIVE_SIZE_BYTES * 100 / $WORLD_SIZE_BYTES))
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))
clean-up () {
# Re-enable world autosaving
execute-command "save-on"
# Check that archive size is not null and at least 1024 KB
if [[ "$ARCHIVE_SIZE" != "" && "$ARCHIVE_SIZE_BYTES" -gt 8 ]]; then
message-players-success "Backup complete!" "$TIME_DELTA s, $ARCHIVE_SIZE/$BACKUP_DIRECTORY_SIZE, $COMPRESSION_PERCENT%"
delete-old-backups
# Save the world
execute-command "save-all"
TIME_DELTA=$((END_TIME - START_TIME))
if [[ "$BACKUP_DIRECTORY" != "" ]]; then
WORLD_SIZE_BYTES=$(du --bytes --total --max-depth=0 "${SERVER_WORLDS[@]}" | tail -n 1 | awk '{print $1}')
ARCHIVE_SIZE_BYTES=$(du -b "$ARCHIVE_PATH" | awk '{print $1}')
ARCHIVE_SIZE=$(du -h "$ARCHIVE_PATH" | awk '{print $1}')
BACKUP_DIRECTORY_SIZE=$(du -h --max-depth=0 "$BACKUP_DIRECTORY" | awk '{print $1}')
# Check that archive size is not null and at least 200 Bytes
if [[ "$ARCHIVE_EXIT_CODE" == "0" && "$WORLD_SIZE_BYTES" -gt 0 && "$ARCHIVE_SIZE" != "" && "$ARCHIVE_SIZE_BYTES" -gt 200 ]]; then
# Notify players of completion
COMPRESSION_PERCENT=$((ARCHIVE_SIZE_BYTES * 100 / WORLD_SIZE_BYTES))
message-players-success "Backup complete!" "$TIME_DELTA s, $ARCHIVE_SIZE/$BACKUP_DIRECTORY_SIZE, $COMPRESSION_PERCENT%"
delete-old-backups
exit 0
else
rm "$ARCHIVE_PATH" # Delete bad archive so we can't fill up with bad archives
message-players-error "Backup was not saved!" "Please notify an administrator"
exit 1
fi
fi
if [[ "$RESTIC_REPO" != "" ]]; then
if [[ "$ARCHIVE_EXIT_CODE" == "0" ]]; then
message-players-success "Backup complete!" "$TIME_DELTA s"
delete-old-backups
exit 0
else
message-players-error "Backup was not saved!" "Please notify an administrator"
exit 1
fi
fi
}
trap "clean-up" 2
do-backup () {
# Notify players of start
message-players "Starting backup..." "$ARCHIVE_PATH"
# Disable world autosaving
execute-command "save-off"
# Backup world
START_TIME=$(date +"%s")
if [[ "$BACKUP_DIRECTORY" != "" ]]; then
# Ensure backup directory exists
mkdir -p "$(dirname "$ARCHIVE_PATH")"
case $COMPRESSION_ALGORITHM in
# No compression
"") tar -cf "$ARCHIVE_PATH" "${SERVER_WORLDS[@]}"
;;
# With compression
*) tar -cf - "${SERVER_WORLDS[@]}" | $COMPRESSION_ALGORITHM -cv -"$COMPRESSION_LEVEL" - > "$ARCHIVE_PATH" 2>> /dev/null
;;
esac
EXIT_CODES=("${PIPESTATUS[@]}")
# tar exit codes: http://www.gnu.org/software/tar/manual/html_section/Synopsis.html
# 0 = successful, 1 = some files differ, 2 = fatal
if [ "${EXIT_CODES[0]}" == "1" ]; then
log-warning "Some files may differ in the backup archive (file changed as read)"
TAR_EXIT_CODE="0"
else
TAR_EXIT_CODE="${EXIT_CODES[0]}"
fi
ARCHIVE_EXIT_CODE="$(exit-code "$TAR_EXIT_CODE" "${EXIT_CODES[1]}")"
if [ "$ARCHIVE_EXIT_CODE" -ne 0 ]; then
log-fatal "Archive command exited with nonzero exit code $ARCHIVE_EXIT_CODE"
fi
fi
if [[ "$RESTIC_REPO" != "" ]]; then
RESTIC_TIMESTAMP="${TIMESTAMP:0:10} ${TIMESTAMP:11:2}:${TIMESTAMP:14:2}:${TIMESTAMP:17:2}"
if [[ "$RESTIC_HOSTNAME" == "" ]]; then
RESTIC_HOSTNAME_OPTION=()
else
RESTIC_HOSTNAME_OPTION=("--host" "$RESTIC_HOSTNAME")
fi
restic backup -r "$RESTIC_REPO" "${SERVER_WORLDS[@]}" --time "$RESTIC_TIMESTAMP" "$QUIET" "${RESTIC_HOSTNAME_OPTION[@]}"
ARCHIVE_EXIT_CODE=$?
if [ "$ARCHIVE_EXIT_CODE" -eq 3 ]; then
log-warning "Incomplete snapshot taken (some files could not be read)"
ARCHIVE_EXIT_CODE="0"
else
if [ "$ARCHIVE_EXIT_CODE" -ne 0 ]; then
# According to the restic docs, exit code is either 0, 1, or 3
# Exit code 1 means fatal
# See: https://restic.readthedocs.io/en/latest/040_backup.html
log-fatal "No restic snapshot created (exit code $ARCHIVE_EXIT_CODE)"
fi
fi
fi
sync
END_TIME=$(date +"%s")
clean-up
}
if [[ "$LOCK_FILE" != "" ]]; then
TIMEOUT_OPTION=()
if [[ "$LOCK_FILE_TIMEOUT" != "" ]]; then
TIMEOUT_OPTION=("-w" "$LOCK_FILE_TIMEOUT")
fi
(if ! flock "${TIMEOUT_OPTION[@]}" --no-fork 200; then
log-fatal "Could not acquire lock on lock file: $LOCK_FILE"
exit 1
fi
do-backup) 200>"$LOCK_FILE"
else
message-players-error "Backup was not saved!" "Please notify an administrator"
do-backup
fi

61
flake.lock generated Normal file
View file

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1725634671,
"narHash": "sha256-v3rIhsJBOMLR8e/RNWxr828tB+WywYIoajrZKFM+0Gg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "574d1eac1c200690e27b8eb4e24887f8df7ac27c",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

36
flake.nix Normal file
View file

@ -0,0 +1,36 @@
{
description = "A flake for bash, coreutils, xxd, restic, util-linux, and openssh";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = {
self,
nixpkgs,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (
system: let
pkgs = nixpkgs.legacyPackages.${system};
in {
devShell = pkgs.mkShell {
buildInputs = with pkgs; [
bash
coreutils
kcov
vim # provides xxd
python3
python312Packages.fusepy
restic
screen
shellcheck
tmux
utillinux
openssh
];
};
}
);
}

119
rcon.sh Executable file
View file

@ -0,0 +1,119 @@
#!/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
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
exec 3<>/dev/tcp/"$HOST"/"$PORT"
login "$PASSWORD" || return 1
run-command "$COMMAND"
# Close the socket
exec 3<&-
exec 3>&-
}
HOST="$1"
PORT="$2"
PASSWORD="$3"
COMMAND="$4"
rcon-command "$HOST:$PORT:$PASSWORD" "$COMMAND"

18
test.sh
View file

@ -1,18 +0,0 @@
#!/bin/bash
START_TIMESTAMP=1501484400
ITERATIONS=1000
MINUTE_INTERVAL=30
MINUTES_SINCE_START=0
if [[ $1 != "" ]]; then
ITERATIONS=$1
fi
for (( c=1; c<=$ITERATIONS; c++ )); do
TIMESTAMP=$(( START_TIMESTAMP + MINUTES_SINCE_START * 60 ))
FILE_NAME=$(date -d "@$TIMESTAMP" +%F_%H-%M-%S)
./backup.sh -q -i /home/nicolas/privatesurvival/world -o /home/nicolas/backups -f $FILE_NAME
(( MINUTES_SINCE_START += MINUTE_INTERVAL ))
done

1
test/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
tmp

View file

@ -0,0 +1,5 @@
tellraw @a ["",{"text":"[Backup] ","color":"gray","italic":true},{"text":"Starting backup...","color":"gray","italic":true,"hoverEvent":{"action":"show_text","value":{"text":"","extra":[{"text":"test/tmp/backups/2021-01-01_00-00-00.tar.gz"}]}}}]
save-off
save-on
save-all
tellraw @a ["",{"text":"[Backup] ","color":"gray","italic":true},{"text":"Backup complete!","color":"green","italic":true,"hoverEvent":{"action":"show_text","value":{"text":"","extra":[{"text":"0 s, 4.0K/4.0K, 316%"}]}}}]

View file

@ -0,0 +1,5 @@
tellraw @a ["",{"text":"[Hello] ","color":"gray","italic":true},{"text":"Starting backup...","color":"gray","italic":true,"hoverEvent":{"action":"show_text","value":{"text":"","extra":[{"text":"test/tmp/backups/2021-01-01_00-00-00.tar.gz"}]}}}]
save-off
save-on
save-all
tellraw @a ["",{"text":"[Hello] ","color":"gray","italic":true},{"text":"Hello complete!","color":"green","italic":true,"hoverEvent":{"action":"show_text","value":{"text":"","extra":[{"text":"0 s, 4.0K/4.0K, 316%"}]}}}]

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

1
test/shunit2 Submodule

@ -0,0 +1 @@
Subproject commit ebc4baa08f045b7ef0f45c4b7d6f34f08d732f3d

578
test/test.sh Executable file
View file

@ -0,0 +1,578 @@
#!/usr/bin/env bash
# Helper functions
TEST_DIR="test"
TEST_TMP="$TEST_DIR/tmp"
SCREEN_TMP="tmp-screen"
RCON_PORT="8088"
RCON_PASSWORD="supersecret"
export RESTIC_PASSWORD="restic-pass-secret"
setUp () {
chmod -R 755 "$TEST_TMP" || true
rm -rf "$TEST_TMP"
mkdir -p "$TEST_TMP/server/world"
mkdir -p "$TEST_TMP/backups"
echo "file1" > "$TEST_TMP/server/world/file1.txt"
echo "file2" > "$TEST_TMP/server/world/file2.txt"
echo "file3" > "$TEST_TMP/server/world/file3.txt"
restic init -r "$TEST_TMP/backups-restic" -q
screen -dmS "$SCREEN_TMP" bash
while ! screen -S "$SCREEN_TMP" -Q "select" . &>/dev/null; do
sleep 0.1
done
screen -S "$SCREEN_TMP" -X stuff "cat > $TEST_TMP/screen-output\n"
tmux new-session -d -s "$SCREEN_TMP"
while ! tmux has-session -t "$SCREEN_TMP" 2>/dev/null; do
sleep 0.1
done
tmux send-keys -t "$SCREEN_TMP" "cat > $TEST_TMP/tmux-output" ENTER
python test/mock_rcon.py "$RCON_PORT" "$RCON_PASSWORD" > "$TEST_TMP/rcon-output" &
echo "$!" > "$TEST_TMP/rcon-pid"
while ! [[ (-f "$TEST_TMP/screen-output") && (-f "$TEST_TMP/tmux-output") && (-f "$TEST_TMP/rcon-output") ]]; do
sleep 0.1
done
}
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
sleep 0.1
}
assert-equals-directory () {
if ! [ -e "$1" ]; then
fail "File not found: $1"
fi
if [ -d "$1" ]; then
for FILE in "$1"/*; do
assert-equals-directory "$FILE" "$2/${FILE##"$1"}"
done
else
assertEquals "$(cat "$1")" "$(cat "$2")"
fi
}
check-backup-full-paths () {
BACKUP_ARCHIVE="$1"
WORLD_DIR="$2"
mkdir -p "$TEST_TMP/restored"
tar --extract --file "$BACKUP_ARCHIVE" --directory "$TEST_TMP/restored"
assert-equals-directory "$WORLD_DIR" "$TEST_TMP/restored/$WORLD_DIR"
rm -rf "$TEST_TMP/restored"
}
check-backup () {
BACKUP_ARCHIVE="$1"
check-backup-full-paths "$TEST_TMP/backups/$BACKUP_ARCHIVE" "$TEST_TMP/server/world"
}
check-latest-backup-restic () {
WORLD_DIR="$TEST_TMP/server/world"
restic restore latest -r "$TEST_TMP/backups-restic" --target "$TEST_TMP/restored" -q
assert-equals-directory "$WORLD_DIR" "$TEST_TMP/restored/$WORLD_DIR"
rm -rf "$TEST_TMP/restored"
}
# Tests
test-restic-explicit-hostname () {
EXPECTED_HOSTNAME="${HOSTNAME}blahblah"
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
./backup.sh -i "$TEST_TMP/server/world" -r "$TEST_TMP/backups-restic" -s "$SCREEN_TMP" -f "$TIMESTAMP" -H "$EXPECTED_HOSTNAME"
check-latest-backup-restic
LATEST_BACKUP_HOSTNAME=$(restic -r "$TEST_TMP/backups-restic" snapshots latest --json | jq -r '.[0]["hostname"]')
assertEquals "$EXPECTED_HOSTNAME" "$LATEST_BACKUP_HOSTNAME"
}
test-restic-default-hostname () {
EXPECTED_HOSTNAME="${HOSTNAME}"
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
./backup.sh -i "$TEST_TMP/server/world" -r "$TEST_TMP/backups-restic" -s "$SCREEN_TMP" -f "$TIMESTAMP"
check-latest-backup-restic
LATEST_BACKUP_HOSTNAME=$(restic -r "$TEST_TMP/backups-restic" snapshots latest --json | jq -r '.[0]["hostname"]')
assertEquals "$EXPECTED_HOSTNAME" "$LATEST_BACKUP_HOSTNAME"
}
test-backup-defaults () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
./backup.sh -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP"
check-backup "$TIMESTAMP.tar.gz"
}
test-backup-multiple-worlds () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
cp -r "$TEST_TMP/server/world" "$TEST_TMP/server/world_nether"
cp -r "$TEST_TMP/server/world" "$TEST_TMP/server/world_the_end"
./backup.sh -i "$TEST_TMP/server/world" -i "$TEST_TMP/server/world_nether" -i "$TEST_TMP/server/world_the_end" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP"
mkdir -p "$TEST_TMP/restored"
tar --extract --file "$TEST_TMP/backups/$TIMESTAMP.tar.gz" --directory "$TEST_TMP/restored"
assert-equals-directory "$TEST_TMP/server/world" "$TEST_TMP/restored/$TEST_TMP/server/world"
assert-equals-directory "$TEST_TMP/server/world_nether" "$TEST_TMP/restored/$TEST_TMP/server/world_nether"
assert-equals-directory "$TEST_TMP/server/world_the_end" "$TEST_TMP/restored/$TEST_TMP/server/world_the_end"
}
test-file-changed-as-read-warning () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
dd if=/dev/urandom of="$TEST_TMP/server/world/random" &
DD_PID="$!"
OUTPUT="$(./backup.sh -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP" 2>&1)"
EXIT_CODE="$?"
kill "$DD_PID"
assertEquals 0 "$EXIT_CODE"
assertContains "$OUTPUT" "Some files may differ in the backup archive"
# Check that the backup actually resulted in a valid tar
assertTrue '[ -f '"$TEST_TMP/backups/$TIMESTAMP.tar.gz"' ]'
mkdir -p "$TEST_TMP/restored"
tar --extract --file "$TEST_TMP/backups/$TIMESTAMP.tar.gz" --directory "$TEST_TMP/restored"
assert-equals-directory "$WORLD_DIR/file1.txt" "$TEST_TMP/restored/$WORLD_DIR/file1.txt"
assert-equals-directory "$WORLD_DIR/file2.txt" "$TEST_TMP/restored/$WORLD_DIR/file2.txt"
assert-equals-directory "$WORLD_DIR/file3.txt" "$TEST_TMP/restored/$WORLD_DIR/file3.txt"
}
test-lock-defaults () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
./backup.sh -t "$TEST_TMP/lockfile" -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP"
check-backup "$TIMESTAMP.tar.gz"
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01 +1 hour")"
./backup.sh -t "$TEST_TMP/lockfile" -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP"
check-backup "$TIMESTAMP.tar.gz"
}
test-lock-timeout () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
flock "$TEST_TMP/lockfile" sleep 10 &
OUTPUT=$(./backup.sh -t "$TEST_TMP/lockfile" -u 0 -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP")
assertNotEquals 0 "$?"
assertContains "$OUTPUT" "Could not acquire lock on lock file: $TEST_TMP/lockfile"
}
test-restic-incomplete-snapshot () {
chmod 000 "$TEST_TMP/server/world/file1.txt"
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
OUTPUT="$(./backup.sh -i "$TEST_TMP/server/world" -r "$TEST_TMP/backups-restic" -s "$SCREEN_TMP" -f "$TIMESTAMP")"
assertEquals 1 "$(restic list snapshots -r "$TEST_TMP/backups-restic" | wc -l)"
assertContains "$OUTPUT" "Incomplete snapshot taken"
}
test-restic-no-snapshot () {
rm -rf "$TEST_TMP/server"
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
OUTPUT="$(./backup.sh -i "$TEST_TMP/server/world" -r "$TEST_TMP/backups-restic" -s "$SCREEN_TMP" -f "$TIMESTAMP")"
EXIT_CODE="$?"
assertNotEquals 0 "$EXIT_CODE"
assertContains "$OUTPUT" "No restic snapshot created"
}
test-restic-thinning-too-few () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
OUTPUT="$(./backup.sh -m 10 -i "$TEST_TMP/server/world" -r "$TEST_TMP/backups-restic" -s "$SCREEN_TMP" -f "$TIMESTAMP" 2>&1)"
EXIT_CODE="$?"
assertNotEquals 0 "$EXIT_CODE"
assertContains "$OUTPUT" "Thinning delete with restic requires at least 70 snapshots to be kept."
}
test-restic-thinning-delete-long () {
for i in $(seq 0 99); do
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01 +$i day")"
./backup.sh -m -1 -i "$TEST_TMP/server/world" -r "$TEST_TMP/backups-restic" -s "$SCREEN_TMP" -f "$TIMESTAMP"
done
EXPECTED_TIMESTAMPS=(
# Weekly
"2021-01-03 00:00:00"
"2021-01-10 00:00:00"
"2021-01-17 00:00:00"
"2021-01-24 00:00:00"
"2021-01-31 00:00:00"
# Daily (30)
"2021-03-13 00:00:00"
"2021-03-14 00:00:00"
"2021-03-15 00:00:00"
"2021-03-16 00:00:00"
"2021-03-17 00:00:00"
"2021-03-18 00:00:00"
# Hourly (24)
"2021-03-19 00:00:00"
"2021-03-20 00:00:00"
"2021-03-21 00:00:00"
"2021-03-22 00:00:00"
"2021-03-23 00:00:00"
"2021-03-24 00:00:00"
"2021-03-25 00:00:00"
"2021-03-26 00:00:00"
# Sub-hourly (16)
"2021-03-26 00:00:00"
"2021-03-27 00:00:00"
"2021-03-28 00:00:00"
"2021-03-29 00:00:00"
"2021-03-30 00:00:00"
"2021-03-31 00:00:00"
"2021-04-01 00:00:00"
"2021-04-02 00:00:00"
"2021-04-03 00:00:00"
"2021-04-04 00:00:00"
"2021-04-05 00:00:00"
"2021-04-06 00:00:00"
"2021-04-07 00:00:00"
"2021-04-08 00:00:00"
"2021-04-09 00:00:00"
"2021-04-10 00:00:00"
)
SNAPSHOTS="$(restic snapshots -r "$TEST_TMP/backups-restic")"
for TIMESTAMP in "${EXPECTED_TIMESTAMPS[@]}"; do
assertContains "$SNAPSHOTS" "$TIMESTAMP"
done
}
test-restic-defaults () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
./backup.sh -i "$TEST_TMP/server/world" -r "$TEST_TMP/backups-restic" -s "$SCREEN_TMP" -f "$TIMESTAMP"
check-latest-backup-restic
}
test-backup-spaces-in-directory () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
WORLD_SPACES="$TEST_TMP/minecraft server/the world"
mkdir -p "$(dirname "$WORLD_SPACES")"
BACKUP_SPACES="$TEST_TMP/My Backups"
mkdir -p "$BACKUP_SPACES"
cp -r "$TEST_TMP/server/world" "$WORLD_SPACES"
./backup.sh -i "$WORLD_SPACES" -o "$BACKUP_SPACES" -s "$SCREEN_TMP" -f "$TIMESTAMP"
check-backup-full-paths "$BACKUP_SPACES/$TIMESTAMP.tar.gz" "$WORLD_SPACES"
}
test-backup-no-compression () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
./backup.sh -a "" -e "" -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP"
check-backup "$TIMESTAMP.tar"
}
test-backup-max-compression () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
./backup.sh -a "xz" -e "xz" -l 9e -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP"
check-backup "$TIMESTAMP.tar.xz"
}
test-chat-messages () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
./backup.sh -c -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP"
EXPECTED_OUTPUT="$(head -n-1 "$TEST_DIR/data/test-chat-messages.txt")"
ACTUAL_OUTPUT="$(head -n-1 "$TEST_TMP/screen-output")"
assertEquals "$EXPECTED_OUTPUT" "$ACTUAL_OUTPUT"
}
test-chat-prefix () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
./backup.sh -p "Hello" -c -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP"
EXPECTED_OUTPUT="$(head -n-1 "$TEST_DIR/data/test-chat-prefix.txt")"
ACTUAL_OUTPUT="$(head -n-1 "$TEST_TMP/screen-output")"
assertEquals "$EXPECTED_OUTPUT" "$ACTUAL_OUTPUT"
}
test-check-help () {
HELP_HEADER="$(./backup.sh -h)"
assertEquals "Minecraft Backup" "$(head -n1 <<< "$HELP_HEADER")"
}
test-missing-options () {
OUTPUT="$(./backup.sh 2>&1)"
EXIT_CODE="$?"
assertEquals 1 "$EXIT_CODE"
assertContains "$OUTPUT" "Minecraft screen/tmux/rcon location not specified (use -s)"
assertContains "$OUTPUT" "Server world not specified"
assertContains "$OUTPUT" "Backup location not specified"
}
test-restic-and-output-options () {
OUTPUT="$(./backup.sh -c -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP" -r "$TEST_TMP/backups-restic" 2>&1)"
EXIT_CODE="$?"
assertEquals 1 "$EXIT_CODE"
assertContains "$OUTPUT" "Both output directory (-o) and restic repo (-r) specified but only one may be used at a time"
}
test-missing-options-suppress-warnings () {
OUTPUT="$(./backup.sh -q 2>&1)"
EXIT_CODE="$?"
assertEquals 1 "$EXIT_CODE"
assertNotContains "$OUTPUT" "Minecraft screen/tmux/rcon location not specified (use -s)"
}
test-invalid-options () {
OUTPUT="$(./backup.sh -z 2>&1)"
EXIT_CODE="$?"
assertEquals 1 "$EXIT_CODE"
assertContains "$OUTPUT" "Invalid option"
}
test-empty-world-warning () {
mkdir -p "$TEST_TMP/server/empty-world"
OUTPUT="$(./backup.sh -v -i "$TEST_TMP/server/empty-world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP" 2>&1)"
assertContains "$OUTPUT" "Backup was not saved!"
}
test-block-size-warning () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
OUTPUT="$(./backup.sh -m 10 -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP" 2>&1)"
EXIT_CODE="$?"
assertContains "$OUTPUT" "is smaller than TOTAL_BLOCK_SIZE"
}
test-bad-input-world () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
OUTPUT="$(./backup.sh -m 10 -i "$TEST_TMP/server/notworld" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP" 2>&1)"
EXIT_CODE="$?"
assertNotEquals 0 "$EXIT_CODE"
assertFalse '[ -f '"$TEST_TMP/backups/$TIMESTAMP.tar.gz"' ]'
}
test-nonzero-exit-warning () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
OUTPUT="$(./backup.sh -a _BLAH_ -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP" 2>&1)"
EXIT_CODE="$?"
assertNotEquals 0 "$EXIT_CODE"
assertContains "$OUTPUT" "Archive command exited with nonzero exit code"
assertFalse '[ -f '"$TEST_TMP/backups/$TIMESTAMP.tar.gz"' ]'
}
test-screen-interface () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
./backup.sh -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP"
EXPECTED_CONTENTS=$(echo -e "save-off\nsave-on\nsave-all")
SCREEN_CONTENTS="$(cat "$TEST_TMP/screen-output")"
assertEquals "$EXPECTED_CONTENTS" "$SCREEN_CONTENTS"
}
test-tmux-interface () {
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
./backup.sh -w tmux -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP"
EXPECTED_CONTENTS=$(echo -e "save-off\nsave-on\nsave-all")
SCREEN_CONTENTS="$(cat "$TEST_TMP/tmux-output")"
assertEquals "$EXPECTED_CONTENTS" "$SCREEN_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 "$EXPECTED_CONTENTS" "$SCREEN_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-rcon-interface-not-running () {
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 "@!@#:$RCON_PORT:$RCON_PASSWORD" -f "$TIMESTAMP" 2>&1)"
assertContains "$OUTPUT" "Could not connect"
}
test-docker-rcon () {
CONTAINER="$(docker run -d -e EULA=TRUE docker.io/itzg/minecraft-server)"
while ! docker exec "$CONTAINER" grep 'RCON running on 0.0.0.0:25575' /data/logs/latest.log; do
sleep 0.1
done
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01")"
./backup.sh -w docker-rcon -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$CONTAINER" -f "$TIMESTAMP"
OUTPUT="$(docker exec "$CONTAINER" cat /data/logs/latest.log)"
docker rm -f "$CONTAINER"
assertContains "$OUTPUT" "[Rcon: Automatic saving is now disabled]"
assertContains "$OUTPUT" "[Rcon: Automatic saving is now enabled]"
assertContains "$OUTPUT" "[Rcon: Saved the game]"
}
test-sequential-delete () {
for i in $(seq 0 20); do
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01 +$i hour")"
./backup.sh -d "sequential" -m 30 -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP"
done
for i in $(seq 20 99); do
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01 +$i hour")"
./backup.sh -d "sequential" -m 10 -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP"
done
for i in $(seq 90 99); do
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01 +$i hour")"
check-backup "$TIMESTAMP.tar.gz"
done
assertEquals 10 "$(find "$TEST_TMP/backups" -type f | wc -l)"
}
test-restic-sequential-delete () {
for i in $(seq 0 20); do
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01 +$i hour")"
./backup.sh -d "sequential" -m 10 -i "$TEST_TMP/server/world" -r "$TEST_TMP/backups-restic" -s "$SCREEN_TMP" -f "$TIMESTAMP"
done
assertEquals 10 "$(restic list snapshots -r "$TEST_TMP/backups-restic" | wc -l)"
check-latest-backup-restic
SNAPSHOTS="$(restic snapshots -r "$TEST_TMP/backups-restic")"
for i in $(seq 11 20); do
TIMESTAMP="$(date "+%F %H:%M:%S" --date="2021-01-01 +$i hour")"
assertContains "$SNAPSHOTS" "$TIMESTAMP"
done
for i in $(seq 0 10); do
TIMESTAMP="$(date "+%F %H:%M:%S" --date="2021-01-01 +$i hour")"
assertNotContains "$SNAPSHOTS" "$TIMESTAMP"
done
}
test-thinning-delete () {
for i in $(seq 0 99); do
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01 +$i hour")"
./backup.sh -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP"
done
EXPECTED_TIMESTAMPS=(
# Weekly
# Daily (30)
"2021-01-01_00-00-00"
"2021-01-02_00-00-00"
"2021-01-03_00-00-00"
# Hourly (24)
"2021-01-03_12-00-00"
"2021-01-03_13-00-00"
"2021-01-03_14-00-00"
"2021-01-03_15-00-00"
"2021-01-03_16-00-00"
"2021-01-03_17-00-00"
"2021-01-03_18-00-00"
"2021-01-03_19-00-00"
"2021-01-03_20-00-00"
"2021-01-04_09-00-00"
"2021-01-04_10-00-00"
"2021-01-04_11-00-00"
# Sub-hourly (16)
"2021-01-04_12-00-00"
"2021-01-04_13-00-00"
"2021-01-04_14-00-00"
"2021-01-04_15-00-00"
"2021-01-04_16-00-00"
"2021-01-04_17-00-00"
"2021-01-04_18-00-00"
"2021-01-04_19-00-00"
"2021-01-04_20-00-00"
"2021-01-04_21-00-00"
"2021-01-04_22-00-00"
"2021-01-04_23-00-00"
"2021-01-05_00-00-00"
"2021-01-05_01-00-00"
"2021-01-05_02-00-00"
"2021-01-05_03-00-00"
)
for TIMESTAMP in "${EXPECTED_TIMESTAMPS[@]}"; do
check-backup "$TIMESTAMP.tar.gz"
done
}
test-restic-thinning-delete () {
for i in $(seq 0 99); do
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01 +$i hour")"
./backup.sh -m 70 -i "$TEST_TMP/server/world" -r "$TEST_TMP/backups-restic" -s "$SCREEN_TMP" -f "$TIMESTAMP"
done
EXPECTED_TIMESTAMPS=(
# Weekly
# Daily (30)
"2021-01-01 23:00:00"
"2021-01-02 23:00:00"
"2021-01-03 23:00:00"
# Hourly (24)
"2021-01-04 04:00:00"
"2021-01-04 05:00:00"
"2021-01-04 06:00:00"
"2021-01-04 07:00:00"
"2021-01-04 08:00:00"
"2021-01-04 09:00:00"
"2021-01-04 10:00:00"
"2021-01-04 11:00:00"
# Sub-hourly (16)
"2021-01-04 12:00:00"
"2021-01-04 13:00:00"
"2021-01-04 14:00:00"
"2021-01-04 15:00:00"
"2021-01-04 16:00:00"
"2021-01-04 17:00:00"
"2021-01-04 18:00:00"
"2021-01-04 19:00:00"
"2021-01-04 20:00:00"
"2021-01-04 21:00:00"
"2021-01-04 22:00:00"
"2021-01-04 23:00:00"
"2021-01-05 00:00:00"
"2021-01-05 01:00:00"
"2021-01-05 02:00:00"
"2021-01-05 03:00:00"
)
SNAPSHOTS="$(restic snapshots -r "$TEST_TMP/backups-restic")"
for TIMESTAMP in "${EXPECTED_TIMESTAMPS[@]}"; do
assertContains "$SNAPSHOTS" "$TIMESTAMP"
done
UNEXPECTED_TIMESTAMPS=(
"2021-01-01 00:00:00"
"2021-01-01 01:00:00"
"2021-01-01 02:00:00"
"2021-01-02 22:00:00"
"2021-01-03 22:00:00"
"2021-01-04 00:00:00"
)
for TIMESTAMP in "${UNEXPECTED_TIMESTAMPS[@]}"; do
assertNotContains "$SNAPSHOTS" "$TIMESTAMP"
done
}
test-thinning-delete-long () {
for i in $(seq 0 99); do
TIMESTAMP="$(date +%F_%H-%M-%S --date="2021-01-01 +$i day")"
OUTPUT="$(./backup.sh -v -m 73 -i "$TEST_TMP/server/world" -o "$TEST_TMP/backups" -s "$SCREEN_TMP" -f "$TIMESTAMP")"
done
UNEXPECTED_TIMESTAMPS=(
"2021-01-04_00-00-00"
"2021-01-05_00-00-00"
"2021-01-12_00-00-00"
"2021-01-24_00-00-00"
)
for TIMESTAMP in "${UNEXPECTED_TIMESTAMPS[@]}"; do
assertFalse '[ -f '"$TEST_TMP/backups/$TIMESTAMP.tar.gz"' ]'
done
assertEquals 73 "$(find "$TEST_TMP/backups" -type f | wc -l)"
EXPECTED_TIMESTAMPS=(
# Weekly
"2021-01-11_00-00-00"
"2021-01-25_00-00-00"
"2021-01-25_00-00-00"
# Daily (30)
"2021-01-31_00-00-00"
"2021-03-01_00-00-00"
# Hourly (24)
"2021-03-02_00-00-00"
"2021-03-25_00-00-00"
# Sub-hourly (16)
"2021-03-26_00-00-00"
"2021-04-10_00-00-00"
)
assertContains "$OUTPUT" "promoted to next block"
for TIMESTAMP in "${EXPECTED_TIMESTAMPS[@]}"; do
check-backup "$TIMESTAMP.tar.gz"
done
}
# shellcheck disable=SC1091
. test/shunit2/shunit2