fixes
This commit is contained in:
@@ -1,199 +1,174 @@
|
|||||||
# 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`** — генерирует ключи и манифесты одной командой на сервере.
|
||||||
|
- **`build_client.py`** — отдельно собирает `updater.exe` на Windows, забирая всё нужное с сервера по сети, без доступа к серверному `config.json` целиком.
|
||||||
|
|
||||||
## How it works
|
## Архитектура: сервер и клиентская сборка — раздельно
|
||||||
|
|
||||||
1. You put your game's files in a folder on the server (e.g. `game_client/`).
|
PyInstaller не кросс-компилирует: собранный на Linux/macOS «updater.exe» — это бинарник для той ОС, на которой его собрали, просто с расширением `.exe`; на Windows он не запустится. Поэтому в проекте два независимых пайплайна:
|
||||||
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
|
- **Серверная часть** (`quickstart.py`, `generate_key.py`, `generate_manifest.py`, `generate_updater_manifest.py`, `server.py`) — работает на любой ОС, обычно Linux. Генерирует ключи, манифесты, раздаёт файлы. Никогда не собирает exe.
|
||||||
|
- **Клиентская сборка** (`build_client.py`) — работает **только на Windows** (реальной машине, VM или CI-раннере). Ничего не генерирует сама: забирает access-ключи, публичный ключ подписи и остальные настройки с работающего сервера по HTTP(S) и собирает `updater.exe`.
|
||||||
|
|
||||||
### 1. Server
|
Приватный ключ подписи манифестов никогда не покидает сервер — `build_client.py` физически не может его получить (сервер его и не отдаёт, см. ниже).
|
||||||
|
|
||||||
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
|
||||||
```
|
|
||||||
|
|
||||||
This prints a random 64-character hex string. Generate one per client (e.g. one for `main`, one for `test`).
|
# сгенерировать ключи + положить файлы игры в game_client/ + собрать манифесты
|
||||||
|
python quickstart.py
|
||||||
|
|
||||||
Edit `config.json`:
|
# запустить сервер
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"host": "0.0.0.0",
|
|
||||||
"port": 3232,
|
|
||||||
"clients": {
|
|
||||||
"<your-generated-hex-key>": "game_client",
|
|
||||||
"<another-generated-hex-key>": "game_client_test"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `host` / `port` — what the server listens on.
|
|
||||||
- `clients` — maps an access key to the folder (relative to `server.py`) that key serves.
|
|
||||||
|
|
||||||
Put your game files in the folders referenced above (`game_client/`, etc.), then generate the manifest for each:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python generate_manifest.py game_client
|
|
||||||
python generate_manifest.py game_client_test
|
|
||||||
```
|
|
||||||
|
|
||||||
Re-run this after every update to the client files, then start (or restart) the server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python server.py
|
python server.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Files become available at `http://<server-address>:<port>/<access-key>/...`.
|
Первый запуск `quickstart.py` создаст пустые папки клиентов (`game_client`, `game_client_test` — имена берутся из `config.json`'s `clients`), сгенерирует access-ключи и пару ключей для подписи манифестов. Положите файлы игры в `game_client/`, запустите `quickstart.py` ещё раз — манифест сгенерируется и подпишется автоматически.
|
||||||
|
|
||||||
### 2. Updater (client)
|
**На Windows-машине для сборки клиента:**
|
||||||
|
|
||||||
Edit the configuration block at the top of `updater.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
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 = {
|
|
||||||
"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")
|
|
||||||
BANNER_RELATIVE_PATH = "banner.png"
|
|
||||||
ICON_RELATIVE_PATH = "icon.ico"
|
|
||||||
```
|
|
||||||
|
|
||||||
- `SERVER_ADDRESS` accepts either a domain name or a raw IP — nothing else needs to change.
|
|
||||||
- `SERVER_PORT` / `USE_HTTPS` should match how your server is actually reachable. If nginx force-redirects port 80 to 443, set `SERVER_PORT = "443"` and `USE_HTTPS = True` directly rather than relying on the redirect — the updater downloads files one request at a time, so an extra 80→443 redirect on every file adds real overhead on large clients.
|
|
||||||
- `CLIENT_KEYS` maps a friendly name shown in the UI to the actual access key sent in the request URL. Keys must match `config.json` on the server exactly.
|
|
||||||
- `GAME_EXE_RELATIVE_PATH` is where the updater looks for the game's executable, relative to the folder the updater itself is placed in.
|
|
||||||
- `BANNER_WIDTH` / `BANNER_HEIGHT` — the banner image is always scaled to exactly this size, so it doesn't matter what dimensions the source `banner.png` actually has; the layout won't shift.
|
|
||||||
|
|
||||||
Banner scaling uses [Pillow](https://pypi.org/project/Pillow/) if it's installed (`pip install Pillow`), for proper resizing. If Pillow isn't available, the updater falls back to Tkinter's built-in (cruder, integer-only) zoom/subsample — it'll still work, just less precisely scaled. Install Pillow for best results, and make sure it's bundled when compiling (see below).
|
|
||||||
|
|
||||||
Then compile with your favorite tool, e.g. [PyInstaller](https://pyinstaller.org/):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pyinstaller --onefile --noconsole --icon=icon.ico --add-data "banner.png;." --add-data "icon.ico;." updater.py
|
pip install -r requirements-client.txt
|
||||||
|
|
||||||
|
python build_client.py --server-url https://example.com:443/ --save-server-url
|
||||||
```
|
```
|
||||||
|
|
||||||
Ship the resulting `updater.exe` alongside the game folder.
|
Скрипт скачает нужные настройки с сервера и соберёт `updater.exe` в `dist/`. При повторных запусках `--server-url` можно не указывать — адрес запоминается локально. Готовый exe нужно вручную скопировать в клиентские папки на сервере (или сразу опубликовать как self-update — см. «Ручной запуск отдельных шагов»).
|
||||||
|
|
||||||
## Settings
|
## config.json (сервер)
|
||||||
|
|
||||||
On first run, the updater creates a `settings.ini` file next to itself with the selected client version:
|
Все настройки серверной части живут здесь, вложенными секциями:
|
||||||
|
|
||||||
```ini
|
| Секция | Что настраивает |
|
||||||
[updater]
|
|---|---|
|
||||||
client = Main
|
| `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, текущая версия обновлятора |
|
||||||
|
|
||||||
|
Чтобы что-то поменять — правьте `config.json` и перезапускайте `quickstart.py` (для ключей/манифестов) или `server.py` (для настроек сервера). Ручное редактирование `updater.py`/`generate_manifest.py` не нужно.
|
||||||
|
|
||||||
|
### Приватный ключ подписи
|
||||||
|
|
||||||
|
Единственный настоящий секрет в проекте — приватный ключ подписи манифестов. Он **не хранится в `config.json`**, а лежит в отдельном файле `SIGNING_PRIVATE_KEY.txt` на сервере (имя настраивается через `signing_keys.private_key_file`). Публичный ключ — в `config.json`, его можно раскрывать сколько угодно, зашивать в exe и т.д.
|
||||||
|
|
||||||
|
Держите `SIGNING_PRIVATE_KEY.txt` только на сервере — не копируйте его вместе с остальным проектом, добавьте в `.gitignore`, если используете git. `build_client.py` его никогда не запрашивает и не получает: сервер отдаёт клиентским сборкам только `client_build_info.json` (см. ниже), где приватного ключа нет по построению.
|
||||||
|
|
||||||
|
### /client_build_info.json
|
||||||
|
|
||||||
|
`server.py` отдаёт этот путь без access-ключа — это весь `config.json`, кроме секции `server` (host/port/лимиты — внутренняя топология сервера, не нужна сборке). Всё остальное содержимое всё равно окажется зашитым в `updater.exe` открытым текстом, так что прятать его за access-ключом смысла нет. Приватного ключа подписи там нет ни при каких обстоятельствах — он физически не входит в `config.json`.
|
||||||
|
|
||||||
|
## Сжатие (.gz)
|
||||||
|
|
||||||
|
При генерации манифеста каждый файл пробуется сжать gzip'ом; если сжатая копия меньше оригинала, она попадает в манифест и раздаётся вместо исходного файла.
|
||||||
|
|
||||||
|
- **Отдельная папка для `.gz`** — по умолчанию `manifest.compressed_dir` в `config.json` равен `"_gz"`, то есть сжатые копии лежат в `game_client/_gz/...`, а не рядом с оригиналами. `updater.py` при этом не меняется: он как и раньше запрашивает `system/game.exe.gz` напрямую — `server.py` сам подставляет реальный путь из `_gz/`, если файла нет на «прямом» месте. Поставьте `compressed_dir` в `""`, чтобы вернуть старое поведение (файлы рядом с оригиналом).
|
||||||
|
- **Кэш сжатия** — повторный запуск `generate_manifest.py` на той же папке пересжимает только файлы, у которых изменился хэш содержимого с прошлого раза (сверяется со старым `manifest.json`). Не изменившиеся файлы просто переиспользуют старые `compressed_hash`/`compressed_size` без повторного gzip. Это делает частую регенерацию манифеста на большом клиенте быстрой.
|
||||||
|
- **Принудительное пересжатие** — `python generate_manifest.py game_client --force-recompress` игнорирует кэш и пересжимает всё заново (например, после смены `compression_level` в `config.json`).
|
||||||
|
- **Очистка осиротевших `.gz`** — после каждой генерации манифеста автоматически удаляются `.gz`-файлы, которые больше ни на что не ссылаются: оригинал удалили/переименовали, файл перестал быть выгодным для сжатия, или сменили `compressed_dir` (старые копии в прежнем месте подчищаются). Без этого `.gz`-файлы только копились бы на диске. Отключить можно флагом `--no-clean-orphans`, если по какой-то причине это нежелательно.
|
||||||
|
|
||||||
|
## Как всё это работает вместе
|
||||||
|
|
||||||
|
```
|
||||||
|
СЕРВЕР:
|
||||||
|
|
||||||
|
config.json ──┬──> generate_key.py ────────> access-ключи в config.json
|
||||||
|
│ приватный ключ в SIGNING_PRIVATE_KEY.txt
|
||||||
|
│ публичный ключ в config.json
|
||||||
|
│
|
||||||
|
├──> generate_manifest.py ───> game_client/manifest.json + .sig
|
||||||
|
│ game_client/_gz/... (сжатые копии)
|
||||||
|
│
|
||||||
|
├──> generate_updater_manifest.py ──> game_client/updater_manifest.json + .sig
|
||||||
|
│
|
||||||
|
└──> quickstart.py ──> запускает все шаги выше по порядку
|
||||||
|
|
||||||
|
server.py ──> раздаёт клиентские папки по HTTP(S) под access-ключом в URL
|
||||||
|
──> отдаёт /client_build_info.json (без access-ключа, без приватного ключа)
|
||||||
|
|
||||||
|
WINDOWS (отдельная машина):
|
||||||
|
|
||||||
|
build_client.py ──> тянет /client_build_info.json ──> патчит updater.py ──> updater.exe
|
||||||
|
|
||||||
|
ИГРОК:
|
||||||
|
|
||||||
|
updater.py / updater.exe ──> скачивает манифест, проверяет подпись,
|
||||||
|
докачивает изменившиеся файлы, запускает игру
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
## Ручной запуск отдельных шагов
|
||||||
|
|
||||||
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.
|
Если `quickstart.py`/`build_client.py` не подходят, каждый скрипт можно запускать по отдельности.
|
||||||
|
|
||||||
## 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.
|
```bash
|
||||||
|
# access-ключ для клиента, сохранить в config.json
|
||||||
|
python generate_key.py --client Main --save
|
||||||
|
|
||||||
**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.
|
# пара ключей для подписи манифестов
|
||||||
|
python generate_key.py --signing-keypair --save
|
||||||
|
|
||||||
If a key leaks and you want to rotate it:
|
# сгенерировать и подписать манифест для одной клиентской папки
|
||||||
|
python generate_manifest.py game_client
|
||||||
|
|
||||||
1. Generate a new key with `generate_key.py`.
|
# то же самое, но пересжать вообще все файлы, игнорируя кэш
|
||||||
2. Update it in the server's `config.json`.
|
python generate_manifest.py game_client --force-recompress
|
||||||
3. Update it in `updater.py`'s `CLIENT_KEYS`.
|
|
||||||
4. Rebuild and redistribute `updater.exe`.
|
|
||||||
|
|
||||||
No folder renaming or manifest regeneration needed.
|
# сгенерировать манифест, но не трогать осиротевшие .gz файлы на диске
|
||||||
|
python generate_manifest.py game_client --no-clean-orphans
|
||||||
|
|
||||||
## Ignoring files during manifest generation
|
# опубликовать exe (уже собранный на Windows) как новую self-update версию
|
||||||
|
python generate_updater_manifest.py updater.exe game_client --version 2
|
||||||
|
|
||||||
Edit the configuration block at the top of `generate_manifest.py`:
|
# запустить сервер
|
||||||
|
python server.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.
|
На Windows:
|
||||||
|
|
||||||
## Compression
|
```bash
|
||||||
|
# первый раз — указать сервер и запомнить адрес
|
||||||
|
python build_client.py --server-url https://example.com:443/ --save-server-url
|
||||||
|
|
||||||
`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.
|
# дальше — просто
|
||||||
|
python build_client.py
|
||||||
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.
|
```bash
|
||||||
- `COMPRESSION_LEVEL` (default `6`, in `generate_manifest.py` only) — gzip level 1 (fastest, larger) to 9 (slowest, smallest).
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
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.
|
`cryptography` — подпись/проверка манифестов (Ed25519).
|
||||||
|
|
||||||
## Notes / limitations
|
Клиентская сборка (Windows):
|
||||||
|
|
||||||
- File integrity is checked with MD5 — fine for detecting changed files, not intended as a cryptographic guarantee.
|
```bash
|
||||||
- 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.
|
pip install -r requirements-client.txt
|
||||||
- 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.
|
`cryptography` — то же самое, но нужно уже внутри собранного exe (он сам проверяет подпись манифестов). `Pillow` — опционален, используется `updater.py` для качественного масштабирования баннера; без него баннер всё равно отрисуется, просто грубее. `pyinstaller` — сам сборщик exe.
|
||||||
|
|||||||
+338
@@ -0,0 +1,338 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
build_client.py
|
||||||
|
----------------------------------
|
||||||
|
Собирает updater.exe. ТОЛЬКО для Windows - PyInstaller не умеет
|
||||||
|
кросс-компилировать, так что запуск этого скрипта на Linux/macOS
|
||||||
|
произвёл бы ELF-бинарник с расширением .exe, который не запустится ни
|
||||||
|
на одной Windows-машине игрока. Запускайте это на настоящей Windows
|
||||||
|
(или Windows VM / Windows CI-раннере).
|
||||||
|
|
||||||
|
В отличие от quickstart.py (который живёт на сервере и генерирует
|
||||||
|
ключи), этот скрипт ничего не генерирует - он тянет всё нужное с уже
|
||||||
|
работающего сервера через /client_build_info.json:
|
||||||
|
|
||||||
|
- access_keys (по клиенту)
|
||||||
|
- signing_keys.public (публичный ключ подписи - не секрет)
|
||||||
|
- connection.* (адрес/порт сервера, HTTPS)
|
||||||
|
- updater_ui / paths / manifest / self_update / default_client
|
||||||
|
|
||||||
|
Приватный ключ подписи (SIGNING_PRIVATE_KEY.txt) в этот список НЕ входит
|
||||||
|
и никогда не будет отдан сервером - он должен существовать только на
|
||||||
|
машине, где генерируются манифесты (см. generate_manifest.py). Эта
|
||||||
|
Windows build-машина его не получает и не должна.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# первый раз - указываете адрес сервера, он запоминается локально
|
||||||
|
python build_client.py --server-url https://example.com:443/
|
||||||
|
|
||||||
|
# дальше можно просто:
|
||||||
|
python build_client.py
|
||||||
|
|
||||||
|
# выбрать другой сервер / переписать закэшированный адрес:
|
||||||
|
python build_client.py --server-url https://staging.example.com:8443/ --save-server-url
|
||||||
|
|
||||||
|
Результат - updater.exe в dist/ рядом со скриптом. Что делать с ним
|
||||||
|
дальше (положить в клиентские папки, опубликовать как self-update)
|
||||||
|
решаете сами на стороне сервера, например через:
|
||||||
|
python generate_updater_manifest.py dist/updater.exe game_client --version N
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_UPDATER_PY_PATH = os.path.join(_SCRIPT_DIR, "updater.py")
|
||||||
|
_LOCAL_SETTINGS_PATH = os.path.join(_SCRIPT_DIR, "build_client_settings.json")
|
||||||
|
|
||||||
|
CLIENT_BUILD_INFO_PATH = "client_build_info.json"
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
print(f"[build_client] {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def _check_windows():
|
||||||
|
if os.name != "nt":
|
||||||
|
raise SystemExit(
|
||||||
|
"build_client.py only makes sense on Windows: PyInstaller cannot "
|
||||||
|
"cross-compile, so an exe built here would not run on players' "
|
||||||
|
"Windows machines. Run this script on an actual Windows install, "
|
||||||
|
"VM, or CI runner instead."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_local_settings():
|
||||||
|
if not os.path.exists(_LOCAL_SETTINGS_PATH):
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(_LOCAL_SETTINGS_PATH, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_local_settings(settings):
|
||||||
|
with open(_LOCAL_SETTINGS_PATH, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(settings, f, indent=4)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_server_url(cli_url, save_it):
|
||||||
|
"""
|
||||||
|
Where to fetch client_build_info.json from. Precedence:
|
||||||
|
1. --server-url on the command line, if given.
|
||||||
|
2. Previously saved value in build_client_settings.json.
|
||||||
|
3. Neither -> error asking for --server-url once.
|
||||||
|
|
||||||
|
A URL given on the command line is only written back to the local
|
||||||
|
settings file if --save-server-url was also passed - otherwise a
|
||||||
|
one-off override (e.g. testing against staging) doesn't silently
|
||||||
|
become the new default for next time.
|
||||||
|
"""
|
||||||
|
settings = load_local_settings()
|
||||||
|
|
||||||
|
url = cli_url or settings.get("server_url")
|
||||||
|
if not url:
|
||||||
|
raise SystemExit(
|
||||||
|
"No server URL known yet. Run once with --server-url, e.g.:\n"
|
||||||
|
" python build_client.py --server-url https://example.com:443/"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not url.endswith("/"):
|
||||||
|
url += "/"
|
||||||
|
|
||||||
|
if cli_url and save_it:
|
||||||
|
settings["server_url"] = url
|
||||||
|
save_local_settings(settings)
|
||||||
|
log(f"Saved server URL to {os.path.basename(_LOCAL_SETTINGS_PATH)} for next time.")
|
||||||
|
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_client_build_info(server_url):
|
||||||
|
info_url = server_url + CLIENT_BUILD_INFO_PATH
|
||||||
|
log(f"Fetching build info from {info_url} ...")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(info_url, timeout=15) as resp:
|
||||||
|
raw = resp.read()
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
raise SystemExit(
|
||||||
|
f"Couldn't reach {info_url} ({e}). Is server.py running and reachable "
|
||||||
|
"from this machine? Check --server-url / firewall / HTTPS setup."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise SystemExit(f"Server response at {info_url} wasn't valid JSON: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_build_info(info):
|
||||||
|
access_keys = info.get("access_keys", {})
|
||||||
|
if not access_keys or not any(access_keys.values()):
|
||||||
|
raise SystemExit(
|
||||||
|
"Server's client_build_info.json has no usable access_keys. "
|
||||||
|
"Run quickstart.py on the server first to generate them."
|
||||||
|
)
|
||||||
|
|
||||||
|
public_key = info.get("signing_keys", {}).get("public")
|
||||||
|
if not public_key:
|
||||||
|
raise SystemExit(
|
||||||
|
"Server's client_build_info.json has no signing_keys.public. "
|
||||||
|
"Run quickstart.py on the server first to generate the signing keypair."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Defense in depth: the server-side code never includes this, but if
|
||||||
|
# someone hand-edits server.py or points this script at some other
|
||||||
|
# JSON endpoint by mistake, refuse outright rather than silently
|
||||||
|
# baking a private key into an exe that ships to players.
|
||||||
|
if info.get("signing_keys", {}).get("private"):
|
||||||
|
raise SystemExit(
|
||||||
|
"SECURITY: the fetched build info contains a private signing key. "
|
||||||
|
"Refusing to build - this should never happen against a real "
|
||||||
|
"server.py; double-check --server-url points at the right server."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Patch updater.py from the fetched build info and build the exe.
|
||||||
|
# Same regex-based approach quickstart.py used to use server-side, just
|
||||||
|
# fed from fetched JSON instead of a local config.json.
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_patch_rules(info):
|
||||||
|
ui = info.get("updater_ui", {})
|
||||||
|
conn = info.get("connection", {})
|
||||||
|
paths = info.get("paths", {})
|
||||||
|
manifest = info.get("manifest", {})
|
||||||
|
self_update = info.get("self_update", {})
|
||||||
|
access_keys = info.get("access_keys", {})
|
||||||
|
signing = info.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(info.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(info.get("require_signature", True))}'),
|
||||||
|
|
||||||
|
(r'^MAX_DECOMPRESSED_SIZE = .*$',
|
||||||
|
f'MAX_DECOMPRESSED_SIZE = {int(max_mb)} * 1024 * 1024 # {max_mb} MiB, from server config'),
|
||||||
|
|
||||||
|
(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(info):
|
||||||
|
"""
|
||||||
|
Returns the patched updater.py source as a string, WITHOUT writing it
|
||||||
|
anywhere - caller decides where the patched copy goes. Never modifies
|
||||||
|
the checked-in updater.py in place, so re-running this repeatedly
|
||||||
|
can't accumulate stale patched cruft in the source you might also
|
||||||
|
edit by hand for other reasons.
|
||||||
|
"""
|
||||||
|
with open(_UPDATER_PY_PATH, "r", encoding="utf-8") as f:
|
||||||
|
source = f.read()
|
||||||
|
|
||||||
|
for pattern, replacement in _build_patch_rules(info):
|
||||||
|
new_source, count = re.subn(pattern, replacement, source, count=1, flags=re.M)
|
||||||
|
if count == 0:
|
||||||
|
raise SystemExit(
|
||||||
|
f"build_client.py couldn't find a line matching {pattern!r} in updater.py. "
|
||||||
|
"updater.py's structure may have changed - update _build_patch_rules() in "
|
||||||
|
"build_client.py, or patch this value by hand before building."
|
||||||
|
)
|
||||||
|
source = new_source
|
||||||
|
|
||||||
|
return source
|
||||||
|
|
||||||
|
|
||||||
|
def build_exe(info, pyinstaller_args=None):
|
||||||
|
build_dir = os.path.join(_SCRIPT_DIR, "_build_client_work")
|
||||||
|
if os.path.exists(build_dir):
|
||||||
|
shutil.rmtree(build_dir)
|
||||||
|
os.makedirs(build_dir)
|
||||||
|
|
||||||
|
patched_source = patch_updater_source(info)
|
||||||
|
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 = info.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 build_client.py - "
|
||||||
|
f"the built exe will show its 'banner not found' placeholder / default icon. "
|
||||||
|
f"Copy {rel_name} next to build_client.py before building if you want it included.")
|
||||||
|
|
||||||
|
icon_path = os.path.join(build_dir, icon_name)
|
||||||
|
banner_path = os.path.join(build_dir, banner_name)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
sys.executable, "-m", "PyInstaller",
|
||||||
|
"--onefile", "--noconsole",
|
||||||
|
"--distpath", os.path.join(_SCRIPT_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};."]
|
||||||
|
if os.path.exists(banner_path):
|
||||||
|
cmd += ["--add-data", f"{banner_path};."]
|
||||||
|
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(_SCRIPT_DIR, "dist")
|
||||||
|
built_exe = os.path.join(dist_dir, "updater.exe")
|
||||||
|
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 main():
|
||||||
|
_check_windows()
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
parser.add_argument("--server-url", help="e.g. https://example.com:443/ - where to fetch client_build_info.json from.")
|
||||||
|
parser.add_argument("--save-server-url", action="store_true",
|
||||||
|
help="Remember --server-url locally for future runs (build_client_settings.json).")
|
||||||
|
parser.add_argument("--pyinstaller-arg", action="append", dest="pyinstaller_args",
|
||||||
|
help="Доп. аргумент для PyInstaller, можно указывать несколько раз.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
server_url = resolve_server_url(args.server_url, args.save_server_url)
|
||||||
|
info = fetch_client_build_info(server_url)
|
||||||
|
validate_build_info(info)
|
||||||
|
|
||||||
|
built_exe = build_exe(info, pyinstaller_args=args.pyinstaller_args)
|
||||||
|
|
||||||
|
log(f"Done. {built_exe} is ready.")
|
||||||
|
log("To publish it for self-update or fresh installs, run on the SERVER side:")
|
||||||
|
log(f" python generate_updater_manifest.py {os.path.basename(built_exe)} <client_dir> [<client_dir> ...]")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+73
-4
@@ -1,8 +1,77 @@
|
|||||||
{
|
{
|
||||||
"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",
|
||||||
|
"compressed_dir": "_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)
|
||||||
|
|||||||
+395
-29
@@ -1,42 +1,121 @@
|
|||||||
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")
|
||||||
|
|
||||||
# Suffix used for the precompressed copy of each file, stored alongside the
|
|
||||||
# original (e.g. "texture.dds" -> "texture.dds.gz").
|
def _load_config():
|
||||||
COMPRESSED_SUFFIX = ".gz"
|
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 (e.g. "texture.dds"
|
||||||
|
# -> "texture.dds.gz").
|
||||||
|
COMPRESSED_SUFFIX = _manifest_cfg.get("compressed_suffix", ".gz")
|
||||||
|
|
||||||
|
# Where to put those precompressed copies, relative to the client folder.
|
||||||
|
# Empty string (default "") keeps the old behavior: "texture.dds.gz" sits
|
||||||
|
# right next to "texture.dds". Setting this to e.g. "_gz" moves ALL
|
||||||
|
# compressed copies into client_dir/_gz/ instead, mirroring the original
|
||||||
|
# folder structure underneath it (client_dir/_gz/system/game.exe.gz for
|
||||||
|
# client_dir/system/game.exe) - keeps the client folder itself free of
|
||||||
|
# .gz clutter. This only affects where generate_manifest.py writes files
|
||||||
|
# and where server.py looks for them (server.py falls back to this
|
||||||
|
# subfolder when a requested .gz isn't found at the direct path) -
|
||||||
|
# updater.py is completely unaware of this and always requests
|
||||||
|
# "<rel_path><compressed_suffix>" at what looks like the direct location.
|
||||||
|
COMPRESSED_DIR = _manifest_cfg.get("compressed_dir", "") or ""
|
||||||
|
|
||||||
# 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 +139,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
|
||||||
@@ -81,6 +179,8 @@ def compress_file(filepath, compressed_path):
|
|||||||
with open(filepath, "rb") as f_in:
|
with open(filepath, "rb") as f_in:
|
||||||
data = f_in.read()
|
data = f_in.read()
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(compressed_path) or ".", exist_ok=True)
|
||||||
|
|
||||||
with open(compressed_path, "wb") as raw_out:
|
with open(compressed_path, "wb") as raw_out:
|
||||||
with gzip.GzipFile(fileobj=raw_out, mode="wb", compresslevel=COMPRESSION_LEVEL, mtime=0) as f_out:
|
with gzip.GzipFile(fileobj=raw_out, mode="wb", compresslevel=COMPRESSION_LEVEL, mtime=0) as f_out:
|
||||||
f_out.write(data)
|
f_out.write(data)
|
||||||
@@ -95,30 +195,240 @@ def compress_file(filepath, compressed_path):
|
|||||||
return compressed_size, compressed_hash
|
return compressed_size, compressed_hash
|
||||||
|
|
||||||
|
|
||||||
def generate(client_dir):
|
def _compressed_path_for(client_dir, rel_path_web):
|
||||||
|
"""
|
||||||
|
Where the .gz copy of rel_path_web should live on disk, honoring
|
||||||
|
COMPRESSED_DIR (see its definition above for the "" vs "_gz" behavior).
|
||||||
|
"""
|
||||||
|
gz_rel = rel_path_web + COMPRESSED_SUFFIX
|
||||||
|
if COMPRESSED_DIR:
|
||||||
|
return os.path.join(client_dir, COMPRESSED_DIR, *gz_rel.split("/"))
|
||||||
|
return os.path.join(client_dir, *gz_rel.split("/"))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_compress(filepath, rel_path_web, file_hash, client_dir, old_manifest, force_recompress):
|
||||||
|
"""
|
||||||
|
Returns (compressed_size, compressed_hash, reused_bool).
|
||||||
|
|
||||||
|
If force_recompress is False and the previous manifest already has an
|
||||||
|
entry for this exact rel_path with the SAME source hash (i.e. the
|
||||||
|
original file hasn't changed since the last run) AND a compressed
|
||||||
|
copy that still actually exists on disk at the expected location -
|
||||||
|
trusts the cached compressed_size/compressed_hash instead of
|
||||||
|
re-running gzip. This is what makes repeated manifest generation on a
|
||||||
|
mostly-unchanged client folder fast: only files that actually changed
|
||||||
|
get recompressed.
|
||||||
|
|
||||||
|
Any mismatch (hash differs, no cached entry, cached entry says
|
||||||
|
"compressed": false, or the .gz file has gone missing from disk for
|
||||||
|
any reason) falls through to actually compressing it fresh - caching
|
||||||
|
is purely an optimization here, never a correctness requirement, so
|
||||||
|
a torn/missing cache never produces a wrong manifest.
|
||||||
|
"""
|
||||||
|
compressed_path = _compressed_path_for(client_dir, rel_path_web)
|
||||||
|
|
||||||
|
if not force_recompress:
|
||||||
|
old_entry = old_manifest.get(rel_path_web)
|
||||||
|
if (
|
||||||
|
isinstance(old_entry, dict)
|
||||||
|
and old_entry.get("hash") == file_hash
|
||||||
|
and old_entry.get("compressed") is True
|
||||||
|
and old_entry.get("compressed_hash")
|
||||||
|
and old_entry.get("compressed_size")
|
||||||
|
and os.path.isfile(compressed_path)
|
||||||
|
):
|
||||||
|
return old_entry["compressed_size"], old_entry["compressed_hash"], True
|
||||||
|
|
||||||
|
if (
|
||||||
|
isinstance(old_entry, dict)
|
||||||
|
and old_entry.get("hash") == file_hash
|
||||||
|
and old_entry.get("compressed") is False
|
||||||
|
):
|
||||||
|
# Previously determined compression wasn't worth it for this
|
||||||
|
# exact content (e.g. an already-compressed asset format) -
|
||||||
|
# trust that too, rather than re-running gzip just to
|
||||||
|
# rediscover the same "not worth it" result.
|
||||||
|
return None, None, True
|
||||||
|
|
||||||
|
compressed_size, compressed_hash = compress_file(filepath, compressed_path)
|
||||||
|
return compressed_size, compressed_hash, False
|
||||||
|
|
||||||
|
|
||||||
|
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 _iter_existing_gz_files(client_dir):
|
||||||
|
"""
|
||||||
|
Yields absolute paths of every *.gz file currently on disk under
|
||||||
|
client_dir, in both places a compressed copy could plausibly be:
|
||||||
|
directly next to originals, and inside compressed_dir (if configured).
|
||||||
|
Covers the "changed compressed_dir setting at some point" case too -
|
||||||
|
stray .gz files from before the switch are still found and can be
|
||||||
|
cleaned up rather than sitting there forever.
|
||||||
|
"""
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
# Direct-location .gz files (relevant when compressed_dir is "" now,
|
||||||
|
# or was "" at some point in the past for files generated back then).
|
||||||
|
for root, dirs, files in os.walk(client_dir):
|
||||||
|
# Don't walk into compressed_dir here - it's handled by the
|
||||||
|
# second pass below, and walking it here too would just mean
|
||||||
|
# visiting the same files twice with different rel-path math.
|
||||||
|
if COMPRESSED_DIR:
|
||||||
|
compressed_dir_abs = os.path.realpath(os.path.join(client_dir, COMPRESSED_DIR))
|
||||||
|
dirs[:] = [d for d in dirs if os.path.realpath(os.path.join(root, d)) != compressed_dir_abs]
|
||||||
|
for file in files:
|
||||||
|
if file.endswith(COMPRESSED_SUFFIX):
|
||||||
|
path = os.path.join(root, file)
|
||||||
|
if path not in seen:
|
||||||
|
seen.add(path)
|
||||||
|
yield path
|
||||||
|
|
||||||
|
# compressed_dir's own .gz files (relevant when compressed_dir is set
|
||||||
|
# now, or was set to a different value in the past - a rename of
|
||||||
|
# compressed_dir leaves the old subfolder behind, which this also
|
||||||
|
# cleans up since old_dir no longer matches COMPRESSED_DIR and so
|
||||||
|
# isn't skipped by the os.walk above).
|
||||||
|
if COMPRESSED_DIR:
|
||||||
|
compressed_dir_path = os.path.join(client_dir, COMPRESSED_DIR)
|
||||||
|
if os.path.isdir(compressed_dir_path):
|
||||||
|
for root, _dirs, files in os.walk(compressed_dir_path):
|
||||||
|
for file in files:
|
||||||
|
if file.endswith(COMPRESSED_SUFFIX):
|
||||||
|
path = os.path.join(root, file)
|
||||||
|
if path not in seen:
|
||||||
|
seen.add(path)
|
||||||
|
yield path
|
||||||
|
|
||||||
|
|
||||||
|
def clean_orphaned_gz_files(client_dir, manifest):
|
||||||
|
"""
|
||||||
|
Removes .gz files on disk that no longer correspond to any entry in
|
||||||
|
the freshly generated manifest - e.g. the original file was deleted
|
||||||
|
or renamed, compression stopped being worth it for it, or
|
||||||
|
compressed_dir was changed and old copies were left behind in the old
|
||||||
|
location. Without this, compressed copies only ever accumulate: they
|
||||||
|
get written when a file benefits from compression, but nothing ever
|
||||||
|
deleted them again on its own.
|
||||||
|
|
||||||
|
A .gz file is considered current (kept) only if BOTH:
|
||||||
|
- its path matches exactly where _compressed_path_for() would put
|
||||||
|
the compressed copy for some rel_path in the new manifest, AND
|
||||||
|
- that manifest entry actually says "compressed": true (an entry
|
||||||
|
that flipped to "not worth compressing" this run should have its
|
||||||
|
old .gz removed even if the path would otherwise match).
|
||||||
|
|
||||||
|
Returns the number of files removed. Never raises on a single file
|
||||||
|
failing to delete (permissions, file in use, etc.) - logs and moves on,
|
||||||
|
since a stale .gz left behind is a cosmetic problem, not a correctness
|
||||||
|
one (the server/updater never trust a .gz that isn't in the manifest).
|
||||||
|
"""
|
||||||
|
current_gz_paths = set()
|
||||||
|
for rel_path_web, entry in manifest.items():
|
||||||
|
if entry.get("compressed"):
|
||||||
|
current_gz_paths.add(os.path.realpath(_compressed_path_for(client_dir, rel_path_web)))
|
||||||
|
|
||||||
|
removed = 0
|
||||||
|
for gz_path in _iter_existing_gz_files(client_dir):
|
||||||
|
if os.path.realpath(gz_path) in current_gz_paths:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
os.remove(gz_path)
|
||||||
|
removed += 1
|
||||||
|
except OSError as e:
|
||||||
|
print(f" WARNING: couldn't remove orphaned {gz_path}: {e}")
|
||||||
|
|
||||||
|
# Clean up now-empty directories left behind inside compressed_dir
|
||||||
|
# (e.g. a whole subfolder of the game was removed). Doesn't touch
|
||||||
|
# compressed_dir's own top-level folder even if it ends up empty -
|
||||||
|
# leaving an empty "_gz" around is harmless and avoids surprising
|
||||||
|
# "the folder I configured just vanished" behavior.
|
||||||
|
if COMPRESSED_DIR:
|
||||||
|
compressed_dir_path = os.path.join(client_dir, COMPRESSED_DIR)
|
||||||
|
if os.path.isdir(compressed_dir_path):
|
||||||
|
for root, dirs, files in os.walk(compressed_dir_path, topdown=False):
|
||||||
|
if root == compressed_dir_path:
|
||||||
|
continue
|
||||||
|
if not dirs and not files:
|
||||||
|
try:
|
||||||
|
os.rmdir(root)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def generate(client_dir, force_recompress=False, clean_orphans=True):
|
||||||
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!")
|
||||||
return
|
return
|
||||||
|
|
||||||
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"
|
||||||
|
|
||||||
|
# Load the previous manifest (if any) so unchanged files can skip
|
||||||
|
# recompression - see _get_or_compress below. A missing/corrupt old
|
||||||
|
# manifest just means nothing is cached yet, not an error.
|
||||||
|
old_manifest = {}
|
||||||
|
if os.path.exists(manifest_path):
|
||||||
|
try:
|
||||||
|
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||||
|
old_manifest = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
old_manifest = {}
|
||||||
|
|
||||||
|
compressed_dir_abs = os.path.realpath(os.path.join(client_dir, COMPRESSED_DIR)) if COMPRESSED_DIR else None
|
||||||
|
|
||||||
print(f"Scanning client folder: {client_dir}")
|
print(f"Scanning client folder: {client_dir}")
|
||||||
|
if force_recompress:
|
||||||
|
print("--force-recompress given: recompressing every file regardless of cache.")
|
||||||
|
|
||||||
total_original = 0
|
total_original = 0
|
||||||
total_saved = 0
|
total_saved = 0
|
||||||
|
skipped_unsafe = 0
|
||||||
|
reused_count = 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)...
|
||||||
dirs[:] = [d for d in dirs if d.lower() not in IGNORE_FOLDERS]
|
dirs[:] = [d for d in dirs if d.lower() not in IGNORE_FOLDERS]
|
||||||
|
|
||||||
|
# ...and specifically the compressed_dir subtree, wherever it is
|
||||||
|
# nested, so its .gz files are never themselves scanned as game
|
||||||
|
# files. IGNORE_FOLDERS above only matches by folder *name* at any
|
||||||
|
# depth; this matches the exact configured path, since a name
|
||||||
|
# like "_gz" could in principle collide with an unrelated folder
|
||||||
|
# the game legitimately ships.
|
||||||
|
if compressed_dir_abs is not None:
|
||||||
|
dirs[:] = [
|
||||||
|
d for d in dirs
|
||||||
|
if os.path.realpath(os.path.join(root, d)) != compressed_dir_abs
|
||||||
|
]
|
||||||
|
|
||||||
for file in files:
|
for file in files:
|
||||||
if file in IGNORE_FILES:
|
if file in IGNORE_FILES:
|
||||||
continue
|
continue
|
||||||
if any(file.lower().endswith(ext) for ext in IGNORE_EXTENSIONS):
|
if any(file.lower().endswith(ext) for ext in IGNORE_EXTENSIONS):
|
||||||
continue
|
continue
|
||||||
# Skip stray leftover compressed copies from a previous run so
|
# Skip stray leftover compressed copies from a previous run
|
||||||
# we never treat "texture.dds.gz" as a distributable file itself.
|
# (relevant when compressed_dir is "" and .gz files sit right
|
||||||
|
# next to their originals) so we never treat "texture.dds.gz"
|
||||||
|
# as a distributable file itself.
|
||||||
if file.endswith(COMPRESSED_SUFFIX):
|
if file.endswith(COMPRESSED_SUFFIX):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -128,6 +438,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
|
||||||
@@ -137,8 +452,12 @@ def generate(client_dir):
|
|||||||
"size": os.path.getsize(filepath),
|
"size": os.path.getsize(filepath),
|
||||||
}
|
}
|
||||||
|
|
||||||
compressed_path = filepath + COMPRESSED_SUFFIX
|
compressed_size, compressed_hash, reused = _get_or_compress(
|
||||||
compressed_size, compressed_hash = compress_file(filepath, compressed_path)
|
filepath, rel_path_web, file_hash, client_dir,
|
||||||
|
old_manifest, force_recompress,
|
||||||
|
)
|
||||||
|
if reused:
|
||||||
|
reused_count += 1
|
||||||
|
|
||||||
if compressed_hash:
|
if compressed_hash:
|
||||||
entry["compressed"] = True
|
entry["compressed"] = True
|
||||||
@@ -152,13 +471,39 @@ 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}%)")
|
||||||
|
|
||||||
|
if clean_orphans:
|
||||||
|
removed = clean_orphaned_gz_files(client_dir, manifest)
|
||||||
|
if removed:
|
||||||
|
print(f"Removed {removed} orphaned .gz file(s) no longer referenced by the manifest.")
|
||||||
except IOError as e:
|
except IOError as e:
|
||||||
print(f"Error saving manifest: {e}")
|
print(f"Error saving manifest: {e}")
|
||||||
|
|
||||||
@@ -166,7 +511,28 @@ def generate(client_dir):
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser(description="Universal manifest generator for game updaters.")
|
parser = argparse.ArgumentParser(description="Universal manifest generator for game updaters.")
|
||||||
parser.add_argument("client_path", help="Path to the client files folder")
|
parser.add_argument("client_path", help="Path to the client files folder")
|
||||||
|
parser.add_argument(
|
||||||
|
"--force-recompress", action="store_true",
|
||||||
|
help=(
|
||||||
|
"Recompress every file with gzip, ignoring the cache from the previous "
|
||||||
|
"manifest.json (which normally skips recompression for files whose hash "
|
||||||
|
"hasn't changed). Use this after changing manifest.compression_level in "
|
||||||
|
"config.json, or if you suspect the .gz cache is stale/corrupt."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-clean-orphans", action="store_true",
|
||||||
|
help=(
|
||||||
|
"Don't remove .gz files that no longer correspond to any file in the "
|
||||||
|
"manifest (leftover from deleted/renamed originals, or from switching "
|
||||||
|
"manifest.compressed_dir in config.json). Cleanup runs by default."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
generate(args.client_path)
|
generate(
|
||||||
|
args.client_path,
|
||||||
|
force_recompress=args.force_recompress,
|
||||||
|
clean_orphans=not args.no_clean_orphans,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
+225
@@ -0,0 +1,225 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
quickstart.py
|
||||||
|
----------------------------------
|
||||||
|
Одна команда вместо ручного дёргания generate_key.py / generate_manifest.py /
|
||||||
|
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 для каждой клиентской папки, у которой
|
||||||
|
есть хоть один файл (пустые папки пропускаются с предупреждением -
|
||||||
|
скорее всего, вы просто ещё не положили туда файлы игры).
|
||||||
|
|
||||||
|
Каждый шаг идемпотентен и безопасен перезапускать повторно: уже
|
||||||
|
сгенерированные ключи не трогаются (кроме как через generate_key.py
|
||||||
|
--save напрямую, если вы явно хотите их сменить), уже существующие папки
|
||||||
|
клиентов не перезаписываются, манифесты просто перегенерируются с
|
||||||
|
текущим содержимым папки.
|
||||||
|
|
||||||
|
Сборка updater.exe - ОТДЕЛЬНЫЙ шаг, вне этого скрипта. PyInstaller не
|
||||||
|
умеет кросс-компилировать: собранный на Linux "updater.exe" - это ELF-
|
||||||
|
бинарник с расширением .exe, который на Windows просто не запустится.
|
||||||
|
Соберите его на настоящей Windows-машине (или Windows VM/CI-раннере)
|
||||||
|
скриптом build_client.py, который тянет всё нужное (access-ключи,
|
||||||
|
публичный ключ подписи, адрес сервера и т.д.) прямо с работающего
|
||||||
|
сервера через /client_build_info.json - см. build_client.py и README.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python quickstart.py # ключи + манифесты
|
||||||
|
python quickstart.py --skip-keys # только манифесты
|
||||||
|
python quickstart.py --skip-manifests # только ключи
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_CONFIG_PATH = os.path.join(_SCRIPT_DIR, "config.json")
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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 для клиентских папок.")
|
||||||
|
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).")
|
||||||
|
|
||||||
|
log("Done. To build updater.exe, run build_client.py on a Windows machine")
|
||||||
|
log("pointed at this server (see README) - PyInstaller can't cross-compile,")
|
||||||
|
log("so an exe built here on Linux would not run on players' machines.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Требования для КЛИЕНТСКОЙ сборки: build_client.py, запускается на
|
||||||
|
# Windows (PyInstaller не умеет кросс-компилировать - сборка на любой
|
||||||
|
# другой ОС дала бы exe, не запускающийся на машинах игроков).
|
||||||
|
#
|
||||||
|
# cryptography - updater.exe проверяет Ed25519-подпись манифестов сам,
|
||||||
|
# так что это нужно уже в собранном экзешнике, а не только на сервере.
|
||||||
|
cryptography>=42.0
|
||||||
|
|
||||||
|
# Pillow опционален - используется updater.py только для более
|
||||||
|
# качественного масштабирования banner.png; без него баннер всё равно
|
||||||
|
# отрисуется, просто грубее (см. README).
|
||||||
|
Pillow>=10.0
|
||||||
|
|
||||||
|
# Сам сборщик exe.
|
||||||
|
pyinstaller>=6.0
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Требования для СЕРВЕРНОЙ части: quickstart.py, generate_key.py,
|
||||||
|
# generate_manifest.py, generate_updater_manifest.py, server.py.
|
||||||
|
# Запускается на любой ОС (обычно Linux).
|
||||||
|
#
|
||||||
|
# cryptography - подпись/проверка манифестов (Ed25519).
|
||||||
|
cryptography>=42.0
|
||||||
|
|
||||||
|
# Для сборки updater.exe требования отдельные - см. requirements-client.txt.
|
||||||
|
# Собирать exe можно ТОЛЬКО на Windows (PyInstaller не кросс-компилирует),
|
||||||
|
# так что requirements-client.txt устанавливается на Windows-машине,
|
||||||
|
# запускающей build_client.py, а не здесь.
|
||||||
@@ -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,53 @@ 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 = {}
|
||||||
|
|
||||||
|
# Where generate_manifest.py puts precompressed .gz copies, relative to
|
||||||
|
# each client folder - e.g. "_gz" means game_client/_gz/system/game.exe.gz
|
||||||
|
# instead of game_client/system/game.exe.gz sitting right next to the
|
||||||
|
# original. updater.py always requests "<rel_path><compressed_suffix>"
|
||||||
|
# (e.g. "system/game.exe.gz") with no knowledge of this subfolder at all -
|
||||||
|
# _resolve_client_path below is what quietly redirects that request to
|
||||||
|
# the real location on disk, so updater.py never needs to change.
|
||||||
|
# Empty string ("") restores the old behavior of .gz files living right
|
||||||
|
# next to their originals.
|
||||||
|
COMPRESSED_SUFFIX = CONFIG.get("manifest", {}).get("compressed_suffix", ".gz")
|
||||||
|
COMPRESSED_DIR = CONFIG.get("manifest", {}).get("compressed_dir", "") or ""
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Client-build info endpoint
|
||||||
|
#
|
||||||
|
# build_client.py runs on a separate Windows machine and needs a handful
|
||||||
|
# of config.json values to bake into updater.exe (access keys, the
|
||||||
|
# PUBLIC signing key, server address, UI settings, etc.) without ever
|
||||||
|
# having the server's own config.json - which lives only here, and which
|
||||||
|
# never contains the private signing key in the first place (see
|
||||||
|
# generate_key.py / SIGNING_PRIVATE_KEY.txt). This endpoint hands out
|
||||||
|
# exactly that subset as JSON, deliberately excluding the "server" section
|
||||||
|
# (host/port/connection-limit are this machine's own topology, not
|
||||||
|
# something a client build needs to know) and never touching the private
|
||||||
|
# key file on disk.
|
||||||
|
#
|
||||||
|
# Not access-key-gated like game files: everything in this response ends
|
||||||
|
# up baked into updater.exe in plaintext anyway (that's the whole point -
|
||||||
|
# see build_client.py), so gating it behind a key would just be security
|
||||||
|
# theater while adding a step. If you don't want this exposed publicly,
|
||||||
|
# put the reverse proxy in front of it the same way you would for
|
||||||
|
# anything else internet-facing (see README).
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
CLIENT_BUILD_INFO_PATH = "/client_build_info.json"
|
||||||
|
_CLIENT_BUILD_INFO_BYTES = json.dumps(
|
||||||
|
{k: v for k, v in CONFIG.items() if k != "server"},
|
||||||
|
indent=4, sort_keys=True,
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
class MultiClientHandler(SimpleHTTPRequestHandler):
|
class MultiClientHandler(SimpleHTTPRequestHandler):
|
||||||
"""
|
"""
|
||||||
@@ -61,6 +123,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)
|
||||||
@@ -77,19 +144,58 @@ class MultiClientHandler(SimpleHTTPRequestHandler):
|
|||||||
rel_parts = parts[1:]
|
rel_parts = parts[1:]
|
||||||
rel_path = posixpath.join(*rel_parts) if rel_parts else ""
|
rel_path = posixpath.join(*rel_parts) if rel_parts else ""
|
||||||
|
|
||||||
full_path = os.path.realpath(os.path.join(base_dir, rel_path))
|
direct_path = self._safe_join(base_dir, rel_path)
|
||||||
|
if direct_path is None:
|
||||||
|
return None
|
||||||
|
|
||||||
# Confirm the resolved path is still inside base_dir (blocks "..",
|
if os.path.isfile(direct_path):
|
||||||
# symlink tricks, etc.). commonpath needs both paths to exist on the
|
return direct_path
|
||||||
# same drive/root, which realpath + os.path.join guarantees here.
|
|
||||||
|
# Not found at the direct location. If this looks like a
|
||||||
|
# precompressed file (".gz" suffix) and a compressed_dir is
|
||||||
|
# configured, try the same relative path underneath that
|
||||||
|
# subfolder instead - e.g. a request for "system/game.exe.gz"
|
||||||
|
# falls back to "<compressed_dir>/system/game.exe.gz". updater.py
|
||||||
|
# only ever asks for "<rel_path><compressed_suffix>" at the
|
||||||
|
# direct-looking URL, so this fallback is the only thing that
|
||||||
|
# needs to know .gz files might actually live elsewhere on disk.
|
||||||
|
if COMPRESSED_DIR and COMPRESSED_SUFFIX and rel_path.endswith(COMPRESSED_SUFFIX):
|
||||||
|
gz_path = self._safe_join(base_dir, posixpath.join(COMPRESSED_DIR, rel_path))
|
||||||
|
if gz_path is not None and os.path.isfile(gz_path):
|
||||||
|
return gz_path
|
||||||
|
|
||||||
|
# Genuinely missing either way - return the direct path so the
|
||||||
|
# normal "file not found" -> 404 flow in http.server still
|
||||||
|
# applies (translate_path callers expect a path, not a bool).
|
||||||
|
return direct_path
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_join(base_dir, rel_path):
|
||||||
|
"""
|
||||||
|
Joins rel_path onto base_dir and confirms the result is still
|
||||||
|
inside base_dir (blocks "..", symlink tricks, etc. - the same
|
||||||
|
check previously done inline in _resolve_client_path, now shared
|
||||||
|
with the compressed_dir fallback above). Returns None if the
|
||||||
|
result would escape base_dir.
|
||||||
|
"""
|
||||||
|
full_path = os.path.realpath(os.path.join(base_dir, rel_path))
|
||||||
try:
|
try:
|
||||||
if os.path.commonpath([full_path, base_dir]) != base_dir:
|
if os.path.commonpath([full_path, base_dir]) != base_dir:
|
||||||
return None
|
return None
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return full_path
|
return full_path
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path.split("?", 1)[0] == CLIENT_BUILD_INFO_PATH:
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(_CLIENT_BUILD_INFO_BYTES)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(_CLIENT_BUILD_INFO_BYTES)
|
||||||
|
return
|
||||||
|
super().do_GET()
|
||||||
|
|
||||||
def translate_path(self, path):
|
def translate_path(self, path):
|
||||||
resolved = self._resolve_client_path(path)
|
resolved = self._resolve_client_path(path)
|
||||||
if resolved is None:
|
if resolved is None:
|
||||||
@@ -102,10 +208,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
|
||||||
|
|||||||
+521
-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,24 @@ 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. build_client.py
|
||||||
|
# writes this whole block (pulling values from a running server's
|
||||||
|
# /client_build_info.json, not from a local config.json - see
|
||||||
|
# build_client.py's docstring) before running PyInstaller, so in normal
|
||||||
|
# use you edit the SERVER's config.json and re-run build_client.py rather
|
||||||
|
# than touching this file by hand. See config.json / README for what each
|
||||||
|
# setting under updater_ui / connection / paths / manifest / self_update /
|
||||||
|
# signing_keys / access_keys maps to here.
|
||||||
|
#
|
||||||
|
# The block below is also read as a live fallback: if this script is run
|
||||||
|
# directly with `python updater.py` (not the compiled exe) and a
|
||||||
|
# config.json happens to sit next to it, values from config.json override
|
||||||
|
# these constants at import time - convenient for local testing without
|
||||||
|
# recompiling. A compiled exe never does this lookup (see
|
||||||
|
# CONFIG_OVERRIDES_APPLIED below) - the values below are simply what got
|
||||||
|
# baked in at build time, so a released exe never depends on a
|
||||||
|
# config.json existing on the player's machine.
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
GAME_NAME = "My Game" # Shown in the window title
|
GAME_NAME = "My Game" # Shown in the window title
|
||||||
@@ -54,6 +76,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 +111,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 +120,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 +250,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 +296,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 +497,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 +711,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 +733,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 +751,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 +761,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 +817,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 +842,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