jerry@homelab:~/projects

jerry@homelab:~/projects$ cat mc-tool.md

mc-tool

ยท Python ยท asyncio ยท SQLite ยท RCON ยท Docker ยท n8n

We run three Minecraft servers at home (creative, one block and survival), playable from both Java and Bedrock. mc-tool is the one container that looks after them. It replaced three older pieces: an idle-shutdown script, a command-reference site, and the Minecraft features in my Signal bot.

Why I built it

To make life easier, mostly for me. The servers sleep when nobody's playing, and before mc-tool, waking one up meant asking me. Now the kids start a server themselves from a web page, and I just get a Signal message that someone joined.

While I was at it, I pulled the old pieces together. They were spread over three projects on two hosts. Now everything Minecraft lives in one place, with two pages:

  • The kids' page is in Dutch and has no login. It shows each server's status and who's online, a Start button for sleeping servers, how to join (Java and Bedrock addresses with a copy button), and a searchable list of commands they can paste into the game.
  • The admin page sits behind Authelia and is for me. It has the whitelist (Java and Bedrock), start/stop, chat messages to the players ("Eten is klaar!"), playtime and recent sessions, and a log of who did what.

What it does

  • Idle shutdown: a server stops after 15 minutes with nobody on, and the kids start it again from their page.
  • Presence and playtime: every join and leave is tracked in SQLite, so playtime per player per day is one query away. n8n reads it for my morning briefing.
  • Notifications: join, leave, started, asleep, crashed and bedtime events go to n8n, which turns them into Signal messages.
  • Bedtime: in the evening the servers get quiet hours, with a later bedtime at the weekend. Players get chat warnings 10 and 2 minutes before, then "Het is bedtijd!". The kids' page shows a bedtime banner, and the Start button is locked until morning. Running servers are not killed at bedtime: they stop once everyone has left. And there's a "one more hour tonight" button on the admin page.
  • Health checks for Gatus, which can tell "asleep on purpose" from "crashed". See below.

How it's built

One Python process running a poll loop and two small web apps (one per port), all on asyncio. Every 30 seconds it checks each server's container and asks the server who's online.

 kids' page (no login) โ”€โ”€โ”€โ”
 admin page (Authelia) โ”€โ”€โ”€โ”ผโ”€โ”€โ–ถ mc-tool โ”€โ”€โ”ฌโ”€โ”€โ–ถ RCON: who's online, whitelist, say
 Gatus: /health/<server> โ”€โ”˜   poll 30 s  โ”œโ”€โ”€โ–ถ socket proxy โ”€โ”€โ–ถ Docker: inspect/start/stop,
                                         โ”‚                     3 containers only
                                         โ”œโ”€โ”€โ–ถ SQLite: sessions, playtime, admin log
                                         โ””โ”€โ”€โ–ถ n8n โ”€โ”€โ–ถ Signal
  • Two apps, two ports: the kids' app has no admin routes at all, and a test checks that. The admin port isn't published, so the only way in is through Caddy with Authelia in front.
  • Strict Content-Security-Policy on every page, with local fonts and scripts and no external calls.
  • CI/CD: Forgejo Actions runs pytest on every push and builds and deploys on main. Docker, RCON, n8n, Mojang and GeyserMC are all faked in-process in the tests, so they run anywhere.

Problems worth telling

Giving it Docker access without giving it Docker

mc-tool needs to start and stop containers, and the usual shortcut is mounting the Docker socket, which is root on the host. Instead it talks to a socket proxy on a private network. The proxy allows exactly three things: inspect, start and stop, for exactly three container names. Listing containers, exec and creating containers all get a 403. A test checks that the proxy's allowlist matches the Docker calls mc-tool actually makes, so the two can't drift apart.

A network blip is not "everyone left"

If RCON fails for one poll, the naive approach would see nobody online. That means a leave message for every player, and possibly an idle shutdown in the middle of a game. So:

  • a failed or unrecognised RCON reply keeps the last known players;
  • a server is only idle-stopped after a successful check showed nobody on, and idle time counts from when the server started, so a server a kid just started gets the full 15 minutes;
  • if a server crashes, sessions close at the time each player was last seen, so downtime doesn't count as playtime;
  • it asks with the namespaced minecraft:list, so a plugin can't change the reply format.

Whitelisting is three different problems

  • Java, online-mode: RCON whitelist add while the server runs. While it's asleep, mc-tool looks up the UUID from Mojang and writes it to the file.
  • Java, offline-mode: RCON would store the online UUID, which is wrong for an offline-mode server (PaperMC/Paper#79). So mc-tool computes the offline UUID itself, the same way Paper does, and writes that.
  • Bedrock: mc-tool looks up the gamertag's XUID through GeyserMC's API, converts it to the UUID Floodgate expects, and adds the Floodgate name prefix.

The file write had its own catch. Each whitelist.json is a single-file bind mount, and the safe "write a temp file, then rename" trick fails on a mount point (EBUSY). So it writes in place, but only after building the complete new content, and it keeps a backup of the last 20 versions. Because a single-file mount follows the file's inode, mc-tool also checks every 10 minutes that what the running server reports matches the file it sees. It warns if the mount ever goes stale.

Asleep is not down

Gatus used to flag a sleeping server as an outage. mc-tool remembers which servers it put to sleep. Its health endpoint answers asleep-expected for those, and down-unexpected only when something else stopped the server. BlueMap, the live web map, runs inside each server, so it disappears too when a server sleeps. Gatus now checks it through mc-tool, so a sleeping server's map doesn't alert either. My home-dashboard follows the same idea: a server stopped on purpose is "asleep", not "down".

Rewriting this also turned up a bug in the old idle script: it checked the host ports on the containers' internal names, so two of the three servers showed as unreachable.

The small things

  • Python as PID 1 ignores SIGTERM, so every docker stop waited out the full timeout until Docker killed it. One line fixed it: init: true in the compose file.
  • The only free text that reaches RCON is the chat message, always sent as minecraft:say, with newlines and control characters stripped and capped at 200 characters. There's deliberately no command console.

What's next

Maybe a fourth server. Adding one is a block of settings plus one line in the socket proxy's allowlist, and the test catches it if I forget that line.

jerry@homelab:~/projects$ โ–ˆ

cd ..