Title: Using systemd to make a Minecraft server to start on-demand and
stop when it has no player
Author: Solène
Date: 20 August 2022
Tags: minecraft nixos systemd automation
Description: This article explains how to use systemd to start a
network daemon upon connection and make it stop when it's not needed
anymore, using Minecraft as a real world example.

# Introduction

Sometimes it feels I have specific use cases I need to solve alone. 
Today, I wanted to have a local Minecraft server running on my own
workstation, but only when someone needs it.  The point was that
instead of having a big java server running all the system, Minecraft
server would start upon connection from a player, and would stop when
no player remains.

However, after looking a bit more into this topic, it seems I'm not the
only one who need this.
on-demand-minecraft: a project to automatically start a remote cloud server for whitelisted players
minecraft-server-hibernation: a wrapper that starts and stop a Minecraft server upon condition
As often, I prefer not to rely on third party tools when I can, so I
found a solution to implement this using only systemd.

Even better, note that this method can work with any daemon given you
can programmatically get the information whether to let it running or
stop.  In this example, I'm using Minecraft and the server stop is
decided based on the player connecting fetch through rcon (a remote
administration protocol).

# The setup

I made a simple graph to show the dependencies, there are many systemd
components used to build this.
systemd dependency graph
The important part is the use of the systemd proxifier, it's a command
to accept a connection over TCP and relay it to another socket,
meanwhile you can do things such as starting a server and wait for it
to be ready.  This is the key of this setup, without it, this couldn't
be possible.

Basically, listen-minecraft.socket listens on the public TCP port and
runs listen-minecraft.service upon connection.  This service needs
hook-minecraft.service which is responsible for stopping or starting
minecraft, but will also make listen-minecraft.service wait for the TCP
port to be open so the proxifier will relay the connection to the
daemon.

Then, minecraft-server.service is started alongside with
stop-minecraft.timer which will regularly run stop-minecraft.service to
try to stop the server if possible.

# Configuration

I used NixOS to configure my on-demand Minecraft server.  This is
something you can do on any systemd capable system, but I will provide
a NixOS example, it shouldn't be hard to translate to a regular systemd
configuration files.

```nix
{ config, lib, pkgs, modulesPath, ... }:
let

  # check every 20 seconds if the server
  # need to be stopped
  frequency-check-players = "*-*-* *:*:0/20";

  # time in second before we could stop the server
  # this should let it time to spawn
  minimum-server-lifetime = 300;

  # minecraft port
  # used in a few places in the code
  # this is not the port that should be used publicly
  # don't need to open it on the firewall
  minecraft-port = 25564;

  # this is the port that will trigger the server start
  # and the one that should be used by players
  # you need to open it in the firewall
  public-port = 25565;

  # a rcon password used by the local systemd commands
  # to get information about the server such as the
  # player list
  # this will be stored plaintext in the store
  rcon-password = "260a368f55f4fb4fa";

  # a script used by hook-minecraft.service
  # to start minecraft and the timer regularly
  # polling for stopping it
  start-mc = pkgs.writeShellScriptBin "start-mc" ''
    systemctl start minecraft-server.service
    systemctl start stop-minecraft.timer
  '';

  # wait 60s for a TCP socket to be available
  # to wait in the proxifier
  # idea found in http://web.archive.org/web/20240215035104/https://blog.developer.atlassian.com/docker-systemd-socket-activation/
  wait-tcp = pkgs.writeShellScriptBin "wait-tcp" ''
    for i in `seq 60`; do
      if ${pkgs.libressl.nc}/bin/nc -z 127.0.0.1 ${toString minecraft-port} > /dev/null ; then
        exit 0
      fi
      ${pkgs.busybox.out}/bin/sleep 1
    done
    exit 1
  '';

  # script returning true if the server has to be shutdown
  # for minecraft, uses rcon to get the player list
  # skips the checks if the service started less than minimum-server-lifetime
  no-player-connected = pkgs.writeShellScriptBin "no-player-connected" ''
    servicestartsec=$(date -d "$(systemctl show --property=ActiveEnterTimestamp minecraft-server.service | cut -d= -f2)" +%s)
    serviceelapsedsec=$(( $(date +%s) - servicestartsec))

    # exit if the server started less than 5 minutes ago
    if [ $serviceelapsedsec -lt ${toString minimum-server-lifetime} ]
    then
      echo "server is too young to be stopped"
      exit 1
    fi

    PLAYERS=`printf "list\n" | ${pkgs.rcon.out}/bin/rcon -m -H 127.0.0.1 -p 25575 -P ${rcon-password}`
    if echo "$PLAYERS" | grep "are 0 of a"
    then
      exit 0
    else
      exit 1
    fi
  '';

in
{

  # use NixOS module to declare your Minecraft
  # rcon is mandatory for no-player-connected
  services.minecraft-server = {
    enable = true;
    eula = true;
    openFirewall = false;
    declarative = true;
    serverProperties = {
      server-port = minecraft-port;
      difficulty = 3;
      gamemode = "survival";
      force-gamemode = true;
      max-players = 10;
      level-seed = 238902389203;
      motd = "NixOS Minecraft server!";
      white-list = false;
      enable-rcon = true;
      "rcon.password" = rcon-password;
    };
  };

  # don't start Minecraft on startup
  systemd.services.minecraft-server = {
      wantedBy = pkgs.lib.mkForce [];
  };

  # this waits for incoming connection on public-port
  # and triggers listen-minecraft.service upon connection
  systemd.sockets.listen-minecraft = {
    enable = true;
    wantedBy = [ "sockets.target" ];
    requires = [ "network.target" ];
    listenStreams = [ "${toString public-port}" ];
  };

  # this is triggered by a connection on TCP port public-port
  # start hook-minecraft if not running yet and wait for it to return
  # then, proxify the TCP connection to the real Minecraft port on localhost
  systemd.services.listen-minecraft = {
    path = with pkgs; [ systemd ];
    enable = true;
    requires = [ "hook-minecraft.service" "listen-minecraft.socket" ];
    after =    [ "hook-minecraft.service" "listen-minecraft.socket"];
    serviceConfig.ExecStart = "${pkgs.systemd.out}/lib/systemd/systemd-socket-proxyd 127.0.0.1:${toString minecraft-port}";
  };

  # this starts Minecraft is required
  # and wait for it to be available over TCP
  # to unlock listen-minecraft.service proxy
  systemd.services.hook-minecraft = {
    path = with pkgs; [ systemd libressl busybox ];
    enable = true;
    serviceConfig = {
        ExecStartPost = "${wait-tcp.out}/bin/wait-tcp";
        ExecStart     = "${start-mc.out}/bin/start-mc";
    };
  };

  # create a timer running every frequency-check-players
  # that runs stop-minecraft.service script on a regular
  # basis to check if the server needs to be stopped
  systemd.timers.stop-minecraft = {
    enable = true;
    timerConfig = {
      OnCalendar = "${frequency-check-players}";
      Unit = "stop-minecraft.service";
    };
    wantedBy = [ "timers.target" ];
  };

  # run the script no-player-connected
  # and if it returns true, stop the minecraft-server
  # but also the timer and the hook-minecraft service
  # to prepare a working state ready to resume the
  # server again
  systemd.services.stop-minecraft = {
    enable = true;
    serviceConfig.Type = "oneshot";
    script = ''
      if ${no-player-connected}/bin/no-player-connected
      then
        echo "stopping server"
        systemctl stop minecraft-server.service
        systemctl stop hook-minecraft.service
        systemctl stop stop-minecraft.timer
      fi
    '';
  };

}
```

# Conclusion

I'm really happy to have figured out this smart way to create an
on-demand Minecraft, and the design can be reused with many other kinds
of daemons.