mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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:
1 parent
fc02103818
commit
b59777f00b
4 files changed
+243
No files matched your search
@@ -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
|
||||
|
||||
Executable
+11
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user