This commit is contained in:
2026-09-12 03:11:49 +03:00
parent b184ea76f4
commit a71580462f
11 changed files with 2303 additions and 225 deletions
+225
View File
@@ -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()