major fixes test

This commit is contained in:
2026-09-09 12:30:40 +03:00
parent b184ea76f4
commit bcd200b1e8
9 changed files with 1785 additions and 226 deletions
+80 -168
View File
@@ -1,199 +1,111 @@
# Universal Game Updater
# simple-updater
A simple, self-hosted updater for distributing game client files. Consists of three parts:
Лёгкий self-hosted обновлятор для игры/приложения: HTTP-сервер, который раздаёт файлы клиентам, и Windows-обновлятор (`updater.py` → `updater.exe`), который сравнивает локальные файлы с манифестом на сервере и докачивает то, что изменилось.
- **`updater.py`** — the client-side app (compiled to `updater.exe`) that players run. Checks the server for changed files and downloads only what's needed.
- **`server.py`** — a lightweight HTTP server that serves client files and manifests.
- **`generate_manifest.py`** — a script you run on your build machine to generate the manifest the server distributes.
- **`generate_key.py`** — a helper to generate access keys (see [Access keys](#access-keys) below).
## Возможности
Works with any game — just fill in the configuration blocks at the top of each script.
- **Раздача нескольких версий клиента** с одного сервера (например `Main` и `Test`) через отдельные access-ключи в URL.
- **Подпись манифестов (Ed25519)** — сервер не может незаметно подменить список файлов или их хэши; обновлятор проверяет подпись публичным ключом перед тем, как довериться манифесту.
- **Сжатие файлов** — при генерации манифеста каждый файл сжимается gzip'ом; если сжатая копия меньше оригинала, обновлятор скачивает её и разжимает локально.
- **Self-update** — сам `updater.exe` умеет проверять и устанавливать собственные новые версии, без отдельной ручной раздачи.
- **Единый `config.json`** — все настройки (сервер, клиенты, ключи, вид окна, лимиты) в одном файле.
- **`quickstart.py`** — генерирует ключи, манифесты и (по флагу) собирает exe одной командой.
## How it works
1. You put your game's files in a folder on the server (e.g. `game_client/`).
2. You run `generate_manifest.py` on that folder. It walks the folder, hashes every file (MD5), and writes a `manifest.json` listing metadata for every file. It also gzip-compresses every file into a `<file>.gz` copy next to the original — if the compressed copy isn't actually smaller (common for already-compressed formats like `.jpg`, `.mp3`, `.zip`), the `.gz` copy is discarded and the manifest just records the original.
3. `server.py` serves that folder over HTTP, keyed by an access key defined in `config.json`.
4. Players run `updater.exe`. It downloads the server's `manifest.json`, compares hashes against their local files, and for anything missing or different: downloads the `.gz` copy if one exists, verifies it, decompresses it locally, and writes the result — or downloads the original directly if there's no compressed copy.
5. Once everything matches, the "Play" button launches the game executable.
## Setup
### 1. Server
Install Python 3 (no external dependencies needed — everything uses the standard library).
Generate an access key for each client version you want to distribute:
## Быстрый старт
```bash
python generate_key.py
pip install -r requirements.txt
# сгенерировать ключи + положить файлы игры в game_client/ + собрать манифесты
python quickstart.py
# то же самое, но и собрать updater.exe
python quickstart.py --build-exe
```
This prints a random 64-character hex string. Generate one per client (e.g. one for `main`, one for `test`).
Первый запуск создаст пустые папки клиентов (`game_client`, `game_client_test` — имена берутся из `config.json`'s `clients`), сгенерирует access-ключи и пару ключей для подписи манифестов. Положите файлы игры в `game_client/`, запустите `quickstart.py` ещё раз — манифест сгенерируется и подпишется автоматически.
Edit `config.json`:
```json
{
"host": "0.0.0.0",
"port": 3232,
"clients": {
"<your-generated-hex-key>": "game_client",
"<another-generated-hex-key>": "game_client_test"
}
}
```
- `host` / `port` — what the server listens on.
- `clients` — maps an access key to the folder (relative to `server.py`) that key serves.
Put your game files in the folders referenced above (`game_client/`, etc.), then generate the manifest for each:
```bash
python generate_manifest.py game_client
python generate_manifest.py game_client_test
```
Re-run this after every update to the client files, then start (or restart) the server:
Запустить сервер:
```bash
python server.py
```
Files become available at `http://<server-address>:<port>/<access-key>/...`.
## config.json
### 2. Updater (client)
Все настройки проекта живут здесь, вложенными секциями:
Edit the configuration block at the top of `updater.py`:
| Секция | Что настраивает |
|---|---|
| `server` | host, port, лимит соединений на IP |
| `clients` | имя клиента → папка с файлами (`"Main": "game_client"`) |
| `access_keys` | имя клиента → access-ключ (генерируется автоматически) |
| `default_client` | какой клиент выбран в обновляторе по умолчанию |
| `signing_keys` | публичный ключ подписи + имя файла с приватным ключом |
| `require_signature` | обязательна ли подпись манифеста (по умолчанию да) |
| `connection` | адрес и порт сервера, HTTPS вкл/выкл — то, к чему подключается обновлятор |
| `updater_ui` | название игры, размеры окна и баннера |
| `paths` | путь к exe игры, баннеру, иконке, файлу настроек |
| `manifest` | имя файла манифеста, настройки сжатия, лимит размера файла |
| `ignore` | какие файлы/папки/расширения не попадают в манифест |
| `self_update` | включён ли self-update, текущая версия обновлятора |
```python
GAME_NAME = "My Game"
UPDATER_NAME = "Launcher"
WINDOW_WIDTH = 450
WINDOW_HEIGHT = 300
BANNER_WIDTH = 430
BANNER_HEIGHT = 140
Чтобы что-то поменять — правьте `config.json` и перезапускайте `quickstart.py` (или `server.py` для настроек сервера). Ручное редактирование `updater.py`/`generate_manifest.py` не нужно — все они читают значения оттуда.
SERVER_ADDRESS = "update.mygame.com" # domain or IP
SERVER_PORT = "443" # 80/443 for HTTP/HTTPS, or a custom port for direct IP
USE_HTTPS = True
### Приватный ключ подписи
CLIENT_KEYS = {
"Main": "<same-hex-key-as-in-server-config.json>",
"Test": "<same-hex-key-as-in-server-config.json>",
}
DEFAULT_CLIENT = "Main"
Единственный настоящий секрет в проекте — приватный ключ подписи манифестов. Он **не хранится в `config.json`**, а лежит в отдельном файле `SIGNING_PRIVATE_KEY.txt` (имя настраивается через `signing_keys.private_key_file`). Публичный ключ — в `config.json`, его можно раскрывать сколько угодно, зашивать в exe и т.д.
GAME_EXE_RELATIVE_PATH = os.path.join("system", "game.exe")
BANNER_RELATIVE_PATH = "banner.png"
ICON_RELATIVE_PATH = "icon.ico"
Держите `SIGNING_PRIVATE_KEY.txt` только на машине, где генерируются манифесты — не копируйте его вместе с остальным проектом, добавьте в `.gitignore`, если используете git.
## Как всё это работает вместе
```
config.json ──┬──> generate_key.py ────────> access-ключи в config.json
│ приватный ключ в SIGNING_PRIVATE_KEY.txt
│ публичный ключ в config.json
│
├──> generate_manifest.py ───> game_client/manifest.json + .sig
│
├──> generate_updater_manifest.py ──> game_client/updater_manifest.json + .sig
│
└──> quickstart.py ──> запускает все шаги выше по порядку,
патчит updater.py и собирает updater.exe
server.py ──> раздаёт содержимое клиентских папок по HTTP(S) под access-ключом в URL
updater.py / updater.exe ──> скачивает манифест, проверяет подпись,
докачивает изменившиеся файлы, запускает игру
```
- `SERVER_ADDRESS` accepts either a domain name or a raw IP — nothing else needs to change.
- `SERVER_PORT` / `USE_HTTPS` should match how your server is actually reachable. If nginx force-redirects port 80 to 443, set `SERVER_PORT = "443"` and `USE_HTTPS = True` directly rather than relying on the redirect — the updater downloads files one request at a time, so an extra 80→443 redirect on every file adds real overhead on large clients.
- `CLIENT_KEYS` maps a friendly name shown in the UI to the actual access key sent in the request URL. Keys must match `config.json` on the server exactly.
- `GAME_EXE_RELATIVE_PATH` is where the updater looks for the game's executable, relative to the folder the updater itself is placed in.
- `BANNER_WIDTH` / `BANNER_HEIGHT` — the banner image is always scaled to exactly this size, so it doesn't matter what dimensions the source `banner.png` actually has; the layout won't shift.
## Ручной запуск отдельных шагов
Banner scaling uses [Pillow](https://pypi.org/project/Pillow/) if it's installed (`pip install Pillow`), for proper resizing. If Pillow isn't available, the updater falls back to Tkinter's built-in (cruder, integer-only) zoom/subsample — it'll still work, just less precisely scaled. Install Pillow for best results, and make sure it's bundled when compiling (see below).
Then compile with your favorite tool, e.g. [PyInstaller](https://pyinstaller.org/):
Если `quickstart.py` не подходит (например, генерация ключей и манифестов происходит на разных машинах), каждый скрипт можно запускать по отдельности:
```bash
pyinstaller --onefile --noconsole --icon=icon.ico --add-data "banner.png;." --add-data "icon.ico;." updater.py
# access-ключ для клиента, сохранить в config.json
python generate_key.py --client Main --save
# пара ключей для подписи манифестов
python generate_key.py --signing-keypair --save
# сгенерировать и подписать манифест для одной клиентской папки
python generate_manifest.py game_client
# опубликовать новую версию updater.exe для self-update
python generate_updater_manifest.py dist/updater.exe game_client --version 2
# запустить сервер
python server.py
```
Ship the resulting `updater.exe` alongside the game folder.
## Требования
## Settings
On first run, the updater creates a `settings.ini` file next to itself with the selected client version:
```ini
[updater]
client = Main
```
pip install -r requirements.txt
```
Whenever the player changes the client version dropdown, it's saved immediately so the choice persists across restarts. If `settings.ini` is missing, corrupted, or contains an unrecognized client name, the updater silently falls back to `DEFAULT_CLIENT` and continues — it never crashes over a bad settings file.
`cryptography` — подпись/проверка манифестов (Ed25519), нужен и при генерации манифестов, и при сборке `updater.exe`.
`Pillow` — опционален, используется `updater.py` для качественного масштабирования баннера; без него баннер всё равно отрисуется, просто грубее.
This is currently the only setting. To add more later, extend `load_settings()` / `save_settings()` in `updater.py` with additional keys under the same `[updater]` section.
## Access keys
Client folders are addressed by a long random hex key instead of a plain name like `main`, so the update path isn't obvious or easy to stumble on by guessing.
**This is obscurity, not real security.** The key has to live inside `updater.exe` for it to work, and anyone motivated enough to disassemble the executable or inspect network traffic can recover it. Don't rely on it to gate anything sensitive.
If a key leaks and you want to rotate it:
1. Generate a new key with `generate_key.py`.
2. Update it in the server's `config.json`.
3. Update it in `updater.py`'s `CLIENT_KEYS`.
4. Rebuild and redistribute `updater.exe`.
No folder renaming or manifest regeneration needed.
## Ignoring files during manifest generation
Edit the configuration block at the top of `generate_manifest.py`:
```python
IGNORE_FILES = {
MANIFEST_FILENAME,
"updater.exe",
"Option.ini",
"WindowsInfo.ini",
}
IGNORE_FOLDERS = {
"screenshots",
"logs",
"replays",
}
IGNORE_EXTENSIONS = {
".log",
}
```
Add any player-specific files (settings, saves, screenshots, logs) here so the updater never overwrites or deletes them.
## Compression
`generate_manifest.py` gzip-compresses every distributable file into a `<file>.gz` copy alongside the original, unconditionally — no file-type filtering. If the compressed copy ends up the same size or larger than the original (common for already-compressed formats), it's discarded automatically and that file is just served uncompressed; there's nothing to configure per file type.
The manifest records both the original file's hash/size and, when a compressed copy exists, its hash/size too:
```json
{
"script.txt": {
"hash": "d3650c70e7d942736c3ca77daf381262",
"size": 60000,
"compressed": true,
"compressed_size": 177,
"compressed_hash": "852ab2df07a4c65cddd6ed3ec0282301"
},
"texture.bin": {
"hash": "1b6d041423b9863dee54e87fed1f0cdd",
"size": 20000,
"compressed": false
}
}
```
`updater.py` uses this to download the smaller `.gz` copy when one is available, verifies its hash, decompresses it in memory, and verifies the decompressed result against the original hash before writing it to disk. If anything about the compressed copy fails (missing, corrupted, hash mismatch), it automatically falls back to downloading the uncompressed original — a bad `.gz` on the server never blocks an update.
Two settings control this, and must be kept in sync between `generate_manifest.py` and `updater.py`:
- `COMPRESSED_SUFFIX` (default `.gz`) — the suffix used for compressed copies.
- `COMPRESSION_LEVEL` (default `6`, in `generate_manifest.py` only) — gzip level 1 (fastest, larger) to 9 (slowest, smallest).
Re-run `generate_manifest.py` after any change to the client files — it regenerates both the manifest and the `.gz` copies. Compressed output is generated with a fixed mtime so re-running it without any real file changes produces byte-identical `.gz` files and stable hashes.
## Notes / limitations
- File integrity is checked with MD5 — fine for detecting changed files, not intended as a cryptographic guarantee.
- Compression roughly doubles the disk space needed on the server for each client version (original + `.gz` copies), in exchange for faster downloads for compressible files.
- The server has no built-in rate limiting or HTTPS; put it behind a reverse proxy (nginx, Caddy) if you need either.
- The server never serves directory listings — a bare `/`, an unknown key, or a path that tries to escape the client folder (`..`) all get a 404, including the server's own script/config files.
- `updater.py` uses Tkinter for the UI, which ships with most standard Python installs on Windows.
Для сборки `updater.exe` также нужен `pyinstaller` (`pip install pyinstaller`), если запускаете `quickstart.py --build-exe`.
+70 -2
View File
@@ -1,8 +1,76 @@
{
"server": {
"host": "0.0.0.0",
"port": 3232,
"max_connections_per_ip": 20
},
"clients": {
"d5af3f9fd06b15fcfcd1e6204b10804edbb9936b094ece123ce38ee7d3382ee0": "game_client",
"aa697a8988d49f161c9764c3bb54b9111ad016bca7470243ea3aa2ee4fba37c1": "game_client_test"
"Main": "game_client",
"Test": "game_client_test"
},
"access_keys": {
"Main": "",
"Test": ""
},
"default_client": "Main",
"signing_keys": {
"public": "",
"private_key_file": "SIGNING_PRIVATE_KEY.txt"
},
"require_signature": true,
"connection": {
"server_address": "example.com",
"server_port": "443",
"use_https": true
},
"updater_ui": {
"game_name": "My Game",
"updater_name": "Launcher",
"window_width": 450,
"window_height": 280,
"banner_width": 430,
"banner_height": 140
},
"paths": {
"game_exe_relative_path": "system/game.exe",
"banner_relative_path": "banner.png",
"icon_relative_path": "icon.ico",
"settings_filename": "updater.ini"
},
"manifest": {
"manifest_filename": "manifest.json",
"compressed_suffix": ".gz",
"compression_level": 6,
"max_decompressed_size_mb": 512
},
"ignore": {
"files": [
"Option.ini",
"WindowsInfo.ini"
],
"folders": [
"screenshots",
"logs",
"replays"
],
"extensions": [
".log"
]
},
"self_update": {
"enabled": true,
"updater_version": 1,
"updater_manifest_filename": "updater_manifest.json",
"download_filename": "updater.exe"
}
}
+252 -8
View File
@@ -1,16 +1,260 @@
#!/usr/bin/env python3
"""
generate_key.py
----------------------------------
Генерирует ключи для simple-updater и умеет сразу сохранять их в
config.json (см. --save), чтобы не копировать хэши руками между файлами.
Без аргументов — печатает один 64-символьный hex access-key (используется
в config.json's access_keys для маршрутизации клиентских папок). Это НЕ
криптографический секрет в строгом смысле - он обязан жить внутри
updater.exe, чтобы обновлятор работал, и его можно извлечь декомпиляцией.
Он просто не даёт угадать URL клиентской папки наугад.
С флагом --signing-keypair генерирует пару Ed25519-ключей для подписи
манифестов:
- приватный ключ - с --save сохраняется в отдельный текстовый файл
(по умолчанию SIGNING_PRIVATE_KEY.txt, см. config.json's
signing_keys.private_key_file) НЕ внутри config.json. Остаётся
ТОЛЬКО на машине, где вы генерируете манифесты
(generate_manifest.py / generate_updater_manifest.py). Никогда не
попадает ни на сервер, ни в updater.exe, ни в config.json - именно
чтобы его нельзя было случайно закоммитить или разослать вместе с
остальными настройками.
- публичный ключ - идёт в config.json (signing_keys.public) и
вписывается в updater.py перед компиляцией exe. Его можно
раскрывать сколько угодно: имея только публичный ключ, подделать
подпись невозможно (в отличие от HMAC, где один и тот же секрет и
подписывает, и проверяет - и потому достать его из exe означало бы
получить возможность подделывать манифесты).
Это меняет модель угроз по сравнению с HMAC: даже если updater.exe
полностью декомпилирован, из него можно извлечь только публичный ключ,
которым НИЧЕГО подписать нельзя - только проверить чужую подпись.
Примеры:
python generate_key.py --client Main --save
python generate_key.py --signing-keypair --save
"""
import os
import json
import secrets
import argparse
# Generates a long random hex key suitable for use as a client access key.
# Run this once per client (main/test/etc.) and put the result into:
# - server-side config.json (as the dict key, mapped to the folder)
# - updater.py CLIENT_KEYS (as the value, mapped to the display name)
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives import serialization
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
_CONFIG_PATH = os.path.join(_SCRIPT_DIR, "config.json")
def generate_access_key() -> str:
return secrets.token_hex(32) # 64 hex chars
def generate_signing_keypair():
"""
Returns (private_hex, public_hex) - both raw 32-byte Ed25519 keys,
hex-encoded. private_hex goes only in generate_manifest.py's config
(kept secret, ideally outside version control). public_hex goes in
updater.py and is safe to publish/decompile.
"""
private_key = Ed25519PrivateKey.generate()
public_key = private_key.public_key()
private_bytes = private_key.private_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PrivateFormat.Raw,
encryption_algorithm=serialization.NoEncryption(),
)
public_bytes = public_key.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
return private_bytes.hex(), public_bytes.hex()
def _private_key_file_path(cfg):
filename = cfg.get("signing_keys", {}).get("private_key_file", "SIGNING_PRIVATE_KEY.txt")
return os.path.join(_SCRIPT_DIR, filename), filename
def write_private_key_file(path, private_hex):
"""
Writes the Ed25519 private signing key to a standalone .txt file next
to the scripts, NOT into config.json. This key is the one real secret
in the whole project - it must never end up in a file that gets
zipped up, committed, or handed to anyone else, and config.json is
exactly the kind of file that tends to get copied around wholesale
(backed up, committed "just to be safe", sent to a teammate along with
the rest of the project). A dedicated file makes the secret visually
obvious and easy to .gitignore / delete on its own, separate from the
non-secret settings in config.json.
"""
sep = "=" * 70
lines = [
sep,
"СЕКРЕТНЫЙ КЛЮЧ - НЕ ДЕЛИТЕСЬ И НЕ КОММИТЬТЕ ЭТОТ ФАЙЛ",
sep,
"",
"Это приватный Ed25519-ключ для подписи manifest.json /",
"updater_manifest.json (generate_manifest.py, generate_updater_manifest.py,",
"quickstart.py). Кто угодно с этим ключом может подписать поддельный",
"манифест, который ваши игроки примут как настоящий, - то есть",
"фактически получит возможность выполнить произвольный код на машинах",
"всех игроков через игровые файлы или сам updater.exe.",
"",
"ЧТО С ЭТИМ ФАЙЛОМ ДЕЛАТЬ:",
" - Держите его ТОЛЬКО на машине, где вы генерируете манифесты.",
" - Никогда не кладите рядом с config.json в архив/бэкап/репозиторий,",
" который может куда-то уехать целиком.",
" - Добавьте имя файла в .gitignore, если используете git.",
" - Если ключ больше не нужен на этой машине (например, манифесты",
" теперь генерируются на другом сервере) - удалите файл. Он не",
" нужен ни серверу (server.py), ни updater.exe - только скриптам",
" генерации манифестов.",
" - Если ключ утёк - сгенерируйте новую пару (generate_key.py",
" --signing-keypair --save), заново сгенерируйте ВСЕ манифесты и",
" пересоберите updater.exe с новым публичным ключом.",
"",
sep,
"",
]
banner = "\n".join(lines) + "\n"
with open(path, "w", encoding="utf-8") as f:
f.write(banner)
f.write(f"SIGNING_PRIVATE_KEY={private_hex}\n")
def read_private_key_file(path):
"""
Reads the hex key back out of a file written by write_private_key_file.
Tolerates the human-readable banner around it - only the
SIGNING_PRIVATE_KEY=... line matters.
"""
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line.startswith("SIGNING_PRIVATE_KEY="):
return line.split("=", 1)[1].strip()
return None
def load_config():
if not os.path.exists(_CONFIG_PATH):
raise SystemExit(f"config.json not found at {_CONFIG_PATH} - can't use --save without it.")
with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
return json.load(f)
def save_config(cfg):
"""
Writes config.json back out, preserving key order/indentation style
(indent=4) so diffs stay readable. Not atomic-write-protected like the
runtime files elsewhere in this project - this is a one-off dev-time
tool run interactively, not something that needs to survive a crash
mid-write the way a live server/updater does.
"""
with open(_CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=4)
f.write("\n")
def save_access_key(client_name, key):
cfg = load_config()
cfg.setdefault("access_keys", {})
existing = cfg["access_keys"].get(client_name)
if existing:
print(f"WARNING: overwriting existing access key for '{client_name}' in config.json.")
print(" Any already-built updater.exe with the old key will stop being able")
print(" to reach the server until it's rebuilt with the new one.")
cfg["access_keys"][client_name] = key
cfg.setdefault("clients", {})
if client_name not in cfg["clients"]:
# Keep clients/access_keys in sync - a key with no matching folder
# entry would be silently skipped by server.py's startup check.
default_folder = f"game_client_{client_name.lower()}" if client_name.lower() != "main" else "game_client"
cfg["clients"][client_name] = default_folder
print(f"Also added clients.{client_name} = \"{default_folder}\" (edit this in config.json if needed).")
save_config(cfg)
print(f"Saved access_keys.{client_name} to config.json.")
def save_signing_keypair(private_hex, public_hex):
"""
Public half goes into config.json as usual - it's not a secret.
Private half goes into a standalone .txt file NEXT TO config.json,
never into config.json itself (see write_private_key_file's docstring
for why). Prints a loud reminder either way, since this overwriting
the private key file, if one already existed, invalidates every
manifest signed with the old key.
"""
cfg = load_config()
cfg.setdefault("signing_keys", {})
key_path, key_filename = _private_key_file_path(cfg)
if cfg["signing_keys"].get("public") or os.path.exists(key_path):
print("WARNING: overwriting existing signing keypair.")
print(" Manifests signed with the old private key will fail verification")
print(" against exes built with the new public key, and vice versa -")
print(" regenerate all manifests and rebuild updater.exe after this.")
cfg["signing_keys"]["public"] = public_hex
cfg["signing_keys"].setdefault("private_key_file", key_filename)
save_config(cfg)
write_private_key_file(key_path, private_hex)
print(f"Saved signing_keys.public to config.json.")
print(f"Saved PRIVATE key to {key_filename} - read the warning inside that file.")
print(f"{key_filename} is NOT part of config.json and should never be committed or shared.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Generate a random hex access key for a client.")
parser.add_argument("--bytes", type=int, default=32,
help="Number of random bytes (default 32 -> 64 hex chars).")
parser = argparse.ArgumentParser(
description="Генератор ключей для simple-updater.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--signing-keypair",
action="store_true",
help=(
"Сгенерировать пару Ed25519-ключей для подписи манифестов "
"вместо access key. Печатает и приватный, и публичный ключ."
),
)
parser.add_argument(
"--client",
metavar="NAME",
help="Имя клиента (как 'Main' или 'Test') для сохранения access key под ним в config.json. Требуется вместе с --save, если не указан --signing-keypair.",
)
parser.add_argument(
"--save",
action="store_true",
help="Сохранить сгенерированный ключ прямо в config.json вместо/в дополнение к печати в консоль.",
)
args = parser.parse_args()
key = secrets.token_hex(args.bytes)
if args.signing_keypair:
private_hex, public_hex = generate_signing_keypair()
if args.save:
save_signing_keypair(private_hex, public_hex)
else:
print("Приватный ключ (держите в секрете, только для генерации манифестов):")
print(f" {private_hex}")
print()
print("Публичный ключ (безопасно зашивать в updater.exe):")
print(f" {public_hex}")
print()
print("Без --save ключи никуда не сохраняются - скопируйте их сами,")
print("или перезапустите с --save чтобы приватный ключ ушёл в отдельный")
print(".txt файл, а публичный - в config.json.")
else:
key = generate_access_key()
print(key)
if args.save:
if not args.client:
raise SystemExit("--save для access key требует --client NAME (например --client Main).")
save_access_key(args.client, key)
+150 -19
View File
@@ -1,42 +1,108 @@
import os
import json
import gzip
import shutil
import hashlib
import argparse
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
# ============================================================
# CONFIGURATION - edit these values for your game/project
# CONFIGURATION - loaded from config.json, with hardcoded fallbacks below
# for anyone running this script without a config.json present (e.g. it
# was copied out on its own). Edit config.json, not this file - see
# README/quickstart.py for the intended workflow.
# ============================================================
MANIFEST_FILENAME = "manifest.json"
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
_CONFIG_PATH = os.path.join(_SCRIPT_DIR, "config.json")
def _load_config():
if not os.path.exists(_CONFIG_PATH):
return {}
try:
with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return {}
def _read_private_signing_key(cfg):
"""
The private signing key lives in a standalone .txt file next to the
scripts (see generate_key.py's write_private_key_file), NOT inside
config.json - config.json only stores which filename to look for
(signing_keys.private_key_file), so the secret itself never ends up
in a file that's likely to get committed, zipped, or shared alongside
the rest of the project's settings.
Returns the hex key string, or None if the file doesn't exist or has
no usable key in it (signing is then disabled - see SIGNING_PRIVATE_KEY
below and the "unsigned manifest" warning where it's used).
"""
filename = cfg.get("signing_keys", {}).get("private_key_file", "SIGNING_PRIVATE_KEY.txt")
key_path = os.path.join(_SCRIPT_DIR, filename)
if not os.path.exists(key_path):
return None
try:
with open(key_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line.startswith("SIGNING_PRIVATE_KEY="):
return line.split("=", 1)[1].strip() or None
except OSError:
return None
return None
_CFG = _load_config()
_manifest_cfg = _CFG.get("manifest", {})
_ignore_cfg = _CFG.get("ignore", {})
MANIFEST_FILENAME = _manifest_cfg.get("manifest_filename", "manifest.json")
# Suffix used for the precompressed copy of each file, stored alongside the
# original (e.g. "texture.dds" -> "texture.dds.gz").
COMPRESSED_SUFFIX = ".gz"
COMPRESSED_SUFFIX = _manifest_cfg.get("compressed_suffix", ".gz")
# gzip compression level, 1 (fastest, larger) to 9 (slowest, smaller).
COMPRESSION_LEVEL = 6
COMPRESSION_LEVEL = _manifest_cfg.get("compression_level", 6)
# Files the updater must never touch (launcher itself, local settings, etc.)
# Ed25519 PRIVATE key used to sign manifest.json, hex-encoded (32 raw bytes
# = 64 hex chars). Generate a keypair with:
# python generate_key.py --signing-keypair --save
# This is the actual secret - keep it OFF the server and OFF any machine
# that ships to players. It only needs to exist wherever you run this
# script. The matching PUBLIC key goes into updater.py's SIGNING_PUBLIC_KEY
# instead of a shared secret, so decompiling updater.exe never exposes
# anything that could be used to forge a manifest (unlike a shared-secret
# HMAC key, which the client would also need to hold and could therefore
# leak from the exe).
# Read from a standalone .txt file (see _read_private_signing_key above),
# never from config.json - if that file is missing, signing is disabled
# (NOT recommended, see README).
SIGNING_PRIVATE_KEY = _read_private_signing_key(_CFG)
# Files the updater must never touch (launcher itself, local settings,
# self-update artifacts, etc.) - the self-update filenames are always
# ignored regardless of config.json, since scanning/hashing them as
# regular game files would never make sense. config.json's ignore.files
# is additive on top of this fixed set.
IGNORE_FILES = {
MANIFEST_FILENAME,
MANIFEST_FILENAME + ".sig",
"updater.exe", # Your compiled updater executable
"Option.ini", # Example: graphics/sound settings
"WindowsInfo.ini", # Example: window layout settings
}
"updater_manifest.json", # Self-update manifest (see generate_updater_manifest.py)
"updater_manifest.json.sig",
"updater.exe.new", # Leftover from an in-progress self-update, if generate_manifest.py
"updater.exe.update_helper.bat", # is ever run inside a live client folder rather than a build dir
} | set(_ignore_cfg.get("files", ["Option.ini", "WindowsInfo.ini"]))
# Folders the updater must never scan or update
IGNORE_FOLDERS = {
"screenshots",
"logs",
"replays",
}
IGNORE_FOLDERS = set(_ignore_cfg.get("folders", ["screenshots", "logs", "replays"]))
# File extensions to always skip (lowercase, without the dot doesn't matter, include the dot)
IGNORE_EXTENSIONS = {
".log",
}
IGNORE_EXTENSIONS = set(_ignore_cfg.get("extensions", [".log"]))
# ============================================================
# END CONFIGURATION
@@ -60,6 +126,25 @@ def get_md5(filepath):
return None
def is_safe_rel_path(rel_path_web):
"""
Defense in depth: refuses to put a path in the manifest that the
updater's own sanitizer would reject anyway (absolute path, ".."
segments, drive letters, backslashes). Should never trigger in
practice since paths come from os.walk() on a real folder, but if
it ever does, we want a loud failure here rather than a silently
unusable manifest entry on the client.
"""
if not rel_path_web or rel_path_web.startswith("/"):
return False
if ":" in rel_path_web or "\\" in rel_path_web:
return False
parts = rel_path_web.split("/")
if any(p in ("", ".", "..") for p in parts):
return False
return True
def compress_file(filepath, compressed_path):
"""
Compresses filepath into compressed_path with gzip. Every file is
@@ -95,6 +180,24 @@ def compress_file(filepath, compressed_path):
return compressed_size, compressed_hash
def sign_manifest(manifest_bytes):
"""
Returns a hex Ed25519 signature over the exact bytes written to
manifest.json, or None if SIGNING_PRIVATE_KEY isn't configured. The
updater verifies this signature with the matching PUBLIC key before
trusting anything in the manifest, so a compromised/spoofed server
can no longer hand out a manifest pointing files at arbitrary paths
or vouching for a tampered game.exe - forging a valid signature
requires this private key, which is never present in updater.exe or
on the server, only wherever manifests are generated.
"""
if not SIGNING_PRIVATE_KEY:
return None
private_key = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(SIGNING_PRIVATE_KEY))
signature = private_key.sign(manifest_bytes)
return signature.hex()
def generate(client_dir):
if not os.path.exists(client_dir) or not os.path.isdir(client_dir):
print(f"Error: directory '{client_dir}' not found!")
@@ -102,11 +205,13 @@ def generate(client_dir):
manifest = {}
manifest_path = os.path.join(client_dir, MANIFEST_FILENAME)
sig_path = manifest_path + ".sig"
print(f"Scanning client folder: {client_dir}")
total_original = 0
total_saved = 0
skipped_unsafe = 0
for root, dirs, files in os.walk(client_dir):
# Filter out ignored folders (case-insensitive)
@@ -128,6 +233,11 @@ def generate(client_dir):
rel_path = os.path.relpath(filepath, client_dir)
rel_path_web = rel_path.replace("\\", "/")
if not is_safe_rel_path(rel_path_web):
print(f" WARNING: skipping unsafe path (should never happen): {rel_path_web}")
skipped_unsafe += 1
continue
file_hash = get_md5(filepath)
if not file_hash:
continue
@@ -152,10 +262,31 @@ def generate(client_dir):
manifest[rel_path_web] = entry
try:
with open(manifest_path, "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=4)
# Serialize once with sorted, fixed formatting so the byte sequence
# that gets hashed/signed is deterministic and matches exactly what
# gets written to disk (and what the updater will read back).
manifest_bytes = json.dumps(manifest, indent=4, sort_keys=True).encode("utf-8")
with open(manifest_path, "wb") as f:
f.write(manifest_bytes)
signature = sign_manifest(manifest_bytes)
if signature:
with open(sig_path, "w", encoding="utf-8") as f:
f.write(signature)
print(f"Manifest signed (Ed25519). Signature saved to: {sig_path}")
else:
print("WARNING: no private signing key found - manifest.json is UNSIGNED.")
print(" Run: python generate_key.py --signing-keypair --save")
print(" The updater will refuse unsigned manifests unless it")
print(" has REQUIRE_SIGNATURE = False, which is not recommended.")
if os.path.exists(sig_path):
os.remove(sig_path) # don't leave a stale signature lying around
print(f"Done! Manifest saved to: {manifest_path}")
print(f"Total files in manifest: {len(manifest)}")
if skipped_unsafe:
print(f"Skipped {skipped_unsafe} file(s) with unsafe paths.")
if total_original:
pct = (total_saved / total_original) * 100
print(f"Compression saved {total_saved:,} bytes of {total_original:,} ({pct:.1f}%)")
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""
generate_updater_manifest.py
----------------------------------
Публикует новую версию updater.exe для self-update.
Запускается на build-машине после того, как вы пересобрали updater.exe
(PyInstaller) с увеличенным UPDATER_VERSION внутри updater.py. Копирует
exe в раздаваемую папку клиента(ов), считает MD5, подписывает манифест тем
же Ed25519 приватным ключом, что и generate_manifest.py (оба читают его из
одного .txt файла, см. config.json's signing_keys.private_key_file), и
кладёт рядом updater_manifest.json + updater_manifest.json.sig.
Usage:
python generate_updater_manifest.py <path-to-new-updater.exe> <client_dir> [<client_dir> ...] [--version N]
Если --version не указан, берётся self_update.updater_version из
config.json - удобно, если вы просто хотите опубликовать то, что
quickstart.py только что собрал.
Example:
python generate_updater_manifest.py dist/updater.exe game_client game_client_test
python generate_updater_manifest.py dist/updater.exe game_client --version 3
Кладёт updater_manifest.json/.sig в КАЖДУЮ указанную client_dir - self-update
проверяется через тот же access-key URL, что и обычный игровой манифест
(см. UPDATER_MANIFEST_FILENAME в updater.py), так что файл должен лежать
там же, где manifest.json.
"""
import os
import sys
import json
import shutil
import hashlib
import argparse
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
_CONFIG_PATH = os.path.join(_SCRIPT_DIR, "config.json")
def _load_config():
if not os.path.exists(_CONFIG_PATH):
return {}
try:
with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return {}
def _read_private_signing_key(cfg):
"""
Same convention as generate_manifest.py: the private key lives in a
standalone .txt file (see generate_key.py's write_private_key_file),
never in config.json. Both scripts read the SAME file, named by
config.json's signing_keys.private_key_file, so as long as that file
exists in one place, the two scripts can't drift apart on which key
they sign with.
"""
filename = cfg.get("signing_keys", {}).get("private_key_file", "SIGNING_PRIVATE_KEY.txt")
key_path = os.path.join(_SCRIPT_DIR, filename)
if not os.path.exists(key_path):
return None
try:
with open(key_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line.startswith("SIGNING_PRIVATE_KEY="):
return line.split("=", 1)[1].strip() or None
except OSError:
return None
return None
_CFG = _load_config()
_self_update_cfg = _CFG.get("self_update", {})
# Read from the same standalone .txt file generate_manifest.py uses - see
# _read_private_signing_key above. Never stored in config.json itself.
SIGNING_PRIVATE_KEY = _read_private_signing_key(_CFG)
MANIFEST_FILENAME = _self_update_cfg.get("updater_manifest_filename", "updater_manifest.json")
DOWNLOAD_FILENAME = _self_update_cfg.get("download_filename", "updater.exe")
# Used as the default --version when the caller doesn't pass one - this is
# the version quickstart.py would have baked into updater.py right before
# building the exe, so "publish whatever config.json currently says" is a
# safe and expected default for the common case of "I just built the exe
# quickstart just produced."
CONFIGURED_VERSION = _self_update_cfg.get("updater_version")
def get_md5(filepath):
hash_md5 = hashlib.md5()
with open(filepath, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hash_md5.update(chunk)
return hash_md5.hexdigest()
def sign_bytes(data: bytes) -> str:
if not SIGNING_PRIVATE_KEY:
raise SystemExit(
"No private signing key found (expected in the .txt file named by "
"config.json's signing_keys.private_key_file). Run "
"'python generate_key.py --signing-keypair --save' first. An unsigned "
"updater_manifest.json would let updater.py's REQUIRE_SIGNATURE "
"check reject it outright (or, worse, get accepted if someone "
"disabled that check) - refusing to publish an update that "
"can't be verified as coming from you."
)
private_key = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(SIGNING_PRIVATE_KEY))
return private_key.sign(data).hex()
def publish(exe_path, version, client_dirs):
if not os.path.isfile(exe_path):
raise SystemExit(f"Error: '{exe_path}' not found.")
exe_hash = get_md5(exe_path)
exe_size = os.path.getsize(exe_path)
manifest = {
"version": version,
"filename": DOWNLOAD_FILENAME,
"hash": exe_hash,
"size": exe_size,
}
manifest_bytes = json.dumps(manifest, indent=4, sort_keys=True).encode("utf-8")
signature_hex = sign_bytes(manifest_bytes)
for client_dir in client_dirs:
if not os.path.isdir(client_dir):
print(f"WARNING: '{client_dir}' is not a directory, skipping.")
continue
dest_exe = os.path.join(client_dir, DOWNLOAD_FILENAME)
dest_manifest = os.path.join(client_dir, MANIFEST_FILENAME)
dest_sig = dest_manifest + ".sig"
shutil.copy2(exe_path, dest_exe)
with open(dest_manifest, "wb") as f:
f.write(manifest_bytes)
with open(dest_sig, "w", encoding="utf-8") as f:
f.write(signature_hex)
print(f"Published v{version} to {client_dir}/ "
f"({DOWNLOAD_FILENAME}, {MANIFEST_FILENAME}, {MANIFEST_FILENAME}.sig)")
print()
print(f"Version: {version}")
print(f"MD5: {exe_hash}")
print(f"Size: {exe_size:,} bytes")
print()
print("Reminder: players only pick this up if their currently-running")
print("updater.exe has UPDATER_VERSION < the version you just published,")
print("and ENABLE_SELF_UPDATE = True was set when THAT build was compiled.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Publish a new self-updating updater.exe build.")
parser.add_argument("exe_path", help="Path to the newly built updater.exe")
parser.add_argument("client_dirs", nargs="+", help="One or more client folders to publish into (same ones served by server.py)")
parser.add_argument(
"--version", type=int, default=CONFIGURED_VERSION,
help=(
"Integer version number, must be > UPDATER_VERSION baked into the new exe. "
"Defaults to config.json's self_update.updater_version if omitted."
),
)
args = parser.parse_args()
if args.version is None:
raise SystemExit(
"No --version given and config.json has no self_update.updater_version set - "
"pass one explicitly: generate_updater_manifest.py <exe> <client_dir> ... --version <n>"
)
publish(args.exe_path, args.version, args.client_dirs)
+442
View File
@@ -0,0 +1,442 @@
#!/usr/bin/env python3
"""
quickstart.py
----------------------------------
Одна команда вместо ручного дёргания generate_key.py / generate_manifest.py /
PyInstaller / generate_updater_manifest.py по отдельности.
Что делает, по порядку, всё с оглядкой на config.json:
1. Для каждого клиента в config.json's "clients", у которого нет
access_keys - генерирует его и сохраняет в config.json.
2. Если приватный ключ подписи (файл, заданный signing_keys.private_key_file
в config.json) отсутствует - генерирует новую Ed25519 keypair:
приватный ключ сохраняется в отдельный .txt файл, публичный - в
config.json (signing_keys.public).
3. Создаёт папки клиентов (из "clients"), если их ещё нет на диске,
рядом с этим скриптом.
4. Генерирует manifest.json + .sig для каждой клиентской папки, у которой
есть хоть один файл (пустые папки пропускаются с предупреждением -
скорее всего, вы просто ещё не положили туда файлы игры).
5. (Опционально, --build-exe) Патчит updater.py итоговыми значениями из
config.json (адрес сервера, access keys, публичный ключ подписи,
размеры окна и т.д.) и собирает updater.exe через PyInstaller.
6. (Опционально, тот же --build-exe) Публикует собранный exe как новую
self-update версию во все клиентские папки, если self_update.enabled.
Каждый шаг идемпотентен и безопасен перезапускать повторно: уже
сгенерированные ключи не трогаются (кроме как через generate_key.py
--save напрямую, если вы явно хотите их сменить), уже существующие папки
клиентов не перезаписываются, манифесты просто перегенерируются с
текущим содержимым папки.
Usage:
python quickstart.py # ключи + манифесты, без сборки exe
python quickstart.py --build-exe # + собрать updater.exe и опубликовать self-update
python quickstart.py --skip-manifests --build-exe # только пересобрать exe
"""
import os
import re
import sys
import json
import shutil
import argparse
import subprocess
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
_CONFIG_PATH = os.path.join(_SCRIPT_DIR, "config.json")
_UPDATER_PY_PATH = os.path.join(_SCRIPT_DIR, "updater.py")
def log(msg):
print(f"[quickstart] {msg}")
def load_config():
if not os.path.exists(_CONFIG_PATH):
raise SystemExit(f"config.json not found at {_CONFIG_PATH}")
with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
return json.load(f)
def save_config(cfg):
with open(_CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=4)
f.write("\n")
# ------------------------------------------------------------
# Step 1: access keys per client
# ------------------------------------------------------------
def ensure_access_keys(cfg):
import secrets
clients = cfg.get("clients", {})
if not clients:
log("WARNING: config.json has no 'clients' configured - nothing to generate access keys for.")
return cfg
cfg.setdefault("access_keys", {})
changed = False
for name in clients:
if cfg["access_keys"].get(name):
log(f"Access key for '{name}' already set, leaving it alone.")
continue
key = secrets.token_hex(32)
cfg["access_keys"][name] = key
changed = True
log(f"Generated access key for '{name}'.")
if changed:
save_config(cfg)
return cfg
# ------------------------------------------------------------
# Step 2: Ed25519 signing keypair
# ------------------------------------------------------------
def ensure_signing_keypair(cfg):
"""
Delegates the actual key generation/writing to generate_key.py so
there's exactly one place (generate_key.py's write_private_key_file)
that decides how the private key is stored - here we just decide
WHETHER to generate one.
The private half is written to a standalone .txt file next to the
scripts (never into config.json - see generate_key.py's module
docstring for why); only the public half and the key filename land in
config.json.
"""
sys.path.insert(0, _SCRIPT_DIR)
import generate_key
importlib_reload(generate_key)
signing = cfg.setdefault("signing_keys", {"public": "", "private_key_file": "SIGNING_PRIVATE_KEY.txt"})
signing.setdefault("private_key_file", "SIGNING_PRIVATE_KEY.txt")
key_path, _ = generate_key._private_key_file_path(cfg)
has_public = bool(signing.get("public"))
has_private_file = os.path.exists(key_path)
if has_public and has_private_file:
log("Signing keypair already set, leaving it alone.")
return cfg
if has_public or has_private_file:
raise SystemExit(
f"config.json has signing_keys.public set ({has_public}) but the private "
f"key file {'exists' if has_private_file else 'is missing'} ({key_path}) - "
"these are out of sync. Fix or clear both manually, then re-run quickstart.py."
)
private_hex, public_hex = generate_key.generate_signing_keypair()
generate_key.write_private_key_file(key_path, private_hex)
# Re-load in case write_private_key_file's caller (generate_key.py) has
# its own opinions about the file later - here we just need the dict
# we already have to carry the public half back to the caller.
cfg["signing_keys"]["public"] = public_hex
save_config(cfg)
log(f"Generated new Ed25519 signing keypair. Private key written to {os.path.basename(key_path)}.")
log(f"IMPORTANT: {os.path.basename(key_path)} contains a secret - do not commit or share it.")
return cfg
# ------------------------------------------------------------
# Step 3: client folders
# ------------------------------------------------------------
def ensure_client_folders(cfg):
clients = cfg.get("clients", {})
for name, folder in clients.items():
folder_path = os.path.join(_SCRIPT_DIR, folder)
if os.path.isdir(folder_path):
continue
os.makedirs(folder_path, exist_ok=True)
log(f"Created empty client folder '{folder}' for '{name}' - "
f"put game files in it before generating a real manifest.")
def _folder_has_files(folder_path):
for _root, _dirs, files in os.walk(folder_path):
if files:
return True
return False
# ------------------------------------------------------------
# Step 4: manifests
# ------------------------------------------------------------
def importlib_reload(module):
import importlib
importlib.reload(module)
def generate_manifests(cfg):
clients = cfg.get("clients", {})
if not clients:
return
sys.path.insert(0, _SCRIPT_DIR)
import generate_manifest
importlib_reload(generate_manifest) # pick up config.json changes from steps 1-2 above
for name, folder in clients.items():
folder_path = os.path.join(_SCRIPT_DIR, folder)
if not os.path.isdir(folder_path):
log(f"Skipping manifest for '{name}': folder '{folder}' doesn't exist.")
continue
if not _folder_has_files(folder_path):
log(f"Skipping manifest for '{name}': folder '{folder}' has no files yet.")
continue
log(f"Generating manifest for '{name}' ({folder})...")
generate_manifest.generate(folder_path)
# ------------------------------------------------------------
# Step 5: patch updater.py from config.json and build the exe
# ------------------------------------------------------------
def _build_patch_rules(cfg):
"""
Each entry: (regex matching the line to replace in updater.py,
replacement line). Matched with re.M anchored to line start, so only
the exact top-level assignment lines below are ever touched.
"""
ui = cfg.get("updater_ui", {})
conn = cfg.get("connection", {})
paths = cfg.get("paths", {})
manifest = cfg.get("manifest", {})
self_update = cfg.get("self_update", {})
access_keys = cfg.get("access_keys", {})
signing = cfg.get("signing_keys", {})
def py_str(s):
return json.dumps(s)
def py_dict_literal(d):
lines = ["{"]
for k, v in d.items():
lines.append(f" {py_str(k)}: {py_str(v)},")
lines.append("}")
return "\n".join(lines)
max_mb = manifest.get("max_decompressed_size_mb", 512)
public_key = signing.get("public") or ""
rules = [
(r'^GAME_NAME = .*$', f'GAME_NAME = {py_str(ui.get("game_name", "My Game"))}'),
(r'^UPDATER_NAME = .*$', f'UPDATER_NAME = {py_str(ui.get("updater_name", "Launcher"))}'),
(r'^WINDOW_WIDTH = .*$', f'WINDOW_WIDTH = {int(ui.get("window_width", 450))}'),
(r'^WINDOW_HEIGHT = .*$', f'WINDOW_HEIGHT = {int(ui.get("window_height", 280))}'),
(r'^BANNER_WIDTH = .*$', f'BANNER_WIDTH = {int(ui.get("banner_width", 430))}'),
(r'^BANNER_HEIGHT = .*$', f'BANNER_HEIGHT = {int(ui.get("banner_height", 140))}'),
(r'^SERVER_ADDRESS = .*$', f'SERVER_ADDRESS = {py_str(conn.get("server_address", "example.com"))}'),
(r'^SERVER_PORT = .*$', f'SERVER_PORT = {py_str(str(conn.get("server_port", "443")))}'),
(r'^USE_HTTPS = .*$', f'USE_HTTPS = {bool(conn.get("use_https", True))}'),
(r'^CLIENT_KEYS = \{[^}]*\}', f'CLIENT_KEYS = {py_dict_literal(access_keys)}'),
(r'^DEFAULT_CLIENT = .*$', f'DEFAULT_CLIENT = {py_str(cfg.get("default_client", "Main"))}'),
(r'^SIGNING_PUBLIC_KEY = .*$', f'SIGNING_PUBLIC_KEY = {py_str(public_key) if public_key else "None"}'),
(r'^REQUIRE_SIGNATURE = .*$', f'REQUIRE_SIGNATURE = {bool(cfg.get("require_signature", True))}'),
(r'^MAX_DECOMPRESSED_SIZE = .*$',
f'MAX_DECOMPRESSED_SIZE = {int(max_mb)} * 1024 * 1024 # {max_mb} MiB, from config.json'),
(r'^GAME_EXE_RELATIVE_PATH = .*$',
f'GAME_EXE_RELATIVE_PATH = os.path.normpath({py_str(paths.get("game_exe_relative_path", "system/game.exe"))})'),
(r'^BANNER_RELATIVE_PATH = .*$', f'BANNER_RELATIVE_PATH = {py_str(paths.get("banner_relative_path", "banner.png"))}'),
(r'^ICON_RELATIVE_PATH = .*$', f'ICON_RELATIVE_PATH = {py_str(paths.get("icon_relative_path", "icon.ico"))}'),
(r'^MANIFEST_FILENAME = "manifest\.json"$',
f'MANIFEST_FILENAME = {py_str(manifest.get("manifest_filename", "manifest.json"))}'),
(r'^COMPRESSED_SUFFIX = .*$', f'COMPRESSED_SUFFIX = {py_str(manifest.get("compressed_suffix", ".gz"))}'),
(r'^SETTINGS_FILENAME = .*$', f'SETTINGS_FILENAME = {py_str(paths.get("settings_filename", "updater.ini"))}'),
(r'^UPDATER_VERSION = .*$', f'UPDATER_VERSION = {int(self_update.get("updater_version", 1))}'),
(r'^UPDATER_MANIFEST_FILENAME = .*$',
f'UPDATER_MANIFEST_FILENAME = {py_str(self_update.get("updater_manifest_filename", "updater_manifest.json"))}'),
(r'^ENABLE_SELF_UPDATE = .*$', f'ENABLE_SELF_UPDATE = {bool(self_update.get("enabled", True))}'),
]
return rules
def patch_updater_source(cfg):
"""
Returns the patched updater.py source as a string, WITHOUT writing it
anywhere - caller decides where the patched copy goes. This never
modifies the checked-in updater.py in place: quickstart always builds
from a fresh in-memory copy, so re-running it repeatedly can't
accumulate stale patched cruft in the source you edit by hand.
"""
with open(_UPDATER_PY_PATH, "r", encoding="utf-8") as f:
source = f.read()
for pattern, replacement in _build_patch_rules(cfg):
new_source, count = re.subn(pattern, replacement, source, count=1, flags=re.M)
if count == 0:
raise SystemExit(
f"quickstart.py couldn't find a line matching {pattern!r} in updater.py. "
"updater.py's structure may have changed - update _build_patch_rules() in "
"quickstart.py, or patch this value by hand before building."
)
source = new_source
return source
def build_exe(cfg, pyinstaller_args=None):
signing = cfg.get("signing_keys", {})
if not signing.get("public"):
raise SystemExit("signing_keys.public is empty in config.json - run the key-generation steps first.")
access_keys = cfg.get("access_keys", {})
if not access_keys or not any(access_keys.values()):
raise SystemExit("No access_keys configured in config.json - run the key-generation steps first.")
build_dir = os.path.join(_SCRIPT_DIR, "_quickstart_build")
if os.path.exists(build_dir):
shutil.rmtree(build_dir)
os.makedirs(build_dir)
patched_source = patch_updater_source(cfg)
build_updater_py = os.path.join(build_dir, "updater.py")
with open(build_updater_py, "w", encoding="utf-8") as f:
f.write(patched_source)
paths_cfg = cfg.get("paths", {})
banner_name = os.path.basename(paths_cfg.get("banner_relative_path", "banner.png"))
icon_name = os.path.basename(paths_cfg.get("icon_relative_path", "icon.ico"))
for rel_name in (banner_name, icon_name):
src = os.path.join(_SCRIPT_DIR, rel_name)
if os.path.exists(src):
shutil.copy2(src, os.path.join(build_dir, rel_name))
else:
log(f"WARNING: resource '{rel_name}' not found next to updater.py - "
f"the built exe will show its 'banner not found' placeholder / default icon.")
icon_path = os.path.join(build_dir, icon_name)
banner_path = os.path.join(build_dir, banner_name)
sep = ";" if os.name == "nt" else ":"
cmd = [
sys.executable, "-m", "PyInstaller",
"--onefile", "--noconsole",
"--distpath", os.path.join(build_dir, "dist"),
"--workpath", os.path.join(build_dir, "work"),
"--specpath", build_dir,
]
if os.path.exists(icon_path):
cmd += ["--icon", icon_path, "--add-data", f"{icon_path}{sep}."]
if os.path.exists(banner_path):
cmd += ["--add-data", f"{banner_path}{sep}."]
if pyinstaller_args:
cmd += pyinstaller_args
cmd.append(build_updater_py)
log("Running PyInstaller (this can take a minute)...")
log(" ".join(cmd))
result = subprocess.run(cmd, cwd=build_dir)
if result.returncode != 0:
raise SystemExit(f"PyInstaller failed with exit code {result.returncode}.")
dist_dir = os.path.join(build_dir, "dist")
expected_name = "updater.exe" if os.name == "nt" else "updater"
built_exe = os.path.join(dist_dir, expected_name)
if not os.path.exists(built_exe):
candidates = os.listdir(dist_dir) if os.path.isdir(dist_dir) else []
if not candidates:
raise SystemExit(f"PyInstaller reported success but no output found in {dist_dir}.")
built_exe = os.path.join(dist_dir, candidates[0])
log(f"Built: {built_exe}")
return built_exe
def publish_self_update(cfg, built_exe):
self_update = cfg.get("self_update", {})
if not self_update.get("enabled", True):
log("self_update.enabled is false in config.json - skipping self-update publish.")
return
clients = cfg.get("clients", {})
client_dirs = [
os.path.join(_SCRIPT_DIR, folder)
for folder in clients.values()
if os.path.isdir(os.path.join(_SCRIPT_DIR, folder))
]
if not client_dirs:
log("No existing client folders to publish the self-update into - skipping.")
return
sys.path.insert(0, _SCRIPT_DIR)
import generate_updater_manifest
importlib_reload(generate_updater_manifest)
version = self_update.get("updater_version", 1)
generate_updater_manifest.publish(built_exe, version, client_dirs)
def copy_exe_into_clients(cfg, built_exe):
"""
Drops a copy of the freshly built exe into each client folder as a
convenience for fresh installs (so "here's your game_client folder,
updater.exe included" is literally true right after quickstart runs).
This is separate from publish_self_update: that one is about *existing*
installs picking up a newer build; this is about what a brand new
player downloading the client folder for the first time gets.
"""
clients = cfg.get("clients", {})
download_name = cfg.get("self_update", {}).get("download_filename", "updater.exe")
for name, folder in clients.items():
folder_path = os.path.join(_SCRIPT_DIR, folder)
if not os.path.isdir(folder_path):
continue
dest = os.path.join(folder_path, download_name)
shutil.copy2(built_exe, dest)
log(f"Copied built exe into {folder}/{download_name}.")
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--skip-keys", action="store_true", help="Не генерировать access keys / signing keypair.")
parser.add_argument("--skip-manifests", action="store_true", help="Не генерировать manifest.json для клиентских папок.")
parser.add_argument("--build-exe", action="store_true", help="Собрать updater.exe через PyInstaller и опубликовать self-update.")
parser.add_argument("--pyinstaller-arg", action="append", dest="pyinstaller_args",
help="Доп. аргумент для PyInstaller, можно указывать несколько раз.")
args = parser.parse_args()
cfg = load_config()
if not args.skip_keys:
cfg = ensure_access_keys(cfg)
cfg = ensure_signing_keypair(cfg)
else:
log("Skipping key generation (--skip-keys).")
ensure_client_folders(cfg)
if not args.skip_manifests:
generate_manifests(cfg)
else:
log("Skipping manifest generation (--skip-manifests).")
if args.build_exe:
cfg = load_config() # re-load in case key generation above changed it
built_exe = build_exe(cfg, pyinstaller_args=args.pyinstaller_args)
copy_exe_into_clients(cfg, built_exe)
publish_self_update(cfg, built_exe)
else:
log("Skipping exe build (pass --build-exe to compile updater.exe).")
log("Done.")
if __name__ == "__main__":
main()
+7
View File
@@ -0,0 +1,7 @@
# Нужен и на машине, где генерируются ключи/манифесты (generate_key.py,
# generate_manifest.py), и при сборке updater.exe (для проверки Ed25519-
# подписи манифеста). Pillow опционален - используется updater.py только
# для более качественного масштабирования banner.png, без него всё равно
# работает (см. README).
cryptography>=42.0
Pillow>=10.0
+80 -13
View File
@@ -1,8 +1,9 @@
import os
import json
import socket
import posixpath
import urllib.parse
from http.server import SimpleHTTPRequestHandler, HTTPServer
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
CONFIG_FILENAME = "config.json"
@@ -14,17 +15,31 @@ def load_config():
CONFIG = load_config()
HOST = CONFIG.get("host", "0.0.0.0")
PORT = CONFIG.get("port", 3232)
HOST = CONFIG.get("server", {}).get("host", "0.0.0.0")
PORT = CONFIG.get("server", {}).get("port", 3232)
# Maps a URL access key (long hex string, see generate_key.py) -> physical
# folder on the server holding that client's files. A random hex key instead
# of a plain name like "main" just makes the update path harder to stumble
# upon by guessing; it does not stop someone who deliberately extracts the
# key from the updater executable. If a key ever leaks, generate a new one
# and update it here plus in the updater's CLIENT_KEYS - no need to touch
# folder names or anything else.
CLIENTS = CONFIG.get("clients", {})
# config.json keeps client folders and access keys under two separate
# top-level maps, both keyed by the same friendly client name
# ("Main", "Test", ...):
#
# "clients": {"Main": "game_client", "Test": "game_client_test"}
# "access_keys": {"Main": "<hex>", "Test": "<hex>"}
#
# This mirrors how updater.py's CLIENT_KEYS is keyed (by display name),
# so quickstart.py can populate both from the exact same source of truth
# without the server and the updater disagreeing about what "Main" means.
# The server only cares about the resulting access_key -> folder mapping,
# built once here at startup.
_CLIENT_FOLDERS = CONFIG.get("clients", {})
_ACCESS_KEYS = CONFIG.get("access_keys", {})
CLIENTS = {}
for _name, _folder in _CLIENT_FOLDERS.items():
_key = _ACCESS_KEYS.get(_name)
if not _key:
print(f"WARNING: client '{_name}' has no access_key configured in config.json - skipping it.")
continue
CLIENTS[_key] = _folder
# Absolute, resolved base directories for each client key, computed once at
# startup. Relative folder paths in config.json are resolved relative to
@@ -43,6 +58,14 @@ CLIENT_BASE_DIRS = {
# from the script's own working directory.
NONEXISTENT_PATH = os.path.join(os.path.abspath(os.sep), "__no_such_path__")
# Simple per-IP concurrent connection cap. This is not a substitute for a
# real reverse proxy (nginx/Caddy) doing proper rate limiting, but it stops
# a single naive client (or a basic flood) from tying up every worker
# thread the process has. Put a real reverse proxy in front for anything
# internet-facing - see README.
MAX_CONNECTIONS_PER_IP = CONFIG.get("server", {}).get("max_connections_per_ip", 20)
_active_connections = {}
class MultiClientHandler(SimpleHTTPRequestHandler):
"""
@@ -61,6 +84,11 @@ class MultiClientHandler(SimpleHTTPRequestHandler):
working directory.
"""
# Keep noisy default logging off stdout in production; flip to True
# (or add real logging) if you need request-level debugging.
def log_message(self, format, *args):
pass
def _resolve_client_path(self, path):
path = urllib.parse.urlparse(path).path
path = urllib.parse.unquote(path)
@@ -102,10 +130,49 @@ class MultiClientHandler(SimpleHTTPRequestHandler):
self.send_error(404, "Not Found")
return None
def finish(self):
try:
super().finish()
finally:
ip = self.client_address[0]
remaining = _active_connections.get(ip, 1) - 1
if remaining <= 0:
_active_connections.pop(ip, None)
else:
_active_connections[ip] = remaining
def handle_one_request(self):
ip = self.client_address[0]
current = _active_connections.get(ip, 0)
if current >= MAX_CONNECTIONS_PER_IP:
self.close_connection = True
try:
self.send_error(429, "Too Many Requests")
except (BrokenPipeError, ConnectionResetError):
pass
return
_active_connections[ip] = current + 1
super().handle_one_request()
class Server(ThreadingHTTPServer):
# Handle each connection in its own thread so one slow/large download
# doesn't stall every other player's update. daemon_threads=True keeps
# Ctrl+C responsive instead of hanging on stray worker threads.
daemon_threads = True
allow_reuse_address = True
if __name__ == '__main__':
server_address = (HOST, PORT)
httpd = HTTPServer(server_address, MultiClientHandler)
httpd = Server(server_address, MultiClientHandler)
print(f"Server running on {HOST}:{PORT}...")
print(f"Available client keys: {', '.join(CLIENTS.keys()) if CLIENTS else '(none configured)'}")
if CLIENTS:
active_names = [name for name in _CLIENT_FOLDERS if _ACCESS_KEYS.get(name)]
print(f"Configured clients: {', '.join(active_names)}")
else:
print("WARNING: no clients configured (check config.json 'clients' and 'access_keys').")
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
+519 -11
View File
@@ -3,6 +3,7 @@ import sys
import json
import gzip
import hashlib
import tempfile
import threading
import urllib.request
import urllib.parse
@@ -11,6 +12,9 @@ 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
@@ -19,6 +23,22 @@ except ImportError:
# ============================================================
# CONFIGURATION - edit these values for your game/project
#
# These are the values baked into a compiled updater.exe. quickstart.py
# writes this whole block from config.json before running PyInstaller, so
# in normal use you edit config.json and re-run quickstart.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
@@ -54,6 +74,33 @@ CLIENT_KEYS = {
}
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")
@@ -62,6 +109,7 @@ 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).
@@ -70,10 +118,116 @@ 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):
@@ -94,6 +248,12 @@ 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
@@ -134,6 +294,82 @@ def build_server_base_url():
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
@@ -259,6 +495,213 @@ class UpdaterApp:
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
@@ -266,12 +709,20 @@ class UpdaterApp:
display_name = self.mode_var.get()
access_key = CLIENT_KEYS[display_name]
current_server_url = build_server_base_url() + f"{access_key}/"
manifest_url = current_server_url + MANIFEST_FILENAME
try:
self.update_ui(text=f"Connecting to server ({display_name})...")
req = urllib.request.urlopen(manifest_url, timeout=10)
server_manifest = json.loads(req.read().decode('utf-8'))
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)
@@ -280,7 +731,11 @@ class UpdaterApp:
for i, (rel_path, entry) in enumerate(server_manifest.items()):
self.update_ui(text=f"Checking: {rel_path}", prog_val=i)
local_path = os.path.join(BASE_PATH, os.path.normpath(rel_path))
# 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"]:
@@ -294,7 +749,7 @@ class UpdaterApp:
self.update_ui(text=f"Downloading ({i + 1}/{total_dl}): {rel_path}", prog_val=i)
entry = server_manifest[rel_path]
local_path = os.path.join(BASE_PATH, os.path.normpath(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)
@@ -304,18 +759,52 @@ class UpdaterApp:
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 or
download, so a bad/missing .gz on the server doesn't block the update.
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='/')
@@ -326,13 +815,24 @@ class UpdaterApp:
if self.get_md5_bytes(compressed_data) != entry.get("compressed_hash"):
raise ValueError("compressed file hash mismatch")
data = gzip.decompress(compressed_data)
# 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")
with open(local_path, "wb") as f:
f.write(data)
self._atomic_write(local_path, data)
return
except Exception:
# Fall through to downloading the uncompressed original below.
@@ -340,7 +840,15 @@ class UpdaterApp:
safe_url_path = urllib.parse.quote(rel_path, safe='/')
file_url = base_url + safe_url_path
urllib.request.urlretrieve(file_url, local_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):