diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd5e95c --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +minecraftd-backup.service +minecraftd-backup.timer +minecraftd-backup@.service +minecraftd-backup@.timer +minecraftd-init@.service +minecraftd.conf +minecraftd.service +minecraftd.sh +minecraftd.sysusers +minecraftd.tmpfiles +minecraftd@.service diff --git a/Makefile b/Makefile index 6fd9c22..e6f2bb4 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ SHELL = /bin/sh INSTALL = install INSTALL_PROGRAM = $(INSTALL) -m755 +INSTALL_DIR = $(INSTALL) -d -m755 INSTALL_DATA = $(INSTALL) -m644 confdir = /etc/conf.d prefix = /usr @@ -10,27 +11,39 @@ datarootdir = $(prefix)/share mandir = $(prefix)/share/man man1dir = $(mandir)/man1 -SOURCES = minecraftd.sh.in minecraftd.conf.in minecraftd.service.in minecraftd.sysusers.in minecraftd.tmpfiles.in minecraftd-backup.service.in minecraftd-backup.timer.in +SOURCES = $(wildcard *.in) OBJECTS = $(SOURCES:.in=) GAME = minecraft INAME = minecraftd SERVER_ROOT = /srv/$(GAME) +CONFIG_PATH = /etc/conf.d/$(GAME) +SYSCONFDIR = /etc +INSTANCE_CONFIG_DIR = $(SYSCONFDIR)/$(GAME) BACKUP_DEST = $(SERVER_ROOT)/backup +LIBRARY_PATH = $(libdir)/$(GAME) BACKUP_PATHS = world BACKUP_FLAGS = -z KEEP_BACKUPS = 10 GAME_USER = $(GAME) +SERVER_MEMORY_INITIAL = 512 +SERVER_MEMORY_MAXIMUM = 1024 MAIN_EXECUTABLE = minecraft_server.jar +ifeq ($(MAIN_EXECUTABLE),$(MAIN_EXECUTABLE:/%=)) +MAIN_EXECUTABLE_ABSOLUTE = $(SERVER_ROOT)/$(MAIN_EXECUTABLE) +else +MAIN_EXECUTABLE_ABSOLUTE = $(MAIN_EXECUTABLE) +endif +TMUX_SOCKET_DIR = /run/$(GAME)/tmux SESSION_NAME = $(GAME) -SERVER_START_CMD = java -Xms512M -Xmx1024M -jar ./$${MAIN_EXECUTABLE} nogui +SERVER_START_CMD = java -Xms@SERVER_MEMORY_INITIAL@M -Xmx@SERVER_MEMORY_MAXIMUM@M -jar @MAIN_EXECUTABLE@ nogui SERVER_START_SUCCESS = done IDLE_SERVER = false -IDLE_SESSION_NAME = idle_server_$${SESSION_NAME} +IDLE_SESSION_NAME = idle_server_@SESSION_NAME@ GAME_PORT = 25565 CHECK_PLAYER_TIME = 30 IDLE_IF_TIME = 1200 -GAME_COMMAND_DUMP = /tmp/$${INAME}_$${SESSION_NAME}_command_dump.txt +GAME_COMMAND_DUMP = $(TMUX_SOCKET_DIR)/@INAME@_@SESSION_NAME@_command_dump.txt .MAIN = all @@ -40,12 +53,20 @@ define replace_all -e 's#@INAME@#$(INAME)#g' \ -e 's#@GAME@#$(GAME)#g' \ -e 's#@SERVER_ROOT@#$(SERVER_ROOT)#g' \ + -e 's#@CONFIG_PATH@#$(CONFIG_PATH)#g' \ + -e 's#@LIBRARY_PATH@#$(LIBRARY_PATH)#g' \ + -e 's#@SYSCONFDIR@#$(SYSCONFDIR)#g' \ + -e 's#@INSTANCE_CONFIG_DIR@#$(INSTANCE_CONFIG_DIR)#g' \ -e 's#@BACKUP_DEST@#$(BACKUP_DEST)#g' \ -e 's#@BACKUP_PATHS@#$(BACKUP_PATHS)#g' \ -e 's#@BACKUP_FLAGS@#$(BACKUP_FLAGS)#g' \ -e 's#@KEEP_BACKUPS@#$(KEEP_BACKUPS)#g' \ -e 's#@GAME_USER@#$(GAME_USER)#g' \ -e 's#@MAIN_EXECUTABLE@#$(MAIN_EXECUTABLE)#g' \ + -e 's#@MAIN_EXECUTABLE_ABSOLUTE@#$(MAIN_EXECUTABLE_ABSOLUTE)#g' \ + -e 's#@SERVER_MEMORY_INITIAL@#$(SERVER_MEMORY_INITIAL)#g' \ + -e 's#@SERVER_MEMORY_MAXIMUM@#$(SERVER_MEMORY_MAXIMUM)#g' \ + -e 's#@TMUX_SOCKET_DIR@#$(TMUX_SOCKET_DIR)#g' \ -e 's#@SESSION_NAME@#$(SESSION_NAME)#g' \ -e 's#@SERVER_START_CMD@#$(SERVER_START_CMD)#g' \ -e 's#@SERVER_START_SUCCESS@#$(SERVER_START_SUCCESS)#g' \ @@ -88,12 +109,18 @@ maintainer-clean: clean install: $(INSTALL_PROGRAM) -D minecraftd.sh "$(DESTDIR)$(bindir)/$(INAME)" - $(INSTALL_DATA) -D minecraftd.conf "$(DESTDIR)$(confdir)/$(GAME)" - $(INSTALL_DATA) -D minecraftd.service "$(DESTDIR)$(libdir)/systemd/system/$(INAME).service" - $(INSTALL_DATA) -D minecraftd-backup.service "$(DESTDIR)$(libdir)/systemd/system/$(INAME)-backup.service" - $(INSTALL_DATA) -D minecraftd-backup.timer "$(DESTDIR)$(libdir)/systemd/system/$(INAME)-backup.timer" - $(INSTALL_DATA) -D minecraftd.sysusers "$(DESTDIR)$(libdir)/sysusers.d/$(INAME).conf" - $(INSTALL_DATA) -D minecraftd.tmpfiles "$(DESTDIR)$(libdir)/tmpfiles.d/$(INAME).conf" + $(INSTALL_DIR) "$(DESTDIR)$(LIBRARY_PATH)" + $(INSTALL_DATA) -D argparse.sh "$(DESTDIR)$(LIBRARY_PATH)/argparse.sh" + $(INSTALL_DATA) -D minecraftd.conf "$(DESTDIR)$(confdir)/$(GAME)" + $(INSTALL_DATA) -D minecraftd.service "$(DESTDIR)$(libdir)/systemd/system/$(INAME).service" + $(INSTALL_DATA) -D minecraftd-backup.service "$(DESTDIR)$(libdir)/systemd/system/$(INAME)-backup.service" + $(INSTALL_DATA) -D minecraftd-backup.timer "$(DESTDIR)$(libdir)/systemd/system/$(INAME)-backup.timer" + $(INSTALL_DATA) -D minecraftd@.service "$(DESTDIR)$(libdir)/systemd/system/$(INAME)@.service" + $(INSTALL_DATA) -D minecraftd-init@.service "$(DESTDIR)$(libdir)/systemd/system/$(INAME)-init@.service" + $(INSTALL_DATA) -D minecraftd-backup@.service "$(DESTDIR)$(libdir)/systemd/system/$(INAME)-backup@.service" + $(INSTALL_DATA) -D minecraftd-backup@.timer "$(DESTDIR)$(libdir)/systemd/system/$(INAME)-backup@.timer" + $(INSTALL_DATA) -D minecraftd.sysusers "$(DESTDIR)$(libdir)/sysusers.d/$(INAME).conf" + $(INSTALL_DATA) -D minecraftd.tmpfiles "$(DESTDIR)$(libdir)/tmpfiles.d/$(INAME).conf" uninstall: rm -f "$(bindir)/$(INAME)" diff --git a/README.md b/README.md index e28aef7..d7d08d5 100644 --- a/README.md +++ b/README.md @@ -61,13 +61,42 @@ Use the minecraft script under /usr/bin/minecraftd to start, stop or backup the ### How to configure the Server -Adjust the configuration file under /etc/conf.d/minecraft to your liking. +Adjust the configuration file under `/etc/conf.d/minecraft` to your liking. + +If you are running multiple servers, the configuration file `/etc/minecraft/` will be loaded if it exists, and will supersede any options set in the global configuration. + +Any configuration variable can be overridden in the environment. + +To see the effective configuration based on the instance name and all applicable configuration files, run `minecraftd print`, `minecraftd -i server2 print`, etc. To print the plain value of a single configuration key, use the `-k` argument: `minecraftd print -k SERVER_ROOT`. ### Server does not start For the server to start you have to accept the EULA in /srv/minecraft/eula.txt ! The EULA file is generated after the first server start. +### Running multiple servers on the same host + +The generated file `minecraftd@.service` allows you to run than one instance of the server on a single host. A unique data directory (`/srv/minecraft/servers/`) is created for each instance. + +You need to create, at minimum, `eula.txt` and `server.properties` (the latter specifying an alternate `server-port`) before the server will start. This means the workflow for setting up a new server looks something like: + +```sh +systemctl start minecraftd@server-name # (creates directories; the service will fail to start, that's ok) +sed -re 's;^eula=.*$;eula=true;' -i /srv/minecraft/servers/server-name/eula.txt +sed -re 's;^(server-port|query\.port)=.*$;\1=25567;g' /srv/minecraft/servers/server-name/server.properties +systemctl start minecraftd@server-name # (it should start this time) +``` + +To facilitate the use of a unique environment variable file per instance, the file `/etc/minecraft/` is read in addition to the global environment file, `/etc/conf.d/minecraft`. Note that `minecraft` is the value of the `@GAME@` macro in this case, so change this to `/etc/spigot/`, etc. Values in the instance-specific environment file take precedence over values in the global configuration. + +The configuration variables whose defaults change in instantiated mode are: + +* `SERVER_ROOT` changes from `/srv/minecraft` to `/srv/minecraft/servers/` +* `SESSION_NAME` changes from `minecraft` to `minecraft-` +* `BACKUP_DEST` changes from `/srv/minecraft/backup` to `/srv/minecraft/servers//backup` + +These can also be overridden in the instance-specific configuration file, `/etc/minecraft/`. + ## License Unless otherwise stated, the files in this project may be distributed under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or any later version. This work is distributed in the hope that it will be useful, but without any warranty; without even the implied warranty of merchantability or fitness for a particular purpose. See [version 2](https://www.gnu.org/licenses/old-licenses/gpl-2.0.html) and [version 3](https://www.gnu.org/copyleft/gpl-3.0.html) of the GNU General Public License for more details. diff --git a/argparse.sh b/argparse.sh new file mode 100755 index 0000000..8269b31 --- /dev/null +++ b/argparse.sh @@ -0,0 +1,189 @@ +#!/bin/bash + +# shell script library to parse and validate command line arguments, and generate the --help text. + +set -u + +re_subcmd_vn='^[a-z0-9]+:[A-Za-z0-9_]+$' +COMMAND= + +declare -A args args_help subcmds + +add_arg() { + local short="$1" + local long="$2" + local varname="$3" + local help="$4" + local required="${5:-false}" + [ -v 6 ] && local default="$6" + if [[ "$varname" =~ $re_subcmd_vn ]]; then + local subc _vn + IFS=":" read subc _vn <<< "$varname" + [[ -n "${subcmds[$subc]}" ]] + fi + args[$varname]="$short $long $required" + args_help[$varname]="$help" + # init the global + [[ -v default ]] && declare -g ${varname#*:}="${default}" +} + +add_subcommand() { + local subc="$1" + local help="$2" + + subcmds[$subc]="$help" +} + +usage() { + # help function. generates usage instructions + local short long required help + local n_subcmds=${#subcmds[@]} + local ofd=1 + [[ -v 1 ]] && ofd=2 + local cols=80 + [[ -t $ofd ]] && cols=$(tput cols) + ( + if [[ -v 1 ]]; then + echo "ERROR: $1" + echo "" + fi + if [[ -v DESCRIPTION ]]; then + echo -e "$DESCRIPTION" + echo "" + fi + if [[ $n_subcmds < 1 ]]; then + echo "Usage: $0 [options]" + echo "" + echo "Valid options are:" + else + echo "Usage: $0 [global options] COMMAND [command-specific options]" + echo "" + echo "Global options:" + fi + for varname in ${!args[@]}; do + [[ $varname =~ $re_subcmd_vn ]] && continue + IFS=" " read short long required <<< "${args[$varname]}" + help="${args_help[$varname]}" + printf " -%s, --%-16s %s (required: %s)\n" "$short" "$long" "$help" "$required" + done + + if [[ $n_subcmds > 0 ]]; then + echo "" + echo "Valid commands:" + for subcmd in ${!subcmds[@]}; do + printf " %-12s %s\n" "$subcmd" "${subcmds[$subcmd]}" + done + + for subcmd in ${!subcmds[@]}; do + echo "" + echo "Options for command \"$subcmd\":" + local n=0 + for varname in ${!args[@]}; do + [[ "${varname%:*}" == "$subcmd" ]] || continue + ((n++)) + IFS=" " read short long required <<< "${args[$varname]}" + help="${args_help[$varname]}" + printf " -%s, --%-16s %s (required: %s)\n" "$short" "$long" "$help" "$required" + done + [[ $n == 0 ]] && echo " (None)" + done + echo "" + + [[ -v COPYRIGHT ]] && echo -e "$COPYRIGHT" + fi + ) | fold -w "$cols" -s >&$ofd + exit 1 +} + +parse_args() { + local short long required longprefix found + local n_subcmds=${#subcmds[@]} + declare -a bareargs reqargs + while [[ -v 1 ]]; do + case "$1" in + --help|-h) + usage ;; + -*) + # parse as named option + found=false + for varname in ${!args[@]}; do + IFS=" " read short long required <<< "${args[$varname]}" + if [[ $varname =~ $re_subcmd_vn ]]; then + # subcommand option + local vsubc _vn + IFS=":" read vsubc _vn <<< "$varname" + [[ "$vsubc" = "$COMMAND" ]] || continue + varname=$_vn + fi + longprefix="--$long=" + case "$1" in + -$short|--$long) + [[ -v 2 ]] || usage "value for argument $1 may not be omitted" + declare -g $varname="$2" + found=true + shift + ;; + $longprefix*) + found=true + declare -g $varname=${1:${#longprefix}} + ;; + esac + done + [[ "$found" == "true" ]] || usage "Unknown option: $1" + ;; + *) + if [[ $n_subcmds > 0 && $COMMAND == "" ]]; then + COMMAND="$1" + [[ -v subcmds[$COMMAND] ]] || usage "Undefined command: $COMMAND" + else + bareargs+=("$1") + fi + ;; + esac + shift + done + + [[ $n_subcmds > 0 && $COMMAND == "" ]] && usage "No command was given." + + # build list of missing required args + for varname in ${!args[@]}; do + IFS=" " read short long required <<< "${args[$varname]}" + [[ "$required" == "true" ]] || continue + if [[ $varname =~ $re_subcmd_vn ]]; then + # subcommand option + local vsubc _vn + IFS=":" read vsubc _vn <<< "$varname" + [[ "$vsubc" = "$COMMAND" ]] || continue + [[ -v $_vn ]] && continue + reqargs+=("$varname") + else + [[ -v $varname ]] && continue + reqargs+=("$varname") + fi + done + # process bareword args + while [[ -v bareargs[0] && -v reqargs[0] ]]; do + declare -g ${reqargs[0]#*:}="${bareargs[0]}" + bareargs=("${bareargs[@]:1}") + reqargs=("${reqargs[@]:1}") + done + + # if there's any bareword arguments left, we are out of ideas for what to do with them, so fail. + [[ -v bareargs[0] ]] && usage "Unknown argument: ${bareargs[0]}" + + # enforce required args + for varname in ${!args[@]}; do + IFS=" " read short long required <<< "${args[$varname]}" + [[ "$required" == "true" ]] || continue + if [[ $varname =~ $re_subcmd_vn ]]; then + # subcommand arg + local vsubc _vn + IFS=":" read vsubc _vn <<< "$varname" + [[ "$vsubc" == "$COMMAND" ]] || continue + [[ ! -v $_vn ]] && usage "Option is required for command $vsubc but not set: $long" + else + # global arg + [[ ! -v $varname ]] && usage "Required option or positional argument not set: $long" + fi + done +} diff --git a/minecraftd-backup@.service.in b/minecraftd-backup@.service.in new file mode 100644 index 0000000..933481a --- /dev/null +++ b/minecraftd-backup@.service.in @@ -0,0 +1,16 @@ +[Unit] +Description=@GAME@ Server World Backup +After=local-fs.target + +[Service] +Type=oneshot +Environment=SERVER_ROOT=@SERVER_ROOT@/servers/%i +Environment=BACKUP_DEST=@SERVER_ROOT@/servers/%i/backup +Environment=SESSION_NAME=@GAME@-%i +EnvironmentFile=-@INSTANCE_CONFIG_DIR@/%i +ExecStart=/usr/bin/@INAME@ backup +User=@GAME_USER@ +Group=@GAME_USER@ + +[Install] +WantedBy=multi-user.target diff --git a/minecraftd-backup@.timer.in b/minecraftd-backup@.timer.in new file mode 100644 index 0000000..1ba98d0 --- /dev/null +++ b/minecraftd-backup@.timer.in @@ -0,0 +1,10 @@ +[Unit] +Description=Daily @GAME@ Server Backup + +[Timer] +OnCalendar=daily +AccuracySec=5min +Persistent=true + +[Install] +WantedBy=multi-user.target diff --git a/minecraftd-init@.service.in b/minecraftd-init@.service.in new file mode 100644 index 0000000..fce9707 --- /dev/null +++ b/minecraftd-init@.service.in @@ -0,0 +1,12 @@ +[Unit] +Description=Create server directories for @GAME@ %i instance +After=local-fs.target network.target multi-user.target + +[Service] +Type=oneshot +RemainAfterExit=no +# create the "servers" directory followed by the instance directory +ExecStart=/usr/bin/@INAME@ -i %i init + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/minecraftd.conf.in b/minecraftd.conf.in index bad0198..1b03de8 100644 --- a/minecraftd.conf.in +++ b/minecraftd.conf.in @@ -8,7 +8,7 @@ BACKUP_PATHS="@BACKUP_PATHS@" # World paths separated by spaces relative to SERV BACKUP_FLAGS="@BACKUP_FLAGS@" KEEP_BACKUPS="@KEEP_BACKUPS@" GAME_USER="@GAME_USER@" -MAIN_EXECUTABLE="@MAIN_EXECUTABLE@" +MAIN_EXECUTABLE="@MAIN_EXECUTABLE_ABSOLUTE@" SESSION_NAME="@SESSION_NAME@" # System parameters for java diff --git a/minecraftd.service.in b/minecraftd.service.in index 5c1d27f..05f5315 100644 --- a/minecraftd.service.in +++ b/minecraftd.service.in @@ -8,6 +8,15 @@ ExecStart=/usr/bin/@INAME@ start ExecStop=/usr/bin/@INAME@ stop User=@GAME_USER@ Group=@GAME_USER@ +ProtectSystem=strict +ProtectHome=yes +ReadOnlyPaths=/usr /bin /lib -/lib64 @CONFIG_PATH@ +ReadWritePaths=@SERVER_ROOT@ +# Prevent the minecraft server itself from tampering with backups. This means a +# compromised server would not be able to delete backups. +InaccessiblePaths=-@SERVER_ROOT@/backup +PrivateTmp=yes +BindPaths=@TMUX_SOCKET_DIR@ [Install] WantedBy=multi-user.target diff --git a/minecraftd.sh.in b/minecraftd.sh.in index 9be860a..b3dcee9 100755 --- a/minecraftd.sh.in +++ b/minecraftd.sh.in @@ -3,44 +3,143 @@ # The actual program name (name of the interface) declare -r INAME="@INAME@" declare -r GAME="@GAME@" +declare -r LIBRARY_PATH="@LIBRARY_PATH@" + +test -r "$LIBRARY_PATH/argparse.sh" || (echo "Cannot find argparse.sh in $LIBRARY_PATH" >&2; exit 1) + +# Note: this calls "set -u" to disallow the use of uninitialized variables. +source "$LIBRARY_PATH/argparse.sh" + +DESCRIPTION="This script was designed to easily control any ${GAME} server. Almost\n" +DESCRIPTION+="any parameter for a given ${GAME} server derivative can be changed by\n" +DESCRIPTION+="editing the variables in the configuration file." + +COPYRIGHT="Report bugs to .\n" +COPYRIGHT+="Copyright (c) Gordian Edenhofer \n" +COPYRIGHT+="and contributors (see: https://github.com/Edenhofer/minecraft-server/graphs/contributors)\n" +COPYRIGHT+="\n" +COPYRIGHT+="This program is free software; you can redistribute it and/or\n" +COPYRIGHT+="modify it under the terms of the GNU General Public License\n" +COPYRIGHT+="as published by the Free Software Foundation; either version 2\n" +COPYRIGHT+="of the License, or (at your option) any later version.\n" +COPYRIGHT+="\n" +COPYRIGHT+="For details, see: https://github.com/Edenhofer/minecraft-server#license" + +add_subcommand start "Start the server instance." +add_subcommand stop "Stop the server instance." +add_subcommand status "Check status of a running server instance." +add_subcommand restart "Restart the server instance." +add_subcommand console "Attach to the server console. Press Ctrl+B and then Ctrl+D to detach (by default; your tmux settings may be different)." +add_subcommand command "Run a command on the server console and report the result." +add_subcommand backup "Immediately take a backup of the server. Backups are written to the directory specified in the BACKUP_DEST configuration variable." +add_subcommand restore "Restore a server backup from an archive file." +add_subcommand print "Print a variable from the configuration." +add_subcommand init "Create directories for the server." +add_subcommand idle_server_daemon "Internal use only" + +add_arg i instance SERVER_INSTANCE "Server instance. Multiple instances of @GAME@ can run concurrently. If omitted, the default instance is used." false +add_arg c command command:SERVER_COMMAND "Command to run" true +add_arg a archive restore:ARCHIVE_PATH "Path to the backup archive to restore." true +add_arg k key print:CONFIG_VAR "Configuration variable to print (SERVER_ROOT, SESSION_NAME, etc.). If omitted, prints the entire configuration." false + +parse_args "$@" # General rule for the variable-naming-schema: # Variables in capital letters may be passed through the command line others not. # Avoid altering any of those later in the code since they may be readonly (IDLE_SERVER is an exception!) # You may use this script for any game server of your choice, just alter the config file -[[ -n "${SERVER_ROOT}" ]] && declare -r SERVER_ROOT=${SERVER_ROOT} || SERVER_ROOT="@SERVER_ROOT@" -[[ -n "${BACKUP_DEST}" ]] && declare -r BACKUP_DEST=${BACKUP_DEST} || BACKUP_DEST="@BACKUP_DEST@" -[[ -n "${BACKUP_PATHS}" ]] && declare -r BACKUP_PATHS=${BACKUP_PATHS} || BACKUP_PATHS="@BACKUP_PATHS@" -[[ -n "${BACKUP_FLAGS}" ]] && declare -r BACKUP_FLAGS=${BACKUP_FLAGS} || BACKUP_FLAGS="@BACKUP_FLAGS@" -[[ -n "${KEEP_BACKUPS}" ]] && declare -r KEEP_BACKUPS=${KEEP_BACKUPS} || KEEP_BACKUPS="@KEEP_BACKUPS@" -[[ -n "${GAME_USER}" ]] && declare -r GAME_USER=${GAME_USER} || GAME_USER="@GAME_USER@" -[[ -n "${MAIN_EXECUTABLE}" ]] && declare -r MAIN_EXECUTABLE=${MAIN_EXECUTABLE} || MAIN_EXECUTABLE="@MAIN_EXECUTABLE@" -[[ -n "${SESSION_NAME}" ]] && declare -r SESSION_NAME=${SESSION_NAME} || SESSION_NAME="@SESSION_NAME@" +declare -A default_config +default_config[SESSION_NAME]="@SESSION_NAME@" +default_config[SERVER_ROOT]="@SERVER_ROOT@" +default_config[CONFIG_PATH]="@CONFIG_PATH@" +default_config[INSTANCE_CONFIG_DIR]="@INSTANCE_CONFIG_DIR@" +default_config[BACKUP_DEST]="@BACKUP_DEST@" +default_config[BACKUP_PATHS]="@BACKUP_PATHS@" +default_config[BACKUP_FLAGS]="@BACKUP_FLAGS@" +default_config[KEEP_BACKUPS]="@KEEP_BACKUPS@" +default_config[GAME_USER]="@GAME_USER@" +default_config[MAIN_EXECUTABLE]="@MAIN_EXECUTABLE_ABSOLUTE@" +default_config[SESSION_NAME]="@SESSION_NAME@" # Command and parameter declaration with which to start the server -[[ -n "${SERVER_START_CMD}" ]] && declare -r SERVER_START_CMD=${SERVER_START_CMD} || SERVER_START_CMD="@SERVER_START_CMD@" -[[ -n "${SERVER_START_SUCCESS}" ]] && declare -r SERVER_START_SUCCESS=${SERVER_START_SUCCESS} || SERVER_START_SUCCESS="@SERVER_START_SUCCESS@" +default_config[SERVER_MEMORY_INITIAL]="@SERVER_MEMORY_INITIAL@" +default_config[SERVER_MEMORY_MAXIMUM]="@SERVER_MEMORY_MAXIMUM@" +default_config[SERVER_START_CMD]="@SERVER_START_CMD@" +default_config[SERVER_START_SUCCESS]="@SERVER_START_SUCCESS@" # System parameters for the control script -[[ -n "${IDLE_SERVER}" ]] && tmp_IDLE_SERVER=${IDLE_SERVER} || IDLE_SERVER="@IDLE_SERVER@" -[[ -n "${IDLE_SESSION_NAME}" ]] && declare -r IDLE_SESSION_NAME=${IDLE_SESSION_NAME} || IDLE_SESSION_NAME="@IDLE_SESSION_NAME@" -[[ -n "${GAME_PORT}" ]] && declare -r GAME_PORT=${GAME_PORT} || GAME_PORT="@GAME_PORT@" -[[ -n "${CHECK_PLAYER_TIME}" ]] && declare -r CHECK_PLAYER_TIME=${CHECK_PLAYER_TIME} || CHECK_PLAYER_TIME="@CHECK_PLAYER_TIME@" -[[ -n "${IDLE_IF_TIME}" ]] && declare -r IDLE_IF_TIME=${IDLE_IF_TIME} || IDLE_IF_TIME="@IDLE_IF_TIME@" +default_config[IDLE_SERVER]="@IDLE_SERVER@" +default_config[IDLE_SESSION_NAME]="@IDLE_SESSION_NAME@" +default_config[GAME_PORT]="@GAME_PORT@" +default_config[CHECK_PLAYER_TIME]="@CHECK_PLAYER_TIME@" +default_config[IDLE_IF_TIME]="@IDLE_IF_TIME@" # Additional configuration options which only few may need to alter -[[ -n "${GAME_COMMAND_DUMP}" ]] && declare -r GAME_COMMAND_DUMP=${GAME_COMMAND_DUMP} || GAME_COMMAND_DUMP="@GAME_COMMAND_DUMP@" +default_config[GAME_COMMAND_DUMP]="@GAME_COMMAND_DUMP@" + +# Variables whose defaults get overridden in instantiated mode. %i is substituted with the instance name. +declare -A instance_config +instance_config[SESSION_NAME]="@GAME@-%i" +instance_config[SERVER_ROOT]="@SERVER_ROOT@/servers/%i" +instance_config[BACKUP_DEST]="@SERVER_ROOT@/servers/%i/backup" + +# Variables set through the environment can never be overridden by config files. +declare -A environment_overrides +# Variables set through instance_config can be overridden by the instance config file, but not by the +# global configuration file. +declare -A instance_overrides + +# Gather environment variables and make note of overrides. +for var in ${!default_config[@]}; do + if [[ -v $var ]]; then + echo "Configuration variable overridden by environment: ${var}=${!var}" >&2 + environment_overrides[$var]="${!var}" + continue + fi + + if [[ -v SERVER_INSTANCE && -v instance_config[$var] ]]; then + instance_overrides[$var]="${instance_config[$var]//%i/${SERVER_INSTANCE}}" + else + declare $var="${default_config[$var]}" + fi +done # Variables passed over the command line will always override the one from a config file -source /etc/conf.d/"${GAME}" 2>/dev/null || >&2 echo "Could not source /etc/conf.d/${GAME}" +if test -r "${CONFIG_PATH}"; then + source "${CONFIG_PATH}" || (echo "Could not source ${CONFIG_PATH}" >&2) +fi -# Preserve the content of IDLE_SERVER without making it readonly -[[ -n ${tmp_IDLE_SERVER} ]] && IDLE_SERVER=${tmp_IDLE_SERVER} +# Restore config variables that are always instance-specific. +for var in ${!instance_overrides[@]}; do + declare $var="${instance_overrides[$var]}" +done +# Load the instance-specific configuration if it exists. +if [[ -v SERVER_INSTANCE ]]; then + INSTANCE_CONFIG_PATH="${INSTANCE_CONFIG_DIR}/${SERVER_INSTANCE}" + if test -r "${INSTANCE_CONFIG_PATH}"; then + source "${INSTANCE_CONFIG_PATH}" || (echo "Could not source ${INSTANCE_CONFIG_PATH}" >&2) + fi +fi + +# Save the instance argument for launches of e.g. the idle server +INSTANCE_ARG="" +if [[ -v SERVER_INSTANCE ]]; then + INSTANCE_ARG="--instance=${SERVER_INSTANCE}" +fi + +# Expand macros in the config and set final values. +# These are set to readonly so they cannot be accidentally changed later. +for var in ${!default_config[@]}; do + value="${environment_overrides[$var]:-${!var}}" + for ivar in ${!default_config[@]} INAME GAME_USER; do + value="${value//@$ivar@/${!ivar}}" + done + + declare -r $var="$value" +done -# Strictly disallow uninitialized Variables -set -u # Exit if a single command breaks and its failure is not handled accordingly set -e @@ -53,6 +152,23 @@ else SUDO_CMD="sudo -u ${GAME_USER}" fi +# We use an alternate tmux socket directory because tmux's default socket +# directory includes the uid. The systemd sandboxing uses a private /tmp, +# so to expose the tmux socket directory to the host we need to change to +# a socket path that uses the user id. +GAME_USER_UID=$(id -u ${GAME_USER}) +LEGACY_TMUX_SOCKET_PATH="/tmp/tmux-${GAME_USER_UID}/${SESSION_NAME}" +TMUX_SOCKET_PATH="@TMUX_SOCKET_DIR@/${SESSION_NAME}" +# Look for the old socket path prior to version upgrade. If we find it, +# and there's nothing running on the new path yet, use the old path so we +# can still safely stop the old server, send commands, etc. +if [[ -S "${LEGACY_TMUX_SOCKET_PATH}" && ! -S "${TMUX_SOCKET_PATH}" ]]; then + TMUX_CMD="${SUDO_CMD} tmux -S ${LEGACY_TMUX_SOCKET_PATH}" +else + test -d `dirname "${TMUX_SOCKET_PATH}"` || mkdir -p `dirname "${TMUX_SOCKET_PATH}"` + TMUX_CMD="${SUDO_CMD} tmux -S ${TMUX_SOCKET_PATH}" +fi + # Choose which flavor of netcat is to be used if command -v netcat &> /dev/null; then NETCAT_CMD="netcat" @@ -62,26 +178,28 @@ else NETCAT_CMD="" fi -# Check for sudo rigths -if [[ "$(${SUDO_CMD} whoami)" != "${GAME_USER}" ]]; then - >&2 echo -e "You have \e[39;1mno permission\e[0m to run commands as $GAME_USER user." - exit 21 +# Check for sudo rights +if [[ "${COMMAND}" != "print" ]]; then + if [[ "$(${SUDO_CMD} whoami)" != "${GAME_USER}" ]]; then + >&2 echo -e "You have \e[39;1mno permission\e[0m to run commands as $GAME_USER user." + exit 21 + fi fi # Pipe any given argument to the game server console, # sleep for $sleep_time and return its output if $return_stdout is set game_command() { - ${SUDO_CMD} tmux -L "${SESSION_NAME}" wait-for -L "command_lock" + ${TMUX_CMD} wait-for -L "command_lock" if [[ -z "${return_stdout:-}" ]]; then - ${SUDO_CMD} tmux -L "${SESSION_NAME}" send-keys -t "${SESSION_NAME}":0.0 "$*" Enter + ${TMUX_CMD} send-keys -t "${SESSION_NAME}":0.0 "$*" Enter else - ${SUDO_CMD} tmux -L "${SESSION_NAME}" pipe-pane -t "${SESSION_NAME}":0.0 "cat > ${GAME_COMMAND_DUMP}" - ${SUDO_CMD} tmux -L "${SESSION_NAME}" send-keys -t "${SESSION_NAME}":0.0 "$*" Enter + ${TMUX_CMD} pipe-pane -t "${SESSION_NAME}":0.0 "cat > ${GAME_COMMAND_DUMP}" + ${TMUX_CMD} send-keys -t "${SESSION_NAME}":0.0 "$*" Enter sleep "${sleep_time:-0.3}" - ${SUDO_CMD} tmux -L "${SESSION_NAME}" pipe-pane -t "${SESSION_NAME}":0.0 + ${TMUX_CMD} pipe-pane -t "${SESSION_NAME}":0.0 ${SUDO_CMD} cat "${GAME_COMMAND_DUMP}" fi - ${SUDO_CMD} tmux -L "${SESSION_NAME}" wait-for -U "command_lock" + ${TMUX_CMD} wait-for -U "command_lock" } # Check whether there are player on the server through list @@ -125,7 +243,7 @@ idle_server_daemon() { if socket_has_session "${SESSION_NAME}"; then # Game server is up and running # Check for active player - if [[ -n "$(tmux -L "${SESSION_NAME}" list-clients -t "${SESSION_NAME}":0.0 2> /dev/null)" ]]; then + if [[ -n "$(${TMUX_CMD} list-clients -t "${SESSION_NAME}":0.0 2> /dev/null)" ]]; then # An administrator is connected to the console, pause player checking echo "An admin is connected to the console. Pause player checking." elif SUDO_CMD="" is_player_online; then @@ -133,7 +251,7 @@ idle_server_daemon() { no_player=$(( no_player + CHECK_PLAYER_TIME )) # Stop the game server if no player was active for at least ${IDLE_IF_TIME} if [[ "${no_player}" -ge "${IDLE_IF_TIME}" ]]; then - IDLE_SERVER="false" ${INAME} stop + env IDLE_SERVER="false" ${INAME} ${INSTANCE_ARG} stop # Wait for game server to go down for i in {1..100}; do socket_has_session "${SESSION_NAME}" || break @@ -146,7 +264,7 @@ idle_server_daemon() { echo -n "Netcat: " ${NETCAT_CMD} -v -l -p ${GAME_PORT} 2>&1 | (grep -m1 -i "connect" && pkill -P $$ ${NETCAT_CMD}) || true echo "Netcat caught a connection. The server is coming up again..." - IDLE_SERVER="false" ${INAME} start + env IDLE_SERVER="false" ${INAME} ${INSTANCE_ARG} start fi else # Reset timer since there is an active player on the server @@ -159,7 +277,7 @@ idle_server_daemon() { echo -n "Netcat: " ${NETCAT_CMD} -v -l -p ${GAME_PORT} 2>&1 | (grep -m1 -i "connect" && pkill -P $$ ${NETCAT_CMD}) || true echo "Netcat caught a connection. The server is coming up again..." - IDLE_SERVER="false" ${INAME} start + env IDLE_SERVER="false" ${INAME} ${INSTANCE_ARG} start fi done } @@ -175,12 +293,12 @@ server_start() { # Use a plain file as command buffers for the server startup and switch to a FIFO pipe later ${SUDO_CMD} touch "${GAME_COMMAND_DUMP}" # Ensure pipe-pine is started before the server itself by splitting the session creation and server startup - ${SUDO_CMD} tmux -L "${SESSION_NAME}" new-session -s "${SESSION_NAME}" -c "${SERVER_ROOT}" -d /bin/bash + ${TMUX_CMD} new-session -s "${SESSION_NAME}" -c "${SERVER_ROOT}" -d /bin/bash # Mimic GNU screen and allow for both C-a and C-b as prefix - ${SUDO_CMD} tmux -L "${SESSION_NAME}" set -g prefix2 C-a - ${SUDO_CMD} tmux -L "${SESSION_NAME}" wait-for -L "command_lock" - ${SUDO_CMD} tmux -L "${SESSION_NAME}" pipe-pane -t "${SESSION_NAME}":0.0 "cat > ${GAME_COMMAND_DUMP}" - ${SUDO_CMD} tmux -L "${SESSION_NAME}" send-keys -t "${SESSION_NAME}":0.0 "exec ${SERVER_START_CMD}" Enter + ${TMUX_CMD} set -g prefix2 C-a + ${TMUX_CMD} wait-for -L "command_lock" + ${TMUX_CMD} pipe-pane -t "${SESSION_NAME}":0.0 "cat > ${GAME_COMMAND_DUMP}" + ${TMUX_CMD} send-keys -t "${SESSION_NAME}":0.0 "exec ${SERVER_START_CMD}" Enter for ((i=1; i<=MAX_SERVER_START_TIME; i++)); do sleep "${sleep_time:-0.1}" if ! socket_session_is_alive "${SESSION_NAME}"; then @@ -188,7 +306,7 @@ server_start() { >&2 ${SUDO_CMD} cat "${GAME_COMMAND_DUMP}" ${SUDO_CMD} rm -f "${GAME_COMMAND_DUMP}" # Session is dead but remain-on-exit left it open; close it for sure - ${SUDO_CMD} tmux -L "${SESSION_NAME}" kill-session -t "${SESSION_NAME}" + ${TMUX_CMD} kill-session -t "${SESSION_NAME}" exit 1 elif grep -q -i "${SERVER_START_SUCCESS}" "${GAME_COMMAND_DUMP}"; then echo -e "\e[39;1m done\e[0m" @@ -198,11 +316,11 @@ server_start() { >&2 echo -e "Server startup has not finished yet; continuing anyways" fi done - ${SUDO_CMD} tmux -L "${SESSION_NAME}" pipe-pane -t "${SESSION_NAME}":0.0 + ${TMUX_CMD} pipe-pane -t "${SESSION_NAME}":0.0 # Let the command buffer be a FIFO pipe ${SUDO_CMD} rm -f "${GAME_COMMAND_DUMP}" ${SUDO_CMD} mkfifo "${GAME_COMMAND_DUMP}" - ${SUDO_CMD} tmux -L "${SESSION_NAME}" wait-for -U "command_lock" + ${TMUX_CMD} wait-for -U "command_lock" fi if [[ "${IDLE_SERVER,,}" == "true" ]]; then @@ -214,19 +332,19 @@ server_start() { # Start the idle server daemon if socket_has_session "${IDLE_SESSION_NAME}"; then - ${SUDO_CMD} tmux -L "${SESSION_NAME}" kill-session -t "${IDLE_SESSION_NAME}" + ${TMUX_CMD} kill-session -t "${IDLE_SESSION_NAME}" # Restart as soon as the idle_server_daemon has shut down completely for i in {1..100}; do sleep 0.1 if ! socket_has_session "${IDLE_SESSION_NAME}"; then - ${SUDO_CMD} tmux -L "${SESSION_NAME}" new-session -s "${IDLE_SESSION_NAME}" -d "${INAME} idle_server_daemon" + ${TMUX_CMD} new-session -s "${IDLE_SESSION_NAME}" -d "${INAME} ${INSTANCE_ARG} idle_server_daemon" break fi [[ $i -eq 100 ]] && echo -e "An \e[39;1merror\e[0m occurred while trying to reset the idle_server!" done else echo -en "Starting idle server daemon..." - ${SUDO_CMD} tmux -L "${SESSION_NAME}" new-session -s "${IDLE_SESSION_NAME}" -d "${INAME} idle_server_daemon" + ${TMUX_CMD} new-session -s "${IDLE_SESSION_NAME}" -d "${INAME} ${INSTANCE_ARG} idle_server_daemon" echo -e "\e[39;1m done\e[0m" fi fi @@ -244,7 +362,7 @@ server_stop() { if socket_has_session "${IDLE_SESSION_NAME}"; then echo -en "Stopping idle server daemon..." - ${SUDO_CMD} tmux -L "${SESSION_NAME}" kill-session -t "${IDLE_SESSION_NAME}" + ${TMUX_CMD} kill-session -t "${IDLE_SESSION_NAME}" echo -e "\e[39;1m done\e[0m" else echo "The corresponding tmux session for ${IDLE_SESSION_NAME} was already dead." @@ -449,7 +567,7 @@ server_command() { # Enter the tmux game session server_console() { if socket_has_session "${SESSION_NAME}"; then - ${SUDO_CMD} tmux -L "${SESSION_NAME}" attach -t "${SESSION_NAME}":0.0 + ${TMUX_CMD} attach -t "${SESSION_NAME}":0.0 else echo "There is no ${SESSION_NAME} session to connect to." fi @@ -458,45 +576,48 @@ server_console() { # Check if there is a session available socket_has_session() { if [[ "$(whoami)" != "${GAME_USER}" ]]; then - ${SUDO_CMD} tmux -L "${SESSION_NAME}" has-session -t "${1}":0.0 2> /dev/null + ${TMUX_CMD} has-session -t "${1}":0.0 2> /dev/null return $? fi - tmux -L "${SESSION_NAME}" has-session -t "${1}":0.0 2> /dev/null + ${TMUX_CMD} -L "${SESSION_NAME}" has-session -t "${1}":0.0 2> /dev/null return $? } socket_session_is_alive() { if socket_has_session "${1}"; then if [[ "$(whoami)" != "${GAME_USER}" ]]; then - return $(${SUDO_CMD} tmux -L "${SESSION_NAME}" list-panes -t "${1}":0.0 -F '#{pane_dead}' 2> /dev/null) + return $(${TMUX_CMD} list-panes -t "${1}":0.0 -F '#{pane_dead}' 2> /dev/null) fi - return $(tmux -L "${SESSION_NAME}" list-panes -t "${1}":0.0 -F '#{pane_dead}' 2> /dev/null) + return $(${TMUX_CMD} list-panes -t "${1}":0.0 -F '#{pane_dead}' 2> /dev/null) else return 1 fi } -# Help function, no arguments required -help() { - cat <<-EOF - This script was designed to easily control any ${GAME} server. Almost any parameter for a given - ${GAME} server derivative can be changed by editing the variables in the configuration file. - - Usage: ${INAME} {start|stop|restart|status|backup|restore|command |console} - start Start the ${GAME} server - stop Stop the ${GAME} server - restart Restart the ${GAME} server - status Print some status information - backup Backup the world data - restore [filename] Restore the world data from a backup - command Run the given command at the ${GAME} server console - console Enter the server console through a tmux session - - Copyright (c) Gordian Edenhofer - EOF +print_config() { + if [[ -n "$1" ]]; then + if [[ ! -v "default_config[$1]" ]]; then + echo "$1 is not a recognized configuration variable." >&2 + return 1 + fi + + echo "${!1}" + else + ( + for var in ${!default_config[@]}; do + echo "$var=${!var}" + done + ) | sort + fi +} + +init_directories() { + for dir in $SERVER_ROOT $BACKUP_DEST; do + $SUDO_CMD test -d "$dir" || $SUDO_CMD install -D -d -m 0700 "$dir" + done } -case "${1:-}" in +case "$COMMAND" in start) server_start ;; @@ -518,7 +639,7 @@ case "${1:-}" in ;; command) - server_command "${@:2}" + server_command "${SERVER_COMMAND}" ;; backup) @@ -526,22 +647,20 @@ case "${1:-}" in ;; restore) - backup_restore "${@:2}" + backup_restore "${ARCHIVE_PATH}" ;; - idle_server_daemon) - # This shall be a hidden function which should only be invoked internally - idle_server_daemon + print) + print_config "${CONFIG_VAR:-}" ;; - -h|--help) - help - exit 0 + init) + init_directories ;; - *) - help - exit 1 + idle_server_daemon) + # This shall be a hidden function which should only be invoked internally + idle_server_daemon ;; esac diff --git a/minecraftd.tmpfiles.in b/minecraftd.tmpfiles.in index 227bf89..bef0932 100644 --- a/minecraftd.tmpfiles.in +++ b/minecraftd.tmpfiles.in @@ -1,3 +1,4 @@ z @SERVER_ROOT@ 2775 @GAME_USER@ @GAME_USER@ - - d @SERVER_ROOT@/logs 2755 @GAME_USER@ @GAME_USER@ - z @SERVER_ROOT@/logs - @GAME_USER@ @GAME_USER@ - - +d @TMUX_SOCKET_DIR@ 0755 @GAME_USER@ @GAME_USER@ - - diff --git a/minecraftd@.service.in b/minecraftd@.service.in new file mode 100644 index 0000000..b1adbd2 --- /dev/null +++ b/minecraftd@.service.in @@ -0,0 +1,25 @@ +[Unit] +Description=@GAME@ Server - %i instance +After=local-fs.target network.target multi-user.target @GAME@-init@%i.service +Requires=@GAME@-init@%i.service + +[Service] +Type=forking +ExecStart=/usr/bin/@INAME@ -i %i start +ExecStop=/usr/bin/@INAME@ -i %i stop +User=@GAME_USER@ +Group=@GAME_USER@ +ProtectSystem=strict +ProtectHome=yes +ReadOnlyPaths=/usr /bin /lib -/lib64 @CONFIG_PATH@ +# Prevent the minecraft server itself from tampering with backups. This means a +# compromised server would not be able to delete backups. +PrivateTmp=yes +TemporaryFileSystem=@SERVER_ROOT@/servers:ro +BindPaths=@TMUX_SOCKET_DIR@ -@SERVER_ROOT@/servers/%i +InaccessiblePaths=-@SERVER_ROOT@/servers/%i/backup +# Make paths related to the non-instantiated server inaccessible. +InaccessiblePaths=-@SERVER_ROOT@/world -@SERVER_ROOT@/world_nether -@SERVER_ROOT@/world_the_end -@SERVER_ROOT@/plugins -@SERVER_ROOT@/logs -@SERVER_ROOT@/backup + +[Install] +WantedBy=multi-user.target