Releases: hopper-panel/hopper
Release list
v0.6.1
A template's strictest validation rule was enforcing nothing at all.
Variable rules are written Laravel-style, and the parser split them on | before it looked for regex:. So a rule an egg author wrote as:
required|string|regex:/^(paper|purpur)$/
was torn in two at the alternation. The first fragment would not compile, and the code that noticed that returned "accept it" — so the variable took any value at all. The second fragment became a rule nobody recognised and was thrown away.
Nothing was logged, and the panel showed the rule exactly as written. Pterodactyl eggs use alternations constantly, so any template imported with one has been running that variable unvalidated.
What changed
The rule string is scanned rather than split: a regex: is read through to its closing delimiter and its flags, so slashes and pipes inside a character class or behind a backslash stay part of the expression. Rules written after a regex are applied again, which they were not.
And an expression that will not compile now refuses the value rather than accepting it — blaming the template, not the person typing, and writing an error to the log that names the variable and the rule. That happens even when the value is empty, because a variable that is normally left blank would otherwise keep its broken rule secret until the day somebody filled it in.
What you may notice
A rule that was silently doing nothing now does something. If you imported an egg whose variable carries an alternation, that variable has been unvalidated; a value saved earlier may be refused the next time you save it. That is the fix working, but it is the one visible change.
The bundled templates are unaffected — every expression they ship is written without a pipe, deliberately, to work around this bug.
Upgrading
From the panel: Settings → Update now.
v0.6.0
An installation can now take an hour without being killed, and can no longer take for ever.
This is the groundwork for games installed from Steam. A depot is tens of gigabytes; the pipeline that installs a Minecraft jar in ten seconds was not built for it, in two specific ways.
An installation that has stopped is noticed
waitForExit was a bare container.wait() with no bound at all. An install that stalled on a dead mirror left the server in installing for ever, and nothing gave up.
There is a deadline now, and it measures inactivity rather than duration — a total cap would have to be set so high to accommodate a real depot that it would never fire, and low enough to be useful it would kill working installs.
What counts as activity is what the kernel charges the container's own cgroup: CPU time and block I/O, plus anything the install prints. A transfer that is moving burns CPU on every packet it takes off the socket; one blocked on a socket that stopped answering burns none and issues no I/O.
Two things it deliberately does not watch, both learned the hard way:
- Output alone. Every install script here downloads with
curl -sSL, and-sprints nothing at all for the whole transfer. A silence window would have killed a two-gigabyte modpack on a slow connection — the commonest install shape there is. - Network counters. Those are interface counters, not a measure of what the container caused: every server on a node shares one bridge, a bridge floods broadcast traffic to every port, and idle connections send keepalives. A stalled install kept looking alive on exactly the busy nodes where an endless one costs most.
A host that keeps neither counter is treated as unreadable, not as still — those are opposite answers, and confusing them would kill every installation on such a node.
A template that needs a longer window says so with installInactivityTimeoutMs; the default is fifteen minutes of nothing whatsoever.
Space is checked before anything is downloaded
There was no disk check anywhere. A depot larger than the node's free space filled the host disk, which takes down every server on that machine.
The check runs before the image is pulled, measures the filesystem the volumes are really on, and refuses rather than warning — naming how much is free, how much is wanted and which filesystem was measured. A reinstall counts what the volume already holds towards the requirement, since nothing wipes it first; without that a forty-gigabyte server could never be reinstalled at all. A template declares its appetite with installRequiredDiskBytes.
If the filesystem cannot be read, the installation proceeds and says so. Refusing every install on an unreadable filesystem would be a bigger failure than the one being guarded against.
Docker cannot hang the daemon
Every request the daemon makes to Docker is now bounded at one place — the client itself — rather than call by call. A call added tomorrow is bounded without its author having to know the rule exists, which is the only version of this that stays true.
The exceptions are named and deliberate: the wait for a container to end, and the console, statistics and download streams, which are held open by design. For those the bound covers Docker answering; what flows afterwards passes no clock, so a console idle for a week stays open.
Upgrading
From the panel: Settings → Update now. Nothing changes for an existing server or template. No shipped template declares either new field, the egg importer sets neither, and a template that declares nothing sends the daemon exactly the payload it sent before.
Not yet
Resuming an installation that a daemon restart interrupted, and real disk quotas. An install script writes into a bind mount with no quota on it — the preflight is a check, not an enforcement, and docs/security.md now says so rather than implying otherwise.
v0.5.0
A server that reads no standard input can now be stopped cleanly, and talked to.
v0.3.1 said this was coming and why it could not come then:
RCON as a stop transport — for games that do not read stdin — needs a change to the stop contract and comes separately.
Rust, ARK, Palworld and most Source servers read nothing from standard input. Until now the only clean stop Hopper had was a string written to a pipe nobody holds, and the alternative was a signal — which for a game that writes its save on shutdown is a save that does not get written.
Stopping over RCON
A template declares the command, which port to use, and the name of the variable holding the password — never the password, which the daemon resolves against the server's environment at the moment it connects.
Delivery is what counts, not an answer. That distinction is the whole feature. A server that has just executed quit closes its socket or says nothing at all, and reading that as "the command never arrived" would have Hopper refuse a stop it had already delivered — then never wait for the exit, never arm the timeout, while the game went down regardless. Restart would not restart, and a reinstall would mark a running server as failed. So the stop settles when the bytes leave, after a successful authentication. The readiness check keeps waiting for a real answer, because a server that hangs up has proved nothing about being ready.
The console takes the same rule. A command that runs and answers nothing was being reported as one that never arrived — which would have a scheduled task record a failure for work the server did, and an operator run it twice.
What is still refused, all of it before the command leaves: a port name that matches nothing, an unset password variable, a refused password, a connection that never opens. A refusal leaves the server exactly as it was — the game has been told nothing, and killing it would lose the session the refusal exists to protect.
A stop is allowed to take its time
stopTimeoutSeconds existed with a default of 30 that no template could raise, so every server inherited a figure sized for a Bukkit shutdown — and a world that takes longer than that to serialise was killed mid-write. A template declares it now. Factorio asks for 240.
Transfers follow that figure too, instead of giving up after a hardcoded two minutes — thirty seconds before a large world would have finished saving.
Refusals are visible
A refused stop used to be a console line at four in the morning and nothing else: the scheduled task recorded no failure and the server list still said the server was running. It now reaches the panel, lands in the Activity tab, and says it was refused rather than reporting the stop that never happened.
Upgrading
From the panel: Settings → Update now. Nothing changes for an existing server. A template that declares none of this keeps its stop command, its 30 seconds, and sends a byte-identical configuration; the shipped catalogue is unchanged apart from Factorio's longer stop.
A node running an older daemon cannot honour an RCON stop, and the panel refuses to save one for it rather than letting the daemon fall back to a signal without saying so.
Not yet
Palworld is the proof this was built for: anonymous Steam install, no standard input at all, RCON on its own port, and a save that a signal does not write. It needs an install pipeline that can survive downloading tens of gigabytes, which comes separately.
v0.4.0
Hopper runs a game that is not Minecraft.
A template says when it is ready
Two releases shipped readiness plumbing with no tap on it: v0.3.0 added four strategies to the contract and the daemon, v0.3.1 an RCON client — and no operator could select any of them. There was no column, no field on a template definition, no mapping in the egg importer. Every server fell back to log with a single regular expression.
That is connected now, end to end. A template declares a readiness, an imported Pterodactyl egg keeps all its startup markers instead of the first one, and a strategy the node cannot run is said out loud rather than silently downgraded — a udp port probe used to become a TCP connect against a port where nothing listens, and fail for the whole timeout while the server was up and taking players.
Deadlines are opt-in. A template that names no timeoutMs keeps the open-ended wait it has always had; declaring a number is how it opts into a start that is allowed to fail. Giving it a default would have handed every imported egg a deadline that now stops the server, where expiring used to print a console line.
Factorio
Not Java, so nothing reads SERVER_MEMORY. No Bukkit line — it says Hosting game at IP ADDR. Its port is UDP, the one case a port readiness cannot serve. It takes its port on the command line, so it touches no configuration file at all. And it installs from a public tarball, which is what lets continuous integration run it end to end.
The install script seeds the server private: the archive's own example asks to be listed publicly with no credentials, which produces a server that claims to be on the list and is not.
A port can be named
readiness.role — "which of the server's ports to knock on" — has been in the contract since the strategies were added and never worked, because an allocation had no name to match against. A port can carry one now, and the daemon reads it. Without this, a template declaring RCON on its own port had the daemon speak the handshake at the game port and, at the deadline, stop a server that was serving players.
A startup command can name one too: --rcon-port {{server.allocations.rcon.port}}.
And a command nobody wrote
The invocation builder dropped any token whose substitution came out empty — right for -Xmx{{SERVER_MEMORY}}M, wrong for a flag/value pair, where it left the bare flag behind to eat the next argument. An unresolved variable now refuses the start and names what to create, on the install path as well as the start path.
Upgrading
From the panel: Settings → Update now. Nothing changes for an existing server: templates that declare none of this keep their startupDetection, their stop command and their 30-second stop, and send the daemon a byte-identical configuration.
Not yet
Factorio speaks RCON and its template does not use it. Stopping a server over RCON — for the games that read no standard input at all — comes next.
v0.3.2
A server owner could write files anywhere on the host, as root. This closes it.
The hole
The file jail probed for an existing path with access(2), which follows symlinks — so it answers ENOENT for a link whose target does not exist. A dangling link was therefore filed as a free name, the path came back unresolved with the link still on it, and the write that followed walked the link and created the target.
Planting one costs a server owner a single command from inside their own container, and nothing had to be timed:
ln -s /etc/cron.d/backdoor /home/container/notes.txt
The daemon writes as root.
What changed
Four mechanisms, none sufficient alone:
- the path walk probes with
lstatand follows a link chain by hand, so a name is seen for what it is whether or not its target exists; - every open passes
O_NOFOLLOW, which moves the refusal intoopen(2)itself — a check describes the filesystem as it was, and the server's own process keeps running; - the descriptor is read back through
/proc/self/fdand rejected if it landed outside the volume, which catches a parent directory swapped for a link; lchown,lutimesand anfchmodthrough a vetted descriptor replace the name-based calls that followed links on their way.
Reads go through the same door. The dangling variant does not apply there, but the raced one does and is worth more to an attacker: win it and the daemon opens the link's target as root and streams it back with a 200.
The install and ownership-reclaim containers, which ran as uid 0 with Docker's full default capability set, now keep only the seven a package manager and a chown -R demonstrably need.
Upgrading
From the panel: Settings → Update now. Nothing changes for existing servers, and no template or configuration needs editing.
If you run a public instance
Every server owner on your node could reach this. Updating is the whole fix; there is nothing to clean up afterwards unless you have reason to think it was used, in which case the daemon's log records every refused path under "File access refused by the jail".
v0.3.1
RCON, and with it the readiness strategy v0.3.0 declared and refused.
Added
The daemon speaks Source RCON — Valve's protocol, copied by half the game-server world: Rust, ARK, Palworld, 7 Days to Die, Factorio, Minecraft. It is written here rather than pulled in as a dependency: a package would be more code to audit than the protocol is to write, and this one handles a password.
A template can now declare readiness: { type: 'rcon', secretVariable: '...' }. It is the truest readiness signal there is, because a server answers only once it is serving rather than merely running. A refused password ends the wait immediately and says so, rather than leaving the server in "starting" for ten minutes for a reason nobody can see.
The password is named, never carried: secretVariable holds the name of a template variable, and the daemon resolves it against the server's environment at the moment it connects. No configuration payload and no log line has to hold the secret in order to describe the check.
Not yet
RCON as a stop transport — for games that do not read stdin — needs a change to the stop contract and comes separately.
Upgrading
From the panel: Settings → Update now. Nothing changes for existing servers: no shipped template uses RCON yet.
v0.3.0
The first step out of Minecraft-shaped assumptions, and it fixes a Minecraft bug on the way.
Readiness is a choice now
A server was called running when a single regular expression matched a console line — Done (12.4s)! and nothing else. That is a line a Source server will never print, and a game that says nothing on its console cannot be waited for that way at all.
Worse, the old shape had a default nobody chose: when the pattern was absent or malformed, the server went online the instant its container did. Right for a workload with no notion of ready, wrong for a Minecraft server still loading its world, and invisible either way.
Four strategies, each somebody's only option:
log— several patterns, any one of which is enough. Different versions of the same server announce themselves differently, and the Pterodactyl egg importer had to throw all but the first away.port— waits for something to accept a connection. Crude, and the only thing left for a server whose console says nothing useful.immediate— the old silent default, now written down and chosen.rcon— declared and refused: there is no RCON client yet, and pretending otherwise would call every server ready the moment its container starts.
Nothing to do on your side
startupDetection still works and is read as a single-pattern log. Every imported Pterodactyl egg carries that shape and nothing else — an import that broke the day the field moved would be a migration imposed on people who never asked for one. All seven shipped templates are unchanged.
Upgrading
From the panel: Settings → Update now.
v0.2.9
Documentation only. Two claims that described features nobody had built.
Fixed
The security guide recommended an S3 backup driver that does not exist. It named MinIO, Backblaze and Wasabi, under the heading that tells you to keep backups off the machine — advice worse than silence, since anyone following it would have found out on the day they needed the archives. The page now says to copy the archives off yourself, and says plainly that there is no object-storage driver yet.
The template guide listed ten shipped templates. Seven exist: Vanilla, Paper, Purpur, Fabric, NeoForge, Velocity and BungeeCord. Folia, Forge and Bedrock had been written down before they were built. Anything else runs through an imported Pterodactyl egg, as it always did.
Where this leaves 0.2.x
An audit of this codebase turned up nine defects. Seven were code, and 0.2.4 through 0.2.8 fixed them: a server that ignored the port it was given, a first start that ignored it again, a reinstall button that answered 404, an installation whose failure was never reported, an installation orphaned by a daemon restart, systemd units that could not find node, and a restart rate limit that systemd was silently ignoring.
Two were prose that had drifted from the code. This release is those two.
Every one of the seven shipped green through lint, typecheck and the unit suite. What found them was running the thing: by hand at first, and since 0.2.8 by a CI job that installs Hopper on a blank machine and then creates a server on a non-default port, reinstalls it, starts it and speaks Minecraft to it.
Upgrading
From the panel: Settings → Update now.
v0.2.8
The installer is now tested by running it.
Fixed
The services could not start on a machine where node is not in /usr/bin. Both systemd units named the interpreter absolutely — systemd resolves no PATH — and the installer installs node without ever telling the units where it put it. Anywhere else, the service died with 203/EXEC, a code that says the interpreter could not be run and nothing about which one.
The restart rate limit did not exist. StartLimitBurst and StartLimitIntervalSec sat in [Service]; systemd has read them in [Unit] since v230 and ignores them elsewhere, with one line in the journal to say so. A broken daemon looped in restarts unbounded — four in twenty seconds, in the run that caught it.
The installer could end with a dead daemon. It started hopperd before certbot had issued the certificate, while the node had already been registered as https and its configuration pointed at a Let's Encrypt path that did not exist yet. The node and the daemon now come after the web server, the restart is verified rather than assumed, and a certbot failure now puts the instance back on http properly instead of warning and carrying on.
Added
A CI job that installs Hopper on a blank machine and then uses it: both services stay up, the panel serves the asset its own page references, the node has ports, and a Paper server created on 25566 installs, reinstalls, starts once, carries that port in its own configuration and answers a Minecraft ping there.
Every defect above was found by that job, on its first three runs. Lint, typecheck and 266 unit tests had been green through every release that shipped them.
Upgrading
From the panel: Settings → Update now.
v0.2.7
Closes the last link of the chain that v0.2.5 and v0.2.6 opened.
Fixed
An installation interrupted by a daemon restart hung at "Installing" for ever — and the panel's own update button restarts the daemon, so taking an update while a server was installing was enough to cause it.
v0.2.6 taught the daemon to report an install that fails. It could not report one whose process no longer existed. The daemon now finds the leftover installation on startup, clears it, and records the failure nobody sent — a state you can act on, since reinstall works as of v0.2.6.
It is recorded as a failure even when the install script itself succeeded, because the work that follows the script — building the container, taking ownership of the files — did not happen. A server marked ready with nothing behind it would be the worse answer.
Verified
On real hardware, across v0.2.5 to v0.2.7:
- A Paper server created on port 25568 binds
0.0.0.0:25568on its first start and answers a Minecraft ping there. - A server stuck at "Installing" went to READY twenty seconds after a reinstall.
Upgrading
From the panel: Settings → Update now.