10 Commits

Author SHA1 Message Date
Jan Nedbal
b5d046c91a docs: link make-release.sh from update-manager doc 2026-04-14 13:52:27 +02:00
Jan Nedbal
c2804fe1a6 scripts: add make-release.sh 2026-04-14 13:52:23 +02:00
1dba3e3c91 Merge pull request 'docs: design the update manager + manifest generator' (#3) from jann/m2dev-client:claude/update-manager into main
Reviewed-on: metin-server/m2dev-client#3
2026-04-14 11:54:39 +02:00
Jan Nedbal
fdb9e98075 docs: add caddy updates vhost bring-up runbook
Step-by-step operator runbook for turning on updates.jakubkadlec.dev:
create the webroot, append the site block, validate the Caddyfile
before reload, watch for Let's Encrypt cert issuance, verify from an
external client, plus explicit rollback for every mutating step and a
catastrophic-recovery section in case Caddy drops all sites. Targeted
at Jakub (VPS operator) so Claude does not touch the running service.
2026-04-14 11:35:39 +02:00
Jan Nedbal
02061f6e07 docs: add caddy snippet for updates.jakubkadlec.dev
Caddy site block for the update CDN. Serves the signed manifest with
short TTL, content-addressed blobs as immutable, historical manifests
as immutable, and the Velopack launcher feed alongside. Caching rules
are calibrated so a new release is visible within a minute without
hammering the origin on thundering herds.
2026-04-14 11:22:32 +02:00
Jan Nedbal
b7e4514677 scripts: add manifest signer
Companion to make-manifest.py that signs the output with an Ed25519
private key. Signs the literal manifest bytes — never re-serializes —
because the launcher verifies against exactly what the server delivers.
Warns if the private key file is readable beyond owner. Verified
end-to-end against the launcher's real public key and a tamper test.
2026-04-14 11:22:32 +02:00
Jan Nedbal
759b31d390 docs: record prior art survey and switch to velopack for self-update
After a survey of existing Metin2 launchers, general-purpose
auto-updaters, and adjacent open-source game launchers, update the
design to:

- drop the hand-rolled rename-before-replace self-update path
- use Velopack for launcher self-update (MIT, modern successor to
  Squirrel.Windows, handles atomic replace, delta, Authenticode, AV
  friendliness out of the box)
- keep the custom asset patcher for the 4 GB game payload, which
  Velopack is not designed for
- reference runelite/launcher as the architectural template
- name Sparkle 2 and wowemulation-dev/wow-patcher as Ed25519 prior art

No Metin2 community launcher is worth forking; the ceiling of
published prior art is 'file list + sha256 + HTTP GET' and this design
is already above it. Greenfield confirmed.
2026-04-14 10:44:44 +02:00
Jan Nedbal
605f8765d5 scripts: add make-manifest.py manifest generator
Walks a client directory, sha256-hashes every file, emits a canonical
JSON manifest matching docs/update-manifest.md. Excludes runtime
artifacts (.log, .dxvk-cache, .pdb, .old) and the launcher is broken
out as a top-level field rather than an entry in files[]. Does not
sign; pair with a separate signer step.
2026-04-14 10:36:24 +02:00
Jan Nedbal
6f70ef201a docs: add update manifest schema
Formal JSON schema for the release manifest, with canonical ordering
rules so signatures stay stable. Includes a small synthetic example
under docs/examples/.
2026-04-14 10:36:24 +02:00
Jan Nedbal
05af7e55b3 docs: add update manager design
Design for a content-addressed, signed manifest-based update system for
the Metin2 client. Launcher is a single entry point; server is static
files behind Caddy at updates.jakubkadlec.dev; manifests are signed with
Ed25519. Publishing starts manual in v1 and moves to Gitea Actions in v2.
2026-04-14 10:36:23 +02:00
8 changed files with 1208 additions and 0 deletions

71
docs/caddy-updates.conf Normal file
View File

@@ -0,0 +1,71 @@
# Caddy snippet for updates.jakubkadlec.dev.
#
# Drop this into the main Caddyfile on the VPS (or include it from there).
# Caddy already handles TLS via Let's Encrypt for the parent zone; this block
# only adds a subdomain that serves the update manifest, detached signature,
# and content-addressed blob store.
#
# Directory layout on disk (owned by the release operator, not Caddy):
#
# /var/www/updates.jakubkadlec.dev/
# ├── manifest.json
# ├── manifest.json.sig
# ├── manifests/
# │ └── 2026.04.14-1.json (archived historical manifests)
# ├── files/
# │ └── <hash[0:2]>/<hash> content-addressed blobs
# └── launcher/ Velopack feed (populated by Velopack's own publish tool)
#
# Create with:
# sudo mkdir -p /var/www/updates.jakubkadlec.dev/{files,manifests,launcher}
# sudo chown -R mt2.jakubkadlec.dev:mt2.jakubkadlec.dev /var/www/updates.jakubkadlec.dev
#
# Then add this to Caddy and `sudo systemctl reload caddy`.
updates.jakubkadlec.dev {
root * /var/www/updates.jakubkadlec.dev
# Allow clients to resume interrupted downloads via HTTP Range.
# Caddy's file_server sets Accept-Ranges: bytes by default, so there's
# nothing extra to configure for this — listed explicitly as a reminder.
file_server {
precompressed gzip br
}
# Content-addressed blobs are immutable (the hash IS the file name), so we
# can tell clients to cache them forever. A manifest update never rewrites
# an existing blob.
@blobs path /files/*
header @blobs Cache-Control "public, max-age=31536000, immutable"
# The manifest and its signature must never be cached beyond a minute —
# clients need to see new releases quickly, and stale caches would delay
# rollouts. Short TTL, not zero, to absorb thundering herds on release.
@manifest path /manifest.json /manifest.json.sig
header @manifest Cache-Control "public, max-age=60, must-revalidate"
# Historical manifests are as immutable as blobs — named by version.
@archive path /manifests/*
header @archive Cache-Control "public, max-age=31536000, immutable"
# The Velopack feed (launcher self-update) is a separate tree managed by
# Velopack's publishing tool. Same cache rules as the main manifest: short
# TTL on the feed metadata, blobs are immutable.
@velopack-feed path /launcher/RELEASES*
header @velopack-feed Cache-Control "public, max-age=60, must-revalidate"
@velopack-blobs path /launcher/*.nupkg
header @velopack-blobs Cache-Control "public, max-age=31536000, immutable"
# CORS is not needed — the launcher is a native app, not a browser — so
# no Access-Control-Allow-Origin header. If a web changelog page ever needs
# to fetch the manifest from the browser, revisit this.
# Deny directory listings; the launcher knows exactly which paths it wants.
file_server browse off 2>/dev/null || file_server
log {
output file /var/log/caddy/updates.jakubkadlec.dev.access.log
format json
}
}

View File

@@ -0,0 +1,52 @@
{
"version": "2026.04.14-1",
"created_at": "2026-04-14T14:00:00Z",
"previous": "2026.04.13-3",
"notes": "synthetic example showing the manifest structure. a real manifest covers tens of thousands of files.",
"launcher": {
"path": "Metin2Launcher.exe",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 15728640,
"platform": "windows"
},
"files": [
{
"path": "Metin2.exe",
"sha256": "2653e87ecd8ba305b70a96098788478e8b60c125ec83bcd1307486eafb5c5289",
"size": 27982848,
"platform": "windows"
},
{
"path": "assets/root/serverinfo.py",
"sha256": "b564fef7e45326ff8ad1b188c49cd61d46af1b07de657689814526725919d5cb",
"size": 9231
},
{
"path": "config.exe",
"sha256": "a79c4e0daef43ce3a2666a92774fc6a369718177e456cb209fabe02ca640bcc2",
"size": 258048,
"platform": "windows"
},
{
"path": "pack/item.pck",
"sha256": "7aa9d46724a921fecf5af14c10372d0d03922e92a4cace4b5c15c451416f36b7",
"size": 128547328
},
{
"path": "pack/locale.pck",
"sha256": "3b9dfe45317a14fcb70a138c1b9d57d984fe130e833c4341deaaff93d615ac67",
"size": 4587520
},
{
"path": "pack/metin2_patch_easter1.pck",
"sha256": "2dab6a06d317014782cbe417d68dd3365d1d8c7cc35daa465611ce2108547706",
"size": 12345600,
"required": false
},
{
"path": "pack/uiscript.eix",
"sha256": "79bd367b31882e52dfa902f62112f5d43831673ed548ebbd530e2f86dfa77d14",
"size": 892416
}
]
}

View File

@@ -0,0 +1,210 @@
# Runbook — bring up updates.jakubkadlec.dev
Operator runbook for turning the update channel on. Does the following on the production VPS `mt2.jakubkadlec.dev`:
1. Creates the directory layout the update manager expects
2. Adds the Caddy site block for `updates.jakubkadlec.dev`
3. Validates the Caddy config before reloading
4. Reloads Caddy so the new vhost serves HTTPS with a fresh Let's Encrypt cert
5. Verifies the vhost is up from an external client
**Pre-requisites:**
- Root or `sudo` access on the VPS.
- DNS: `updates.jakubkadlec.dev` already resolves to the VPS IP (verified 2026-04-14: `194.163.138.177`). If it stops resolving, fix DNS first.
- Port 80 open from the public internet (Caddy uses it for the ACME HTTP-01 challenge). Already open because Caddy is serving other sites on 443.
**Estimated time:** 5 minutes, most of it waiting for LE cert issuance.
**Rollback:** every mutating step has an explicit rollback below. The safest rollback is to restore the backup Caddyfile and reload — Caddy will drop the new vhost and keep everything else running exactly as before.
## Step 1 — SSH to the VPS
```bash
ssh -i ~/.ssh/metin mt2.jakubkadlec.dev@mt2.jakubkadlec.dev
```
All following commands run as the `mt2.jakubkadlec.dev` user unless marked `sudo`.
## Step 2 — Create the directory layout
```bash
sudo mkdir -p /var/www/updates.jakubkadlec.dev/{files,manifests,launcher}
sudo chown -R mt2.jakubkadlec.dev:mt2.jakubkadlec.dev /var/www/updates.jakubkadlec.dev
sudo chmod -R 755 /var/www/updates.jakubkadlec.dev
# Drop a placeholder manifest so the vhost has something to serve during validation.
# This file will be overwritten by the first real release.
cat > /tmp/placeholder-manifest.json <<'EOF'
{
"version": "0.0.0-placeholder",
"created_at": "2026-04-14T00:00:00Z",
"notes": "placeholder — replace with the first real signed release",
"launcher": {
"path": "Metin2Launcher.exe",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"size": 0,
"platform": "windows"
},
"files": []
}
EOF
sudo mv /tmp/placeholder-manifest.json /var/www/updates.jakubkadlec.dev/manifest.json
sudo chown mt2.jakubkadlec.dev:mt2.jakubkadlec.dev /var/www/updates.jakubkadlec.dev/manifest.json
```
**Rollback for step 2:**
```bash
sudo rm -rf /var/www/updates.jakubkadlec.dev
```
Note that without a signed placeholder the launcher will refuse to launch, because the zero-hash signature won't verify. That's **by design** — the launcher treats signature failure as "server is lying" and blocks the game. The placeholder is only there to prove HTTPS works; the first real release will overwrite it with a properly signed manifest.
## Step 3 — Back up the current Caddyfile
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.bak.$(date +%Y%m%d-%H%M%S)
ls -la /etc/caddy/Caddyfile.bak.*
```
**Rollback for step 3:** there's nothing to roll back — backup files are harmless.
## Step 4 — Append the new vhost block to Caddyfile
The block lives in the repo at `docs/caddy-updates.conf`. Copy its contents and append them to `/etc/caddy/Caddyfile`:
```bash
# From your local machine, or by pulling the file onto the VPS:
sudo tee -a /etc/caddy/Caddyfile < /path/to/docs/caddy-updates.conf
```
Or, if you'd rather pull it from Gitea directly on the VPS:
```bash
curl -sS -H "Authorization: token $(cat ~/.config/metin/gitea-token)" \
"https://gitea.jakubkadlec.dev/api/v1/repos/metin-server/m2dev-client/raw/main/docs/caddy-updates.conf" \
| sudo tee -a /etc/caddy/Caddyfile
```
(Replace `main` with the PR branch if you want to test before merge.)
Open the Caddyfile and confirm the block is at the end with no mangled whitespace:
```bash
sudo tail -80 /etc/caddy/Caddyfile
```
**Rollback for step 4:**
```bash
sudo cp /etc/caddy/Caddyfile.bak.<timestamp> /etc/caddy/Caddyfile
```
## Step 5 — Validate the new Caddyfile
**Do not skip this.** A broken Caddyfile + reload would take every Caddy-served site down together.
```bash
sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
```
Expected output ends with `Valid configuration`. Any line starting with `error` means stop and roll back step 4:
```bash
sudo cp /etc/caddy/Caddyfile.bak.<timestamp> /etc/caddy/Caddyfile
```
## Step 6 — Reload Caddy
```bash
sudo systemctl reload caddy
sudo systemctl status caddy --no-pager
```
`reload` is not `restart` — running connections are preserved and Caddy loads the new config in place. If something goes wrong Caddy keeps the old config active.
**If reload fails** (systemctl returns non-zero), run the validate step again and read `journalctl -u caddy -n 50` to see the exact error, then roll back step 4 and reload again.
**Rollback for step 6:** restoring the backup Caddyfile and reloading takes you back to the previous state:
```bash
sudo cp /etc/caddy/Caddyfile.bak.<timestamp> /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
## Step 7 — Wait for Let's Encrypt cert issuance
Caddy issues a cert for the new subdomain automatically via the HTTP-01 challenge. Usually takes under 30 seconds.
Watch Caddy's logs for the issuance event:
```bash
sudo journalctl -u caddy -f
```
Look for lines mentioning `updates.jakubkadlec.dev`, specifically `certificate obtained successfully`. Ctrl-C out once you see it.
**If it doesn't issue within 2 minutes**, one of:
- Port 80 is blocked — check `sudo ss -tlnp | grep ':80'` shows Caddy listening.
- DNS hasn't propagated — check `dig updates.jakubkadlec.dev +short` matches the VPS IP.
- Let's Encrypt rate limit — check `journalctl -u caddy` for `too many certificates`. Wait an hour and retry; don't hammer.
## Step 8 — Verify from an external client
From any machine that isn't the VPS:
```bash
# Cert subject should contain updates.jakubkadlec.dev in SAN
echo | openssl s_client -connect updates.jakubkadlec.dev:443 \
-servername updates.jakubkadlec.dev 2>/dev/null \
| openssl x509 -noout -text | grep -A1 "Subject Alternative Name"
# Manifest should return 200 with a short Cache-Control
curl -I https://updates.jakubkadlec.dev/manifest.json
# Placeholder manifest body, pretty-printed
curl -sS https://updates.jakubkadlec.dev/manifest.json | jq .
```
Expected: SAN contains `updates.jakubkadlec.dev`, HTTP 200, `Cache-Control: public, max-age=60, must-revalidate`, body is the placeholder JSON from step 2.
If all three pass, the update channel is live and the launcher will accept fetches (though it will still refuse to apply the placeholder manifest because its signature is not valid — see step 2 note).
## Step 9 — Clean up old backups (optional, later)
Once the vhost has been live for a week without incident:
```bash
# List backups older than 7 days
sudo find /etc/caddy/Caddyfile.bak.* -mtime +7
# Remove them
sudo find /etc/caddy/Caddyfile.bak.* -mtime +7 -delete
```
## Post-runbook — What's next
- The first real release uses `scripts/make-manifest.py` + `scripts/sign-manifest.py` (both in this repo) to produce `manifest.json` + `manifest.json.sig`, then rsync them onto `/var/www/updates.jakubkadlec.dev/` along with the content-addressed blobs under `files/<hash[0:2]>/<hash>`.
- The launcher binary's own self-update path (Velopack) needs a separate publish step (`vpk pack`) that populates `/var/www/updates.jakubkadlec.dev/launcher/`. That's its own runbook and not part of this one.
## If something goes catastrophically wrong
Caddy dies across the board → Gitea (`gitea.jakubkadlec.dev`) and any other served site are offline. System SSH on port 22 is independent of Caddy, so you can always reach the box.
Recovery:
```bash
ssh -i ~/.ssh/metin mt2.jakubkadlec.dev@mt2.jakubkadlec.dev
# Restore the last known-good Caddyfile
sudo ls -lt /etc/caddy/Caddyfile.bak.* | head -1
sudo cp /etc/caddy/Caddyfile.bak.<most-recent> /etc/caddy/Caddyfile
sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
sudo systemctl reload caddy || sudo systemctl restart caddy
sudo systemctl status caddy --no-pager
```
Gitea SSH remains on port 2222 the whole time; it's a separate process and does not share fate with Caddy.

260
docs/update-manager.md Normal file
View File

@@ -0,0 +1,260 @@
# Update manager — design
This is the design for how the Metin2 client gets updated after the player's first install. Scope covers the launcher, the server-side manifest, the publishing flow, and the security model. Implementation plan is at the bottom.
## Goals and constraints
- The **base install is large** (~4.3 GB of packs + binaries). Shipping it through the update channel is a non-goal; base install is a separate bundled download.
- Releases can happen **as often as daily**. A small script change in a Python pack should not force players to re-download the full client.
- The update must be **atomic from the player's point of view**: they end up either on the old version or on the new one, never on a half-patched client.
- **Integrity matters**: a malicious or buggy mirror must not be able to ship tampered files.
- **Offline fallback**: if the update server is unreachable, the launcher lets the player into the game with whatever they have.
- The launcher is the **single entry point** the player runs. It owns update detection, download, integrity checks, self-update, and game launch.
- Publishing is **manual for v1** (`make-release.sh` + rsync), automated via Gitea Actions once the flow is proven.
## High-level architecture
```
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ Player machine │ HTTPS │ VPS (Caddy) │
│ ├────────► │
│ Launcher.exe │ │ updates.jakubkadlec.dev/ │
│ ├─ fetch manifest │ │ manifest.json │
│ ├─ verify Ed25519 signature │ │ manifest.json.sig │
│ ├─ diff with local files │ │ files/<hash>/<hash> │
│ ├─ download missing files │ │ │
│ ├─ verify each sha256 │ └──────────────────────────────┘
│ ├─ atomic move into place │
│ ├─ self-update if needed │
│ └─ launch Metin2.exe │
│ │
│ client/ │
│ Metin2.exe │
│ Metin2Launcher.exe │
│ pack/*.pck assets/* ... │
└──────────────────────────────┘
```
### Server-side layout
Served statically by Caddy from `/var/www/updates.jakubkadlec.dev/`:
```
updates.jakubkadlec.dev/
├── manifest.json ← current release manifest
├── manifest.json.sig ← Ed25519 signature over manifest.json
├── manifests/
│ ├── 2026.04.14-1.json ← archived historical manifests
│ ├── 2026.04.14-1.json.sig
│ └── ...
└── files/
└── ab/
└── abc123...def ← content-addressed blob, named after sha256
```
**Content-addressed storage** means a file is named after its sha256. Two consequences:
- **Automatic deduplication** across releases: if `item.pck` is unchanged, the new manifest points at the same blob. Nothing is uploaded or stored twice.
- **Atomic publishing**: upload new blobs first, then replace `manifest.json` last. A partially-uploaded release never causes an inconsistent client state, because the client never sees the new manifest until it's complete.
### Manifest
See [update-manifest.md](./update-manifest.md) for the formal schema. Summary:
```json
{
"version": "2026.04.14-1",
"created_at": "2026-04-14T12:00:00Z",
"previous": "2026.04.13-3",
"launcher": {
"path": "Metin2Launcher.exe",
"sha256": "..."
},
"files": [
{
"path": "Metin2.exe",
"sha256": "...",
"size": 27982848,
"platform": "windows",
"required": true
},
{
"path": "pack/item.pck",
"sha256": "...",
"size": 128000000
}
]
}
```
- `version` is date-based (`YYYY.MM.DD-N` where `N` is the daily counter). Human-readable, sortable, forgiving of multiple releases per day.
- `previous` lets the launcher show a changelog chain and enables smarter diff strategies later.
- `launcher` is called out separately because it needs special handling (self-update).
- `platform` is `windows` by default; future native Linux build can use `linux` and the launcher filters by its own platform.
- `required: true` files block game launch if missing; optional files (language packs, optional assets) are opportunistic.
### Security model
- A single **Ed25519 keypair** signs each manifest. Private key lives on the release machine only (never in any repo). Public key is compiled into the launcher binary.
- Launcher **refuses to apply** a manifest whose signature doesn't verify against the baked-in public key. No fallback, no "accept this once" dialog.
- **sha256 per file** catches storage or transport corruption. A file whose downloaded bytes don't match the manifest hash is discarded and retried.
- **Key rotation** flow: ship a new launcher that knows both the old and new public keys, transition period of a week, then ship one that only knows the new key. Because the launcher itself is delivered through the same update channel, this is clean.
- **Transport** is HTTPS via Caddy (Let's Encrypt already). Ed25519 signing is defense-in-depth against compromised CDN / MITM, not the primary trust mechanism.
### Client behavior
Launcher does, in order:
1. **Fetch** `manifest.json` and `manifest.json.sig` (HTTP GET, timeout 10 s).
2. **Verify** signature. On failure: abort update, log, go to step 8.
3. **Parse** manifest, filter `files[]` by matching `platform`.
4. For each file:
- **Hash** the local copy (if present). If sha256 matches, skip.
- Otherwise **download** the blob from `files/<hash[0:2]>/<hash>` into `staging/<path>` using HTTP Range requests (to resume partial downloads from a prior interrupted run).
- **Verify** downloaded bytes against manifest hash. Mismatch = delete staging file, mark file as failed.
5. If any **required** file failed after N retries: abort update, log, go to step 8 (offline fallback). Optional files that failed are silently skipped.
6. **Self-update check**: if `launcher.sha256` differs from our own running binary, write the new launcher to `Metin2Launcher.new.exe`, spawn a small **trampoline** that waits for our PID to exit, replaces `Metin2Launcher.exe` with `Metin2Launcher.new.exe`, then exits. We then exit ourselves; the trampoline is a tiny native exe that lives alongside the launcher. See [Self-update details](#self-update-details).
7. **Atomic apply**: for each non-launcher file, `MoveFileEx(staging, final, MOVEFILE_REPLACE_EXISTING)`. Keep a small manifest of moved paths so we can roll back on failure.
8. **Launch**: `CreateProcess("Metin2.exe", ...)` with the current working directory at the client root. Exit the launcher once the game process has established itself.
### Self-update details
We do not implement self-update from scratch. The launcher embeds **[Velopack](https://github.com/velopack/velopack)** (MIT, Rust+.NET, actively maintained), which handles:
- Atomic replacement of the running launcher binary (stable install path, unlike legacy Squirrel)
- Delta patches between launcher versions
- Authenticode signature verification
- Antivirus / firewall friendliness (no UAC prompt, no path churn)
- ~2s update + relaunch
Velopack is used **only for the launcher binary itself**, which is small (~15 MB). The 4 GB game assets are handled by our own patcher code — Velopack is explicitly not designed for payloads that large.
Practical shape: at launcher startup we call `VelopackApp.Build().Run()`, then later `UpdateManager.CheckForUpdatesAsync()` against a separate Velopack release feed that lives alongside our asset manifest (e.g. `updates.jakubkadlec.dev/launcher/`). If a new launcher version is available, Velopack downloads it in the background and applies it on next restart. The asset update (sha256 manifest walk) runs unconditionally regardless of whether the launcher itself is updating.
The fallback path if Velopack ever fails — rename-before-replace plus a small `launcher-update.exe` trampoline — is documented but not implemented in the MVP. Velopack has been stable enough in production for us to start without it.
### Offline fallback
- If step 1 times out or returns non-2xx, launcher logs the failure and goes straight to step 8. The player gets into the game with whatever local version they already have.
- If signature verification (step 2) fails, launcher does **not** fall back silently — it shows an error and refuses to launch, because "the server is lying to me" is more dangerous than "the server is down". This is the one case where we stop the player.
- If the game server is down but the update server is up, that's the server runtime team's problem; the launcher is still successful.
### Directory layout on the player's machine
```
client/
├── Metin2Launcher.exe ← self-updating launcher, the player's entry point
├── Metin2.exe ← managed by the launcher
├── Metin2Launcher.exe.old ← previous launcher, kept for rollback (deleted after 1 successful run)
├── Metin2.exe.old ← same for Metin2.exe
├── pack/
├── assets/
├── config/
├── log/
└── .updates/
├── current-manifest.json ← the manifest we're currently on
├── staging/ ← download staging area, cleared after successful apply
└── launcher.log ← launcher's own log
```
Files under `.updates/` are created by the launcher. The user shouldn't touch them and we ship a `.gitignore` so they don't end up in any accidental archive.
## Publishing flow (v1, manual)
1. On a trusted machine (not random laptop), with the private signing key present:
```bash
./scripts/make-release.sh --source /path/to/fresh/client --version 2026.04.14-1 \
--previous 2026.04.13-3 --notes notes.md --dry-run
```
[`scripts/make-release.sh`](../scripts/make-release.sh) is the single entry
point for the v1 manual flow. It drives `make-manifest.py` + `sign-manifest.py`,
builds the content-addressed blob tree under `files/<hash[0:2]>/<hash>` with
hardlink-based deduplication, archives the signed manifest into
`manifests/<version>.json`, and — unless `--dry-run` is passed — rsyncs the
blob tree first and the `manifest.json` + `manifest.json.sig` pair last so the
release becomes visible atomically. Flags: `--key` (default
`~/.config/metin/launcher-signing-key`, must be mode 600), `--out` (default
`/tmp/release-<version>`), `--force` to overwrite a non-empty out dir, `--yes`
to skip the interactive rsync confirmation, `--rsync-target <user@host:/path>`
to override the upload destination.
2. The script walks the client directory, computes sha256 for each file, writes a `manifest.json`, signs it, and produces a release directory containing the manifest, its signature, and the deduplicated blob tree.
3. Human review: diff the new manifest against the previous one, sanity-check size and file count.
4. Re-run without `--dry-run` (same args) to rsync to the VPS. The script prints the target and waits for confirmation unless `--yes` is passed.
5. Verify from a second machine: `curl` the manifest, check signature, check a random blob.
6. Tag the release in git.
Manual because v1 should let us feel the flow before we automate. After ~2 weeks of successful manual releases, wire it into Gitea Actions.
## Publishing flow (v2, Gitea Actions)
Not implemented in MVP. Sketch:
- `m2dev-client-src` build artifact (Metin2.exe) and `m2dev-client` runtime content are combined by a release workflow.
- The workflow runs `make-release.sh` using a signing key stored as a Gitea secret.
- rsyncs to VPS via a deploy SSH key.
- Opens a PR that updates `CHANGELOG.md` with the new version.
Trade-off: automation speed vs. the attack surface of a CI-held signing key. When we get there, we'll probably **sign offline** and let CI only publish pre-signed bundles.
## Failure modes and what we do about them
| Failure | Client behavior | Operator behavior |
|---|---|---|
| Update server 5xx | Launch game with current version | Investigate VPS / Caddy |
| Update server returns invalid signature | Refuse to launch, show error | Rotate signing key, investigate source |
| Partial download (network drop) | Resume on next run via Range | None, user retries |
| Individual file hash mismatch after retries | Skip file if optional, abort if required | Investigate blob corruption |
| Launcher self-update fails mid-replace | Rollback from `.old` copy, launch old launcher | Investigate, ship fixed launcher |
| Player filesystem is full | Error out with actionable message ("free X MB, retry") | None |
| Player has antivirus quarantining files | Error message naming the file that disappeared | Document, whitelist in launcher installer |
| Someone ships a manifest with missing blobs | Launcher reports which files it can't fetch | Broken release, re-run publish |
## Prior art survey
Before writing code, a scan of the ecosystem for things to fork or copy. Bottom line: nothing in the Metin2 community is worth forking, but three external projects inform this design.
**Metin2-specific launchers**: the community reference is [Karbust/Metin2-Patcher-Electron](https://github.com/Karbust/Metin2-Patcher-Electron) (TypeScript/Electron, MIT, last push 2021). SHA256 per-file manifest, parallel HTTP downloads, two-zip deploy model. No signing, no delta, no self-update, dead deps. Worth skimming to understand what Metin2 server admins UX-expect. Everything else in the space (`VoidEngineCC/Metin2-Simple-C-Patcher`, `CeerdaN/metin2-patcher-electron`, `Cankira-BK/metin2-pvp-launcher`, ...) is either unlicensed, a toy, or abandoned. The ceiling of published prior art is "file list + sha256 + HTTP GET." We are already above it on paper.
**d1str4ught upstream**: no launcher, no patcher. The upstream distribution model is "clone the repo." Greenfield for us.
**General-purpose auto-updaters**:
- **[Velopack](https://github.com/velopack/velopack)** — the modern successor to Squirrel.Windows, by the same primary author. MIT, Rust+.NET, released regularly in 2025. Handles atomic binary replacement, delta patches, Authenticode, stable install paths. Used for the launcher self-update layer. Not used for game assets — not designed for 4 GB payloads.
- Squirrel.Windows (legacy, unmaintained, known path-churn bugs), WiX Burn (wrong shape — chain installer, not update loop), NSIS (you reimplement everything), Rust `self_update` crate (single-file, no multi-artifact) — all rejected.
**Architectural reference**: **[runelite/launcher](https://github.com/runelite/launcher)** (Java, BSD-2). A tiny native launcher for a non-Steam game, with exactly the shape we want: bootstrap JSON → signed → list of artifacts with hashes → download missing → verify → launch. X.509 instead of Ed25519, same threat model. Before writing launcher code we read this end-to-end as the reference implementation; we do not copy code (wrong language), we copy structure.
**Ed25519 prior art in private-server game launchers**: [wowemulation-dev/wow-patcher](https://github.com/wowemulation-dev/wow-patcher) (Rust) replaces the WoW client's Ed25519 public key to redirect auth. Direct precedent for using Ed25519 in this role. Sparkle 2 on macOS has shipped Ed25519 appcast since 2021; same primitive, coarser per-release granularity.
**Manifest format**: the shape we have is loosely TUF-lite — one signed top-level JSON pointing at content-addressed blobs, without TUF's role separation. Full [TUF](https://theupdateframework.io/) is overkill for a 4-dev private-server project, but worth naming as the professional vocabulary. [OSTree](https://ostreedev.github.io/ostree/) implements exactly the content-addressed part at the filesystem level — a good read, too Linux-specific to reuse.
**Net take**: this design converges on the intersection of OSTree (content addressing), Sparkle (Ed25519 signing) and RuneLite launcher (bootstrap-signed-JSON → artifact list → verify → launch), with Velopack handling the self-update plumbing. Nothing novel, which is the point.
## Implementation plan
Effort is real-days of Claude + review time from the team.
| # | Task | Effort | Output |
|---|---|---|---|
| 1 | This design doc, reviewed | 0.5 d | `docs/update-manager.md` |
| 2 | Manifest schema spec | 0.5 d | `docs/update-manifest.md` |
| 3 | `scripts/make-manifest.py` — walk dir, produce unsigned manifest | 1 d | Python script + docs |
| 4 | Sign/verify script (Ed25519) | 0.5 d | Python + keygen docs |
| 5 | Caddy config for `updates.jakubkadlec.dev` | 0.5 d | Caddyfile fragment + DNS note |
| 6 | Launcher (C# .NET 8 self-contained, single-file) — skeleton + HTTP fetch + manifest parse + Ed25519 verify | 2 d | `launcher/` project |
| 7 | Launcher — file diff + download + hash verify + atomic apply | 2 d | |
| 8 | Launcher — Velopack integration for self-update | 0.5 d | |
| 9 | End-to-end test (publish → client updates → launch) | 1 d | |
| 10 | `scripts/make-release.sh` wiring it all together | 1 d | |
| 11 | Docs: publisher runbook, player troubleshooting, threat model | 1 d | |
**MVP is items 110**, roughly **10 working days** of implementation. Review + integration + real-world hardening on top.
## Open questions left for the team
- **Launcher UI**: bare minimum (single window with a progress bar and "Play" button) vs. something nicer (changelog panel, news feed, image banner)? MVP is bare minimum; richer UI is a v2 concern.
- **Localization**: manifest fields are English, but the launcher UI needs Czech (at least). Load strings from the client's existing `locale.pck`, or ship a separate small locale for the launcher? Lean toward the latter because launcher runs before the game and shouldn't depend on game assets.
- **News feed**: optional. If yes, add a `news_url` field to the manifest and let the launcher fetch a small JSON blob. Nice-to-have.
- **Analytics**: do we want to know how many players are on which version? Simple: launcher sends an HTTP POST with `{version, platform}` after successful update. Requires GDPR thought. Off by default, opt-in.
None of these block the MVP — they can be decided once the skeleton works.

124
docs/update-manifest.md Normal file
View File

@@ -0,0 +1,124 @@
# Update manifest — format specification
The update manifest is a JSON document describing a single release of the Metin2 client. It lives at `https://updates.jakubkadlec.dev/manifest.json` alongside its Ed25519 signature at `manifest.json.sig`.
See [update-manager.md](./update-manager.md) for the overall architecture this fits into.
## Top-level schema
```json
{
"version": "2026.04.14-1",
"created_at": "2026-04-14T12:00:00Z",
"previous": "2026.04.13-3",
"launcher": {
"path": "Metin2Launcher.exe",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 15728640
},
"files": [
{
"path": "Metin2.exe",
"sha256": "a1b2c3...",
"size": 27982848,
"platform": "windows",
"required": true
}
]
}
```
### Required top-level fields
| Field | Type | Description |
|---|---|---|
| `version` | string | Release version. Format: `YYYY.MM.DD-N` where `N` is the 1-indexed daily counter. Sortable, human-readable, allows multiple releases per day. |
| `created_at` | string | ISO 8601 timestamp in UTC with `Z` suffix. When the release was produced. |
| `launcher` | object | The launcher binary. See below. Special because of self-update handling. |
| `files` | array | The non-launcher files in the release. May be empty (launcher-only update). |
### Optional top-level fields
| Field | Type | Description |
|---|---|---|
| `previous` | string | `version` of the manifest this release replaces. Omit for the first release ever. Used for changelog display and future delta-patch strategies. |
| `notes` | string | Free-form release notes (Markdown). Displayed by the launcher in the changelog panel. |
| `min_launcher_version` | string | Refuse to apply this manifest if launcher's own version is older than this. Used when a manifest change requires a newer launcher. |
## File entry schema
```json
{
"path": "pack/item.pck",
"sha256": "def456abc123...",
"size": 128000000,
"platform": "all",
"required": true
}
```
### Required file fields
| Field | Type | Description |
|---|---|---|
| `path` | string | Path relative to the client root, using forward slashes. No `..` segments. |
| `sha256` | string | Lowercase hex sha256 of the file contents. |
| `size` | integer | File size in bytes. Used for the progress bar and to detect truncated downloads before hashing. |
### Optional file fields
| Field | Type | Default | Description |
|---|---|---|---|
| `platform` | string | `"all"` | One of `"windows"`, `"linux"`, `"all"`. Launcher filters by its own platform. |
| `required` | boolean | `true` | If `false`, a failed download for this file does not block the game launch. |
| `executable` | boolean | `false` | On Unix-like systems, set the executable bit after applying. Ignored on Windows. |
## Launcher entry
The `launcher` top-level object has the same fields as a file entry, but is called out separately because the launcher is a privileged file:
- The launcher replaces itself via **rename-before-replace**, not normal atomic move.
- The launcher is **always required**; if it fails to update, the launcher does not launch the game, to avoid a broken loop where the player is running a buggy launcher that can't fix itself.
- The launcher is **never** listed in the `files` array.
## Signing
`manifest.json.sig` is the raw Ed25519 signature over the literal bytes of `manifest.json`, in detached form. The public key is compiled into the launcher binary. Signing and verification use the standard Ed25519 algorithm (RFC 8032), no prehashing.
Example verification in Python:
```python
import json
from nacl.signing import VerifyKey
with open("manifest.json", "rb") as f:
manifest_bytes = f.read()
with open("manifest.json.sig", "rb") as f:
sig = f.read()
VerifyKey(bytes.fromhex(PUBLIC_KEY_HEX)).verify(manifest_bytes, sig)
```
In C# with `System.Security.Cryptography` (.NET 8+) or BouncyCastle.
## Canonical JSON
To keep signatures stable across trivial reformatting:
- Top-level keys appear in the order `version, created_at, previous, notes, min_launcher_version, launcher, files`.
- Within the `files` array, entries are **sorted by `path`** lexicographically.
- Within a file object, keys appear in the order `path, sha256, size, platform, required, executable`.
- JSON is pretty-printed with **2-space indentation**, **LF line endings**, final newline.
- Strings use the shortest valid JSON escapes (no `\u00XX` for printable ASCII).
`scripts/make-manifest.py` produces output in exactly this form. Do not re-serialize a manifest with a different JSON library before signing; the bytes must match.
## Versioning rules
- `version` strings are compared **as date + counter** (not as semver), via `(date, counter)` tuples.
- A launcher always replaces its own installed version with the one from the latest manifest, regardless of whether the manifest's `version` is newer than the launcher's own version. There is no "downgrade protection" for the launcher itself because the server is the source of truth.
- For the **client files** (not launcher), the launcher refuses to apply a manifest whose `version` is older than the locally-recorded `current-manifest.json` version. This prevents rollback attacks where a compromised CDN replays an old manifest to force players back onto an outdated client that had a known vulnerability.
## Example
See [examples/manifest-example.json](./examples/manifest-example.json) for a real manifest produced by `scripts/make-manifest.py` over the current dev client.

220
scripts/make-manifest.py Executable file
View File

@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""Walk a client directory and emit a release manifest.
Produces canonical-form JSON matching docs/update-manifest.md. Does not sign —
pair with a separate signer that reads the manifest bytes verbatim and emits
a detached Ed25519 signature.
Usage:
make-manifest.py --source /path/to/client --version 2026.04.14-1 \\
[--previous 2026.04.13-3] [--notes "bugfixes"] \\
[--launcher Metin2Launcher.exe] [--out manifest.json]
The launcher path is treated specially: its entry appears at the top level
under "launcher", not inside "files". Defaults to "Metin2Launcher.exe"; if
that file does not exist in the source tree, the script refuses to run.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
# Files and directories that must never appear in a release manifest.
# These are runtime artifacts, build outputs, or developer state.
EXCLUDE_DIRS = {
".git",
".vs",
".updates",
"log",
"__pycache__",
}
EXCLUDE_FILES = {
".gitignore",
".gitattributes",
"desktop.ini",
"Thumbs.db",
".DS_Store",
}
EXCLUDE_SUFFIXES = {
".pdb", # debug symbols
".ilk", # MSVC incremental link
".old", # rollback copies written by the launcher
".log", # runtime logs, not release content
".dxvk-cache", # DXVK shader cache, per-machine
".swp",
".tmp",
}
@dataclass
class FileEntry:
path: str
sha256: str
size: int
platform: str = "all"
required: bool = True
executable: bool = False
def to_dict(self) -> dict:
out = {
"path": self.path,
"sha256": self.sha256,
"size": self.size,
}
if self.platform != "all":
out["platform"] = self.platform
if not self.required:
out["required"] = False
if self.executable:
out["executable"] = True
return out
def sha256_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def should_skip(rel_path: Path) -> bool:
for part in rel_path.parts:
if part in EXCLUDE_DIRS:
return True
if rel_path.name in EXCLUDE_FILES:
return True
if rel_path.suffix in EXCLUDE_SUFFIXES:
return True
return False
def classify_platform(rel_path: Path) -> str:
"""Very simple platform inference. Extend as native Linux build lands."""
suffix = rel_path.suffix.lower()
if suffix in {".exe", ".dll"}:
return "windows"
return "all"
def walk_client(source: Path, launcher_rel: Path) -> tuple[FileEntry, list[FileEntry]]:
launcher_entry: FileEntry | None = None
files: list[FileEntry] = []
for path in sorted(source.rglob("*")):
if not path.is_file():
continue
rel = path.relative_to(source)
if should_skip(rel):
continue
entry = FileEntry(
path=rel.as_posix(),
sha256=sha256_file(path),
size=path.stat().st_size,
platform=classify_platform(rel),
)
if rel == launcher_rel:
launcher_entry = entry
else:
files.append(entry)
if launcher_entry is None:
raise SystemExit(
f"error: launcher file {launcher_rel} not found under {source}. "
f"pass --launcher if the launcher is named differently, or create it."
)
files.sort(key=lambda e: e.path)
return launcher_entry, files
def build_manifest(
version: str,
created_at: str,
previous: str | None,
notes: str | None,
min_launcher_version: str | None,
launcher: FileEntry,
files: list[FileEntry],
) -> dict:
"""Assemble the manifest dict in canonical key order."""
manifest: dict = {"version": version, "created_at": created_at}
if previous is not None:
manifest["previous"] = previous
if notes is not None:
manifest["notes"] = notes
if min_launcher_version is not None:
manifest["min_launcher_version"] = min_launcher_version
manifest["launcher"] = launcher.to_dict()
manifest["files"] = [f.to_dict() for f in files]
return manifest
def canonical_json(manifest: dict) -> bytes:
text = json.dumps(manifest, indent=2, ensure_ascii=False)
return (text + "\n").encode("utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--source", required=True, type=Path,
help="Path to the client root directory")
parser.add_argument("--version", required=True,
help="Release version, e.g. 2026.04.14-1")
parser.add_argument("--previous",
help="Previous release version, if any")
parser.add_argument("--notes",
help="Release notes (Markdown allowed)")
parser.add_argument("--min-launcher-version",
help="Minimum launcher version that can apply this manifest")
parser.add_argument("--launcher", default="Metin2Launcher.exe",
help="Launcher filename relative to source (default: Metin2Launcher.exe)")
parser.add_argument("--out", type=Path, default=Path("manifest.json"),
help="Output file path (default: ./manifest.json)")
parser.add_argument("--created-at",
help="Override created_at timestamp (default: now, UTC). Useful for reproducible test runs.")
args = parser.parse_args()
source: Path = args.source.resolve()
if not source.is_dir():
print(f"error: {source} is not a directory", file=sys.stderr)
return 1
created_at = args.created_at or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
launcher_rel = Path(args.launcher)
launcher, files = walk_client(source, launcher_rel)
manifest = build_manifest(
version=args.version,
created_at=created_at,
previous=args.previous,
notes=args.notes,
min_launcher_version=args.min_launcher_version,
launcher=launcher,
files=files,
)
args.out.write_bytes(canonical_json(manifest))
total_size = launcher.size + sum(f.size for f in files)
print(
f"manifest: {args.out} "
f"files: {len(files) + 1} "
f"total: {total_size / (1024 * 1024):.1f} MiB",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

169
scripts/make-release.sh Executable file
View File

@@ -0,0 +1,169 @@
#!/usr/bin/env bash
# make-release.sh — assemble, sign, and (optionally) publish a client release.
#
# Drives scripts/make-manifest.py + scripts/sign-manifest.py, then builds the
# content-addressed blob tree the launcher pulls from. See docs/update-manager.md
# for the end-to-end design; this script is the v1 manual publishing flow.
#
# Usage:
# scripts/make-release.sh --source <client-dir> --version <version> \
# [--previous <version>] [--notes <file>] \
# [--key <path>] [--out <path>] [--force] \
# [--dry-run] [--yes] [--rsync-target <user@host:/path>]
set -euo pipefail
# -------- arg parsing --------
SOURCE=""
VERSION=""
PREVIOUS=""
NOTES_FILE=""
KEY="${HOME}/.config/metin/launcher-signing-key"
OUT=""
FORCE=0
DRY_RUN=0
YES=0
RSYNC_TARGET="mt2.jakubkadlec.dev@mt2.jakubkadlec.dev:/var/www/updates.jakubkadlec.dev/"
die() { echo "error: $*" >&2; exit 1; }
say() { echo "[make-release] $*"; }
while [[ $# -gt 0 ]]; do
case "$1" in
--source) SOURCE="$2"; shift 2 ;;
--version) VERSION="$2"; shift 2 ;;
--previous) PREVIOUS="$2"; shift 2 ;;
--notes) NOTES_FILE="$2"; shift 2 ;;
--key) KEY="$2"; shift 2 ;;
--out) OUT="$2"; shift 2 ;;
--force) FORCE=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--yes) YES=1; shift ;;
--rsync-target) RSYNC_TARGET="$2"; shift 2 ;;
-h|--help) sed -n '1,15p' "$0"; exit 0 ;;
*) die "unknown arg: $1" ;;
esac
done
[[ -n "$SOURCE" ]] || die "--source is required"
[[ -n "$VERSION" ]] || die "--version is required"
[[ -d "$SOURCE" ]] || die "source dir does not exist: $SOURCE"
[[ -f "$SOURCE/Metin2.exe" ]] || die "source does not look like a client dir (missing Metin2.exe): $SOURCE"
[[ -f "$KEY" ]] || die "signing key not found: $KEY"
KEY_MODE=$(stat -c '%a' "$KEY")
[[ "$KEY_MODE" == "600" ]] || die "signing key $KEY must be mode 600, got $KEY_MODE"
[[ -n "$NOTES_FILE" && ! -f "$NOTES_FILE" ]] && die "notes file not found: $NOTES_FILE"
: "${OUT:=/tmp/release-${VERSION}}"
SOURCE=$(cd "$SOURCE" && pwd)
OUT_ABS=$(mkdir -p "$OUT" && cd "$OUT" && pwd)
if [[ -n "$(ls -A "$OUT_ABS" 2>/dev/null)" && "$FORCE" -ne 1 ]]; then
die "output directory $OUT_ABS is non-empty (use --force to overwrite)"
fi
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
say "source: $SOURCE"
say "version: $VERSION"
say "out: $OUT_ABS"
say "key: $KEY"
# -------- [1/6] manifest --------
say "[1/6] building manifest"
mkdir -p "$OUT_ABS/manifests" "$OUT_ABS/files"
MANIFEST="$OUT_ABS/manifest.json"
mk_args=(--source "$SOURCE" --version "$VERSION" --out "$MANIFEST")
[[ -n "$PREVIOUS" ]] && mk_args+=(--previous "$PREVIOUS")
if [[ -n "$NOTES_FILE" ]]; then
notes_text=$(cat "$NOTES_FILE")
mk_args+=(--notes "$notes_text")
fi
python3 "$SCRIPT_DIR/make-manifest.py" "${mk_args[@]}"
# -------- [2/6] sign --------
say "[2/6] signing manifest"
python3 "$SCRIPT_DIR/sign-manifest.py" --manifest "$MANIFEST" --key "$KEY"
SIG="$MANIFEST.sig"
[[ -f "$SIG" ]] || die "signature not produced"
sig_len=$(stat -c '%s' "$SIG")
[[ "$sig_len" == "64" ]] || die "signature is $sig_len bytes, expected 64"
# -------- [3/6] archive historical manifest --------
say "[3/6] archiving historical manifest -> manifests/${VERSION}.json"
cp -f "$MANIFEST" "$OUT_ABS/manifests/${VERSION}.json"
cp -f "$SIG" "$OUT_ABS/manifests/${VERSION}.json.sig"
# -------- [4/6] blob tree --------
say "[4/6] building content-addressed blob tree"
# Extract (path, sha256) pairs for launcher + every file entry.
mapfile -t PAIRS < <(jq -r '
([.launcher] + .files)
| .[]
| "\(.sha256)\t\(.path)"
' "$MANIFEST")
total_entries=${#PAIRS[@]}
unique_count=0
dedup_count=0
bytes_written=0
declare -A SEEN
for pair in "${PAIRS[@]}"; do
hash="${pair%%$'\t'*}"
rel="${pair#*$'\t'}"
src="$SOURCE/$rel"
[[ -f "$src" ]] || die "file in manifest missing from source: $rel"
if [[ -n "${SEEN[$hash]:-}" ]]; then
dedup_count=$((dedup_count + 1))
continue
fi
SEEN[$hash]=1
unique_count=$((unique_count + 1))
prefix="${hash:0:2}"
dst_dir="$OUT_ABS/files/$prefix"
dst="$dst_dir/$hash"
mkdir -p "$dst_dir"
if [[ -f "$dst" ]]; then
continue
fi
# Try hardlink first, fall back to copy across filesystems.
if ! cp -l "$src" "$dst" 2>/dev/null; then
cp "$src" "$dst"
fi
sz=$(stat -c '%s' "$dst")
bytes_written=$((bytes_written + sz))
done
say " entries: $total_entries unique blobs: $unique_count deduped: $dedup_count"
say " bytes written: $bytes_written"
# -------- [5/6] layout summary --------
say "[5/6] final layout:"
(cd "$OUT_ABS" && find . -maxdepth 2 -mindepth 1 -printf ' %p\n' | sort | head -40)
# -------- [6/6] rsync --------
if [[ "$DRY_RUN" -eq 1 ]]; then
say "[6/6] --dry-run set, skipping rsync. target would be: $RSYNC_TARGET"
exit 0
fi
say "[6/6] rsync target: $RSYNC_TARGET"
if [[ "$YES" -ne 1 ]]; then
read -r -p "continue? [y/N] " ans
[[ "$ans" == "y" || "$ans" == "Y" ]] || die "aborted by user"
fi
# Stage 1: everything except manifest.json(.sig) — blobs and historical archive.
rsync -av --delay-updates --checksum --omit-dir-times --no-perms \
--exclude 'manifest.json' --exclude 'manifest.json.sig' \
"$OUT_ABS"/ "$RSYNC_TARGET"
# Stage 2: manifest + signature, so the new release becomes visible last.
rsync -av --checksum --omit-dir-times --no-perms \
"$MANIFEST" "$SIG" "$RSYNC_TARGET"
say "done."

102
scripts/sign-manifest.py Executable file
View File

@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Sign a manifest.json with the launcher's Ed25519 private key.
Produces a detached signature at the same path with a ``.sig`` suffix. The
signature is over the **literal bytes** of the manifest file — do not
re-serialize the JSON before signing, or the launcher will refuse the result.
Usage:
sign-manifest.py --manifest /path/to/manifest.json \\
--key ~/.config/metin/launcher-signing-key \\
[--out manifest.json.sig]
The private key file is 32 raw bytes (no PEM header, no encryption). Create it
with:
python3 -c 'from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey; from cryptography.hazmat.primitives import serialization; p=Ed25519PrivateKey.generate(); open("key","wb").write(p.private_bytes(serialization.Encoding.Raw,serialization.PrivateFormat.Raw,serialization.NoEncryption())); print(p.public_key().public_bytes(serialization.Encoding.Raw,serialization.PublicFormat.Raw).hex())'
Keep the file ``chmod 600`` on the release machine. Never commit it to any repo
and never store it in CI secrets unless the release flow is moved off the CI
runner entirely.
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
try:
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives import serialization
except ImportError:
print("error: this script requires the 'cryptography' package "
"(pip install cryptography, or dnf install python3-cryptography)",
file=sys.stderr)
raise SystemExit(1)
def load_private_key(key_path: Path) -> Ed25519PrivateKey:
raw = key_path.read_bytes()
if len(raw) != 32:
raise SystemExit(
f"error: private key at {key_path} is {len(raw)} bytes, expected 32 "
f"(a raw Ed25519 seed, not a PEM file)"
)
return Ed25519PrivateKey.from_private_bytes(raw)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--manifest", required=True, type=Path,
help="Path to manifest.json to sign")
parser.add_argument("--key", required=True, type=Path,
help="Path to the raw 32-byte Ed25519 private key")
parser.add_argument("--out", type=Path,
help="Signature output path (default: <manifest>.sig)")
args = parser.parse_args()
manifest_path: Path = args.manifest
if not manifest_path.is_file():
print(f"error: manifest not found: {manifest_path}", file=sys.stderr)
return 1
key_path: Path = args.key
if not key_path.is_file():
print(f"error: private key not found: {key_path}", file=sys.stderr)
return 1
key_mode = key_path.stat().st_mode & 0o777
if key_mode & 0o077:
print(
f"warning: private key {key_path} is readable by group or world "
f"(mode {oct(key_mode)}). chmod 600 recommended.",
file=sys.stderr,
)
private_key = load_private_key(key_path)
manifest_bytes = manifest_path.read_bytes()
signature = private_key.sign(manifest_bytes)
assert len(signature) == 64, "Ed25519 signatures are always 64 bytes"
out_path: Path = args.out or manifest_path.with_suffix(manifest_path.suffix + ".sig")
out_path.write_bytes(signature)
os.chmod(out_path, 0o644)
public_hex = private_key.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
).hex()
print(
f"signed {manifest_path} -> {out_path} "
f"(64 bytes, verify with public key {public_hex})",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())