Text

A Control Panel That Refuses to Own the Server

Written by Gustama Dimas

The Valheim server had been running for months before I touched it. It had a world, a handful of players, a backup script, and a join code that someone had to look up by hand every time a new person wanted in.

Every web panel I looked at solved that problem by taking the server over. Install this, and it owns your world, your compose file, your mods, your updates. That is a reasonable design. It was not the design I wanted. I did not want a new server. I wanted a window into the one I already had.

So the constraint came first, before any code: attach, do not own.

Panel dashboard: join code, server state, world day, players, and backups
Dashboard — join code, server state, world day, players, and the backup list on one screen.

Three channels that were already there

The server was already exposing everything a panel needs. It just was not labelled that way.

  • Supervisor XML-RPC on :8088 — start, stop, process state, log reads. Already authenticated, already running.
  • RCON on :25575 — admin commands and player lists, out of band over TCP, works with crossplay.
  • The ValheimUI agent on :2456 — structured JSON: players with positions, world state, deaths, map layers.

No Docker socket. No privileged container. No shell string interpolation. The panel runs as an unprivileged user, drops every capability, and can only do what those three channels allow — which is exactly the point. A control panel that can run arbitrary commands as root is not a control panel, it is a remote shell with nicer fonts.

Two verbs that lied

Supervisor has no restartProcess. There is a restart button in its HTML form and a restart verb in supervisorctl, so it is reasonable to assume the XML-RPC API has one too. It does not. supervisor.restart restarts supervisord itself, and calling restartProcess returns UNKNOWN_METHOD.

The fix is boring: stop, wait for STOPPED, start. It is also better, because the wait is what gives the game time to run its SIGTERM handler and save the world. A restart that returns before the save finishes is a restart that loses progress.

The same trap appeared a second time. The agent's /v1/commands/time looks like a getter. It is a setter — call it with no arguments and it answers "fraction, skip=morning or seconds is required". The read lives in RCON. The catalog entry moved.

Both bugs share a shape: the name of the verb suggested behaviour I had not verified. Wiring a button to an endpoint is not the same as knowing what the endpoint does.

Command console with a quick-command row, a command catalog, and a free-form RCON box
Command console — a 25-command catalog where each entry declares its own transport, a free-form RCON box for admins, and live chat.

The mod that installed perfectly and never loaded

This one is my favourite, because the panel was not wrong about anything it reported.

The panel writes plugins to /config/bepinex/plugins. The game loads them from /opt/valheim/bepinex/BepInEx/plugins. Those are different directories, and the copy between them happens inside valheim-bootstrap — a one-shot supervisor program that runs at container start and then sits in EXITED for the rest of its life.

So a plain stop/start restarted the server with the old plugin set. The panel said "installed". The install had worked. The game log said three plugins loaded, not five.

For a mod manager, silently ignoring a new mod is the worst possible failure. Worse than an error, because an error gets fixed. The fix was to run the bootstrap program between stop and start, on every restart, and to make start sync as well. Bootstrap is idempotent, so running it when nothing changed costs a second. Then the log said five loaded, zero failed.

There is a smaller lesson inside that one: the panel's success message described the panel's work, not the server's state. Those are different claims, and only one of them is worth showing to a user.

Mod manager showing installed packages, a crossplay warning banner, and a merged store search
Mod manager — Thunderstore and Hexium merged into one view, with a crossplay warning, per-package quarantine, and the three hand-installed plugins listed honestly as unmanaged.

The map was not a picture

I assumed /v1/map/layers returned an image. It returns a 2048×2048 RGBA PNG of raw fields: red carries the terrain mask and biome code, green and blue carry height, alpha carries forest density. POST /v1/map/render does not render — it starts sampling. The actual styling lives in the agent's Go code, which I was not going to port.

So the panel styles the map itself, in the browser, on a canvas. That turned out to be the better architecture anyway: 4.2 million pixels styled in Python takes seconds per frame, and a canvas does it in milliseconds and then pans without asking the server at all. The server's job shrank to caching, auth, and aggregating objects.

I verified it by rendering a 512-pixel preview and looking at it. Deep North white at the top, Ashlands red-brown at the bottom, ocean blue, meadows green, mountains grey with snow. North-up confirmed, biomes in the right places, coastlines clean. Code review would not have caught a flipped axis; a picture does.

Zoomed world map with biomes, boss altars, portals and fog of war
The map, styled in the browser: biomes, boss altars, portals, and the fog mask that fills in as people play.

Fog of war is currently 100% unexplored — the agent only started recording player positions today. Honouring that mask would have shown a black square. So fog turns itself off while the data is empty, says why in the UI, and will turn itself back on when there is something to hide.

The security mistake I made

The Discord bridge lets a bot query and control the server. My first version had the bot pass the caller's role IDs to the relay script, which then decided what to allow.

That is a trust boundary in the wrong place. The bot runs a small free model. A prompt injection could have made it claim the Admin role, and the script would have believed it.

Now the relay asks Discord directly: given this guild and this user ID, what roles does the member actually have? The claim is verified against the guild, not accepted.

And because one relay account would have made the script's check the only boundary, there are three — relay-user, relay-mod, relay-admin — and the script logs in as the granted role. The panel enforces the same limit independently. I tested it by pretending the script had a bug: a relay-user session gets 403 on restart, on list edits, on mod installs, and 200 on reads.

Users and roles screen listing accounts and the capability grid for viewer, moderator and admin
Users and roles — three roles, thirty capabilities, and the grid the server enforces rather than the UI.

Two locks, one of which does not trust the other. That is the shape I want around anything that can restart a server.

A CSS rule worth keeping

Pan and zoom did not work. The map drew, the controls responded, nothing moved.

The message overlay carried the hidden attribute. Its class also set display: grid. Author CSS beats the browser's default [hidden] { display: none }, so the overlay stayed laid out — absolutely positioned, inset zero, semi-opaque — and swallowed every mouse event before it reached the canvas.

Pan and zoom were never broken. They were unreachable.

The fix is one line: [hidden] { display: none !important }. The pattern recurs across the whole panel, so it is now global rather than local. Two smaller map bugs came out of the same pass: the fog layer was being rebuilt pixel by pixel on every mousemove, and the canvas sized itself from a hidden tab and came out 1×1.

What is still honest-gaps

  • The death leaderboard is empty. Nobody has died since the tracker started, and history before today is not recoverable.
  • Fog stays off until exploration data exists.
  • Three plugins were installed by hand before the panel existed, so the panel lists them as unmanaged rather than claiming them.
  • World switching is not offered. The active world comes from an environment variable, so switching needs a privileged compose edit. The UI says so instead of pretending.

The panel is about 9,000 lines — 5,600 of Python, 2,000 of JavaScript, the rest markup and CSS. It has no Docker socket and runs beside a server it did not build. It is published under MIT.

The reason it exists is the constraint I started with. Owning the server would have been easier to write and harder to trust.

Code: github.com/gustamadim/valheim-panel