💾 Archived View for perso.pw › blog › articles › on-demand-minecraft-with-systemd.gmi captured on 2023-04-19 at 22:50:32. Gemini links have been rewritten to link to archived content
⬅️ Previous capture (2023-01-29)
-=-=-=-=-=-=-
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.
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).
I made a simple graph to show the dependencies, there are many systemd components used to build this.
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.
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.
{ 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 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 ''; }; }
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.