Hosting: a Pterodactyl egg for horizon-server

Adds packaging/server/pterodactyl-egg.json and panel-start.sh, a docs/hosting.md section,
and tests/test_server_panels.py. Part of #8238.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
HrafnaValkyrjaandClaude Sonnet 5.5 committed 2026-11-03 09:23:32 -05:00
1 parent fc02103818
commit b59777f00b
4 files changed
+243

No files matched your search

+14
View File
@@ -523,6 +523,20 @@ The service runs `horizon-server run` through the installed `current` release, s
next restart. `service print` shows what it would install, `service uninstall` removes it and leaves the server
folder alone.
## Hosting panels (Pterodactyl)
The egg installs the server build of the current Nightly (nothing of Mojang's) and runs `horizon-server`
through `packaging/server/panel-start.sh`: the first start writes `server.toml` from the panel's settings, and the
port always follows the panel's allocation. Prepare the game files on your own computer (section 2) and upload the
prepared `26.3` folder's contents to `resources/` in the server's files. Set the EULA setting to true before the
first start: until then the server stops and says why. The settings (world, seed, whitelist, players, operator,
online mode) are read on the first start; afterwards edit `server.toml`. The panel's Stop sends `stop`, which saves
the world first.
Import `packaging/server/pterodactyl-egg.json` under Nests. Its Docker image is
`ghcr.io/ptero-eggs/yolks:debian`. The `HORIZON_VERSION` variable picks the release to install (`nightly`, or a tag);
it is read at install and reinstall.
## 9. Docker (build it yourself)
No image is published: build one from the repository. The image holds none of Minecraft's files; you mount the
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# Starts horizon-server in the server folder of a hosting panel (the Pterodactyl egg; docs/hosting.md).
# The program sits in ./horizon/, the prepared game files in ./resources/. The first start writes server.toml
# from the panel's HORIZON_* variables (horizon-server init: HORIZON_EULA, WORLD, SEED, WHITELIST, PLAYERS, OP,
# ONLINE); the port always follows the panel's allocation (SERVER_PORT).
cd "$(dirname "$0")" || exit 1
if [ ! -f server.toml ]; then
./horizon/horizon-server init --yes --port "${SERVER_PORT:-25565}" || exit 1
fi
sed -i "s/^port = .*/port = ${SERVER_PORT:-25565}/" server.toml
exec ./horizon/horizon-server run
+112
View File
@@ -0,0 +1,112 @@
{
"_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO",
"meta": {
"version": "PTDL_v2",
"update_url": null
},
"exported_at": "2026-11-03T00:00:00+00:00",
"name": "Horizon",
"author": "[email protected]",
"description": "Horizon, Minecraft Java Edition 26.3 in Rust: horizon-server, one native program, no Java. Needs the game files prepared from your own copy of Minecraft (docs/hosting.md).",
"features": [],
"docker_images": {
"Debian": "ghcr.io/ptero-eggs/yolks:debian"
},
"file_denylist": [],
"startup": "sh ./horizon-start.sh",
"config": {
"files": "{}",
"startup": "{\n \"done\": \")! For help, type \"\n}",
"logs": "{}",
"stop": "stop"
},
"scripts": {
"installation": {
"script": "#!/bin/bash\n# Horizon server install (packaging/server/pterodactyl-egg.json). Runs in the installer container with the server\n# folder at /mnt/server (override with HORIZON_INSTALL_DIR for a dry run). Downloads the server build of the Horizon\n# release named by HORIZON_VERSION from GitHub, checks it against the SHA-256 GitHub publishes, and writes\n# horizon-start.sh. Nothing of Mojang's is downloaded: the prepared game files go in resources/ (docs/hosting.md).\nset -eu\nDIR=\"${HORIZON_INSTALL_DIR:-/mnt/server}\"\nREPO=horizon-rust/horizon\nASSET=horizon-server-linux-x86_64.tar.gz\nTAG=\"${HORIZON_VERSION:-nightly}\"\n[ -n \"$TAG\" ] || TAG=nightly\ndie() { echo \"horizon install: $*\" >&2; exit 1; }\ncommand -v curl >/dev/null || die \"curl is needed\"\ncommand -v sha256sum >/dev/null || die \"sha256sum is needed\"\nmkdir -p \"$DIR\"\ncd \"$DIR\"\nTMP=$(mktemp -d)\ntrap 'rm -rf \"$TMP\"' EXIT\napi() { curl -fsSL -H \"Accept: application/vnd.github+json\" \"https://api.github.com/repos/$REPO/releases/$1\"; }\nJSON=$(api \"tags/$TAG\") || die \"no release $TAG on GitHub\"\nFIELDS=$(printf '%s\\n' \"$JSON\" | awk -v asset=\"$ASSET\" '\n /\"name\": / { line = $0; sub(/.*\"name\": \"/, \"\", line); sub(/\".*/, \"\", line); current = line }\n current == asset && /\"digest\": / { line = $0; sub(/.*\"digest\": \"sha256:/, \"\", line); sub(/\".*/, \"\", line); digest = line }\n current == asset && /\"browser_download_url\": / { line = $0; sub(/.*\"browser_download_url\": \"/, \"\", line); sub(/\".*/, \"\", line); url = line }\n END { print url; print digest }')\nURL=$(printf '%s\\n' \"$FIELDS\" | sed -n 1p)\nDIGEST=$(printf '%s\\n' \"$FIELDS\" | sed -n 2p)\n[ -n \"$URL\" ] || die \"release $TAG has no $ASSET\"\n[ -n \"$DIGEST\" ] || die \"release $TAG publishes no SHA-256 for $ASSET\"\necho \"Downloading Horizon $TAG ($ASSET)...\"\ncurl -fL --progress-bar -o \"$TMP/$ASSET\" \"$URL\"\nGOT=$(sha256sum \"$TMP/$ASSET\" | cut -d' ' -f1)\n[ \"$GOT\" = \"$DIGEST\" ] || die \"$ASSET doesn't match its SHA-256 (got $GOT, expected $DIGEST): not installed\"\nmkdir -p \"$TMP/x\"\ntar -xzf \"$TMP/$ASSET\" -C \"$TMP/x\" --strip-components=1\n[ -x \"$TMP/x/horizon-server\" ] || die \"$ASSET holds no horizon-server\"\n# Replace the program, keep the server's own files (server.toml, worlds, resources/).\nrm -rf \"$DIR/horizon\"\nmv \"$TMP/x\" \"$DIR/horizon\"\nmkdir -p \"$DIR/resources\"\ncat > \"$DIR/horizon-start.sh\" <<'START'\n#!/bin/sh\n# Starts horizon-server in the server folder of a hosting panel (the Pterodactyl egg; docs/hosting.md).\n# The program sits in ./horizon/, the prepared game files in ./resources/. The first start writes server.toml\n# from the panel's HORIZON_* variables (horizon-server init: HORIZON_EULA, WORLD, SEED, WHITELIST, PLAYERS, OP,\n# ONLINE); the port always follows the panel's allocation (SERVER_PORT).\ncd \"$(dirname \"$0\")\" || exit 1\nif [ ! -f server.toml ]; then\n ./horizon/horizon-server init --yes --port \"${SERVER_PORT:-25565}\" || exit 1\nfi\nsed -i \"s/^port = .*/port = ${SERVER_PORT:-25565}/\" server.toml\nexec ./horizon/horizon-server run\nSTART\nchmod +x \"$DIR/horizon-start.sh\"\necho \"Installed Horizon $TAG. Upload the prepared 26.3 game files to resources/ (docs/hosting.md), set HORIZON_EULA to true, and start the server.\"\n",
"container": "ghcr.io/ptero-eggs/installers:debian",
"entrypoint": "bash"
}
},
"variables": [
{
"name": "Accept the Minecraft EULA",
"description": "true accepts the Minecraft EULA for this server. The first start fails until it is true.",
"env_variable": "HORIZON_EULA",
"default_value": "false",
"user_viewable": true,
"user_editable": true,
"rules": "required|string|in:true,false",
"field_type": "text"
},
{
"name": "Horizon release",
"description": "The GitHub release tag to install: nightly (the current Nightly) or an exact tag. Read at install and reinstall only.",
"env_variable": "HORIZON_VERSION",
"default_value": "nightly",
"user_viewable": true,
"user_editable": true,
"rules": "required|string|max:64",
"field_type": "text"
},
{
"name": "World name",
"description": "The world's folder name. Read on the first start only.",
"env_variable": "HORIZON_WORLD",
"default_value": "world",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|string|max:64",
"field_type": "text"
},
{
"name": "World seed",
"description": "The world's seed; empty picks a random one. Read on the first start only.",
"env_variable": "HORIZON_SEED",
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|string|max:64",
"field_type": "text"
},
{
"name": "Whitelist",
"description": "true turns the whitelist on, as in vanilla. Read on the first start only.",
"env_variable": "HORIZON_WHITELIST",
"default_value": "true",
"user_viewable": true,
"user_editable": true,
"rules": "required|string|in:true,false",
"field_type": "text"
},
{
"name": "Whitelisted players",
"description": "Names separated by commas, added to the whitelist. Read on the first start only.",
"env_variable": "HORIZON_PLAYERS",
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|string|max:512",
"field_type": "text"
},
{
"name": "Operator",
"description": "A player to make an operator. Read on the first start only.",
"env_variable": "HORIZON_OP",
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|string|max:16",
"field_type": "text"
},
{
"name": "Online mode",
"description": "true checks players' Minecraft accounts; Horizon's own client can join offline servers only for now (docs/hosting.md). Read on the first start only.",
"env_variable": "HORIZON_ONLINE",
"default_value": "true",
"user_viewable": true,
"user_editable": true,
"rules": "required|string|in:true,false",
"field_type": "text"
}
]
}
+106
View File
@@ -0,0 +1,106 @@
"""The Pterodactyl egg for horizon-server (docs/hosting.md, "Hosting panels") name only
things the server has: its HORIZON_* variables, its start script, its ready line and the `port` key of server.toml."""
import json
import re
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SERVER = ROOT / "packaging/server"
EGG = SERVER / "pterodactyl-egg.json"
START = SERVER / "panel-start.sh"
INIT_RS = (ROOT / "crates/horizon-server/src/init.rs").read_text()
CONFIG_RS = (ROOT / "crates/horizon-server/src/config.rs").read_text()
DEDICATED_RS = (ROOT / "crates/horizon-client/src/game/dedicated.rs").read_text()
# Read at install only, so init.rs does not know them.
INSTALL_ONLY = {"HORIZON_VERSION"}
# The panel allocates the port: the start script passes SERVER_PORT, so no variable for it.
PANEL_OWNED = {"HORIZON_PORT"}
def init_variables():
"""The HORIZON_* names `Answers::from_env` reads."""
body = INIT_RS.split("pub fn from_env", 1)[1].split("\n }\n", 1)[0]
return set(re.findall(r'(?:var|flag)\("(HORIZON_[A-Z]+)"\)', body))
class EggTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.egg = json.loads(EGG.read_text())
def test_fields_pterodactyl_requires_for_a_PTDL_v2_import(self):
# EggParserService accepts meta.version PTDL_v1 or PTDL_v2; the Egg model requires these.
egg = self.egg
self.assertEqual(egg["meta"]["version"], "PTDL_v2")
for key in ("name", "author", "description", "features", "docker_images", "file_denylist", "startup", "config", "scripts", "variables"):
self.assertIn(key, egg)
self.assertTrue(1 <= len(egg["name"]) <= 191)
self.assertRegex(egg["author"], r"^[^@\s]+@[^@\s]+$")
self.assertTrue(egg["docker_images"] and all(isinstance(k, str) and isinstance(v, str) for k, v in egg["docker_images"].items()))
self.assertTrue(egg["startup"].strip())
for key in ("files", "startup", "logs"):
self.assertIsInstance(json.loads(egg["config"][key]), dict) # JSON documents stored as strings
self.assertTrue(egg["config"]["stop"])
install = egg["scripts"]["installation"]
for key in ("script", "container", "entrypoint"):
self.assertTrue(install[key])
seen = set()
for var in egg["variables"]:
for key in ("name", "description", "env_variable", "default_value", "user_viewable", "user_editable", "rules", "field_type"):
self.assertIn(key, var)
self.assertRegex(var["env_variable"], r"^\w{1,191}$")
self.assertNotIn(var["env_variable"], {"STARTUP", "P_SERVER_LOCATION", "P_SERVER_UUID", "SERVER_PORT", "SERVER_IP", "SERVER_MEMORY"})
self.assertIsInstance(var["user_viewable"], bool)
self.assertIsInstance(var["user_editable"], bool)
self.assertIsInstance(var["default_value"], str)
self.assertTrue(var["rules"])
self.assertNotIn(var["env_variable"], seen)
seen.add(var["env_variable"])
def test_every_variable_is_one_the_server_reads(self):
known = init_variables() | INSTALL_ONLY
self.assertGreaterEqual(len(init_variables()), 7)
for var in self.egg["variables"]:
self.assertIn(var["env_variable"], known)
# ...and every answer init reads can be set in the panel.
self.assertEqual({v["env_variable"] for v in self.egg["variables"]} - INSTALL_ONLY, init_variables() - PANEL_OWNED)
def test_the_eula_is_never_assumed(self):
eula = next(v for v in self.egg["variables"] if v["env_variable"] == "HORIZON_EULA")
self.assertEqual(eula["default_value"], "false")
def test_the_start_script_is_the_one_in_the_repository(self):
script = self.egg["scripts"]["installation"]["script"]
embedded = script.split("<<'START'\n", 1)[1].split("\nSTART\n", 1)[0] + "\n"
self.assertEqual(embedded, START.read_text())
self.assertIn("horizon-start.sh", self.egg["startup"])
def test_the_ready_line_is_the_one_the_game_logs(self):
done = json.loads(self.egg["config"]["startup"])["done"]
self.assertIn(done, DEDICATED_RS)
def test_the_script_rewrites_a_key_server_toml_has(self):
self.assertIn('^port = ', START.read_text())
self.assertRegex(CONFIG_RS + INIT_RS, r"\bport\b")
def test_scripts_parse(self):
shell = shutil.which("sh")
if not shell:
self.skipTest("no sh")
install = self.egg["scripts"]["installation"]["script"]
with tempfile.TemporaryDirectory() as tmp:
for name, text in (("install.sh", install), ("start.sh", START.read_text())):
path = Path(tmp) / name
path.write_text(text)
interpreter = shutil.which("bash") if name == "install.sh" else shell
result = subprocess.run([interpreter, "-n", str(path)], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
if __name__ == "__main__":
unittest.main()