From 3965c828eb684b4a6890e8c750c3bea22dabd4f4 Mon Sep 17 00:00:00 2001 From: server Date: Tue, 14 Apr 2026 06:36:37 +0200 Subject: [PATCH] deploy: version systemd runtime setup --- README.md | 22 ++- deploy/systemd/README.md | 32 ++++ .../systemd/bin/metin-game-instance-start.in | 23 +++ deploy/systemd/bin/metin-wait-port | 29 +++ deploy/systemd/install_systemd.py | 169 ++++++++++++++++++ .../systemd/templates/metin-auth.service.in | 22 +++ .../templates/metin-db-ready.service.in | 16 ++ deploy/systemd/templates/metin-db.service.in | 22 +++ .../systemd/templates/metin-game@.service.in | 22 +++ .../systemd/templates/metin-server.service.in | 15 ++ 10 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 deploy/systemd/README.md create mode 100644 deploy/systemd/bin/metin-game-instance-start.in create mode 100644 deploy/systemd/bin/metin-wait-port create mode 100644 deploy/systemd/install_systemd.py create mode 100644 deploy/systemd/templates/metin-auth.service.in create mode 100644 deploy/systemd/templates/metin-db-ready.service.in create mode 100644 deploy/systemd/templates/metin-db.service.in create mode 100644 deploy/systemd/templates/metin-game@.service.in create mode 100644 deploy/systemd/templates/metin-server.service.in diff --git a/README.md b/README.md index 3e3098c..16ec082 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,26 @@ This tutorial will be showing how to install in both FreeBSD and Windows environ
+## Debian (systemd) + +For the Debian runtime used in production, the repository now also contains versioned `systemd` deployment files under [deploy/systemd](deploy/systemd/README.md). + +Example installation: + +```bash +python3 deploy/systemd/install_systemd.py \ + --user mt2.jakubkadlec.dev \ + --group mt2.jakubkadlec.dev \ + --runtime-root /home/mt2.jakubkadlec.dev/metin/runtime/server \ + --channel 1 \ + --channel 99 \ + --restart +``` + +This installs the stack units, direct game/db launch helpers and a DB readiness gate so `auth` and `game` do not start before the DB socket is listening. + +
+ ## ![FreeBSD](https://metin2.download/picture/36rMX2LFRT3U8qlPNQ81nsPZ7F4yg4c3/.png) **FreeBSD** ### ⬇️ Obtaining the Serverfiles @@ -773,4 +793,4 @@ After finishing this part, you should now have knowledge of: ## Next steps After following either the **FreeBSD** method or the **Windows** method, you should be ready to proceed to cloning, building and distributing the [Client Source project](https://github.com/d1str4ught/m2dev-client-src) -⭐ **NEW**: We are now on Discord, feel free to [check us out](https://discord.gg/ETnBChu2Ca)! \ No newline at end of file +⭐ **NEW**: We are now on Discord, feel free to [check us out](https://discord.gg/ETnBChu2Ca)! diff --git a/deploy/systemd/README.md b/deploy/systemd/README.md new file mode 100644 index 0000000..de36b60 --- /dev/null +++ b/deploy/systemd/README.md @@ -0,0 +1,32 @@ +# systemd deployment + +This directory contains the versioned systemd deployment used for the Debian runtime. + +## Install + +Run the installer as `root` and point it at the live runtime root: + +```bash +cd /path/to/m2dev-server +python3 deploy/systemd/install_systemd.py \ + --user mt2.jakubkadlec.dev \ + --group mt2.jakubkadlec.dev \ + --runtime-root /home/mt2.jakubkadlec.dev/metin/runtime/server \ + --channel 1 \ + --channel 99 \ + --restart +``` + +`--channel-limit 1` is also supported and will auto-include channel `99` when present in `channels.py`. + +## What it installs + +- `metin-server.service` +- `metin-db.service` +- `metin-db-ready.service` +- `metin-auth.service` +- `metin-game@.service` +- `/usr/local/libexec/metin-game-instance-start` +- `/usr/local/libexec/metin-wait-port` + +The `metin-db-ready.service` gate waits until the DB socket is actually accepting connections before `auth` and `game` units start. diff --git a/deploy/systemd/bin/metin-game-instance-start.in b/deploy/systemd/bin/metin-game-instance-start.in new file mode 100644 index 0000000..906a42b --- /dev/null +++ b/deploy/systemd/bin/metin-game-instance-start.in @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +instance="${1:?missing instance name}" +root_dir="{{RUNTIME_ROOT}}/channels" +channel_dir="${instance%_*}" +core_dir="${instance##*_}" +workdir="${root_dir}/${channel_dir}/${core_dir}" +binary="./${instance}" + +if [ ! -d "$workdir" ]; then + echo "Missing workdir for instance ${instance}: ${workdir}" >&2 + exit 1 +fi + +cd "$workdir" + +if [ ! -x "$binary" ]; then + echo "Missing executable for instance ${instance}: ${workdir}/${instance}" >&2 + exit 1 +fi + +exec "$binary" diff --git a/deploy/systemd/bin/metin-wait-port b/deploy/systemd/bin/metin-wait-port new file mode 100644 index 0000000..de3dcb1 --- /dev/null +++ b/deploy/systemd/bin/metin-wait-port @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +import socket +import sys +import time + + +def main() -> int: + if len(sys.argv) < 3: + print("usage: metin-wait-port [timeout_secs]", file=sys.stderr) + return 2 + + host = sys.argv[1] + port = int(sys.argv[2]) + timeout_secs = float(sys.argv[3]) if len(sys.argv) > 3 else 30.0 + deadline = time.monotonic() + timeout_secs + + while time.monotonic() < deadline: + try: + with socket.create_connection((host, port), timeout=0.5): + return 0 + except OSError: + time.sleep(0.2) + + print(f"Timed out waiting for {host}:{port}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deploy/systemd/install_systemd.py b/deploy/systemd/install_systemd.py new file mode 100644 index 0000000..693a2f6 --- /dev/null +++ b/deploy/systemd/install_systemd.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +import argparse +import os +import shutil +import subprocess +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +import channels + + +TEMPLATES_DIR = SCRIPT_DIR / "templates" +BIN_DIR = SCRIPT_DIR / "bin" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Install Metin systemd units") + parser.add_argument("--user", required=True, help="Runtime user") + parser.add_argument("--group", help="Runtime group, defaults to --user") + parser.add_argument("--runtime-root", required=True, help="Absolute path to the live runtime root") + parser.add_argument("--systemd-dir", default="/etc/systemd/system", help="systemd unit destination") + parser.add_argument("--libexec-dir", default="/usr/local/libexec", help="Helper script destination") + parser.add_argument("--wait-host", default="127.0.0.1", help="DB readiness host") + parser.add_argument("--wait-port", type=int, default=9000, help="DB readiness port") + parser.add_argument("--wait-timeout", type=int, default=30, help="DB readiness timeout in seconds") + parser.add_argument("--restart", action="store_true", help="Restart metin-server.service after install") + + channel_group = parser.add_mutually_exclusive_group(required=True) + channel_group.add_argument( + "--channel-limit", + type=int, + help="Enable channels up to this number and keep channel 99 if present", + ) + channel_group.add_argument( + "--channel", + dest="channels", + action="append", + type=int, + help="Enable an explicit channel, may be passed multiple times", + ) + return parser.parse_args() + + +def ensure_root() -> None: + if os.geteuid() != 0: + print("This installer must run as root.", file=sys.stderr) + raise SystemExit(1) + + +def render_template(template_path: Path, values: dict[str, str]) -> str: + content = template_path.read_text(encoding="utf-8") + for key, value in values.items(): + content = content.replace(f"{{{{{key}}}}}", value) + return content + + +def write_text(path: Path, content: str, mode: int) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + os.chmod(path, mode) + + +def copy_file(source: Path, destination: Path, mode: int) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + os.chmod(destination, mode) + + +def resolve_channels(args: argparse.Namespace) -> list[int]: + available_channels = {int(channel_id) for channel_id in channels.CHANNEL_MAP.keys()} + + if args.channels: + selected = set(args.channels) + else: + selected = {channel_id for channel_id in available_channels if channel_id <= args.channel_limit} + + if 99 in available_channels: + selected.add(99) + + unknown = sorted(selected - available_channels) + if unknown: + print(f"Unknown channels requested: {unknown}", file=sys.stderr) + raise SystemExit(1) + + return sorted(selected) + + +def resolve_instances(selected_channels: list[int]) -> list[str]: + instances: list[str] = [] + for channel_id in selected_channels: + cores = channels.CHANNEL_MAP[int(channel_id)] + for core_id in sorted(int(core) for core in cores.keys()): + instances.append(f"channel{channel_id}_core{core_id}") + return instances + + +def run(command: list[str]) -> None: + subprocess.run(command, check=True) + + +def main() -> int: + args = parse_args() + ensure_root() + + group_name = args.group or args.user + runtime_root = str(Path(args.runtime_root).resolve()) + systemd_dir = Path(args.systemd_dir) + libexec_dir = Path(args.libexec_dir) + + selected_channels = resolve_channels(args) + instances = resolve_instances(selected_channels) + + template_values = { + "USER_NAME": args.user, + "GROUP_NAME": group_name, + "RUNTIME_ROOT": runtime_root, + "WAIT_HOST": args.wait_host, + "WAIT_PORT": str(args.wait_port), + "WAIT_TIMEOUT": str(args.wait_timeout), + "WAIT_TIMEOUT_SYSTEMD": str(args.wait_timeout + 5), + } + + unit_names = [ + "metin-server.service", + "metin-db.service", + "metin-db-ready.service", + "metin-auth.service", + "metin-game@.service", + ] + + for unit_name in unit_names: + template_path = TEMPLATES_DIR / f"{unit_name}.in" + write_text(systemd_dir / unit_name, render_template(template_path, template_values), 0o644) + + write_text( + libexec_dir / "metin-game-instance-start", + render_template(BIN_DIR / "metin-game-instance-start.in", template_values), + 0o755, + ) + copy_file(BIN_DIR / "metin-wait-port", libexec_dir / "metin-wait-port", 0o755) + + verify_units = [str(systemd_dir / unit_name) for unit_name in unit_names] + run(["systemd-analyze", "verify", *verify_units]) + run(["systemctl", "daemon-reload"]) + + enable_units = [ + "metin-server.service", + "metin-db.service", + "metin-db-ready.service", + "metin-auth.service", + *[f"metin-game@{instance}.service" for instance in instances], + ] + run(["systemctl", "enable", *enable_units]) + + if args.restart: + run(["systemctl", "restart", "metin-server.service"]) + + print("Installed systemd units for instances:") + for instance in instances: + print(f" - {instance}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deploy/systemd/templates/metin-auth.service.in b/deploy/systemd/templates/metin-auth.service.in new file mode 100644 index 0000000..86b1621 --- /dev/null +++ b/deploy/systemd/templates/metin-auth.service.in @@ -0,0 +1,22 @@ +[Unit] +Description=Metin auth runtime +After=metin-db-ready.service +Wants=metin-db-ready.service +Requires=metin-db-ready.service +PartOf=metin-server.service +Before=metin-server.service + +[Service] +Type=simple +User={{USER_NAME}} +Group={{GROUP_NAME}} +WorkingDirectory={{RUNTIME_ROOT}}/channels/auth +ExecStart={{RUNTIME_ROOT}}/channels/auth/game_auth +Restart=on-failure +RestartSec=5 +KillSignal=SIGTERM +TimeoutStopSec=60 +LimitNOFILE=65535 + +[Install] +RequiredBy=metin-server.service diff --git a/deploy/systemd/templates/metin-db-ready.service.in b/deploy/systemd/templates/metin-db-ready.service.in new file mode 100644 index 0000000..fae50a0 --- /dev/null +++ b/deploy/systemd/templates/metin-db-ready.service.in @@ -0,0 +1,16 @@ +[Unit] +Description=Metin DB readiness gate +After=metin-db.service +Wants=metin-db.service +Requires=metin-db.service +PartOf=metin-server.service +Before=metin-auth.service metin-game@.service metin-server.service + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/local/libexec/metin-wait-port {{WAIT_HOST}} {{WAIT_PORT}} {{WAIT_TIMEOUT}} +TimeoutStartSec={{WAIT_TIMEOUT_SYSTEMD}} + +[Install] +RequiredBy=metin-server.service diff --git a/deploy/systemd/templates/metin-db.service.in b/deploy/systemd/templates/metin-db.service.in new file mode 100644 index 0000000..33109fe --- /dev/null +++ b/deploy/systemd/templates/metin-db.service.in @@ -0,0 +1,22 @@ +[Unit] +Description=Metin database runtime +After=network-online.target mariadb.service +Wants=network-online.target +Requires=mariadb.service +PartOf=metin-server.service +Before=metin-server.service + +[Service] +Type=simple +User={{USER_NAME}} +Group={{GROUP_NAME}} +WorkingDirectory={{RUNTIME_ROOT}}/channels/db +ExecStart={{RUNTIME_ROOT}}/channels/db/db +Restart=on-failure +RestartSec=5 +KillSignal=SIGTERM +TimeoutStopSec=60 +LimitNOFILE=65535 + +[Install] +RequiredBy=metin-server.service diff --git a/deploy/systemd/templates/metin-game@.service.in b/deploy/systemd/templates/metin-game@.service.in new file mode 100644 index 0000000..dd15b94 --- /dev/null +++ b/deploy/systemd/templates/metin-game@.service.in @@ -0,0 +1,22 @@ +[Unit] +Description=Metin game runtime (%i) +After=metin-auth.service metin-db-ready.service +Wants=metin-auth.service metin-db-ready.service +Requires=metin-auth.service metin-db-ready.service +PartOf=metin-server.service +Before=metin-server.service + +[Service] +Type=simple +User={{USER_NAME}} +Group={{GROUP_NAME}} +WorkingDirectory={{RUNTIME_ROOT}} +ExecStart=/usr/local/libexec/metin-game-instance-start %i +Restart=on-failure +RestartSec=5 +KillSignal=SIGTERM +TimeoutStopSec=60 +LimitNOFILE=65535 + +[Install] +RequiredBy=metin-server.service diff --git a/deploy/systemd/templates/metin-server.service.in b/deploy/systemd/templates/metin-server.service.in new file mode 100644 index 0000000..cd3014c --- /dev/null +++ b/deploy/systemd/templates/metin-server.service.in @@ -0,0 +1,15 @@ +[Unit] +Description=Metin server stack +After=network-online.target mariadb.service +Wants=network-online.target +Requires=mariadb.service + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/bin/true +ExecStop=/bin/true +TimeoutStopSec=60 + +[Install] +WantedBy=multi-user.target