An on demand Minecraft Server

:: games, tricks, linux

Sometimes I play minecraft. Sometimes I play a lot of minecraft and sometimes I just stop playing for months. Lately when I do play, I’ve bene playing with a slightly modified version of Tekkit and running my own server. I have a VPS that I probably under use, so I decided to run the server there for when I do play with my friends.

My VPS is not very powerful, and running a Minecraft server when I stop playing for months is a huge waste of resources. I sought a way to automatically bring the server up when I wanted to play and shut it down when I wasn’t playing for a while.

Intro and credits

Most of this I gleemed by reading this article at planetminecraft.com. It’s quite well written, but I made my own changes to suite my needs and expanded on some things. In particular, I wanted more abstraction, the website seems to mangle the bits of code posted there, and a couple of things were left unexplained.

I also heavily referenced this wiki to understand the minecraft ping protocol to get MOTD even when the server is down, letting users know the server is starting up.

All this code is available on github.

Disclaimer: This code probably has at least one bug.

Assumptions

A couple of notes before we get started. I run Arch Linux on all my machines, including my server. I use cronie as my crontab implementation. These scripts make use of lots of ‘standard’ tools such as a screen, sed, grep, and tr, and ‘less standard’ ones like netcat, pgrep, and xinetd. You don’t really need to understand them use this, but I won’t explain them here. I will assume you know how to run and install a Minecraft server. These scripts should work with any Minecraft server; I personally use Tekkit.

This article, and the scripts to a lesser extend, expect files to be in particular places. The only hard-coded path in the scripts should be /etc/tekkit-on-demand/config.sh. The article expects the binaries to be installed in /usr/bin/tekkit-{start,idle}, and the launch helper to be installed in /etc/tekkit-on-demand/launch.sh

The server: overview

The server runs in a screen session. I previously used systemd to manage the server, but there are a few advantages to running it in screen. You can easily, and programatically, send commands to the server, and more easily filter logs, which I find necessary due to the absurd number of info messages. The server is run as an unprivileged user, but requires root to launch it.

config.sh

The file config.sh contains all the configuration variables, and the functions with the core commands for starting and stopping the server, and detecting when the server is idle.

The file is configured by simply setting the variables at the top of the file. The variables have sensible defaults seen later in the file. We will refer to some of the variables such as $SERVER_USER in the rest of the guide.

Advanced configuration involved changing the functions start, stop, idle, and debug. The functions shouldn’t need to be changed unless your server is configured quite differently, or you want to avoid screen, xinetd, or some other vital piece explained in the rest of the guide.

#!/bin/sh

## Change these configuration variables. They should probably match server.properties
## Leave them blank if you think I'm a good guesser.
SERVER_ROOT=
SERVER_PROPERTIES=
LOCAL_PORT=
LOCAL_IP=
MINECRAFT_JAR=
MINECRAFT_LOG=
SESSION=
WAIT_TIME=
SERVER_USER=
LAUNCH=
START_LOCKFILE=
IDLE_LOCKFILE=
PLAYERS_FILE=

## NB: This default may not be sensible
JAVAOPTS=
JAVAOPTS=${JAVAOPTS:--Xmx2G -Xms1G -server -XX:+UseG1GC -XX:MaxGCPauseMillis=50 \
  -XX:ParallelGCThreads=2 -XX:+DisableExplicitGC -XX:+AggressiveOpts -d64}

## TODO: Currenently not used. Need to recompute size and UTF-16BE
## encode the message, which is annoying
MESSAGE=
## Here be defaults
SERVER_ROOT=${SERVER_ROOT:-/srv/tekkit}
SERVER_PROPERTIES=${SERVER_PROPERTIES:-$SERVER_ROOT/server.properties}
LOCAL_PORT=${LOCAL_PORT:-$(sed -n 's/^server-port=\([0-9]*\)$/\1/p' ${SERVER_PROPERTIES})}
LOCAL_IP=${LOCAL_IP:-$(sed -n 's/^server-ip=\([0-9]*\)$/\1/p' ${SERVER_PROPERTIES})}
MINECRAFT_JAR=${MINECRAFT_JAR:-$SERVER_ROOT/Tekkit.jar}
MINECRAFT_LOG=${MINECRAFT_LOG:-$SERVER_ROOT/server.log}
SESSION=${SESSION:-Minecraft}
MESSAGE=${MESSAGE:-Just a moment please}
WAIT_TIME=${WAIT_TIME:-600}
SERVER_USER=${SERVER_USER:-tekkit}
LAUNCH=${LAUNCH:-/etc/tekkit-on-demand/launch.sh}
START_LOCKFILE=${START_LOCKFILE:-/tmp/startingtekkit}
IDLE_LOCKFILE=${IDLE_LOCKFILE:-/tmp/idleingtekkit}
PLAYERS_FILE=${PLAYERS_FILE:-/tmp/tekkitplayers}

...

Starting the server

Starting the server is tricky. We must ensure any user that tries to connect sees a message alerting them that the server is not up now, but will be shortly. We also need to be sure to start only one instance of the server. Finally, we have to route traffic between the server and the meta-server that is watching to start the server on-demand.

Start on-demand

To automatically start the server, we use xinetd, a ‘super-server’ (or as I prefer, ‘meta-server’). The meta-server is a server that manages servers by binding to the server’s port, starting the server when a client attempts to connect, then forwarding all traffic to the server.

tekkit:

service tekkit
{
  type = UNLISTED
  instances = 20
  socket_type = stream
  protocol = tcp
  wait = no
  user = root
  group = root
  server = /usr/bin/tekkit-start
  port = 25565
  disable = no
}

We install this file in /etc/xinetd.d/tekkit, and have xinetd reread configuration files, for instance, via systemctl reload xinetd. This path is fixed by xinetd. You must change port = ... in this file if you change $SERVER_PORT.

Now when someone tries to connect to your server on port 25565, the meta-server will run the file /usr/bin/tekkit-start. Note that since the meta-server is binding port 25565, your server must use a different port. I use port 25555, but you can configure this with $LOCAL_PORT.

Screen and the server command

A typical Minecraft server is started with a command that looks something like /usr/bin/java $JAVAOPTS -jar $MINECRAFT_JAR nogui. We add to this command a filter to filter out the INFO messages, and to capture the number of players. All output is first piped to a sed script that watches for the response to a list command. The list command is a Minecraft server command that lists the number of players online. The number of players is captured to the file specified by $PLAYERS_FILE. The remaining output is filtered through grep to discard INFO messages. This is done in config.sh in the start function:

start() {
  /usr/bin/java $JAVAOPTS -jar $MINECRAFT_JAR nogui 2>&1 \
    | sed -n -e 's/^.*There are \([0-9]*\)\/[0-9] players.*$/\1/' -e 't M' -e 'b' -e ": M w $PLAYERS_FILE" -e 'd' \
    | grep -v -e "INFO" -e "Can't keep up"
}

We want to run the server in screen to allow issuing commands, such as list, to the server. Unfortunately, screen doesn’t appear to take a function as an argument. We use launch.sh as a wrapper, and have screen run launch.sh as an unprivileged user called $SERVER_USER.

launch.sh: sh #!/bin/sh source /etc/tekkit-on-demand/config.sh cd $SERVER_ROOT start

The file /usr/bin/tekkit-start is actually responsible for starting the server, and the screen command appears in there. However, much more happens before starting the server…

Server starting message

When a player first connects, we do not want them scared away by a “Can’t reach server” message. We implement the minecraft server list ping response, details here, to give them a less scary message. This protocol is implemented in the function sign in the tekkit-start file.

tekkit-start:

#!/bin/sh
source /etc/tekkit-on-demand/config.sh

sign(){
  # Kick protocol start
  echo -en "\xFF"
  # Length in characters: (including protocol, MOTD, current, max players)
  #               22
  #               |
  echo -en "\x00\x22"
  # UTF-16BE String: Protocol header
  echo -en "\x00\xA7\x00\x31\x00\x00"
  # Protocol version:
  #                4       7
  #                |       |
  echo -en "\x00\x34\x00\x37\x00\x00"
  # Minecraft version:
  #                1       .       6        .      4
  #                |               |               |
  echo -en "\x00\x31\x00\x2E\x00\x36\x00\x2E\x00\x34\x00\x00"
  # MOTD: "Up in just a sec.."
  echo -en "\x00\x55\x00\x70\x00\x20\x00\x69\x00\x6E\x00\x20\x00\x6A\x00\x75\x00\x73\x00\x74\x00\x20\x00\x61\x00\x20\x00\x73\x00\x65\x00\x63\x00\x2E\x00\x2E\x00\x00"
  # Current Players:
  #                0
  #                |
  echo -en "\x00\x30\x00\x00"
  # Max Players:
  #                0
  #                |
  echo -en "\x00\x30"
}

This implementation is kind of bad, with lengths computed and strings encoded by hand. Maybe I’ll fix it later. The comment above each string explain what the string means. The first two echos send binary strings representing the protocol start packet and the length of the message. The remaining echos send UTF16-BE encoded information, such as the minecraft version, the MOTD (the message displayed under the server name), and the number of players.

Control Flow

The rest of the tekkit-start file is dedicated to control flow. We must ensure only one instance of the server is started, so we use pgrep to ask if the $SERVER_USER user has any process using the $MINECRAFT_JAR. If the server is not running, we start the server, post ping response, and wait for the server to start responding. If the server is already up, we use nc to route traffic between the server and meta-server.

To ensure every user continues to see the ping response while the server is starting but not yet responding, we add a $START_LOCKFILE While the $START_LOCKFILE exists, the only thing tekkit-start will do is post the ping response.

...
if [ ! -f $START_LOCKFILE ]; then
  touch $START_LOCKFILE
  if ! pgrep -U $SERVER_USER -f "$MINECRAFT_JAR" >/dev/null; then
    sudo -u $SERVER_USER -- screen -dmS $SESSION $LAUNCH
    sign
    while netcat -vz -w 1 localhost 25555 2>&1 | grep refused > /dev/null; do
      debug "Connection refused"
      sleep 1
    done
    debug "Deleting start lock"
    /bin/rm $START_LOCKFILE
    debug `[ -f $START_LOCKFILE ] && echo "Lockfile still exists"`
  else
    /bin/rm $START_LOCKFILE
    debug `[ -f $START_LOCKFILE ] && echo "Lockfile still exists"`
    exec sudo -u $SERVER_USER nc $LOCAL_IP $LOCAL_PORT
  fi
else
  sign
fi

Stopping the server

Stopping the server is easier than starting it. We want to stop the server when there have been no players online for some amount of time. However, we want to make sure not to stop too frequently, since a player may stop briefly to make food, do some work, or just get away from the computers for a little bit. Once we know the server is idle, we just stop it by issuing a stop command to the server.

stop() {
  screen -S $SESSION -p 0 -X stuff 'stop\15'
  debug "Shit's going down"
}

This commands tells screen to connect to the $SESSION session, on window 0, and stuff the string stop\15 into the input buffer. The command stop tells the server stop running. The final character \15 is the control character for enter/return, so this simulates typing stop and pressing enter.

Detecting an idle server

We specify how frequently to perform an idle check using crontab. If there is no one online during the check, the script will wait $WAIT_TIME seconds and check again. If both checks pass then the server will shutdown.

We add the following to $SERVER_USER’s crontab to run the idle check once an hour.

crontab -e:

@hourly /usr/bin/tekkit-idle

To determine the number of users online via script, we have all logs filtered through the sed script seen in start(). The script looks for a particular server message, and dumps the number to the file $PLAYERS_FILE.

We can force the server to output this message by using the list command. Since the server is running in a screen process, we can issue this command via screen -S Minecraft -p 0 -X stuff 'list\15'. The command list asks the server to dump the current number of players.

Before issuing the requre, we clear the file. After issuing the request, we wait until the file is not blank, so sed must have found the message and dumped it to the file. This prevents race conditions. We read in and compare to 0. All this logic is implemented in the config.sh function idle. There is also a bunch of debugging information there, because I had trouble with sed outputing invisible characters to the $PLAYERS_FILE. We use tr -d [:cntrl:] to remove these invisible control characters.

idle() {
  echo -n "" > ${PLAYERS_FILE}
  debug `cat ${PLAYERS_FILE}`
  screen -S $SESSION -p 0 -X stuff 'list\15'
  players=`tail -n 1 ${PLAYERS_FILE} | tr -d [:cntrl:]`
  while [ -z ${players} ]; do
    sleep 1
    players=`tail -n 1 ${PLAYERS_FILE} | tr -d [:cntrl:]`
  done
  debug "There are ${players} players"
  if [ "0" = "${players}" ]; then
    debug "Idle"
    true
  else
    debug "Not idle"
    false
  fi
}

Below is the idle detection script, called tekkit-idle. The function idle is implemented in config.sh and returns true when no players are online. The reset of the script implements the logic I explained before: if the server is idle, i.e., no one is online, then wait $WAIT_TIME seconds. If the server is still idle, shut it down.

tekkit-idle:

#!/bin/sh
source /etc/tekkit-on-demand/config.sh

if [ ! -f $IDLE_LOCKFILE ]; then
  touch $IDLE_LOCKFILE
  debug "No lock file, checking!"
  if idle; then
    debug "Idle, waiting!..."
    sleep $WAIT_TIME
    if idle; then
      debug "Still idle, stopping!"
      stop
    fi
  fi
  /bin/rm $IDLE_LOCKFILE
fi
debug "Idle check complete"