release: yet another Nix Minecraft Module

This commit is contained in:
Peritia 2025-09-26 11:42:25 +02:00
parent 9fdb947c2a
commit 839bb7de43
13 changed files with 1621 additions and 1 deletions

119
README.md
View file

@ -1 +1,118 @@
# Nyx-Minecraft # Yet another Nix Minecraft module
This NixOS module extends [Infinidoge/nix-minecraft](https://github.com/Infinidoge/nix-minecraft) with additional features for managing multiple Minecraft servers declaratively.
## Features
- Declarative configuration of multiple Minecraft servers
- Control over memory, operators, whitelist, symlinks, and server properties
- Automatic **systemd services** and **timers** for scheduled jobs
- Prebuilt helper scripts for administration:
- `rcon`
- `query`
- `backup`
- `say`
- `backup-routine`
- Support for:
- Backups
- Logging
- Scheduled maintenance tasks
## Helper Scripts
For each server defined, helper scripts are generated into your system `$PATH`.
They follow the naming convention:
```bash
minecraft-<SERVERNAME>-<TASK>
# Example:
minecraft-myserver-say "yellow" "hello"
```
You can inspect the generated scripts under: `minecraft/Scripts`
## Configuration Options
```nix
nyx-minecraft.service = {
enable = true; # Enable the enhanced servers (boolean)
eula = true; # I can't accept this for you
user = "minecraft"; # System user that owns/runs servers - DON'T CHANGE THIS
group = "minecraft"; # System group that owns/runs servers - DON'T CHANGE THIS
dataDir = "/srv/minecraft"; # Directory for Minecraft server data
servers = {
myserver = {
enable = true;
memory.min = "2G"; # JVM minimum memory
memory.max = "4G"; # JVM maximum memory
# This will be directly exposed to [Nix-Minecraft](https://github.com/Infinidoge/nix-minecraft/tree/master?tab=readme-ov-file#packages)
# Check their documentation for available options.
package = pkgs.minecraftServers.vanilla-1_20_4;
autoStart = true; # Start server on boot
whitelist = { # Declarative whitelist (UUIDs per user)
alice = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
bob = "ffffffff-1111-2222-3333-444444444444";
};
operators = { # Declarative operator list
alice = {
uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
level = 4;
};
};
properties = { # Declarative `server.properties`
serverPort = 25565;
motd = "Welcome to my NixOS server!";
maxPlayers = 10;
};
schedules.backup = { # Systemd timer + service job
timer = "daily";
code = "minecraft-myserver-backup";
};
};
};
};
```
For more details, refer to the [official nix-minecraft documentation](https://github.com/Infinidoge/nix-minecraft), since most arguments are passed through.
### Server Options
- **memory.min / memory.max** — JVM memory allocation (e.g. `"2G"`)
- **package** — Minecraft server package (default: `pkgs.minecraft-server`)
- **autoStart** — Start on boot (`true` / `false`)
- **whitelist** — Declarative whitelist keyed by username with UUID
- **operators** — Operator list with permission levels (`04`)
- **symlinks** — Files/packages symlinked into server data directory
- **properties** — Declarative `server.properties` values (ports, motd, difficulty, etc.)
- **schedules** — Declarative scheduled jobs (with systemd timers and services)
## Examples
See the examples in:
- `./example/full`
- `./example/small`
## Warnings
- **Use at your own risk** — verify backups and test schedules before relying on them
- Pin a known working version for stable usage
- Perform **manual backups before updates**
## Contributing
I am happy to help with issues and welcome pull requests for improvements.
Since I use this module personally, you can expect frequent updates.

View file

@ -0,0 +1,112 @@
{
config,
lib,
pkgs,
...
}:
imports = [
# ... your imports
inputs.nyx-minecraft.nixosModules.minecraft-servers
];
nyx-minecraft.service = {
enable = true;
eula = true;
# user # don't change this
# group # don't change this
dataDir = "srv/minecraft";
servers = {
testingServer = {
enable = true;
memory = {
min = "2G";
max = "4G";
};
package = pkgs.minecraftServers.vanilla-1_20_4;
autoStart = true;
whitelist = {
player1 = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
player2 = "ffffffff-1111-2222-3333-444444444444";
};
operators = {
admin = {
uuid = "99999999-aaaa-bbbb-cccc-dddddddddddd";
level = 4;
bypassesPlayerLimit = true;
};
mod = {
uuid = "88888888-aaaa-bbbb-cccc-eeeeeeeeeeee";
level = 2;
bypassesPlayerLimit = false;
};
};
properties = {
serverPort = 25565;
difficulty = 2;
gamemode = 0;
maxPlayers = 20;
motd = "Welcome to the testingServer!";
rconPassword = "superSecret123";
hardcore = false;
levelSeed = "8675309";
};
schedules = {
# note schedule can be enabled without the server being enabled
backup-hourly = {
enable = true;
# this is using systemD timers check the official Documentation
timer = "hourly";
code = ''
minecraft-testingServer-backup-routine \
--sleep 16 \
--destination /srv/minecraft/backups/testingServer/hourly \
--pure
'';
};
backup-daily = {
enable = true;
timer = "daily";
code = ''
minecraft-testingServer-backup-routine \
--sleep 60 \
--destination /srv/minecraft/backups/testingServer/daily \
--format zip
'';
};
backup-weekly = {
enable = true;
timer = "weekly";
code = ''
minecraft-testingServer-backup-routine \
--sleep 600 \
--full \
--destination /srv/minecraft/backups/testingServer/weekly \
--format zip
'';
};
backup-monthly = {
enable = true;
timer = "monthly";
code = ''
minecraft-testingServer-backup-routine \
--sleep 960 \
--full \
--destination /srv/minecraft/backups/testingServer/monthly \
--format zip
'';
};
};
};
};
};
};

34
example/full/flake.nix Normal file
View file

@ -0,0 +1,34 @@
{
description = "EXAMPLE - Flake";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
# your own imports
nyx-minecraft.url = "github:Peritia-System/Nyx-Minecraft";
nyx-minecraft.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = inputs @ {
self,
nixpkgs,
nix-minecraft,
...
}: {
nixosConfigurations = {
yourSystem = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
specialArgs = {
inherit inputs self;
host = "yourSystem";
};
modules = [
nixos95.nixosModules.default
./configuration.nix
];
};
};
}

View file

@ -0,0 +1,58 @@
{
config,
lib,
pkgs,
...
}:
imports = [
# ... your imports
inputs.nyx-minecraft.nixosModules.minecraft-servers
];
nyx-minecraft.service = {
enable = true;
eula = true;
# user # don't change this
# group # don't change this
dataDir = "srv/minecraft";
servers = {
testingServer = {
enable = true;
memory = {
min = "2G";
max = "4G";
};
package = pkgs.minecraftServers.vanilla-1_20_4;
# leaving whitelis out just deactivates whitelist
# leaving Operators out will just not set any operator
# Leaving out a property will just set the default
properties = {
serverPort = 25565;
# note you don't need to set query or rcon port
# since they will be set 200 and 100 above the Serverport
};
# you can leave them out than but here a simple example
schedules = {
# Hourly world-only, pure rsync, no restart
greeting-hourly = {
enable = true;
# note schedule can be enabled without the server being enabled
timer = "hourly";
code = ''
minecraft-testingServer-say "yellow" "hello"
# now once an hour it will greet everyone in the server
'';
};
};
};
};
};
};

34
example/small/flake.nix Normal file
View file

@ -0,0 +1,34 @@
{
description = "EXAMPLE - Flake";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
# your own imports
nyx-minecraft.url = "github:Peritia-System/Nyx-Minecraft";
nyx-minecraft.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = inputs @ {
self,
nixpkgs,
nix-minecraft,
...
}: {
nixosConfigurations = {
yourSystem = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
specialArgs = {
inherit inputs self;
host = "yourSystem";
};
modules = [
nixos95.nixosModules.default
./configuration.nix
];
};
};
}

23
flake.nix Normal file
View file

@ -0,0 +1,23 @@
{
description = "Nyx-Modules";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
nix-minecraft.url = "github:Infinidoge/nix-minecraft";
};
outputs = { self, nixpkgs, nix-minecraft, ... }: {
nixosModules.minecraft-servers = {
config,
lib,
pkgs,
...
}: {
imports = [
./minecraft
nix-minecraft.nixosModules.minecraft-servers
];
nixpkgs.overlays = [nix-minecraft.overlay];
};
};
}

View file

@ -0,0 +1,420 @@
#!/usr/bin/env bash
set -euo pipefail
# Injected by Nix
RSYNC_BIN="@RSYNC_BIN@"
DATA_DIR="@DATA_DIR@"
MCSTATUS_BIN="@MCSTATUS_BIN@"
MCRCON_BIN="@MCRCON_BIN@"
AWK_BIN="@AWK_BIN@"
QUERY_PORT="@QUERY_PORT@"
RCON_PORT="@RCON_PORT@"
RCON_PASSWORD="@RCON_PASSWORD@"
SERVER_NAME="@SERVER_NAME@"
TAR_BIN="@TAR_BIN@"
ZIP_BIN="@ZIP_BIN@"
UNZIP_BIN="@UNZIP_BIN@"
GZIP_BIN="@GZIP_BIN@"
ZSTD_BIN="@ZSTD_BIN@"
PV_BIN="@PV_BIN@"
DU_BIN="@DU_BIN@"
BZIP2_BIN="@BZIP2_BIN@"
XZ_BIN="@XZ_BIN@"
# Convenience wrappers
rsync_cmd="$RSYNC_BIN"
awk_cmd="$AWK_BIN"
mcstatus_cmd="$MCSTATUS_BIN 127.0.0.1:${QUERY_PORT}"
mcrcon_cmd="$MCRCON_BIN -H 127.0.0.1 -P ${RCON_PORT} -p ${RCON_PASSWORD}"
tar_cmd="$TAR_BIN"
zip_cmd="$ZIP_BIN"
unzip_cmd="$UNZIP_BIN"
gzip_cmd="$GZIP_BIN"
zstd_cmd="$ZSTD_BIN"
pv_cmd="$PV_BIN"
du_cmd="$DU_BIN"
bzip2_cmd="$BZIP2_BIN"
xz_cmd="$XZ_BIN"
# PATH extension
# (only figured that out later if you add it here it can actually just use the bin)
# So you can easily just switch out the "*_cmd" with the "normal" name
export PATH="$(dirname "$GZIP_BIN")":"$(dirname "$ZSTD_BIN")":"$(dirname "$PV_BIN")":"$(dirname "$DU_BIN")":"$(dirname "$BZIP2_BIN")":"$(dirname "$XZ_BIN")":"$PATH"
# Defaults
REBOOT=false
SLEEP_TIME=0
FULL=false
DESTINATION="/srv/minecraft/backups/unknown"
PURE=false
FORMAT="tar"
COMPRESSION="gzip"
# Usage
usage() {
cat <<EOF
Usage: $0 [--reboot] [--sleep <seconds>] [--full] [--destination <path>] [--pure]
[--format <tar|zip>] [--compression <gzip|bzip2|xz|zstd>]
--reboot Stop server before backup and start afterwards [DOES NOT WORK]
--sleep N Wait N seconds with countdown announcements
--full Backup entire server directory (default: world only)
--destination X Backup target directory (default: /srv/minecraft/backups/unknown)
--pure Use rsync to copy files (no compression, symlinks resolved)
--format X Archive format: tar or zip (ignored if --pure)
--compression X Compression for tar (default: gzip)
EOF
exit 1
}
# Argument parsing
echo "[DEBUG] Parsing command-line arguments..."
while [[ $# -gt 0 ]]; do
case "$1" in
--reboot) echo "[DEBUG] Flag: --reboot"; REBOOT=true; shift ;;
--sleep) echo "[DEBUG] Flag: --sleep $2"; SLEEP_TIME="$2"; shift 2 ;;
--full) echo "[DEBUG] Flag: --full"; FULL=true; shift ;;
--destination) echo "[DEBUG] Flag: --destination $2"; DESTINATION="$2"; shift 2 ;;
--pure) echo "[DEBUG] Flag: --pure"; PURE=true; shift ;;
--format) echo "[DEBUG] Flag: --format $2"; FORMAT="$2"; shift 2 ;;
--compression) echo "[DEBUG] Flag: --compression $2"; COMPRESSION="$2"; shift 2 ;;
--help) usage ;;
*) echo "[ERROR] Unknown option: $1"; usage ;;
esac
done
# Restart if rebooting
if [[ "$REBOOT" == true ]]; then
echo "[DEBUG] Restarting server does not work"
echo "[DEBUG] The sudo can't be enabled due to it not being used by the same path as for the systemd"
echo "[DEBUG] and just using systemctl won't work either due to it not having the rights to stop it"
echo "[DEBUG] if you fix this pls make a PR"
fi
# Helpers
say_with_color() {
local color="$1"
shift
local message="$*"
local code
case "$color" in
black) code="§0" ;;
dark_blue) code="§1" ;;
dark_green) code="§2" ;;
dark_aqua) code="§3" ;;
dark_red) code="§4" ;;
dark_purple) code="§5" ;;
gold) code="§6" ;;
gray) code="§7" ;;
dark_gray) code="§8" ;;
blue) code="§9" ;;
green) code="§a" ;;
aqua) code="§b" ;;
red) code="§c" ;;
light_purple|pink) code="§d" ;;
yellow) code="§e" ;;
white) code="§f" ;;
obfuscated) code="§k" ;;
bold) code="§l" ;;
strikethrough) code="§m" ;;
underline) code="§n" ;;
italic) code="§o" ;;
reset) code="§r" ;;
*) code="" ;;
esac
local full_message="${code}${message}§r"
# echo "[DEBUG] Sending RCON say: $full_message"
$mcrcon_cmd "say $full_message"
}
say() {
echo "[INFO] $1"
say_with_color yellow "$1"
}
countdown() {
local seconds="$1"
#echo "[DEBUG] Starting countdown of $seconds seconds..."
say "Backup will start in $seconds seconds"
while [ "$seconds" -gt 0 ]; do
#echo " $seconds"
# Logic for when to speak updates
if [ "$seconds" -le 15 ]; then
say "$seconds"
elif [ "$seconds" -le 60 ] && (( seconds % 10 == 0 )); then
say "$seconds seconds remaining"
elif [ "$seconds" -le 120 ] && (( seconds % 30 == 0 )); then
say "$seconds seconds remaining"
elif [ "$seconds" -le 300 ] && (( seconds % 60 == 0 )); then
say "$seconds seconds remaining"
fi
sleep 1
((seconds--))
done
echo
say "Countdown finished. Starting backup now."
}
do_backup() {
#echo "[DEBUG] Entering do_backup with args: $*"
local source=""
local destination=""
local compression="gzip"
local format="tar"
local pure=false
# parse args
while [[ $# -gt 0 ]]; do
case "$1" in
--source) source="$2"; shift 2 ;;
--destination) destination="$2"; shift 2 ;;
--compression) compression="$2"; shift 2 ;;
--format) format="$2"; shift 2 ;;
--pure) pure=true; shift ;;
*) echo "[ERROR] Unknown option to do_backup: $1"; return 1 ;;
esac
done
if [[ -z "$source" || -z "$destination" ]]; then
echo "[ERROR] Missing --source or --destination"
return 1
fi
local timestamp="$(date +%Y%m%d-%H%M%S)"
local full_source="$DATA_DIR/$source"
local basename="$(basename "$source")"
local archive=""
local ext=""
if [[ ! -d "$full_source" ]]; then
echo "[ERROR] Source directory not found: $full_source"
return 1
fi
mkdir -p "$destination"
if [[ "$pure" == true ]]; then
local target_dir="$destination/${basename}-${timestamp}"
echo "[INFO] Performing pure rsync backup to $target_dir"
echo "#####"
local last_percentage=-1
"$rsync_cmd" -rptgoDL --delete --info=progress2 --stats \
"$full_source/" "$target_dir/" 2>&1 | \
while IFS= read -r -d $'\r' chunk; do
# Extract percentage
if [[ $chunk =~ ([0-9]{1,3})% ]]; then
current_percentage=${BASH_REMATCH[1]}
# Print only if percentage changed
if [[ $current_percentage -ne $last_percentage ]]; then
echo -e "Progress: ${current_percentage}%\r" | say "$@"
last_percentage=$current_percentage
fi
fi
done
return 0
fi
#echo "[DEBUG] Using archive mode: format=$format compression=$compression"
# Archive/compression backup
case "$format" in
tar)
# Map compression → extension and tar invocation (using only *_cmd vars)
case "$compression" in
none)
ext="tar"
tar_create=( "$tar_cmd" -cvf )
;;
gzip)
ext="tar.gz"
tar_create=( "$tar_cmd" --use-compress-program="$gzip_cmd" -cvf )
;;
bzip2)
ext="tar.bz2"
tar_create=( "$tar_cmd" --use-compress-program="$bzip2_cmd" -cvf )
;;
xz)
ext="tar.xz"
tar_create=( "$tar_cmd" --use-compress-program="$xz_cmd" -cvf )
;;
zstd)
ext="tar.zst"
tar_create=( "$tar_cmd" --use-compress-program="$zstd_cmd" -cvf )
;;
*)
echo "[ERROR] Unsupported tar compression: $compression" >&2
exit 1
;;
esac
archive="$destination/${basename}-${timestamp}.${ext}"
sources=( $source )
for s in "${sources[@]}"; do
if [[ ! -e "$DATA_DIR/$s" ]]; then
echo "[ERROR] Source not found under DATA_DIR: $DATA_DIR/$s" >&2
exit 1
fi
done
# Count total items for progress (files + dirs)
total_items=0
for s in "${sources[@]}"; do
count=$(find "$DATA_DIR/$s" | wc -l)
total_items=$(( total_items + count ))
done
current=0
echo "[INFO] Creating $archive"
last_percent=-1
last_info_time=$(date +%s)
# seconds between info logs
interval=2
(
"${tar_create[@]}" "$archive" -C "$DATA_DIR" "${sources[@]}"
) 2>&1 | while read -r line; do
if [[ -n "$line" ]]; then
current=$(( current + 1 ))
if (( total_items > 0 )); then
percent=$(( current * 100 / total_items ))
# echo full percent:
#echo "[DEBUG] Progress: ${percent}%"
now=$(date +%s)
if (( percent != last_percent )) && (( now - last_info_time >= interval )); then
#echo "[INFO] Progress: ${percent}%"
say "Progress: ${percent}%"
last_percent=$percent
last_info_time=$now
fi
fi
fi
done
# Ensure 100% gets printed once at the end
if (( last_percent < 100 )); then
#echo "[INFO] Progress: 100%"
say "Progress: 100%"
fi
echo "[INFO] Tar archive created: $archive"
;;
zip)
ext="zip"
archive="$destination/${basename}-${timestamp}.${ext}"
echo "[INFO] Creating zip archive $archive"
# Count both files and directories
total_items=$(find "$DATA_DIR/$source" | wc -l)
current=0
last_percent=-1
last_info_time=$(date +%s)
interval=2 # seconds between info logs
(
cd "$DATA_DIR"
"$zip_cmd" -r "$archive" "$source"
) 2>&1 | while read -r line; do
if [[ $line =~ adding: ]]; then
current=$((current+1))
if (( total_items > 0 )); then
percent=$(( current * 100 / total_items ))
#echo "[DEBUG] Progress: ${percent}%"
now=$(date +%s)
if (( percent != last_percent )) && (( now - last_info_time >= interval )); then
#echo "[INFO] Progress: ${percent}%"
say "Progress: ${percent}%"
last_percent=$percent
last_info_time=$now
fi
fi
fi
done
# Ensure 100% gets printed once at the end
if (( last_percent < 100 )); then
#echo "[INFO] Progress: 100%"
say "Progress: 100%"
fi
echo "[INFO] Zip archive created: $archive"
;;
*)
echo "[ERROR] Unsupported format: $format"
return 1
;;
esac
echo "[INFO] Backup completed: $archive"
return 0
}
# MAIN
#echo "[DEBUG] FULL=$FULL"
if [[ "$FULL" == true ]]; then
BACKUP_SOURCE="${SERVER_NAME}"
BACKUP_MODE="full server directory"
DESTINATION="${DESTINATION}/Full"
else
BACKUP_SOURCE="${SERVER_NAME}/world"
BACKUP_MODE="world folder only"
DESTINATION="${DESTINATION}/World"
fi
say "Backup for ($BACKUP_MODE) initiated"
# Pre-backup wait
if (( SLEEP_TIME > 0 )); then
countdown "$SLEEP_TIME"
fi
mkdir -p "$DESTINATION"
echo "[INFO] Running backup of $BACKUP_MODE to $DESTINATION..."
if do_backup \
--source "$BACKUP_SOURCE" \
--destination "$DESTINATION" \
$([[ "$PURE" == true ]] && echo "--pure") \
--compression "$COMPRESSION" \
--format "$FORMAT"; then
echo "[INFO] Backup finished successfully."
else
echo "[ERROR] Backup failed!"
exit 1
fi
say "Backup ($BACKUP_MODE) completed successfully."

View file

@ -0,0 +1,134 @@
#!/usr/bin/env bash
set -euo pipefail
# Injected by Nix
RSYNC_BIN="@RSYNC_BIN@"
DATA_DIR="@DATA_DIR@"
MCSTATUS_BIN="@MCSTATUS_BIN@"
MCRCON_BIN="@MCRCON_BIN@"
AWK_BIN="@AWK_BIN@"
QUERY_PORT="@QUERY_PORT@"
RCON_PORT="@RCON_PORT@"
RCON_PASSWORD="@RCON_PASSWORD@"
SERVER_NAME="@SERVER_NAME@"
TAR_BIN="@TAR_BIN@"
ZIP_BIN="@ZIP_BIN@"
UNZIP_BIN="@UNZIP_BIN@"
GZIP_BIN="@GZIP_BIN@"
ZSTD_BIN="@ZSTD_BIN@"
PV_BIN="@PV_BIN@"
DU_BIN="@DU_BIN@"
BZIP2_BIN="@BZIP2_BIN@"
XZ_BIN="@XZ_BIN@"
# Convenience wrappers
rsync_cmd="$RSYNC_BIN"
awk_cmd="$AWK_BIN"
mcstatus_cmd="$MCSTATUS_BIN 127.0.0.1:${QUERY_PORT}"
mcrcon_cmd="$MCRCON_BIN -H 127.0.0.1 -P ${RCON_PORT} -p ${RCON_PASSWORD}"
tar_cmd="$TAR_BIN"
zip_cmd="$ZIP_BIN"
unzip_cmd="$UNZIP_BIN"
gzip_cmd="$GZIP_BIN"
zstd_cmd="$ZSTD_BIN"
pv_cmd="$PV_BIN"
du_cmd="$DU_BIN"
bzip2_cmd="$BZIP2_BIN"
xz_cmd="$XZ_BIN"
# PATH extension
# (only figured that out later if you add it here it can actually just use the bin)
# So you can easily just switch out the "*_cmd" with the "normal" name
# export PATH="$(dirname "$GZIP_BIN")":"$(dirname "$ZSTD_BIN")":"$(dirname "$PV_BIN")":"$(dirname "$DU_BIN")":"$(dirname "$BZIP2_BIN")":"$(dirname "$XZ_BIN")":"$PATH"
# Defaults
SOURCE=""
DESTINATION=""
COMPRESSION="gzip"
FORMAT="tar"
PURE=false
usage() {
cat <<EOF
Usage: $0 --source <subfolder> --destination <path>
[--compression <gzip|bzip2|xz|zstd>] [--format <tar|zip>] [--pure]
Options:
--source Subfolder under \$DATA_DIR to back up (required)
--destination Backup destination path (required)
--compression Compression method for tar archives (default: gzip)
--format Archive format: tar or zip (default: tar)
--pure Perform plain rsync copy without compression
--help Show this help
EOF
exit 1
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--source) SOURCE="$2"; shift 2;;
--destination) DESTINATION="$2"; shift 2;;
--compression) COMPRESSION="$2"; shift 2;;
--format) FORMAT="$2"; shift 2;;
--pure) PURE=true; shift 1;;
--help) usage;;
*) echo "Unknown option: $1"; usage;;
esac
done
# Validation
if [[ -z "$SOURCE" || -z "$DESTINATION" ]]; then
echo "Error: --source and --destination are required."
usage
fi
FULL_SOURCE="$DATA_DIR/$SOURCE"
if [[ ! -d "$FULL_SOURCE" ]]; then
echo "Error: Source directory '$FULL_SOURCE' does not exist."
exit 1
fi
mkdir -p "$DESTINATION"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
BASENAME="$(basename "$SOURCE")"
# Pure rsync backup
if [[ "$PURE" == true ]]; then
TARGET_DIR="$DESTINATION/${BASENAME}-${TIMESTAMP}"
echo "Performing pure rsync backup to $TARGET_DIR"
"$rsync_cmd" -rptgoDL --delete "$FULL_SOURCE/" "$TARGET_DIR/"
echo "Backup completed (pure): $TARGET_DIR"
exit 0
fi
# Archive/compression backup
case "$FORMAT" in
tar)
case "$COMPRESSION" in
gzip) EXT="tar.gz"; TAR_ARGS="-czf";;
bzip2) EXT="tar.bz2"; TAR_ARGS="-cjf";;
xz) EXT="tar.xz"; TAR_ARGS="-cJf";;
zstd) EXT="tar.zst"; TAR_ARGS="--zstd -cf";;
*) echo "Unsupported compression for tar: $COMPRESSION"; exit 1;;
esac
ARCHIVE="$DESTINATION/${BASENAME}-${TIMESTAMP}.${EXT}"
"$tar_cmd" -C "$DATA_DIR" $TAR_ARGS "$ARCHIVE" "$SOURCE"
;;
zip)
EXT="zip"
ARCHIVE="$DESTINATION/${BASENAME}-${TIMESTAMP}.${EXT}"
(cd "$DATA_DIR" && "$zip_cmd" -r "$ARCHIVE" "$SOURCE")
;;
*)
echo "Unsupported format: $FORMAT"
exit 1
;;
esac
echo "Backup completed: $ARCHIVE"

View file

@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
# Injected by Nix
RSYNC_BIN="@RSYNC_BIN@"
DATA_DIR="@DATA_DIR@"
MCSTATUS_BIN="@MCSTATUS_BIN@"
MCRCON_BIN="@MCRCON_BIN@"
AWK_BIN="@AWK_BIN@"
QUERY_PORT="@QUERY_PORT@"
RCON_PORT="@RCON_PORT@"
RCON_PASSWORD="@RCON_PASSWORD@"
SERVER_NAME="@SERVER_NAME@"
TAR_BIN="@TAR_BIN@"
ZIP_BIN="@ZIP_BIN@"
UNZIP_BIN="@UNZIP_BIN@"
GZIP_BIN="@GZIP_BIN@"
ZSTD_BIN="@ZSTD_BIN@"
PV_BIN="@PV_BIN@"
DU_BIN="@DU_BIN@"
BZIP2_BIN="@BZIP2_BIN@"
XZ_BIN="@XZ_BIN@"
# Convenience wrappers
rsync_cmd="$RSYNC_BIN"
awk_cmd="$AWK_BIN"
mcstatus_cmd="$MCSTATUS_BIN 127.0.0.1:${QUERY_PORT}"
mcrcon_cmd="$MCRCON_BIN -H 127.0.0.1 -P ${RCON_PORT} -p ${RCON_PASSWORD}"
tar_cmd="$TAR_BIN"
zip_cmd="$ZIP_BIN"
unzip_cmd="$UNZIP_BIN"
gzip_cmd="$GZIP_BIN"
zstd_cmd="$ZSTD_BIN"
pv_cmd="$PV_BIN"
du_cmd="$DU_BIN"
bzip2_cmd="$BZIP2_BIN"
xz_cmd="$XZ_BIN"
# PATH extension
# (only figured that out later if you add it here it can actually just use the bin)
# So you can easily just switch out the "*_cmd" with the "normal" name
# export PATH="$(dirname "$GZIP_BIN")":"$(dirname "$ZSTD_BIN")":"$(dirname "$PV_BIN")":"$(dirname "$DU_BIN")":"$(dirname "$BZIP2_BIN")":"$(dirname "$XZ_BIN")":"$PATH"
# Query the server
exec $mcstatus_cmd query

View file

@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
# Injected by Nix
RSYNC_BIN="@RSYNC_BIN@"
DATA_DIR="@DATA_DIR@"
MCSTATUS_BIN="@MCSTATUS_BIN@"
MCRCON_BIN="@MCRCON_BIN@"
AWK_BIN="@AWK_BIN@"
QUERY_PORT="@QUERY_PORT@"
RCON_PORT="@RCON_PORT@"
RCON_PASSWORD="@RCON_PASSWORD@"
SERVER_NAME="@SERVER_NAME@"
TAR_BIN="@TAR_BIN@"
ZIP_BIN="@ZIP_BIN@"
UNZIP_BIN="@UNZIP_BIN@"
GZIP_BIN="@GZIP_BIN@"
ZSTD_BIN="@ZSTD_BIN@"
PV_BIN="@PV_BIN@"
DU_BIN="@DU_BIN@"
BZIP2_BIN="@BZIP2_BIN@"
XZ_BIN="@XZ_BIN@"
# Convenience wrappers
rsync_cmd="$RSYNC_BIN"
awk_cmd="$AWK_BIN"
mcstatus_cmd="$MCSTATUS_BIN 127.0.0.1:${QUERY_PORT}"
mcrcon_cmd="$MCRCON_BIN -H 127.0.0.1 -P ${RCON_PORT} -p ${RCON_PASSWORD}"
tar_cmd="$TAR_BIN"
zip_cmd="$ZIP_BIN"
unzip_cmd="$UNZIP_BIN"
gzip_cmd="$GZIP_BIN"
zstd_cmd="$ZSTD_BIN"
pv_cmd="$PV_BIN"
du_cmd="$DU_BIN"
bzip2_cmd="$BZIP2_BIN"
xz_cmd="$XZ_BIN"
# PATH extension
# (only figured that out later if you add it here it can actually just use the bin)
# So you can easily just switch out the "*_cmd" with the "normal" name
# export PATH="$(dirname "$GZIP_BIN")":"$(dirname "$ZSTD_BIN")":"$(dirname "$PV_BIN")":"$(dirname "$DU_BIN")":"$(dirname "$BZIP2_BIN")":"$(dirname "$XZ_BIN")":"$PATH"
# Pass arguments directly to mcrcon
exec $mcrcon_cmd "$@"

View file

@ -0,0 +1,109 @@
#!/usr/bin/env bash
set -euo pipefail
# Injected by Nix
RSYNC_BIN="@RSYNC_BIN@"
DATA_DIR="@DATA_DIR@"
MCSTATUS_BIN="@MCSTATUS_BIN@"
MCRCON_BIN="@MCRCON_BIN@"
AWK_BIN="@AWK_BIN@"
QUERY_PORT="@QUERY_PORT@"
RCON_PORT="@RCON_PORT@"
RCON_PASSWORD="@RCON_PASSWORD@"
SERVER_NAME="@SERVER_NAME@"
TAR_BIN="@TAR_BIN@"
ZIP_BIN="@ZIP_BIN@"
UNZIP_BIN="@UNZIP_BIN@"
GZIP_BIN="@GZIP_BIN@"
ZSTD_BIN="@ZSTD_BIN@"
PV_BIN="@PV_BIN@"
DU_BIN="@DU_BIN@"
BZIP2_BIN="@BZIP2_BIN@"
XZ_BIN="@XZ_BIN@"
# Convenience wrappers
rsync_cmd="$RSYNC_BIN"
awk_cmd="$AWK_BIN"
mcstatus_cmd="$MCSTATUS_BIN 127.0.0.1:${QUERY_PORT}"
mcrcon_cmd="$MCRCON_BIN -H 127.0.0.1 -P ${RCON_PORT} -p ${RCON_PASSWORD}"
tar_cmd="$TAR_BIN"
zip_cmd="$ZIP_BIN"
unzip_cmd="$UNZIP_BIN"
gzip_cmd="$GZIP_BIN"
zstd_cmd="$ZSTD_BIN"
pv_cmd="$PV_BIN"
du_cmd="$DU_BIN"
bzip2_cmd="$BZIP2_BIN"
xz_cmd="$XZ_BIN"
# PATH extension
# (only figured that out later if you add it here it can actually just use the bin)
# So you can easily just switch out the "*_cmd" with the "normal" name
# export PATH="$(dirname "$GZIP_BIN")":"$(dirname "$ZSTD_BIN")":"$(dirname "$PV_BIN")":"$(dirname "$DU_BIN")":"$(dirname "$BZIP2_BIN")":"$(dirname "$XZ_BIN")":"$PATH"
# Argument parsing
if [[ $# -lt 2 ]]; then
echo "Usage: $0 <color|format> <message...>"
echo "Example: $0 red 'Server restarting soon!'"
exit 1
fi
CODE_NAME="$1"
shift
MESSAGE="$*"
# Map color/format names to Minecraft § codes
case "$CODE_NAME" in
# Colors
black) CODE="§0" ;;
dark_blue) CODE="§1" ;;
dark_green) CODE="§2" ;;
dark_aqua) CODE="§3" ;;
dark_red) CODE="§4" ;;
dark_purple) CODE="§5" ;;
gold) CODE="§6" ;;
gray) CODE="§7" ;;
dark_gray) CODE="§8" ;;
blue) CODE="§9" ;;
green) CODE="§a" ;;
aqua) CODE="§b" ;;
red) CODE="§c" ;;
light_purple|pink) CODE="§d" ;;
yellow) CODE="§e" ;;
white) CODE="§f" ;;
# Bedrock-only extras
minecoin_gold) CODE="§g" ;;
material_quartz) CODE="§h" ;;
material_iron) CODE="§i" ;;
material_netherite) CODE="§j" ;;
material_redstone) CODE="§m" ;;
material_copper) CODE="§n" ;;
material_gold) CODE="§p" ;;
material_emerald) CODE="§q" ;;
material_diamond) CODE="§s" ;;
material_lapis) CODE="§t" ;;
material_amethyst) CODE="§u" ;;
# Formatting
obfuscated) CODE="§k" ;;
bold) CODE="§l" ;;
strikethrough) CODE="§m" ;;
underline) CODE="§n" ;;
italic) CODE="§o" ;;
reset) CODE="§r" ;;
*)
echo "Unknown code: $CODE_NAME"
echo "Available colors: black, dark_blue, dark_green, dark_aqua, dark_red, dark_purple, gold, gray, dark_gray, blue, green, aqua, red, light_purple, yellow, white"
echo "Formats: obfuscated, bold, strikethrough, underline, italic, reset"
exit 1
;;
esac
FULL_MESSAGE="${CODE}${MESSAGE}§r"
# Send via RCON
exec $mcrcon_cmd "say $FULL_MESSAGE"

11
minecraft/default.nix Normal file
View file

@ -0,0 +1,11 @@
{
config,
lib,
pkgs,
inputs,
...
}: {
imports = [
./minecraft.nix
];
}

474
minecraft/minecraft.nix Normal file
View file

@ -0,0 +1,474 @@
# Enhanced Minecraft Server Module
#
# This module provides:
# - A wrapper around the Infinidoge/nix-minecraft module
# - Declarative configuration for multiple servers, including memory, operators, whitelist, and symlinks
# - Automatic systemd service and timer generation for scheduled jobs
# - Prebuilt helper scripts (rcon, query, backup, say, backup-routine) for server administration
# - Support for backups, logging, and scheduled maintenance tasks
#
# Configuration Options:
# enable Enable the enhanced Minecraft servers (boolean)
# user System user that owns and runs the servers (string) - DON'T CHANGE THIS
# group System group that owns and runs the servers (string) - DON'T CHANGE THIS
# dataDir Directory to store Minecraft server data (path)
# servers Attribute set of servers, keyed by name. Each server can define:
# memory.min / memory.max JVM memory allocation (strings, e.g. "2G")
# package Minecraft server package to use (package)
# autoStart Start server at boot (boolean)
# whitelist Declarative whitelist (UUIDs per user)
# operators Declarative operator list with permission levels
# symlinks Files or packages symlinked into the server data directory
# properties Declarative `server.properties` values (ports, motd, difficulty, etc.)
# schedules Declarative scheduled jobs with systemd timers and services
#
#
# Info:
# I am happy to help if you have Issues and am happy to see a PR for any change
# I ll use it personally too so you can expect frequent updates
#
#
# ToDo:
# Schedule to restart a server using systemD. I have not figured that out yet.
# what i do know is that `sudo` does not work neither does it work if you just tell the server to restart.
#
{
config,
lib,
pkgs,
...
}:
with lib; let
cfg = config.nyx-minecraft.service;
minecraftUUID = types.strMatching "[0-9a-fA-F-]{36}";
# Setup the Scripts for the server
scriptDir = ./Scripts;
mkScript = serverName: serverCfg: scriptType: let
templateFile = scriptDir + "/minecraft-template-${scriptType}.sh";
rawText = builtins.readFile templateFile;
scriptText =
builtins.replaceStrings
[
"@DATA_DIR@"
"@RSYNC_BIN@"
"@MCSTATUS_BIN@"
"@MCRCON_BIN@"
"@AWK_BIN@"
"@QUERY_PORT@"
"@RCON_PORT@"
"@RCON_PASSWORD@"
"@SERVER_NAME@"
"@TAR_BIN@"
"@ZIP_BIN@"
"@UNZIP_BIN@"
"@GZIP_BIN@"
"@ZSTD_BIN@"
"@PV_BIN@"
"@DU_BIN@"
"@BZIP2_BIN@"
"@XZ_BIN@"
]
# If you add anything here make sure to add it at the systemd service too
[
cfg.dataDir
"${pkgs.rsync}/bin/rsync"
"${pkgs.mcstatus}/bin/mcstatus"
"${pkgs.mcrcon}/bin/mcrcon"
"${pkgs.gawk}/bin/awk"
(toString (serverCfg.properties.serverPort + 200))
(toString (serverCfg.properties.serverPort + 100))
serverCfg.properties.rconPassword
serverName
"${pkgs.gnutar}/bin/tar"
"${pkgs.zip}/bin/zip"
"${pkgs.unzip}/bin/unzip"
"${pkgs.gzip}/bin/gzip"
"${pkgs.zstd}/bin/zstd"
"${pkgs.pv}/bin/pv"
"${pkgs.coreutils}/bin/du"
"${pkgs.bzip2}/bin/bzip2"
"${pkgs.xz}/bin/xz"
]
rawText;
in
pkgs.writeShellScriptBin "minecraft-${serverName}-${scriptType}" scriptText;
in {
# Note most of the options get directly exposed
# to nix-minecraft which makes it almost an
# Drop in replacement
options.nyx-minecraft.service = {
enable = mkEnableOption "Enable enhanced Minecraft servers with backup, logging, and admin helpers.";
eula = mkEnableOption ''
Whether you agree to
<link xlink:href="https://account.mojang.com/documents/minecraft_eula">
Mojang's EULA</link>. This option must be set to
<literal>true</literal> to run Minecraft server.
'';
user = mkOption {
type = types.str;
default = "minecraft";
description = ''
Name of the user to create and run servers under.
It is recommended to leave this as the default, as it is
the same user as <option>services.minecraft-server</option>.
'';
internal = true;
visible = false;
};
group = mkOption {
type = types.str;
default = "minecraft";
description = ''
Name of the group to create and run servers under.
In order to modify the server files your user must be a part of this
group. If you are using the tmux management system (the default), you also need to be a part of this group to attach to the tmux socket.
It is recommended to leave this as the default, as it is
the same group as <option>services.minecraft-server</option>.
'';
};
dataDir = mkOption {
type = types.path;
default = "/srv/minecraft";
description = ''
Directory to store the Minecraft servers.
Each server will be under a subdirectory named after
the server name in this directory, such as <literal>/srv/minecraft/servername</literal>. '';
};
servers = mkOption {
type = types.attrsOf (types.submodule ({name, ...}: {
options = {
enable = mkEnableOption "Enable this Server";
memory = mkOption {
type = types.submodule {
options = {
min = mkOption {
type = types.str;
default = "2G";
description = "Min JVM memory.";
};
max = mkOption {
type = types.str;
default = "2G";
description = "Max JVM memory.";
};
};
};
default = {
min = "2G";
max = "2G";
};
description = "JVM memory settings for this server.";
};
package = mkOption {
description = "The Minecraft server package to use.";
type = types.package;
default = pkgs.minecraft-server;
defaultText = literalExpression "pkgs.minecraft-server";
example = "pkgs.minecraftServers.vanilla-1_18_2";
};
autoStart = mkOption {
type = types.bool;
default = true;
description = ''
Whether to start this server on boot.
If set to <literal>false</literal>, it can still be started with
<literal>systemctl start minecraft-server-servername</literal>.
Requires the server to be enabled.
'';
};
whitelist = mkOption {
type = types.attrsOf minecraftUUID;
default = {};
description = ''
Whitelisted players, only has an effect when
enabled via <option>services.minecraft-servers.<name>.serverProperties</option>
by setting <literal>white-list</literal> to <literal>true</literal>.
To use a non-declarative whitelist, enable the whitelist and don't fill in this value.
As long as it is empty, no whitelist file is generated.
'';
example = literalExpression ''
{
username1 = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx";
username2 = "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy";
}
'';
};
symlinks = mkOption {
type = types.attrsOf (types.either types.path types.package);
default = {};
description = ''
Things to symlink into this server's data directory.
Can be used to declaratively manage arbitrary files (e.g., mods, configs).
'';
};
operators = mkOption {
type = types.attrsOf (
types.coercedTo minecraftUUID (v: {uuid = v;}) (
types.submodule {
options = {
uuid = mkOption {
type = minecraftUUID;
description = "The operator's UUID";
example = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx";
};
level = mkOption {
type = types.ints.between 0 4;
description = "The operator's permission level";
default = 4;
};
bypassesPlayerLimit = mkOption {
type = types.bool;
description = "If true, the operator can join the server even if the player limit has been reached";
default = false;
};
};
}
)
);
default = {};
description = "Operators with permission levels.";
};
properties = mkOption {
type = types.submodule {
options = {
serverPort = mkOption {
type = types.int;
default = 25565;
description = "Server Port";
};
difficulty = mkOption {
type = types.int;
default = 2;
description = "Difficulty in numbers: 0=Peaceful, 1=Easy, 2=Normal, 3=Hard";
};
gamemode = mkOption {
type = types.int;
default = 0;
description = "Gamemode: 0=Survival, 1=Creative";
};
maxPlayers = mkOption {
type = types.int;
default = 5;
description = "How many players can join the server";
};
motd = mkOption {
type = types.str;
default = "NixOS Minecraft server!";
description = "Message displayed when selecting the server";
};
rconPassword = mkOption {
type = types.str;
default = "superSecret";
description = "Password for Rcon";
};
hardcore = mkOption {
type = types.bool;
default = false;
description = "Enable Hardcore mode";
};
levelSeed = mkOption {
type = types.str;
default = "42";
description = "World seed (default is the answer to the universe)";
};
};
};
default = {};
description = "Declarative Minecraft server.properties values.";
};
schedules = mkOption {
type = types.attrsOf (types.submodule ({name, ...}: {
options = {
enable = mkOption {
type = types.bool;
default = true;
description = "Whether this schedule is active.";
};
timer = mkOption {
type = types.str;
example = "hourly";
description = "Systemd timer unit specifier (e.g., hourly, daily, weekly).";
};
code = mkOption {
type = types.lines;
description = "Shell code to execute when the schedule fires.";
};
# Not properly enough tested
#customEnviorment = mkOption {
# type = types.lines;
# description = "to expose binaries or other things to the SystemD service";
# example = "ZSTD_BIN=${pkgs.zstd}/bin/zstd";
#};
};
}));
default = {};
description = "Scheduled jobs for this Minecraft server.";
};
};
}));
default = {};
description = "Servers to run under the enhanced Minecraft service.";
};
};
# Wrapper for nix-minecraft
config = mkIf cfg.enable {
services.minecraft-servers = {
enable = true;
eula = cfg.eula;
openFirewall = true;
user = cfg.user;
group = cfg.group;
dataDir = cfg.dataDir;
servers =
lib.mapAttrs (serverName: serverCfg: {
enable = serverCfg.enable;
package = serverCfg.package;
jvmOpts = "-Xmx${serverCfg.memory.max} -Xms${serverCfg.memory.min}";
autoStart = serverCfg.autoStart;
symlinks = serverCfg.symlinks;
whitelist = serverCfg.whitelist;
operators = serverCfg.operators;
serverProperties = {
enable-rcon = true;
enable-command-block = true;
allow-flight = true;
enable-query = true;
server-port = serverCfg.properties.serverPort;
difficulty = serverCfg.properties.difficulty;
gamemode = serverCfg.properties.gamemode;
max-players = serverCfg.properties.maxPlayers;
motd = serverCfg.properties.motd;
"rcon.password" = serverCfg.properties.rconPassword;
"rcon.port" = serverCfg.properties.serverPort + 100;
"query.port" = serverCfg.properties.serverPort + 200;
hardcore = serverCfg.properties.hardcore;
level-seed = serverCfg.properties.levelSeed;
};
})
cfg.servers;
};
# Schedule logic
systemd.services = lib.mkMerge (
lib.mapAttrsToList (
serverName: serverCfg:
lib.mapAttrs' (scheduleName: scheduleCfg: let
# yes this will be building the scripts twice but thsi
# way the path is accessible by the SystemD service
rconBin = mkScript serverName serverCfg "rcon";
queryBin = mkScript serverName serverCfg "query";
backupBin = mkScript serverName serverCfg "backup";
sayBin = mkScript serverName serverCfg "say";
routineBin = mkScript serverName serverCfg "backup-routine";
in {
name = "minecraft-${serverName}-${scheduleName}";
value = {
description = "Minecraft ${serverName} scheduled job: ${scheduleName}";
serviceConfig = {
Type = "oneshot";
User = cfg.user;
Group = cfg.group;
Environment = [
"RCON_BIN=${rconBin}/bin/minecraft-${serverName}-rcon"
"QUERY_BIN=${queryBin}/bin/minecraft-${serverName}-query"
"BACKUP_BIN=${backupBin}/bin/minecraft-${serverName}-backup"
"SAY_BIN=${sayBin}/bin/minecraft-${serverName}-say"
"ROUTINE_BIN=${routineBin}/bin/minecraft-${serverName}-backup-routine"
"ZSTD_BIN=${pkgs.zstd}/bin/zstd"
# add more bin here
# Not properly enough tested
#
#scheduleCfg.customEnviorment
];
ExecStart = pkgs.writeShellScript "minecraft-${serverName}-${scheduleName}.sh" ''
#!/usr/bin/env bash
echo "hi available helpers:"
echo "if you want any custom scripts or"
echo "packages for your script you need to ExecStart"
echo " $RCON_BIN"
echo " $QUERY_BIN"
echo " $BACKUP_BIN"
echo " $SAY_BIN"
echo " $ROUTINE_BIN"
# this is so it can use the scripts the same way you would run them:
minecraft-${serverName}-query() { $QUERY_BIN "$@"; }
minecraft-${serverName}-rcon() { $RCON_BIN "$@"; }
minecraft-${serverName}-backup() { $BACKUP_BIN "$@"; }
minecraft-${serverName}-say() { $SAY_BIN "$@"; }
minecraft-${serverName}-backup-routine() { $ROUTINE_BIN "$@"; }
minecraft-${serverName}-query
# her your code will go:
${scheduleCfg.code}
'';
};
# If you have this on:
# wantedBy = [ "multi-user.target" ];
# it will run the service on each rebuild.
# i dont want that you can enable it if you like
};
}) serverCfg.schedules
)
cfg.servers
);
# the timers to actually run the SystemD service
systemd.timers = lib.mkMerge (
lib.mapAttrsToList (
serverName: serverCfg:
lib.mapAttrs' (scheduleName: scheduleCfg: {
name = "minecraft-${serverName}-${scheduleName}";
value = {
description = "Timer for Minecraft ${serverName} schedule ${scheduleName}";
wantedBy = ["timers.target"];
timerConfig.OnCalendar = scheduleCfg.timer;
};
})
serverCfg.schedules
)
cfg.servers
);
# this is building the scripts for the user
# Those are the prewritten scripts from the ./Script dir
environment.systemPackages = lib.flatten (
lib.mapAttrsToList (serverName: serverCfg: [
(mkScript serverName serverCfg "rcon")
(mkScript serverName serverCfg "query")
(mkScript serverName serverCfg "backup")
(mkScript serverName serverCfg "say")
(mkScript serverName serverCfg "backup-routine")
])
cfg.servers
);
};
}