major fixes test
This commit is contained in:
@@ -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
|
```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
|
```bash
|
||||||
python server.py
|
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
|
Чтобы что-то поменять — правьте `config.json` и перезапускайте `quickstart.py` (или `server.py` для настроек сервера). Ручное редактирование `updater.py`/`generate_manifest.py` не нужно — все они читают значения оттуда.
|
||||||
GAME_NAME = "My Game"
|
|
||||||
UPDATER_NAME = "Launcher"
|
|
||||||
WINDOW_WIDTH = 450
|
|
||||||
WINDOW_HEIGHT = 300
|
|
||||||
BANNER_WIDTH = 430
|
|
||||||
BANNER_HEIGHT = 140
|
|
||||||
|
|
||||||
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 = {
|
Единственный настоящий секрет в проекте — приватный ключ подписи манифестов. Он **не хранится в `config.json`**, а лежит в отдельном файле `SIGNING_PRIVATE_KEY.txt` (имя настраивается через `signing_keys.private_key_file`). Публичный ключ — в `config.json`, его можно раскрывать сколько угодно, зашивать в exe и т.д.
|
||||||
"Main": "<same-hex-key-as-in-server-config.json>",
|
|
||||||
"Test": "<same-hex-key-as-in-server-config.json>",
|
|
||||||
}
|
|
||||||
DEFAULT_CLIENT = "Main"
|
|
||||||
|
|
||||||
GAME_EXE_RELATIVE_PATH = os.path.join("system", "game.exe")
|
Держите `SIGNING_PRIVATE_KEY.txt` только на машине, где генерируются манифесты — не копируйте его вместе с остальным проектом, добавьте в `.gitignore`, если используете git.
|
||||||
BANNER_RELATIVE_PATH = "banner.png"
|
|
||||||
ICON_RELATIVE_PATH = "icon.ico"
|
## Как всё это работает вместе
|
||||||
|
|
||||||
|
```
|
||||||
|
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).
|
Если `quickstart.py` не подходит (например, генерация ключей и манифестов происходит на разных машинах), каждый скрипт можно запускать по отдельности:
|
||||||
|
|
||||||
Then compile with your favorite tool, e.g. [PyInstaller](https://pyinstaller.org/):
|
|
||||||
|
|
||||||
```bash
|
```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
|
```
|
||||||
|
pip install -r requirements.txt
|
||||||
On first run, the updater creates a `settings.ini` file next to itself with the selected client version:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[updater]
|
|
||||||
client = Main
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
Для сборки `updater.exe` также нужен `pyinstaller` (`pip install pyinstaller`), если запускаете `quickstart.py --build-exe`.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|||||||
+72
-4
@@ -1,8 +1,76 @@
|
|||||||
{
|
{
|
||||||
"host": "0.0.0.0",
|
"server": {
|
||||||
"port": 3232,
|
"host": "0.0.0.0",
|
||||||
|
"port": 3232,
|
||||||
|
"max_connections_per_ip": 20
|
||||||
|
},
|
||||||
|
|
||||||
"clients": {
|
"clients": {
|
||||||
"d5af3f9fd06b15fcfcd1e6204b10804edbb9936b094ece123ce38ee7d3382ee0": "game_client",
|
"Main": "game_client",
|
||||||
"aa697a8988d49f161c9764c3bb54b9111ad016bca7470243ea3aa2ee4fba37c1": "game_client_test"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+253
-9
@@ -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 secrets
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
# Generates a long random hex key suitable for use as a client access key.
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
# Run this once per client (main/test/etc.) and put the result into:
|
from cryptography.hazmat.primitives import serialization
|
||||||
# - server-side config.json (as the dict key, mapped to the folder)
|
|
||||||
# - updater.py CLIENT_KEYS (as the value, mapped to the display name)
|
_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__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser(description="Generate a random hex access key for a client.")
|
parser = argparse.ArgumentParser(
|
||||||
parser.add_argument("--bytes", type=int, default=32,
|
description="Генератор ключей для simple-updater.",
|
||||||
help="Number of random bytes (default 32 -> 64 hex chars).")
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
key = secrets.token_hex(args.bytes)
|
if args.signing_keypair:
|
||||||
print(key)
|
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)
|
||||||
|
|||||||
+151
-20
@@ -1,42 +1,108 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import gzip
|
import gzip
|
||||||
import shutil
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import argparse
|
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
|
# Suffix used for the precompressed copy of each file, stored alongside the
|
||||||
# original (e.g. "texture.dds" -> "texture.dds.gz").
|
# 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).
|
# 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 = {
|
IGNORE_FILES = {
|
||||||
MANIFEST_FILENAME,
|
MANIFEST_FILENAME,
|
||||||
"updater.exe", # Your compiled updater executable
|
MANIFEST_FILENAME + ".sig",
|
||||||
"Option.ini", # Example: graphics/sound settings
|
"updater.exe", # Your compiled updater executable
|
||||||
"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
|
# Folders the updater must never scan or update
|
||||||
IGNORE_FOLDERS = {
|
IGNORE_FOLDERS = set(_ignore_cfg.get("folders", ["screenshots", "logs", "replays"]))
|
||||||
"screenshots",
|
|
||||||
"logs",
|
|
||||||
"replays",
|
|
||||||
}
|
|
||||||
|
|
||||||
# File extensions to always skip (lowercase, without the dot doesn't matter, include the dot)
|
# File extensions to always skip (lowercase, without the dot doesn't matter, include the dot)
|
||||||
IGNORE_EXTENSIONS = {
|
IGNORE_EXTENSIONS = set(_ignore_cfg.get("extensions", [".log"]))
|
||||||
".log",
|
|
||||||
}
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# END CONFIGURATION
|
# END CONFIGURATION
|
||||||
@@ -60,6 +126,25 @@ def get_md5(filepath):
|
|||||||
return None
|
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):
|
def compress_file(filepath, compressed_path):
|
||||||
"""
|
"""
|
||||||
Compresses filepath into compressed_path with gzip. Every file is
|
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
|
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):
|
def generate(client_dir):
|
||||||
if not os.path.exists(client_dir) or not os.path.isdir(client_dir):
|
if not os.path.exists(client_dir) or not os.path.isdir(client_dir):
|
||||||
print(f"Error: directory '{client_dir}' not found!")
|
print(f"Error: directory '{client_dir}' not found!")
|
||||||
@@ -102,11 +205,13 @@ def generate(client_dir):
|
|||||||
|
|
||||||
manifest = {}
|
manifest = {}
|
||||||
manifest_path = os.path.join(client_dir, MANIFEST_FILENAME)
|
manifest_path = os.path.join(client_dir, MANIFEST_FILENAME)
|
||||||
|
sig_path = manifest_path + ".sig"
|
||||||
|
|
||||||
print(f"Scanning client folder: {client_dir}")
|
print(f"Scanning client folder: {client_dir}")
|
||||||
|
|
||||||
total_original = 0
|
total_original = 0
|
||||||
total_saved = 0
|
total_saved = 0
|
||||||
|
skipped_unsafe = 0
|
||||||
|
|
||||||
for root, dirs, files in os.walk(client_dir):
|
for root, dirs, files in os.walk(client_dir):
|
||||||
# Filter out ignored folders (case-insensitive)
|
# Filter out ignored folders (case-insensitive)
|
||||||
@@ -128,6 +233,11 @@ def generate(client_dir):
|
|||||||
rel_path = os.path.relpath(filepath, client_dir)
|
rel_path = os.path.relpath(filepath, client_dir)
|
||||||
rel_path_web = rel_path.replace("\\", "/")
|
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)
|
file_hash = get_md5(filepath)
|
||||||
if not file_hash:
|
if not file_hash:
|
||||||
continue
|
continue
|
||||||
@@ -152,10 +262,31 @@ def generate(client_dir):
|
|||||||
manifest[rel_path_web] = entry
|
manifest[rel_path_web] = entry
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(manifest_path, "w", encoding="utf-8") as f:
|
# Serialize once with sorted, fixed formatting so the byte sequence
|
||||||
json.dump(manifest, f, indent=4)
|
# 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"Done! Manifest saved to: {manifest_path}")
|
||||||
print(f"Total files in manifest: {len(manifest)}")
|
print(f"Total files in manifest: {len(manifest)}")
|
||||||
|
if skipped_unsafe:
|
||||||
|
print(f"Skipped {skipped_unsafe} file(s) with unsafe paths.")
|
||||||
if total_original:
|
if total_original:
|
||||||
pct = (total_saved / total_original) * 100
|
pct = (total_saved / total_original) * 100
|
||||||
print(f"Compression saved {total_saved:,} bytes of {total_original:,} ({pct:.1f}%)")
|
print(f"Compression saved {total_saved:,} bytes of {total_original:,} ({pct:.1f}%)")
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import socket
|
||||||
import posixpath
|
import posixpath
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from http.server import SimpleHTTPRequestHandler, HTTPServer
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
CONFIG_FILENAME = "config.json"
|
CONFIG_FILENAME = "config.json"
|
||||||
|
|
||||||
@@ -14,17 +15,31 @@ def load_config():
|
|||||||
|
|
||||||
|
|
||||||
CONFIG = load_config()
|
CONFIG = load_config()
|
||||||
HOST = CONFIG.get("host", "0.0.0.0")
|
HOST = CONFIG.get("server", {}).get("host", "0.0.0.0")
|
||||||
PORT = CONFIG.get("port", 3232)
|
PORT = CONFIG.get("server", {}).get("port", 3232)
|
||||||
|
|
||||||
# Maps a URL access key (long hex string, see generate_key.py) -> physical
|
# config.json keeps client folders and access keys under two separate
|
||||||
# folder on the server holding that client's files. A random hex key instead
|
# top-level maps, both keyed by the same friendly client name
|
||||||
# of a plain name like "main" just makes the update path harder to stumble
|
# ("Main", "Test", ...):
|
||||||
# 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
|
# "clients": {"Main": "game_client", "Test": "game_client_test"}
|
||||||
# and update it here plus in the updater's CLIENT_KEYS - no need to touch
|
# "access_keys": {"Main": "<hex>", "Test": "<hex>"}
|
||||||
# folder names or anything else.
|
#
|
||||||
CLIENTS = CONFIG.get("clients", {})
|
# 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
|
# Absolute, resolved base directories for each client key, computed once at
|
||||||
# startup. Relative folder paths in config.json are resolved relative to
|
# 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.
|
# from the script's own working directory.
|
||||||
NONEXISTENT_PATH = os.path.join(os.path.abspath(os.sep), "__no_such_path__")
|
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):
|
class MultiClientHandler(SimpleHTTPRequestHandler):
|
||||||
"""
|
"""
|
||||||
@@ -61,6 +84,11 @@ class MultiClientHandler(SimpleHTTPRequestHandler):
|
|||||||
working directory.
|
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):
|
def _resolve_client_path(self, path):
|
||||||
path = urllib.parse.urlparse(path).path
|
path = urllib.parse.urlparse(path).path
|
||||||
path = urllib.parse.unquote(path)
|
path = urllib.parse.unquote(path)
|
||||||
@@ -102,10 +130,49 @@ class MultiClientHandler(SimpleHTTPRequestHandler):
|
|||||||
self.send_error(404, "Not Found")
|
self.send_error(404, "Not Found")
|
||||||
return None
|
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__':
|
if __name__ == '__main__':
|
||||||
server_address = (HOST, PORT)
|
server_address = (HOST, PORT)
|
||||||
httpd = HTTPServer(server_address, MultiClientHandler)
|
httpd = Server(server_address, MultiClientHandler)
|
||||||
print(f"Server running on {HOST}:{PORT}...")
|
print(f"Server running on {HOST}:{PORT}...")
|
||||||
print(f"Available client keys: {', '.join(CLIENTS.keys()) if CLIENTS else '(none configured)'}")
|
if CLIENTS:
|
||||||
httpd.serve_forever()
|
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
@@ -3,6 +3,7 @@ import sys
|
|||||||
import json
|
import json
|
||||||
import gzip
|
import gzip
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
@@ -11,6 +12,9 @@ import configparser
|
|||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import ttk, messagebox
|
from tkinter import ttk, messagebox
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||||
|
from cryptography.exceptions import InvalidSignature
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from PIL import Image, ImageTk
|
from PIL import Image, ImageTk
|
||||||
HAS_PIL = True
|
HAS_PIL = True
|
||||||
@@ -19,6 +23,22 @@ except ImportError:
|
|||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# CONFIGURATION - edit these values for your game/project
|
# 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
|
GAME_NAME = "My Game" # Shown in the window title
|
||||||
@@ -54,6 +74,33 @@ CLIENT_KEYS = {
|
|||||||
}
|
}
|
||||||
DEFAULT_CLIENT = "Main"
|
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
|
# Relative path (from the game folder) to the executable that launches the game
|
||||||
GAME_EXE_RELATIVE_PATH = os.path.join("system", "game.exe")
|
GAME_EXE_RELATIVE_PATH = os.path.join("system", "game.exe")
|
||||||
|
|
||||||
@@ -62,6 +109,7 @@ BANNER_RELATIVE_PATH = "banner.png"
|
|||||||
ICON_RELATIVE_PATH = "icon.ico"
|
ICON_RELATIVE_PATH = "icon.ico"
|
||||||
|
|
||||||
MANIFEST_FILENAME = "manifest.json"
|
MANIFEST_FILENAME = "manifest.json"
|
||||||
|
SIGNATURE_FILENAME = MANIFEST_FILENAME + ".sig"
|
||||||
|
|
||||||
# Suffix used for precompressed files on the server (must match
|
# Suffix used for precompressed files on the server (must match
|
||||||
# COMPRESSED_SUFFIX in generate_manifest.py).
|
# COMPRESSED_SUFFIX in generate_manifest.py).
|
||||||
@@ -70,10 +118,116 @@ COMPRESSED_SUFFIX = ".gz"
|
|||||||
# Local settings file (created next to the updater on first run).
|
# Local settings file (created next to the updater on first run).
|
||||||
SETTINGS_FILENAME = "updater.ini"
|
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
|
# 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):
|
def get_resource_path(relative_path):
|
||||||
if getattr(sys, 'frozen', False):
|
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)
|
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():
|
def load_settings():
|
||||||
"""
|
"""
|
||||||
Reads settings.ini next to the updater. If it doesn't exist yet (first
|
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}/"
|
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:
|
class UpdaterApp:
|
||||||
def __init__(self, root):
|
def __init__(self, root):
|
||||||
self.root = root
|
self.root = root
|
||||||
@@ -259,6 +495,213 @@ class UpdaterApp:
|
|||||||
self.mode_combo.config(state="disabled") # Lock the selector during update
|
self.mode_combo.config(state="disabled") # Lock the selector during update
|
||||||
threading.Thread(target=self.update_process, daemon=True).start()
|
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):
|
def update_process(self):
|
||||||
# Build URLs dynamically based on the selected client version.
|
# Build URLs dynamically based on the selected client version.
|
||||||
# The display name is only for the UI - the actual URL uses the
|
# 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()
|
display_name = self.mode_var.get()
|
||||||
access_key = CLIENT_KEYS[display_name]
|
access_key = CLIENT_KEYS[display_name]
|
||||||
current_server_url = build_server_base_url() + f"{access_key}/"
|
current_server_url = build_server_base_url() + f"{access_key}/"
|
||||||
manifest_url = current_server_url + MANIFEST_FILENAME
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.update_ui(text=f"Connecting to server ({display_name})...")
|
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 = []
|
files_to_download = []
|
||||||
total_files = len(server_manifest)
|
total_files = len(server_manifest)
|
||||||
@@ -280,7 +731,11 @@ class UpdaterApp:
|
|||||||
for i, (rel_path, entry) in enumerate(server_manifest.items()):
|
for i, (rel_path, entry) in enumerate(server_manifest.items()):
|
||||||
self.update_ui(text=f"Checking: {rel_path}", prog_val=i)
|
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)
|
local_hash = self.get_md5(local_path)
|
||||||
|
|
||||||
if local_hash != entry["hash"]:
|
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)
|
self.update_ui(text=f"Downloading ({i + 1}/{total_dl}): {rel_path}", prog_val=i)
|
||||||
|
|
||||||
entry = server_manifest[rel_path]
|
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)
|
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
||||||
|
|
||||||
self._download_file(current_server_url, rel_path, entry, local_path)
|
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.btn_update.config(state="normal"))
|
||||||
self.root.after(0, lambda: self.mode_combo.config(state="readonly"))
|
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:
|
except Exception as e:
|
||||||
self.update_ui(text=f"Error: {str(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.btn_update.config(state="normal"))
|
||||||
self.root.after(0, lambda: self.mode_combo.config(state="readonly"))
|
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):
|
def _download_file(self, base_url, rel_path, entry, local_path):
|
||||||
"""
|
"""
|
||||||
Downloads one file. If the manifest marks it as compressed, fetches
|
Downloads one file. If the manifest marks it as compressed, fetches
|
||||||
the .gz copy, verifies its hash, and decompresses it into local_path.
|
the .gz copy, verifies its hash, and decompresses it into local_path.
|
||||||
Otherwise downloads the original file directly. Falls back to the
|
Otherwise downloads the original file directly. Falls back to the
|
||||||
uncompressed original if the compressed copy fails verification or
|
uncompressed original if the compressed copy fails verification,
|
||||||
download, so a bad/missing .gz on the server doesn't block the update.
|
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"):
|
if entry.get("compressed"):
|
||||||
gz_url = base_url + urllib.parse.quote(rel_path + COMPRESSED_SUFFIX, safe='/')
|
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"):
|
if self.get_md5_bytes(compressed_data) != entry.get("compressed_hash"):
|
||||||
raise ValueError("compressed file hash mismatch")
|
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"]:
|
if self.get_md5_bytes(data) != entry["hash"]:
|
||||||
raise ValueError("decompressed file hash mismatch")
|
raise ValueError("decompressed file hash mismatch")
|
||||||
|
|
||||||
with open(local_path, "wb") as f:
|
self._atomic_write(local_path, data)
|
||||||
f.write(data)
|
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fall through to downloading the uncompressed original below.
|
# Fall through to downloading the uncompressed original below.
|
||||||
@@ -340,7 +840,15 @@ class UpdaterApp:
|
|||||||
|
|
||||||
safe_url_path = urllib.parse.quote(rel_path, safe='/')
|
safe_url_path = urllib.parse.quote(rel_path, safe='/')
|
||||||
file_url = base_url + safe_url_path
|
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):
|
def play_game(self):
|
||||||
if os.path.exists(GAME_EXE):
|
if os.path.exists(GAME_EXE):
|
||||||
|
|||||||
Reference in New Issue
Block a user