875 lines
38 KiB
Python
875 lines
38 KiB
Python
import os
|
|
import sys
|
|
import json
|
|
import gzip
|
|
import hashlib
|
|
import tempfile
|
|
import threading
|
|
import urllib.request
|
|
import urllib.parse
|
|
import subprocess
|
|
import configparser
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox
|
|
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
from cryptography.exceptions import InvalidSignature
|
|
|
|
try:
|
|
from PIL import Image, ImageTk
|
|
HAS_PIL = True
|
|
except ImportError:
|
|
HAS_PIL = False
|
|
|
|
# ============================================================
|
|
# CONFIGURATION - edit these values for your game/project
|
|
#
|
|
# These are the values baked into a compiled updater.exe. build_client.py
|
|
# writes this whole block (pulling values from a running server's
|
|
# /client_build_info.json, not from a local config.json - see
|
|
# build_client.py's docstring) before running PyInstaller, so in normal
|
|
# use you edit the SERVER's config.json and re-run build_client.py rather
|
|
# than touching this file by hand. See config.json / README for what each
|
|
# setting under updater_ui / connection / paths / manifest / self_update /
|
|
# signing_keys / access_keys maps to here.
|
|
#
|
|
# The block below is also read as a live fallback: if this script is run
|
|
# directly with `python updater.py` (not the compiled exe) and a
|
|
# config.json happens to sit next to it, values from config.json override
|
|
# these constants at import time - convenient for local testing without
|
|
# recompiling. A compiled exe never does this lookup (see
|
|
# CONFIG_OVERRIDES_APPLIED below) - the values below are simply what got
|
|
# baked in at build time, so a released exe never depends on a
|
|
# config.json existing on the player's machine.
|
|
# ============================================================
|
|
|
|
GAME_NAME = "My Game" # Shown in the window title
|
|
UPDATER_NAME = "Launcher" # Updater, Launcher, etc.
|
|
WINDOW_WIDTH = 450
|
|
WINDOW_HEIGHT = 280
|
|
|
|
# Banner image is scaled to exactly this size (pixels) regardless of the
|
|
# source file's actual dimensions, so the layout never shifts if someone
|
|
# drops in a differently-sized image. Keep this narrower than WINDOW_WIDTH
|
|
# and leave room for the controls below it.
|
|
BANNER_WIDTH = 430
|
|
BANNER_HEIGHT = 140
|
|
|
|
# Server address. Use either a domain or an IP - whichever you set SERVER_ADDRESS to.
|
|
# Examples: "update.mygame.com" or "123.456.78.90"
|
|
SERVER_ADDRESS = "example.com"
|
|
SERVER_PORT = "443" # 80/443 for HTTP/HTTPS or servcer port for direct ip
|
|
USE_HTTPS = True # Set True if your server is behind HTTPS/SSL
|
|
|
|
# Available client versions: display name -> access key.
|
|
# The access key is a long random hex string (generate with generate_key.py)
|
|
# and must match a key in the server's config.json "clients" dict.
|
|
# This just makes the update folder harder to stumble upon by guessing a
|
|
# short name in the URL. It is NOT real security: anyone willing to
|
|
# disassemble/decompile this exe or sniff the network traffic can recover
|
|
# the key, since the program has to know it to work. If a key ever leaks,
|
|
# generate a new one, update it here and in the server's config.json, and
|
|
# rebuild the exe - no need to rename anything else.
|
|
CLIENT_KEYS = {
|
|
"Main": "d5af3f9fd06b15fcfcd1e6204b10xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
"Test": "aa697a8988d49f161c9764c3bb54xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
}
|
|
DEFAULT_CLIENT = "Main"
|
|
|
|
# Ed25519 PUBLIC key used to verify manifest.json's signature
|
|
# (manifest.json.sig). Must match the public half of the keypair whose
|
|
# PRIVATE half is used by generate_manifest.py's SIGNING_PRIVATE_KEY.
|
|
# Generate a keypair with:
|
|
# python generate_key.py --signing-keypair
|
|
#
|
|
# This is what actually protects players: even if the server is
|
|
# compromised, spoofed via DNS, or MITM'd over plain HTTP, the attacker
|
|
# cannot produce a manifest the updater will accept without the matching
|
|
# PRIVATE key - which never ships in this exe. Unlike a shared-secret
|
|
# (HMAC) scheme, this public key is not sensitive: decompiling updater.exe
|
|
# and recovering this value gives an attacker nothing they can use to
|
|
# forge a signature, only to verify one (which they could already do by
|
|
# just running the updater).
|
|
SIGNING_PUBLIC_KEY = None # e.g. "b3a1...64 hex chars (32 bytes)..."
|
|
|
|
# If True, the updater refuses to install anything from an unsigned or
|
|
# badly-signed manifest. Turning this off makes the whole signature
|
|
# mechanism pointless - only do this for local testing.
|
|
REQUIRE_SIGNATURE = True
|
|
|
|
# Hard cap on how large a single decompressed file is allowed to be before
|
|
# the updater gives up on the compressed copy and falls back to the plain
|
|
# download. Prevents a hostile/compromised server from serving a tiny .gz
|
|
# that decompresses into gigabytes and exhausts memory ("zip bomb").
|
|
MAX_DECOMPRESSED_SIZE = 512 * 1024 * 1024 # 512 MiB, adjust for your game
|
|
|
|
# Relative path (from the game folder) to the executable that launches the game
|
|
GAME_EXE_RELATIVE_PATH = os.path.join("system", "game.exe")
|
|
|
|
# Resource files bundled with the updater (banner/icon), relative to the script/exe
|
|
BANNER_RELATIVE_PATH = "banner.png"
|
|
ICON_RELATIVE_PATH = "icon.ico"
|
|
|
|
MANIFEST_FILENAME = "manifest.json"
|
|
SIGNATURE_FILENAME = MANIFEST_FILENAME + ".sig"
|
|
|
|
# Suffix used for precompressed files on the server (must match
|
|
# COMPRESSED_SUFFIX in generate_manifest.py).
|
|
COMPRESSED_SUFFIX = ".gz"
|
|
|
|
# Local settings file (created next to the updater on first run).
|
|
SETTINGS_FILENAME = "updater.ini"
|
|
|
|
# ------------------------------------------------------------
|
|
# Self-update (the updater updates itself, not just the game)
|
|
# ------------------------------------------------------------
|
|
|
|
# Bump this every time you rebuild updater.exe. Must match the "version"
|
|
# field you put in updater_manifest.json when you publish a new build.
|
|
# Purely a monotonically increasing integer - the updater does not try to
|
|
# parse semver or anything fancier than "is the server's number bigger
|
|
# than mine".
|
|
UPDATER_VERSION = 1
|
|
|
|
# Name of the self-update manifest on the server, fetched from the SAME
|
|
# access-key folder as the game manifest (so it inherits the same routing
|
|
# and the same signature verification - see _fetch_verified_json below).
|
|
# Deliberately a separate file from manifest.json: which updater build is
|
|
# current has nothing to do with which game-client version the player
|
|
# picked (Main/Test), so it shouldn't be duplicated per client folder or
|
|
# accidentally tied to game file hashes.
|
|
UPDATER_MANIFEST_FILENAME = "updater_manifest.json"
|
|
UPDATER_MANIFEST_SIGNATURE_FILENAME = UPDATER_MANIFEST_FILENAME + ".sig"
|
|
|
|
# If True, checks for a new updater.exe once per run, before checking game
|
|
# files. If a newer signed build is available, the updater replaces itself
|
|
# and relaunches - the game-file update is deferred to the *next* run of
|
|
# the new exe, so a self-update never happens in the middle of an
|
|
# in-progress game-file update.
|
|
ENABLE_SELF_UPDATE = True
|
|
|
|
# ============================================================
|
|
# END CONFIGURATION
|
|
# ============================================================
|
|
|
|
# ------------------------------------------------------------
|
|
# Optional live override from config.json - dev/test convenience only.
|
|
#
|
|
# This block NEVER runs inside a compiled exe (sys.frozen is True there,
|
|
# so it's skipped outright) - a released exe's behavior is fully decided
|
|
# by the constants above, baked in at build time by quickstart.py. This
|
|
# exists purely so `python updater.py` picks up config.json changes
|
|
# during local testing without needing a rebuild each time.
|
|
#
|
|
# Deliberately reads only signing_keys.public - config.json never
|
|
# contains a private key at all (it lives in a standalone .txt file that
|
|
# only generate_manifest.py / generate_updater_manifest.py touch), so
|
|
# there's no "private" field here to accidentally read even by mistake.
|
|
# This block only pulls the subset of config.json that's safe/meaningful
|
|
# for a client to know, mirroring exactly what quickstart.py bakes into a
|
|
# real build.
|
|
# ------------------------------------------------------------
|
|
CONFIG_OVERRIDES_APPLIED = False
|
|
|
|
if not getattr(sys, "frozen", False):
|
|
_config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
|
|
if os.path.exists(_config_path):
|
|
try:
|
|
with open(_config_path, "r", encoding="utf-8") as _f:
|
|
_cfg = json.load(_f)
|
|
|
|
_ui = _cfg.get("updater_ui", {})
|
|
GAME_NAME = _ui.get("game_name", GAME_NAME)
|
|
UPDATER_NAME = _ui.get("updater_name", UPDATER_NAME)
|
|
WINDOW_WIDTH = _ui.get("window_width", WINDOW_WIDTH)
|
|
WINDOW_HEIGHT = _ui.get("window_height", WINDOW_HEIGHT)
|
|
BANNER_WIDTH = _ui.get("banner_width", BANNER_WIDTH)
|
|
BANNER_HEIGHT = _ui.get("banner_height", BANNER_HEIGHT)
|
|
|
|
_conn = _cfg.get("connection", {})
|
|
SERVER_ADDRESS = _conn.get("server_address", SERVER_ADDRESS)
|
|
SERVER_PORT = str(_conn.get("server_port", SERVER_PORT))
|
|
USE_HTTPS = _conn.get("use_https", USE_HTTPS)
|
|
|
|
_access_keys = _cfg.get("access_keys", {})
|
|
if _access_keys:
|
|
CLIENT_KEYS = {name: key for name, key in _access_keys.items() if key}
|
|
DEFAULT_CLIENT = _cfg.get("default_client", DEFAULT_CLIENT)
|
|
|
|
_signing = _cfg.get("signing_keys", {})
|
|
SIGNING_PUBLIC_KEY = _signing.get("public") or SIGNING_PUBLIC_KEY
|
|
REQUIRE_SIGNATURE = _cfg.get("require_signature", REQUIRE_SIGNATURE)
|
|
|
|
_paths = _cfg.get("paths", {})
|
|
GAME_EXE_RELATIVE_PATH = os.path.normpath(
|
|
_paths.get("game_exe_relative_path", GAME_EXE_RELATIVE_PATH)
|
|
)
|
|
BANNER_RELATIVE_PATH = _paths.get("banner_relative_path", BANNER_RELATIVE_PATH)
|
|
ICON_RELATIVE_PATH = _paths.get("icon_relative_path", ICON_RELATIVE_PATH)
|
|
SETTINGS_FILENAME = _paths.get("settings_filename", SETTINGS_FILENAME)
|
|
|
|
_manifest = _cfg.get("manifest", {})
|
|
MANIFEST_FILENAME = _manifest.get("manifest_filename", MANIFEST_FILENAME)
|
|
SIGNATURE_FILENAME = MANIFEST_FILENAME + ".sig"
|
|
COMPRESSED_SUFFIX = _manifest.get("compressed_suffix", COMPRESSED_SUFFIX)
|
|
MAX_DECOMPRESSED_SIZE = int(
|
|
_manifest.get("max_decompressed_size_mb", MAX_DECOMPRESSED_SIZE // (1024 * 1024))
|
|
) * 1024 * 1024
|
|
|
|
_self_update = _cfg.get("self_update", {})
|
|
ENABLE_SELF_UPDATE = _self_update.get("enabled", ENABLE_SELF_UPDATE)
|
|
UPDATER_VERSION = _self_update.get("updater_version", UPDATER_VERSION)
|
|
UPDATER_MANIFEST_FILENAME = _self_update.get(
|
|
"updater_manifest_filename", UPDATER_MANIFEST_FILENAME
|
|
)
|
|
UPDATER_MANIFEST_SIGNATURE_FILENAME = UPDATER_MANIFEST_FILENAME + ".sig"
|
|
|
|
CONFIG_OVERRIDES_APPLIED = True
|
|
except (OSError, json.JSONDecodeError, AttributeError, TypeError):
|
|
# Malformed config.json during dev testing shouldn't crash the
|
|
# app - just fall back to the constants above.
|
|
pass
|
|
|
|
|
|
def get_resource_path(relative_path):
|
|
if getattr(sys, 'frozen', False):
|
|
return os.path.join(sys._MEIPASS, relative_path)
|
|
return os.path.join(os.path.abspath("."), relative_path)
|
|
|
|
|
|
def get_base_path():
|
|
if getattr(sys, 'frozen', False):
|
|
return os.path.dirname(sys.executable)
|
|
return os.path.dirname(os.path.abspath(__file__))
|
|
|
|
|
|
BASE_PATH = get_base_path()
|
|
BANNER_FILE = get_resource_path(BANNER_RELATIVE_PATH)
|
|
ICON_FILE = get_resource_path(ICON_RELATIVE_PATH)
|
|
GAME_EXE = os.path.join(BASE_PATH, GAME_EXE_RELATIVE_PATH)
|
|
SETTINGS_FILE = os.path.join(BASE_PATH, SETTINGS_FILENAME)
|
|
|
|
|
|
class ManifestSecurityError(Exception):
|
|
"""Raised when the manifest fails signature verification or contains
|
|
an unsafe path. Always aborts the update - never partially applied."""
|
|
pass
|
|
|
|
|
|
def load_settings():
|
|
"""
|
|
Reads settings.ini next to the updater. If it doesn't exist yet (first
|
|
run) or is missing/invalid, creates it with default values and returns
|
|
those defaults. Unknown/corrupt values fall back to the default rather
|
|
than crashing the app.
|
|
"""
|
|
config = configparser.ConfigParser()
|
|
settings = {"client": DEFAULT_CLIENT}
|
|
|
|
if os.path.exists(SETTINGS_FILE):
|
|
try:
|
|
config.read(SETTINGS_FILE, encoding="utf-8")
|
|
client = config.get("updater", "client", fallback=DEFAULT_CLIENT)
|
|
if client in CLIENT_KEYS:
|
|
settings["client"] = client
|
|
except (configparser.Error, OSError):
|
|
pass # Fall back to defaults below and rewrite the file.
|
|
else:
|
|
save_settings(settings) # First run: create the file with defaults.
|
|
|
|
return settings
|
|
|
|
|
|
def save_settings(settings):
|
|
"""Writes updater.ini next to the updater. Failures are non-fatal."""
|
|
config = configparser.ConfigParser()
|
|
config["updater"] = {"client": settings.get("client", DEFAULT_CLIENT)}
|
|
try:
|
|
with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
|
|
config.write(f)
|
|
except OSError:
|
|
pass # Not critical - the app just won't remember the choice.
|
|
|
|
|
|
def build_server_base_url():
|
|
scheme = "https" if USE_HTTPS else "http"
|
|
return f"{scheme}://{SERVER_ADDRESS}:{SERVER_PORT}/"
|
|
|
|
|
|
def verify_manifest_signature(manifest_bytes, signature_hex):
|
|
"""
|
|
Verifies manifest_bytes against signature_hex using Ed25519 and
|
|
SIGNING_PUBLIC_KEY. Returns True/False.
|
|
|
|
If SIGNING_PUBLIC_KEY isn't configured, returns True only when
|
|
REQUIRE_SIGNATURE is False (test/dev mode) - this keeps "no key
|
|
configured" from silently behaving as "always trusted" in a real
|
|
deployment.
|
|
|
|
Unlike an HMAC scheme, this key is asymmetric: SIGNING_PUBLIC_KEY
|
|
can only verify signatures, never produce them. So even a full
|
|
decompilation of this exe cannot yield anything usable to forge a
|
|
manifest - the private key needed for that never ships here.
|
|
"""
|
|
if not SIGNING_PUBLIC_KEY:
|
|
return not REQUIRE_SIGNATURE
|
|
|
|
if not signature_hex:
|
|
return False
|
|
|
|
try:
|
|
public_key = Ed25519PublicKey.from_public_bytes(bytes.fromhex(SIGNING_PUBLIC_KEY))
|
|
public_key.verify(bytes.fromhex(signature_hex.strip()), manifest_bytes)
|
|
return True
|
|
except (InvalidSignature, ValueError):
|
|
return False
|
|
|
|
|
|
def sanitize_manifest_rel_path(rel_path):
|
|
"""
|
|
Validates a path taken from the (now-verified) manifest before it's
|
|
ever joined with BASE_PATH and written to disk. This is the fix for
|
|
the original path-traversal hole: previously os.path.normpath() alone
|
|
did NOT stop rel_path values like "../../whatever" or an absolute
|
|
path ("C:\\Windows\\..." or "/etc/...") from escaping the game folder
|
|
entirely, because normpath() only collapses redundant separators/dots -
|
|
it does not confine the result to any base directory.
|
|
|
|
Returns the normalized, safe, OS-specific relative path, or raises
|
|
ManifestSecurityError if the path tries to escape BASE_PATH in any way.
|
|
"""
|
|
if not rel_path or not isinstance(rel_path, str):
|
|
raise ManifestSecurityError(f"Invalid path entry: {rel_path!r}")
|
|
|
|
# Manifest paths are always forward-slash (see generate_manifest.py).
|
|
# Reject backslashes and drive letters outright - on Windows a value
|
|
# like "C:\\Windows\\System32\\x.dll" or "\\\\server\\share" must never
|
|
# reach os.path.join() as a "relative" path.
|
|
if "\\" in rel_path or ":" in rel_path:
|
|
raise ManifestSecurityError(f"Unsafe path in manifest: {rel_path!r}")
|
|
|
|
if rel_path.startswith("/"):
|
|
raise ManifestSecurityError(f"Absolute path in manifest: {rel_path!r}")
|
|
|
|
parts = rel_path.split("/")
|
|
if any(p in ("", ".", "..") for p in parts):
|
|
raise ManifestSecurityError(f"Path traversal attempt in manifest: {rel_path!r}")
|
|
|
|
local_path = os.path.normpath(os.path.join(BASE_PATH, *parts))
|
|
|
|
# Belt-and-braces: after normalization, confirm the result is still
|
|
# actually inside BASE_PATH. Catches anything the segment-level check
|
|
# above might have missed (e.g. platform-specific quirks).
|
|
base_real = os.path.realpath(BASE_PATH)
|
|
target_real = os.path.realpath(local_path)
|
|
try:
|
|
if os.path.commonpath([target_real, base_real]) != base_real:
|
|
raise ManifestSecurityError(f"Path escapes game folder: {rel_path!r}")
|
|
except ValueError:
|
|
# Different drives on Windows - definitely not inside BASE_PATH.
|
|
raise ManifestSecurityError(f"Path escapes game folder: {rel_path!r}")
|
|
|
|
return local_path
|
|
|
|
|
|
class UpdaterApp:
|
|
def __init__(self, root):
|
|
self.root = root
|
|
self.settings = load_settings()
|
|
|
|
self.root.title(f"{GAME_NAME} {UPDATER_NAME}")
|
|
self.root.geometry(f"{WINDOW_WIDTH}x{WINDOW_HEIGHT}")
|
|
self.root.resizable(False, False)
|
|
|
|
try:
|
|
self.root.iconbitmap(ICON_FILE)
|
|
except Exception:
|
|
pass
|
|
|
|
self.bg_image = self._load_banner()
|
|
if self.bg_image is not None:
|
|
tk.Label(root, image=self.bg_image).pack(pady=5)
|
|
else:
|
|
tk.Label(
|
|
root, text="[ Banner not found ]", bg="gray",
|
|
width=60, height=BANNER_HEIGHT // 20
|
|
).pack(pady=5)
|
|
|
|
# Client version selector
|
|
mode_frame = tk.Frame(root)
|
|
mode_frame.pack(pady=5)
|
|
|
|
tk.Label(mode_frame, text="Client version:").pack(side="left")
|
|
|
|
self.mode_var = tk.StringVar(value=self.settings["client"])
|
|
self.mode_combo = ttk.Combobox(
|
|
mode_frame, textvariable=self.mode_var,
|
|
values=list(CLIENT_KEYS.keys()), state="readonly", width=15
|
|
)
|
|
self.mode_combo.pack(side="left", padx=5)
|
|
self.mode_combo.bind("<<ComboboxSelected>>", self.on_client_changed)
|
|
|
|
# Status text
|
|
self.status_var = tk.StringVar(value="Select a version and click Check for Updates...")
|
|
tk.Label(root, textvariable=self.status_var).pack(pady=5)
|
|
|
|
# Progress bar
|
|
self.progress = ttk.Progressbar(root, orient="horizontal", length=400, mode="determinate")
|
|
self.progress.pack(pady=5)
|
|
|
|
# Buttons
|
|
btn_frame = tk.Frame(root)
|
|
btn_frame.pack(pady=10)
|
|
|
|
self.btn_update = tk.Button(btn_frame, text="Check for Updates", command=self.start_update, width=20)
|
|
self.btn_update.pack(side="left", padx=10)
|
|
|
|
btn_state = "normal" if os.path.exists(GAME_EXE) else "disabled"
|
|
self.btn_play = tk.Button(btn_frame, text="Play", command=self.play_game, width=20, state=btn_state)
|
|
self.btn_play.pack(side="left", padx=10)
|
|
|
|
def _load_banner(self):
|
|
"""
|
|
Loads BANNER_FILE and scales it to exactly (BANNER_WIDTH, BANNER_HEIGHT).
|
|
Uses Pillow for proper resizing when available; falls back to
|
|
tk.PhotoImage's coarse integer zoom/subsample if Pillow isn't
|
|
installed, so the app still runs without the extra dependency.
|
|
Returns None if the banner file is missing or fails to load.
|
|
"""
|
|
if not os.path.exists(BANNER_FILE):
|
|
return None
|
|
|
|
if HAS_PIL:
|
|
try:
|
|
img = Image.open(BANNER_FILE).convert("RGBA")
|
|
img = img.resize((BANNER_WIDTH, BANNER_HEIGHT), Image.LANCZOS)
|
|
return ImageTk.PhotoImage(img)
|
|
except Exception:
|
|
return None
|
|
|
|
# Pillow not available: best-effort scaling with tk.PhotoImage.
|
|
# zoom/subsample only take integer factors, so this can't hit the
|
|
# target size exactly - it just gets as close as possible.
|
|
try:
|
|
image = tk.PhotoImage(file=BANNER_FILE)
|
|
src_w, src_h = image.width(), image.height()
|
|
if src_w <= 0 or src_h <= 0:
|
|
return image
|
|
|
|
if src_w > BANNER_WIDTH or src_h > BANNER_HEIGHT:
|
|
factor = max(1, round(max(src_w / BANNER_WIDTH, src_h / BANNER_HEIGHT)))
|
|
image = image.subsample(factor, factor)
|
|
elif src_w < BANNER_WIDTH and src_h < BANNER_HEIGHT:
|
|
factor = max(1, round(min(BANNER_WIDTH / src_w, BANNER_HEIGHT / src_h)))
|
|
image = image.zoom(factor, factor)
|
|
|
|
return image
|
|
except Exception:
|
|
return None
|
|
|
|
def on_client_changed(self, event=None):
|
|
self.settings["client"] = self.mode_var.get()
|
|
save_settings(self.settings)
|
|
|
|
def update_ui(self, text=None, prog_val=None, prog_max=None):
|
|
if text: self.root.after(0, lambda: self.status_var.set(text))
|
|
if prog_max: self.root.after(0, lambda: self.progress.config(maximum=prog_max))
|
|
if prog_val is not None: self.root.after(0, lambda: self.progress.config(value=prog_val))
|
|
|
|
def get_md5(self, filepath):
|
|
if not os.path.exists(filepath):
|
|
return None
|
|
hash_md5 = hashlib.md5()
|
|
try:
|
|
with open(filepath, "rb") as f:
|
|
for chunk in iter(lambda: f.read(4096), b""):
|
|
hash_md5.update(chunk)
|
|
return hash_md5.hexdigest()
|
|
except IOError:
|
|
return None
|
|
|
|
def get_md5_bytes(self, data):
|
|
return hashlib.md5(data).hexdigest()
|
|
|
|
def start_update(self):
|
|
self.btn_update.config(state="disabled")
|
|
self.btn_play.config(state="disabled")
|
|
self.mode_combo.config(state="disabled") # Lock the selector during update
|
|
threading.Thread(target=self.update_process, daemon=True).start()
|
|
|
|
def _fetch_verified_json(self, base_url, filename, signature_filename):
|
|
"""
|
|
Downloads <filename> + <signature_filename> from base_url, verifies
|
|
the Ed25519 signature over the raw manifest bytes, and only then
|
|
parses/returns it as JSON. Shared by both the game-file manifest
|
|
and the self-update manifest - same trust model, same failure mode
|
|
(raise ManifestSecurityError, never partially trust the content).
|
|
|
|
Raises ManifestSecurityError if verification fails (or is required
|
|
but the signature is missing). Raises normal exceptions (URLError,
|
|
json errors, ...) for ordinary network/parsing failures - callers
|
|
distinguish the two so a plain network hiccup doesn't get reported
|
|
as a security incident.
|
|
"""
|
|
data_url = base_url + filename
|
|
sig_url = base_url + signature_filename
|
|
|
|
req = urllib.request.urlopen(data_url, timeout=10)
|
|
raw_bytes = req.read()
|
|
|
|
signature_hex = None
|
|
try:
|
|
sig_req = urllib.request.urlopen(sig_url, timeout=10)
|
|
signature_hex = sig_req.read().decode("utf-8", errors="ignore").strip()
|
|
except urllib.error.HTTPError:
|
|
signature_hex = None # No .sig on server - handled below.
|
|
|
|
if not verify_manifest_signature(raw_bytes, signature_hex):
|
|
if SIGNING_PUBLIC_KEY and REQUIRE_SIGNATURE:
|
|
raise ManifestSecurityError(
|
|
f"{filename}: signature verification failed - refusing to use it. "
|
|
"The server may be compromised, or the signing keys are out of sync."
|
|
)
|
|
if REQUIRE_SIGNATURE:
|
|
raise ManifestSecurityError(
|
|
f"No SIGNING_PUBLIC_KEY configured but REQUIRE_SIGNATURE is True - refusing {filename}."
|
|
)
|
|
# REQUIRE_SIGNATURE is False and no key configured: dev/test mode.
|
|
|
|
return json.loads(raw_bytes.decode("utf-8"))
|
|
|
|
def _fetch_verified_manifest(self, current_server_url):
|
|
return self._fetch_verified_json(current_server_url, MANIFEST_FILENAME, SIGNATURE_FILENAME)
|
|
|
|
def _check_self_update(self, current_server_url):
|
|
"""
|
|
Checks updater_manifest.json (signed the same way as the game
|
|
manifest) for a newer build of this very updater.exe. If one is
|
|
available, downloads it, verifies its hash, and hands off to a
|
|
tiny helper script that does the actual file swap - because a
|
|
running Windows executable cannot overwrite or delete its own
|
|
.exe file while it's still executing.
|
|
|
|
Returns True if a self-update was launched (caller should stop
|
|
immediately and let the new process take over), False if there's
|
|
nothing to do (already current, feature disabled, or the check/
|
|
download failed - any failure here is non-fatal: it just means
|
|
we continue with the regular game-file update on this old build).
|
|
"""
|
|
if not ENABLE_SELF_UPDATE:
|
|
return False
|
|
|
|
if not getattr(sys, "frozen", False):
|
|
# Running as a plain .py script (dev mode) - there's no exe to
|
|
# replace, and re-launching "python updater.py" from a batch
|
|
# script would be more surprising than helpful. Self-update
|
|
# only makes sense for the compiled artifact.
|
|
return False
|
|
|
|
try:
|
|
self.update_ui(text="Checking for launcher updates...")
|
|
update_info = self._fetch_verified_json(
|
|
current_server_url, UPDATER_MANIFEST_FILENAME, UPDATER_MANIFEST_SIGNATURE_FILENAME
|
|
)
|
|
except ManifestSecurityError:
|
|
# Re-raise: an updater_manifest.json that exists but fails
|
|
# signature verification is treated as seriously as a game
|
|
# manifest that fails verification - it's the same signal
|
|
# (compromised/spoofed server) and should stop the update the
|
|
# same way, not be silently swallowed.
|
|
raise
|
|
except Exception:
|
|
# Ordinary network/parsing failure (server doesn't have the
|
|
# file yet, timeout, etc.) - not a security event, just skip
|
|
# self-update this run and continue with the game update.
|
|
return False
|
|
|
|
remote_version = update_info.get("version")
|
|
download_name = update_info.get("filename", "updater.exe")
|
|
expected_hash = update_info.get("hash")
|
|
|
|
if not isinstance(remote_version, int) or remote_version <= UPDATER_VERSION:
|
|
return False # Already current (or manifest is malformed - fail closed).
|
|
|
|
if not expected_hash:
|
|
return False # Malformed entry - fail closed rather than install unverified.
|
|
|
|
# download_name is attacker-influenced content (it comes from the
|
|
# server, same trust boundary as the game manifest's rel_path) -
|
|
# route it through the exact same sanitizer used for game files
|
|
# instead of trusting it to be a bare filename.
|
|
try:
|
|
sanitize_manifest_rel_path(download_name)
|
|
except ManifestSecurityError:
|
|
return False
|
|
|
|
try:
|
|
self.update_ui(text=f"Downloading launcher update v{remote_version}...")
|
|
current_exe = sys.executable
|
|
new_exe_path = current_exe + ".new"
|
|
|
|
exe_url = current_server_url + urllib.parse.quote(download_name, safe='/')
|
|
with urllib.request.urlopen(exe_url, timeout=120) as resp:
|
|
new_exe_data = resp.read(MAX_DECOMPRESSED_SIZE + 1)
|
|
if len(new_exe_data) > MAX_DECOMPRESSED_SIZE:
|
|
return False
|
|
|
|
if self.get_md5_bytes(new_exe_data) != expected_hash:
|
|
return False # Corrupt or tampered download - don't install it.
|
|
|
|
self._atomic_write(new_exe_path, new_exe_data)
|
|
self._launch_self_update_helper(current_exe, new_exe_path)
|
|
return True
|
|
except Exception:
|
|
# Any failure in the download/swap path - clean up and fall
|
|
# back to running the current build. Never leaves the update
|
|
# half-applied: the swap itself only happens in the helper
|
|
# script below, after this process has already exited.
|
|
try:
|
|
stray_new_path = sys.executable + ".new"
|
|
if os.path.exists(stray_new_path):
|
|
os.remove(stray_new_path)
|
|
except OSError:
|
|
pass
|
|
return False
|
|
|
|
def _launch_self_update_helper(self, current_exe, new_exe_path):
|
|
"""
|
|
Writes a small batch script next to the exe and launches it
|
|
detached, then this process should exit immediately (the caller
|
|
does that). The script:
|
|
1. Waits for this process to actually exit (a running Windows
|
|
exe can't be replaced in place - "del" on it fails with a
|
|
sharing violation until the handle is released), retrying
|
|
for a bounded number of attempts rather than looping forever.
|
|
2. Swaps updater.exe.new into place over updater.exe.
|
|
3. Relaunches the new exe.
|
|
4. Deletes itself (a batch file CAN delete itself as its last
|
|
action on Windows, since the OS only locks the file while a
|
|
command *within* it is being read, not the whole script).
|
|
|
|
This is Windows-only (matches the rest of the codebase's
|
|
os.startfile()/PyInstaller --onefile assumptions). On non-Windows
|
|
this is a no-op; self-update there would need a different
|
|
mechanism (e.g. exec() with a swapped path) that isn't
|
|
implemented here.
|
|
"""
|
|
if os.name != "nt":
|
|
return # Unsupported platform for this helper - caller treats as failure.
|
|
|
|
pid = os.getpid()
|
|
helper_path = current_exe + ".update_helper.bat"
|
|
|
|
# Quote paths for cmd.exe; exe paths under Program Files etc. can
|
|
# contain spaces.
|
|
script = (
|
|
"@echo off\r\n"
|
|
"setlocal\r\n"
|
|
f'set "CUR={current_exe}"\r\n'
|
|
f'set "NEW={new_exe_path}"\r\n'
|
|
f'set "PID={pid}"\r\n'
|
|
"set /a TRIES=0\r\n"
|
|
":waitloop\r\n"
|
|
"set /a TRIES+=1\r\n"
|
|
"if %TRIES% GTR 100 goto giveup\r\n"
|
|
# tasklist exits 0 and prints a row if the PID is still alive;
|
|
# findstr returns non-zero (errorlevel 1) if it finds nothing,
|
|
# which is the "process has exited, safe to proceed" case.
|
|
f'tasklist /FI "PID eq %PID%" 2^>nul | findstr /C:"%PID%" >nul\r\n'
|
|
"if %ERRORLEVEL%==0 (\r\n"
|
|
" timeout /t 1 /nobreak >nul\r\n"
|
|
" goto waitloop\r\n"
|
|
")\r\n"
|
|
'move /y "%NEW%" "%CUR%" >nul\r\n'
|
|
'start "" "%CUR%"\r\n'
|
|
"goto cleanup\r\n"
|
|
":giveup\r\n"
|
|
# Couldn't confirm the old process exited after ~100 tries -
|
|
# leave .new in place rather than risk swapping over a file
|
|
# that's still in use, and don't relaunch anything.
|
|
":cleanup\r\n"
|
|
'(goto) 2>nul & del "%~f0"\r\n'
|
|
)
|
|
|
|
with open(helper_path, "w", encoding="ascii", newline="") as f:
|
|
f.write(script)
|
|
|
|
# DETACHED_PROCESS + CREATE_NEW_PROCESS_GROUP so the helper survives
|
|
# this process exiting and isn't tied to our console/job.
|
|
DETACHED_PROCESS = 0x00000008
|
|
CREATE_NEW_PROCESS_GROUP = 0x00000200
|
|
subprocess.Popen(
|
|
["cmd.exe", "/c", helper_path],
|
|
creationflags=DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP,
|
|
close_fds=True,
|
|
)
|
|
|
|
def update_process(self):
|
|
# Build URLs dynamically based on the selected client version.
|
|
# The display name is only for the UI - the actual URL uses the
|
|
# hex access key so the folder name isn't guessable from the UI text.
|
|
display_name = self.mode_var.get()
|
|
access_key = CLIENT_KEYS[display_name]
|
|
current_server_url = build_server_base_url() + f"{access_key}/"
|
|
|
|
try:
|
|
self.update_ui(text=f"Connecting to server ({display_name})...")
|
|
|
|
if self._check_self_update(current_server_url):
|
|
# A new updater.exe is downloaded and the swap-and-relaunch
|
|
# helper is already launched (detached). This process must
|
|
# exit now without touching game files - the freshly
|
|
# relaunched exe will check game files on its own next.
|
|
self.update_ui(text="Launcher update found - restarting...")
|
|
self.root.after(300, self.root.destroy)
|
|
return
|
|
|
|
server_manifest = self._fetch_verified_manifest(current_server_url)
|
|
|
|
files_to_download = []
|
|
total_files = len(server_manifest)
|
|
|
|
self.update_ui(prog_max=total_files, prog_val=0)
|
|
for i, (rel_path, entry) in enumerate(server_manifest.items()):
|
|
self.update_ui(text=f"Checking: {rel_path}", prog_val=i)
|
|
|
|
# Validate BEFORE ever joining this into a filesystem path.
|
|
# A malicious/compromised server could otherwise use a key
|
|
# like "../../../Windows/System32/x.dll" to write outside
|
|
# the game folder entirely.
|
|
local_path = sanitize_manifest_rel_path(rel_path)
|
|
local_hash = self.get_md5(local_path)
|
|
|
|
if local_hash != entry["hash"]:
|
|
files_to_download.append(rel_path)
|
|
|
|
if files_to_download:
|
|
total_dl = len(files_to_download)
|
|
self.update_ui(prog_max=total_dl, prog_val=0)
|
|
|
|
for i, rel_path in enumerate(files_to_download):
|
|
self.update_ui(text=f"Downloading ({i + 1}/{total_dl}): {rel_path}", prog_val=i)
|
|
|
|
entry = server_manifest[rel_path]
|
|
local_path = sanitize_manifest_rel_path(rel_path)
|
|
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
|
|
|
self._download_file(current_server_url, rel_path, entry, local_path)
|
|
|
|
self.update_ui(text=f"Client '{display_name}' is up to date! Ready to play.", prog_val=self.progress['maximum'])
|
|
self.root.after(0, lambda: self.btn_play.config(state="normal"))
|
|
self.root.after(0, lambda: self.btn_update.config(state="normal"))
|
|
self.root.after(0, lambda: self.mode_combo.config(state="readonly"))
|
|
|
|
except ManifestSecurityError as e:
|
|
# Security failures get their own message so they're visibly
|
|
# distinct from ordinary network hiccups - this should never
|
|
# be silently retried or ignored.
|
|
self.update_ui(text=f"Security error: {str(e)}")
|
|
self.root.after(0, lambda: messagebox.showerror("Update blocked", str(e)))
|
|
self.root.after(0, lambda: self.btn_update.config(state="normal"))
|
|
self.root.after(0, lambda: self.mode_combo.config(state="readonly"))
|
|
except Exception as e:
|
|
self.update_ui(text=f"Error: {str(e)}")
|
|
self.root.after(0, lambda: self.btn_update.config(state="normal"))
|
|
self.root.after(0, lambda: self.mode_combo.config(state="readonly"))
|
|
|
|
def _atomic_write(self, local_path, data):
|
|
"""
|
|
Writes data to local_path via a temp file + os.replace() in the
|
|
same directory, so a crash or power loss mid-write can never leave
|
|
a half-written file sitting at the real path (which previously
|
|
could be picked up as "the file" on next launch, or briefly exist
|
|
in a corrupt state if the game happened to be running concurrently).
|
|
os.replace() is atomic on both Windows and POSIX when source and
|
|
destination are on the same filesystem, which they always are here
|
|
since the temp file is created right next to the target.
|
|
"""
|
|
directory = os.path.dirname(local_path) or "."
|
|
fd, tmp_path = tempfile.mkstemp(dir=directory, prefix=".tmp_upd_")
|
|
try:
|
|
with os.fdopen(fd, "wb") as f:
|
|
f.write(data)
|
|
os.replace(tmp_path, local_path)
|
|
except Exception:
|
|
try:
|
|
os.remove(tmp_path)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
|
|
def _download_file(self, base_url, rel_path, entry, local_path):
|
|
"""
|
|
Downloads one file. If the manifest marks it as compressed, fetches
|
|
the .gz copy, verifies its hash, and decompresses it into local_path.
|
|
Otherwise downloads the original file directly. Falls back to the
|
|
uncompressed original if the compressed copy fails verification,
|
|
download, or is too large once decompressed - so a bad/missing/
|
|
oversized .gz on the server doesn't block the update and can't be
|
|
used as a memory-exhaustion ("zip bomb") vector.
|
|
"""
|
|
if entry.get("compressed"):
|
|
gz_url = base_url + urllib.parse.quote(rel_path + COMPRESSED_SUFFIX, safe='/')
|
|
try:
|
|
with urllib.request.urlopen(gz_url, timeout=30) as resp:
|
|
compressed_data = resp.read()
|
|
|
|
if self.get_md5_bytes(compressed_data) != entry.get("compressed_hash"):
|
|
raise ValueError("compressed file hash mismatch")
|
|
|
|
# Guard against decompression bombs: decompress with a hard
|
|
# byte cap instead of gzip.decompress()'s unbounded
|
|
# "read everything into memory" behavior. If the real file
|
|
# is legitimately larger than the cap for your game, raise
|
|
# MAX_DECOMPRESSED_SIZE in the config block above.
|
|
expected_size = entry.get("size", 0)
|
|
if expected_size > MAX_DECOMPRESSED_SIZE:
|
|
raise ValueError("file exceeds MAX_DECOMPRESSED_SIZE, refusing to decompress")
|
|
|
|
decompressor = gzip.GzipFile(fileobj=__import__("io").BytesIO(compressed_data))
|
|
data = decompressor.read(MAX_DECOMPRESSED_SIZE + 1)
|
|
if len(data) > MAX_DECOMPRESSED_SIZE:
|
|
raise ValueError("decompressed data exceeded MAX_DECOMPRESSED_SIZE")
|
|
|
|
if self.get_md5_bytes(data) != entry["hash"]:
|
|
raise ValueError("decompressed file hash mismatch")
|
|
|
|
self._atomic_write(local_path, data)
|
|
return
|
|
except Exception:
|
|
# Fall through to downloading the uncompressed original below.
|
|
pass
|
|
|
|
safe_url_path = urllib.parse.quote(rel_path, safe='/')
|
|
file_url = base_url + safe_url_path
|
|
with urllib.request.urlopen(file_url, timeout=60) as resp:
|
|
data = resp.read(MAX_DECOMPRESSED_SIZE + 1)
|
|
if len(data) > MAX_DECOMPRESSED_SIZE:
|
|
raise ValueError(f"{rel_path} exceeds MAX_DECOMPRESSED_SIZE, aborting download")
|
|
|
|
if self.get_md5_bytes(data) != entry["hash"]:
|
|
raise ValueError(f"{rel_path}: downloaded file hash mismatch")
|
|
|
|
self._atomic_write(local_path, data)
|
|
|
|
def play_game(self):
|
|
if os.path.exists(GAME_EXE):
|
|
game_dir = os.path.dirname(GAME_EXE)
|
|
game_exe_name = os.path.basename(GAME_EXE)
|
|
os.chdir(game_dir)
|
|
|
|
try:
|
|
os.startfile(game_exe_name)
|
|
except AttributeError:
|
|
subprocess.Popen([os.path.join(".", game_exe_name)])
|
|
|
|
self.root.destroy()
|
|
else:
|
|
messagebox.showerror("Error", f"Game executable not found:\n{GAME_EXE}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
root = tk.Tk()
|
|
app = UpdaterApp(root)
|
|
root.mainloop()
|