#!/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()